mirror of
https://github.com/immich-app/immich.git
synced 2025-12-22 17:24:56 +03:00
merge: remote-tracking branch 'immich/main' into feat/database-restores
This commit is contained in:
@@ -9,7 +9,7 @@
|
||||
"build:stats": "BUILD_STATS=true vite build",
|
||||
"package": "svelte-kit package",
|
||||
"preview": "vite preview",
|
||||
"check:svelte": "svelte-check --no-tsconfig --fail-on-warnings",
|
||||
"check:svelte": "svelte-check --no-tsconfig --fail-on-warnings --compiler-warnings 'state_referenced_locally:ignore'",
|
||||
"check:typescript": "tsc --noEmit",
|
||||
"check:watch": "pnpm run check:svelte --watch",
|
||||
"check:code": "pnpm run format && pnpm run lint && pnpm run check:svelte && pnpm run check:typescript",
|
||||
@@ -28,7 +28,7 @@
|
||||
"@formatjs/icu-messageformat-parser": "^2.9.8",
|
||||
"@immich/justified-layout-wasm": "^0.4.3",
|
||||
"@immich/sdk": "file:../open-api/typescript-sdk",
|
||||
"@immich/ui": "^0.49.2",
|
||||
"@immich/ui": "^0.50.1",
|
||||
"@mapbox/mapbox-gl-rtl-text": "0.2.3",
|
||||
"@mdi/js": "^7.4.47",
|
||||
"@photo-sphere-viewer/core": "^5.14.0",
|
||||
@@ -61,7 +61,8 @@
|
||||
"svelte-maplibre": "^1.2.5",
|
||||
"svelte-persisted-store": "^0.12.0",
|
||||
"tabbable": "^6.2.0",
|
||||
"thumbhash": "^0.1.1"
|
||||
"thumbhash": "^0.1.1",
|
||||
"uplot": "^1.6.32"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.36.0",
|
||||
@@ -92,12 +93,12 @@
|
||||
"factory.ts": "^1.4.1",
|
||||
"globals": "^16.0.0",
|
||||
"happy-dom": "^20.0.0",
|
||||
"prettier": "^3.4.2",
|
||||
"prettier": "^3.7.4",
|
||||
"prettier-plugin-organize-imports": "^4.0.0",
|
||||
"prettier-plugin-sort-json": "^4.1.1",
|
||||
"prettier-plugin-svelte": "^3.3.3",
|
||||
"rollup-plugin-visualizer": "^6.0.0",
|
||||
"svelte": "5.45.2",
|
||||
"svelte": "5.43.3",
|
||||
"svelte-check": "^4.1.5",
|
||||
"svelte-eslint-parser": "^1.3.3",
|
||||
"tailwindcss": "^4.1.7",
|
||||
|
||||
24
web/src/lib/components/HeaderActionButton.svelte
Normal file
24
web/src/lib/components/HeaderActionButton.svelte
Normal file
@@ -0,0 +1,24 @@
|
||||
<script lang="ts">
|
||||
import type { HeaderButtonActionItem } from '$lib/types';
|
||||
import { Button } from '@immich/ui';
|
||||
|
||||
type Props = {
|
||||
action: HeaderButtonActionItem;
|
||||
};
|
||||
|
||||
const { action }: Props = $props();
|
||||
const { title, icon, color = 'secondary', onAction } = $derived(action);
|
||||
</script>
|
||||
|
||||
{#if action.$if?.() ?? true}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="small"
|
||||
{color}
|
||||
leadingIcon={icon}
|
||||
onclick={() => onAction(action)}
|
||||
title={action.data?.title}
|
||||
>
|
||||
{title}
|
||||
</Button>
|
||||
{/if}
|
||||
@@ -1,17 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { type ActionItem, Button, Text } from '@immich/ui';
|
||||
|
||||
type Props = {
|
||||
action: ActionItem;
|
||||
title?: string;
|
||||
};
|
||||
|
||||
const { action, title: titleAttr }: Props = $props();
|
||||
const { title, icon, color = 'secondary', onAction } = $derived(action);
|
||||
</script>
|
||||
|
||||
{#if action.$if?.() ?? true}
|
||||
<Button variant="ghost" size="small" {color} leadingIcon={icon} onclick={() => onAction(action)} title={titleAttr}>
|
||||
<Text class="hidden md:block">{title}</Text>
|
||||
</Button>
|
||||
{/if}
|
||||
@@ -1,11 +1,15 @@
|
||||
<script lang="ts">
|
||||
import QueueCardBadge from '$lib/components/QueueCardBadge.svelte';
|
||||
import QueueCardButton from '$lib/components/QueueCardButton.svelte';
|
||||
import Badge from '$lib/elements/Badge.svelte';
|
||||
import { asQueueItem, getQueueDetailUrl } from '$lib/services/queue.service';
|
||||
import { locale } from '$lib/stores/preferences.store';
|
||||
import { QueueCommand, type QueueCommandDto, type QueueStatisticsDto, type QueueStatusLegacyDto } from '@immich/sdk';
|
||||
import { Icon, IconButton } from '@immich/ui';
|
||||
import { QueueCommand, type QueueCommandDto, type QueueResponseDto } from '@immich/sdk';
|
||||
import { Icon, IconButton, Link } from '@immich/ui';
|
||||
import {
|
||||
mdiAlertCircle,
|
||||
mdiAllInclusive,
|
||||
mdiChartLine,
|
||||
mdiClose,
|
||||
mdiFastForward,
|
||||
mdiImageRefreshOutline,
|
||||
@@ -15,39 +19,23 @@
|
||||
} from '@mdi/js';
|
||||
import { type Component } from 'svelte';
|
||||
import { t } from 'svelte-i18n';
|
||||
import JobTileButton from './JobTileButton.svelte';
|
||||
import JobTileStatus from './JobTileStatus.svelte';
|
||||
|
||||
interface Props {
|
||||
title: string;
|
||||
subtitle: string | undefined;
|
||||
description: Component | undefined;
|
||||
statistics: QueueStatisticsDto;
|
||||
queueStatus: QueueStatusLegacyDto;
|
||||
icon: string;
|
||||
queue: QueueResponseDto;
|
||||
description?: Component;
|
||||
disabled?: boolean;
|
||||
allText: string | undefined;
|
||||
refreshText: string | undefined;
|
||||
allText?: string;
|
||||
refreshText?: string;
|
||||
missingText: string;
|
||||
onCommand: (command: QueueCommandDto) => void;
|
||||
}
|
||||
|
||||
let {
|
||||
title,
|
||||
subtitle,
|
||||
description,
|
||||
statistics,
|
||||
queueStatus,
|
||||
icon,
|
||||
disabled = false,
|
||||
allText,
|
||||
refreshText,
|
||||
missingText,
|
||||
onCommand,
|
||||
}: Props = $props();
|
||||
let { queue, description, disabled = false, allText, refreshText, missingText, onCommand }: Props = $props();
|
||||
|
||||
const { icon, title, subtitle } = $derived(asQueueItem($t, queue));
|
||||
const { statistics } = $derived(queue);
|
||||
let waitingCount = $derived(statistics.waiting + statistics.paused + statistics.delayed);
|
||||
let isIdle = $derived(!queueStatus.isActive && !queueStatus.isPaused);
|
||||
let isIdle = $derived(statistics.active + statistics.waiting === 0 && !queue.isPaused);
|
||||
let multipleButtons = $derived(allText || refreshText);
|
||||
|
||||
const commonClasses = 'flex place-items-center justify-between w-full py-2 sm:py-4 pe-4 ps-6';
|
||||
@@ -55,17 +43,25 @@
|
||||
|
||||
<div class="flex flex-col overflow-hidden rounded-2xl bg-gray-100 dark:bg-immich-dark-gray sm:flex-row sm:rounded-9">
|
||||
<div class="flex w-full flex-col">
|
||||
{#if queueStatus.isPaused}
|
||||
<JobTileStatus color="warning">{$t('paused')}</JobTileStatus>
|
||||
{:else if queueStatus.isActive}
|
||||
<JobTileStatus color="success">{$t('active')}</JobTileStatus>
|
||||
{#if queue.isPaused}
|
||||
<QueueCardBadge color="warning">{$t('paused')}</QueueCardBadge>
|
||||
{:else if statistics.active > 0}
|
||||
<QueueCardBadge color="success">{$t('active')}</QueueCardBadge>
|
||||
{/if}
|
||||
<div class="flex flex-col gap-2 p-5 sm:p-7 md:p-9">
|
||||
<div class="flex items-center gap-4 text-xl font-semibold text-primary">
|
||||
<span class="flex items-center gap-2">
|
||||
<div class="flex items-center gap-2 text-xl font-semibold text-primary">
|
||||
<Link class="flex items-center gap-2 hover:underline" href={getQueueDetailUrl(queue)} underline={false}>
|
||||
<Icon {icon} size="1.25em" class="hidden shrink-0 sm:block" />
|
||||
<span class="uppercase">{title}</span>
|
||||
</span>
|
||||
</Link>
|
||||
<IconButton
|
||||
color="primary"
|
||||
icon={mdiChartLine}
|
||||
aria-label={$t('view_details')}
|
||||
size="small"
|
||||
variant="ghost"
|
||||
href={getQueueDetailUrl(queue)}
|
||||
/>
|
||||
<div class="flex gap-2">
|
||||
{#if statistics.failed > 0}
|
||||
<Badge>
|
||||
@@ -128,62 +124,62 @@
|
||||
</div>
|
||||
<div class="flex w-full flex-row overflow-hidden sm:w-32 sm:flex-col">
|
||||
{#if disabled}
|
||||
<JobTileButton
|
||||
<QueueCardButton
|
||||
disabled={true}
|
||||
color="light-gray"
|
||||
onClick={() => onCommand({ command: QueueCommand.Start, force: false })}
|
||||
>
|
||||
<Icon icon={mdiAlertCircle} size="36" />
|
||||
<span class="uppercase">{$t('disabled')}</span>
|
||||
</JobTileButton>
|
||||
</QueueCardButton>
|
||||
{/if}
|
||||
|
||||
{#if !disabled && !isIdle}
|
||||
{#if waitingCount > 0}
|
||||
<JobTileButton color="gray" onClick={() => onCommand({ command: QueueCommand.Empty, force: false })}>
|
||||
<QueueCardButton color="gray" onClick={() => onCommand({ command: QueueCommand.Empty, force: false })}>
|
||||
<Icon icon={mdiClose} size="24" />
|
||||
<span class="uppercase">{$t('clear')}</span>
|
||||
</JobTileButton>
|
||||
</QueueCardButton>
|
||||
{/if}
|
||||
{#if queueStatus.isPaused}
|
||||
{#if queue.isPaused}
|
||||
{@const size = waitingCount > 0 ? '24' : '48'}
|
||||
<JobTileButton color="light-gray" onClick={() => onCommand({ command: QueueCommand.Resume, force: false })}>
|
||||
<QueueCardButton color="light-gray" onClick={() => onCommand({ command: QueueCommand.Resume, force: false })}>
|
||||
<!-- size property is not reactive, so have to use width and height -->
|
||||
<Icon icon={mdiFastForward} {size} />
|
||||
<span class="uppercase">{$t('resume')}</span>
|
||||
</JobTileButton>
|
||||
</QueueCardButton>
|
||||
{:else}
|
||||
<JobTileButton color="light-gray" onClick={() => onCommand({ command: QueueCommand.Pause, force: false })}>
|
||||
<QueueCardButton color="light-gray" onClick={() => onCommand({ command: QueueCommand.Pause, force: false })}>
|
||||
<Icon icon={mdiPause} size="24" />
|
||||
<span class="uppercase">{$t('pause')}</span>
|
||||
</JobTileButton>
|
||||
</QueueCardButton>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
{#if !disabled && multipleButtons && isIdle}
|
||||
{#if allText}
|
||||
<JobTileButton color="dark-gray" onClick={() => onCommand({ command: QueueCommand.Start, force: true })}>
|
||||
<QueueCardButton color="dark-gray" onClick={() => onCommand({ command: QueueCommand.Start, force: true })}>
|
||||
<Icon icon={mdiAllInclusive} size="24" />
|
||||
<span class="uppercase">{allText}</span>
|
||||
</JobTileButton>
|
||||
</QueueCardButton>
|
||||
{/if}
|
||||
{#if refreshText}
|
||||
<JobTileButton color="gray" onClick={() => onCommand({ command: QueueCommand.Start, force: undefined })}>
|
||||
<QueueCardButton color="gray" onClick={() => onCommand({ command: QueueCommand.Start, force: undefined })}>
|
||||
<Icon icon={mdiImageRefreshOutline} size="24" />
|
||||
<span class="uppercase">{refreshText}</span>
|
||||
</JobTileButton>
|
||||
</QueueCardButton>
|
||||
{/if}
|
||||
<JobTileButton color="light-gray" onClick={() => onCommand({ command: QueueCommand.Start, force: false })}>
|
||||
<QueueCardButton color="light-gray" onClick={() => onCommand({ command: QueueCommand.Start, force: false })}>
|
||||
<Icon icon={mdiSelectionSearch} size="24" />
|
||||
<span class="uppercase">{missingText}</span>
|
||||
</JobTileButton>
|
||||
</QueueCardButton>
|
||||
{/if}
|
||||
|
||||
{#if !disabled && !multipleButtons && isIdle}
|
||||
<JobTileButton color="light-gray" onClick={() => onCommand({ command: QueueCommand.Start, force: false })}>
|
||||
<QueueCardButton color="light-gray" onClick={() => onCommand({ command: QueueCommand.Start, force: false })}>
|
||||
<Icon icon={mdiPlay} size="48" />
|
||||
<span class="uppercase">{missingText}</span>
|
||||
</JobTileButton>
|
||||
</QueueCardButton>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
160
web/src/lib/components/QueueGraph.svelte
Normal file
160
web/src/lib/components/QueueGraph.svelte
Normal file
@@ -0,0 +1,160 @@
|
||||
<script lang="ts">
|
||||
import { queueManager } from '$lib/managers/queue-manager.svelte';
|
||||
import type { QueueSnapshot } from '$lib/types';
|
||||
import type { QueueResponseDto } from '@immich/sdk';
|
||||
import { LoadingSpinner, Theme, theme } from '@immich/ui';
|
||||
import { DateTime } from 'luxon';
|
||||
import { onMount } from 'svelte';
|
||||
import uPlot, { type AlignedData, type Axis } from 'uplot';
|
||||
import 'uplot/dist/uPlot.min.css';
|
||||
|
||||
type Props = {
|
||||
queue: QueueResponseDto;
|
||||
class?: string;
|
||||
};
|
||||
|
||||
const { queue, class: className = '' }: Props = $props();
|
||||
|
||||
type Data = number | null;
|
||||
type NormalizedData = [
|
||||
Data[], // timestamps
|
||||
Data[], // failed counts
|
||||
Data[], // active counts
|
||||
Data[], // waiting counts
|
||||
];
|
||||
|
||||
const normalizeData = (snapshots: QueueSnapshot[]) => {
|
||||
const items: NormalizedData = [[], [], [], []];
|
||||
|
||||
for (const { timestamp, snapshot } of snapshots) {
|
||||
items[0].push(timestamp);
|
||||
|
||||
const statistics = (snapshot || []).find(({ name }) => name === queue.name)?.statistics;
|
||||
|
||||
if (statistics) {
|
||||
items[1].push(statistics.failed);
|
||||
items[2].push(statistics.active);
|
||||
items[3].push(statistics.waiting + statistics.paused);
|
||||
} else {
|
||||
items[0].push(timestamp);
|
||||
items[1].push(null);
|
||||
items[2].push(null);
|
||||
items[3].push(null);
|
||||
}
|
||||
}
|
||||
|
||||
items[0].push(Date.now() + 5000);
|
||||
items[1].push(items[1].at(-1) ?? 0);
|
||||
items[2].push(items[2].at(-1) ?? 0);
|
||||
items[3].push(items[3].at(-1) ?? 0);
|
||||
|
||||
return items;
|
||||
};
|
||||
|
||||
const data = $derived(normalizeData(queueManager.snapshots));
|
||||
|
||||
let chartElement: HTMLDivElement | undefined = $state();
|
||||
let isDark = $derived(theme.value === Theme.Dark);
|
||||
let plot: uPlot;
|
||||
|
||||
const axisOptions: Axis = {
|
||||
stroke: () => (isDark ? '#ccc' : 'black'),
|
||||
ticks: {
|
||||
show: true,
|
||||
stroke: () => (isDark ? '#444' : '#ddd'),
|
||||
},
|
||||
grid: {
|
||||
show: true,
|
||||
stroke: () => (isDark ? '#444' : '#ddd'),
|
||||
},
|
||||
};
|
||||
|
||||
const seriesOptions: uPlot.Series = {
|
||||
spanGaps: false,
|
||||
points: {
|
||||
show: false,
|
||||
},
|
||||
width: 2,
|
||||
};
|
||||
|
||||
const options: uPlot.Options = {
|
||||
legend: {
|
||||
show: false,
|
||||
},
|
||||
cursor: {
|
||||
show: false,
|
||||
lock: true,
|
||||
drag: {
|
||||
setScale: false,
|
||||
},
|
||||
},
|
||||
width: 200,
|
||||
height: 200,
|
||||
ms: 1,
|
||||
pxAlign: true,
|
||||
scales: {
|
||||
y: {
|
||||
distr: 1,
|
||||
},
|
||||
},
|
||||
series: [
|
||||
{},
|
||||
{
|
||||
stroke: '#d94a4a',
|
||||
...seriesOptions,
|
||||
},
|
||||
{
|
||||
stroke: '#4250af',
|
||||
...seriesOptions,
|
||||
},
|
||||
{
|
||||
stroke: '#1075db',
|
||||
...seriesOptions,
|
||||
},
|
||||
],
|
||||
|
||||
axes: [
|
||||
{
|
||||
...axisOptions,
|
||||
values: (plot, values) => {
|
||||
return values.map((value) => {
|
||||
if (!value) {
|
||||
return '';
|
||||
}
|
||||
return DateTime.fromMillis(value).toFormat('hh:mm:ss');
|
||||
});
|
||||
},
|
||||
},
|
||||
axisOptions,
|
||||
],
|
||||
};
|
||||
|
||||
const onThemeChange = () => plot?.redraw(false);
|
||||
|
||||
$effect(() => theme.value && onThemeChange());
|
||||
|
||||
onMount(() => {
|
||||
plot = new uPlot(options, data as AlignedData, chartElement);
|
||||
});
|
||||
|
||||
const update = () => {
|
||||
if (plot && chartElement && data[0].length > 0) {
|
||||
const now = Date.now();
|
||||
const scale = { min: now - chartElement!.clientWidth * 100, max: now };
|
||||
|
||||
plot.setData(data as AlignedData, false);
|
||||
plot.setScale('x', scale);
|
||||
plot.setSize({ width: chartElement.clientWidth, height: chartElement.clientHeight });
|
||||
}
|
||||
|
||||
requestAnimationFrame(update);
|
||||
};
|
||||
|
||||
requestAnimationFrame(update);
|
||||
</script>
|
||||
|
||||
<div class="w-full {className}" bind:this={chartElement}>
|
||||
{#if data[0].length === 0}
|
||||
<LoadingSpinner size="giant" />
|
||||
{/if}
|
||||
</div>
|
||||
132
web/src/lib/components/QueuePanel.svelte
Normal file
132
web/src/lib/components/QueuePanel.svelte
Normal file
@@ -0,0 +1,132 @@
|
||||
<script lang="ts">
|
||||
import QueueCard from '$lib/components/QueueCard.svelte';
|
||||
import QueueStorageMigrationDescription from '$lib/components/QueueStorageMigrationDescription.svelte';
|
||||
import { featureFlagsManager } from '$lib/managers/feature-flags-manager.svelte';
|
||||
import { queueManager } from '$lib/managers/queue-manager.svelte';
|
||||
import { asQueueItem } from '$lib/services/queue.service';
|
||||
import { handleError } from '$lib/utils/handle-error';
|
||||
import {
|
||||
QueueCommand,
|
||||
type QueueCommandDto,
|
||||
QueueName,
|
||||
type QueueResponseDto,
|
||||
runQueueCommandLegacy,
|
||||
} from '@immich/sdk';
|
||||
import { modalManager, toastManager } from '@immich/ui';
|
||||
import type { Component } from 'svelte';
|
||||
import { t } from 'svelte-i18n';
|
||||
|
||||
type Props = {
|
||||
queues: QueueResponseDto[];
|
||||
};
|
||||
|
||||
let { queues }: Props = $props();
|
||||
const featureFlags = featureFlagsManager.value;
|
||||
|
||||
type QueueDetails = {
|
||||
description?: Component;
|
||||
allText?: string;
|
||||
refreshText?: string;
|
||||
missingText: string;
|
||||
disabled?: boolean;
|
||||
handleCommand?: (jobId: QueueName, jobCommand: QueueCommandDto) => Promise<void>;
|
||||
};
|
||||
|
||||
const queueDetails: Partial<Record<QueueName, QueueDetails>> = {
|
||||
[QueueName.ThumbnailGeneration]: {
|
||||
allText: $t('all'),
|
||||
missingText: $t('missing'),
|
||||
},
|
||||
[QueueName.MetadataExtraction]: {
|
||||
allText: $t('all'),
|
||||
missingText: $t('missing'),
|
||||
},
|
||||
[QueueName.Library]: {
|
||||
missingText: $t('rescan'),
|
||||
},
|
||||
[QueueName.Sidecar]: {
|
||||
allText: $t('sync'),
|
||||
missingText: $t('discover'),
|
||||
disabled: !featureFlags.sidecar,
|
||||
},
|
||||
[QueueName.SmartSearch]: {
|
||||
allText: $t('all'),
|
||||
missingText: $t('missing'),
|
||||
disabled: !featureFlags.smartSearch,
|
||||
},
|
||||
[QueueName.DuplicateDetection]: {
|
||||
allText: $t('all'),
|
||||
missingText: $t('missing'),
|
||||
disabled: !featureFlags.duplicateDetection,
|
||||
},
|
||||
[QueueName.FaceDetection]: {
|
||||
allText: $t('reset'),
|
||||
refreshText: $t('refresh'),
|
||||
missingText: $t('missing'),
|
||||
disabled: !featureFlags.facialRecognition,
|
||||
},
|
||||
[QueueName.FacialRecognition]: {
|
||||
allText: $t('reset'),
|
||||
missingText: $t('missing'),
|
||||
disabled: !featureFlags.facialRecognition,
|
||||
},
|
||||
[QueueName.Ocr]: {
|
||||
allText: $t('all'),
|
||||
missingText: $t('missing'),
|
||||
disabled: !featureFlags.ocr,
|
||||
},
|
||||
[QueueName.VideoConversion]: {
|
||||
allText: $t('all'),
|
||||
missingText: $t('missing'),
|
||||
},
|
||||
[QueueName.StorageTemplateMigration]: {
|
||||
missingText: $t('start'),
|
||||
description: QueueStorageMigrationDescription,
|
||||
},
|
||||
[QueueName.Migration]: {
|
||||
missingText: $t('start'),
|
||||
},
|
||||
};
|
||||
|
||||
let queueList = Object.entries(queueDetails) as [QueueName, QueueDetails][];
|
||||
|
||||
const handleCommand = async (name: QueueName, dto: QueueCommandDto) => {
|
||||
const item = asQueueItem($t, { name });
|
||||
|
||||
switch (name) {
|
||||
case QueueName.FaceDetection:
|
||||
case QueueName.FacialRecognition: {
|
||||
if (dto.force) {
|
||||
const confirmed = await modalManager.showDialog({ prompt: $t('admin.confirm_reprocess_all_faces') });
|
||||
if (!confirmed) {
|
||||
return;
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
await runQueueCommandLegacy({ name, queueCommandDto: dto });
|
||||
await queueManager.refresh();
|
||||
|
||||
switch (dto.command) {
|
||||
case QueueCommand.Empty: {
|
||||
toastManager.success($t('admin.cleared_jobs', { values: { job: item.title } }));
|
||||
break;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
handleError(error, $t('admin.failed_job_command', { values: { command: dto.command, job: item.title } }));
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<div class="flex flex-col gap-7 mt-10">
|
||||
{#each queueList as [queueName, props] (queueName)}
|
||||
{@const queue = queues.find(({ name }) => name === queueName)}
|
||||
{#if queue}
|
||||
<QueueCard {queue} onCommand={(command) => handleCommand(queueName, command)} {...props} />
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
@@ -254,7 +254,7 @@
|
||||
values={{ job: $t('admin.storage_template_migration_job') }}
|
||||
>
|
||||
{#snippet children({ message })}
|
||||
<a href={resolve(AppRoute.ADMIN_JOBS)} class="text-primary">
|
||||
<a href={resolve(AppRoute.ADMIN_QUEUES)} class="text-primary">
|
||||
{message}
|
||||
</a>
|
||||
{/snippet}
|
||||
|
||||
@@ -12,12 +12,14 @@
|
||||
import { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
|
||||
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
|
||||
import { dragAndDropFilesStore } from '$lib/stores/drag-and-drop-files.store';
|
||||
import { mobileDevice } from '$lib/stores/mobile-device.svelte';
|
||||
import { SlideshowNavigation, SlideshowState, slideshowStore } from '$lib/stores/slideshow.store';
|
||||
import { handlePromiseError } from '$lib/utils';
|
||||
import { cancelMultiselect } from '$lib/utils/asset-utils';
|
||||
import { fileUploadHandler, openFileUploadDialog } from '$lib/utils/file-uploader';
|
||||
import type { AlbumResponseDto, SharedLinkResponseDto, UserResponseDto } from '@immich/sdk';
|
||||
import { IconButton, Logo } from '@immich/ui';
|
||||
import { mdiDownload, mdiFileImagePlusOutline } from '@mdi/js';
|
||||
import { mdiDownload, mdiFileImagePlusOutline, mdiPresentationPlay } from '@mdi/js';
|
||||
import { t } from 'svelte-i18n';
|
||||
import ControlAppBar from '../shared-components/control-app-bar.svelte';
|
||||
import ThemeButton from '../shared-components/theme-button.svelte';
|
||||
@@ -32,7 +34,8 @@
|
||||
|
||||
const album = sharedLink.album as AlbumResponseDto;
|
||||
|
||||
let { isViewing: showAssetViewer } = assetViewingStore;
|
||||
let { isViewing: showAssetViewer, setAssetId } = assetViewingStore;
|
||||
let { slideshowState, slideshowNavigation } = slideshowStore;
|
||||
|
||||
const options = $derived({ albumId: album.id, order: album.order });
|
||||
let timelineManager = $state<TimelineManager>() as TimelineManager;
|
||||
@@ -45,6 +48,16 @@
|
||||
dragAndDropFilesStore.set({ isDragging: false, files: [] });
|
||||
}
|
||||
});
|
||||
|
||||
const handleStartSlideshow = async () => {
|
||||
const asset =
|
||||
$slideshowNavigation === SlideshowNavigation.Shuffle
|
||||
? await timelineManager.getRandomAsset()
|
||||
: timelineManager.months[0]?.dayGroups[0]?.viewerAssets[0]?.asset;
|
||||
if (asset) {
|
||||
handlePromiseError(setAssetId(asset.id).then(() => ($slideshowState = SlideshowState.PlaySlideshow)));
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<svelte:document
|
||||
@@ -98,7 +111,7 @@
|
||||
<ControlAppBar showBackButton={false}>
|
||||
{#snippet leading()}
|
||||
<a data-sveltekit-preload-data="hover" class="ms-4" href="/">
|
||||
<Logo variant="inline" />
|
||||
<Logo variant={mobileDevice.maxMd ? 'icon' : 'inline'} class="min-w-10" />
|
||||
</a>
|
||||
{/snippet}
|
||||
|
||||
@@ -117,6 +130,14 @@
|
||||
{/if}
|
||||
|
||||
{#if album.assetCount > 0 && sharedLink.allowDownload}
|
||||
<IconButton
|
||||
shape="round"
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
aria-label={$t('slideshow')}
|
||||
onclick={handleStartSlideshow}
|
||||
icon={mdiPresentationPlay}
|
||||
/>
|
||||
<IconButton
|
||||
shape="round"
|
||||
color="secondary"
|
||||
|
||||
@@ -32,7 +32,7 @@
|
||||
</script>
|
||||
|
||||
<tr
|
||||
class="flex h-12 w-full place-items-center border-3 border-transparent p-2 text-center even:bg-subtle/20 odd:bg-subtle/80 hover:cursor-pointer hover:border-immich-primary/75 odd:dark:bg-immich-dark-gray/75 even:dark:bg-immich-dark-gray/50 dark:hover:border-immich-dark-primary/75 md:p-5"
|
||||
class="flex w-full place-items-center border-3 border-transparent p-2 text-center even:bg-subtle/20 odd:bg-subtle/80 hover:cursor-pointer hover:border-immich-primary/75 odd:dark:bg-immich-dark-gray/75 even:dark:bg-immich-dark-gray/50 dark:hover:border-immich-dark-primary/75 md:px-5 md:py-2"
|
||||
onclick={() => goto(resolve(`${AppRoute.ALBUMS}/${album.id}`))}
|
||||
{oncontextmenu}
|
||||
>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import { locale } from '$lib/stores/preferences.store';
|
||||
import type { ActivityResponseDto } from '@immich/sdk';
|
||||
import { Icon } from '@immich/ui';
|
||||
import { mdiCommentOutline, mdiHeart, mdiHeartOutline } from '@mdi/js';
|
||||
import { mdiCommentOutline, mdiThumbUp, mdiThumbUpOutline } from '@mdi/js';
|
||||
|
||||
interface Props {
|
||||
isLiked: ActivityResponseDto | null;
|
||||
@@ -19,7 +19,7 @@
|
||||
<div class="w-full flex p-4 items-center justify-center rounded-full gap-5 bg-subtle border bg-opacity-60">
|
||||
<button type="button" class={disabled ? 'cursor-not-allowed' : ''} onclick={onFavorite} {disabled}>
|
||||
<div class="flex gap-2 items-center justify-center">
|
||||
<Icon icon={isLiked ? mdiHeart : mdiHeartOutline} size="24" class={isLiked ? 'text-red-400' : 'text-fg'} />
|
||||
<Icon icon={isLiked ? mdiThumbUp : mdiThumbUpOutline} size="24" class={isLiked ? 'text-primary' : 'text-fg'} />
|
||||
{#if numberOfLikes}
|
||||
<div class="text-l">{numberOfLikes.toLocaleString($locale)}</div>
|
||||
{/if}
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
import { isTenMinutesApart } from '$lib/utils/timesince';
|
||||
import { ReactionType, type ActivityResponseDto, type AssetTypeEnum, type UserResponseDto } from '@immich/sdk';
|
||||
import { Icon, IconButton, LoadingSpinner, toastManager } from '@immich/ui';
|
||||
import { mdiClose, mdiDeleteOutline, mdiDotsVertical, mdiHeart, mdiSend } from '@mdi/js';
|
||||
import { mdiClose, mdiDeleteOutline, mdiDotsVertical, mdiSend, mdiThumbUp } from '@mdi/js';
|
||||
import * as luxon from 'luxon';
|
||||
import { t } from 'svelte-i18n';
|
||||
import UserAvatar from '../shared-components/user-avatar.svelte';
|
||||
@@ -181,7 +181,7 @@
|
||||
{:else if reaction.type === ReactionType.Like}
|
||||
<div class="relative">
|
||||
<div class="flex py-3 ps-3 mt-3 gap-4 items-center text-sm">
|
||||
<div class="text-red-600"><Icon icon={mdiHeart} size="20" /></div>
|
||||
<div class="text-primary"><Icon icon={mdiThumbUp} size="20" /></div>
|
||||
|
||||
<div class="w-full" title={`${reaction.user.name} (${reaction.user.email})`}>
|
||||
{$t('user_liked', {
|
||||
@@ -254,7 +254,7 @@
|
||||
shortcut: { key: 'Enter' },
|
||||
onShortcut: () => handleSendComment(),
|
||||
}}
|
||||
class="h-[18px] {disabled
|
||||
class="h-4.5 {disabled
|
||||
? 'cursor-not-allowed'
|
||||
: ''} w-full max-h-56 pe-2 items-center overflow-y-auto leading-4 outline-none resize-none bg-gray-200"
|
||||
></textarea>
|
||||
|
||||
@@ -114,7 +114,11 @@
|
||||
return;
|
||||
}
|
||||
|
||||
await modalManager.show(AssetChangeDateModal, { asset: toTimelineAsset(asset), initialDate: dateTime });
|
||||
await modalManager.show(AssetChangeDateModal, {
|
||||
asset: toTimelineAsset(asset),
|
||||
initialDate: dateTime,
|
||||
initialTimeZone: timeZone,
|
||||
});
|
||||
};
|
||||
</script>
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
{#if downloadManager.isDownloading}
|
||||
<div
|
||||
transition:fly={{ x: -100, duration: 350 }}
|
||||
class="fixed bottom-10 start-2 max-h-67.5 w-79 rounded-2xl border dark:border-white/10 p-4 shadow-lg bg-subtle"
|
||||
class="fixed bottom-10 start-2 max-h-67.5 w-79 z-60 rounded-2xl border dark:border-white/10 p-4 shadow-lg bg-subtle"
|
||||
>
|
||||
<Heading size="tiny">{$t('downloading')}</Heading>
|
||||
<div class="my-2 mb-2 flex max-h-50 flex-col overflow-y-auto text-sm">
|
||||
|
||||
@@ -126,6 +126,7 @@
|
||||
|
||||
const onMouseLeave = () => {
|
||||
mouseOver = false;
|
||||
onMouseEvent?.({ isMouseOver: false, selectedGroupIndex: groupIndex });
|
||||
};
|
||||
|
||||
let timer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
@@ -1,197 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { featureFlagsManager } from '$lib/managers/feature-flags-manager.svelte';
|
||||
import { getQueueName } from '$lib/utils';
|
||||
import { handleError } from '$lib/utils/handle-error';
|
||||
import {
|
||||
QueueCommand,
|
||||
type QueueCommandDto,
|
||||
QueueName,
|
||||
type QueuesResponseLegacyDto,
|
||||
runQueueCommandLegacy,
|
||||
} from '@immich/sdk';
|
||||
import { modalManager, toastManager } from '@immich/ui';
|
||||
import {
|
||||
mdiContentDuplicate,
|
||||
mdiFaceRecognition,
|
||||
mdiFileJpgBox,
|
||||
mdiFileXmlBox,
|
||||
mdiFolderMove,
|
||||
mdiImageSearch,
|
||||
mdiLibraryShelves,
|
||||
mdiOcr,
|
||||
mdiTable,
|
||||
mdiTagFaces,
|
||||
mdiVideo,
|
||||
} from '@mdi/js';
|
||||
import type { Component } from 'svelte';
|
||||
import { t } from 'svelte-i18n';
|
||||
import JobTile from './JobTile.svelte';
|
||||
import StorageMigrationDescription from './StorageMigrationDescription.svelte';
|
||||
|
||||
interface Props {
|
||||
jobs: QueuesResponseLegacyDto;
|
||||
}
|
||||
|
||||
let { jobs = $bindable() }: Props = $props();
|
||||
const featureFlags = featureFlagsManager.value;
|
||||
|
||||
type JobDetails = {
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
description?: Component;
|
||||
allText?: string;
|
||||
refreshText?: string;
|
||||
missingText: string;
|
||||
disabled?: boolean;
|
||||
icon: string;
|
||||
handleCommand?: (jobId: QueueName, jobCommand: QueueCommandDto) => Promise<void>;
|
||||
};
|
||||
|
||||
const handleConfirmCommand = async (jobId: QueueName, dto: QueueCommandDto) => {
|
||||
if (dto.force) {
|
||||
const isConfirmed = await modalManager.showDialog({
|
||||
prompt: $t('admin.confirm_reprocess_all_faces'),
|
||||
});
|
||||
|
||||
if (isConfirmed) {
|
||||
await handleCommand(jobId, { command: QueueCommand.Start, force: true });
|
||||
return;
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
await handleCommand(jobId, dto);
|
||||
};
|
||||
|
||||
let jobDetails: Partial<Record<QueueName, JobDetails>> = {
|
||||
[QueueName.ThumbnailGeneration]: {
|
||||
icon: mdiFileJpgBox,
|
||||
title: $getQueueName(QueueName.ThumbnailGeneration),
|
||||
subtitle: $t('admin.thumbnail_generation_job_description'),
|
||||
allText: $t('all'),
|
||||
missingText: $t('missing'),
|
||||
},
|
||||
[QueueName.MetadataExtraction]: {
|
||||
icon: mdiTable,
|
||||
title: $getQueueName(QueueName.MetadataExtraction),
|
||||
subtitle: $t('admin.metadata_extraction_job_description'),
|
||||
allText: $t('all'),
|
||||
missingText: $t('missing'),
|
||||
},
|
||||
[QueueName.Library]: {
|
||||
icon: mdiLibraryShelves,
|
||||
title: $getQueueName(QueueName.Library),
|
||||
subtitle: $t('admin.library_tasks_description'),
|
||||
missingText: $t('rescan'),
|
||||
},
|
||||
[QueueName.Sidecar]: {
|
||||
title: $getQueueName(QueueName.Sidecar),
|
||||
icon: mdiFileXmlBox,
|
||||
subtitle: $t('admin.sidecar_job_description'),
|
||||
allText: $t('sync'),
|
||||
missingText: $t('discover'),
|
||||
disabled: !featureFlags.sidecar,
|
||||
},
|
||||
[QueueName.SmartSearch]: {
|
||||
icon: mdiImageSearch,
|
||||
title: $getQueueName(QueueName.SmartSearch),
|
||||
subtitle: $t('admin.smart_search_job_description'),
|
||||
allText: $t('all'),
|
||||
missingText: $t('missing'),
|
||||
disabled: !featureFlags.smartSearch,
|
||||
},
|
||||
[QueueName.DuplicateDetection]: {
|
||||
icon: mdiContentDuplicate,
|
||||
title: $getQueueName(QueueName.DuplicateDetection),
|
||||
subtitle: $t('admin.duplicate_detection_job_description'),
|
||||
allText: $t('all'),
|
||||
missingText: $t('missing'),
|
||||
disabled: !featureFlags.duplicateDetection,
|
||||
},
|
||||
[QueueName.FaceDetection]: {
|
||||
icon: mdiFaceRecognition,
|
||||
title: $getQueueName(QueueName.FaceDetection),
|
||||
subtitle: $t('admin.face_detection_description'),
|
||||
allText: $t('reset'),
|
||||
refreshText: $t('refresh'),
|
||||
missingText: $t('missing'),
|
||||
handleCommand: handleConfirmCommand,
|
||||
disabled: !featureFlags.facialRecognition,
|
||||
},
|
||||
[QueueName.FacialRecognition]: {
|
||||
icon: mdiTagFaces,
|
||||
title: $getQueueName(QueueName.FacialRecognition),
|
||||
subtitle: $t('admin.facial_recognition_job_description'),
|
||||
allText: $t('reset'),
|
||||
missingText: $t('missing'),
|
||||
handleCommand: handleConfirmCommand,
|
||||
disabled: !featureFlags.facialRecognition,
|
||||
},
|
||||
[QueueName.Ocr]: {
|
||||
icon: mdiOcr,
|
||||
title: $getQueueName(QueueName.Ocr),
|
||||
subtitle: $t('admin.ocr_job_description'),
|
||||
allText: $t('all'),
|
||||
missingText: $t('missing'),
|
||||
disabled: !featureFlags.ocr,
|
||||
},
|
||||
[QueueName.VideoConversion]: {
|
||||
icon: mdiVideo,
|
||||
title: $getQueueName(QueueName.VideoConversion),
|
||||
subtitle: $t('admin.video_conversion_job_description'),
|
||||
allText: $t('all'),
|
||||
missingText: $t('missing'),
|
||||
},
|
||||
[QueueName.StorageTemplateMigration]: {
|
||||
icon: mdiFolderMove,
|
||||
title: $getQueueName(QueueName.StorageTemplateMigration),
|
||||
missingText: $t('start'),
|
||||
description: StorageMigrationDescription,
|
||||
},
|
||||
[QueueName.Migration]: {
|
||||
icon: mdiFolderMove,
|
||||
title: $getQueueName(QueueName.Migration),
|
||||
subtitle: $t('admin.migration_job_description'),
|
||||
missingText: $t('start'),
|
||||
},
|
||||
};
|
||||
|
||||
let jobList = Object.entries(jobDetails) as [QueueName, JobDetails][];
|
||||
|
||||
async function handleCommand(name: QueueName, dto: QueueCommandDto) {
|
||||
const title = jobDetails[name]?.title;
|
||||
|
||||
try {
|
||||
jobs[name] = await runQueueCommandLegacy({ name, queueCommandDto: dto });
|
||||
|
||||
switch (dto.command) {
|
||||
case QueueCommand.Empty: {
|
||||
toastManager.success($t('admin.cleared_jobs', { values: { job: title } }));
|
||||
break;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
handleError(error, $t('admin.failed_job_command', { values: { command: dto.command, job: title } }));
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="flex flex-col gap-7">
|
||||
{#each jobList as [jobName, { title, subtitle, description, disabled, allText, refreshText, missingText, icon, handleCommand: handleCommandOverride }] (jobName)}
|
||||
{@const { jobCounts: statistics, queueStatus } = jobs[jobName]}
|
||||
<JobTile
|
||||
{icon}
|
||||
{title}
|
||||
{disabled}
|
||||
{subtitle}
|
||||
{description}
|
||||
{allText}
|
||||
{refreshText}
|
||||
{missingText}
|
||||
{statistics}
|
||||
{queueStatus}
|
||||
onCommand={(command) => (handleCommandOverride || handleCommand)(jobName, command)}
|
||||
/>
|
||||
{/each}
|
||||
</div>
|
||||
@@ -1,19 +1,33 @@
|
||||
<script lang="ts">
|
||||
import PageContent from '$lib/components/layouts/PageContent.svelte';
|
||||
import TitleLayout from '$lib/components/layouts/TitleLayout.svelte';
|
||||
import NavigationBar from '$lib/components/shared-components/navigation-bar/navigation-bar.svelte';
|
||||
import AdminSidebar from '$lib/sidebars/AdminSidebar.svelte';
|
||||
import { sidebarStore } from '$lib/stores/sidebar.svelte';
|
||||
import { AppShell, AppShellHeader, AppShellSidebar, Scrollable, type BreadcrumbItem } from '@immich/ui';
|
||||
import type { HeaderButtonActionItem } from '$lib/types';
|
||||
import {
|
||||
AppShell,
|
||||
AppShellHeader,
|
||||
AppShellSidebar,
|
||||
Breadcrumbs,
|
||||
Button,
|
||||
ContextMenuButton,
|
||||
HStack,
|
||||
MenuItemType,
|
||||
Scrollable,
|
||||
isMenuItemType,
|
||||
type BreadcrumbItem,
|
||||
} from '@immich/ui';
|
||||
import { mdiSlashForward } from '@mdi/js';
|
||||
import type { Snippet } from 'svelte';
|
||||
import { t } from 'svelte-i18n';
|
||||
|
||||
type Props = {
|
||||
breadcrumbs: BreadcrumbItem[];
|
||||
buttons?: Snippet;
|
||||
actions?: Array<HeaderButtonActionItem | MenuItemType>;
|
||||
children?: Snippet;
|
||||
};
|
||||
|
||||
let { breadcrumbs, buttons, children }: Props = $props();
|
||||
let { breadcrumbs, actions = [], children }: Props = $props();
|
||||
</script>
|
||||
|
||||
<AppShell>
|
||||
@@ -24,11 +38,37 @@
|
||||
<AdminSidebar />
|
||||
</AppShellSidebar>
|
||||
|
||||
<TitleLayout {breadcrumbs} {buttons}>
|
||||
<div class="h-full flex flex-col">
|
||||
<div class="flex h-16 w-full justify-between items-center border-b py-2 px-4 md:px-2">
|
||||
<Breadcrumbs items={breadcrumbs} separator={mdiSlashForward} />
|
||||
|
||||
{#if actions.length > 0}
|
||||
<div class="hidden md:block">
|
||||
<HStack gap={0}>
|
||||
{#each actions as action, i (i)}
|
||||
{#if !isMenuItemType(action) && (action.$if?.() ?? true)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="small"
|
||||
color={action.color ?? 'secondary'}
|
||||
leadingIcon={action.icon}
|
||||
onclick={() => action.onAction(action)}
|
||||
title={action.data?.title}
|
||||
>
|
||||
{action.title}
|
||||
</Button>
|
||||
{/if}
|
||||
{/each}
|
||||
</HStack>
|
||||
</div>
|
||||
|
||||
<ContextMenuButton aria-label={$t('open')} items={actions} class="md:hidden" />
|
||||
{/if}
|
||||
</div>
|
||||
<Scrollable class="grow">
|
||||
<PageContent>
|
||||
{@render children?.()}
|
||||
</PageContent>
|
||||
</Scrollable>
|
||||
</TitleLayout>
|
||||
</div>
|
||||
</AppShell>
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { Breadcrumbs, type BreadcrumbItem } from '@immich/ui';
|
||||
import { mdiSlashForward } from '@mdi/js';
|
||||
import type { Snippet } from 'svelte';
|
||||
|
||||
type Props = {
|
||||
breadcrumbs: BreadcrumbItem[];
|
||||
buttons?: Snippet;
|
||||
children?: Snippet;
|
||||
};
|
||||
|
||||
let { breadcrumbs, buttons, children }: Props = $props();
|
||||
</script>
|
||||
|
||||
<div class="h-full flex flex-col">
|
||||
<div class="flex h-16 w-full place-items-center justify-between border-b p-2">
|
||||
<Breadcrumbs items={breadcrumbs} separator={mdiSlashForward} />
|
||||
{@render buttons?.()}
|
||||
</div>
|
||||
{@render children?.()}
|
||||
</div>
|
||||
@@ -9,6 +9,7 @@
|
||||
import type { Viewport } from '$lib/managers/timeline-manager/types';
|
||||
import { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
|
||||
import { dragAndDropFilesStore } from '$lib/stores/drag-and-drop-files.store';
|
||||
import { mobileDevice } from '$lib/stores/mobile-device.svelte';
|
||||
import { handlePromiseError } from '$lib/utils';
|
||||
import { cancelMultiselect, downloadArchive } from '$lib/utils/asset-utils';
|
||||
import { fileUploadHandler, openFileUploadDialog } from '$lib/utils/file-uploader';
|
||||
@@ -108,7 +109,7 @@
|
||||
<ControlAppBar onClose={() => goto(AppRoute.PHOTOS)} backIcon={mdiArrowLeft} showBackButton={false}>
|
||||
{#snippet leading()}
|
||||
<a data-sveltekit-preload-data="hover" class="ms-4" href="/">
|
||||
<Logo variant="inline" />
|
||||
<Logo variant={mobileDevice.maxMd ? 'icon' : 'inline'} class="min-w-10" />
|
||||
</a>
|
||||
{/snippet}
|
||||
|
||||
|
||||
@@ -9,9 +9,9 @@
|
||||
import { generateId } from '$lib/utils/generate-id';
|
||||
import { getMetadataSearchQuery } from '$lib/utils/metadata-search';
|
||||
import type { MetadataSearchDto, SmartSearchDto } from '@immich/sdk';
|
||||
import { IconButton, modalManager } from '@immich/ui';
|
||||
import { Button, IconButton, modalManager } from '@immich/ui';
|
||||
import { mdiClose, mdiMagnify, mdiTune } from '@mdi/js';
|
||||
import { onDestroy, tick } from 'svelte';
|
||||
import { onDestroy, onMount, tick } from 'svelte';
|
||||
import { t } from 'svelte-i18n';
|
||||
import SearchHistoryBox from './search-history-box.svelte';
|
||||
|
||||
@@ -31,6 +31,8 @@
|
||||
let isSearchSuggestions = $state(false);
|
||||
let selectedId: string | undefined = $state();
|
||||
let close: (() => Promise<void>) | undefined;
|
||||
let showSearchTypeDropdown = $state(false);
|
||||
let currentSearchType = $state('smart');
|
||||
|
||||
const listboxId = generateId();
|
||||
const searchTypeId = generateId();
|
||||
@@ -70,16 +72,37 @@
|
||||
|
||||
const onFocusIn = () => {
|
||||
searchStore.isSearchEnabled = true;
|
||||
getSearchType();
|
||||
};
|
||||
|
||||
const onFocusOut = () => {
|
||||
searchStore.isSearchEnabled = false;
|
||||
};
|
||||
|
||||
const buildSearchPayload = (term: string): SmartSearchDto | MetadataSearchDto => {
|
||||
const searchType = getSearchType();
|
||||
switch (searchType) {
|
||||
case 'smart': {
|
||||
return { query: term };
|
||||
}
|
||||
case 'metadata': {
|
||||
return { originalFileName: term };
|
||||
}
|
||||
case 'description': {
|
||||
return { description: term };
|
||||
}
|
||||
case 'ocr': {
|
||||
return { ocr: term };
|
||||
}
|
||||
default: {
|
||||
return { query: term };
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const onHistoryTermClick = async (searchTerm: string) => {
|
||||
value = searchTerm;
|
||||
const searchPayload = { query: searchTerm };
|
||||
await handleSearch(searchPayload);
|
||||
await handleSearch(buildSearchPayload(searchTerm));
|
||||
};
|
||||
|
||||
const onFilterClick = async () => {
|
||||
@@ -98,6 +121,9 @@
|
||||
const searchResult = await result.onClose;
|
||||
close = undefined;
|
||||
|
||||
// Refresh search type after modal closes
|
||||
getSearchType();
|
||||
|
||||
if (!searchResult) {
|
||||
return;
|
||||
}
|
||||
@@ -106,29 +132,7 @@
|
||||
};
|
||||
|
||||
const onSubmit = () => {
|
||||
const searchType = getSearchType();
|
||||
let payload = {} as SmartSearchDto | MetadataSearchDto;
|
||||
|
||||
switch (searchType) {
|
||||
case 'smart': {
|
||||
payload = { query: value } as SmartSearchDto;
|
||||
break;
|
||||
}
|
||||
case 'metadata': {
|
||||
payload = { originalFileName: value } as MetadataSearchDto;
|
||||
break;
|
||||
}
|
||||
case 'description': {
|
||||
payload = { description: value } as MetadataSearchDto;
|
||||
break;
|
||||
}
|
||||
case 'ocr': {
|
||||
payload = { ocr: value } as MetadataSearchDto;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
handlePromiseError(handleSearch(payload));
|
||||
handlePromiseError(handleSearch(buildSearchPayload(value)));
|
||||
saveSearchTerm(value);
|
||||
};
|
||||
|
||||
@@ -139,6 +143,7 @@
|
||||
|
||||
const onEscape = () => {
|
||||
closeDropdown();
|
||||
closeSearchTypeDropdown();
|
||||
};
|
||||
|
||||
const onArrow = async (direction: 1 | -1) => {
|
||||
@@ -168,6 +173,20 @@
|
||||
searchHistoryBox?.clearSelection();
|
||||
};
|
||||
|
||||
const toggleSearchTypeDropdown = () => {
|
||||
showSearchTypeDropdown = !showSearchTypeDropdown;
|
||||
};
|
||||
|
||||
const closeSearchTypeDropdown = () => {
|
||||
showSearchTypeDropdown = false;
|
||||
};
|
||||
|
||||
const selectSearchType = (type: string) => {
|
||||
localStorage.setItem('searchQueryType', type);
|
||||
currentSearchType = type;
|
||||
showSearchTypeDropdown = false;
|
||||
};
|
||||
|
||||
const onsubmit = (event: Event) => {
|
||||
event.preventDefault();
|
||||
onSubmit();
|
||||
@@ -180,17 +199,18 @@
|
||||
case 'metadata':
|
||||
case 'description':
|
||||
case 'ocr': {
|
||||
currentSearchType = searchType;
|
||||
return searchType;
|
||||
}
|
||||
default: {
|
||||
currentSearchType = 'smart';
|
||||
return 'smart';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function getSearchTypeText(): string {
|
||||
const searchType = getSearchType();
|
||||
switch (searchType) {
|
||||
switch (currentSearchType) {
|
||||
case 'smart': {
|
||||
return $t('context');
|
||||
}
|
||||
@@ -203,8 +223,22 @@
|
||||
case 'ocr': {
|
||||
return $t('ocr');
|
||||
}
|
||||
default: {
|
||||
return $t('context');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
getSearchType();
|
||||
});
|
||||
|
||||
const searchTypes = [
|
||||
{ value: 'smart', label: () => $t('context') },
|
||||
{ value: 'metadata', label: () => $t('filename') },
|
||||
{ value: 'description', label: () => $t('description') },
|
||||
{ value: 'ocr', label: () => $t('ocr') },
|
||||
] as const;
|
||||
</script>
|
||||
|
||||
<svelte:document
|
||||
@@ -293,11 +327,34 @@
|
||||
class:max-md:hidden={value}
|
||||
class:end-28={value.length > 0}
|
||||
>
|
||||
<p
|
||||
class="bg-immich-primary text-white dark:bg-immich-dark-primary/90 dark:text-black/75 rounded-full px-3 py-1 text-xs"
|
||||
>
|
||||
{getSearchTypeText()}
|
||||
</p>
|
||||
<div class="relative">
|
||||
<Button
|
||||
class="bg-immich-primary text-white dark:bg-immich-dark-primary/90 dark:text-black/75 rounded-full px-3 py-1 text-xs hover:opacity-80 transition-opacity cursor-pointer"
|
||||
onclick={toggleSearchTypeDropdown}
|
||||
aria-expanded={showSearchTypeDropdown}
|
||||
aria-haspopup="listbox"
|
||||
>
|
||||
{getSearchTypeText()}
|
||||
</Button>
|
||||
|
||||
{#if showSearchTypeDropdown}
|
||||
<div
|
||||
class="absolute top-full right-0 mt-1 bg-white dark:bg-immich-dark-gray border border-gray-200 dark:border-gray-600 rounded-lg shadow-lg py-1 min-w-32 z-9999"
|
||||
use:focusOutside={{ onFocusOut: closeSearchTypeDropdown }}
|
||||
>
|
||||
{#each searchTypes as searchType (searchType.value)}
|
||||
<button
|
||||
type="button"
|
||||
class="w-full text-left px-3 py-2 text-xs hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors
|
||||
{currentSearchType === searchType.value ? 'bg-gray-100 dark:bg-gray-700' : ''}"
|
||||
onclick={() => selectSearchType(searchType.value)}
|
||||
>
|
||||
{searchType.label()}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
import { getSharedLinkActions } from '$lib/services/shared-link.service';
|
||||
import { locale } from '$lib/stores/preferences.store';
|
||||
import { SharedLinkType, type SharedLinkResponseDto } from '@immich/sdk';
|
||||
import { Badge, ContextMenuButton, MenuItemType, Text } from '@immich/ui';
|
||||
import { ContextMenuButton, MenuItemType, Text } from '@immich/ui';
|
||||
import { DateTime, type ToRelativeUnit } from 'luxon';
|
||||
import { t } from 'svelte-i18n';
|
||||
|
||||
@@ -32,6 +32,28 @@
|
||||
};
|
||||
|
||||
const { Edit, Copy, Delete } = $derived(getSharedLinkActions($t, sharedLink));
|
||||
|
||||
const capabilities = $derived.by(() => {
|
||||
const items = [];
|
||||
|
||||
if (sharedLink.allowUpload) {
|
||||
items.push($t('upload'));
|
||||
}
|
||||
|
||||
if (sharedLink.allowDownload) {
|
||||
items.push($t('download'));
|
||||
}
|
||||
|
||||
if (sharedLink.showMetadata) {
|
||||
items.push($t('exif'));
|
||||
}
|
||||
|
||||
if (sharedLink.password) {
|
||||
items.push($t('password'));
|
||||
}
|
||||
|
||||
return items;
|
||||
});
|
||||
</script>
|
||||
|
||||
<div
|
||||
@@ -44,50 +66,41 @@
|
||||
>
|
||||
<ShareCover class="transition-all duration-300 hover:shadow-lg" {sharedLink} />
|
||||
|
||||
<div class="flex flex-col gap-2">
|
||||
<Text size="large" color="primary" class="flex place-items-center gap-2 break-all">
|
||||
{#if sharedLink.type === SharedLinkType.Album}
|
||||
{sharedLink.album?.albumName}
|
||||
{:else if sharedLink.type === SharedLinkType.Individual}
|
||||
{$t('individual_share')}
|
||||
{/if}
|
||||
</Text>
|
||||
|
||||
<div class="flex flex-wrap gap-1">
|
||||
{#if isExpired}
|
||||
<Badge size="small" color="danger">{$t('expired')}</Badge>
|
||||
{:else if expiresAt}
|
||||
<Badge size="small" color="secondary">
|
||||
<div class="flex flex-col gap-4 justify-between">
|
||||
<div class="flex flex-col">
|
||||
<Text size="tiny" color={isExpired ? 'danger' : 'muted'} class="font-medium">
|
||||
{#if isExpired}
|
||||
{$t('expired')}
|
||||
{:else if expiresAt}
|
||||
{$t('expires_date', { values: { date: getCountDownExpirationDate(expiresAt, now) } })}
|
||||
</Badge>
|
||||
{:else}
|
||||
<Badge size="small" color="secondary">{$t('expires_date', { values: { date: '∞' } })}</Badge>
|
||||
{/if}
|
||||
{:else}
|
||||
{$t('expires_date', { values: { date: '∞' } })}
|
||||
{/if}
|
||||
</Text>
|
||||
|
||||
{#if sharedLink.slug}
|
||||
<Badge size="small" color="secondary">{$t('custom_url')}</Badge>
|
||||
{/if}
|
||||
<Text size="large" color="primary" class="flex place-items-center gap-2 break-all font-medium">
|
||||
{#if sharedLink.type === SharedLinkType.Album}
|
||||
{sharedLink.album?.albumName}
|
||||
{:else if sharedLink.type === SharedLinkType.Individual}
|
||||
{$t('individual_share')}
|
||||
{/if}
|
||||
</Text>
|
||||
|
||||
{#if sharedLink.allowUpload}
|
||||
<Badge size="small" color="secondary">{$t('upload')}</Badge>
|
||||
{/if}
|
||||
|
||||
{#if sharedLink.showMetadata && sharedLink.allowDownload}
|
||||
<Badge size="small" color="secondary">{$t('download')}</Badge>
|
||||
{/if}
|
||||
|
||||
{#if sharedLink.showMetadata}
|
||||
<Badge size="small" color="secondary">{$t('exif')}</Badge>
|
||||
{/if}
|
||||
|
||||
{#if sharedLink.password}
|
||||
<Badge size="small" color="secondary">{$t('password')}</Badge>
|
||||
{#if sharedLink.description}
|
||||
<Text size="small" class="line-clamp-1">{sharedLink.description}</Text>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if sharedLink.description}
|
||||
<Text size="small" class="line-clamp-1">{sharedLink.description}</Text>
|
||||
{/if}
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
{#each capabilities as capability, index (index)}
|
||||
<Text size="small" color="primary" class="font-medium">
|
||||
{capability}
|
||||
</Text>
|
||||
{#if index < capabilities.length - 1}
|
||||
<Text size="small" color="muted">•</Text>
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
</svelte:element>
|
||||
<div class="flex flex-auto flex-col place-content-center place-items-end text-end ms-4">
|
||||
@@ -188,7 +188,7 @@
|
||||
// the performance benefits of deferred layouts while still supporting deep linking
|
||||
// to assets at the end of the timeline.
|
||||
timelineManager.isScrollingOnLoad = true;
|
||||
const monthGroup = await timelineManager.findMonthGroupForAsset(assetId);
|
||||
const monthGroup = await timelineManager.findMonthGroupForAsset({ id: assetId });
|
||||
if (!monthGroup) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -24,7 +24,7 @@ export enum AppRoute {
|
||||
ADMIN_SETTINGS = '/admin/system-settings',
|
||||
ADMIN_MAINTENANCE = '/admin/maintenance',
|
||||
ADMIN_STATS = '/admin/server-status',
|
||||
ADMIN_JOBS = '/admin/jobs-status',
|
||||
ADMIN_QUEUES = '/admin/queues',
|
||||
ADMIN_REPAIR = '/admin/repair',
|
||||
|
||||
ALBUMS = '/albums',
|
||||
|
||||
@@ -4,6 +4,7 @@ import type {
|
||||
AlbumResponseDto,
|
||||
LibraryResponseDto,
|
||||
LoginResponseDto,
|
||||
QueueResponseDto,
|
||||
SharedLinkResponseDto,
|
||||
SystemConfigDto,
|
||||
UserAdminResponseDto,
|
||||
@@ -21,6 +22,8 @@ export type Events = {
|
||||
|
||||
AlbumDelete: [AlbumResponseDto];
|
||||
|
||||
QueueUpdate: [QueueResponseDto];
|
||||
|
||||
SharedLinkCreate: [SharedLinkResponseDto];
|
||||
SharedLinkUpdate: [SharedLinkResponseDto];
|
||||
SharedLinkDelete: [SharedLinkResponseDto];
|
||||
|
||||
45
web/src/lib/managers/queue-manager.svelte.ts
Normal file
45
web/src/lib/managers/queue-manager.svelte.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import { eventManager } from '$lib/managers/event-manager.svelte';
|
||||
import type { QueueSnapshot } from '$lib/types';
|
||||
import { getQueues, type QueueResponseDto } from '@immich/sdk';
|
||||
import { DateTime } from 'luxon';
|
||||
|
||||
export class QueueManager {
|
||||
#snapshots = $state<QueueSnapshot[]>([]);
|
||||
#queues: QueueResponseDto[] = $derived(this.#snapshots.at(-1)?.snapshot ?? []);
|
||||
|
||||
#interval?: ReturnType<typeof setInterval>;
|
||||
#listenerCount = 0;
|
||||
|
||||
get snapshots() {
|
||||
return this.#snapshots;
|
||||
}
|
||||
|
||||
get queues() {
|
||||
return this.#queues;
|
||||
}
|
||||
|
||||
constructor() {
|
||||
eventManager.on('QueueUpdate', () => void this.refresh());
|
||||
}
|
||||
|
||||
listen() {
|
||||
if (!this.#interval) {
|
||||
this.#interval = setInterval(() => void this.refresh(true), 3000);
|
||||
}
|
||||
|
||||
this.#listenerCount++;
|
||||
void this.refresh();
|
||||
|
||||
return () => this.#listenerCount--;
|
||||
}
|
||||
|
||||
async refresh(tick = false) {
|
||||
this.#snapshots.push({
|
||||
timestamp: DateTime.now().toMillis(),
|
||||
snapshot: this.#listenerCount > 0 || !tick ? await getQueues().catch(() => undefined) : undefined,
|
||||
});
|
||||
this.#snapshots = this.#snapshots.slice(-30);
|
||||
}
|
||||
}
|
||||
|
||||
export const queueManager = new QueueManager();
|
||||
@@ -1,5 +1,5 @@
|
||||
import { plainDateTimeCompare, type TimelineYearMonth } from '$lib/utils/timeline-util';
|
||||
import { AssetOrder } from '@immich/sdk';
|
||||
import { AssetOrder, type AssetResponseDto } from '@immich/sdk';
|
||||
import { DateTime } from 'luxon';
|
||||
import type { MonthGroup } from '../month-group.svelte';
|
||||
import { TimelineManager } from '../timeline-manager.svelte';
|
||||
@@ -7,12 +7,16 @@ import type { AssetDescriptor, Direction, TimelineAsset } from '../types';
|
||||
|
||||
export async function getAssetWithOffset(
|
||||
timelineManager: TimelineManager,
|
||||
assetDescriptor: AssetDescriptor,
|
||||
assetDescriptor: AssetDescriptor | AssetResponseDto,
|
||||
interval: 'asset' | 'day' | 'month' | 'year' = 'asset',
|
||||
direction: Direction,
|
||||
): Promise<TimelineAsset | undefined> {
|
||||
const { asset, monthGroup } = findMonthGroupForAsset(timelineManager, assetDescriptor.id) ?? {};
|
||||
if (!monthGroup || !asset) {
|
||||
const monthGroup = await timelineManager.findMonthGroupForAsset(assetDescriptor);
|
||||
if (!monthGroup) {
|
||||
return;
|
||||
}
|
||||
const asset = monthGroup.findAssetById(assetDescriptor);
|
||||
if (!asset) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -524,6 +524,7 @@ describe('TimelineManager', () => {
|
||||
{ count: 3, timeBucket: '2024-01-01T00:00:00.000Z' },
|
||||
]);
|
||||
sdkMock.getTimeBucket.mockImplementation(({ timeBucket }) => Promise.resolve(bucketAssetsResponse[timeBucket]));
|
||||
sdkMock.getAssetInfo.mockRejectedValue(new Error('Asset not found'));
|
||||
await timelineManager.updateViewport({ width: 1588, height: 1000 });
|
||||
});
|
||||
|
||||
|
||||
@@ -16,12 +16,13 @@ import { WebsocketSupport } from '$lib/managers/timeline-manager/internal/websoc
|
||||
import { CancellableTask } from '$lib/utils/cancellable-task';
|
||||
import { PersistedLocalStorage } from '$lib/utils/persisted';
|
||||
import {
|
||||
isAssetResponseDto,
|
||||
setDifference,
|
||||
toTimelineAsset,
|
||||
type TimelineDateTime,
|
||||
type TimelineYearMonth,
|
||||
} from '$lib/utils/timeline-util';
|
||||
import { AssetOrder, getAssetInfo, getTimeBuckets } from '@immich/sdk';
|
||||
import { AssetOrder, getAssetInfo, getTimeBuckets, type AssetResponseDto } from '@immich/sdk';
|
||||
import { clamp, isEqual } from 'lodash-es';
|
||||
import { SvelteDate, SvelteSet } from 'svelte/reactivity';
|
||||
import { DayGroup } from './day-group.svelte';
|
||||
@@ -343,27 +344,30 @@ export class TimelineManager extends VirtualScrollManager {
|
||||
this.addAssetsUpsertSegments([...notExcluded]);
|
||||
}
|
||||
|
||||
async findMonthGroupForAsset(id: string) {
|
||||
async findMonthGroupForAsset(asset: AssetDescriptor | AssetResponseDto) {
|
||||
if (!this.isInitialized) {
|
||||
await this.initTask.waitUntilCompletion();
|
||||
}
|
||||
|
||||
const { id } = asset;
|
||||
let { monthGroup } = findMonthGroupForAssetUtil(this, id) ?? {};
|
||||
if (monthGroup) {
|
||||
return monthGroup;
|
||||
}
|
||||
|
||||
const response = await getAssetInfo({ ...authManager.params, id }).catch(() => null);
|
||||
const response = isAssetResponseDto(asset)
|
||||
? asset
|
||||
: await getAssetInfo({ ...authManager.params, id }).catch(() => null);
|
||||
if (!response) {
|
||||
return;
|
||||
}
|
||||
|
||||
const asset = toTimelineAsset(response);
|
||||
if (!asset || this.isExcluded(asset)) {
|
||||
const timelineAsset = toTimelineAsset(response);
|
||||
if (this.isExcluded(timelineAsset)) {
|
||||
return;
|
||||
}
|
||||
|
||||
monthGroup = await this.#loadMonthGroupAtTime(asset.localDateTime, { cancelable: false });
|
||||
monthGroup = await this.#loadMonthGroupAtTime(timelineAsset.localDateTime, { cancelable: false });
|
||||
if (monthGroup?.findAssetById({ id })) {
|
||||
return monthGroup;
|
||||
}
|
||||
@@ -532,14 +536,14 @@ export class TimelineManager extends VirtualScrollManager {
|
||||
}
|
||||
|
||||
async getLaterAsset(
|
||||
assetDescriptor: AssetDescriptor,
|
||||
assetDescriptor: AssetDescriptor | AssetResponseDto,
|
||||
interval: 'asset' | 'day' | 'month' | 'year' = 'asset',
|
||||
): Promise<TimelineAsset | undefined> {
|
||||
return await getAssetWithOffset(this, assetDescriptor, interval, 'later');
|
||||
}
|
||||
|
||||
async getEarlierAsset(
|
||||
assetDescriptor: AssetDescriptor,
|
||||
assetDescriptor: AssetDescriptor | AssetResponseDto,
|
||||
interval: 'asset' | 'day' | 'month' | 'year' = 'asset',
|
||||
): Promise<TimelineAsset | undefined> {
|
||||
return await getAssetWithOffset(this, assetDescriptor, interval, 'earlier');
|
||||
|
||||
@@ -89,7 +89,7 @@
|
||||
|
||||
<Text size="small" class="mt-2" color="muted">
|
||||
{$t('admin.note_apply_storage_label_previous_assets')}
|
||||
<Link href={AppRoute.ADMIN_JOBS}>
|
||||
<Link href={AppRoute.ADMIN_QUEUES}>
|
||||
{$t('admin.storage_template_migration_job')}
|
||||
</Link>
|
||||
</Text>
|
||||
|
||||
@@ -28,7 +28,7 @@ export const getLibrariesActions = ($t: MessageFormatter, libraries: LibraryResp
|
||||
title: $t('scan_all_libraries'),
|
||||
type: $t('command'),
|
||||
icon: mdiSync,
|
||||
onAction: () => void handleScanAllLibraries(),
|
||||
onAction: () => handleScanAllLibraries(),
|
||||
shortcuts: { shift: true, key: 'r' },
|
||||
$if: () => libraries.length > 0,
|
||||
};
|
||||
@@ -37,7 +37,7 @@ export const getLibrariesActions = ($t: MessageFormatter, libraries: LibraryResp
|
||||
title: $t('create_library'),
|
||||
type: $t('command'),
|
||||
icon: mdiPlusBoxOutline,
|
||||
onAction: () => void handleCreateLibrary(),
|
||||
onAction: () => handleCreateLibrary(),
|
||||
shortcuts: { shift: true, key: 'n' },
|
||||
};
|
||||
|
||||
@@ -49,7 +49,7 @@ export const getLibraryActions = ($t: MessageFormatter, library: LibraryResponse
|
||||
icon: mdiPencilOutline,
|
||||
type: $t('command'),
|
||||
title: $t('rename'),
|
||||
onAction: () => void modalManager.show(LibraryRenameModal, { library }),
|
||||
onAction: () => modalManager.show(LibraryRenameModal, { library }),
|
||||
shortcuts: { key: 'r' },
|
||||
};
|
||||
|
||||
@@ -58,7 +58,7 @@ export const getLibraryActions = ($t: MessageFormatter, library: LibraryResponse
|
||||
type: $t('command'),
|
||||
title: $t('delete'),
|
||||
color: 'danger',
|
||||
onAction: () => void handleDeleteLibrary(library),
|
||||
onAction: () => handleDeleteLibrary(library),
|
||||
shortcuts: { key: 'Backspace' },
|
||||
};
|
||||
|
||||
@@ -66,21 +66,21 @@ export const getLibraryActions = ($t: MessageFormatter, library: LibraryResponse
|
||||
icon: mdiPlusBoxOutline,
|
||||
type: $t('command'),
|
||||
title: $t('add'),
|
||||
onAction: () => void modalManager.show(LibraryFolderAddModal, { library }),
|
||||
onAction: () => modalManager.show(LibraryFolderAddModal, { library }),
|
||||
};
|
||||
|
||||
const AddExclusionPattern: ActionItem = {
|
||||
icon: mdiPlusBoxOutline,
|
||||
type: $t('command'),
|
||||
title: $t('add'),
|
||||
onAction: () => void modalManager.show(LibraryExclusionPatternAddModal, { library }),
|
||||
onAction: () => modalManager.show(LibraryExclusionPatternAddModal, { library }),
|
||||
};
|
||||
|
||||
const Scan: ActionItem = {
|
||||
icon: mdiSync,
|
||||
type: $t('command'),
|
||||
title: $t('scan_library'),
|
||||
onAction: () => void handleScanLibrary(library),
|
||||
onAction: () => handleScanLibrary(library),
|
||||
shortcuts: { shift: true, key: 'r' },
|
||||
};
|
||||
|
||||
@@ -92,14 +92,14 @@ export const getLibraryFolderActions = ($t: MessageFormatter, library: LibraryRe
|
||||
icon: mdiPencilOutline,
|
||||
type: $t('command'),
|
||||
title: $t('edit'),
|
||||
onAction: () => void modalManager.show(LibraryFolderEditModal, { folder, library }),
|
||||
onAction: () => modalManager.show(LibraryFolderEditModal, { folder, library }),
|
||||
};
|
||||
|
||||
const Delete: ActionItem = {
|
||||
icon: mdiTrashCanOutline,
|
||||
type: $t('command'),
|
||||
title: $t('delete'),
|
||||
onAction: () => void handleDeleteLibraryFolder(library, folder),
|
||||
onAction: () => handleDeleteLibraryFolder(library, folder),
|
||||
};
|
||||
|
||||
return { Edit, Delete };
|
||||
@@ -114,14 +114,14 @@ export const getLibraryExclusionPatternActions = (
|
||||
icon: mdiPencilOutline,
|
||||
type: $t('command'),
|
||||
title: $t('edit'),
|
||||
onAction: () => void modalManager.show(LibraryExclusionPatternEditModal, { exclusionPattern, library }),
|
||||
onAction: () => modalManager.show(LibraryExclusionPatternEditModal, { exclusionPattern, library }),
|
||||
};
|
||||
|
||||
const Delete: ActionItem = {
|
||||
icon: mdiTrashCanOutline,
|
||||
type: $t('command'),
|
||||
title: $t('delete'),
|
||||
onAction: () => void handleDeleteExclusionPattern(library, exclusionPattern),
|
||||
onAction: () => handleDeleteExclusionPattern(library, exclusionPattern),
|
||||
};
|
||||
|
||||
return { Edit, Delete };
|
||||
@@ -273,7 +273,7 @@ const handleDeleteLibraryFolder = async (library: LibraryResponseDto, folder: st
|
||||
});
|
||||
|
||||
if (!confirmed) {
|
||||
return false;
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -285,10 +285,7 @@ const handleDeleteLibraryFolder = async (library: LibraryResponseDto, folder: st
|
||||
toastManager.success($t('admin.library_updated'));
|
||||
} catch (error) {
|
||||
handleError(error, $t('errors.unable_to_update_library'));
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
export const handleAddLibraryExclusionPattern = async (library: LibraryResponseDto, exclusionPattern: string) => {
|
||||
@@ -345,9 +342,8 @@ const handleDeleteExclusionPattern = async (library: LibraryResponseDto, exclusi
|
||||
const $t = await getFormatter();
|
||||
|
||||
const confirmed = await modalManager.showDialog({ prompt: $t('admin.library_remove_exclusion_pattern_prompt') });
|
||||
|
||||
if (!confirmed) {
|
||||
return false;
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -361,8 +357,5 @@ const handleDeleteExclusionPattern = async (library: LibraryResponseDto, exclusi
|
||||
toastManager.success($t('admin.library_updated'));
|
||||
} catch (error) {
|
||||
handleError(error, $t('errors.unable_to_update_library'));
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
268
web/src/lib/services/queue.service.ts
Normal file
268
web/src/lib/services/queue.service.ts
Normal file
@@ -0,0 +1,268 @@
|
||||
import { goto } from '$app/navigation';
|
||||
import { AppRoute } from '$lib/constants';
|
||||
import { eventManager } from '$lib/managers/event-manager.svelte';
|
||||
import { queueManager } from '$lib/managers/queue-manager.svelte';
|
||||
import JobCreateModal from '$lib/modals/JobCreateModal.svelte';
|
||||
import type { HeaderButtonActionItem } from '$lib/types';
|
||||
import { handleError } from '$lib/utils/handle-error';
|
||||
import { getFormatter } from '$lib/utils/i18n';
|
||||
import {
|
||||
emptyQueue,
|
||||
getQueue,
|
||||
QueueCommand,
|
||||
QueueName,
|
||||
runQueueCommandLegacy,
|
||||
updateQueue,
|
||||
type QueueResponseDto,
|
||||
} from '@immich/sdk';
|
||||
import { modalManager, toastManager, type ActionItem, type IconLike } from '@immich/ui';
|
||||
import {
|
||||
mdiClose,
|
||||
mdiCog,
|
||||
mdiContentDuplicate,
|
||||
mdiDatabaseOutline,
|
||||
mdiFaceRecognition,
|
||||
mdiFileJpgBox,
|
||||
mdiFileXmlBox,
|
||||
mdiFolderMove,
|
||||
mdiImageSearch,
|
||||
mdiLibraryShelves,
|
||||
mdiOcr,
|
||||
mdiPause,
|
||||
mdiPlay,
|
||||
mdiPlus,
|
||||
mdiStateMachine,
|
||||
mdiTable,
|
||||
mdiTagFaces,
|
||||
mdiTrashCanOutline,
|
||||
mdiTrayFull,
|
||||
mdiVideo,
|
||||
} from '@mdi/js';
|
||||
import type { MessageFormatter } from 'svelte-i18n';
|
||||
|
||||
type QueueItem = {
|
||||
icon: IconLike;
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
};
|
||||
|
||||
export const getQueuesActions = ($t: MessageFormatter, queues: QueueResponseDto[] | undefined) => {
|
||||
const pausedQueues = (queues ?? []).filter(({ isPaused }) => isPaused).map(({ name }) => name);
|
||||
|
||||
const ResumePaused: HeaderButtonActionItem = {
|
||||
title: $t('resume_paused_jobs', { values: { count: pausedQueues.length } }),
|
||||
$if: () => pausedQueues.length > 0,
|
||||
icon: mdiPlay,
|
||||
onAction: () => handleResumePausedJobs(pausedQueues),
|
||||
data: {
|
||||
title: pausedQueues.join(', '),
|
||||
},
|
||||
};
|
||||
|
||||
const CreateJob: ActionItem = {
|
||||
icon: mdiPlus,
|
||||
title: $t('admin.create_job'),
|
||||
type: $t('command'),
|
||||
shortcuts: { shift: true, key: 'n' },
|
||||
onAction: async () => {
|
||||
await modalManager.show(JobCreateModal, {});
|
||||
},
|
||||
};
|
||||
|
||||
const ManageConcurrency: ActionItem = {
|
||||
icon: mdiCog,
|
||||
title: $t('admin.manage_concurrency'),
|
||||
description: $t('admin.manage_concurrency_description'),
|
||||
type: $t('page'),
|
||||
onAction: () => goto(`${AppRoute.ADMIN_SETTINGS}?isOpen=job`),
|
||||
};
|
||||
|
||||
return { ResumePaused, ManageConcurrency, CreateJob };
|
||||
};
|
||||
|
||||
export const getQueueActions = ($t: MessageFormatter, queue: QueueResponseDto) => {
|
||||
const Pause: ActionItem = {
|
||||
icon: mdiPause,
|
||||
title: $t('pause'),
|
||||
$if: () => !queue.isPaused,
|
||||
onAction: () => handlePauseQueue(queue),
|
||||
};
|
||||
|
||||
const Resume: ActionItem = {
|
||||
icon: mdiPlay,
|
||||
title: $t('resume'),
|
||||
$if: () => queue.isPaused,
|
||||
onAction: () => handleResumeQueue(queue),
|
||||
};
|
||||
|
||||
const Empty: ActionItem = {
|
||||
icon: mdiClose,
|
||||
title: $t('clear'),
|
||||
onAction: () => handleEmptyQueue(queue),
|
||||
};
|
||||
|
||||
const RemoveFailedJobs: ActionItem = {
|
||||
icon: mdiTrashCanOutline,
|
||||
color: 'danger',
|
||||
title: $t('admin.remove_failed_jobs'),
|
||||
onAction: () => handleRemoveFailedJobs(queue),
|
||||
};
|
||||
|
||||
return { Pause, Resume, Empty, RemoveFailedJobs };
|
||||
};
|
||||
|
||||
export const handlePauseQueue = async (queue: QueueResponseDto) => {
|
||||
const response = await updateQueue({ name: queue.name, queueUpdateDto: { isPaused: true } });
|
||||
eventManager.emit('QueueUpdate', response);
|
||||
};
|
||||
|
||||
export const handleResumeQueue = async (queue: QueueResponseDto) => {
|
||||
const response = await updateQueue({ name: queue.name, queueUpdateDto: { isPaused: false } });
|
||||
eventManager.emit('QueueUpdate', response);
|
||||
};
|
||||
|
||||
export const handleEmptyQueue = async (queue: QueueResponseDto) => {
|
||||
const $t = await getFormatter();
|
||||
const item = asQueueItem($t, queue);
|
||||
|
||||
try {
|
||||
await emptyQueue({ name: queue.name, queueDeleteDto: { failed: false } });
|
||||
const response = await getQueue({ name: queue.name });
|
||||
eventManager.emit('QueueUpdate', response);
|
||||
toastManager.success($t('admin.cleared_jobs', { values: { job: item.title } }));
|
||||
} catch (error) {
|
||||
handleError(error, $t('errors.something_went_wrong'));
|
||||
}
|
||||
};
|
||||
|
||||
const handleResumePausedJobs = async (queues: QueueName[]) => {
|
||||
const $t = await getFormatter();
|
||||
|
||||
try {
|
||||
for (const name of queues) {
|
||||
await runQueueCommandLegacy({ name, queueCommandDto: { command: QueueCommand.Resume, force: false } });
|
||||
}
|
||||
await queueManager.refresh();
|
||||
} catch (error) {
|
||||
handleError(error, $t('admin.failed_job_command', { values: { command: 'resume', job: 'paused jobs' } }));
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemoveFailedJobs = async (queue: QueueResponseDto) => {
|
||||
const $t = await getFormatter();
|
||||
|
||||
try {
|
||||
await emptyQueue({ name: queue.name, queueDeleteDto: { failed: true } });
|
||||
const response = await getQueue({ name: queue.name });
|
||||
eventManager.emit('QueueUpdate', response);
|
||||
toastManager.success();
|
||||
} catch (error) {
|
||||
handleError(error, $t('errors.something_went_wrong'));
|
||||
}
|
||||
};
|
||||
|
||||
export const asQueueItem = ($t: MessageFormatter, queue: { name: QueueName }): QueueItem => {
|
||||
// TODO merge this mapping with data from QueuePanel.svelte
|
||||
const items: Record<QueueName, QueueItem> = {
|
||||
[QueueName.ThumbnailGeneration]: {
|
||||
icon: mdiFileJpgBox,
|
||||
title: $t('admin.thumbnail_generation_job'),
|
||||
subtitle: $t('admin.thumbnail_generation_job_description'),
|
||||
},
|
||||
[QueueName.MetadataExtraction]: {
|
||||
icon: mdiTable,
|
||||
title: $t('admin.metadata_extraction_job'),
|
||||
subtitle: $t('admin.metadata_extraction_job_description'),
|
||||
},
|
||||
[QueueName.Library]: {
|
||||
icon: mdiLibraryShelves,
|
||||
title: $t('external_libraries'),
|
||||
subtitle: $t('admin.library_tasks_description'),
|
||||
},
|
||||
[QueueName.Sidecar]: {
|
||||
title: $t('admin.sidecar_job'),
|
||||
icon: mdiFileXmlBox,
|
||||
subtitle: $t('admin.sidecar_job_description'),
|
||||
},
|
||||
[QueueName.SmartSearch]: {
|
||||
icon: mdiImageSearch,
|
||||
title: $t('admin.machine_learning_smart_search'),
|
||||
subtitle: $t('admin.smart_search_job_description'),
|
||||
},
|
||||
[QueueName.DuplicateDetection]: {
|
||||
icon: mdiContentDuplicate,
|
||||
title: $t('admin.machine_learning_duplicate_detection'),
|
||||
subtitle: $t('admin.duplicate_detection_job_description'),
|
||||
},
|
||||
[QueueName.FaceDetection]: {
|
||||
icon: mdiFaceRecognition,
|
||||
title: $t('admin.face_detection'),
|
||||
subtitle: $t('admin.face_detection_description'),
|
||||
},
|
||||
[QueueName.FacialRecognition]: {
|
||||
icon: mdiTagFaces,
|
||||
title: $t('admin.machine_learning_facial_recognition'),
|
||||
subtitle: $t('admin.facial_recognition_job_description'),
|
||||
},
|
||||
[QueueName.Ocr]: {
|
||||
icon: mdiOcr,
|
||||
title: $t('admin.machine_learning_ocr'),
|
||||
subtitle: $t('admin.ocr_job_description'),
|
||||
},
|
||||
[QueueName.VideoConversion]: {
|
||||
icon: mdiVideo,
|
||||
title: $t('admin.video_conversion_job'),
|
||||
subtitle: $t('admin.video_conversion_job_description'),
|
||||
},
|
||||
[QueueName.StorageTemplateMigration]: {
|
||||
icon: mdiFolderMove,
|
||||
title: $t('admin.storage_template_migration'),
|
||||
},
|
||||
[QueueName.Migration]: {
|
||||
icon: mdiFolderMove,
|
||||
title: $t('admin.migration_job'),
|
||||
subtitle: $t('admin.migration_job_description'),
|
||||
},
|
||||
[QueueName.BackgroundTask]: {
|
||||
icon: mdiTrayFull,
|
||||
title: $t('admin.background_task_job'),
|
||||
},
|
||||
[QueueName.Search]: {
|
||||
icon: '',
|
||||
title: $t('search'),
|
||||
},
|
||||
[QueueName.Notifications]: {
|
||||
icon: '',
|
||||
title: $t('notifications'),
|
||||
},
|
||||
[QueueName.BackupDatabase]: {
|
||||
icon: mdiDatabaseOutline,
|
||||
title: $t('admin.backup_database'),
|
||||
},
|
||||
[QueueName.Workflow]: {
|
||||
icon: mdiStateMachine,
|
||||
title: $t('workflow'),
|
||||
},
|
||||
};
|
||||
|
||||
return items[queue.name];
|
||||
};
|
||||
|
||||
export const asQueueSlug = (name: QueueName) => {
|
||||
return name.replaceAll(/[A-Z]/g, (m) => '-' + m.toLowerCase());
|
||||
};
|
||||
|
||||
export const fromQueueSlug = (slug: string): QueueName | undefined => {
|
||||
const name = slug.replaceAll(/-([a-z])/g, (_, c) => c.toUpperCase());
|
||||
if (Object.values(QueueName).includes(name as QueueName)) {
|
||||
return name as QueueName;
|
||||
}
|
||||
};
|
||||
|
||||
export const getQueueDetailUrl = (queue: QueueResponseDto) => {
|
||||
return `${AppRoute.ADMIN_QUEUES}/${asQueueSlug(queue.name)}`;
|
||||
};
|
||||
|
||||
export const handleViewQueue = (queue: QueueResponseDto) => {
|
||||
return goto(getQueueDetailUrl(queue));
|
||||
};
|
||||
@@ -24,26 +24,26 @@ export const getSharedLinkActions = ($t: MessageFormatter, sharedLink: SharedLin
|
||||
const Edit: ActionItem = {
|
||||
title: $t('edit_link'),
|
||||
icon: mdiPencilOutline,
|
||||
onAction: () => void goto(`${AppRoute.SHARED_LINKS}/${sharedLink.id}`),
|
||||
onAction: () => goto(`${AppRoute.SHARED_LINKS}/${sharedLink.id}`),
|
||||
};
|
||||
|
||||
const Delete: ActionItem = {
|
||||
title: $t('delete_link'),
|
||||
icon: mdiTrashCanOutline,
|
||||
color: 'danger',
|
||||
onAction: () => void handleDeleteSharedLink(sharedLink),
|
||||
onAction: () => handleDeleteSharedLink(sharedLink),
|
||||
};
|
||||
|
||||
const Copy: ActionItem = {
|
||||
title: $t('copy_link'),
|
||||
icon: mdiContentCopy,
|
||||
onAction: () => void copyToClipboard(asUrl(sharedLink)),
|
||||
onAction: () => copyToClipboard(asUrl(sharedLink)),
|
||||
};
|
||||
|
||||
const ViewQrCode: ActionItem = {
|
||||
title: $t('view_qr_code'),
|
||||
icon: mdiQrcode,
|
||||
onAction: () => void handleShowSharedLinkQrCode(sharedLink),
|
||||
onAction: () => handleShowSharedLinkQrCode(sharedLink),
|
||||
};
|
||||
|
||||
return { Edit, Delete, Copy, ViewQrCode };
|
||||
@@ -88,7 +88,7 @@ export const handleUpdateSharedLink = async (sharedLink: SharedLinkResponseDto,
|
||||
}
|
||||
};
|
||||
|
||||
export const handleDeleteSharedLink = async (sharedLink: SharedLinkResponseDto): Promise<boolean> => {
|
||||
const handleDeleteSharedLink = async (sharedLink: SharedLinkResponseDto) => {
|
||||
const $t = await getFormatter();
|
||||
const success = await modalManager.showDialog({
|
||||
title: $t('delete_shared_link'),
|
||||
@@ -96,17 +96,15 @@ export const handleDeleteSharedLink = async (sharedLink: SharedLinkResponseDto):
|
||||
confirmText: $t('delete'),
|
||||
});
|
||||
if (!success) {
|
||||
return false;
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await removeSharedLink({ id: sharedLink.id });
|
||||
eventManager.emit('SharedLinkDelete', sharedLink);
|
||||
toastManager.success($t('deleted_shared_link'));
|
||||
return true;
|
||||
} catch (error) {
|
||||
handleError(error, $t('errors.unable_to_delete_shared_link'));
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@ export const getSystemConfigActions = (
|
||||
description: $t('admin.copy_config_to_clipboard_description'),
|
||||
type: $t('command'),
|
||||
icon: mdiContentCopy,
|
||||
onAction: () => void handleCopyToClipboard(config),
|
||||
onAction: () => handleCopyToClipboard(config),
|
||||
shortcuts: { shift: true, key: 'c' },
|
||||
};
|
||||
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import { goto } from '$app/navigation';
|
||||
import { eventManager } from '$lib/managers/event-manager.svelte';
|
||||
import { serverConfigManager } from '$lib/managers/server-config-manager.svelte';
|
||||
import PasswordResetSuccessModal from '$lib/modals/PasswordResetSuccessModal.svelte';
|
||||
import UserCreateModal from '$lib/modals/UserCreateModal.svelte';
|
||||
import UserDeleteConfirmModal from '$lib/modals/UserDeleteConfirmModal.svelte';
|
||||
import UserEditModal from '$lib/modals/UserEditModal.svelte';
|
||||
import UserRestoreConfirmModal from '$lib/modals/UserRestoreConfirmModal.svelte';
|
||||
import { user as authUser } from '$lib/stores/user.store';
|
||||
import type { HeaderButtonActionItem } from '$lib/types';
|
||||
import { handleError } from '$lib/utils/handle-error';
|
||||
import { getFormatter } from '$lib/utils/i18n';
|
||||
import {
|
||||
@@ -28,6 +30,7 @@ import {
|
||||
mdiPlusBoxOutline,
|
||||
mdiTrashCanOutline,
|
||||
} from '@mdi/js';
|
||||
import { DateTime } from 'luxon';
|
||||
import type { MessageFormatter } from 'svelte-i18n';
|
||||
import { get } from 'svelte/store';
|
||||
|
||||
@@ -36,7 +39,7 @@ export const getUserAdminsActions = ($t: MessageFormatter) => {
|
||||
title: $t('create_user'),
|
||||
type: $t('command'),
|
||||
icon: mdiPlusBoxOutline,
|
||||
onAction: () => void modalManager.show(UserCreateModal, {}),
|
||||
onAction: () => modalManager.show(UserCreateModal, {}),
|
||||
shortcuts: { shift: true, key: 'n' },
|
||||
};
|
||||
|
||||
@@ -60,11 +63,17 @@ export const getUserAdminActions = ($t: MessageFormatter, user: UserAdminRespons
|
||||
shortcuts: { key: 'Backspace' },
|
||||
};
|
||||
|
||||
const Restore: ActionItem = {
|
||||
const getDeleteDate = (deletedAt: string): Date =>
|
||||
DateTime.fromISO(deletedAt).plus({ days: serverConfigManager.value.userDeleteDelay }).toJSDate();
|
||||
|
||||
const Restore: HeaderButtonActionItem = {
|
||||
icon: mdiDeleteRestore,
|
||||
title: $t('restore'),
|
||||
type: $t('command'),
|
||||
color: 'primary',
|
||||
data: {
|
||||
title: $t('admin.user_restore_scheduled_removal', { values: { date: getDeleteDate(user.deletedAt!) } }),
|
||||
},
|
||||
$if: () => !!user.deletedAt && user.status === UserStatus.Deleted,
|
||||
onAction: () => modalManager.show(UserRestoreConfirmModal, { user }),
|
||||
};
|
||||
@@ -74,14 +83,14 @@ export const getUserAdminActions = ($t: MessageFormatter, user: UserAdminRespons
|
||||
title: $t('reset_password'),
|
||||
type: $t('command'),
|
||||
$if: () => get(authUser).id !== user.id,
|
||||
onAction: () => void handleResetPasswordUserAdmin(user),
|
||||
onAction: () => handleResetPasswordUserAdmin(user),
|
||||
};
|
||||
|
||||
const ResetPinCode: ActionItem = {
|
||||
icon: mdiLockSmart,
|
||||
type: $t('command'),
|
||||
title: $t('reset_pin_code'),
|
||||
onAction: () => void handleResetPinCodeUserAdmin(user),
|
||||
onAction: () => handleResetPinCodeUserAdmin(user),
|
||||
};
|
||||
|
||||
return { Update, Delete, Restore, ResetPassword, ResetPinCode };
|
||||
@@ -162,12 +171,12 @@ const generatePassword = (length: number = 16) => {
|
||||
return generatedPassword;
|
||||
};
|
||||
|
||||
export const handleResetPasswordUserAdmin = async (user: UserAdminResponseDto) => {
|
||||
const handleResetPasswordUserAdmin = async (user: UserAdminResponseDto) => {
|
||||
const $t = await getFormatter();
|
||||
const prompt = $t('admin.confirm_user_password_reset', { values: { user: user.name } });
|
||||
const success = await modalManager.showDialog({ prompt });
|
||||
if (!success) {
|
||||
return false;
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -176,28 +185,24 @@ export const handleResetPasswordUserAdmin = async (user: UserAdminResponseDto) =
|
||||
eventManager.emit('UserAdminUpdate', response);
|
||||
toastManager.success();
|
||||
await modalManager.show(PasswordResetSuccessModal, { newPassword: dto.password });
|
||||
return true;
|
||||
} catch (error) {
|
||||
handleError(error, $t('errors.unable_to_reset_password'));
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
export const handleResetPinCodeUserAdmin = async (user: UserAdminResponseDto) => {
|
||||
const handleResetPinCodeUserAdmin = async (user: UserAdminResponseDto) => {
|
||||
const $t = await getFormatter();
|
||||
const prompt = $t('admin.confirm_user_pin_code_reset', { values: { user: user.name } });
|
||||
const success = await modalManager.showDialog({ prompt });
|
||||
if (!success) {
|
||||
return false;
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await updateUserAdmin({ id: user.id, userAdminUpdateDto: { pinCode: null } });
|
||||
eventManager.emit('UserAdminUpdate', response);
|
||||
toastManager.success($t('pin_code_reset_successfully'));
|
||||
return true;
|
||||
} catch (error) {
|
||||
handleError(error, $t('errors.unable_to_reset_pin_code'));
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -2,17 +2,17 @@
|
||||
import BottomInfo from '$lib/components/shared-components/side-bar/bottom-info.svelte';
|
||||
import { AppRoute } from '$lib/constants';
|
||||
import { NavbarItem } from '@immich/ui';
|
||||
import { mdiAccountMultipleOutline, mdiBookshelf, mdiCog, mdiServer, mdiSync, mdiWrench } from '@mdi/js';
|
||||
import { mdiAccountMultipleOutline, mdiBookshelf, mdiCog, mdiServer, mdiTrayFull, mdiWrench } from '@mdi/js';
|
||||
import { t } from 'svelte-i18n';
|
||||
</script>
|
||||
|
||||
<div class="h-full flex flex-col justify-between gap-2">
|
||||
<div class="flex flex-col pt-8 pe-4 gap-1">
|
||||
<NavbarItem title={$t('users')} href={AppRoute.ADMIN_USERS} icon={mdiAccountMultipleOutline} />
|
||||
<NavbarItem title={$t('jobs')} href={AppRoute.ADMIN_JOBS} icon={mdiSync} />
|
||||
<NavbarItem title={$t('external_libraries')} href={AppRoute.ADMIN_LIBRARY_MANAGEMENT} icon={mdiBookshelf} />
|
||||
<NavbarItem title={$t('admin.queues')} href={AppRoute.ADMIN_QUEUES} icon={mdiTrayFull} />
|
||||
<NavbarItem title={$t('settings')} href={AppRoute.ADMIN_SETTINGS} icon={mdiCog} />
|
||||
<NavbarItem title={$t('admin.maintenance_settings')} href={AppRoute.ADMIN_MAINTENANCE} icon={mdiWrench} />
|
||||
<NavbarItem title={$t('external_libraries')} href={AppRoute.ADMIN_LIBRARY_MANAGEMENT} icon={mdiBookshelf} />
|
||||
<NavbarItem title={$t('server_stats')} href={AppRoute.ADMIN_STATS} icon={mdiServer} />
|
||||
</div>
|
||||
|
||||
|
||||
225
web/src/lib/stores/ocr.svelte.spec.ts
Normal file
225
web/src/lib/stores/ocr.svelte.spec.ts
Normal file
@@ -0,0 +1,225 @@
|
||||
import { ocrManager, type OcrBoundingBox } from '$lib/stores/ocr.svelte';
|
||||
import { getAssetOcr } from '@immich/sdk';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
// Mock the SDK
|
||||
vi.mock('@immich/sdk', () => ({
|
||||
getAssetOcr: vi.fn(),
|
||||
}));
|
||||
|
||||
const createMockOcrData = (overrides?: Partial<OcrBoundingBox>): OcrBoundingBox[] => [
|
||||
{
|
||||
id: '1',
|
||||
assetId: 'asset-123',
|
||||
x1: 0,
|
||||
y1: 0,
|
||||
x2: 100,
|
||||
y2: 0,
|
||||
x3: 100,
|
||||
y3: 50,
|
||||
x4: 0,
|
||||
y4: 50,
|
||||
boxScore: 0.95,
|
||||
textScore: 0.98,
|
||||
text: 'Hello World',
|
||||
...overrides,
|
||||
},
|
||||
];
|
||||
|
||||
describe('OcrManager', () => {
|
||||
beforeEach(() => {
|
||||
// Reset the singleton state before each test
|
||||
ocrManager.clear();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('initial state', () => {
|
||||
it('should initialize with empty data', () => {
|
||||
expect(ocrManager.data).toEqual([]);
|
||||
});
|
||||
|
||||
it('should initialize with showOverlay as false', () => {
|
||||
expect(ocrManager.showOverlay).toBe(false);
|
||||
});
|
||||
|
||||
it('should initialize with hasOcrData as false', () => {
|
||||
expect(ocrManager.hasOcrData).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getAssetOcr', () => {
|
||||
it('should load OCR data for an asset', async () => {
|
||||
const mockData = createMockOcrData();
|
||||
vi.mocked(getAssetOcr).mockResolvedValue(mockData);
|
||||
|
||||
await ocrManager.getAssetOcr('asset-123');
|
||||
|
||||
expect(getAssetOcr).toHaveBeenCalledWith({ id: 'asset-123' });
|
||||
expect(ocrManager.data).toEqual(mockData);
|
||||
expect(ocrManager.hasOcrData).toBe(true);
|
||||
});
|
||||
|
||||
it('should handle empty OCR data', async () => {
|
||||
vi.mocked(getAssetOcr).mockResolvedValue([]);
|
||||
|
||||
await ocrManager.getAssetOcr('asset-456');
|
||||
|
||||
expect(ocrManager.data).toEqual([]);
|
||||
expect(ocrManager.hasOcrData).toBe(false);
|
||||
});
|
||||
|
||||
it('should reset the loader when previously cleared', async () => {
|
||||
const mockData = createMockOcrData();
|
||||
vi.mocked(getAssetOcr).mockResolvedValue(mockData);
|
||||
|
||||
// First clear
|
||||
ocrManager.clear();
|
||||
expect(ocrManager.data).toEqual([]);
|
||||
|
||||
// Then load new data
|
||||
await ocrManager.getAssetOcr('asset-789');
|
||||
|
||||
expect(ocrManager.data).toEqual(mockData);
|
||||
expect(ocrManager.hasOcrData).toBe(true);
|
||||
});
|
||||
|
||||
it('should handle concurrent requests safely', async () => {
|
||||
const firstData = createMockOcrData({ id: '1', text: 'First' });
|
||||
const secondData = createMockOcrData({ id: '2', text: 'Second' });
|
||||
|
||||
vi.mocked(getAssetOcr)
|
||||
.mockImplementationOnce(
|
||||
() =>
|
||||
new Promise((resolve) => {
|
||||
setTimeout(() => resolve(firstData), 100);
|
||||
}),
|
||||
)
|
||||
.mockResolvedValueOnce(secondData);
|
||||
|
||||
// Start first request
|
||||
const promise1 = ocrManager.getAssetOcr('asset-1');
|
||||
// Start second request immediately (should wait for first to complete)
|
||||
const promise2 = ocrManager.getAssetOcr('asset-2');
|
||||
|
||||
await Promise.all([promise1, promise2]);
|
||||
|
||||
// CancellableTask waits for first request, so second request is ignored
|
||||
// The data should be from the first request that completed
|
||||
expect(ocrManager.data).toEqual(firstData);
|
||||
});
|
||||
|
||||
it('should handle errors gracefully', async () => {
|
||||
const error = new Error('Network error');
|
||||
vi.mocked(getAssetOcr).mockRejectedValue(error);
|
||||
|
||||
// The error should be handled by CancellableTask
|
||||
await expect(ocrManager.getAssetOcr('asset-error')).resolves.not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('clear', () => {
|
||||
it('should clear OCR data', async () => {
|
||||
const mockData = createMockOcrData({ text: 'Test' });
|
||||
vi.mocked(getAssetOcr).mockResolvedValue(mockData);
|
||||
await ocrManager.getAssetOcr('asset-123');
|
||||
|
||||
ocrManager.clear();
|
||||
|
||||
expect(ocrManager.data).toEqual([]);
|
||||
expect(ocrManager.hasOcrData).toBe(false);
|
||||
});
|
||||
|
||||
it('should reset showOverlay to false', () => {
|
||||
ocrManager.showOverlay = true;
|
||||
|
||||
ocrManager.clear();
|
||||
|
||||
expect(ocrManager.showOverlay).toBe(false);
|
||||
});
|
||||
|
||||
it('should mark as cleared for next load', async () => {
|
||||
const mockData = createMockOcrData({ text: 'Test' });
|
||||
vi.mocked(getAssetOcr).mockResolvedValue(mockData);
|
||||
|
||||
ocrManager.clear();
|
||||
await ocrManager.getAssetOcr('asset-123');
|
||||
|
||||
// Should successfully load after clear
|
||||
expect(ocrManager.data).toEqual(mockData);
|
||||
});
|
||||
});
|
||||
|
||||
describe('toggleOcrBoundingBox', () => {
|
||||
it('should toggle showOverlay from false to true', () => {
|
||||
expect(ocrManager.showOverlay).toBe(false);
|
||||
|
||||
ocrManager.toggleOcrBoundingBox();
|
||||
|
||||
expect(ocrManager.showOverlay).toBe(true);
|
||||
});
|
||||
|
||||
it('should toggle showOverlay from true to false', () => {
|
||||
ocrManager.showOverlay = true;
|
||||
|
||||
ocrManager.toggleOcrBoundingBox();
|
||||
|
||||
expect(ocrManager.showOverlay).toBe(false);
|
||||
});
|
||||
|
||||
it('should toggle multiple times', () => {
|
||||
ocrManager.toggleOcrBoundingBox();
|
||||
expect(ocrManager.showOverlay).toBe(true);
|
||||
|
||||
ocrManager.toggleOcrBoundingBox();
|
||||
expect(ocrManager.showOverlay).toBe(false);
|
||||
|
||||
ocrManager.toggleOcrBoundingBox();
|
||||
expect(ocrManager.showOverlay).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('hasOcrData derived state', () => {
|
||||
it('should be false when data is empty', () => {
|
||||
expect(ocrManager.hasOcrData).toBe(false);
|
||||
});
|
||||
|
||||
it('should be true when data is present', async () => {
|
||||
const mockData = createMockOcrData({ text: 'Test' });
|
||||
vi.mocked(getAssetOcr).mockResolvedValue(mockData);
|
||||
await ocrManager.getAssetOcr('asset-123');
|
||||
|
||||
expect(ocrManager.hasOcrData).toBe(true);
|
||||
});
|
||||
|
||||
it('should update when data is cleared', async () => {
|
||||
const mockData = createMockOcrData({ text: 'Test' });
|
||||
vi.mocked(getAssetOcr).mockResolvedValue(mockData);
|
||||
await ocrManager.getAssetOcr('asset-123');
|
||||
expect(ocrManager.hasOcrData).toBe(true);
|
||||
|
||||
ocrManager.clear();
|
||||
expect(ocrManager.hasOcrData).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('data immutability', () => {
|
||||
it('should return the same reference when data does not change', () => {
|
||||
const firstReference = ocrManager.data;
|
||||
const secondReference = ocrManager.data;
|
||||
|
||||
expect(firstReference).toBe(secondReference);
|
||||
});
|
||||
|
||||
it('should return a new reference when data changes', async () => {
|
||||
const firstReference = ocrManager.data;
|
||||
const mockData = createMockOcrData({ text: 'Test' });
|
||||
|
||||
vi.mocked(getAssetOcr).mockResolvedValue(mockData);
|
||||
await ocrManager.getAssetOcr('asset-123');
|
||||
|
||||
const secondReference = ocrManager.data;
|
||||
|
||||
expect(firstReference).not.toBe(secondReference);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,3 +1,4 @@
|
||||
import { CancellableTask } from '$lib/utils/cancellable-task';
|
||||
import { getAssetOcr } from '@immich/sdk';
|
||||
|
||||
export type OcrBoundingBox = {
|
||||
@@ -20,6 +21,8 @@ class OcrManager {
|
||||
#data = $state<OcrBoundingBox[]>([]);
|
||||
showOverlay = $state(false);
|
||||
#hasOcrData = $derived(this.#data.length > 0);
|
||||
#ocrLoader = new CancellableTask();
|
||||
#cleared = false;
|
||||
|
||||
get data() {
|
||||
return this.#data;
|
||||
@@ -30,10 +33,17 @@ class OcrManager {
|
||||
}
|
||||
|
||||
async getAssetOcr(id: string) {
|
||||
this.#data = await getAssetOcr({ id });
|
||||
if (this.#cleared) {
|
||||
await this.#ocrLoader.reset();
|
||||
this.#cleared = false;
|
||||
}
|
||||
await this.#ocrLoader.execute(async () => {
|
||||
this.#data = await getAssetOcr({ id });
|
||||
}, false);
|
||||
}
|
||||
|
||||
clear() {
|
||||
this.#cleared = true;
|
||||
this.#data = [];
|
||||
this.showOverlay = false;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { ServerVersionResponseDto } from '@immich/sdk';
|
||||
import type { QueueResponseDto, ServerVersionResponseDto } from '@immich/sdk';
|
||||
import type { ActionItem } from '@immich/ui';
|
||||
|
||||
export interface ReleaseEvent {
|
||||
isAvailable: boolean;
|
||||
@@ -7,3 +8,7 @@ export interface ReleaseEvent {
|
||||
serverVersion: ServerVersionResponseDto;
|
||||
releaseVersion: ServerVersionResponseDto;
|
||||
}
|
||||
|
||||
export type QueueSnapshot = { timestamp: number; snapshot?: QueueResponseDto[] };
|
||||
|
||||
export type HeaderButtonActionItem = ActionItem & { data?: { title?: string } };
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { TimelineAsset, ViewportTopMonth } from '$lib/managers/timeline-manager/types';
|
||||
import type { AssetDescriptor, TimelineAsset, ViewportTopMonth } from '$lib/managers/timeline-manager/types';
|
||||
import { locale } from '$lib/stores/preferences.store';
|
||||
import { getAssetRatio } from '$lib/utils/asset-utils';
|
||||
import { AssetTypeEnum, type AssetResponseDto } from '@immich/sdk';
|
||||
@@ -192,8 +192,13 @@ export const toTimelineAsset = (unknownAsset: AssetResponseDto | TimelineAsset):
|
||||
};
|
||||
};
|
||||
|
||||
export const isTimelineAsset = (unknownAsset: AssetResponseDto | TimelineAsset): unknownAsset is TimelineAsset =>
|
||||
(unknownAsset as TimelineAsset).ratio !== undefined;
|
||||
export const isTimelineAsset = (
|
||||
unknownAsset: AssetDescriptor | AssetResponseDto | TimelineAsset,
|
||||
): unknownAsset is TimelineAsset => (unknownAsset as TimelineAsset).ratio !== undefined;
|
||||
|
||||
export const isAssetResponseDto = (
|
||||
unknownAsset: AssetDescriptor | AssetResponseDto | TimelineAsset,
|
||||
): unknownAsset is AssetResponseDto => (unknownAsset as AssetResponseDto).type !== undefined;
|
||||
|
||||
export const isTimelineAssets = (assets: AssetResponseDto[] | TimelineAsset[]): assets is TimelineAsset[] =>
|
||||
assets.length === 0 || 'ratio' in assets[0];
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { page } from '$app/state';
|
||||
import UserPageLayout from '$lib/components/layouts/user-page-layout.svelte';
|
||||
import OnEvents from '$lib/components/OnEvents.svelte';
|
||||
import SharedLinkCard from '$lib/components/sharedlinks-page/shared-link-card.svelte';
|
||||
import SharedLinkCard from '$lib/components/sharedlinks-page/SharedLinkCard.svelte';
|
||||
import { AppRoute } from '$lib/constants';
|
||||
import GroupTab from '$lib/elements/GroupTab.svelte';
|
||||
import SharedLinkUpdateModal from '$lib/modals/SharedLinkUpdateModal.svelte';
|
||||
|
||||
@@ -15,9 +15,24 @@
|
||||
import { joinPaths, TreeNode } from '$lib/utils/tree-utils';
|
||||
import { deleteTag, getAllTags, type TagResponseDto } from '@immich/sdk';
|
||||
import { Button, HStack, modalManager, Text } from '@immich/ui';
|
||||
import { mdiPencil, mdiPlus, mdiTag, mdiTagMultiple, mdiTrashCanOutline } from '@mdi/js';
|
||||
import { mdiDotsVertical, mdiPencil, mdiPlus, mdiTag, mdiTagMultiple, mdiTrashCanOutline } from '@mdi/js';
|
||||
import { t } from 'svelte-i18n';
|
||||
import type { PageData } from './$types';
|
||||
import AssetSelectControlBar from '$lib/components/timeline/AssetSelectControlBar.svelte';
|
||||
import AddToAlbum from '$lib/components/timeline/actions/AddToAlbumAction.svelte';
|
||||
import ArchiveAction from '$lib/components/timeline/actions/ArchiveAction.svelte';
|
||||
import ChangeDate from '$lib/components/timeline/actions/ChangeDateAction.svelte';
|
||||
import ChangeDescription from '$lib/components/timeline/actions/ChangeDescriptionAction.svelte';
|
||||
import ChangeLocation from '$lib/components/timeline/actions/ChangeLocationAction.svelte';
|
||||
import CreateSharedLink from '$lib/components/timeline/actions/CreateSharedLinkAction.svelte';
|
||||
import DeleteAssets from '$lib/components/timeline/actions/DeleteAssetsAction.svelte';
|
||||
import DownloadAction from '$lib/components/timeline/actions/DownloadAction.svelte';
|
||||
import FavoriteAction from '$lib/components/timeline/actions/FavoriteAction.svelte';
|
||||
import SelectAllAssets from '$lib/components/timeline/actions/SelectAllAction.svelte';
|
||||
import SetVisibilityAction from '$lib/components/timeline/actions/SetVisibilityAction.svelte';
|
||||
import TagAction from '$lib/components/timeline/actions/TagAction.svelte';
|
||||
import ButtonContextMenu from '$lib/components/shared-components/context-menu/button-context-menu.svelte';
|
||||
import { preferences, user } from '$lib/stores/user.store';
|
||||
|
||||
interface Props {
|
||||
data: PageData;
|
||||
@@ -79,6 +94,11 @@
|
||||
// navigate to parent
|
||||
await navigateToView(tag.parent ? tag.parent.path : '');
|
||||
};
|
||||
|
||||
const handleSetVisibility = (assetIds: string[]) => {
|
||||
timelineManager.removeAssets(assetIds);
|
||||
assetInteraction.clearMultiselect();
|
||||
};
|
||||
</script>
|
||||
|
||||
<UserPageLayout title={data.meta.title}>
|
||||
@@ -131,3 +151,45 @@
|
||||
{/if}
|
||||
</section>
|
||||
</UserPageLayout>
|
||||
|
||||
<section>
|
||||
{#if assetInteraction.selectionActive}
|
||||
<div class="fixed top-0 start-0 w-full">
|
||||
<AssetSelectControlBar
|
||||
ownerId={$user.id}
|
||||
assets={assetInteraction.selectedAssets}
|
||||
clearSelect={() => assetInteraction.clearMultiselect()}
|
||||
>
|
||||
<CreateSharedLink />
|
||||
<SelectAllAssets {timelineManager} {assetInteraction} />
|
||||
<ButtonContextMenu icon={mdiPlus} title={$t('add_to')}>
|
||||
<AddToAlbum />
|
||||
<AddToAlbum shared />
|
||||
</ButtonContextMenu>
|
||||
<FavoriteAction
|
||||
removeFavorite={assetInteraction.isAllFavorite}
|
||||
onFavorite={(ids, isFavorite) => timelineManager.update(ids, (asset) => (asset.isFavorite = isFavorite))}
|
||||
></FavoriteAction>
|
||||
<ButtonContextMenu icon={mdiDotsVertical} title={$t('menu')}>
|
||||
<DownloadAction menuItem />
|
||||
<ChangeDate menuItem />
|
||||
<ChangeDescription menuItem />
|
||||
<ChangeLocation menuItem />
|
||||
<ArchiveAction
|
||||
menuItem
|
||||
onArchive={(ids, visibility) => timelineManager.update(ids, (asset) => (asset.visibility = visibility))}
|
||||
/>
|
||||
{#if $preferences.tags.enabled}
|
||||
<TagAction menuItem />
|
||||
{/if}
|
||||
<DeleteAssets
|
||||
menuItem
|
||||
onAssetDelete={(assetIds) => timelineManager.removeAssets(assetIds)}
|
||||
onUndoDelete={(assets) => timelineManager.upsertAssets(assets)}
|
||||
/>
|
||||
<SetVisibilityAction menuItem onVisibilitySet={handleSetVisibility} />
|
||||
</ButtonContextMenu>
|
||||
</AssetSelectControlBar>
|
||||
</div>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
@@ -14,13 +14,14 @@
|
||||
import { themeManager } from '$lib/managers/theme-manager.svelte';
|
||||
import ServerRestartingModal from '$lib/modals/ServerRestartingModal.svelte';
|
||||
import VersionAnnouncementModal from '$lib/modals/VersionAnnouncementModal.svelte';
|
||||
import { sidebarStore } from '$lib/stores/sidebar.svelte';
|
||||
import { user } from '$lib/stores/user.store';
|
||||
import { closeWebsocketConnection, openWebsocketConnection, websocketStore } from '$lib/stores/websocket';
|
||||
import type { ReleaseEvent } from '$lib/types';
|
||||
import { copyToClipboard, getReleaseType, semverToName } from '$lib/utils';
|
||||
import { maintenanceShouldRedirect } from '$lib/utils/maintenance';
|
||||
import { isAssetViewerRoute } from '$lib/utils/navigation';
|
||||
import { CommandPaletteContext, modalManager, setTranslations, type ActionItem } from '@immich/ui';
|
||||
import { CommandPaletteContext, modalManager, setTranslations, toastManager, type ActionItem } from '@immich/ui';
|
||||
import { mdiAccountMultipleOutline, mdiBookshelf, mdiCog, mdiServer, mdiSync, mdiThemeLightDark } from '@mdi/js';
|
||||
import { onMount, type Snippet } from 'svelte';
|
||||
import { t } from 'svelte-i18n';
|
||||
@@ -52,6 +53,8 @@
|
||||
return new URL(page.url.pathname + page.url.search, 'https://my.immich.app');
|
||||
};
|
||||
|
||||
toastManager.setOptions({ class: 'top-16' });
|
||||
|
||||
onMount(() => {
|
||||
const element = document.querySelector('#stencil');
|
||||
element?.remove();
|
||||
@@ -61,6 +64,10 @@
|
||||
eventManager.emit('AppInit');
|
||||
|
||||
beforeNavigate(({ from, to }) => {
|
||||
if (sidebarStore.isOpen) {
|
||||
sidebarStore.reset();
|
||||
}
|
||||
|
||||
if (isAssetViewerRoute(from) && isAssetViewerRoute(to)) {
|
||||
return;
|
||||
}
|
||||
@@ -142,18 +149,19 @@
|
||||
icon: mdiAccountMultipleOutline,
|
||||
onAction: () => goto(AppRoute.ADMIN_USERS),
|
||||
},
|
||||
{
|
||||
title: $t('jobs'),
|
||||
description: $t('admin.jobs_page_description'),
|
||||
icon: mdiSync,
|
||||
onAction: () => goto(AppRoute.ADMIN_JOBS),
|
||||
},
|
||||
{
|
||||
title: $t('settings'),
|
||||
description: $t('admin.jobs_page_description'),
|
||||
description: $t('admin.settings_page_description'),
|
||||
icon: mdiCog,
|
||||
onAction: () => goto(AppRoute.ADMIN_SETTINGS),
|
||||
},
|
||||
{
|
||||
title: $t('admin.queues'),
|
||||
description: $t('admin.queues_page_description'),
|
||||
icon: mdiSync,
|
||||
type: $t('page'),
|
||||
onAction: () => goto(AppRoute.ADMIN_QUEUES),
|
||||
},
|
||||
{
|
||||
title: $t('external_libraries'),
|
||||
description: $t('admin.external_libraries_page_description'),
|
||||
|
||||
@@ -1,116 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import JobsPanel from '$lib/components/jobs/JobsPanel.svelte';
|
||||
import AdminPageLayout from '$lib/components/layouts/AdminPageLayout.svelte';
|
||||
import { AppRoute } from '$lib/constants';
|
||||
import JobCreateModal from '$lib/modals/JobCreateModal.svelte';
|
||||
import { asyncTimeout } from '$lib/utils';
|
||||
import { handleError } from '$lib/utils/handle-error';
|
||||
import {
|
||||
getQueuesLegacy,
|
||||
QueueCommand,
|
||||
QueueName,
|
||||
runQueueCommandLegacy,
|
||||
type QueuesResponseLegacyDto,
|
||||
} from '@immich/sdk';
|
||||
import { Button, CommandPaletteContext, HStack, modalManager, Text, type ActionItem } from '@immich/ui';
|
||||
import { mdiCog, mdiPlay, mdiPlus } from '@mdi/js';
|
||||
import { onDestroy, onMount } from 'svelte';
|
||||
import { t } from 'svelte-i18n';
|
||||
import type { PageData } from './$types';
|
||||
|
||||
interface Props {
|
||||
data: PageData;
|
||||
}
|
||||
|
||||
let { data }: Props = $props();
|
||||
|
||||
let jobs: QueuesResponseLegacyDto | undefined = $state();
|
||||
|
||||
let running = true;
|
||||
|
||||
const pausedJobs = $derived(
|
||||
Object.entries(jobs ?? {})
|
||||
.filter(([_, queue]) => queue.queueStatus?.isPaused)
|
||||
.map(([name]) => name as QueueName),
|
||||
);
|
||||
|
||||
const handleResumePausedJobs = async () => {
|
||||
try {
|
||||
for (const name of pausedJobs) {
|
||||
await runQueueCommandLegacy({ name, queueCommandDto: { command: QueueCommand.Resume, force: false } });
|
||||
}
|
||||
// Refresh jobs status immediately after resuming
|
||||
jobs = await getQueuesLegacy();
|
||||
} catch (error) {
|
||||
handleError(error, $t('admin.failed_job_command', { values: { command: 'resume', job: 'paused jobs' } }));
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreateJob = () => modalManager.show(JobCreateModal);
|
||||
|
||||
const jobConcurrencyLink = `${AppRoute.ADMIN_SETTINGS}?isOpen=job`;
|
||||
|
||||
const commands: ActionItem[] = [
|
||||
{
|
||||
title: $t('admin.create_job'),
|
||||
type: $t('command'),
|
||||
icon: mdiPlus,
|
||||
onAction: () => void handleCreateJob(),
|
||||
shortcuts: { shift: true, key: 'n' },
|
||||
},
|
||||
{
|
||||
title: $t('admin.manage_concurrency'),
|
||||
description: $t('admin.manage_concurrency_description'),
|
||||
type: $t('page'),
|
||||
icon: mdiCog,
|
||||
onAction: () => goto(jobConcurrencyLink),
|
||||
},
|
||||
];
|
||||
|
||||
onMount(async () => {
|
||||
while (running) {
|
||||
jobs = await getQueuesLegacy();
|
||||
await asyncTimeout(5000);
|
||||
}
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
running = false;
|
||||
});
|
||||
</script>
|
||||
|
||||
<CommandPaletteContext {commands} />
|
||||
|
||||
<AdminPageLayout breadcrumbs={[{ title: data.meta.title }]}>
|
||||
{#snippet buttons()}
|
||||
<HStack gap={0}>
|
||||
{#if pausedJobs.length > 0}
|
||||
<Button
|
||||
leadingIcon={mdiPlay}
|
||||
onclick={handleResumePausedJobs}
|
||||
size="small"
|
||||
variant="ghost"
|
||||
title={pausedJobs.join(', ')}
|
||||
>
|
||||
<Text class="hidden md:block">
|
||||
{$t('resume_paused_jobs', { values: { count: pausedJobs.length } })}
|
||||
</Text>
|
||||
</Button>
|
||||
{/if}
|
||||
<Button leadingIcon={mdiPlus} onclick={handleCreateJob} size="small" variant="ghost" color="secondary">
|
||||
<Text class="hidden md:block">{$t('admin.create_job')}</Text>
|
||||
</Button>
|
||||
<Button leadingIcon={mdiCog} href={jobConcurrencyLink} size="small" variant="ghost" color="secondary">
|
||||
<Text class="hidden md:block">{$t('admin.manage_concurrency')}</Text>
|
||||
</Button>
|
||||
</HStack>
|
||||
{/snippet}
|
||||
<section id="setting-content" class="flex place-content-center sm:mx-4">
|
||||
<section class="w-full pb-28 sm:w-5/6 md:w-212.5">
|
||||
{#if jobs}
|
||||
<JobsPanel {jobs} />
|
||||
{/if}
|
||||
</section>
|
||||
</section>
|
||||
</AdminPageLayout>
|
||||
@@ -1,18 +1,5 @@
|
||||
import { authenticate } from '$lib/utils/auth';
|
||||
import { getFormatter } from '$lib/utils/i18n';
|
||||
import { getQueuesLegacy } from '@immich/sdk';
|
||||
import { AppRoute } from '$lib/constants';
|
||||
import { redirect } from '@sveltejs/kit';
|
||||
import type { PageLoad } from './$types';
|
||||
|
||||
export const load = (async ({ url }) => {
|
||||
await authenticate(url, { admin: true });
|
||||
|
||||
const jobs = await getQueuesLegacy();
|
||||
const $t = await getFormatter();
|
||||
|
||||
return {
|
||||
jobs,
|
||||
meta: {
|
||||
title: $t('admin.job_status'),
|
||||
},
|
||||
};
|
||||
}) satisfies PageLoad;
|
||||
export const load = (() => redirect(307, AppRoute.ADMIN_QUEUES)) satisfies PageLoad;
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import HeaderButton from '$lib/components/HeaderButton.svelte';
|
||||
import AdminPageLayout from '$lib/components/layouts/AdminPageLayout.svelte';
|
||||
import OnEvents from '$lib/components/OnEvents.svelte';
|
||||
import EmptyPlaceholder from '$lib/components/shared-components/empty-placeholder.svelte';
|
||||
@@ -60,17 +59,11 @@
|
||||
|
||||
<CommandPaletteContext commands={[Create, ScanAll]} />
|
||||
|
||||
<AdminPageLayout breadcrumbs={[{ title: data.meta.title }]}>
|
||||
{#snippet buttons()}
|
||||
<div class="flex justify-end gap-2">
|
||||
<HeaderButton action={ScanAll} />
|
||||
<HeaderButton action={Create} />
|
||||
</div>
|
||||
{/snippet}
|
||||
<AdminPageLayout breadcrumbs={[{ title: data.meta.title }]} actions={[ScanAll, Create]}>
|
||||
<section class="my-4">
|
||||
<div class="flex flex-col items-center gap-2" in:fade={{ duration: 500 }}>
|
||||
{#if libraries.length > 0}
|
||||
<table class="w-3/4 text-start">
|
||||
<table class="text-start">
|
||||
<thead
|
||||
class="mb-4 flex h-12 w-full rounded-md border bg-gray-50 text-primary dark:border-immich-dark-gray dark:bg-immich-dark-gray"
|
||||
>
|
||||
|
||||
@@ -23,7 +23,7 @@ export const load = (async ({ url }) => {
|
||||
statistics: Object.fromEntries(statistics),
|
||||
owners: Object.fromEntries(owners),
|
||||
meta: {
|
||||
title: $t('admin.external_library_management'),
|
||||
title: $t('external_libraries'),
|
||||
},
|
||||
};
|
||||
}) satisfies PageLoad;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import emptyFoldersUrl from '$lib/assets/empty-folders.svg';
|
||||
import HeaderButton from '$lib/components/HeaderButton.svelte';
|
||||
import HeaderActionButton from '$lib/components/HeaderActionButton.svelte';
|
||||
import AdminPageLayout from '$lib/components/layouts/AdminPageLayout.svelte';
|
||||
import OnEvents from '$lib/components/OnEvents.svelte';
|
||||
import ServerStatisticsCard from '$lib/components/server-statistics/ServerStatisticsCard.svelte';
|
||||
@@ -53,18 +53,9 @@
|
||||
<CommandPaletteContext commands={[Rename, Delete, AddFolder, AddExclusionPattern, Scan]} />
|
||||
|
||||
<AdminPageLayout
|
||||
breadcrumbs={[
|
||||
{ title: $t('admin.external_library_management'), href: AppRoute.ADMIN_LIBRARY_MANAGEMENT },
|
||||
{ title: library.name },
|
||||
]}
|
||||
breadcrumbs={[{ title: $t('external_libraries'), href: AppRoute.ADMIN_LIBRARY_MANAGEMENT }, { title: library.name }]}
|
||||
actions={[Scan, Rename, Delete]}
|
||||
>
|
||||
{#snippet buttons()}
|
||||
<div class="flex justify-end gap-2">
|
||||
<HeaderButton action={Scan} />
|
||||
<HeaderButton action={Rename} />
|
||||
<HeaderButton action={Delete} />
|
||||
</div>
|
||||
{/snippet}
|
||||
<Container size="large" center>
|
||||
<div class="grid gap-4 grid-cols-1 lg:grid-cols-2 w-full">
|
||||
<Heading tag="h1" size="large" class="col-span-full my-4">{library.name}</Heading>
|
||||
@@ -80,7 +71,7 @@
|
||||
<Icon icon={mdiFolderOutline} size="1.5rem" />
|
||||
<CardTitle>{$t('folders')}</CardTitle>
|
||||
</div>
|
||||
<HeaderButton action={AddFolder} />
|
||||
<HeaderActionButton action={AddFolder} />
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardBody>
|
||||
@@ -120,7 +111,7 @@
|
||||
<Icon icon={mdiFilterMinusOutline} size="1.5rem" />
|
||||
<CardTitle>{$t('exclusion_pattern')}</CardTitle>
|
||||
</div>
|
||||
<HeaderButton action={AddExclusionPattern} />
|
||||
<HeaderActionButton action={AddExclusionPattern} />
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardBody>
|
||||
|
||||
48
web/src/routes/admin/queues/+page.svelte
Normal file
48
web/src/routes/admin/queues/+page.svelte
Normal file
@@ -0,0 +1,48 @@
|
||||
<script lang="ts">
|
||||
import AdminPageLayout from '$lib/components/layouts/AdminPageLayout.svelte';
|
||||
import OnEvents from '$lib/components/OnEvents.svelte';
|
||||
import JobsPanel from '$lib/components/QueuePanel.svelte';
|
||||
import { queueManager } from '$lib/managers/queue-manager.svelte';
|
||||
import { getQueuesActions } from '$lib/services/queue.service';
|
||||
import { type QueueResponseDto } from '@immich/sdk';
|
||||
import { CommandPaletteContext, type ActionItem } from '@immich/ui';
|
||||
import { onMount } from 'svelte';
|
||||
import { t } from 'svelte-i18n';
|
||||
import type { PageData } from './$types';
|
||||
|
||||
type Props = {
|
||||
data: PageData;
|
||||
};
|
||||
|
||||
const { data }: Props = $props();
|
||||
|
||||
onMount(() => queueManager.listen());
|
||||
|
||||
let queues = $derived<QueueResponseDto[]>(queueManager.queues);
|
||||
|
||||
const { ResumePaused, CreateJob, ManageConcurrency } = $derived(getQueuesActions($t, queueManager.queues));
|
||||
const commands: ActionItem[] = $derived([CreateJob, ManageConcurrency]);
|
||||
|
||||
const onQueueUpdate = (update: QueueResponseDto) => {
|
||||
queues = queues.map((queue) => {
|
||||
if (queue.name === update.name) {
|
||||
return update;
|
||||
}
|
||||
return queue;
|
||||
});
|
||||
};
|
||||
</script>
|
||||
|
||||
<CommandPaletteContext {commands} />
|
||||
|
||||
<OnEvents {onQueueUpdate} />
|
||||
|
||||
<AdminPageLayout breadcrumbs={[{ title: data.meta.title }]} actions={[ResumePaused, CreateJob, ManageConcurrency]}>
|
||||
<section id="setting-content" class="flex place-content-center sm:mx-4">
|
||||
<section class="w-full pb-28 sm:w-5/6 md:w-212.5">
|
||||
{#if queues}
|
||||
<JobsPanel {queues} />
|
||||
{/if}
|
||||
</section>
|
||||
</section>
|
||||
</AdminPageLayout>
|
||||
18
web/src/routes/admin/queues/+page.ts
Normal file
18
web/src/routes/admin/queues/+page.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { authenticate } from '$lib/utils/auth';
|
||||
import { getFormatter } from '$lib/utils/i18n';
|
||||
import { getQueues } from '@immich/sdk';
|
||||
import type { PageLoad } from './$types';
|
||||
|
||||
export const load = (async ({ url }) => {
|
||||
await authenticate(url, { admin: true });
|
||||
|
||||
const queues = await getQueues();
|
||||
const $t = await getFormatter();
|
||||
|
||||
return {
|
||||
queues,
|
||||
meta: {
|
||||
title: $t('admin.queues'),
|
||||
},
|
||||
};
|
||||
}) satisfies PageLoad;
|
||||
87
web/src/routes/admin/queues/[name]/+page.svelte
Normal file
87
web/src/routes/admin/queues/[name]/+page.svelte
Normal file
@@ -0,0 +1,87 @@
|
||||
<script lang="ts">
|
||||
import AdminPageLayout from '$lib/components/layouts/AdminPageLayout.svelte';
|
||||
import OnEvents from '$lib/components/OnEvents.svelte';
|
||||
import QueueGraph from '$lib/components/QueueGraph.svelte';
|
||||
import { AppRoute } from '$lib/constants';
|
||||
import { queueManager } from '$lib/managers/queue-manager.svelte';
|
||||
import { asQueueItem, getQueueActions } from '$lib/services/queue.service';
|
||||
import { type QueueResponseDto } from '@immich/sdk';
|
||||
import {
|
||||
Badge,
|
||||
Card,
|
||||
CardBody,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
Container,
|
||||
Heading,
|
||||
Icon,
|
||||
MenuItemType,
|
||||
Text,
|
||||
} from '@immich/ui';
|
||||
import { mdiClockTimeTwoOutline } from '@mdi/js';
|
||||
import { onMount } from 'svelte';
|
||||
import { t } from 'svelte-i18n';
|
||||
import type { PageData } from './$types';
|
||||
|
||||
type Props = {
|
||||
data: PageData;
|
||||
};
|
||||
|
||||
const { data }: Props = $props();
|
||||
|
||||
let queue = $derived(data.queue);
|
||||
|
||||
const { Pause, Resume, Empty, RemoveFailedJobs } = $derived(getQueueActions($t, queue));
|
||||
const item = $derived(asQueueItem($t, queue));
|
||||
|
||||
onMount(() => queueManager.listen());
|
||||
|
||||
const onQueueUpdate = (update: QueueResponseDto) => {
|
||||
if (update.name === queue.name) {
|
||||
queue = update;
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<OnEvents {onQueueUpdate} />
|
||||
|
||||
<AdminPageLayout
|
||||
breadcrumbs={[{ title: $t('admin.queues'), href: AppRoute.ADMIN_QUEUES }, { title: item.title }]}
|
||||
actions={[Pause, Resume, Empty, MenuItemType.Divider, RemoveFailedJobs]}
|
||||
>
|
||||
<div>
|
||||
<Container size="large" center>
|
||||
<div class="mb-1 mt-4 flex items-center gap-2">
|
||||
<Heading tag="h1" size="large">{item.title}</Heading>
|
||||
{#if queue.isPaused}
|
||||
<Badge color="warning">
|
||||
{$t('paused')}
|
||||
</Badge>
|
||||
{/if}
|
||||
</div>
|
||||
<Text color="muted" class="mb-4">{item.subtitle}</Text>
|
||||
|
||||
<div class="flex gap-1 mb-4">
|
||||
<Badge>{$t('active_count', { values: { count: queue.statistics.active } })}</Badge>
|
||||
<Badge>{$t('waiting_count', { values: { count: queue.statistics.waiting } })}</Badge>
|
||||
{#if queue.statistics.failed > 0}
|
||||
<Badge color="danger">{$t('failed_count', { values: { count: queue.statistics.failed } })}</Badge>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="mt-8">
|
||||
<Card color="secondary">
|
||||
<CardHeader>
|
||||
<div class="flex items-center gap-2 text-primary">
|
||||
<Icon icon={mdiClockTimeTwoOutline} size="1.5rem" />
|
||||
<CardTitle>{$t('admin.jobs_over_time')}</CardTitle>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardBody>
|
||||
<QueueGraph {queue} class="h-[300px]" />
|
||||
</CardBody>
|
||||
</Card>
|
||||
</div>
|
||||
</Container>
|
||||
</div>
|
||||
</AdminPageLayout>
|
||||
31
web/src/routes/admin/queues/[name]/+page.ts
Normal file
31
web/src/routes/admin/queues/[name]/+page.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { AppRoute } from '$lib/constants';
|
||||
import { fromQueueSlug } from '$lib/services/queue.service';
|
||||
import { authenticate, requestServerInfo } from '$lib/utils/auth';
|
||||
import { getFormatter } from '$lib/utils/i18n';
|
||||
import { getQueue, getQueueJobs, QueueJobStatus } from '@immich/sdk';
|
||||
import { redirect } from '@sveltejs/kit';
|
||||
import type { PageLoad } from './$types';
|
||||
|
||||
export const load = (async ({ params, url }) => {
|
||||
await authenticate(url, { admin: true });
|
||||
await requestServerInfo();
|
||||
|
||||
const name = fromQueueSlug(params.name);
|
||||
if (!name) {
|
||||
redirect(302, AppRoute.ADMIN_QUEUES);
|
||||
}
|
||||
|
||||
const [queue, failedJobs] = await Promise.all([
|
||||
getQueue({ name }),
|
||||
getQueueJobs({ name, status: [QueueJobStatus.Failed, QueueJobStatus.Paused] }),
|
||||
]);
|
||||
const $t = await getFormatter();
|
||||
|
||||
return {
|
||||
queue,
|
||||
failedJobs,
|
||||
meta: {
|
||||
title: $t('admin.queue_details'),
|
||||
},
|
||||
};
|
||||
}) satisfies PageLoad;
|
||||
@@ -17,7 +17,6 @@
|
||||
import ThemeSettings from '$lib/components/admin-settings/ThemeSettings.svelte';
|
||||
import TrashSettings from '$lib/components/admin-settings/TrashSettings.svelte';
|
||||
import UserSettings from '$lib/components/admin-settings/UserSettings.svelte';
|
||||
import HeaderButton from '$lib/components/HeaderButton.svelte';
|
||||
import AdminPageLayout from '$lib/components/layouts/AdminPageLayout.svelte';
|
||||
import SettingAccordionState from '$lib/components/shared-components/settings/setting-accordion-state.svelte';
|
||||
import SettingAccordion from '$lib/components/shared-components/settings/setting-accordion.svelte';
|
||||
@@ -26,7 +25,7 @@
|
||||
import { featureFlagsManager } from '$lib/managers/feature-flags-manager.svelte';
|
||||
import { systemConfigManager } from '$lib/managers/system-config-manager.svelte';
|
||||
import { getSystemConfigActions } from '$lib/services/system-config.service';
|
||||
import { Alert, CommandPaletteContext, HStack } from '@immich/ui';
|
||||
import { Alert, CommandPaletteContext } from '@immich/ui';
|
||||
import {
|
||||
mdiAccountOutline,
|
||||
mdiBackupRestore,
|
||||
@@ -208,24 +207,13 @@
|
||||
|
||||
<CommandPaletteContext commands={[CopyToClipboard, Upload, Download]} />
|
||||
|
||||
<AdminPageLayout breadcrumbs={[{ title: data.meta.title }]}>
|
||||
{#snippet buttons()}
|
||||
<HStack gap={1}>
|
||||
<div class="hidden lg:block">
|
||||
<SearchBar placeholder={$t('search_settings')} bind:name={searchQuery} showLoadingSpinner={false} />
|
||||
</div>
|
||||
<HeaderButton action={CopyToClipboard} />
|
||||
<HeaderButton action={Download} />
|
||||
<HeaderButton action={Upload} />
|
||||
</HStack>
|
||||
{/snippet}
|
||||
|
||||
<section id="setting-content" class="flex place-content-center sm:mx-4">
|
||||
<AdminPageLayout breadcrumbs={[{ title: data.meta.title }]} actions={[CopyToClipboard, Download, Upload]}>
|
||||
<section id="setting-content" class="flex place-content-center sm:mx-4 mt-4">
|
||||
<section class="w-full pb-28 sm:w-5/6 md:w-4xl">
|
||||
{#if featureFlagsManager.value.configFile}
|
||||
<Alert color="warning" class="text-dark my-4" title={$t('admin.config_set_by_file')} />
|
||||
{/if}
|
||||
<div class="block lg:hidden">
|
||||
<div>
|
||||
<SearchBar placeholder={$t('search_settings')} bind:name={searchQuery} showLoadingSpinner={false} />
|
||||
</div>
|
||||
<SettingAccordionState queryParam={QueryParameter.IS_OPEN}>
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
<script lang="ts">
|
||||
import HeaderButton from '$lib/components/HeaderButton.svelte';
|
||||
import AdminPageLayout from '$lib/components/layouts/AdminPageLayout.svelte';
|
||||
import OnEvents from '$lib/components/OnEvents.svelte';
|
||||
import { getUserAdminsActions, handleNavigateUserAdmin } from '$lib/services/user-admin.service';
|
||||
import { locale } from '$lib/stores/preferences.store';
|
||||
import { getByteUnitString } from '$lib/utils/byte-units';
|
||||
import { searchUsersAdmin, type UserAdminResponseDto } from '@immich/sdk';
|
||||
import { Button, CommandPaletteContext, HStack, Icon } from '@immich/ui';
|
||||
import { Button, CommandPaletteContext, Icon } from '@immich/ui';
|
||||
import { mdiInfinity } from '@mdi/js';
|
||||
import { t } from 'svelte-i18n';
|
||||
import type { PageData } from './$types';
|
||||
@@ -45,12 +44,7 @@
|
||||
|
||||
<CommandPaletteContext commands={[Create]} />
|
||||
|
||||
<AdminPageLayout breadcrumbs={[{ title: data.meta.title }]}>
|
||||
{#snippet buttons()}
|
||||
<HStack gap={1}>
|
||||
<HeaderButton action={Create} />
|
||||
</HStack>
|
||||
{/snippet}
|
||||
<AdminPageLayout breadcrumbs={[{ title: data.meta.title }]} actions={[Create]}>
|
||||
<section id="setting-content" class="flex place-content-center sm:mx-4">
|
||||
<section class="w-full pb-28 lg:w-212.5">
|
||||
<table class="my-5 w-full text-start">
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import HeaderButton from '$lib/components/HeaderButton.svelte';
|
||||
import AdminPageLayout from '$lib/components/layouts/AdminPageLayout.svelte';
|
||||
import OnEvents from '$lib/components/OnEvents.svelte';
|
||||
import ServerStatisticsCard from '$lib/components/server-statistics/ServerStatisticsCard.svelte';
|
||||
@@ -8,7 +7,6 @@
|
||||
import DeviceCard from '$lib/components/user-settings-page/device-card.svelte';
|
||||
import FeatureSetting from '$lib/components/users/FeatureSetting.svelte';
|
||||
import { AppRoute } from '$lib/constants';
|
||||
import { serverConfigManager } from '$lib/managers/server-config-manager.svelte';
|
||||
import { getUserAdminActions } from '$lib/services/user-admin.service';
|
||||
import { locale } from '$lib/stores/preferences.store';
|
||||
import { createDateFormatter, findLocale } from '$lib/utils';
|
||||
@@ -26,8 +24,8 @@
|
||||
Container,
|
||||
getByteUnitString,
|
||||
Heading,
|
||||
HStack,
|
||||
Icon,
|
||||
MenuItemType,
|
||||
Stack,
|
||||
Text,
|
||||
} from '@immich/ui';
|
||||
@@ -42,15 +40,14 @@
|
||||
mdiPlayCircle,
|
||||
mdiTrashCanOutline,
|
||||
} from '@mdi/js';
|
||||
import { DateTime } from 'luxon';
|
||||
import { t } from 'svelte-i18n';
|
||||
import type { PageData } from './$types';
|
||||
|
||||
interface Props {
|
||||
type Props = {
|
||||
data: PageData;
|
||||
}
|
||||
};
|
||||
|
||||
let { data }: Props = $props();
|
||||
const { data }: Props = $props();
|
||||
|
||||
let user = $derived(data.user);
|
||||
const userPreferences = $derived(data.userPreferences);
|
||||
@@ -94,9 +91,6 @@
|
||||
await goto(AppRoute.ADMIN_USERS);
|
||||
}
|
||||
};
|
||||
|
||||
const getDeleteDate = (deletedAt: string): Date =>
|
||||
DateTime.fromISO(deletedAt).plus({ days: serverConfigManager.value.userDeleteDelay }).toJSDate();
|
||||
</script>
|
||||
|
||||
<OnEvents
|
||||
@@ -110,19 +104,8 @@
|
||||
|
||||
<AdminPageLayout
|
||||
breadcrumbs={[{ title: $t('admin.user_management'), href: AppRoute.ADMIN_USERS }, { title: user.name }]}
|
||||
actions={[ResetPassword, ResetPinCode, Update, Restore, MenuItemType.Divider, Delete]}
|
||||
>
|
||||
{#snippet buttons()}
|
||||
<HStack gap={0}>
|
||||
<HeaderButton action={ResetPassword} />
|
||||
<HeaderButton action={ResetPinCode} />
|
||||
<HeaderButton action={Update} />
|
||||
<HeaderButton
|
||||
action={Restore}
|
||||
title={$t('admin.user_restore_scheduled_removal', { values: { date: getDeleteDate(user.deletedAt!) } })}
|
||||
/>
|
||||
<HeaderButton action={Delete} />
|
||||
</HStack>
|
||||
{/snippet}
|
||||
<div>
|
||||
<Container size="large" center>
|
||||
{#if user.deletedAt}
|
||||
|
||||
Reference in New Issue
Block a user