Compare commits

...

19 Commits

Author SHA1 Message Date
Jason Rasmussen
e39de5e8cf chore: remove unused file 2026-03-23 12:18:15 -04:00
Yaros
0e93aa74cf fix(mobile): add keys to people list (#27112)
mobile(fix): add keys to people list
2026-03-23 10:50:56 -05:00
Yaros
e95ad9d2eb fix(mobile): option padding on search dropdowns (#27154)
* fix(mobile): option padding on search dropdowns

* chore: prevent height fill up screen and block the bottom menu entry

---------

Co-authored-by: Alex <alex.tran1502@gmail.com>
2026-03-23 15:03:07 +00:00
Nicolas-micuda-becker
b98a227bbd fix(web): update upload summary when removing items (#27035) (#27139) 2026-03-23 10:02:09 -05:00
Michel Heusschen
2dd785e3e2 fix(web): restore duplicate viewer arrow key navigation (#27176) 2026-03-23 10:01:15 -05:00
Daniel Dietzler
7e754125cd fix: download original stale cache when edited (#27195) 2026-03-23 10:00:32 -05:00
Yaros
e2eb03d3a4 fix(mobile): star rating always defaults to 0 (#27157) 2026-03-23 09:56:27 -05:00
Yaros
bf065a834f fix(mobile): no results before applying filter (#27155) 2026-03-23 09:41:13 -05:00
Daniel Dietzler
db79173b5b chore: vite 8 (#26913) 2026-03-23 15:39:46 +01:00
Yaros
33666ccd21 fix(mobile): view similar photos from search (#27149)
* fix(mobile): view similar photos from search

* clean up

---------

Co-authored-by: Alex Tran <alex.tran1502@gmail.com>
2026-03-23 09:36:42 -05:00
bo0tzz
be93b9040c feat: consolidate auto-close workflows (#27172) 2026-03-23 11:22:44 +01:00
Luis Nachtigall
00dae6ac38 fix(mobile): cronet image cache clearing on android (#27054) 2026-03-20 18:28:24 -04:00
Daniel Dietzler
5a8fd40dc5 fix: svelte reactivity issues (#27109) 2026-03-20 15:56:16 -04:00
Jason Rasmussen
813d684aaa fix: shared link add to album (#27063) 2026-03-20 13:14:07 -05:00
Luis Nachtigall
644f705be1 fix(mobile): correct maximumSizeBytes setter to use maximumSizeBytes property (#27098)
fix: correct maximumSizeBytes setter to use maximumSizeBytes property
2026-03-20 12:22:59 -04:00
Thomas
f3e4bcc733 fix(server): queue version check job when config changed (#27094)
Nothing happens when the version checks are enabled, which means the
server may return very stale versions until the next job runs.

Refs: #26935
2026-03-20 10:07:40 -05:00
Michel Heusschen
9a0c17fdb8 fix(web): preserve album scroll when adding to other albums (#27078) 2026-03-20 09:42:19 -05:00
Michel Heusschen
b7c4497dfd fix(web): allow showing combobox items outside modals (#27075)
fix(web): allow showing combobox items outside of modals
2026-03-20 09:41:34 -05:00
Yaros
9c227aeaf5 fix(mobile): simplified chinese not available (#27066) 2026-03-20 00:27:50 -05:00
38 changed files with 1209 additions and 577 deletions

143
.github/workflows/auto-close.yml vendored Normal file
View File

@@ -0,0 +1,143 @@
name: Auto-close PRs
on:
pull_request_target: # zizmor: ignore[dangerous-triggers]
types: [opened, edited, labeled]
permissions: {}
jobs:
parse_template:
runs-on: ubuntu-latest
if: ${{ github.event.action != 'labeled' && github.event.pull_request.head.repo.fork == true }}
permissions:
contents: read
outputs:
uses_template: ${{ steps.check.outputs.uses_template }}
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
sparse-checkout: .github/pull_request_template.md
sparse-checkout-cone-mode: false
persist-credentials: false
- name: Check required sections
id: check
env:
BODY: ${{ github.event.pull_request.body }}
run: |
OK=true
while IFS= read -r header; do
printf '%s\n' "$BODY" | grep -qF "$header" || OK=false
done < <(sed '/<!--/,/-->/d' .github/pull_request_template.md | grep "^## ")
echo "uses_template=$OK" >> "$GITHUB_OUTPUT"
close_template:
runs-on: ubuntu-latest
needs: parse_template
if: ${{ needs.parse_template.outputs.uses_template == 'false' && github.event.pull_request.state != 'closed' }}
permissions:
pull-requests: write
steps:
- name: Comment and close
env:
GH_TOKEN: ${{ github.token }}
NODE_ID: ${{ github.event.pull_request.node_id }}
run: |
gh api graphql \
-f prId="$NODE_ID" \
-f body="This PR has been automatically closed as the description doesn't follow our template. After you edit it to match the template, the PR will automatically be reopened." \
-f query='
mutation CommentAndClosePR($prId: ID!, $body: String!) {
addComment(input: {
subjectId: $prId,
body: $body
}) {
__typename
}
closePullRequest(input: {
pullRequestId: $prId
}) {
__typename
}
}'
- name: Add label
env:
GH_TOKEN: ${{ github.token }}
PR_NUMBER: ${{ github.event.pull_request.number }}
run: gh pr edit "$PR_NUMBER" --add-label "auto-closed:template"
close_llm:
runs-on: ubuntu-latest
if: ${{ github.event.action == 'labeled' && github.event.label.name == 'auto-closed:llm' }}
permissions:
pull-requests: write
steps:
- name: Comment and close
env:
GH_TOKEN: ${{ github.token }}
NODE_ID: ${{ github.event.pull_request.node_id }}
run: |
gh api graphql \
-f prId="$NODE_ID" \
-f body="Thank you for your interest in contributing to Immich! Unfortunately this PR looks like it was generated using an LLM. As noted in our [CONTRIBUTING.md](https://github.com/immich-app/immich/blob/main/CONTRIBUTING.md#use-of-generative-ai), we request that you don't use LLMs to generate PRs as those are not a good use of maintainer time." \
-f query='
mutation CommentAndClosePR($prId: ID!, $body: String!) {
addComment(input: {
subjectId: $prId,
body: $body
}) {
__typename
}
closePullRequest(input: {
pullRequestId: $prId
}) {
__typename
}
}'
reopen:
runs-on: ubuntu-latest
needs: parse_template
if: >-
${{
needs.parse_template.outputs.uses_template == 'true'
&& github.event.pull_request.state == 'closed'
&& contains(github.event.pull_request.labels.*.name, 'auto-closed:template')
}}
permissions:
pull-requests: write
steps:
- name: Remove template label
env:
GH_TOKEN: ${{ github.token }}
PR_NUMBER: ${{ github.event.pull_request.number }}
run: gh pr edit "$PR_NUMBER" --remove-label "auto-closed:template" || true
- name: Check for remaining auto-closed labels
id: check_labels
env:
GH_TOKEN: ${{ github.token }}
PR_NUMBER: ${{ github.event.pull_request.number }}
run: |
REMAINING=$(gh pr view "$PR_NUMBER" --json labels \
--jq '[.labels[].name | select(startswith("auto-closed:"))] | length')
echo "remaining=$REMAINING" >> "$GITHUB_OUTPUT"
- name: Reopen PR
if: ${{ steps.check_labels.outputs.remaining == '0' }}
env:
GH_TOKEN: ${{ github.token }}
NODE_ID: ${{ github.event.pull_request.node_id }}
run: |
gh api graphql \
-f prId="$NODE_ID" \
-f query='
mutation ReopenPR($prId: ID!) {
reopenPullRequest(input: {
pullRequestId: $prId
}) {
__typename
}
}'

View File

@@ -1,97 +0,0 @@
name: Check PR Template
on:
pull_request_target: # zizmor: ignore[dangerous-triggers]
types: [opened, edited]
permissions: {}
env:
LABEL_ID: 'LA_kwDOGyI-8M8AAAACcAeOfg' # auto-closed:template
jobs:
parse:
runs-on: ubuntu-latest
if: ${{ github.event.pull_request.head.repo.fork == true }}
permissions:
contents: read
outputs:
uses_template: ${{ steps.check.outputs.uses_template }}
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
sparse-checkout: .github/pull_request_template.md
sparse-checkout-cone-mode: false
persist-credentials: false
- name: Check required sections
id: check
env:
BODY: ${{ github.event.pull_request.body }}
run: |
OK=true
while IFS= read -r header; do
printf '%s\n' "$BODY" | grep -qF "$header" || OK=false
done < <(sed '/<!--/,/-->/d' .github/pull_request_template.md | grep "^## ")
echo "uses_template=$OK" >> "$GITHUB_OUTPUT"
act:
runs-on: ubuntu-latest
needs: parse
permissions:
pull-requests: write
steps:
- name: Close PR
if: ${{ needs.parse.outputs.uses_template == 'false' && github.event.pull_request.state != 'closed' }}
env:
GH_TOKEN: ${{ github.token }}
NODE_ID: ${{ github.event.pull_request.node_id }}
run: |
gh api graphql \
-f prId="$NODE_ID" \
-f labelId="$LABEL_ID" \
-f body="This PR has been automatically closed as the description doesn't follow our template. After you edit it to match the template, the PR will automatically be reopened." \
-f query='
mutation CommentAndClosePR($prId: ID!, $body: String!, $labelId: ID!) {
addComment(input: {
subjectId: $prId,
body: $body
}) {
__typename
}
closePullRequest(input: {
pullRequestId: $prId
}) {
__typename
}
addLabelsToLabelable(input: {
labelableId: $prId,
labelIds: [$labelId]
}) {
__typename
}
}'
- name: Reopen PR (sections now present, PR was auto-closed)
if: ${{ needs.parse.outputs.uses_template == 'true' && github.event.pull_request.state == 'closed' && contains(github.event.pull_request.labels.*.node_id, env.LABEL_ID) }}
env:
GH_TOKEN: ${{ github.token }}
NODE_ID: ${{ github.event.pull_request.node_id }}
run: |
gh api graphql \
-f prId="$NODE_ID" \
-f labelId="$LABEL_ID" \
-f query='
mutation ReopenPR($prId: ID!, $labelId: ID!) {
reopenPullRequest(input: {
pullRequestId: $prId
}) {
__typename
}
removeLabelsFromLabelable(input: {
labelableId: $prId,
labelIds: [$labelId]
}) {
__typename
}
}'

View File

@@ -1,38 +0,0 @@
name: Close LLM-generated PRs
on:
pull_request_target:
types: [labeled]
permissions: {}
jobs:
comment_and_close:
runs-on: ubuntu-latest
if: ${{ github.event.label.name == 'llm-generated' }}
permissions:
pull-requests: write
steps:
- name: Comment and close
env:
GH_TOKEN: ${{ github.token }}
NODE_ID: ${{ github.event.pull_request.node_id }}
run: |
gh api graphql \
-f prId="$NODE_ID" \
-f body="Thank you for your interest in contributing to Immich! Unfortunately this PR looks like it was generated using an LLM. As noted in our [CONTRIBUTING.md](https://github.com/immich-app/immich/blob/main/CONTRIBUTING.md#use-of-generative-ai), we request that you don't use LLMs to generate PRs as those are not a good use of maintainer time." \
-f query='
mutation CommentAndClosePR($prId: ID!, $body: String!) {
addComment(input: {
subjectId: $prId,
body: $body
}) {
__typename
}
closePullRequest(input: {
pullRequestId: $prId
}) {
__typename
}
}'

View File

@@ -35,8 +35,7 @@
"prettier-plugin-organize-imports": "^4.0.0",
"typescript": "^5.3.3",
"typescript-eslint": "^8.28.0",
"vite": "^7.0.0",
"vite-tsconfig-paths": "^6.0.0",
"vite": "^8.0.0",
"vitest": "^4.0.0",
"vitest-fetch-mock": "^0.4.0",
"yaml": "^2.3.1"

View File

@@ -1,10 +1,12 @@
import { defineConfig, UserConfig } from 'vite';
import tsconfigPaths from 'vite-tsconfig-paths';
export default defineConfig({
resolve: { alias: { src: '/src' } },
resolve: {
alias: { src: '/src' },
tsconfigPaths: true,
},
build: {
rollupOptions: {
rolldownOptions: {
input: 'src/index.ts',
output: {
dir: 'dist',
@@ -16,7 +18,6 @@ export default defineConfig({
// bundle everything except for Node built-ins
noExternal: /^(?!node:).*$/,
},
plugins: [tsconfigPaths()],
test: {
name: 'cli:unit',
globals: true,

View File

@@ -0,0 +1,51 @@
import { AssetMediaResponseDto, LoginResponseDto, updateAssets } from '@immich/sdk';
import { expect, test } from '@playwright/test';
import crypto from 'node:crypto';
import { asBearerAuth, utils } from 'src/utils';
test.describe('Duplicates Utility', () => {
let admin: LoginResponseDto;
let firstAsset: AssetMediaResponseDto;
let secondAsset: AssetMediaResponseDto;
test.beforeAll(async () => {
utils.initSdk();
await utils.resetDatabase();
admin = await utils.adminSetup();
});
test.beforeEach(async ({ context }) => {
[firstAsset, secondAsset] = await Promise.all([
utils.createAsset(admin.accessToken, { deviceAssetId: 'duplicate-a' }),
utils.createAsset(admin.accessToken, { deviceAssetId: 'duplicate-b' }),
]);
await updateAssets(
{
assetBulkUpdateDto: {
ids: [firstAsset.id, secondAsset.id],
duplicateId: crypto.randomUUID(),
},
},
{ headers: asBearerAuth(admin.accessToken) },
);
await utils.setAuthCookies(context, admin.accessToken);
});
test('navigates with arrow keys between duplicate preview assets', async ({ page }) => {
await page.goto('/utilities/duplicates');
await page.getByRole('button', { name: 'View' }).first().click();
await page.waitForSelector('#immich-asset-viewer');
const getViewedAssetId = () => new URL(page.url()).pathname.split('/').at(-1) ?? '';
const initialAssetId = getViewedAssetId();
expect([firstAsset.id, secondAsset.id]).toContain(initialAssetId);
await page.keyboard.press('ArrowRight');
await expect.poll(getViewedAssetId).not.toBe(initialAssetId);
await page.keyboard.press('ArrowLeft');
await expect.poll(getViewedAssetId).toBe(initialAssetId);
});
});

View File

@@ -23,10 +23,18 @@ import okhttp3.HttpUrl
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
import okhttp3.OkHttpClient
import org.chromium.net.CronetEngine
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import java.io.ByteArrayInputStream
import java.io.File
import java.io.IOException
import java.nio.file.FileVisitResult
import java.nio.file.Files
import java.nio.file.Path
import java.nio.file.SimpleFileVisitor
import java.nio.file.attribute.BasicFileAttributes
import java.net.Authenticator
import java.net.CookieHandler
import java.net.PasswordAuthentication
@@ -277,10 +285,13 @@ object HttpClientManager {
return result
}
fun rebuildCronetEngine(): CronetEngine {
val old = cronetEngine!!
cronetEngine = buildCronetEngine()
return old
suspend fun rebuildCronetEngine(): Result<Long> {
return runCatching {
cronetEngine?.shutdown()
val deletionResult = deleteFolderAndGetSize(cronetStoragePath.toPath())
cronetEngine = buildCronetEngine()
deletionResult
}
}
val cronetStoragePath: File get() = cronetStorageDir
@@ -301,7 +312,7 @@ object HttpClientManager {
}
}
private fun buildCronetEngine(): CronetEngine {
fun buildCronetEngine(): CronetEngine {
return CronetEngine.Builder(appContext)
.enableHttp2(true)
.enableQuic(true)
@@ -312,6 +323,27 @@ object HttpClientManager {
.build()
}
private suspend fun deleteFolderAndGetSize(root: Path): Long = withContext(Dispatchers.IO) {
var totalSize = 0L
Files.walkFileTree(root, object : SimpleFileVisitor<Path>() {
override fun visitFile(file: Path, attrs: BasicFileAttributes): FileVisitResult {
totalSize += attrs.size()
Files.delete(file)
return FileVisitResult.CONTINUE
}
override fun postVisitDirectory(dir: Path, exc: IOException?): FileVisitResult {
if (dir != root) {
Files.delete(dir)
}
return FileVisitResult.CONTINUE
}
})
totalSize
}
private fun build(cacheDir: File): OkHttpClient {
val connectionPool = ConnectionPool(
maxIdleConnections = KEEP_ALIVE_CONNECTIONS,

View File

@@ -21,11 +21,6 @@ import java.io.EOFException
import java.io.File
import java.io.IOException
import java.nio.ByteBuffer
import java.nio.file.FileVisitResult
import java.nio.file.Files
import java.nio.file.Path
import java.nio.file.SimpleFileVisitor
import java.nio.file.attribute.BasicFileAttributes
import java.util.concurrent.ConcurrentHashMap
private class RemoteRequest(val cancellationSignal: CancellationSignal)
@@ -205,18 +200,15 @@ private class CronetImageFetcher : ImageFetcher {
private fun onDrained() {
val onCacheCleared = synchronized(stateLock) {
val onCacheCleared = onCacheCleared
val onCacheCleared = this.onCacheCleared
this.onCacheCleared = null
onCacheCleared
}
if (onCacheCleared != null) {
val oldEngine = HttpClientManager.rebuildCronetEngine()
oldEngine.shutdown()
CoroutineScope(Dispatchers.IO).launch {
val result = runCatching { deleteFolderAndGetSize(HttpClientManager.cronetStoragePath.toPath()) }
synchronized(stateLock) { draining = false }
onCacheCleared(result)
}
} ?: return
CoroutineScope(Dispatchers.IO).launch {
val result = HttpClientManager.rebuildCronetEngine()
synchronized(stateLock) { draining = false }
onCacheCleared(result)
}
}
@@ -306,26 +298,6 @@ private class CronetImageFetcher : ImageFetcher {
}
}
suspend fun deleteFolderAndGetSize(root: Path): Long = withContext(Dispatchers.IO) {
var totalSize = 0L
Files.walkFileTree(root, object : SimpleFileVisitor<Path>() {
override fun visitFile(file: Path, attrs: BasicFileAttributes): FileVisitResult {
totalSize += attrs.size()
Files.delete(file)
return FileVisitResult.CONTINUE
}
override fun postVisitDirectory(dir: Path, exc: IOException?): FileVisitResult {
if (dir != root) {
Files.delete(dir)
}
return FileVisitResult.CONTINUE
}
})
totalSize
}
}
private class OkHttpImageFetcher private constructor(

View File

@@ -7,7 +7,7 @@ const Map<String, Locale> locales = {
'Arabic (ar)': Locale('ar'),
'Bulgarian (bg)': Locale('bg'),
'Catalan (ca)': Locale('ca'),
'Chinese Simplified (zh_CN)': Locale.fromSubtags(languageCode: 'zh', scriptCode: 'SIMPLIFIED'),
'Chinese Simplified (zh_CN)': Locale.fromSubtags(languageCode: 'zh', scriptCode: 'Hans'),
'Chinese Traditional (zh_TW)': Locale.fromSubtags(languageCode: 'zh', scriptCode: 'Hant'),
'Croatian (hr)': Locale('hr'),
'Czech (cs)': Locale('cs'),

View File

@@ -79,6 +79,7 @@ class _DriftPeopleCollectionPageState extends ConsumerState<DriftPeopleCollectio
final person = people[index];
return Column(
key: ValueKey(person.id),
children: [
GestureDetector(
onTap: () {
@@ -88,6 +89,7 @@ class _DriftPeopleCollectionPageState extends ConsumerState<DriftPeopleCollectio
shape: const CircleBorder(side: BorderSide.none),
elevation: 3,
child: CircleAvatar(
key: ValueKey('avatar-${person.id}'),
maxRadius: isTablet ? 100 / 2 : 96 / 2,
backgroundImage: RemoteImageProvider(url: getFaceThumbnailUrl(person.id)),
),

View File

@@ -69,6 +69,7 @@ class DriftSearchPage extends HookConsumerWidget {
);
final previousFilter = useState<SearchFilter?>(null);
final hasRequestedSearch = useState<bool>(false);
final dateInputFilter = useState<DateFilterInputModel?>(null);
final peopleCurrentFilterWidget = useState<Widget?>(null);
@@ -91,9 +92,11 @@ class DriftSearchPage extends HookConsumerWidget {
if (filter.isEmpty) {
previousFilter.value = null;
hasRequestedSearch.value = false;
return;
}
hasRequestedSearch.value = true;
unawaited(ref.read(paginatedSearchProvider.notifier).search(filter));
previousFilter.value = filter;
}
@@ -107,6 +110,8 @@ class DriftSearchPage extends HookConsumerWidget {
searchPreFilter() {
if (preFilter != null) {
Future.delayed(Duration.zero, () {
filter.value = preFilter;
textSearchController.clear();
searchFilter(preFilter);
if (preFilter.location.city != null) {
@@ -719,7 +724,7 @@ class DriftSearchPage extends HookConsumerWidget {
),
),
),
if (filter.value.isEmpty)
if (!hasRequestedSearch.value)
const _SearchSuggestions()
else
_SearchResultGrid(onScrollEnd: loadMoreSearchResults),

View File

@@ -24,20 +24,22 @@ class SimilarPhotosActionButton extends ConsumerWidget {
}
ref.invalidate(assetViewerProvider);
ref
.read(searchPreFilterProvider.notifier)
.setFilter(
SearchFilter(
assetId: assetId,
people: {},
location: SearchLocationFilter(),
camera: SearchCameraFilter(),
date: SearchDateFilter(),
display: SearchDisplayFilters(isNotInAlbum: false, isArchive: false, isFavorite: false),
rating: SearchRatingFilter(),
mediaType: AssetType.image,
),
);
ref.invalidate(paginatedSearchProvider);
ref.read(searchPreFilterProvider.notifier)
..clear()
..setFilter(
SearchFilter(
assetId: assetId,
people: {},
location: SearchLocationFilter(),
camera: SearchCameraFilter(),
date: SearchDateFilter(),
display: SearchDisplayFilters(isNotInAlbum: false, isArchive: false, isFavorite: false),
rating: SearchRatingFilter(),
mediaType: AssetType.image,
),
);
unawaited(context.navigateTo(const DriftSearchRoute()));
}

View File

@@ -39,6 +39,16 @@ class _RatingBarState extends State<RatingBar> {
_currentRating = widget.initialRating;
}
@override
void didUpdateWidget(covariant RatingBar oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.initialRating != widget.initialRating && _currentRating != widget.initialRating) {
setState(() {
_currentRating = widget.initialRating;
});
}
}
void _updateRating(Offset localPosition, bool isRTL, {bool isTap = false}) {
final totalWidth = widget.itemCount * widget.itemSize + (widget.itemCount - 1) * widget.starPadding;
double dx = localPosition.dx;

View File

@@ -20,7 +20,7 @@ final class CustomImageCache implements ImageCache {
set maximumSize(int value) => _small.maximumSize = value;
@override
set maximumSizeBytes(int value) => _small.maximumSize = value;
set maximumSizeBytes(int value) => _small.maximumSizeBytes = value;
@override
void clear() {

View File

@@ -16,9 +16,15 @@ class SearchDropdown<T> extends StatelessWidget {
final Widget? label;
final Widget? leadingIcon;
static const WidgetStatePropertyAll<EdgeInsetsGeometry> _optionPadding = WidgetStatePropertyAll<EdgeInsetsGeometry>(
EdgeInsetsDirectional.fromSTEB(16, 0, 16, 0),
);
@override
Widget build(BuildContext context) {
final menuStyle = const MenuStyle(
final mediaQuery = MediaQuery.of(context);
final maxMenuHeight = mediaQuery.size.height * 0.5 - mediaQuery.viewPadding.bottom;
const menuStyle = MenuStyle(
shape: WidgetStatePropertyAll<OutlinedBorder>(
RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(15))),
),
@@ -26,11 +32,26 @@ class SearchDropdown<T> extends StatelessWidget {
return LayoutBuilder(
builder: (context, constraints) {
final styledEntries = dropdownMenuEntries
.map(
(entry) => DropdownMenuEntry<T>(
value: entry.value,
label: entry.label,
labelWidget: entry.labelWidget,
enabled: entry.enabled,
leadingIcon: entry.leadingIcon,
trailingIcon: entry.trailingIcon,
style: (entry.style ?? const ButtonStyle()).copyWith(padding: _optionPadding),
),
)
.toList(growable: false);
return DropdownMenu(
controller: controller,
leadingIcon: leadingIcon,
width: constraints.maxWidth,
dropdownMenuEntries: dropdownMenuEntries,
menuHeight: maxMenuHeight,
dropdownMenuEntries: styledEntries,
label: label,
menuStyle: menuStyle,
trailingIcon: const Icon(Icons.arrow_drop_down_rounded),

894
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -24,7 +24,7 @@
"typeorm": "typeorm",
"migrations:debug": "sql-tools -u ${DB_URL:-postgres://postgres:postgres@localhost:5432/immich} migrations generate --debug",
"migrations:generate": "sql-tools -u ${DB_URL:-postgres://postgres:postgres@localhost:5432/immich} migrations generate",
"migrations:create": "sql-tools -u ${DB_URL:-postgres://postgres:postgres@localhost:5432/immich} migrations generate",
"migrations:create": "sql-tools -u ${DB_URL:-postgres://postgres:postgres@localhost:5432/immich} migrations create",
"migrations:run": "sql-tools -u ${DB_URL:-postgres://postgres:postgres@localhost:5432/immich} migrations run",
"migrations:revert": "sql-tools -u ${DB_URL:-postgres://postgres:postgres@localhost:5432/immich} migrations revert",
"schema:drop": "sql-tools -u ${DB_URL:-postgres://postgres:postgres@localhost:5432/immich} query 'DROP schema public cascade; CREATE schema public;'",

View File

@@ -169,6 +169,7 @@ export type AuthSharedLink = {
id: string;
expiresAt: Date | null;
userId: string;
albumId: string | null;
showExif: boolean;
allowUpload: boolean;
allowDownload: boolean;
@@ -357,15 +358,6 @@ export const columns = {
authUser: ['user.id', 'user.name', 'user.email', 'user.isAdmin', 'user.quotaUsageInBytes', 'user.quotaSizeInBytes'],
authApiKey: ['api_key.id', 'api_key.permissions'],
authSession: ['session.id', 'session.updatedAt', 'session.pinExpiresAt', 'session.appVersion'],
authSharedLink: [
'shared_link.id',
'shared_link.userId',
'shared_link.expiresAt',
'shared_link.showExif',
'shared_link.allowUpload',
'shared_link.allowDownload',
'shared_link.password',
],
user: userColumns,
userWithPrefix: userWithPrefixColumns,
userAdmin: [

View File

@@ -173,6 +173,7 @@ order by
select
"shared_link"."id",
"shared_link"."userId",
"shared_link"."albumId",
"shared_link"."expiresAt",
"shared_link"."showExif",
"shared_link"."allowUpload",
@@ -211,6 +212,7 @@ where
select
"shared_link"."id",
"shared_link"."userId",
"shared_link"."albumId",
"shared_link"."expiresAt",
"shared_link"."showExif",
"shared_link"."allowUpload",

View File

@@ -330,6 +330,7 @@ export class AlbumRepository {
await db
.insertInto('album_asset')
.values(assetIds.map((assetId) => ({ albumId, assetId })))
.onConflict((oc) => oc.doNothing())
.execute();
}

View File

@@ -202,7 +202,14 @@ export class SharedLinkRepository {
.leftJoin('album', 'album.id', 'shared_link.albumId')
.where('album.deletedAt', 'is', null)
.select((eb) => [
...columns.authSharedLink,
'shared_link.id',
'shared_link.userId',
'shared_link.albumId',
'shared_link.expiresAt',
'shared_link.showExif',
'shared_link.allowUpload',
'shared_link.allowDownload',
'shared_link.password',
jsonObjectFrom(
eb.selectFrom('user').select(columns.authUser).whereRef('user.id', '=', 'shared_link.userId'),
).as('user'),

View File

@@ -0,0 +1,13 @@
import { Kysely, sql } from 'kysely';
export async function up(db: Kysely<any>): Promise<void> {
await sql`
DELETE FROM "shared_link_asset"
USING "shared_link"
WHERE "shared_link_asset"."sharedLinkId" = "shared_link"."id" AND "shared_link"."type" = 'ALBUM';
`.execute(db);
}
export async function down(): Promise<void> {
// noop
}

View File

@@ -165,6 +165,12 @@ export class AlbumService extends BaseService {
}
async addAssets(auth: AuthDto, id: string, dto: BulkIdsDto): Promise<BulkIdResponseDto[]> {
if (auth.sharedLink) {
this.logger.deprecate(
'Assets uploaded to a shared link are automatically added and calling this endpoint is no longer necessary. It will be removed in the next major release.',
);
}
const album = await this.findOrFail(id, { withAssets: false });
await this.requireAccess({ auth, permission: Permission.AlbumAssetCreate, ids: [id] });
@@ -195,6 +201,12 @@ export class AlbumService extends BaseService {
}
async addAssetsToAlbums(auth: AuthDto, dto: AlbumsAddAssetsDto): Promise<AlbumsAddAssetsResponseDto> {
if (auth.sharedLink) {
this.logger.deprecate(
'Assets uploaded to a shared link are automatically added and calling this endpoint is no longer necessary. It will be removed in the next major release.',
);
}
const results: AlbumsAddAssetsResponseDto = {
success: false,
error: BulkIdErrorReason.DUPLICATE,

View File

@@ -2,7 +2,7 @@ import { BadRequestException, Injectable, InternalServerErrorException, NotFound
import { extname } from 'node:path';
import sanitize from 'sanitize-filename';
import { StorageCore } from 'src/cores/storage.core';
import { Asset } from 'src/database';
import { Asset, AuthSharedLink } from 'src/database';
import {
AssetBulkUploadCheckResponseDto,
AssetMediaResponseDto,
@@ -152,7 +152,7 @@ export class AssetMediaService extends BaseService {
const asset = await this.create(auth.user.id, dto, file, sidecarFile);
if (auth.sharedLink) {
await this.sharedLinkRepository.addAssets(auth.sharedLink.id, [asset.id]);
await this.addToSharedLink(auth.sharedLink, asset.id);
}
await this.userRepository.updateUsage(auth.user.id, file.size);
@@ -326,6 +326,12 @@ export class AssetMediaService extends BaseService {
};
}
private async addToSharedLink(sharedLink: AuthSharedLink, assetId: string) {
await (sharedLink.albumId
? this.albumRepository.addAssetIds(sharedLink.albumId, [assetId])
: this.sharedLinkRepository.addAssets(sharedLink.id, [assetId]));
}
private async handleUploadError(
error: any,
auth: AuthDto,
@@ -347,7 +353,7 @@ export class AssetMediaService extends BaseService {
}
if (auth.sharedLink) {
await this.sharedLinkRepository.addAssets(auth.sharedLink.id, [duplicateId]);
await this.addToSharedLink(auth.sharedLink, duplicateId);
}
return { status: AssetMediaStatus.DUPLICATE, id: duplicateId };

View File

@@ -1,5 +1,6 @@
import { DateTime } from 'luxon';
import { SemVer } from 'semver';
import { defaults } from 'src/config';
import { serverVersion } from 'src/constants';
import { ImmichEnvironment, JobName, JobStatus, SystemMetadataKey } from 'src/enum';
import { VersionService } from 'src/services/version.service';
@@ -130,6 +131,32 @@ describe(VersionService.name, () => {
});
});
describe('onConfigUpdate', () => {
it('should queue a version check job when newVersionCheck is enabled', async () => {
await sut.onConfigUpdate({
oldConfig: { ...defaults, newVersionCheck: { enabled: false } },
newConfig: { ...defaults, newVersionCheck: { enabled: true } },
});
expect(mocks.job.queue).toHaveBeenCalledWith({ name: JobName.VersionCheck, data: {} });
});
it('should not queue a version check job when newVersionCheck is disabled', async () => {
await sut.onConfigUpdate({
oldConfig: { ...defaults, newVersionCheck: { enabled: true } },
newConfig: { ...defaults, newVersionCheck: { enabled: false } },
});
expect(mocks.job.queue).not.toHaveBeenCalled();
});
it('should not queue a version check job when newVersionCheck was already enabled', async () => {
await sut.onConfigUpdate({
oldConfig: { ...defaults, newVersionCheck: { enabled: true } },
newConfig: { ...defaults, newVersionCheck: { enabled: true } },
});
expect(mocks.job.queue).not.toHaveBeenCalled();
});
});
describe('onWebsocketConnection', () => {
it('should send on_server_version client event', async () => {
await sut.onWebsocketConnection({ userId: '42' });

View File

@@ -55,6 +55,13 @@ export class VersionService extends BaseService {
return this.versionRepository.getAll();
}
@OnEvent({ name: 'ConfigUpdate' })
async onConfigUpdate({ oldConfig, newConfig }: ArgOf<'ConfigUpdate'>) {
if (!oldConfig.newVersionCheck.enabled && newConfig.newVersionCheck.enabled) {
await this.handleQueueVersionCheck();
}
}
async handleQueueVersionCheck() {
await this.jobRepository.queue({ name: JobName.VersionCheck, data: {} });
}

View File

@@ -48,6 +48,7 @@ export const authStub = {
showExif: true,
allowDownload: true,
allowUpload: true,
albumId: null,
expiresAt: null,
password: null,
userId: '42',

View File

@@ -220,9 +220,9 @@ export class MediumTestContext<S extends BaseService = BaseService> {
return { result };
}
async newAlbum(dto: Insertable<AlbumTable>) {
async newAlbum(dto: Insertable<AlbumTable>, assetIds?: string[]) {
const album = mediumFactory.albumInsert(dto);
const result = await this.get(AlbumRepository).create(album, [], []);
const result = await this.get(AlbumRepository).create(album, assetIds ?? [], []);
return { album, result };
}

View File

@@ -1,12 +1,15 @@
import { Kysely } from 'kysely';
import { randomBytes } from 'node:crypto';
import { AssetMediaStatus } from 'src/dtos/asset-media-response.dto';
import { AssetMediaSize } from 'src/dtos/asset-media.dto';
import { AssetFileType } from 'src/enum';
import { AssetFileType, SharedLinkType } from 'src/enum';
import { AccessRepository } from 'src/repositories/access.repository';
import { AlbumRepository } from 'src/repositories/album.repository';
import { AssetRepository } from 'src/repositories/asset.repository';
import { EventRepository } from 'src/repositories/event.repository';
import { JobRepository } from 'src/repositories/job.repository';
import { LoggingRepository } from 'src/repositories/logging.repository';
import { SharedLinkRepository } from 'src/repositories/shared-link.repository';
import { StorageRepository } from 'src/repositories/storage.repository';
import { UserRepository } from 'src/repositories/user.repository';
import { DB } from 'src/schema';
@@ -22,7 +25,7 @@ let defaultDatabase: Kysely<DB>;
const setup = (db?: Kysely<DB>) => {
return newMediumService(AssetMediaService, {
database: db || defaultDatabase,
real: [AccessRepository, AssetRepository, UserRepository],
real: [AccessRepository, AlbumRepository, AssetRepository, SharedLinkRepository, UserRepository],
mock: [EventRepository, LoggingRepository, JobRepository, StorageRepository],
});
};
@@ -44,7 +47,6 @@ describe(AssetService.name, () => {
const { asset } = await ctx.newAsset({ ownerId: user.id });
await ctx.newExif({ assetId: asset.id, fileSizeInByte: 12_345 });
const auth = factory.auth({ user: { id: user.id } });
const file = mediumFactory.uploadFile();
await expect(
sut.uploadAsset(
@@ -56,7 +58,7 @@ describe(AssetService.name, () => {
fileCreatedAt: new Date(),
assetData: Buffer.from('some data'),
},
file,
mediumFactory.uploadFile(),
),
).resolves.toEqual({
id: expect.any(String),
@@ -99,6 +101,168 @@ describe(AssetService.name, () => {
status: AssetMediaStatus.CREATED,
});
});
it('should add to a shared link', async () => {
const { sut, ctx } = setup();
const sharedLinkRepo = ctx.get(SharedLinkRepository);
ctx.getMock(StorageRepository).utimes.mockResolvedValue();
ctx.getMock(EventRepository).emit.mockResolvedValue();
ctx.getMock(JobRepository).queue.mockResolvedValue();
const { user } = await ctx.newUser();
const sharedLink = await sharedLinkRepo.create({
key: randomBytes(50),
type: SharedLinkType.Individual,
description: 'Shared link description',
userId: user.id,
allowDownload: true,
allowUpload: true,
});
const auth = factory.auth({ user: { id: user.id }, sharedLink });
const file = mediumFactory.uploadFile();
const uploadDto = {
deviceId: 'some-id',
deviceAssetId: 'some-id',
fileModifiedAt: new Date(),
fileCreatedAt: new Date(),
assetData: Buffer.from('some data'),
};
const response = await sut.uploadAsset(auth, uploadDto, file);
expect(response).toEqual({ id: expect.any(String), status: AssetMediaStatus.CREATED });
const update = await sharedLinkRepo.get(user.id, sharedLink.id);
const assets = update!.assets;
expect(assets).toHaveLength(1);
expect(assets[0]).toMatchObject({ id: response.id });
});
it('should handle adding a duplicate asset to a shared link', async () => {
const { sut, ctx } = setup();
ctx.getMock(StorageRepository).utimes.mockResolvedValue();
ctx.getMock(EventRepository).emit.mockResolvedValue();
ctx.getMock(JobRepository).queue.mockResolvedValue();
const sharedLinkRepo = ctx.get(SharedLinkRepository);
const { user } = await ctx.newUser();
const { asset } = await ctx.newAsset({ ownerId: user.id });
await ctx.newExif({ assetId: asset.id, fileSizeInByte: 12_345 });
const sharedLink = await sharedLinkRepo.create({
key: randomBytes(50),
type: SharedLinkType.Individual,
description: 'Shared link description',
userId: user.id,
allowDownload: true,
allowUpload: true,
assetIds: [asset.id],
});
const auth = factory.auth({ user: { id: user.id }, sharedLink });
const uploadDto = {
deviceId: 'some-id',
deviceAssetId: 'some-id',
fileModifiedAt: new Date(),
fileCreatedAt: new Date(),
assetData: Buffer.from('some data'),
};
const response = await sut.uploadAsset(auth, uploadDto, mediumFactory.uploadFile({ checksum: asset.checksum }));
expect(response).toEqual({ id: expect.any(String), status: AssetMediaStatus.DUPLICATE });
const update = await sharedLinkRepo.get(user.id, sharedLink.id);
const assets = update!.assets;
expect(assets).toHaveLength(1);
expect(assets[0]).toMatchObject({ id: response.id });
});
it('should add to an album shared link', async () => {
const { sut, ctx } = setup();
const sharedLinkRepo = ctx.get(SharedLinkRepository);
ctx.getMock(StorageRepository).utimes.mockResolvedValue();
ctx.getMock(EventRepository).emit.mockResolvedValue();
ctx.getMock(JobRepository).queue.mockResolvedValue();
const { user } = await ctx.newUser();
const { album } = await ctx.newAlbum({ ownerId: user.id });
const sharedLink = await sharedLinkRepo.create({
key: randomBytes(50),
type: SharedLinkType.Album,
albumId: album.id,
description: 'Shared link description',
userId: user.id,
allowDownload: true,
allowUpload: true,
});
const auth = factory.auth({ user: { id: user.id }, sharedLink });
const uploadDto = {
deviceId: 'some-id',
deviceAssetId: 'some-id',
fileModifiedAt: new Date(),
fileCreatedAt: new Date(),
assetData: Buffer.from('some data'),
};
const response = await sut.uploadAsset(auth, uploadDto, mediumFactory.uploadFile());
expect(response).toEqual({ id: expect.any(String), status: AssetMediaStatus.CREATED });
const result = await ctx.get(AlbumRepository).getAssetIds(album.id, [response.id]);
const assets = [...result];
expect(assets).toHaveLength(1);
expect(assets[0]).toEqual(response.id);
});
it('should handle adding a duplicate asset to an album shared link', async () => {
const { sut, ctx } = setup();
const sharedLinkRepo = ctx.get(SharedLinkRepository);
ctx.getMock(StorageRepository).utimes.mockResolvedValue();
ctx.getMock(EventRepository).emit.mockResolvedValue();
ctx.getMock(JobRepository).queue.mockResolvedValue();
const { user } = await ctx.newUser();
const { asset } = await ctx.newAsset({ ownerId: user.id });
const { album } = await ctx.newAlbum({ ownerId: user.id }, [asset.id]);
// await ctx.newExif({ assetId: asset.id, fileSizeInByte: 12_345 });
const sharedLink = await sharedLinkRepo.create({
key: randomBytes(50),
type: SharedLinkType.Album,
albumId: album.id,
description: 'Shared link description',
userId: user.id,
allowDownload: true,
allowUpload: true,
});
const auth = factory.auth({ user: { id: user.id }, sharedLink });
const uploadDto = {
deviceId: 'some-id',
deviceAssetId: 'some-id',
fileModifiedAt: new Date(),
fileCreatedAt: new Date(),
assetData: Buffer.from('some data'),
};
const response = await sut.uploadAsset(auth, uploadDto, mediumFactory.uploadFile({ checksum: asset.checksum }));
expect(response).toEqual({ id: expect.any(String), status: AssetMediaStatus.DUPLICATE });
const result = await ctx.get(AlbumRepository).getAssetIds(album.id, [response.id]);
const assets = [...result];
expect(assets).toHaveLength(1);
expect(assets[0]).toEqual(response.id);
});
});
describe('viewThumbnail', () => {

View File

@@ -63,12 +63,22 @@ const authSharedLinkFactory = (sharedLink: Partial<AuthSharedLink> = {}) => {
expiresAt = null,
userId = newUuid(),
showExif = true,
albumId = null,
allowUpload = false,
allowDownload = true,
password = null,
} = sharedLink;
return { id, expiresAt, userId, showExif, allowUpload, allowDownload, password };
return {
id,
albumId,
expiresAt,
userId,
showExif,
allowUpload,
allowDownload,
password,
};
};
const authApiKeyFactory = (apiKey: Partial<AuthApiKey> = {}) => ({

View File

@@ -72,10 +72,10 @@
"@koddsson/eslint-plugin-tscompat": "^0.2.0",
"@socket.io/component-emitter": "^3.1.0",
"@sveltejs/adapter-static": "^3.0.8",
"@sveltejs/enhanced-img": "^0.10.0",
"@sveltejs/enhanced-img": "^0.10.4",
"@sveltejs/kit": "^2.27.1",
"@sveltejs/vite-plugin-svelte": "6.2.4",
"@tailwindcss/vite": "^4.1.7",
"@sveltejs/vite-plugin-svelte": "7.0.0",
"@tailwindcss/vite": "^4.2.2",
"@testing-library/jest-dom": "^6.4.2",
"@testing-library/svelte": "^5.2.8",
"@testing-library/user-event": "^14.5.2",
@@ -100,13 +100,13 @@
"prettier-plugin-sort-json": "^4.1.1",
"prettier-plugin-svelte": "^3.3.3",
"rollup-plugin-visualizer": "^6.0.0",
"svelte": "5.53.7",
"svelte": "5.53.13",
"svelte-check": "^4.1.5",
"svelte-eslint-parser": "^1.3.3",
"tailwindcss": "^4.1.7",
"tailwindcss": "^4.2.2",
"typescript": "^5.8.3",
"typescript-eslint": "^8.45.0",
"vite": "^7.1.2",
"vite": "^8.0.0",
"vitest": "^4.0.0"
},
"volta": {

View File

@@ -212,12 +212,12 @@
bottom: `${rootHeight - top}px`,
left: `${left}px`,
width: `${boundary.width}px`,
maxHeight: maxHeight(top - dropdownOffset),
maxHeight: maxHeight(boundary.top - dropdownOffset),
};
}
const viewportHeight = visualViewport?.height || rootHeight;
const availableHeight = modalBounds ? rootHeight - bottom : viewportHeight - boundary.bottom;
const viewportHeight = visualViewport?.height || window.innerHeight;
const availableHeight = viewportHeight - boundary.bottom;
return {
top: `${bottom}px`,
left: `${left}px`,

View File

@@ -8,17 +8,16 @@ import SharedLinkCreateModal from '$lib/modals/SharedLinkCreateModal.svelte';
import { isFaceEditMode } from '$lib/stores/face-edit.svelte';
import { user as authUser, preferences } from '$lib/stores/user.store';
import type { AssetControlContext } from '$lib/types';
import { getSharedLink, sleep } from '$lib/utils';
import { getAssetMediaUrl, getSharedLink, sleep } from '$lib/utils';
import { downloadUrl } from '$lib/utils/asset-utils';
import { handleError } from '$lib/utils/handle-error';
import { getFormatter } from '$lib/utils/i18n';
import { asQueryString } from '$lib/utils/shared-links';
import {
AssetJobName,
AssetMediaSize,
AssetTypeEnum,
AssetVisibility,
getAssetInfo,
getBaseUrl,
runAssetJobs,
updateAsset,
type AssetJobsDto,
@@ -308,6 +307,7 @@ export const handleDownloadAsset = async (asset: AssetResponseDto, { edited }: {
{
filename: asset.originalFileName,
id: asset.id,
cacheKey: asset.thumbhash,
},
];
@@ -321,13 +321,12 @@ export const handleDownloadAsset = async (asset: AssetResponseDto, { edited }: {
assets.push({
filename: motionAsset.originalFileName,
id: asset.livePhotoVideoId,
cacheKey: motionAsset.thumbhash,
});
}
}
const queryParams = asQueryString(authManager.params);
for (const [i, { filename, id }] of assets.entries()) {
for (const [i, { filename, id, cacheKey }] of assets.entries()) {
if (i !== 0) {
// play nice with Safari
await sleep(500);
@@ -335,12 +334,7 @@ export const handleDownloadAsset = async (asset: AssetResponseDto, { edited }: {
try {
toastManager.primary($t('downloading_asset_filename', { values: { filename } }));
downloadUrl(
getBaseUrl() +
`/assets/${id}/original` +
(queryParams ? `?${queryParams}&edited=${edited}` : `?edited=${edited}`),
filename,
);
downloadUrl(getAssetMediaUrl({ id, size: AssetMediaSize.Original, edited, cacheKey }), filename);
} catch (error) {
handleError(error, $t('errors.error_downloading', { values: { filename } }));
}

View File

@@ -1,10 +0,0 @@
import { writable } from 'svelte/store';
function createAlbumAssetSelectionStore() {
const isAlbumAssetSelectionOpen = writable<boolean>(false);
return {
isAlbumAssetSelectionOpen,
};
}
export const albumAssetSelectionStore = createAlbumAssetSelectionStore();

View File

@@ -80,7 +80,34 @@ function createUploadStore() {
};
const removeItem = (id: string) => {
uploadAssets.update((uploadingAsset) => uploadingAsset.filter((a) => a.id != id));
uploadAssets.update((uploadingAsset) => {
const assetToRemove = uploadingAsset.find((a) => a.id === id);
if (assetToRemove) {
stats.update((stats) => {
switch (assetToRemove.state) {
case UploadState.DONE: {
stats.success--;
break;
}
case UploadState.DUPLICATED: {
stats.duplicates--;
break;
}
case UploadState.ERROR: {
stats.errors--;
break;
}
}
stats.total--;
return stats;
});
}
return uploadingAsset.filter((a) => a.id != id);
});
};
const dismissErrors = () =>

View File

@@ -216,7 +216,7 @@ async function fileUploader({
uploadAssetsStore.track('success');
}
if (albumId) {
if (albumId && !authManager.isSharedLink) {
uploadAssetsStore.updateItem(deviceAssetId, { message: $t('asset_adding_to_album') });
await addAssetsToAlbums([albumId], [responseData.id], { notify: false });
uploadAssetsStore.updateItem(deviceAssetId, { message: $t('asset_added_to_album') });

View File

@@ -287,7 +287,11 @@
}
};
const onAlbumAddAssets = async () => {
const onAlbumAddAssets = async ({ albumIds }: { albumIds: string[] }) => {
if (!albumIds.includes(album.id)) {
return;
}
await refreshAlbum();
timelineInteraction.clearMultiselect();
await setModeToView();

View File

@@ -178,19 +178,7 @@
const handleFirst = () => navigateToIndex(0);
const handlePrevious = () => navigateToIndex(Math.max(duplicatesIndex - 1, 0));
const handlePreviousShortcut = async () => {
if ($showAssetViewer) {
return;
}
await handlePrevious();
};
const handleNext = async () => navigateToIndex(Math.min(duplicatesIndex + 1, duplicates.length - 1));
const handleNextShortcut = async () => {
if ($showAssetViewer) {
return;
}
await handleNext();
};
const handleLast = () => navigateToIndex(duplicates.length - 1);
const navigateToIndex = async (index: number) =>
@@ -198,10 +186,12 @@
</script>
<svelte:document
use:shortcuts={[
{ shortcut: { key: 'ArrowLeft' }, onShortcut: handlePreviousShortcut },
{ shortcut: { key: 'ArrowRight' }, onShortcut: handleNextShortcut },
]}
use:shortcuts={$showAssetViewer
? []
: [
{ shortcut: { key: 'ArrowLeft' }, onShortcut: handlePrevious },
{ shortcut: { key: 'ArrowRight' }, onShortcut: handleNext },
]}
/>
<UserPageLayout title={data.meta.title + ` (${duplicates.length.toLocaleString($locale)})`} scrollbar={true}>