Author SHA1 Message Date
Codex d17640a283 Handle optional OAuth config in visibility regression assertion 2026-10-22 21:15:41 +03:00
Codex 9e889bd4dc Adapt local regression tests to new configuration queries 2026-10-22 20:48:48 +03:00
Codex ca4d8d5a7a Prepare 3.3.5-local.8 and complete deletion regression test imports 2026-10-22 20:44:45 +03:00
Jason RasmussenandCodex 5f54700344 refactor: folder view queries (#32667)
(cherry picked from commit 6bf016964515ed65bb45cd1cf7b7bfadb405559a)
2026-10-22 20:43:53 +03:00
Jason RasmussenandCodex 0991f506bb refactor: shared link queries (#32666)
(cherry picked from commit 0a73481a8fdb3cfbab29f23da48e26133dc4c064)
2026-10-22 20:43:53 +03:00
Jason RasmussenandCodex fa77f168a0 refactor: notification queries (#32653)
(cherry picked from commit 8f3ba2c808e02e2a606b6acc65ce22f8ee3f9b34)
2026-10-22 20:43:53 +03:00
gitTurbofliegerandCodex 3aa65809b1 fix(mobile): stop trashing assets after deletion cancellation (#32522)
* fix(mobile): stop trashing assets after deletion cancellation

* fix(mobile): handle Android system delete cancellation

* fix(mobile): simplify

* simplify test

(cherry picked from commit 3517973b8a316c8fb729bf82fe4bbf95884b9146)
2026-10-22 20:43:53 +03:00
Jason RasmussenandCodex 9a66f16102 fix: account management url visibility (#32645)
(cherry picked from commit 22fe7b4179464c73d7e0487df90f2064fa30a5e0)
2026-10-22 20:43:14 +03:00
Jason RasmussenandCodex 493870ad7b refactor: config queries (#32498)
(cherry picked from commit b45ec0f55abb9dc39fbbb39dcf5296d45a726ba0)
2026-10-22 20:43:14 +03:00
Jason RasmussenandCodex 1e20bdd74e refactor: workflow queries (#32654)
(cherry picked from commit 98680ad6993ed917e630f8f4d1dbd1fbb6c41936)
2026-10-22 20:43:14 +03:00
Santo ShakilandCodex 99a7830f6c fix(mobile): bump sqlite3_connection_pool to 0.2.13 (#32662)
* fix(mobile): bump sqlite3_connection_pool to 0.2.13

* fix(mobile): require sqlite3_connection_pool 0.2.13

(cherry picked from commit 539499ee5e3843d338287edcf6808c1820c74800)
2026-10-22 20:43:14 +03:00
121 changed files with 1284 additions and 959 deletions
+22
View File
@@ -1,5 +1,6 @@
import { BrowserContext } from '@playwright/test';
import { playwrightHost } from 'src/../playwright.config.js';
import { adminConfig, publicConfig, userConfig } from 'src/ui/mock-network/config-fixtures.js';
export const setupBaseMockApiRoutes = async (context: BrowserContext, adminUserId: string) => {
await context.addCookies([
@@ -113,6 +114,27 @@ export const setupBaseMockApiRoutes = async (context: BrowserContext, adminUserI
},
});
});
await context.route('**/api/public/config', async (route) => {
return route.fulfill({
status: 200,
contentType: 'application/json',
json: publicConfig,
});
});
await context.route('**/api/config', async (route) => {
return route.fulfill({
status: 200,
contentType: 'application/json',
json: userConfig,
});
});
await context.route('**/api/admin/config', async (route) => {
return route.fulfill({
status: 200,
contentType: 'application/json',
json: adminConfig,
});
});
await context.route('**/api/server/features', async (route) => {
return route.fulfill({
status: 200,
+171
View File
@@ -0,0 +1,171 @@
export const publicConfig = {
ffmpeg: { realtime: { enabled: false } },
map: {
enabled: true,
lightStyle: 'https://tiles.immich.cloud/v1/style/light.json',
darkStyle: 'https://tiles.immich.cloud/v1/style/dark.json',
},
oauth: { autoLaunch: false, buttonText: 'Login with OAuth', enabled: false },
passkey: { enabled: true },
passwordLogin: { enabled: true },
server: { loginPageMessage: '' },
};
export const userConfig = {
ffmpeg: { realtime: { enabled: false, videoCodecs: ['h264', 'hevc'], resolutions: [480, 720, 1080] } },
machineLearning: {
enabled: false,
clip: { enabled: true },
duplicateDetection: { enabled: true },
facialRecognition: { enabled: true, minFaces: 3 },
ocr: { enabled: true },
},
map: {
enabled: true,
lightStyle: 'https://tiles.immich.cloud/v1/style/light.json',
darkStyle: 'https://tiles.immich.cloud/v1/style/dark.json',
},
reverseGeocoding: { enabled: true },
oauth: { autoLaunch: false, buttonText: 'Login with OAuth', enabled: false, accountManagementUrl: '' },
passkey: { enabled: true },
passwordLogin: { enabled: true },
image: { thumbnail: { size: 250 }, preview: { size: 1440 }, fullsize: { enabled: false } },
trash: { enabled: true, days: 30 },
notifications: { smtp: { enabled: false } },
server: { externalDomain: '', loginPageMessage: '', publicUsers: true },
user: { deleteDelay: 7 },
};
export const adminConfig = {
backup: { database: { enabled: true, cronExpression: '0 02 * * *', keepLastAmount: 14 } },
ffmpeg: {
crf: 23,
threads: 0,
preset: 'ultrafast',
targetVideoCodec: 'h264',
acceptedVideoCodecs: ['h264'],
targetAudioCodec: 'aac',
acceptedAudioCodecs: ['aac', 'mp3', 'opus'],
acceptedContainers: ['mov', 'ogg', 'webm'],
targetResolution: '720',
maxBitrate: '0',
bframes: -1,
refs: 0,
gopSize: 0,
temporalAQ: false,
cqMode: 'auto',
twoPass: false,
preferredHwDevice: 'auto',
transcode: 'required',
tonemap: 'hable',
accel: 'disabled',
accelDecode: true,
realtime: { enabled: false, videoCodecs: ['h264', 'hevc'], resolutions: [480, 720, 1080] },
},
integrityChecks: {
missingFiles: { enabled: true, cronExpression: '0 03 * * *' },
untrackedFiles: { enabled: true, cronExpression: '0 03 * * *' },
checksumFiles: { enabled: true, cronExpression: '0 03 * * *', timeLimit: 3_600_000, percentageLimit: 1 },
},
job: {
thumbnailGeneration: { concurrency: 3 },
metadataExtraction: { concurrency: 5 },
videoConversion: { concurrency: 1 },
faceDetection: { concurrency: 2 },
smartSearch: { concurrency: 2 },
backgroundTask: { concurrency: 5 },
migration: { concurrency: 5 },
search: { concurrency: 5 },
sidecar: { concurrency: 5 },
library: { concurrency: 5 },
notifications: { concurrency: 5 },
ocr: { concurrency: 1 },
workflow: { concurrency: 5 },
editor: { concurrency: 2 },
integrityCheck: { concurrency: 1 },
},
logging: { enabled: true, level: 'log' },
machineLearning: {
enabled: false,
urls: ['http://immich-machine-learning:3003'],
availabilityChecks: { enabled: true, timeout: 2000, interval: 30_000 },
clip: { enabled: true, modelName: 'ViT-B-32__openai' },
duplicateDetection: { enabled: true, maxDistance: 0.01 },
facialRecognition: { enabled: true, modelName: 'buffalo_l', minScore: 0.7, maxDistance: 0.5, minFaces: 3 },
ocr: {
enabled: true,
modelName: 'PP-OCRv5_mobile',
minDetectionScore: 0.5,
minRecognitionScore: 0.8,
maxResolution: 736,
},
},
map: {
enabled: true,
lightStyle: 'https://tiles.immich.cloud/v1/style/light.json',
darkStyle: 'https://tiles.immich.cloud/v1/style/dark.json',
},
reverseGeocoding: { enabled: true },
metadata: { faces: { import: false } },
oauth: {
autoLaunch: false,
autoRegister: true,
buttonText: 'Login with OAuth',
clientId: '',
clientSecret: '',
defaultStorageQuota: null,
enabled: false,
issuerUrl: '',
accountManagementUrl: '',
endSessionEndpoint: '',
mobileOverrideEnabled: false,
mobileRedirectUri: '',
prompt: '',
scope: 'openid email profile',
signingAlgorithm: 'RS256',
profileSigningAlgorithm: 'none',
storageLabelClaim: 'preferred_username',
storageQuotaClaim: 'immich_quota',
roleClaim: 'immich_role',
tokenEndpointAuthMethod: 'client_secret_post',
timeout: 30_000,
allowInsecureRequests: false,
},
passkey: { enabled: true, domain: null, additionalDomains: [] },
passwordLogin: { enabled: true },
storageTemplate: {
enabled: false,
hashVerificationEnabled: true,
template: '{{y}}/{{y}}-{{MM}}-{{dd}}/{{filename}}',
},
image: {
thumbnail: { format: 'webp', size: 250, quality: 80, progressive: false },
preview: { format: 'jpeg', size: 1440, quality: 80, progressive: false },
colorspace: 'p3',
extractEmbedded: false,
fullsize: { enabled: false, format: 'jpeg', quality: 80, progressive: false },
},
newVersionCheck: { enabled: true, channel: 'stable' },
nightlyTasks: {
startTime: '00:00',
databaseCleanup: true,
generateMemories: true,
syncQuotaUsage: true,
missingThumbnails: true,
clusterNewFaces: true,
},
trash: { enabled: true, days: 30 },
theme: { customCss: '' },
library: { scan: { enabled: true, cronExpression: '0 0 * * *' }, watch: { enabled: false } },
server: { externalDomain: '', loginPageMessage: '', publicUsers: true },
notifications: {
smtp: {
enabled: false,
from: '',
replyTo: '',
transport: { ignoreCert: false, host: '', port: 587, secure: false, username: '', password: '' },
},
},
templates: { email: { welcomeTemplate: '', albumInviteTemplate: '', albumUpdateTemplate: '' } },
user: { deleteDelay: 7 },
};
+1 -1
View File
@@ -122,7 +122,7 @@ class Drift extends $Drift {
@override
Future<void> close() async {
await super.close();
_updatePool?.close();
await _updatePool?.close();
}
Future<void> reset() async {
@@ -121,14 +121,14 @@ class DeleteAction extends AssetActionBuilder {
required bool isLocalOnly,
}) async {
final assetService = ref.read(assetServiceProvider);
final message = context.t.trash_action_prompt(count: remoteIds.length);
if (localIds.isNotEmpty) {
await _cleanupLocalAssets(context, ref, localIds, isLocalOnly: isLocalOnly);
if (!context.mounted) {
final count = await _cleanupLocalAssets(context, ref, localIds, isLocalOnly: isLocalOnly);
if (count <= 0) {
return null;
}
}
final message = context.t.trash_action_prompt(count: remoteIds.length);
await assetService.trash(remoteIds);
return message;
}
+2 -2
View File
@@ -1664,10 +1664,10 @@ packages:
dependency: "direct main"
description:
name: sqlite3_connection_pool
sha256: ad51972d37bdf82929888e4c85e722bd9a0b5ff8ad389f89b1b00d19603c0d2d
sha256: ad8413d9100598ee9ef1a27628f5673b7d9212fb9d15b2742f7fde690f743832
url: "https://pub.dev"
source: hosted
version: "0.2.7"
version: "0.2.13"
sqlite3_web:
dependency: transitive
description:
+2 -2
View File
@@ -2,7 +2,7 @@ name: immich_mobile
description: Immich - selfhosted backup media file on mobile phone
publish_to: 'none'
version: 3.3.5-local.7+3030500
version: 3.3.5-local.8+3030501
environment:
sdk: '>=3.12.0 <4.0.0'
@@ -69,7 +69,7 @@ dependencies:
stream_transform: ^2.1.1
sqlite3: ^3.4.0
sqlite_async: 0.14.2
sqlite3_connection_pool: ^0.2.7
sqlite3_connection_pool: ^0.2.13
thumbhash: 0.1.0+1
timezone: ^0.9.4
url_launcher: ^6.3.2
@@ -57,7 +57,7 @@ Future<void> _writerTask(String path) async {
} finally {
await db.close();
await dbConnection.close();
sqlitePool.close();
await sqlitePool.close();
}
}
@@ -1,3 +1,5 @@
import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
@@ -97,6 +99,7 @@ void main() {
});
testWidgets('trashes a merged asset and removes its device copy', (tester) async {
when(() => cleanupService.deleteLocalAssets(['local'])).thenAnswer((_) async => 1);
final asset = owned(localId: 'local');
await pumpDelete(tester, {asset});
@@ -106,6 +109,65 @@ void main() {
verify(() => assetService.trash([asset.id])).called(1);
});
testWidgets('does not trash a merged asset when the MANAGE_MEDIA prompt is cancelled', (tester) async {
debugDefaultTargetPlatformOverride = TargetPlatform.android;
await StoreService.I.put(StoreKey.manageLocalMediaAndroid, true);
final asset = owned(localId: 'local');
await pumpDelete(tester, {asset});
await respondToDialog(tester, confirm: false);
verifyNever(() => cleanupService.deleteLocalAssets(any()));
verifyNever(() => assetService.trash(any()));
verifyNever(() => assetService.delete(any()));
debugDefaultTargetPlatformOverride = null;
});
testWidgets('does not trash a merged asset when the Android system prompt is cancelled', (tester) async {
debugDefaultTargetPlatformOverride = TargetPlatform.android;
when(context.repository.permission.getAndroidSdkVersion).thenAnswer((_) async => 30);
when(() => cleanupService.deleteLocalAssets(['local'])).thenAnswer((_) async => 0);
final asset = owned(localId: 'local');
await pumpDelete(tester, {asset});
await tester.pumpAndSettle();
verify(() => cleanupService.deleteLocalAssets(['local'])).called(1);
verifyNever(() => assetService.trash(any()));
verifyNever(() => assetService.delete(any()));
debugDefaultTargetPlatformOverride = null;
});
testWidgets('finishes trashing when the action context is disposed during local cleanup', (tester) async {
final asset = owned(localId: 'local');
final cleanupResult = Completer<int>();
when(() => cleanupService.deleteLocalAssets(['local'])).thenAnswer((_) => cleanupResult.future);
var showAction = true;
late StateSetter updateHost;
await tester.pumpTestWidget(
context,
StatefulBuilder(
builder: (_, setState) {
updateHost = setState;
return showAction
? const ActionIconButton(action: DeleteAction(source: .timeline))
: const SizedBox.shrink();
},
),
overrides: context.selected({asset}),
);
await tester.tap(find.byType(ImmichIconButton));
await tester.pump();
updateHost(() => showAction = false);
await tester.pump();
cleanupResult.complete(1);
await tester.pumpAndSettle();
verify(() => assetService.trash([asset.id])).called(1);
});
testWidgets('offers an undo that restores the trashed assets', (tester) async {
final asset = owned();
@@ -283,6 +345,7 @@ void main() {
when(context.repository.permission.getAndroidSdkVersion).thenAnswer((_) async => 28);
final backedUp = owned(localId: 'local');
final localOnly = LocalAssetFactory.create();
when(() => cleanupService.deleteLocalAssets(['local', localOnly.id])).thenAnswer((_) async => 1);
await pumpDelete(tester, {backedUp, localOnly});
await tester.pump(const Duration(milliseconds: 300));
+5
View File
@@ -33974,6 +33974,11 @@
},
"UserConfigOAuthDto": {
"properties": {
"accountManagementUrl": {
"default": "",
"description": "Account management URL",
"type": "string"
},
"autoLaunch": {
"description": "Auto launch",
"type": "boolean"
+2
View File
@@ -1616,6 +1616,8 @@ export type UserConfigNotificationsDto = {
smtp: UserConfigSmtpDto;
};
export type UserConfigOAuthDto = {
/** Account management URL */
accountManagementUrl?: string;
/** Auto launch */
autoLaunch: boolean;
/** Button text */
+11
View File
@@ -39,6 +39,17 @@ describe('config visibility', () => {
expect(getKeysDeep(mapUserConfig(defaults))).toEqual(expect.arrayContaining(PUBLIC_PROPERTIES));
});
it('exposes the OAuth account link to users without exposing client secrets or the link publicly', () => {
const config = {
...defaults,
oauth: { ...defaults.oauth, accountManagementUrl: 'https://accounts.example.com', clientSecret: 'private' },
};
expect(mapUserConfig(config).oauth?.accountManagementUrl).toBe('https://accounts.example.com');
expect(mapUserConfig(config).oauth).not.toHaveProperty('clientSecret');
expect(mapPublicConfig(config).oauth).not.toHaveProperty('accountManagementUrl');
expect(mapPublicConfig(config).oauth).not.toHaveProperty('clientSecret');
});
it('should accept the defaults with the admin schema', () => {
expect(AdminConfigDto.schema.safeParse(defaults)).toEqual(expect.objectContaining({ success: true }));
});
+2 -1
View File
@@ -303,7 +303,8 @@ const AdminConfigSchemaWithVisibility = z
accountManagementUrl: emptyOrUrl('Account management URL must be an empty string or a valid URL')
.describe('Account management URL')
.optional()
.default(''),
.default('')
.meta({ visibility: User }),
scope: z.string().describe('Scope'),
prompt: z.string().describe('OAuth prompt parameter (e.g. select_account, login, consent)'),
endSessionEndpoint: emptyOrUrl('endSessionEndpoint must be an empty string or a valid URL').describe(
+4 -3
View File
@@ -31,7 +31,8 @@ import type { MessageFormatter } from 'svelte-i18n';
import { goto } from '$app/navigation';
import { page } from '$app/state';
import { authManager } from '$lib/managers/auth-manager.svelte';
import { featureFlagsManager } from '$lib/managers/feature-flags-manager.svelte';
import { readPreloadedQuery } from '$lib/queries/client';
import { getUserConfigQuery } from '$lib/queries/config-user.query';
import { Route } from '$lib/route';
import { copyToClipboard } from '$lib/utils';
@@ -92,7 +93,7 @@ export const getPagesProvider = ($t: MessageFormatter) => {
title: $t('map'),
icon: mdiMapOutline,
onAction: () => goto(Route.map()),
$if: () => authManager.authenticated && featureFlagsManager.value.map,
$if: () => authManager.authenticated && readPreloadedQuery(getUserConfigQuery()).map.enabled,
},
{
title: $t('people'),
@@ -173,7 +174,7 @@ export const getPagesProvider = ($t: MessageFormatter) => {
title: $t('trash'),
icon: mdiTrashCanOutline,
onAction: () => goto(Route.trash()),
$if: () => authManager.authenticated && featureFlagsManager.value.trash,
$if: () => authManager.authenticated && readPreloadedQuery(getUserConfigQuery()).trash.enabled,
},
{
title: $t('admin.user_settings'),
@@ -7,7 +7,7 @@
import { authManager } from '$lib/managers/auth-manager.svelte';
import { featureFlagsManager } from '$lib/managers/feature-flags-manager.svelte';
import { createPreloadedQuery, executeMutation } from '$lib/queries/client';
import { getConfigQuery, updateConfigMutation } from '$lib/queries/system-config.query';
import { getAdminConfigQuery, updateAdminConfigMutation } from '$lib/queries/config-admin.query';
import StorageTemplateVariablesModal from '$lib/modals/StorageTemplateVariablesModal.svelte';
import { Route } from '$lib/route';
import { getStorageTemplateOptions, type SystemConfigTemplateStorageOptionDto } from '@immich/sdk';
@@ -40,7 +40,7 @@
type StorageTemplateOption = (typeof StorageTemplateOption)[keyof typeof StorageTemplateOption];
const disabled = $derived(featureFlagsManager.value.configFile);
const query = createPreloadedQuery(getConfigQuery);
const query = createPreloadedQuery(getAdminConfigQuery);
const config = $derived(query.data);
let configToEdit = $state(cloneDeep(config));
@@ -155,7 +155,7 @@
onDestroy(() => {
if (saveOnClose) {
void executeMutation(updateConfigMutation, { storageTemplate: configToEdit.storageTemplate });
void executeMutation(updateAdminConfigMutation, { storageTemplate: configToEdit.storageTemplate });
}
});
</script>
@@ -7,7 +7,6 @@
import Timeline from '$lib/components/timeline/Timeline.svelte';
import { assetMultiSelectManager } from '$lib/managers/asset-multi-select-manager.svelte';
import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte';
import { featureFlagsManager } from '$lib/managers/feature-flags-manager.svelte';
import { TimelineManager } from '$lib/managers/timeline-manager/timeline-manager.svelte';
import { handleDownloadAlbum } from '$lib/services/album.service';
import { getGlobalActions } from '$lib/services/app.service';
@@ -23,6 +22,11 @@
import ControlAppBar from '../shared-components/ControlAppBar.svelte';
import ThemeButton from '../shared-components/ThemeButton.svelte';
import AlbumSummary from './AlbumSummary.svelte';
import { createPreloadedQuery } from '$lib/queries/client';
import { getPublicConfigQuery } from '$lib/queries/config-public.query';
const query = createPreloadedQuery(getPublicConfigQuery);
const publicConfig = $derived(query.data);
interface Props {
sharedLink: SharedLinkResponseDto;
@@ -144,7 +148,7 @@
icon={mdiDownload}
/>
{/if}
{#if sharedLink.showMetadata && featureFlagsManager.value.map}
{#if sharedLink.showMetadata && publicConfig.map.enabled}
<AlbumMap {album} />
{/if}
<ThemeButton />
@@ -15,7 +15,7 @@
} from '$lib/stores/preferences.store';
import { getSelectedAlbumGroupOption, sortAlbums, stringToSortOrder, type AlbumGroup } from '$lib/utils/album-utils';
import { normalizeSearchString } from '$lib/utils/string-utils';
import { type AlbumResponseDto, type SharedLinkResponseDto } from '@immich/sdk';
import { type AlbumResponseDto } from '@immich/sdk';
import { groupBy } from 'lodash-es';
import { onMount, type Snippet } from 'svelte';
import { t } from 'svelte-i18n';
@@ -185,15 +185,9 @@
ownedAlbums = ownedAlbums.filter(({ id }) => id !== album.id);
sharedAlbums = sharedAlbums.filter(({ id }) => id !== album.id);
};
const onSharedLinkCreate = (sharedLink: SharedLinkResponseDto) => {
if (sharedLink.album) {
onAlbumUpdate(sharedLink.album);
}
};
</script>
<OnEvents {onAlbumUpdate} {onAlbumDelete} {onSharedLinkCreate} />
<OnEvents {onAlbumUpdate} {onAlbumDelete} />
{#if albums.length > 0}
{#if userSettings.view === AlbumViewMode.Cover}
@@ -1,6 +1,9 @@
import type { UserConfigDto } from '@immich/sdk';
import '@testing-library/jest-dom';
import { getResizeObserverMock } from '$lib/__mocks__/resize-observer.mock';
import { authManager } from '$lib/managers/auth-manager.svelte';
import { queryClient } from '$lib/queries/client';
import { getUserConfigQuery } from '$lib/queries/config-user.query';
import { renderWithTooltips } from '$tests/helpers';
import { assetFactory } from '@test-data/factories/asset-factory';
import { preferencesFactory } from '@test-data/factories/preferences-factory';
@@ -60,6 +63,16 @@ describe('AssetViewerNavBar component', () => {
const user = userAdminFactory.build({ id: ownerId });
const asset = assetFactory.build({ ownerId, isTrashed: false });
authManager.setUser(user);
queryClient.setQueryData(getUserConfigQuery().queryKey, {
machineLearning: {
enabled: true,
clip: { enabled: true },
duplicateDetection: { enabled: true },
facialRecognition: { enabled: true },
ocr: { enabled: true },
},
trash: { enabled: true, days: 30 },
} as UserConfigDto);
const preferences = preferencesFactory.build({ cast: { gCastEnabled: false } });
authManager.setPreferences(preferences);
@@ -8,7 +8,6 @@
import { timeToLoadTheMap } from '$lib/constants';
import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte';
import { authManager } from '$lib/managers/auth-manager.svelte';
import { featureFlagsManager } from '$lib/managers/feature-flags-manager.svelte';
import { Route } from '$lib/route';
import { locale } from '$lib/stores/preferences.store';
import { getAssetMediaUrl } from '$lib/utils';
@@ -34,6 +33,11 @@
import AlbumListItemDetails from './AlbumListItemDetails.svelte';
import DetailPanelPeople from '$lib/components/asset-viewer/DetailPanelPeople.svelte';
import { faceManager } from '$lib/stores/face.svelte';
import { createPreloadedQuery } from '$lib/queries/client';
import { getPublicConfigQuery } from '$lib/queries/config-public.query';
const query = createPreloadedQuery(getPublicConfigQuery);
const publicConfig = $derived(query.data);
interface Props {
asset: AssetResponseDto;
@@ -278,7 +282,7 @@
</div>
</section>
{#if latlng && featureFlagsManager.value.map}
{#if latlng && publicConfig.map.enabled}
<div class="h-90">
{#await import('$lib/components/shared-components/map/Map.svelte')}
{#await delay(timeToLoadTheMap) then}
@@ -4,7 +4,6 @@
import { assetViewerFadeDuration } from '$lib/constants';
import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte';
import { castManager } from '$lib/managers/cast-manager.svelte';
import { featureFlagsManager } from '$lib/managers/feature-flags-manager.svelte';
import { authManager } from '$lib/managers/auth-manager.svelte';
import { mediaCapabilitiesManager } from '$lib/managers/media-capabilities-manager.svelte';
import { autoPlayVideo, lang, loopVideo as loopVideoPreference } from '$lib/stores/preferences.store';
@@ -45,6 +44,11 @@
import { t } from 'svelte-i18n';
import { fade } from 'svelte/transition';
import './immich-time-range';
import { createPreloadedQuery } from '$lib/queries/client';
import { getPublicConfigQuery } from '$lib/queries/config-public.query';
const query = createPreloadedQuery(getPublicConfigQuery);
const publicConfig = $derived(query.data);
interface Props {
asset: AssetResponseDto;
@@ -78,7 +82,7 @@
let isLoading = $state(true);
let hasLoadedMetadata = $state(false);
let assetFileUrl = $derived.by(() => {
if (featureFlagsManager.value.realtimeTranscoding) {
if (publicConfig.ffmpeg.realtime.enabled) {
return getAssetHlsUrl(assetId);
}
@@ -376,7 +380,7 @@
style:aspect-ratio={aspectRatio}
defaultduration={asset.duration! / 1000}
>
{#if featureFlagsManager.value.realtimeTranscoding}
{#if publicConfig.ffmpeg.realtime.enabled}
<hls-video
bind:this={videoPlayer}
slot="media"
@@ -440,7 +444,7 @@
<span slot="title">{$t('media_chrome.playback_rate')}</span>
</media-playback-rate-menu>
</media-settings-menu-item>
{#if featureFlagsManager.value.realtimeTranscoding}
{#if publicConfig.ffmpeg.realtime.enabled}
<media-settings-menu-item class="mx-1 rounded-lg p-1 ps-2">
{$t('video_quality')}
<Icon slot="suffix" icon={mdiChevronRight} class="m-2" />
@@ -12,7 +12,6 @@
import { afterNavigate } from '$app/navigation';
import OnEvents from '$lib/components/OnEvents.svelte';
import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte';
import { serverConfigManager } from '$lib/managers/server-config-manager.svelte';
import MapSettingsModal from '$lib/modals/MapSettingsModal.svelte';
import { mapSettings } from '$lib/stores/preferences.store';
import { getAssetMediaUrl, handlePromiseError } from '$lib/utils';
@@ -48,6 +47,11 @@
ScaleControl,
} from 'svelte-maplibre';
import type { SelectionBBox } from './types';
import { createPreloadedQuery } from '$lib/queries/client';
import { getPublicConfigQuery } from '$lib/queries/config-public.query';
const query = createPreloadedQuery(getPublicConfigQuery);
const publicConfig = $derived(query.data);
interface Props {
mapMarkers?: MapMarkerResponseDto[];
@@ -111,9 +115,7 @@
let abortController: AbortController;
const mapTheme = $derived($mapSettings.allowDarkMode ? themeManager.value : Theme.Light);
const styleUrl = $derived(
mapTheme === Theme.Dark ? serverConfigManager.value.mapDarkStyleUrl : serverConfigManager.value.mapLightStyleUrl,
);
const styleUrl = $derived(mapTheme === Theme.Dark ? publicConfig.map.darkStyle : publicConfig.map.lightStyle);
export function addClipMapMarker(lng: number, lat: number) {
if (!map) {
@@ -12,11 +12,11 @@
import { Route } from '$lib/route';
import { getGlobalActions } from '$lib/services/app.service';
import { mediaQueryManager } from '$lib/stores/media-query-manager.svelte';
import { notificationManager } from '$lib/stores/notification-manager.svelte';
import { sidebarStore } from '$lib/stores/sidebar.svelte';
import { getNotificationsQuery } from '$lib/queries/notification.query';
import { ActionButton, Button, IconButton, Logo } from '@immich/ui';
import { createQuery } from '@tanstack/svelte-query';
import { mdiBellBadge, mdiBellOutline, mdiMagnify, mdiMenu, mdiTrayArrowUp } from '@mdi/js';
import { onMount } from 'svelte';
import { t } from 'svelte-i18n';
import ThemeButton from '../ThemeButton.svelte';
import UserAvatar from '../UserAvatar.svelte';
@@ -33,15 +33,9 @@
let shouldShowAccountInfoPanel = $state(false);
let shouldShowNotificationPanel = $state(false);
let innerWidth: number = $state(0);
const hasUnreadNotifications = $derived(notificationManager.notifications.length > 0);
onMount(async () => {
try {
await notificationManager.refresh();
} catch (error) {
console.error('Failed to load notifications on mount', error);
}
});
const query = createQuery(getNotificationsQuery);
const unreadCount = $derived(query.data?.length ?? 0);
const hasUnreadNotifications = $derived(unreadCount > 0);
const { Cast } = $derived(getGlobalActions($t));
</script>
@@ -143,7 +137,7 @@
<div
class="pointer-events-none absolute top-0 right-1 flex size-5 items-center justify-center rounded-full border bg-primary text-[10px] font-bold text-light"
>
{notificationManager.notifications.length}
{unreadCount}
</div>
{/if}
</div>
@@ -4,33 +4,28 @@
import NotificationItem from '$lib/components/shared-components/navigation-bar/NotificationItem.svelte';
import { OpenQueryParam } from '$lib/constants';
import { Route } from '$lib/route';
import { notificationManager } from '$lib/stores/notification-manager.svelte';
import { handleError } from '$lib/utils/handle-error';
import { executeMutation } from '$lib/queries/client';
import {
getNotificationsQuery,
updateNotificationMutation,
updateNotificationsMutation,
} from '$lib/queries/notification.query';
import { NotificationType, type NotificationDto } from '@immich/sdk';
import { Button, Icon, Scrollable, Stack, Text, toastManager } from '@immich/ui';
import { Button, Icon, Scrollable, Stack, Text } from '@immich/ui';
import { mdiBellOutline, mdiCheckAll } from '@mdi/js';
import { createQuery } from '@tanstack/svelte-query';
import { t } from 'svelte-i18n';
import { flip } from 'svelte/animate';
import { fade } from 'svelte/transition';
const noUnreadNotifications = $derived(notificationManager.notifications.length === 0);
const query = createQuery(getNotificationsQuery);
const notifications = $derived(query.data ?? []);
const noUnreadNotifications = $derived(notifications.length === 0);
const markAsRead = async (id: string) => {
try {
await notificationManager.markAsRead(id);
} catch (error) {
handleError(error, $t('errors.failed_to_update_notification_status'));
}
};
const markAllAsRead = async () => {
try {
await notificationManager.markAllAsRead();
toastManager.info($t('marked_all_as_read'));
} catch (error) {
handleError(error, $t('errors.failed_to_update_notification_status'));
}
};
const markAllAsRead = () =>
executeMutation(updateNotificationsMutation, [
{ notificationUpdateAllDto: { ids: notifications.map(({ id }) => id), readAt: new Date().toISOString() } },
]);
const handleNotificationAction = async (notification: NotificationDto) => {
switch (notification.type) {
@@ -63,10 +58,12 @@
}
};
const onclick = async (notification: NotificationDto) => {
await markAsRead(notification.id);
await handleNotificationAction(notification);
};
const onclick = (notification: NotificationDto) =>
executeMutation(
updateNotificationMutation,
[{ id: notification.id, notificationUpdateDto: { readAt: new Date().toISOString() } }],
{ onSuccess: () => void handleNotificationAction(notification) },
);
</script>
<div
@@ -104,7 +101,7 @@
{:else}
<Scrollable class="pb-6">
<Stack gap={0}>
{#each notificationManager.notifications as notification (notification.id)}
{#each notifications as notification (notification.id)}
<div animate:flip={{ duration: 400 }}>
<NotificationItem {notification} {onclick} />
</div>
@@ -1,10 +1,14 @@
<script lang="ts">
import { getSearchTypeTitle } from './search-bar-utils';
import SearchButton from './SearchButton.svelte';
import { featureFlagsManager } from '$lib/managers/feature-flags-manager.svelte';
import { searchManager } from '$lib/managers/search-manager.svelte';
import { Text } from '@immich/ui';
import { t } from 'svelte-i18n';
import { createPreloadedQuery } from '$lib/queries/client';
import { getUserConfigQuery } from '$lib/queries/config-user.query';
const query = createPreloadedQuery(getUserConfigQuery);
const config = $derived(query.data);
let queryType = $derived(searchManager.filter.queryType);
@@ -17,7 +21,7 @@
<fieldset>
<Text class="mb-5">{$t('search_type_description')}</Text>
<div class="flex flex-wrap gap-2">
{#if featureFlagsManager.value.smartSearch}
{#if config.machineLearning.clip.enabled}
<SearchButton checked active={queryType === 'smart'} onclick={() => setType('smart')}>
{$t('context')}
</SearchButton>
@@ -31,7 +35,7 @@
<SearchButton checked active={queryType === 'fullPath'} onclick={() => setType('fullPath')}>
{getSearchTypeTitle('fullPath')}
</SearchButton>
{#if featureFlagsManager.value.ocr}
{#if config.machineLearning.ocr.enabled}
<SearchButton checked active={queryType === 'ocr'} onclick={() => setType('ocr')}>
{getSearchTypeTitle('ocr')}
</SearchButton>
@@ -1,6 +1,10 @@
<script lang="ts">
import { createPreloadedQuery } from '$lib/queries/client';
import { getConfigDefaultsQuery, getConfigQuery, updateConfigMutation } from '$lib/queries/system-config.query';
import {
getAdminConfigDefaultsQuery,
getAdminConfigQuery,
updateAdminConfigMutation,
} from '$lib/queries/config-admin.query';
import type { AdminConfigDto } from '@immich/sdk';
import { Button, toastManager } from '@immich/ui';
import { createMutation } from '@tanstack/svelte-query';
@@ -16,19 +20,24 @@
let { disabled, keys, configToEdit = $bindable(), onBeforeSave }: Props = $props();
const config = createPreloadedQuery(getConfigQuery);
const defaults = createPreloadedQuery(getConfigDefaultsQuery);
const save = createMutation(updateConfigMutation);
const queries = {
config: createPreloadedQuery(getAdminConfigQuery),
defaults: createPreloadedQuery(getAdminConfigDefaultsQuery),
};
const showResetToDefault = $derived(!isEqual(pick(config.data, keys), pick(defaults.data, keys)));
const config = $derived(queries.config.data);
const defaults = $derived(queries.defaults.data);
const save = createMutation(updateAdminConfigMutation);
const showResetToDefault = $derived(!isEqual(pick(config, keys), pick(defaults, keys)));
const handleReset = () => {
configToEdit = cloneDeep(config.data);
configToEdit = cloneDeep(config);
toastManager.info($t('admin.reset_settings_to_recent_saved'));
};
const handleResetToDefault = () => {
configToEdit = { ...configToEdit, ...cloneDeep(pick(defaults.data, keys)) };
configToEdit = { ...configToEdit, ...cloneDeep(pick(defaults, keys)) };
toastManager.info($t('admin.reset_settings_to_default'));
};
@@ -13,10 +13,13 @@
const { serverVersion, connected } = websocketStore;
const aboutQuery = createQuery(getAboutInfoQuery);
const info = $derived(aboutQuery.data);
const historyQuery = createQuery(getVersionHistoryQuery);
const versions = $derived(historyQuery.data ?? []);
const queries = {
about: createQuery(getAboutInfoQuery),
history: createQuery(getVersionHistoryQuery),
};
const info = $derived(queries.about.data);
const versions = $derived(queries.history.data ?? []);
let isMain = $derived(info?.sourceRef === 'main' && info.repository === 'immich-app/immich');
let version = $derived($serverVersion ? semverToName($serverVersion) : null);
@@ -3,7 +3,6 @@
import RecentAlbums from '$lib/components/shared-components/side-bar/RecentAlbums.svelte';
import Sidebar from '$lib/components/sidebar/Sidebar.svelte';
import { authManager } from '$lib/managers/auth-manager.svelte';
import { featureFlagsManager } from '$lib/managers/feature-flags-manager.svelte';
import { Route } from '$lib/route';
import { recentAlbumsDropdown } from '$lib/stores/preferences.store';
import { NavbarGroup, NavbarItem } from '@immich/ui';
@@ -37,6 +36,11 @@
} from '@mdi/js';
import { t } from 'svelte-i18n';
import { fly } from 'svelte/transition';
import { createPreloadedQuery } from '$lib/queries/client';
import { getUserConfigQuery } from '$lib/queries/config-user.query';
const query = createPreloadedQuery(getUserConfigQuery);
const config = $derived(query.data);
</script>
<Sidebar ariaLabel={$t('primary')}>
@@ -44,7 +48,7 @@
<NavbarItem title={$t('explore')} href={Route.explore()} icon={mdiMagnify} />
{#if featureFlagsManager.value.map}
{#if config.map.enabled}
<NavbarItem title={$t('map')} href={Route.map()} icon={mdiMapOutline} activeIcon={mdiMap} />
{/if}
@@ -111,7 +115,7 @@
<NavbarItem title={$t('locked_folder')} href={Route.locked()} icon={mdiLockOutline} activeIcon={mdiLock} />
{#if featureFlagsManager.value.trash}
{#if config.trash.enabled}
<NavbarItem title={$t('trash')} href={Route.trash()} icon={mdiTrashCanOutline} activeIcon={mdiTrashCan} />
{/if}
@@ -1,9 +1,10 @@
<script lang="ts">
import { shortcut } from '$lib/actions/shortcut';
import { assetMultiSelectManager } from '$lib/managers/asset-multi-select-manager.svelte';
import { handleRemoveSharedLinkAssets } from '$lib/services/shared-link.service';
import { executeMutation } from '$lib/queries/client';
import { removeSharedLinkAssetsMutation } from '$lib/queries/shared-link.query';
import { type SharedLinkResponseDto } from '@immich/sdk';
import { IconButton } from '@immich/ui';
import { IconButton, modalManager, toastManager } from '@immich/ui';
import { mdiDeleteOutline } from '@mdi/js';
import { t } from 'svelte-i18n';
@@ -13,22 +14,35 @@
let { sharedLink = $bindable() }: Props = $props();
const handleSelect = async () => {
const onAction = async () => {
const assetIds = assetMultiSelectManager.assets.map(({ id }) => id);
const success = await handleRemoveSharedLinkAssets(sharedLink, assetIds);
if (success) {
assetMultiSelectManager.clear();
const success = await modalManager.showDialog({
title: $t('remove_assets_title'),
prompt: $t('remove_assets_shared_link_confirmation', { values: { count: assetIds.length } }),
confirmText: $t('remove'),
});
if (!success) {
return;
}
await executeMutation(removeSharedLinkAssetsMutation, [{ id: sharedLink.id, assetIdsDto: { assetIds } }], {
onSuccess: (results) => {
const removed = new Set(results.filter(({ success }) => success).map(({ assetId }) => assetId));
sharedLink.assets = sharedLink.assets.filter(({ id }) => !removed.has(id));
toastManager.primary($t('assets_removed_count', { values: { count: removed.size } }));
assetMultiSelectManager.clear();
},
});
};
</script>
<svelte:document use:shortcut={{ shortcut: { key: 'Delete' }, onShortcut: handleSelect }} />
<svelte:document use:shortcut={{ shortcut: { key: 'Delete' }, onShortcut: onAction }} />
<IconButton
shape="round"
color="secondary"
variant="ghost"
aria-label={$t('remove_from_shared_link')}
onclick={handleSelect}
onclick={onAction}
icon={mdiDeleteOutline}
/>
+10 -1
View File
@@ -10,6 +10,8 @@ import { goto } from '$app/navigation';
import { page } from '$app/state';
import { eventManager } from '$lib/managers/event-manager.svelte';
import { queryClient } from '$lib/queries/client';
import { getAdminConfigQuery } from '$lib/queries/config-admin.query';
import { getUserConfigQuery } from '$lib/queries/config-user.query';
import { getAboutInfoQuery } from '$lib/queries/server.query';
import { Route } from '$lib/route';
import { isSharedLinkRoute } from '$lib/utils/navigation';
@@ -62,7 +64,14 @@ class AuthManager {
async refresh() {
try {
const [user, preferences] = await Promise.all([getMyUser(), getMyPreferences()]);
const [user, preferences] = await Promise.all([
getMyUser(),
getMyPreferences(),
queryClient.query(getUserConfigQuery()),
]);
if (user.isAdmin) {
await queryClient.query(getAdminConfigQuery());
}
this.#preferences = preferences;
this.#user = user;
@@ -10,12 +10,10 @@ import type {
PersonResponseDto,
QueueResponseDto,
ReleaseEventV1,
SharedLinkResponseDto,
StackResponseDto,
AdminConfigDto,
TagResponseDto,
UserAdminResponseDto,
WorkflowResponseDto,
} from '@immich/sdk';
import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
import { BaseEventManager } from '$lib/utils/base-event-manager.svelte';
@@ -68,10 +66,6 @@ export type Events = {
QueueUpdate: [QueueResponseDto];
SharedLinkCreate: [SharedLinkResponseDto];
SharedLinkUpdate: [SharedLinkResponseDto];
SharedLinkDelete: [SharedLinkResponseDto];
StackCreate: [StackResponseDto];
/** Unstacked, with assets to handle */
StackDelete: [{ id: string; assets: AssetResponseDto[] }];
@@ -96,10 +90,6 @@ export type Events = {
JobCreate: [{ dto: JobCreateDto }];
WorkflowCreate: [WorkflowResponseDto];
WorkflowUpdate: [WorkflowResponseDto];
WorkflowDelete: [WorkflowResponseDto];
ReleaseEvent: [ReleaseEventV1];
WebsocketConnect: [];
@@ -1,7 +1,4 @@
import {
getWorkflowTriggers,
searchPluginMethods,
searchPluginTemplates,
WorkflowTrigger,
type PluginMethodResponseDto,
type PluginTemplateResponseDto,
@@ -12,6 +9,9 @@ import { SvelteMap } from 'svelte/reactivity';
import { get } from 'svelte/store';
import { authManager } from '$lib/managers/auth-manager.svelte';
import { eventManager } from '$lib/managers/event-manager.svelte';
import { queryClient } from '$lib/queries/client';
import { searchPluginMethodsQuery, searchPluginTemplatesQuery } from '$lib/queries/plugin.query';
import { getWorkflowTriggersQuery } from '$lib/queries/workflow.query';
class PluginManager {
#loading: Promise<void> | undefined;
@@ -78,9 +78,9 @@ class PluginManager {
private async load() {
const [methods, triggers, templates] = await Promise.all([
searchPluginMethods({}),
getWorkflowTriggers(),
searchPluginTemplates(),
queryClient.query(searchPluginMethodsQuery()),
queryClient.query(getWorkflowTriggersQuery()),
queryClient.query(searchPluginTemplatesQuery()),
]);
this.#methods = methods;
@@ -1,30 +0,0 @@
import { getServerConfig, type ServerConfigDto } from '@immich/sdk';
import { eventManager } from '$lib/managers/event-manager.svelte';
class ServerConfigManager {
#value?: ServerConfigDto = $state();
constructor() {
eventManager.on({
SystemConfigUpdate: () => this.loadServerConfig(),
});
}
async init() {
await this.loadServerConfig();
}
get value() {
if (!this.#value) {
throw new Error('Server config manager must be initialized first');
}
return this.#value;
}
async loadServerConfig() {
this.#value = await getServerConfig();
}
}
export const serverConfigManager = new ServerConfigManager();
+10 -30
View File
@@ -9,17 +9,11 @@
handleUpdateAlbum,
handleUpdateUserAlbumRole,
} from '$lib/services/album.service';
import {
AlbumUserRole,
AssetOrder,
getAlbumInfo,
getAllSharedLinks,
type AlbumResponseDto,
type SharedLinkResponseDto,
type UserResponseDto,
} from '@immich/sdk';
import { AlbumUserRole, AssetOrder, getAlbumInfo, type AlbumResponseDto, type UserResponseDto } from '@immich/sdk';
import { Field, HStack, Modal, ModalBody, Select, Stack, Switch, Text, type SelectOption } from '@immich/ui';
import { onMount } from 'svelte';
import { createQuery } from '@tanstack/svelte-query';
import { queryClient } from '$lib/queries/client';
import { getAllSharedLinksQuery } from '$lib/queries/shared-link.query';
import { t } from 'svelte-i18n';
type Props = {
@@ -48,30 +42,16 @@
await refreshAlbum();
};
const onSharedLinkCreate = (sharedLink: SharedLinkResponseDto) => {
sharedLinks.push(sharedLink);
};
const onSharedLinkDelete = (sharedLink: SharedLinkResponseDto) => {
sharedLinks = sharedLinks.filter(({ id }) => sharedLink.id !== id);
};
const { AddUsers, CreateSharedLink } = $derived(getAlbumActions($t, album));
let sharedLinks: SharedLinkResponseDto[] = $state([]);
onMount(async () => {
sharedLinks = await getAllSharedLinks({ albumId: album.id });
});
const query = createQuery(
() => getAllSharedLinksQuery({ albumId: album.id }),
() => queryClient,
);
const sharedLinks = $derived(query.data ?? []);
</script>
<OnEvents
{onAlbumUserDelete}
onAlbumShare={refreshAlbum}
{onSharedLinkCreate}
{onSharedLinkDelete}
onAlbumUpdate={(newAlbum) => (album = newAlbum)}
/>
<OnEvents {onAlbumUserDelete} onAlbumShare={refreshAlbum} onAlbumUpdate={(newAlbum) => (album = newAlbum)} />
<Modal title={readOnly ? $t('album') : $t('options')} {onClose} size="small">
<ModalBody>
+6 -2
View File
@@ -1,9 +1,13 @@
<script lang="ts">
import { featureFlagsManager } from '$lib/managers/feature-flags-manager.svelte';
import { handleResetPinCode } from '$lib/services/user.service';
import { BasicModal, Field, FormModal, HelperText, PasswordInput, Stack, type ModalSize } from '@immich/ui';
import { mdiLockReset } from '@mdi/js';
import { t } from 'svelte-i18n';
import { createPreloadedQuery } from '$lib/queries/client';
import { getUserConfigQuery } from '$lib/queries/config-user.query';
const query = createPreloadedQuery(getUserConfigQuery);
const config = $derived(query.data);
type Props = {
onClose: (success?: true) => void;
@@ -23,7 +27,7 @@
const common = $derived({ title: $t('reset'), size: 'small' as ModalSize, icon: mdiLockReset, onClose });
</script>
{#if featureFlagsManager.value.passwordLogin}
{#if config.passwordLogin.enabled}
<FormModal {...common} submitColor="danger" submitText={$t('reset')} disabled={!password} {onSubmit}>
<Stack gap={4}>
<div>{$t('reset_pin_code_description')}</div>
+16 -8
View File
@@ -1,7 +1,10 @@
<script lang="ts">
import { searchPluginMethods, WorkflowTrigger, type PluginMethodResponseDto } from '@immich/sdk';
import { WorkflowTrigger, type PluginMethodResponseDto } from '@immich/sdk';
import { Badge, BasicModal, ListButton, LoadingSpinner, Stack, Text } from '@immich/ui';
import { createQuery } from '@tanstack/svelte-query';
import { t } from 'svelte-i18n';
import { queryClient } from '$lib/queries/client';
import { searchPluginMethodsQuery } from '$lib/queries/plugin.query';
type Props = {
trigger: WorkflowTrigger;
@@ -10,16 +13,17 @@
};
const { trigger, selectedKey, onClose }: Props = $props();
const query = createQuery(
() => searchPluginMethodsQuery({ trigger }),
() => queryClient,
);
</script>
<BasicModal title={$t('add_step')} onClose={() => onClose()} size="medium">
{#await searchPluginMethods({ trigger })}
<div class="flex w-full place-content-center place-items-center">
<LoadingSpinner />
</div>
{:then methods}
{#if query.data}
<Stack>
{#each methods as method (method.key)}
{#each query.data as method (method.key)}
<ListButton selected={method.key === selectedKey} onclick={() => onClose(method)}>
<div class="grow text-start">
<Text fontWeight="medium" class="flex items-center gap-1"
@@ -37,5 +41,9 @@
</ListButton>
{/each}
</Stack>
{/await}
{:else}
<div class="flex w-full place-content-center place-items-center">
<LoadingSpinner />
</div>
{/if}
</BasicModal>
@@ -1,6 +1,8 @@
<script lang="ts">
import SharedLinkFormFields from '$lib/components/SharedLinkFormFields.svelte';
import { handleCreateSharedLink } from '$lib/services/shared-link.service';
import { executeMutation } from '$lib/queries/client';
import { createSharedLinkMutation } from '$lib/queries/shared-link.query';
import { showSharedLinkQrCode } from '$lib/services/shared-link.service';
import { SharedLinkType } from '@immich/sdk';
import { FormModal } from '@immich/ui';
import { mdiLink } from '@mdi/js';
@@ -25,7 +27,7 @@
let type = $derived(albumId ? SharedLinkType.Album : SharedLinkType.Individual);
const onSubmit = async () => {
const success = await handleCreateSharedLink({
const sharedLinkCreateDto = {
type,
albumId,
assetIds,
@@ -36,10 +38,13 @@
allowDownload,
showMetadata,
slug,
});
if (success) {
};
await executeMutation(createSharedLinkMutation, [{ sharedLinkCreateDto }], {
onSuccess: (sharedLink) => {
onClose();
}
void showSharedLinkQrCode(sharedLink);
},
});
};
</script>
@@ -1,12 +1,15 @@
<script lang="ts">
import FormatMessage from '$lib/elements/FormatMessage.svelte';
import { serverConfigManager } from '$lib/managers/server-config-manager.svelte';
import { executeMutation } from '$lib/queries/client';
import { createPreloadedQuery, executeMutation } from '$lib/queries/client';
import { deleteUserAdminMutation } from '$lib/queries/user-admin.query';
import { type UserAdminResponseDto } from '@immich/sdk';
import { Alert, Checkbox, ConfirmModal, Field, Input, Label, Text } from '@immich/ui';
import { mdiTrashCanOutline } from '@mdi/js';
import { t } from 'svelte-i18n';
import { getUserConfigQuery } from '$lib/queries/config-user.query';
const query = createPreloadedQuery(getUserConfigQuery);
const config = $derived(query.data);
type Props = {
user: UserAdminResponseDto;
@@ -48,10 +51,7 @@
{/snippet}
</FormatMessage>
{:else}
<FormatMessage
key="admin.user_delete_delay"
values={{ user: user.name, delay: serverConfigManager.value.userDeleteDelay }}
>
<FormatMessage key="admin.user_delete_delay" values={{ user: user.name, delay: config.user.deleteDelay }}>
{#snippet children({ message })}
<b>{message}</b>
{/snippet}
@@ -1,7 +1,8 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { executeMutation } from '$lib/queries/client';
import { createWorkflowMutation } from '$lib/queries/workflow.query';
import { Route } from '$lib/route';
import { handleCreateWorkflow } from '$lib/services/workflow.service';
import { WorkflowTrigger, type WorkflowResponseDto } from '@immich/sdk';
import { Field, FormModal, Input, Textarea, VStack } from '@immich/ui';
import { t } from 'svelte-i18n';
@@ -18,18 +19,16 @@
let trigger = $state<WorkflowTrigger>(workflow.trigger);
const onSubmit = async () => {
const response = await handleCreateWorkflow({
name,
description,
trigger,
steps: workflow.steps,
enabled: false,
});
if (response) {
await goto(Route.viewWorkflow({ id: response.id }));
await executeMutation(
createWorkflowMutation,
[{ workflowCreateDto: { name, description, trigger, steps: workflow.steps, enabled: false } }],
{
onSuccess: (response) => {
onClose();
}
void goto(Route.viewWorkflow(response));
},
},
);
};
</script>
+7 -7
View File
@@ -21,7 +21,8 @@
import { locale } from '$lib/stores/preferences.store';
import { DateTime } from 'luxon';
import { Route } from '$lib/route';
import { handleUpdateWorkflow } from '$lib/services/workflow.service';
import { executeMutation } from '$lib/queries/client';
import { updateWorkflowMutation } from '$lib/queries/workflow.query';
type Props = {
workflow: WorkflowResponseDto;
@@ -37,14 +38,13 @@
let hasNext = $state(true);
let loading = $state(false);
const setLogging = async (logging: boolean) => {
const success = await handleUpdateWorkflow(workflow.id, { logging });
if (!success) {
return;
}
const setLogging = (logging: boolean) =>
executeMutation(updateWorkflowMutation, [{ id: workflow.id, workflowUpdateDto: { logging } }], {
onSuccess: () => {
workflow = { ...workflow, logging };
reset();
};
},
});
const reset = () => {
entries = [];
@@ -1,6 +1,9 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { pluginManager } from '$lib/managers/plugin-manager.svelte';
import { handleCreateWorkflow } from '$lib/services/workflow.service';
import { executeMutation } from '$lib/queries/client';
import { createWorkflowMutation } from '$lib/queries/workflow.query';
import { Route } from '$lib/route';
import { type PluginTemplateResponseDto } from '@immich/sdk';
import { Badge, FormModal, Icon, ListButton, Text } from '@immich/ui';
import { mdiFlashOutline } from '@mdi/js';
@@ -19,17 +22,15 @@
return;
}
const success = await handleCreateWorkflow({
trigger: selected.trigger,
steps: selected.steps,
name: selected.title,
description: selected.description,
enabled: false,
});
const { trigger, steps, title: name, description } = selected;
const workflowCreateDto = { trigger, steps, name, description, enabled: false };
if (success) {
await executeMutation(createWorkflowMutation, [{ workflowCreateDto }], {
onSuccess: (response) => {
onClose();
}
void goto(Route.viewWorkflow(response));
},
});
};
const isSelected = (template: PluginTemplateResponseDto) => selected?.key === template.key;
+8
View File
@@ -0,0 +1,8 @@
import { getAlbumInfo } from '@immich/sdk';
import { queryOptions } from '@tanstack/svelte-query';
export const getAlbumInfoQuery = (id: string) =>
queryOptions({
queryKey: ['album', id],
queryFn: ({ signal }) => getAlbumInfo({ id }, { signal }),
});
+44 -27
View File
@@ -1,12 +1,12 @@
import { toastManager } from '@immich/ui';
import {
createQuery,
hashKey,
MutationCache,
mutationOptions,
MutationObserver,
QueryClient,
type CreateMutationOptions,
type DataTag,
type DefaultError,
type DefinedCreateQueryResult,
type MutateOptions,
@@ -17,6 +17,8 @@ import {
import { noop } from 'lodash-es';
import type { Translations } from 'svelte-i18n';
import { eventManager } from '$lib/managers/event-manager.svelte';
import { getPublicConfigQuery } from '$lib/queries/config-public.query';
import { getServerConfigQuery } from '$lib/queries/server.query';
import { handleError } from '$lib/utils/handle-error';
import { getFormatter } from '$lib/utils/i18n';
@@ -67,7 +69,15 @@ const createQueryClient = () => {
}),
});
eventManager.on({ AuthLogout: () => client.clear() });
const publicQueryHashes = new Set(
[getServerConfigQuery(), getPublicConfigQuery()].map(({ queryKey }) => hashKey(queryKey)),
);
eventManager.on({
AuthLogout: () => {
client.removeQueries({ predicate: ({ queryHash }) => !publicQueryHashes.has(queryHash) });
client.getMutationCache().clear();
},
});
return client;
};
@@ -79,35 +89,42 @@ export const asMutation = <TArgs extends unknown[], TData, TError = DefaultError
options?: Omit<CreateMutationOptions<TData, TError, TArgs, TOnMutateResult>, 'mutationFn' | 'mutationKey'>,
) => mutationOptions({ ...options, mutationFn: (args: TArgs) => fn(...args) });
export const createPreloadedQuery = <
TQueryFnData,
TError = DefaultError,
TData = TQueryFnData,
TQueryKey extends QueryKey = QueryKey,
>(
optionsFn: () => UndefinedInitialDataOptions<TQueryFnData, TError, TData, TQueryKey> & {
queryKey: DataTag<TQueryKey, TQueryFnData, TError>;
},
): DefinedCreateQueryResult<TData, TError> =>
createQuery(() => {
const options = optionsFn();
const initialData = queryClient.getQueryData<TQueryFnData, TQueryKey, NonUndefinedGuard<TQueryFnData>>(
options.queryKey,
);
if (initialData === undefined) {
throw new Error(`${options.queryKey.join('/')} must be loaded first`);
const getPreloadedData = <TQueryFnData>(queryKey: QueryKey) => {
const data = queryClient.getQueryData<TQueryFnData, QueryKey, NonUndefinedGuard<TQueryFnData>>(queryKey);
if (data === undefined) {
const error = new Error(`${queryKey.join('/')} must be loaded first`);
console.error(error);
throw error;
}
return { ...options, initialData };
});
return data;
};
type MutationArgs<TData, TError, TVariables, TContext> = TVariables extends void
? [variables?: TVariables, mutateOptions?: MutateOptions<TData, TError, TVariables, TContext>]
: [variables: TVariables, mutateOptions?: MutateOptions<TData, TError, TVariables, TContext>];
export const readPreloadedQuery = <TQueryFnData, TError, TData, TQueryKey extends QueryKey>(
options: UndefinedInitialDataOptions<TQueryFnData, TError, TData, TQueryKey>,
): TData => {
const data = getPreloadedData<TQueryFnData>(options.queryKey);
return options.select ? options.select(data) : (data as unknown as TData);
};
export const executeMutation = <TData, TError, TVariables, TContext>(
options: () => CreateMutationOptions<TData, TError, TVariables, TContext>,
...[variables, mutateOptions]: MutationArgs<TData, TError, TVariables, TContext>
export const createPreloadedQuery = <TQueryFnData, TError, TData, TQueryKey extends QueryKey>(
optionsFn: () => UndefinedInitialDataOptions<TQueryFnData, TError, TData, TQueryKey>,
): DefinedCreateQueryResult<TData, TError> =>
createQuery(
() => {
const options = optionsFn();
return { ...options, initialData: getPreloadedData<TQueryFnData>(options.queryKey) };
},
() => queryClient,
);
type MutationArgs<TData, TError, TVariables, TOnMutateResult> = TVariables extends void
? [variables?: TVariables, mutateOptions?: MutateOptions<TData, TError, TVariables, TOnMutateResult>]
: [variables: TVariables, mutateOptions?: MutateOptions<TData, TError, TVariables, TOnMutateResult>];
export const executeMutation = <TData, TError, TVariables, TOnMutateResult>(
options: () => CreateMutationOptions<TData, TError, TVariables, TOnMutateResult>,
...[variables, mutateOptions]: MutationArgs<TData, TError, TVariables, TOnMutateResult>
) => {
const observer = new MutationObserver(queryClient, options());
const unsubscribe = observer.subscribe(noop);
+51
View File
@@ -0,0 +1,51 @@
import { getAdminConfig, getAdminConfigDefaults, updateAdminConfig, type AdminConfigDto } from '@immich/sdk';
import { toastManager } from '@immich/ui';
import { mutationOptions, queryOptions } from '@tanstack/svelte-query';
import { isEqual } from 'lodash-es';
import { eventManager } from '$lib/managers/event-manager.svelte';
import { queryClient } from '$lib/queries/client';
import { getPublicConfigQuery } from '$lib/queries/config-public.query';
import { getUserConfigQuery } from '$lib/queries/config-user.query';
import { getFormatter } from '$lib/utils/i18n';
export const getAdminConfigQuery = () =>
queryOptions({
queryKey: ['config', 'admin'],
queryFn: ({ signal }) => getAdminConfig({ signal }),
});
export const getAdminConfigDefaultsQuery = () =>
queryOptions({
queryKey: ['config', 'admin', 'defaults'],
queryFn: ({ signal }) => getAdminConfigDefaults({ signal }),
});
export const updateAdminConfigMutation = () =>
mutationOptions({
mutationFn: async (update: Partial<AdminConfigDto>) => {
const config = await getAdminConfig();
const adminConfigDto = { ...config, ...update };
if (isEqual(config, adminConfigDto)) {
return;
}
return updateAdminConfig({ adminConfigDto });
},
onSuccess: async (config) => {
if (!config) {
return;
}
queryClient.setQueryData(getAdminConfigQuery().queryKey, config);
await Promise.all([
queryClient.invalidateQueries({ queryKey: getUserConfigQuery().queryKey }),
queryClient.invalidateQueries({ queryKey: getPublicConfigQuery().queryKey }),
]);
eventManager.emit('SystemConfigUpdate', config);
const $t = await getFormatter();
toastManager.primary($t('settings_saved'));
},
meta: { error: { toast: 'errors.unable_to_save_settings' } },
});
@@ -0,0 +1,8 @@
import { getPublicConfig } from '@immich/sdk';
import { queryOptions } from '@tanstack/svelte-query';
export const getPublicConfigQuery = () =>
queryOptions({
queryKey: ['config', 'public'],
queryFn: ({ signal }) => getPublicConfig({ signal }),
});
+27
View File
@@ -0,0 +1,27 @@
import { getUserConfig, type UserConfigDto } from '@immich/sdk';
import { queryOptions } from '@tanstack/svelte-query';
import { cloneDeep } from 'lodash-es';
const withEffectiveFlags = (dto: UserConfigDto) => {
const config = cloneDeep(dto);
const { machineLearning } = config;
if (!machineLearning.enabled) {
machineLearning.clip.enabled = false;
machineLearning.facialRecognition.enabled = false;
machineLearning.ocr.enabled = false;
}
if (!machineLearning.clip.enabled) {
machineLearning.duplicateDetection.enabled = false;
}
return config;
};
export const getUserConfigQuery = () =>
queryOptions({
queryKey: ['config', 'user'],
queryFn: ({ signal }) => getUserConfig({ signal }),
select: withEffectiveFlags,
});
+32
View File
@@ -0,0 +1,32 @@
import { getNotifications, updateNotification, updateNotifications } from '@immich/sdk';
import { queryOptions } from '@tanstack/svelte-query';
import { asMutation, queryClient } from '$lib/queries/client';
export const getNotificationsQuery = () =>
queryOptions({
queryKey: ['notifications'],
queryFn: ({ signal }) => getNotifications({ unread: true }, { signal }),
});
export const updateNotificationMutation = () =>
asMutation(updateNotification, {
onSuccess: (notification) =>
queryClient.setQueryData(getNotificationsQuery().queryKey, (notifications) =>
notifications?.filter((item) => item.id !== notification.id || !notification.readAt),
),
meta: { error: { toast: 'errors.failed_to_update_notification_status' } },
});
export const updateNotificationsMutation = () =>
asMutation(updateNotifications, {
onSuccess: (_, [{ notificationUpdateAllDto }]) =>
queryClient.setQueryData(getNotificationsQuery().queryKey, (notifications) =>
notifications?.filter(
(item) => !notificationUpdateAllDto.ids.includes(item.id) || !notificationUpdateAllDto.readAt,
),
),
meta: {
success: { toast: { color: 'info', description: 'marked_all_as_read' } },
error: { toast: 'errors.failed_to_update_notification_status' },
},
});
+14
View File
@@ -0,0 +1,14 @@
import { searchPluginMethods, searchPluginTemplates, type WorkflowTrigger } from '@immich/sdk';
import { queryOptions } from '@tanstack/svelte-query';
export const searchPluginMethodsQuery = (filter: { trigger?: WorkflowTrigger } = {}) =>
queryOptions({
queryKey: ['plugins', 'methods', filter],
queryFn: ({ signal }) => searchPluginMethods(filter, { signal }),
});
export const searchPluginTemplatesQuery = () =>
queryOptions({
queryKey: ['plugins', 'templates'],
queryFn: ({ signal }) => searchPluginTemplates({ signal }),
});
+35 -18
View File
@@ -1,22 +1,23 @@
import {
deleteLibrary,
getConfig,
getAdminConfig,
getStorage,
updateConfig,
updateAdminConfig,
updateLibrary,
type AdminConfigDto,
type LibraryResponseDto,
} from '@immich/sdk';
import { eventManager } from '$lib/managers/event-manager.svelte';
import { executeMutation, queryClient } from '$lib/queries/client';
import { getAdminConfigQuery, updateAdminConfigMutation } from '$lib/queries/config-admin.query';
import { getPublicConfigQuery } from '$lib/queries/config-public.query';
import {
deleteLibraryMutation,
getAllLibrariesQuery,
getLibraryStatisticsQuery,
updateLibraryMutation,
} from '$lib/queries/library.query';
import { getStorageQuery } from '$lib/queries/server.query';
import { getConfigQuery, updateConfigMutation } from '$lib/queries/system-config.query';
import { getServerConfigQuery, getStorageQuery } from '$lib/queries/server.query';
import { handleError } from '$lib/utils/handle-error';
vi.mock('@immich/ui', () => ({ toastManager: { primary: vi.fn(), info: vi.fn() } }));
@@ -33,9 +34,12 @@ vi.mock('@immich/sdk', () => ({
getVersionHistory: vi.fn(),
getStorage: vi.fn(),
getServerStatistics: vi.fn(),
getConfig: vi.fn(),
getConfigDefaults: vi.fn(),
updateConfig: vi.fn(),
getServerConfig: vi.fn(),
getPublicConfig: vi.fn(),
getUserConfig: vi.fn(),
getAdminConfig: vi.fn(),
getAdminConfigDefaults: vi.fn(),
updateAdminConfig: vi.fn(),
}));
const first = { id: 'first', name: 'Photos' } as LibraryResponseDto;
@@ -51,11 +55,24 @@ describe('shared queries after the upstream migration', () => {
it('clears account data on logout', () => {
queryClient.setQueryData(getAllLibrariesQuery().queryKey, [first]);
queryClient.setQueryData(getConfigQuery().queryKey, {} as AdminConfigDto);
queryClient.setQueryData(getAdminConfigQuery().queryKey, {} as AdminConfigDto);
eventManager.emit('AuthLogout');
expect(queryClient.getQueryCache().getAll()).toHaveLength(0);
});
it('retains public login configuration while clearing private settings on logout', () => {
queryClient.setQueryData(getPublicConfigQuery().queryKey, { passwordLogin: { enabled: true } } as never);
queryClient.setQueryData(getServerConfigQuery().queryKey, { maintenanceMode: false } as never);
queryClient.setQueryData(getAdminConfigQuery().queryKey, {} as AdminConfigDto);
queryClient.setQueryData(['config', 'user'], { oauth: { accountManagementUrl: 'https://accounts.example.com' } });
eventManager.emit('AuthLogout');
expect(queryClient.getQueryData(getPublicConfigQuery().queryKey)).toEqual({ passwordLogin: { enabled: true } });
expect(queryClient.getQueryData(getServerConfigQuery().queryKey)).toEqual({ maintenanceMode: false });
expect(queryClient.getQueryData(getAdminConfigQuery().queryKey)).toBeUndefined();
expect(queryClient.getQueryData(['config', 'user'])).toBeUndefined();
expect(queryClient.getQueryCache().getAll()).toHaveLength(2);
});
it('shares a storage request between consumers', async () => {
const storage = { diskSizeRaw: 100, diskUseRaw: 20 } as Awaited<ReturnType<typeof getStorage>>;
vi.mocked(getStorage).mockResolvedValue(storage);
@@ -112,20 +129,20 @@ describe('shared queries after the upstream migration', () => {
const current = { server: { externalDomain: 'https://example.com' }, trash: { enabled: true, days: 30 } };
const update = { trash: { enabled: true, days: 60 } };
const saved = { ...current, ...update } as AdminConfigDto;
vi.mocked(getConfig).mockResolvedValue(current as AdminConfigDto);
vi.mocked(updateConfig).mockResolvedValue(saved);
await executeMutation(updateConfigMutation, update);
expect(updateConfig).toHaveBeenCalledWith({ adminConfigDto: saved });
expect(queryClient.getQueryData(getConfigQuery().queryKey)).toEqual(saved);
vi.mocked(getAdminConfig).mockResolvedValue(current as AdminConfigDto);
vi.mocked(updateAdminConfig).mockResolvedValue(saved);
await executeMutation(updateAdminConfigMutation, update);
expect(updateAdminConfig).toHaveBeenCalledWith({ adminConfigDto: saved });
expect(queryClient.getQueryData(getAdminConfigQuery().queryKey)).toEqual(saved);
});
it('keeps the previous settings if saving fails', async () => {
const current = { trash: { enabled: true, days: 30 } } as AdminConfigDto;
queryClient.setQueryData(getConfigQuery().queryKey, current);
vi.mocked(getConfig).mockResolvedValue(current);
vi.mocked(updateConfig).mockRejectedValue(new Error('offline'));
await executeMutation(updateConfigMutation, { trash: { enabled: true, days: 60 } });
expect(queryClient.getQueryData(getConfigQuery().queryKey)).toEqual(current);
queryClient.setQueryData(getAdminConfigQuery().queryKey, current);
vi.mocked(getAdminConfig).mockResolvedValue(current);
vi.mocked(updateAdminConfig).mockRejectedValue(new Error('offline'));
await executeMutation(updateAdminConfigMutation, { trash: { enabled: true, days: 60 } });
expect(queryClient.getQueryData(getAdminConfigQuery().queryKey)).toEqual(current);
expect(handleError).toHaveBeenCalledOnce();
});
});
+7 -1
View File
@@ -1,4 +1,4 @@
import { getAboutInfo, getServerStatistics, getStorage, getVersionHistory } from '@immich/sdk';
import { getAboutInfo, getServerConfig, getServerStatistics, getStorage, getVersionHistory } from '@immich/sdk';
import { queryOptions } from '@tanstack/svelte-query';
export const getAboutInfoQuery = () =>
@@ -19,6 +19,12 @@ export const getStorageQuery = () =>
queryFn: ({ signal }) => getStorage({ signal }),
});
export const getServerConfigQuery = () =>
queryOptions({
queryKey: ['server', 'config'],
queryFn: ({ signal }) => getServerConfig({ signal }),
});
export const getServerStatisticsQuery = () =>
queryOptions({
queryKey: ['server', 'statistics'],
+72
View File
@@ -0,0 +1,72 @@
import {
createSharedLink,
getAllSharedLinks,
removeSharedLink,
removeSharedLinkAssets,
updateSharedLink,
type SharedLinkResponseDto,
} from '@immich/sdk';
import { queryOptions } from '@tanstack/svelte-query';
import { getAlbumInfoQuery } from '$lib/queries/album.query';
import { asMutation, queryClient } from '$lib/queries/client';
const findOrFail = (sharedLinks: SharedLinkResponseDto[], id: string) => {
const sharedLink = sharedLinks.find((sharedLink) => sharedLink.id === id);
if (!sharedLink) {
throw new Error(`Shared link ${id} not found`);
}
return sharedLink;
};
export const getAllSharedLinksQuery = ({ albumId }: { albumId?: string } = {}) =>
queryOptions({
queryKey: ['shared-links'],
queryFn: ({ signal }) => getAllSharedLinks({}, { signal }),
select: (sharedLinks) => (albumId ? sharedLinks.filter(({ album }) => album?.id === albumId) : sharedLinks),
});
export const getSharedLinkQuery = (id: string) =>
queryOptions({
...getAllSharedLinksQuery(),
select: (sharedLinks) => findOrFail(sharedLinks, id),
});
const invalidateAlbum = (albumId?: string | null) =>
albumId ? queryClient.invalidateQueries({ queryKey: getAlbumInfoQuery(albumId).queryKey }) : undefined;
export const createSharedLinkMutation = () =>
asMutation(createSharedLink, {
onSuccess: async (_, [{ sharedLinkCreateDto }]) => {
await Promise.all([
queryClient.invalidateQueries({ queryKey: getAllSharedLinksQuery().queryKey, exact: true }),
invalidateAlbum(sharedLinkCreateDto.albumId),
]);
},
meta: { error: { toast: 'errors.failed_to_create_shared_link' } },
});
export const updateSharedLinkMutation = () =>
asMutation(updateSharedLink, {
onSuccess: () => queryClient.invalidateQueries({ queryKey: getAllSharedLinksQuery().queryKey, exact: true }),
meta: { success: { toast: 'saved' }, error: { toast: 'errors.failed_to_edit_shared_link' } },
});
export const removeSharedLinkMutation = () =>
asMutation(removeSharedLink, {
onSuccess: async (_, [{ id }]) => {
const albumId = queryClient
.getQueryData(getAllSharedLinksQuery().queryKey)
?.find((sharedLink) => sharedLink.id === id)?.album?.id;
queryClient.setQueryData(getAllSharedLinksQuery().queryKey, (sharedLinks) =>
sharedLinks?.filter((sharedLink) => sharedLink.id !== id),
);
await invalidateAlbum(albumId);
},
meta: { success: { toast: 'deleted_shared_link' }, error: { toast: 'errors.unable_to_delete_shared_link' } },
});
export const removeSharedLinkAssetsMutation = () =>
asMutation(removeSharedLinkAssets, {
onSuccess: () => queryClient.invalidateQueries({ queryKey: getAllSharedLinksQuery().queryKey, exact: true }),
meta: { error: { toast: 'errors.unable_to_remove_assets_from_shared_link' } },
});
@@ -1,45 +0,0 @@
import { getConfig, getConfigDefaults, updateConfig, type AdminConfigDto } from '@immich/sdk';
import { toastManager } from '@immich/ui';
import { mutationOptions, queryOptions } from '@tanstack/svelte-query';
import { isEqual } from 'lodash-es';
import { eventManager } from '$lib/managers/event-manager.svelte';
import { queryClient } from '$lib/queries/client';
import { getFormatter } from '$lib/utils/i18n';
export const getConfigQuery = () =>
queryOptions({
queryKey: ['system-config'],
queryFn: ({ signal }) => getConfig({ signal }),
});
export const getConfigDefaultsQuery = () =>
queryOptions({
queryKey: ['system-config', 'defaults'],
queryFn: ({ signal }) => getConfigDefaults({ signal }),
});
export const updateConfigMutation = () =>
mutationOptions({
mutationFn: async (update: Partial<AdminConfigDto>) => {
const config = await getConfig();
const adminConfigDto = { ...config, ...update };
if (isEqual(config, adminConfigDto)) {
return;
}
return updateConfig({ adminConfigDto });
},
onSuccess: async (config) => {
if (!config) {
return;
}
queryClient.setQueryData(getConfigQuery().queryKey, config);
eventManager.emit('SystemConfigUpdate', config);
const $t = await getFormatter();
toastManager.primary($t('settings_saved'));
},
meta: { error: { toast: 'errors.unable_to_save_settings' } },
});
+27
View File
@@ -0,0 +1,27 @@
import { getAssetsByOriginalPath, getUniqueOriginalPaths } from '@immich/sdk';
import { queryOptions } from '@tanstack/svelte-query';
import { TreeNode } from '$lib/utils/tree-utils';
const toTree = (paths: string[]) => {
const tree = TreeNode.fromPaths(paths);
tree.collapse();
return tree;
};
export const getUniqueOriginalPathsQuery = () =>
queryOptions({
queryKey: ['view', 'folder', 'unique-paths'],
queryFn: ({ signal }) => getUniqueOriginalPaths({ signal }),
});
export const getFolderTreeQuery = () =>
queryOptions({
...getUniqueOriginalPathsQuery(),
select: toTree,
});
export const getAssetsByOriginalPathQuery = (path: string) =>
queryOptions({
queryKey: ['view', 'folder', 'assets', path],
queryFn: ({ signal }) => getAssetsByOriginalPath({ path }, { signal }),
});
+73
View File
@@ -0,0 +1,73 @@
import {
createWorkflow,
deleteWorkflow,
getWorkflowForShare,
getWorkflowTriggers,
searchWorkflows,
updateWorkflow,
type WorkflowResponseDto,
} from '@immich/sdk';
import { queryOptions } from '@tanstack/svelte-query';
import { asMutation, queryClient } from '$lib/queries/client';
const findOrFail = (workflows: WorkflowResponseDto[], id: string) => {
const workflow = workflows.find((workflow) => workflow.id === id);
if (!workflow) {
throw new Error(`Workflow ${id} not found`);
}
return workflow;
};
export const searchWorkflowsQuery = () =>
queryOptions({
queryKey: ['workflows'],
queryFn: ({ signal }) => searchWorkflows({}, { signal }),
});
export const getWorkflowQuery = (id: string) =>
queryOptions({
...searchWorkflowsQuery(),
select: (workflows) => findOrFail(workflows, id),
});
export const getWorkflowForShareQuery = (id: string) =>
queryOptions({
queryKey: ['workflows', id, 'share'],
queryFn: ({ signal }) => getWorkflowForShare({ id }, { signal }),
staleTime: 0,
});
export const getWorkflowTriggersQuery = () =>
queryOptions({
queryKey: ['workflows', 'triggers'],
queryFn: ({ signal }) => getWorkflowTriggers({ signal }),
});
export const createWorkflowMutation = () =>
asMutation(createWorkflow, {
onSuccess: (workflow) =>
queryClient.setQueryData(searchWorkflowsQuery().queryKey, (workflows) =>
workflows ? [...workflows, workflow] : workflows,
),
meta: { success: { toast: true }, error: { toast: 'errors.unable_to_create' } },
});
export const updateWorkflowMutation = () =>
asMutation(updateWorkflow, {
onSuccess: (workflow) =>
queryClient.setQueryData(searchWorkflowsQuery().queryKey, (workflows) =>
workflows?.map((item) => (item.id === workflow.id ? workflow : item)),
),
meta: { success: { toast: 'workflow_update_success' }, error: { toast: 'errors.unable_to_update_workflow' } },
});
export const deleteWorkflowMutation = () =>
asMutation(deleteWorkflow, {
onSuccess: (_, [{ id }]) => {
queryClient.setQueryData(searchWorkflowsQuery().queryKey, (workflows) =>
workflows?.filter((workflow) => workflow.id !== id),
);
queryClient.removeQueries({ queryKey: getWorkflowForShareQuery(id).queryKey });
},
meta: { success: { toast: 'workflow_deleted' }, error: { toast: 'errors.unable_to_delete_workflow' } },
});
+12 -7
View File
@@ -1,7 +1,9 @@
import { getAssetInfo } from '@immich/sdk';
import { getAssetInfo, type UserConfigDto } from '@immich/sdk';
import { toastManager } from '@immich/ui';
import { vitest } from 'vitest';
import { authManager } from '$lib/managers/auth-manager.svelte';
import { queryClient } from '$lib/queries/client';
import { getUserConfigQuery } from '$lib/queries/config-user.query';
import { getAssetActions, handleDownloadAsset } from '$lib/services/asset.service';
import { setSharedLink } from '$lib/utils';
import { getFormatter } from '$lib/utils/i18n';
@@ -31,16 +33,19 @@ vitest.mock('$lib/utils', async () => {
};
});
vi.mock(import('$lib/managers/feature-flags-manager.svelte'), function () {
return {
featureFlagsManager: { init: vi.fn(), loadFeatureFlags: vi.fn(), value: {} } as never,
};
});
describe('AssetService', () => {
describe('getAssetActions', () => {
beforeEach(() => {
authManager.setPreferences(preferencesFactory.build());
queryClient.setQueryData(getUserConfigQuery().queryKey, {
machineLearning: {
enabled: false,
clip: { enabled: false },
duplicateDetection: { enabled: false },
facialRecognition: { enabled: false },
ocr: { enabled: false },
},
} as UserConfigDto);
});
it('should allow shared link downloads if the user owns the asset and shared link downloads are disabled', () => {
+3 -2
View File
@@ -48,11 +48,12 @@ import { assetMultiSelectManager } from '$lib/managers/asset-multi-select-manage
import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte';
import { authManager } from '$lib/managers/auth-manager.svelte';
import { eventManager } from '$lib/managers/event-manager.svelte';
import { featureFlagsManager } from '$lib/managers/feature-flags-manager.svelte';
import AssetAddToAlbumModal from '$lib/modals/AssetAddToAlbumModal.svelte';
import AssetTagModal from '$lib/modals/AssetTagModal.svelte';
import ProfileImageCropperModal from '$lib/modals/ProfileImageCropperModal.svelte';
import SharedLinkCreateModal from '$lib/modals/SharedLinkCreateModal.svelte';
import { readPreloadedQuery } from '$lib/queries/client';
import { getUserConfigQuery } from '$lib/queries/config-user.query';
import { Route } from '$lib/route';
import { SlideshowState, slideshowStore } from '$lib/stores/slideshow.store';
import { getAssetMediaUrl, getSharedLink, sleep } from '$lib/utils';
@@ -149,7 +150,7 @@ export const getAssetActions = (
const authUser = authManager.authenticated ? authManager.user : undefined;
const isOwner = !!(authUser && authUser.id === asset.ownerId);
const isAlbumOwner = !!(authUser && authUser.id === album?.albumUsers[0].user.id);
const smartSearchEnabled = featureFlagsManager.value.smartSearch;
const smartSearchEnabled = !!authUser && readPreloadedQuery(getUserConfigQuery()).machineLearning.clip.enabled;
const Share: ActionItem = {
title: $t('share'),
@@ -1,13 +1,9 @@
import type { ServerConfigDto } from '@immich/sdk';
import { asUrl } from '$lib/services/shared-link.service';
import { sharedLinkFactory } from '@test-data/factories/shared-link-factory';
vi.mock(import('$lib/managers/server-config-manager.svelte'), () => ({
serverConfigManager: {
value: { externalDomain: 'http://localhost:2283' } as ServerConfigDto,
init: vi.fn(),
loadServerConfig: vi.fn(),
},
vi.mock(import('$lib/queries/client'), async (importOriginal) => ({
...(await importOriginal()),
readPreloadedQuery: vi.fn().mockReturnValue({ server: { externalDomain: 'http://localhost:2283' } }),
}));
describe('SharedLinkService', () => {
+10 -97
View File
@@ -1,23 +1,14 @@
import {
createSharedLink,
getSharedLinkById,
removeSharedLink,
removeSharedLinkAssets,
updateSharedLink,
type SharedLinkCreateDto,
type SharedLinkEditDto,
type SharedLinkResponseDto,
} from '@immich/sdk';
import { modalManager, toastManager, type ActionItem } from '@immich/ui';
import { type SharedLinkResponseDto } from '@immich/sdk';
import { modalManager, type ActionItem } from '@immich/ui';
import { mdiContentCopy, mdiLink, mdiPencilOutline, mdiQrcode, mdiTrashCanOutline } from '@mdi/js';
import type { MessageFormatter } from 'svelte-i18n';
import { goto } from '$app/navigation';
import { eventManager } from '$lib/managers/event-manager.svelte';
import { serverConfigManager } from '$lib/managers/server-config-manager.svelte';
import QrCodeModal from '$lib/modals/QrCodeModal.svelte';
import { executeMutation, readPreloadedQuery } from '$lib/queries/client';
import { getUserConfigQuery } from '$lib/queries/config-user.query';
import { removeSharedLinkMutation } from '$lib/queries/shared-link.query';
import { Route } from '$lib/route';
import { copyToClipboard } from '$lib/utils';
import { handleError } from '$lib/utils/handle-error';
import { getFormatter } from '$lib/utils/i18n';
export const getSharedLinksActions = ($t: MessageFormatter) => {
@@ -53,7 +44,7 @@ export const getSharedLinkActions = ($t: MessageFormatter, sharedLink: SharedLin
const ViewQrCode: ActionItem = {
title: $t('view_qr_code'),
icon: mdiQrcode,
onAction: () => handleShowSharedLinkQrCode(sharedLink),
onAction: () => showSharedLinkQrCode(sharedLink),
};
return { Edit, Delete, Copy, ViewQrCode };
@@ -61,45 +52,12 @@ export const getSharedLinkActions = ($t: MessageFormatter, sharedLink: SharedLin
export const asUrl = (sharedLink: SharedLinkResponseDto) => {
const path = Route.viewSharedLink(sharedLink);
return new URL(path, serverConfigManager.value.externalDomain || location.origin).href;
return new URL(path, readPreloadedQuery(getUserConfigQuery()).server.externalDomain || location.origin).href;
};
export const handleCreateSharedLink = async (dto: SharedLinkCreateDto) => {
export const showSharedLinkQrCode = async (sharedLink: SharedLinkResponseDto) => {
const $t = await getFormatter();
try {
let sharedLink = await createSharedLink({ sharedLinkCreateDto: dto });
if (dto.albumId) {
// fetch album details, for event
sharedLink = await getSharedLinkById({ id: sharedLink.id });
}
eventManager.emit('SharedLinkCreate', sharedLink);
// prevent nested modal
void handleShowSharedLinkQrCode(sharedLink);
return true;
} catch (error) {
handleError(error, $t('errors.failed_to_create_shared_link'));
return false;
}
};
export const handleUpdateSharedLink = async (sharedLink: SharedLinkResponseDto, dto: SharedLinkEditDto) => {
const $t = await getFormatter();
try {
const response = await updateSharedLink({ id: sharedLink.id, sharedLinkEditDto: dto });
eventManager.emit('SharedLinkUpdate', { album: sharedLink.album, ...response });
toastManager.primary($t('saved'));
return true;
} catch (error) {
handleError(error, $t('errors.failed_to_edit_shared_link'));
return false;
}
await modalManager.show(QrCodeModal, { title: $t('view_link'), value: asUrl(sharedLink) });
};
const handleDeleteSharedLink = async (sharedLink: SharedLinkResponseDto) => {
@@ -113,50 +71,5 @@ const handleDeleteSharedLink = async (sharedLink: SharedLinkResponseDto) => {
return;
}
try {
await removeSharedLink({ id: sharedLink.id });
eventManager.emit('SharedLinkDelete', sharedLink);
toastManager.primary($t('deleted_shared_link'));
} catch (error) {
handleError(error, $t('errors.unable_to_delete_shared_link'));
}
};
export const handleRemoveSharedLinkAssets = async (sharedLink: SharedLinkResponseDto, assetIds: string[]) => {
const $t = await getFormatter();
const success = await modalManager.showDialog({
title: $t('remove_assets_title'),
prompt: $t('remove_assets_shared_link_confirmation', { values: { count: assetIds.length } }),
confirmText: $t('remove'),
});
if (!success) {
return false;
}
try {
const results = await removeSharedLinkAssets({
id: sharedLink.id,
assetIdsDto: { assetIds },
});
for (const result of results) {
if (!result.success) {
continue;
}
sharedLink.assets = sharedLink.assets.filter((asset) => asset.id !== result.assetId);
}
const count = results.filter((item) => item.success).length;
toastManager.primary($t('assets_removed_count', { values: { count } }));
return true;
} catch (error) {
handleError(error, $t('errors.unable_to_remove_assets_from_shared_link'));
return false;
}
};
const handleShowSharedLinkQrCode = async (sharedLink: SharedLinkResponseDto) => {
const $t = await getFormatter();
await modalManager.show(QrCodeModal, { title: $t('view_link'), value: asUrl(sharedLink) });
await executeMutation(removeSharedLinkMutation, [{ id: sharedLink.id }]);
};
@@ -3,7 +3,7 @@ import type { ActionItem } from '@immich/ui';
import { mdiContentCopy, mdiDownload, mdiUpload } from '@mdi/js';
import type { MessageFormatter } from 'svelte-i18n';
import { executeMutation } from '$lib/queries/client';
import { updateConfigMutation } from '$lib/queries/system-config.query';
import { updateAdminConfigMutation } from '$lib/queries/config-admin.query';
import { copyToClipboard, downloadJson } from '$lib/utils';
export const getSystemConfigActions = (
@@ -55,7 +55,7 @@ const handleUploadConfig = () => {
}
const reader = async () => {
const text = await file.text();
await executeMutation(updateConfigMutation, JSON.parse(text));
await executeMutation(updateAdminConfigMutation, JSON.parse(text));
};
reader()
.catch((error) => console.error('Error handling JSON config upload', error))
@@ -5,13 +5,13 @@ import { getUserAdminActions } from '$lib/services/user-admin.service';
vi.mock('$app/navigation', () => ({ goto: vi.fn() }));
vi.mock('@immich/ui', () => ({ modalManager: { show: vi.fn(), showDialog: vi.fn() } }));
vi.mock('$lib/managers/auth-manager.svelte', () => ({ authManager: { user: { id: 'self' } } }));
vi.mock('$lib/managers/server-config-manager.svelte', () => ({
serverConfigManager: { value: { userDeleteDelay: 7 } },
}));
vi.mock('$lib/modals/PasswordResetSuccessModal.svelte', () => ({ default: {} }));
vi.mock('$lib/modals/UserDeleteConfirmModal.svelte', () => ({ default: {} }));
vi.mock('$lib/modals/UserRestoreConfirmModal.svelte', () => ({ default: {} }));
vi.mock('$lib/queries/client', () => ({ executeMutation: vi.fn() }));
vi.mock('$lib/queries/client', () => ({
executeMutation: vi.fn(),
readPreloadedQuery: () => ({ user: { deleteDelay: 7 } }),
}));
vi.mock('$lib/queries/user-admin.query', () => ({ updateUserAdminMutation: vi.fn() }));
vi.mock('$lib/route', () => ({ Route: { newUser: vi.fn(), viewUser: vi.fn(), editUser: vi.fn() } }));
vi.mock('$lib/utils/i18n', () => ({ getFormatter: vi.fn() }));
+9 -5
View File
@@ -13,11 +13,11 @@ import { DateTime } from 'luxon';
import type { MessageFormatter } from 'svelte-i18n';
import { goto } from '$app/navigation';
import { authManager } from '$lib/managers/auth-manager.svelte';
import { serverConfigManager } from '$lib/managers/server-config-manager.svelte';
import PasswordResetSuccessModal from '$lib/modals/PasswordResetSuccessModal.svelte';
import UserDeleteConfirmModal from '$lib/modals/UserDeleteConfirmModal.svelte';
import UserRestoreConfirmModal from '$lib/modals/UserRestoreConfirmModal.svelte';
import { executeMutation } from '$lib/queries/client';
import { executeMutation, readPreloadedQuery } from '$lib/queries/client';
import { getUserConfigQuery } from '$lib/queries/config-user.query';
import { updateUserAdminMutation } from '$lib/queries/user-admin.query';
import { Route } from '$lib/route';
import type { HeaderButtonActionItem } from '$lib/types';
@@ -58,8 +58,10 @@ export const getUserAdminActions = ($t: MessageFormatter, user: UserAdminRespons
shortcutOptions: { ignoreInputFields: true },
};
const getDeleteDate = (deletedAt: string): Date =>
DateTime.fromISO(deletedAt).plus({ days: serverConfigManager.value.userDeleteDelay }).toJSDate();
const getDeleteDate = (deletedAt: string): Date => {
const userConfig = readPreloadedQuery(getUserConfigQuery());
return DateTime.fromISO(deletedAt).plus({ days: userConfig.user.deleteDelay }).toJSDate();
};
const Restore: HeaderButtonActionItem = {
icon: mdiDeleteRestore,
@@ -96,7 +98,9 @@ const generatePassword = (length: number = 16) => {
const characterSet = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ,.-{}+!#$%/()=?';
for (let i = 0; i < length; i++) {
let randomNumber = crypto.getRandomValues(new Uint32Array(1))[0];
const buffer = new Uint32Array(1);
crypto.getRandomValues(buffer);
let randomNumber = buffer[0];
randomNumber /= 2 ** 32;
randomNumber = Math.floor(randomNumber * characterSet.length);
+20 -55
View File
@@ -1,13 +1,5 @@
import {
createWorkflow,
deleteWorkflow,
updateWorkflow,
WorkflowTrigger,
type WorkflowCreateDto,
type WorkflowResponseDto,
type WorkflowUpdateDto,
} from '@immich/sdk';
import { modalManager, toastManager, type ActionItem } from '@immich/ui';
import { WorkflowTrigger, type WorkflowResponseDto } from '@immich/sdk';
import { modalManager, type ActionItem } from '@immich/ui';
import {
mdiCodeJson,
mdiContentCopy,
@@ -23,13 +15,14 @@ import {
} from '@mdi/js';
import type { MessageFormatter } from 'svelte-i18n';
import { goto } from '$app/navigation';
import { eventManager } from '$lib/managers/event-manager.svelte';
import { page } from '$app/state';
import WorkflowDuplicateModal from '$lib/modals/WorkflowDuplicateModal.svelte';
import WorkflowLogsModal from '$lib/modals/WorkflowLogsModal.svelte';
import WorkflowTemplatePickerModal from '$lib/modals/WorkflowTemplatePickerModal.svelte';
import { executeMutation } from '$lib/queries/client';
import { createWorkflowMutation, deleteWorkflowMutation, updateWorkflowMutation } from '$lib/queries/workflow.query';
import { Route } from '$lib/route';
import { copyToClipboard, downloadJson } from '$lib/utils';
import { handleError } from '$lib/utils/handle-error';
import { getFormatter } from '$lib/utils/i18n';
export const getWorkflowsActions = ($t: MessageFormatter) => {
@@ -37,11 +30,11 @@ export const getWorkflowsActions = ($t: MessageFormatter) => {
title: $t('create_workflow'),
icon: mdiPlus,
onAction: () =>
handleCreateWorkflow({
trigger: WorkflowTrigger.AssetCreate,
steps: [],
enabled: false,
}),
executeMutation(
createWorkflowMutation,
[{ workflowCreateDto: { trigger: WorkflowTrigger.AssetCreate, steps: [], enabled: false } }],
{ onSuccess: (workflow) => void goto(Route.viewWorkflow(workflow)) },
),
};
const UseTemplate: ActionItem = {
@@ -57,7 +50,8 @@ export const getWorkflowActions = ($t: MessageFormatter, workflow: WorkflowRespo
const ToggleEnabled: ActionItem = {
title: workflow.enabled ? $t('disable') : $t('enable'),
icon: workflow.enabled ? mdiPause : mdiPlay,
onAction: () => handleUpdateWorkflow(workflow.id, { enabled: !workflow.enabled }),
onAction: () =>
executeMutation(updateWorkflowMutation, [{ id: workflow.id, workflowUpdateDto: { enabled: !workflow.enabled } }]),
};
const CopyJson: ActionItem = {
@@ -133,34 +127,7 @@ export const getWorkflowShowSchemaAction = (
onAction: onToggle,
});
export const handleCreateWorkflow = async (dto: WorkflowCreateDto) => {
const $t = await getFormatter();
try {
const response = await createWorkflow({ workflowCreateDto: dto });
eventManager.emit('WorkflowCreate', response);
toastManager.success();
return response;
} catch (error) {
handleError(error, $t('errors.unable_to_create'));
}
};
export const handleUpdateWorkflow = async (id: string, dto: WorkflowUpdateDto) => {
const $t = await getFormatter();
try {
const response = await updateWorkflow({ id, workflowUpdateDto: dto });
eventManager.emit('WorkflowUpdate', response);
toastManager.primary($t('workflow_update_success'), { closable: true });
return true;
} catch (error) {
handleError(error, $t('errors.unable_to_update_workflow'));
return false;
}
};
export const handleDeleteWorkflow = async (workflow: WorkflowResponseDto): Promise<boolean> => {
const handleDeleteWorkflow = async (workflow: WorkflowResponseDto) => {
const $t = await getFormatter();
const confirmed = await modalManager.showDialog({
@@ -169,16 +136,14 @@ export const handleDeleteWorkflow = async (workflow: WorkflowResponseDto): Promi
});
if (!confirmed) {
return false;
return;
}
try {
await deleteWorkflow({ id: workflow.id });
eventManager.emit('WorkflowDelete', workflow);
toastManager.primary($t('workflow_deleted'));
return true;
} catch (error) {
handleError(error, $t('errors.unable_to_delete_workflow'));
return false;
await executeMutation(deleteWorkflowMutation, [{ id: workflow.id }], {
onSuccess: () => {
if (page.params.workflowId === workflow.id) {
void goto(Route.workflows());
}
},
});
};
-42
View File
@@ -1,42 +0,0 @@
import { getUniqueOriginalPaths } from '@immich/sdk';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { foldersStore } from '$lib/stores/folders.svelte';
vi.mock('$lib/managers/event-manager.svelte', () => ({
eventManager: {
on: vi.fn(),
},
}));
vi.mock('@immich/sdk', () => ({
getAssetsByOriginalPath: vi.fn(),
getUniqueOriginalPaths: vi.fn(),
}));
describe('foldersStore', () => {
beforeEach(() => {
foldersStore.clearCache();
vi.clearAllMocks();
});
it('returns the same non-null tree for concurrent fetchTree calls', async () => {
let resolvePaths: (value: string[]) => void;
vi.mocked(getUniqueOriginalPaths).mockReturnValue(
new Promise<string[]>((resolve) => {
resolvePaths = resolve;
}),
);
const first = foldersStore.fetchTree();
const second = foldersStore.fetchTree();
resolvePaths!(['/photos/2026']);
const [firstTree, secondTree] = await Promise.all([first, second]);
expect(firstTree).not.toBeNull();
expect(secondTree).not.toBeNull();
expect(secondTree).toEqual(firstTree);
});
});
-56
View File
@@ -1,56 +0,0 @@
import {
getAssetsByOriginalPath,
getUniqueOriginalPaths,
/**
* TODO: Incorrect type
*/
type AssetResponseDto,
} from '@immich/sdk';
import { eventManager } from '$lib/managers/event-manager.svelte';
import { TreeNode } from '$lib/utils/tree-utils';
type AssetCache = {
[path: string]: AssetResponseDto[];
};
class FoldersStore {
folders = $state.raw<TreeNode | null>(null);
private initialized = false;
private assets = $state<AssetCache>({});
constructor() {
eventManager.on({
AuthLogout: () => this.clearCache(),
});
}
async fetchTree(): Promise<TreeNode> {
if (this.initialized) {
return this.folders!;
}
this.folders = TreeNode.fromPaths(await getUniqueOriginalPaths());
this.folders.collapse();
this.initialized = true;
return this.folders;
}
bustAssetCache() {
this.assets = {};
}
async refreshAssetsByPath(path: string) {
return (this.assets[path] = await getAssetsByOriginalPath({ path }));
}
async fetchAssetsByPath(path: string) {
return (this.assets[path] ??= await getAssetsByOriginalPath({ path }));
}
clearCache() {
this.initialized = false;
this.assets = {};
this.folders = null;
}
}
export const foldersStore = new FoldersStore();
@@ -1,42 +0,0 @@
import { getNotifications, updateNotification, updateNotifications, type NotificationDto } from '@immich/sdk';
import { t } from 'svelte-i18n';
import { get } from 'svelte/store';
import { eventManager } from '$lib/managers/event-manager.svelte';
import { handleError } from '$lib/utils/handle-error';
class NotificationStore {
notifications = $state<NotificationDto[]>([]);
constructor() {
eventManager.on({
AuthLogin: () => this.refresh(),
AuthLogout: () => this.clear(),
});
}
async refresh() {
try {
this.notifications = await getNotifications({ unread: true });
} catch (error) {
const translate = get(t);
handleError(error, translate('errors.failed_to_load_notifications'));
}
}
markAsRead = async (id: string) => {
this.notifications = this.notifications.filter((notification) => notification.id !== id);
await updateNotification({ id, notificationUpdateDto: { readAt: new Date().toISOString() } });
};
markAllAsRead = async () => {
const ids = this.notifications.map(({ id }) => id);
this.notifications = [];
await updateNotifications({ notificationUpdateAllDto: { ids, readAt: new Date().toISOString() } });
};
clear = () => {
this.notifications = [];
};
}
export const notificationManager = new NotificationStore();
+7 -7
View File
@@ -15,11 +15,11 @@ import { page } from '$app/state';
import { authManager } from '$lib/managers/auth-manager.svelte';
import { eventManager } from '$lib/managers/event-manager.svelte';
import { queryClient } from '$lib/queries/client';
import { getConfigQuery } from '$lib/queries/system-config.query';
import { getNotificationsQuery } from '$lib/queries/notification.query';
import { getServerConfigQuery } from '$lib/queries/server.query';
import { searchUsersAdminQuery } from '$lib/queries/user-admin.query';
import { Route } from '$lib/route';
import { maintenanceStore } from '$lib/stores/maintenance.store';
import { notificationManager } from '$lib/stores/notification-manager.svelte';
import { createEventEmitter } from '$lib/utils/eventemitter';
interface AppRestartEvent {
@@ -78,10 +78,10 @@ websocket
void queryClient.invalidateQueries({ queryKey: ['server'] });
}
})
.on(
'on_config_update',
() => void queryClient.invalidateQueries({ queryKey: getConfigQuery().queryKey, exact: true }),
)
.on('on_config_update', () => {
void queryClient.invalidateQueries({ queryKey: ['config'] });
void queryClient.invalidateQueries({ queryKey: getServerConfigQuery().queryKey });
})
.on('AppRestartV1', (mode) => websocketStore.serverRestarting.set(mode))
.on('MaintenanceStatusV1', (status) => {
maintenanceStore.status.set(status);
@@ -103,7 +103,7 @@ websocket
.on('on_asset_trash', (assets) => eventManager.emit('AssetsDelete', assets))
.on('on_asset_update', (asset) => eventManager.emit('AssetUpdate', asset))
.on('on_person_thumbnail', (id) => eventManager.emit('PersonThumbnailReady', { id }))
.on('on_notification', () => notificationManager.refresh())
.on('on_notification', () => void queryClient.invalidateQueries({ queryKey: getNotificationsQuery().queryKey }))
.on('connect_error', (e) => console.log('Websocket Connect Error', e));
export const openWebsocketConnection = () => {
+6 -3
View File
@@ -2,8 +2,8 @@ import { setServerLicense, setUserLicense, type LicenseResponseDto } from '@immi
import { PUBLIC_IMMICH_BUY_HOST, PUBLIC_IMMICH_PAY_HOST } from '$env/static/public';
import type { ImmichProduct } from '$lib/constants';
import { authManager } from '$lib/managers/auth-manager.svelte';
import { serverConfigManager } from '$lib/managers/server-config-manager.svelte';
import { queryClient } from '$lib/queries/client';
import { queryClient, readPreloadedQuery } from '$lib/queries/client';
import { getUserConfigQuery } from '$lib/queries/config-user.query';
import { getAboutInfoQuery } from '$lib/queries/server.query';
export const activateProduct = async (licenseKey: string, activationKey: string): Promise<LicenseResponseDto> => {
@@ -34,6 +34,9 @@ export const getActivationKey = async (licenseKey: string): Promise<string> => {
export const getLicenseLink = (license: ImmichProduct) => {
const url = new URL('/', PUBLIC_IMMICH_BUY_HOST);
url.searchParams.append('productId', license);
url.searchParams.append('instanceUrl', serverConfigManager.value.externalDomain || globalThis.origin);
url.searchParams.append(
'instanceUrl',
readPreloadedQuery(getUserConfigQuery()).server.externalDomain || globalThis.origin,
);
return url.href;
};
+6 -4
View File
@@ -2,7 +2,9 @@ import { setFetch } from '@immich/sdk';
import { memoize } from 'lodash-es';
import { authManager } from '$lib/managers/auth-manager.svelte';
import { featureFlagsManager } from '$lib/managers/feature-flags-manager.svelte';
import { serverConfigManager } from '$lib/managers/server-config-manager.svelte';
import { queryClient } from '$lib/queries/client';
import { getPublicConfigQuery } from '$lib/queries/config-public.query';
import { getServerConfigQuery } from '$lib/queries/server.query';
import { initLanguage } from '$lib/utils';
type Fetch = typeof fetch;
@@ -13,11 +15,11 @@ async function _init(fetch: Fetch) {
// https://github.com/oazapfts/oazapfts/blob/main/README.md#fetch-options
setFetch(fetch);
await initLanguage();
await serverConfigManager.init();
const serverConfig = await queryClient.query(getServerConfigQuery());
await authManager.load();
if (!serverConfigManager.value.maintenanceMode) {
await featureFlagsManager.init();
if (!serverConfig.maintenanceMode) {
await Promise.all([queryClient.query(getPublicConfigQuery()), featureFlagsManager.init()]);
}
}
@@ -1,6 +1,5 @@
<script lang="ts">
import { goto, invalidate, onNavigate } from '$app/navigation';
import { navigating } from '$app/state';
import { goto, onNavigate } from '$app/navigation';
import { scrollMemoryClearer } from '$lib/actions/scroll-memory';
import AlbumMap from '$lib/components/album-page/AlbumMap.svelte';
import AlbumSummary from '$lib/components/album-page/AlbumSummary.svelte';
@@ -29,7 +28,8 @@
import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte';
import { authManager } from '$lib/managers/auth-manager.svelte';
import { eventManager } from '$lib/managers/event-manager.svelte';
import { featureFlagsManager } from '$lib/managers/feature-flags-manager.svelte';
import { getAlbumInfoQuery } from '$lib/queries/album.query';
import { createPreloadedQuery, queryClient } from '$lib/queries/client';
import { TimelineManager } from '$lib/managers/timeline-manager/timeline-manager.svelte';
import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
import { Route } from '$lib/route';
@@ -63,17 +63,17 @@
import { onDestroy } from 'svelte';
import { t } from 'svelte-i18n';
import { fly } from 'svelte/transition';
import type { PageData } from './$types';
import type { PageProps } from './$types';
import AlbumDescription from './AlbumDescription.svelte';
import AlbumTitle from './AlbumTitle.svelte';
import ActionMenuItem from '$lib/components/ActionMenuItem.svelte';
import { delay } from '$lib/utils/asset-utils';
import { getUserConfigQuery } from '$lib/queries/config-user.query';
interface Props {
data: PageData;
}
const query = createPreloadedQuery(getUserConfigQuery);
const config = $derived(query.data);
let { data = $bindable() }: Props = $props();
const { params }: PageProps = $props();
let { slideshowState, slideshowNavigation } = slideshowStore;
let oldAt: AssetGridRouteSearchParams | null | undefined = $state();
let viewMode: AlbumPageViewMode = $state(AlbumPageViewMode.VIEW);
@@ -127,12 +127,10 @@
if (updated.id !== album.id) {
return;
}
album = updated;
queryClient.setQueryData(getAlbumInfoQuery(album.id).queryKey, updated);
}
// invalidating during navigation causes an infinite page load
await navigating.complete;
await invalidate('album:data');
await queryClient.invalidateQueries({ queryKey: getAlbumInfoQuery(album.id).queryKey });
};
const setModeToView = async () => {
@@ -214,7 +212,8 @@
}
});
let album = $derived(data.album);
const albumQuery = createPreloadedQuery(() => getAlbumInfoQuery(params.albumId));
let album = $derived(albumQuery.data);
const albumId = $derived(album.id);
const albumHasViewers = $derived(album.albumUsers.some(({ role }) => role === AlbumUserRole.Viewer));
@@ -325,8 +324,6 @@
</script>
<OnEvents
onSharedLinkCreate={(link) => refreshAlbum(link.album)}
onSharedLinkDelete={() => refreshAlbum()}
{onAlbumDelete}
{onAlbumAddAssets}
{onAlbumRemoveAssets}
@@ -518,7 +515,7 @@
<ActionButton action={Actions.Share} />
{#if featureFlagsManager.value.map}
{#if config.map.enabled}
<AlbumMap {album} />
{/if}
@@ -1,16 +1,14 @@
import { getAlbumInfo } from '@immich/sdk';
import { getAlbumInfoQuery } from '$lib/queries/album.query';
import { queryClient } from '$lib/queries/client';
import { authenticate } from '$lib/utils/auth';
import type { PageLoad } from './$types';
export const load = (async ({ params, url, depends }) => {
export const load = (async ({ params, url }) => {
await authenticate(url);
depends('album:data');
const album = await getAlbumInfo({ id: params.albumId });
const album = await queryClient.query(getAlbumInfoQuery(params.albumId));
return {
album,
meta: {
title: album.albumName,
},
@@ -23,8 +23,9 @@
import { assetMultiSelectManager } from '$lib/managers/asset-multi-select-manager.svelte';
import type { Viewport } from '$lib/managers/timeline-manager/types';
import { Route } from '$lib/route';
import { createPreloadedQuery, queryClient } from '$lib/queries/client';
import { getAssetsByOriginalPathQuery, getFolderTreeQuery } from '$lib/queries/view.query';
import { getAssetBulkActions } from '$lib/services/asset.service';
import { foldersStore } from '$lib/stores/folders.svelte';
import { toTimelineAsset } from '$lib/utils/timeline-util';
import { joinPaths } from '$lib/utils/tree-utils';
import { ActionButton, CommandPaletteDefaultProvider, IconButton, Text } from '@immich/ui';
@@ -38,6 +39,9 @@
let { data }: Props = $props();
const query = createPreloadedQuery(getFolderTreeQuery);
const root = $derived(query.data);
const viewport: Viewport = $state({ width: 0, height: 0 });
let folderScrollContainer: HTMLElement | undefined = $state();
let savedScrollTop = 0;
@@ -67,7 +71,7 @@
const triggerAssetUpdate = async () => {
assetMultiSelectManager.clear();
if (data.tree.path) {
await foldersStore.refreshAssetsByPath(data.tree.path);
await queryClient.invalidateQueries({ queryKey: getAssetsByOriginalPathQuery(data.tree.path).queryKey });
}
await invalidateAll();
};
@@ -94,7 +98,7 @@
<div class="h-full">
<TreeItems
icons={{ default: mdiFolderOutline, active: mdiFolder }}
tree={foldersStore.folders!}
tree={root}
active={data.tree.path}
getLink={getLinkForPath}
/>
@@ -1,25 +1,23 @@
import { QueryParameter } from '$lib/constants';
import { foldersStore } from '$lib/stores/folders.svelte';
import { queryClient } from '$lib/queries/client';
import { getAssetsByOriginalPathQuery, getFolderTreeQuery } from '$lib/queries/view.query';
import { authenticate } from '$lib/utils/auth';
import { getFormatter } from '$lib/utils/i18n';
import type { PageLoad } from './$types';
export const load = (async ({ url }) => {
await authenticate(url);
const [, $t] = await Promise.all([foldersStore.fetchTree(), getFormatter()]);
const [root, $t] = await Promise.all([queryClient.query(getFolderTreeQuery()), getFormatter()]);
let tree = foldersStore.folders!;
let tree = root;
const path = url.searchParams.get(QueryParameter.PATH);
if (path) {
tree = tree.traverse(path);
} else if (path === null) {
// If no path is provided, we've just navigated to the folders page.
// We should bust the asset cache of the folder store, to make sure we don't show stale data
foldersStore.bustAssetCache();
queryClient.removeQueries({ queryKey: ['view', 'folder', 'assets'] });
}
// only fetch assets if the folder has assets
const pathAssets = tree.hasAssets ? await foldersStore.fetchAssetsByPath(tree.path) : null;
const pathAssets = tree.hasAssets ? await queryClient.query(getAssetsByOriginalPathQuery(tree.path)) : null;
return {
tree,
@@ -6,7 +6,6 @@
import { timeToLoadTheMap } from '$lib/constants';
import Portal from '$lib/elements/Portal.svelte';
import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte';
import { featureFlagsManager } from '$lib/managers/feature-flags-manager.svelte';
import { Route } from '$lib/route';
import { handlePromiseError } from '$lib/utils';
import { delay } from '$lib/utils/asset-utils';
@@ -15,6 +14,11 @@
import { LoadingSpinner } from '@immich/ui';
import { onDestroy } from 'svelte';
import type { PageData } from './$types';
import { createPreloadedQuery } from '$lib/queries/client';
import { getUserConfigQuery } from '$lib/queries/config-user.query';
const query = createPreloadedQuery(getUserConfigQuery);
const config = $derived(query.data);
interface Props {
data: PageData;
@@ -35,9 +39,11 @@
assetViewerManager.showAssetViewer(false);
});
if (!featureFlagsManager.value.map) {
$effect(() => {
if (!config.map.enabled) {
handlePromiseError(goto(Route.photos()));
}
});
async function onViewAssets(assetIds: string[]) {
await assetViewerManager.setAssetId(assetIds[0]);
@@ -53,7 +59,7 @@
}
</script>
{#if featureFlagsManager.value.map}
{#if config.map.enabled}
<UserPageLayout title={data.meta.title}>
<div class="isolate flex size-full flex-col sm:flex-row">
<div
@@ -18,7 +18,6 @@
import AssetSelectControlBar from '$lib/components/timeline/AssetSelectControlBar.svelte';
import { QueryParameter } from '$lib/constants';
import { assetMultiSelectManager } from '$lib/managers/asset-multi-select-manager.svelte';
import { featureFlagsManager } from '$lib/managers/feature-flags-manager.svelte';
import { searchManager } from '$lib/managers/search-manager.svelte';
import type { Viewport } from '$lib/managers/timeline-manager/types';
import { Route } from '$lib/route';
@@ -44,6 +43,11 @@
import { mdiArrowLeft, mdiClose, mdiDotsVertical, mdiImageOffOutline, mdiSelectAll } from '@mdi/js';
import { onMount, tick, untrack } from 'svelte';
import { t } from 'svelte-i18n';
import { createPreloadedQuery } from '$lib/queries/client';
import { getUserConfigQuery } from '$lib/queries/config-user.query';
const query = createPreloadedQuery(getUserConfigQuery);
const config = $derived(query.data);
const viewport: Viewport = $state({ width: 0, height: 0 });
let searchResultsElement: HTMLElement | undefined = $state();
@@ -62,7 +66,6 @@
type SearchTerms = MetadataSearchDto & Pick<SmartSearchDto, 'query' | 'queryAssetId'>;
let searchQuery = $derived(page.url.searchParams.get(QueryParameter.QUERY));
let smartSearchEnabled = $derived(featureFlagsManager.value.smartSearch);
let terms = $derived<SearchTerms>(searchQuery ? JSON.parse(searchQuery) : {});
let searchTermKeys = $derived(getObjectKeys(terms));
@@ -139,7 +142,7 @@
try {
const { albums, assets } =
('query' in searchDto || 'queryAssetId' in searchDto) && smartSearchEnabled
('query' in searchDto || 'queryAssetId' in searchDto) && config.machineLearning.clip.enabled
? await searchSmart({
smartSearchDto: { visibility: AssetVisibility.Timeline, ...searchDto, language: $lang },
})
@@ -2,14 +2,15 @@
import { goto } from '$app/navigation';
import { page } from '$app/state';
import UserPageLayout from '$lib/components/layouts/UserPageLayout.svelte';
import OnEvents from '$lib/components/OnEvents.svelte';
import SharedLinkCard from './SharedLinkCard.svelte';
import { type SharedLinkTab } from '$lib/constants';
import GroupTab from '$lib/elements/GroupTab.svelte';
import { Route } from '$lib/route';
import { getAllSharedLinks, SharedLinkType, type SharedLinkResponseDto } from '@immich/sdk';
import { createPreloadedQuery } from '$lib/queries/client';
import { getAllSharedLinksQuery } from '$lib/queries/shared-link.query';
import { SharedLinkType } from '@immich/sdk';
import { Container } from '@immich/ui';
import { onMount, type Snippet } from 'svelte';
import { type Snippet } from 'svelte';
import { t } from 'svelte-i18n';
import type { LayoutData } from './$types';
@@ -20,15 +21,8 @@
const { children, data }: Props = $props();
let sharedLinks: SharedLinkResponseDto[] = $state([]);
const refresh = async () => {
sharedLinks = await getAllSharedLinks({});
};
onMount(async () => {
await refresh();
});
const query = createPreloadedQuery(getAllSharedLinksQuery);
const sharedLinks = $derived(query.data);
const filterMap: Record<SharedLinkTab, string> = {
all: $t('all'),
@@ -54,21 +48,8 @@
(type === SharedLinkType.Individual && selectedTab === 'individual'),
),
);
const onSharedLinkUpdate = (sharedLink: SharedLinkResponseDto) => {
const index = sharedLinks.findIndex((link) => link.id === sharedLink.id);
if (index !== -1) {
sharedLinks[index] = sharedLink;
}
};
const onSharedLinkDelete = (sharedLink: SharedLinkResponseDto) => {
sharedLinks = sharedLinks.filter(({ id }) => id !== sharedLink.id);
};
</script>
<OnEvents {onSharedLinkUpdate} {onSharedLinkDelete} />
<UserPageLayout title={data.meta.title}>
{#snippet buttons()}
<div class="hidden h-10 xl:block">
@@ -1,9 +1,12 @@
import { queryClient } from '$lib/queries/client';
import { getAllSharedLinksQuery } from '$lib/queries/shared-link.query';
import { authenticate } from '$lib/utils/auth';
import { getFormatter } from '$lib/utils/i18n';
import type { LayoutLoad } from './$types';
export const load = (async ({ url }) => {
await authenticate(url);
await queryClient.query(getAllSharedLinksQuery());
const $t = await getFormatter();
return {
@@ -1,6 +1,7 @@
import { getAllSharedLinks } from '@immich/sdk';
import { redirect } from '@sveltejs/kit';
import { UUID_REGEX } from '$lib/constants';
import { queryClient } from '$lib/queries/client';
import { getSharedLinkQuery } from '$lib/queries/shared-link.query';
import { Route } from '$lib/route';
import { authenticate } from '$lib/utils/auth';
import { getFormatter } from '$lib/utils/i18n';
@@ -13,15 +14,11 @@ export const load = (async ({ params, url }) => {
redirect(307, Route.sharedLinks());
}
const [sharedLink] = await getAllSharedLinks({ id: params.id });
if (!sharedLink) {
redirect(307, Route.sharedLinks());
}
await queryClient.query(getSharedLinkQuery(params.id)).catch(() => redirect(307, Route.sharedLinks()));
const $t = await getFormatter();
return {
sharedLink,
meta: {
title: $t('shared_links'),
},
@@ -2,20 +2,18 @@
import { goto } from '$app/navigation';
import SharedLinkFormFields from '$lib/components/SharedLinkFormFields.svelte';
import { Route } from '$lib/route';
import { handleUpdateSharedLink } from '$lib/services/shared-link.service';
import { createPreloadedQuery, executeMutation } from '$lib/queries/client';
import { getSharedLinkQuery, updateSharedLinkMutation } from '$lib/queries/shared-link.query';
import { SharedLinkType } from '@immich/sdk';
import { FormModal } from '@immich/ui';
import { mdiLink } from '@mdi/js';
import { t } from 'svelte-i18n';
import type { PageData } from './$types';
import type { PageProps } from './$types';
type Props = {
data: PageData;
};
const { params }: PageProps = $props();
let { data }: Props = $props();
const sharedLink = $state(data.sharedLink);
const query = createPreloadedQuery(() => getSharedLinkQuery(params.id));
const sharedLink = $derived(query.data);
let description = $state(sharedLink.description ?? '');
let allowDownload = $state(sharedLink.allowDownload);
@@ -26,12 +24,9 @@
let shareType = sharedLink.album ? SharedLinkType.Album : SharedLinkType.Individual;
let expiresAt = $state(sharedLink.expiresAt);
const onClose = async () => {
await goto(Route.sharedLinks());
};
const onClose = () => void goto(Route.sharedLinks());
const onSubmit = async () => {
const success = await handleUpdateSharedLink(sharedLink, {
const sharedLinkEditDto = {
description,
password: password ?? null,
expiresAt,
@@ -39,10 +34,8 @@
allowDownload,
showMetadata,
slug: slug.trim() ?? null,
});
if (success) {
await onClose();
}
};
await executeMutation(updateSharedLinkMutation, [{ id: sharedLink.id, sharedLinkEditDto }], { onSuccess: onClose });
};
</script>
@@ -9,14 +9,17 @@
import AssetSelectControlBar from '$lib/components/timeline/AssetSelectControlBar.svelte';
import Timeline from '$lib/components/timeline/Timeline.svelte';
import { assetMultiSelectManager } from '$lib/managers/asset-multi-select-manager.svelte';
import { featureFlagsManager } from '$lib/managers/feature-flags-manager.svelte';
import { serverConfigManager } from '$lib/managers/server-config-manager.svelte';
import { TimelineManager } from '$lib/managers/timeline-manager/timeline-manager.svelte';
import { Route } from '$lib/route';
import { getTrashActions } from '$lib/services/trash.service';
import { handlePromiseError } from '$lib/utils';
import { t } from 'svelte-i18n';
import type { PageData } from './$types';
import { createPreloadedQuery } from '$lib/queries/client';
import { getUserConfigQuery } from '$lib/queries/config-user.query';
const query = createPreloadedQuery(getUserConfigQuery);
const config = $derived(query.data);
type Props = {
data: PageData;
@@ -27,7 +30,7 @@
let timelineManager = $state<TimelineManager>() as TimelineManager;
const options = { isTrashed: true };
if (!featureFlagsManager.value.trash) {
if (!config.trash.enabled) {
handlePromiseError(goto(Route.photos()));
}
@@ -43,7 +46,7 @@
const { Empty, RestoreAll } = $derived(getTrashActions($t, timelineManager?.assetCount ?? 0));
</script>
{#if featureFlagsManager.value.trash}
{#if config.trash.enabled}
<UserPageLayout
hideNavbar={assetMultiSelectManager.selectionActive}
actions={assetMultiSelectManager.selectionActive ? [] : [Empty, RestoreAll]}
@@ -59,7 +62,7 @@
>
<p class="p-4 font-medium text-gray-500/60 dark:text-gray-300/60">
{$t('trashed_items_will_be_permanently_deleted_after', {
values: { days: serverConfigManager.value.trashDays },
values: { days: config.trash.days },
})}
</p>
{#snippet empty()}
@@ -1,5 +1,4 @@
<script lang="ts">
import { serverConfigManager } from '$lib/managers/server-config-manager.svelte';
import SettingAccordion from '$lib/components/shared-components/settings/SettingAccordion.svelte';
import { authManager } from '$lib/managers/auth-manager.svelte';
import { handleError } from '$lib/utils/handle-error';
@@ -7,6 +6,11 @@
import { Button, Field, NumberInput, Select, Switch, toastManager } from '@immich/ui';
import { t } from 'svelte-i18n';
import { fade } from 'svelte/transition';
import { createPreloadedQuery } from '$lib/queries/client';
import { getUserConfigQuery } from '$lib/queries/config-user.query';
const query = createPreloadedQuery(getUserConfigQuery);
const config = $derived(query.data);
// Albums
let defaultAssetOrder = $state(authManager.preferences.albums?.defaultAssetOrder ?? AssetOrder.Desc);
@@ -23,7 +27,9 @@
// People
let peopleEnabled = $state(authManager.preferences.people?.enabled ?? false);
let peopleSidebar = $state(authManager.preferences.people?.sidebarWeb ?? false);
let peopleMinFaces = $state(authManager.preferences.people?.minimumFaces ?? serverConfigManager.value.minFaces);
let peopleMinFaces = $state(
authManager.preferences.people?.minimumFaces ?? config.machineLearning.facialRecognition.minFaces,
);
let peopleUpdateStrategy = $state(authManager.preferences.people?.updateStrategy ?? PersonUpdateStrategy.Everyone);
// Ratings
@@ -1,14 +1,17 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { serverConfigManager } from '$lib/managers/server-config-manager.svelte';
import { authManager } from '$lib/managers/auth-manager.svelte';
import { featureFlagsManager } from '$lib/managers/feature-flags-manager.svelte';
import { oauth } from '$lib/utils';
import { handleError } from '$lib/utils/handle-error';
import { Button, LoadingSpinner, toastManager } from '@immich/ui';
import { onMount } from 'svelte';
import { t } from 'svelte-i18n';
import { fade } from 'svelte/transition';
import { createPreloadedQuery } from '$lib/queries/client';
import { getUserConfigQuery } from '$lib/queries/config-user.query';
const query = createPreloadedQuery(getUserConfigQuery);
const config = $derived(query.data);
let loading = $state(true);
@@ -47,10 +50,10 @@
<div class="flex place-content-center place-items-center">
<LoadingSpinner />
</div>
{:else if featureFlagsManager.value.oauth}
{:else if config.oauth.enabled}
{#if authManager.user.oauthId}
{#if serverConfigManager.value.oauthAccountManagementUrl}
<Button shape="round" size="small" href={serverConfigManager.value.oauthAccountManagementUrl}
{#if config.oauth.accountManagementUrl}
<Button shape="round" size="small" href={config.oauth.accountManagementUrl}
>{$t('manage_oauth_account')}</Button
>
{/if}
@@ -7,7 +7,6 @@
import UserPurchaseSettings from './UserPurchaseSettings.svelte';
import UserUsageStatistic from './UserUsageStatistic.svelte';
import { OpenQueryParam, QueryParameter } from '$lib/constants';
import { featureFlagsManager } from '$lib/managers/feature-flags-manager.svelte';
import { oauth } from '$lib/utils';
import { type ApiKeyResponseDto, type PasskeyResponseDto, type SessionResponseDto } from '@immich/sdk';
import {
@@ -32,11 +31,16 @@
import AppUpdateSettings from './AppUpdateSettings.svelte';
import ChangePasswordSettings from './ChangePasswordSettings.svelte';
import DeviceList from './DeviceList.svelte';
import OauthSettings from './OauthSettings.svelte';
import OAuthSettings from './OAuthSettings.svelte';
import SharingSettings from './SharingSettings.svelte';
import UserApiKeyList from './UserApiKeyList.svelte';
import UserPasskeyList from './UserPasskeyList.svelte';
import UserProfileSettings from './UserProfileSettings.svelte';
import { createPreloadedQuery } from '$lib/queries/client';
import { getUserConfigQuery } from '$lib/queries/config-user.query';
const query = createPreloadedQuery(getUserConfigQuery);
const config = $derived(query.data);
interface Props {
keys?: ApiKeyResponseDto[];
@@ -127,7 +131,7 @@
<NotificationsSettings />
</SettingAccordion>
{#if featureFlagsManager.value.oauth}
{#if config.oauth.enabled}
<SettingAccordion
icon={mdiTwoFactorAuthentication}
key={OpenQueryParam.OAUTH}
@@ -135,7 +139,7 @@
subtitle={$t('manage_your_oauth_connection')}
isOpen={oauthOpen || undefined}
>
<OauthSettings />
<OAuthSettings />
</SettingAccordion>
{/if}
@@ -6,7 +6,6 @@
import LinkToDocs from './LinkToDocs.svelte';
import DuplicatesCompareControl from './DuplicatesCompareControl.svelte';
import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte';
import { featureFlagsManager } from '$lib/managers/feature-flags-manager.svelte';
import { languageManager } from '$lib/managers/language-manager.svelte';
import ShortcutsModal from '$lib/modals/ShortcutsModal.svelte';
import { Route } from '$lib/route';
@@ -26,6 +25,11 @@
} from '@mdi/js';
import { t } from 'svelte-i18n';
import type { PageData } from './$types';
import { createPreloadedQuery } from '$lib/queries/client';
import { getUserConfigQuery } from '$lib/queries/config-user.query';
const query = createPreloadedQuery(getUserConfigQuery);
const config = $derived(query.data);
interface Props {
data: PageData;
@@ -90,14 +94,14 @@
return;
}
const message = featureFlagsManager.value.trash
const message = config.trash.enabled
? $t('assets_moved_to_trash_count', { values: { count: trashedCount } })
: $t('permanently_deleted_assets_count', { values: { count: trashedCount } });
toastManager.primary(message);
};
const handleResolve = async (duplicateId: string, duplicateAssetIds: string[], trashIds: string[]) => {
const forceDelete = !featureFlagsManager.value.trash;
const forceDelete = !config.trash.enabled;
const shouldConfirmDelete = trashIds.length > 0 && forceDelete;
return withConfirmation(
@@ -141,7 +145,7 @@
});
let prompt, confirmText;
if (featureFlagsManager.value.trash) {
if (config.trash.enabled) {
prompt = $t('bulk_trash_duplicates_confirmation', { values: { count: idsToDelete.length } });
confirmText = $t('confirm');
} else {
+5 -19
View File
@@ -1,12 +1,11 @@
<script lang="ts">
import { goto } from '$app/navigation';
import emptyWorkflows from '$lib/assets/empty-workflows.svg';
import UserPageLayout from '$lib/components/layouts/UserPageLayout.svelte';
import OnEvents from '$lib/components/OnEvents.svelte';
import EmptyPlaceholder from '$lib/components/shared-components/EmptyPlaceholder.svelte';
import { createPreloadedQuery, queryClient } from '$lib/queries/client';
import { getWorkflowForShareQuery, searchWorkflowsQuery } from '$lib/queries/workflow.query';
import { Route } from '$lib/route';
import { getWorkflowActions, getWorkflowsActions, getWorkflowShowSchemaAction } from '$lib/services/workflow.service';
import { getWorkflowForShare, type WorkflowResponseDto } from '@immich/sdk';
import {
Badge,
Button,
@@ -31,7 +30,8 @@
let { data }: Props = $props();
let workflows = $state<WorkflowResponseDto[]>(data.workflows);
const query = createPreloadedQuery(searchWorkflowsQuery);
const workflows = $derived(query.data);
const expandedIds = new SvelteSet<string>();
@@ -44,22 +44,8 @@
};
const { Create, UseTemplate } = $derived(getWorkflowsActions($t));
const onWorkflowCreate = async (response: WorkflowResponseDto) => {
await goto(Route.viewWorkflow(response));
};
const onWorkflowUpdate = (response: WorkflowResponseDto) => {
workflows = workflows.map((workflow) => (workflow.id === response.id ? response : workflow));
};
const onWorkflowDelete = (response: WorkflowResponseDto) => {
workflows = workflows.filter(({ id }) => id !== response.id);
};
</script>
<OnEvents {onWorkflowCreate} {onWorkflowUpdate} {onWorkflowDelete} />
<UserPageLayout title={data.meta.title} actions={[UseTemplate, Create]} scrollbar={false}>
<section class="flex place-content-center sm:mx-4">
<Container center size="large" class="pb-28">
@@ -129,7 +115,7 @@
</a>
{#if expandedIds.has(workflow.id)}
{#await getWorkflowForShare({ id: workflow.id }) then result}
{#await queryClient.query(getWorkflowForShareQuery(workflow.id)) then result}
<div class="border-t border-gray-200 p-4 dark:border-gray-800">
<CodeBlock code={JSON.stringify(result, null, 2)} lineNumbers />
<Button
+3 -3
View File
@@ -1,5 +1,6 @@
import { searchWorkflows } from '@immich/sdk';
import { pluginManager } from '$lib/managers/plugin-manager.svelte';
import { queryClient } from '$lib/queries/client';
import { searchWorkflowsQuery } from '$lib/queries/workflow.query';
import { authenticate } from '$lib/utils/auth';
import { getFormatter } from '$lib/utils/i18n';
import type { PageLoad } from './$types';
@@ -7,11 +8,10 @@ import type { PageLoad } from './$types';
export const load = (async ({ url }) => {
await authenticate(url);
const [workflows] = await Promise.all([searchWorkflows({}), pluginManager.ready()]);
await Promise.all([queryClient.query(searchWorkflowsQuery()), pluginManager.ready()]);
const $t = await getFormatter();
return {
workflows,
meta: {
title: $t('workflows'),
},
@@ -1,14 +1,15 @@
<script lang="ts">
import { beforeNavigate, goto, invalidate } from '$app/navigation';
import OnEvents from '$lib/components/OnEvents.svelte';
import { beforeNavigate, goto } from '$app/navigation';
import WorkflowAddStepModal from '$lib/modals/WorkflowAddStepModal.svelte';
import WorkflowEditStepModal from '$lib/modals/WorkflowEditStepModal.svelte';
import WorkflowTriggerPicker from '$lib/modals/WorkflowTriggerPicker.svelte';
import { createPreloadedQuery, executeMutation } from '$lib/queries/client';
import { getWorkflowQuery, updateWorkflowMutation } from '$lib/queries/workflow.query';
import { Route } from '$lib/route';
import { getWorkflowActions, handleUpdateWorkflow } from '$lib/services/workflow.service';
import { getWorkflowActions } from '$lib/services/workflow.service';
import { generateId } from '$lib/utils/generate-id';
import { getTriggerDescription, getTriggerName } from '$lib/utils/workflow';
import type { WorkflowResponseDto, WorkflowUpdateDto } from '@immich/sdk';
import type { WorkflowUpdateDto } from '@immich/sdk';
import {
ActionBar,
AppShell,
@@ -46,7 +47,7 @@
import { cloneDeep, isEqual } from 'lodash-es';
import { t } from 'svelte-i18n';
import { flip } from 'svelte/animate';
import type { PageData } from './$types';
import type { PageProps } from './$types';
import WorkflowJsonEditor from './WorkflowJsonEditor.svelte';
import WorkflowStepCard from './WorkflowStepCard.svelte';
import WorkflowSummary from './WorkflowSummary.svelte';
@@ -57,15 +58,14 @@
type EditMode = 'visual' | 'json';
type Props = {
data: PageData;
};
let { params }: PageProps = $props();
let { data }: Props = $props();
const query = createPreloadedQuery(() => getWorkflowQuery(params.workflowId));
const workflow = $derived(query.data);
let { id, enabled, name, description, trigger } = $state(data.workflow);
let steps = $state(data.workflow.steps.map((step) => ({ ...step, id: generateId() })));
let savedWorkflow = $derived(cloneDeep(data.workflow));
let { id, enabled, name, description, trigger } = $state(query.data);
let steps = $state(query.data.steps.map((step) => ({ ...step, id: generateId() })));
const savedWorkflow = $derived(cloneDeep(workflow));
let allowNavigation = $state(false);
let isShowingNavigationDialog = $state(false);
let isSaving = $state(false);
@@ -188,22 +188,6 @@
}
};
const onWorkflowUpdate = async (response: WorkflowResponseDto) => {
if (id !== response.id) {
return;
}
data.workflow = response;
savedWorkflow = cloneDeep(response);
await invalidate('workflow:data');
};
const onWorkflowDelete = async (response: WorkflowResponseDto) => {
if (id === response.id) {
await goto(Route.workflows());
}
};
const confirmNavigation = async () => {
if (!hasChanges) {
return true;
@@ -231,12 +215,9 @@
isSaving = true;
try {
const submitted = { enabled, name, description, trigger, steps: cloneDeep(steps) };
const saved = await handleUpdateWorkflow(id, submitted);
if (saved) {
Object.assign(savedWorkflow, submitted);
}
await executeMutation(updateWorkflowMutation, [
{ id, workflowUpdateDto: { enabled, name, description, trigger, steps: cloneDeep(steps) } },
]);
} finally {
isSaving = false;
}
@@ -270,8 +251,6 @@
);
</script>
<OnEvents {onWorkflowUpdate} {onWorkflowDelete} />
<AppShell class="">
<AppShellBar>
<ActionBar
@@ -283,8 +262,8 @@
actions={[Logs, Duplicate, CopyJson, Download, Delete].map((item) => ({ ...item, color: undefined }))}
>
<ControlBarHeader>
<ControlBarTitle>{data.workflow.name}</ControlBarTitle>
<ControlBarDescription>{data.workflow.description}</ControlBarDescription>
<ControlBarTitle>{workflow.name}</ControlBarTitle>
<ControlBarDescription>{workflow.description}</ControlBarDescription>
</ControlBarHeader>
<ControlBarContent class="flex items-center justify-end gap-6">
{#if hasChanges}
@@ -1,24 +1,21 @@
import { searchWorkflows } from '@immich/sdk';
import { redirect } from '@sveltejs/kit';
import { pluginManager } from '$lib/managers/plugin-manager.svelte';
import { queryClient } from '$lib/queries/client';
import { getWorkflowQuery } from '$lib/queries/workflow.query';
import { Route } from '$lib/route';
import { authenticate } from '$lib/utils/auth';
import { getFormatter } from '$lib/utils/i18n';
import type { PageLoad } from './$types';
export const load = (async ({ url, params, depends }) => {
export const load = (async ({ url, params }) => {
await authenticate(url);
const [[workflow]] = await Promise.all([searchWorkflows({ id: params.workflowId }), pluginManager.ready()]);
await Promise.all([
queryClient.query(getWorkflowQuery(params.workflowId)).catch(() => redirect(307, Route.workflows())),
pluginManager.ready(),
]);
const $t = await getFormatter();
if (!workflow) {
redirect(307, Route.workflows());
}
depends('workflow:data');
return {
workflow,
meta: {
title: $t('edit_workflow'),
},
+5 -5
View File
@@ -1,7 +1,8 @@
import { commandPaletteManager } from '@immich/ui';
import { goto } from '$app/navigation';
import { languageManager } from '$lib/managers/language-manager.svelte';
import { serverConfigManager } from '$lib/managers/server-config-manager.svelte';
import { readPreloadedQuery } from '$lib/queries/client';
import { getServerConfigQuery } from '$lib/queries/server.query';
import { maintenanceCreateUrl, maintenanceReturnUrl, maintenanceShouldRedirect } from '$lib/utils/maintenance';
import { init } from '$lib/utils/server';
import type { LayoutLoad } from './$types';
@@ -14,10 +15,9 @@ export const load = (async ({ fetch, url }) => {
try {
await init(fetch);
if (maintenanceShouldRedirect(serverConfigManager.value.maintenanceMode, url)) {
await goto(
serverConfigManager.value.maintenanceMode ? maintenanceCreateUrl(url) : maintenanceReturnUrl(url.searchParams),
);
const { maintenanceMode } = readPreloadedQuery(getServerConfigQuery());
if (maintenanceShouldRedirect(maintenanceMode, url)) {
await goto(maintenanceMode ? maintenanceCreateUrl(url) : maintenanceReturnUrl(url.searchParams));
}
} catch (initError) {
error = initError;
+5 -3
View File
@@ -1,6 +1,7 @@
import { redirect } from '@sveltejs/kit';
import { authManager } from '$lib/managers/auth-manager.svelte';
import { serverConfigManager } from '$lib/managers/server-config-manager.svelte';
import { readPreloadedQuery } from '$lib/queries/client';
import { getServerConfigQuery } from '$lib/queries/server.query';
import { Route } from '$lib/route';
import { getFormatter } from '$lib/utils/i18n';
import { init } from '$lib/utils/server';
@@ -13,7 +14,8 @@ export const load = (async ({ fetch }) => {
try {
await init(fetch);
if (serverConfigManager.value.maintenanceMode) {
const serverConfig = readPreloadedQuery(getServerConfigQuery());
if (serverConfig.maintenanceMode) {
redirect(307, Route.maintenanceMode());
}
@@ -22,7 +24,7 @@ export const load = (async ({ fetch }) => {
redirect(307, Route.photos());
}
if (serverConfigManager.value.isInitialized) {
if (serverConfig.isInitialized) {
// Redirect to login page if there exists an admin account (i.e. server is initialized)
redirect(307, Route.login());
}
@@ -18,11 +18,13 @@
const { library }: Props = $props();
const ownerQuery = createPreloadedQuery(() => getUserAdminQuery(library.ownerId));
const owner = $derived(ownerQuery.data);
const queries = {
owner: createPreloadedQuery(() => getUserAdminQuery(library.ownerId)),
statistics: createQuery(() => getLibraryStatisticsQuery(library.id)),
};
const query = createQuery(() => getLibraryStatisticsQuery(library.id));
const stats = $derived(query.data);
const owner = $derived(queries.owner.data);
const stats = $derived(queries.statistics.data);
const actions = $derived.by(() => {
const { Detail, Scan, Edit, Delete } = getLibraryActions($t, library);
@@ -23,11 +23,13 @@
const { children, params }: LayoutProps = $props();
const libraryQuery = createPreloadedQuery(() => getLibraryQuery(params.id));
const library = $derived(libraryQuery.data);
const queries = {
library: createPreloadedQuery(() => getLibraryQuery(params.id)),
statistics: createQuery(() => getLibraryStatisticsQuery(params.id)),
};
const statisticsQuery = createQuery(() => getLibraryStatisticsQuery(library.id));
const stats = $derived(statisticsQuery.data);
const library = $derived(queries.library.data);
const stats = $derived(queries.statistics.data);
const [usage, usageUnit] = $derived(stats ? getBytesWithUnit(stats.usage) : [undefined, undefined]);
+12 -11
View File
@@ -1,8 +1,9 @@
<script lang="ts">
import QueueCard from './QueueCard.svelte';
import QueueStorageMigrationDescription from './QueueStorageMigrationDescription.svelte';
import { featureFlagsManager } from '$lib/managers/feature-flags-manager.svelte';
import { queueManager } from '$lib/managers/queue-manager.svelte';
import { createPreloadedQuery } from '$lib/queries/client';
import { getUserConfigQuery } from '$lib/queries/config-user.query';
import { asQueueItem } from '$lib/services/queue.service';
import { handleError } from '$lib/utils/handle-error';
import {
@@ -21,7 +22,8 @@
};
let { queues }: Props = $props();
const featureFlags = featureFlagsManager.value;
const query = createPreloadedQuery(getUserConfigQuery);
const { machineLearning } = $derived(query.data);
type QueueDetails = {
description?: Component;
@@ -32,7 +34,7 @@
handleCommand?: (jobId: QueueName, jobCommand: QueueCommandDto) => Promise<void>;
};
const queueDetails: Partial<Record<QueueName, QueueDetails>> = {
const queueDetails: Partial<Record<QueueName, QueueDetails>> = $derived({
[QueueName.ThumbnailGeneration]: {
allText: $t('all'),
missingText: $t('missing'),
@@ -47,33 +49,32 @@
[QueueName.Sidecar]: {
allText: $t('sync'),
missingText: $t('discover'),
disabled: !featureFlags.sidecar,
},
[QueueName.SmartSearch]: {
allText: $t('all'),
missingText: $t('missing'),
disabled: !featureFlags.smartSearch,
disabled: !machineLearning.clip.enabled,
},
[QueueName.DuplicateDetection]: {
allText: $t('all'),
missingText: $t('missing'),
disabled: !featureFlags.duplicateDetection,
disabled: !machineLearning.duplicateDetection.enabled,
},
[QueueName.FaceDetection]: {
allText: $t('reset'),
refreshText: $t('refresh'),
missingText: $t('missing'),
disabled: !featureFlags.facialRecognition,
disabled: !machineLearning.facialRecognition.enabled,
},
[QueueName.FacialRecognition]: {
allText: $t('reset'),
missingText: $t('missing'),
disabled: !featureFlags.facialRecognition,
disabled: !machineLearning.facialRecognition.enabled,
},
[QueueName.Ocr]: {
allText: $t('all'),
missingText: $t('missing'),
disabled: !featureFlags.ocr,
disabled: !machineLearning.ocr.enabled,
},
[QueueName.VideoConversion]: {
allText: $t('all'),
@@ -86,9 +87,9 @@
[QueueName.Migration]: {
missingText: $t('start'),
},
};
});
let queueList = Object.entries(queueDetails) as [QueueName, QueueDetails][];
const queueList = $derived(Object.entries(queueDetails) as [QueueName, QueueDetails][]);
const handleCommand = async (name: QueueName, dto: QueueCommandDto) => {
const item = asQueueItem($t, { name });
@@ -21,11 +21,13 @@
import { mdiCameraIris, mdiChartPie, mdiPlayCircle } from '@mdi/js';
import { t } from 'svelte-i18n';
const usersQuery = createPreloadedQuery(searchUsersAdminQuery);
const users = $derived(usersQuery.data);
const queries = {
users: createPreloadedQuery(searchUsersAdminQuery),
statistics: createQuery(() => ({ ...getServerStatisticsQuery(), refetchInterval: 5000 })),
};
const query = createQuery(() => ({ ...getServerStatisticsQuery(), refetchInterval: 5000 }));
const stats = $derived(query.data);
const users = $derived(queries.users.data);
const stats = $derived(queries.statistics.data);
const getStorageUsageWithUnit = (usage: number) => {
const TiB = 1024 ** 4;
@@ -22,7 +22,7 @@
import SearchBar from '$lib/elements/SearchBar.svelte';
import { featureFlagsManager } from '$lib/managers/feature-flags-manager.svelte';
import { createPreloadedQuery } from '$lib/queries/client';
import { getConfigQuery } from '$lib/queries/system-config.query';
import { getAdminConfigQuery } from '$lib/queries/config-admin.query';
import { getSystemConfigActions } from '$lib/services/system-config.service';
import { Alert, CommandPaletteDefaultProvider, Container } from '@immich/ui';
import {
@@ -57,7 +57,8 @@
const { data }: Props = $props();
const config = createPreloadedQuery(getConfigQuery);
const query = createPreloadedQuery(getAdminConfigQuery);
const config = $derived(query.data);
const settings: Array<{
component: Component;
@@ -205,14 +206,12 @@
let filteredSettings = $derived(
settings.filter(({ title, subtitle }) => {
const query = searchQuery.toLowerCase();
return title.toLowerCase().includes(query) || subtitle.toLowerCase().includes(query);
const term = searchQuery.toLowerCase();
return title.toLowerCase().includes(term) || subtitle.toLowerCase().includes(term);
}),
);
const { CopyToClipboard, Upload, Download } = $derived(
getSystemConfigActions($t, featureFlagsManager.value, config.data),
);
const { CopyToClipboard, Upload, Download } = $derived(getSystemConfigActions($t, featureFlagsManager.value, config));
</script>
<CommandPaletteDefaultProvider name={$t('admin.system_settings')} actions={[CopyToClipboard, Upload, Download]} />
@@ -1,12 +1,12 @@
import { queryClient } from '$lib/queries/client';
import { getConfigDefaultsQuery, getConfigQuery } from '$lib/queries/system-config.query';
import { getAdminConfigDefaultsQuery } from '$lib/queries/config-admin.query';
import { authenticate } from '$lib/utils/auth';
import { getFormatter } from '$lib/utils/i18n';
import type { PageLoad } from './$types';
export const load = (async ({ url }) => {
await authenticate(url, { admin: true });
await Promise.all([queryClient.query(getConfigQuery()), queryClient.query(getConfigDefaultsQuery())]);
await queryClient.query(getAdminConfigDefaultsQuery());
const $t = await getFormatter();
return {
@@ -8,7 +8,7 @@
import { featureFlagsManager } from '$lib/managers/feature-flags-manager.svelte';
import AuthDisableLoginConfirmModal from '$lib/modals/AuthDisableLoginConfirmModal.svelte';
import { createPreloadedQuery } from '$lib/queries/client';
import { getConfigQuery } from '$lib/queries/system-config.query';
import { getAdminConfigQuery } from '$lib/queries/config-admin.query';
import { handleError } from '$lib/utils/handle-error';
import { OAuthTokenEndpointAuthMethod, unlinkAllOAuthAccountsAdmin } from '@immich/sdk';
import { Button, IconButton, Link, modalManager, Text, toastManager } from '@immich/ui';
@@ -18,7 +18,7 @@
import { fade } from 'svelte/transition';
import SettingSelect from './SettingSelect.svelte';
const query = createPreloadedQuery(getConfigQuery);
const query = createPreloadedQuery(getAdminConfigQuery);
const config = $derived(query.data);
const disabled = $derived(featureFlagsManager.value.configFile);
@@ -1,6 +1,6 @@
<script lang="ts">
import { createPreloadedQuery } from '$lib/queries/client';
import { getConfigQuery } from '$lib/queries/system-config.query';
import { getAdminConfigQuery } from '$lib/queries/config-admin.query';
import { cloneDeep } from 'lodash-es';
import SettingButtonsRow from '$lib/components/shared-components/settings/SystemConfigButtonRow.svelte';
import SettingInputField from '$lib/components/shared-components/settings/SettingInputField.svelte';
@@ -13,7 +13,7 @@
import { t } from 'svelte-i18n';
import { fade } from 'svelte/transition';
const query = createPreloadedQuery(getConfigQuery);
const query = createPreloadedQuery(getAdminConfigQuery);
const config = $derived(query.data);
const disabled = $derived(featureFlagsManager.value.configFile);
@@ -1,6 +1,6 @@
<script lang="ts">
import { createPreloadedQuery } from '$lib/queries/client';
import { getConfigQuery } from '$lib/queries/system-config.query';
import { getAdminConfigQuery } from '$lib/queries/config-admin.query';
import SettingAccordion from '$lib/components/shared-components/settings/SettingAccordion.svelte';
import SettingCheckboxes from './SettingCheckboxes.svelte';
import SettingInputField from '$lib/components/shared-components/settings/SettingInputField.svelte';
@@ -26,7 +26,7 @@
import { t } from 'svelte-i18n';
import { fade } from 'svelte/transition';
const query = createPreloadedQuery(getConfigQuery);
const query = createPreloadedQuery(getAdminConfigQuery);
const config = $derived(query.data);
const disabled = $derived(featureFlagsManager.value.configFile);
@@ -1,6 +1,6 @@
<script lang="ts">
import { createPreloadedQuery } from '$lib/queries/client';
import { getConfigQuery } from '$lib/queries/system-config.query';
import { getAdminConfigQuery } from '$lib/queries/config-admin.query';
import { cloneDeep } from 'lodash-es';
import SettingSelect from './SettingSelect.svelte';
import { Colorspace, ImageFormat } from '@immich/sdk';
@@ -14,7 +14,7 @@
import { featureFlagsManager } from '$lib/managers/feature-flags-manager.svelte';
import { t } from 'svelte-i18n';
const query = createPreloadedQuery(getConfigQuery);
const query = createPreloadedQuery(getAdminConfigQuery);
const config = $derived(query.data);
const disabled = $derived(featureFlagsManager.value.configFile);
@@ -1,6 +1,6 @@
<script lang="ts">
import { createPreloadedQuery } from '$lib/queries/client';
import { getConfigQuery } from '$lib/queries/system-config.query';
import { getAdminConfigQuery } from '$lib/queries/config-admin.query';
import { cloneDeep } from 'lodash-es';
import SettingAccordion from '$lib/components/shared-components/settings/SettingAccordion.svelte';
import SettingInputField from '$lib/components/shared-components/settings/SettingInputField.svelte';
@@ -13,7 +13,7 @@
import { t } from 'svelte-i18n';
import { fade } from 'svelte/transition';
const query = createPreloadedQuery(getConfigQuery);
const query = createPreloadedQuery(getAdminConfigQuery);
const config = $derived(query.data);
const disabled = $derived(featureFlagsManager.value.configFile);
@@ -1,6 +1,6 @@
<script lang="ts">
import { createPreloadedQuery } from '$lib/queries/client';
import { getConfigQuery } from '$lib/queries/system-config.query';
import { getAdminConfigQuery } from '$lib/queries/config-admin.query';
import { cloneDeep } from 'lodash-es';
import SettingButtonsRow from '$lib/components/shared-components/settings/SystemConfigButtonRow.svelte';
import SettingInputField from '$lib/components/shared-components/settings/SettingInputField.svelte';
@@ -10,7 +10,7 @@
import { t } from 'svelte-i18n';
import { fade } from 'svelte/transition';
const query = createPreloadedQuery(getConfigQuery);
const query = createPreloadedQuery(getAdminConfigQuery);
const config = $derived(query.data);
const disabled = $derived(featureFlagsManager.value.configFile);
@@ -1,6 +1,6 @@
<script lang="ts">
import { createPreloadedQuery } from '$lib/queries/client';
import { getConfigQuery } from '$lib/queries/system-config.query';
import { getAdminConfigQuery } from '$lib/queries/config-admin.query';
import { cloneDeep } from 'lodash-es';
import SettingAccordion from '$lib/components/shared-components/settings/SettingAccordion.svelte';
import SettingInputField from '$lib/components/shared-components/settings/SettingInputField.svelte';
@@ -14,7 +14,7 @@
import { t } from 'svelte-i18n';
import { fade } from 'svelte/transition';
const query = createPreloadedQuery(getConfigQuery);
const query = createPreloadedQuery(getAdminConfigQuery);
const config = $derived(query.data);
const disabled = $derived(featureFlagsManager.value.configFile);

Some files were not shown because too many files have changed in this diff Show More