Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d17640a283 | ||
|
|
9e889bd4dc | ||
|
|
ca4d8d5a7a | ||
|
|
5f54700344 | ||
|
|
0991f506bb | ||
|
|
fa77f168a0 | ||
|
|
3aa65809b1 | ||
|
|
9a66f16102 | ||
|
|
493870ad7b | ||
|
|
1e20bdd74e | ||
|
|
99a7830f6c |
@@ -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,
|
||||
|
||||
@@ -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 },
|
||||
};
|
||||
@@ -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
@@ -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
@@ -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));
|
||||
|
||||
@@ -33974,6 +33974,11 @@
|
||||
},
|
||||
"UserConfigOAuthDto": {
|
||||
"properties": {
|
||||
"accountManagementUrl": {
|
||||
"default": "",
|
||||
"description": "Account management URL",
|
||||
"type": "string"
|
||||
},
|
||||
"autoLaunch": {
|
||||
"description": "Auto launch",
|
||||
"type": "boolean"
|
||||
|
||||
@@ -1616,6 +1616,8 @@ export type UserConfigNotificationsDto = {
|
||||
smtp: UserConfigSmtpDto;
|
||||
};
|
||||
export type UserConfigOAuthDto = {
|
||||
/** Account management URL */
|
||||
accountManagementUrl?: string;
|
||||
/** Auto launch */
|
||||
autoLaunch: boolean;
|
||||
/** Button text */
|
||||
|
||||
@@ -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 }));
|
||||
});
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,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();
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 }),
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
@@ -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 }),
|
||||
});
|
||||
@@ -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,
|
||||
});
|
||||
@@ -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' },
|
||||
},
|
||||
});
|
||||
@@ -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 }),
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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'],
|
||||
|
||||
@@ -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' } },
|
||||
});
|
||||
@@ -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 }),
|
||||
});
|
||||
@@ -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' } },
|
||||
});
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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() }));
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
@@ -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 = () => {
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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()]);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+13
-16
@@ -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
|
||||
|
||||
+8
-5
@@ -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}
|
||||
|
||||
|
||||
+8
-4
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'),
|
||||
},
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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]);
|
||||
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user