merge: remote-tracking branch 'immich/main' into feat/integrity-checks-izzy

This commit is contained in:
izzy
2025-12-17 15:09:39 +00:00
parent 08e532170f
commit 0e75f38e4a
310 changed files with 18246 additions and 7046 deletions
@@ -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
View 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
View 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>
@@ -512,7 +512,7 @@
{:else if asset.exifInfo?.projectionType === ProjectionType.EQUIRECTANGULAR || (asset.originalPath && asset.originalPath
.toLowerCase()
.endsWith('.insp'))}
<ImagePanoramaViewer {asset} />
<ImagePanoramaViewer bind:zoomToggle {asset} />
{:else if isShowEditor && selectedEditType === 'crop'}
<CropArea {asset} />
{:else}
@@ -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">
@@ -7,11 +7,12 @@
import { t } from 'svelte-i18n';
import { fade } from 'svelte/transition';
interface Props {
type Props = {
asset: AssetResponseDto;
}
zoomToggle?: (() => void) | null;
};
const { asset }: Props = $props();
let { asset, zoomToggle = $bindable() }: Props = $props();
const loadAssetData = async (id: string) => {
const data = await viewAsset({ ...authManager.params, id, size: AssetMediaSize.Preview });
@@ -24,6 +25,7 @@
<LoadingSpinner />
{:then [data, { default: PhotoSphereViewer }]}
<PhotoSphereViewer
bind:zoomToggle
panorama={data}
originalPanorama={isWebCompatibleImage(asset)
? getAssetOriginalUrl(asset.id)
@@ -1,6 +1,8 @@
<script lang="ts">
import { shortcuts } from '$lib/actions/shortcut';
import { boundingBoxesArray, type Faces } from '$lib/stores/people.store';
import { alwaysLoadOriginalFile } from '$lib/stores/preferences.store';
import { photoZoomState } from '$lib/stores/zoom-image.store';
import {
EquirectangularAdapter,
Viewer,
@@ -24,15 +26,23 @@
strokeLinejoin: 'round',
};
interface Props {
type Props = {
panorama: string | { source: string };
originalPanorama?: string | { source: string };
adapter?: AdapterConstructor | [AdapterConstructor, unknown];
plugins?: (PluginConstructor | [PluginConstructor, unknown])[];
navbar?: boolean;
}
zoomToggle?: (() => void) | null;
};
let { panorama, originalPanorama, adapter = EquirectangularAdapter, plugins = [], navbar = false }: Props = $props();
let {
panorama,
originalPanorama,
adapter = EquirectangularAdapter,
plugins = [],
navbar = false,
zoomToggle = $bindable(),
}: Props = $props();
let container: HTMLDivElement | undefined = $state();
let viewer: Viewer;
@@ -93,6 +103,14 @@
}
});
zoomToggle = () => {
if (!viewer) {
return;
}
viewer.animate({ zoom: $photoZoomState.currentZoom > 1 ? 50 : 83.3, speed: 250 });
};
let hasChangedResolution: boolean = false;
onMount(() => {
if (!container) {
return;
@@ -139,10 +157,15 @@
const resolutionPlugin = viewer.getPlugin<ResolutionPlugin>(ResolutionPlugin);
const zoomHandler = ({ zoomLevel }: events.ZoomUpdatedEvent) => {
// zoomLevel range: [0, 100]
if (Math.round(zoomLevel) >= 75) {
photoZoomState.set({
...$photoZoomState,
currentZoom: zoomLevel / 50,
});
if (Math.round(zoomLevel) >= 75 && !hasChangedResolution) {
// Replace the preview with the original
void resolutionPlugin.setResolution('original');
viewer.removeEventListener(events.ZoomUpdatedEvent.type, zoomHandler);
hasChangedResolution = true;
}
};
@@ -158,7 +181,13 @@
viewer.destroy();
}
boundingBoxesUnsubscribe();
// zoomHandler is not called on initial load. Viewer initial zoom is 1, but photoZoomState could be != 1.
photoZoomState.set({
...$photoZoomState,
currentZoom: 1,
});
});
</script>
<svelte:document use:shortcuts={[{ shortcut: { key: 'z' }, onShortcut: zoomToggle, preventDefault: true }]} />
<div class="h-full w-full mb-0" bind:this={container}></div>
@@ -4,7 +4,7 @@
import { getAssetOriginalUrl, getAssetPlaybackUrl, getAssetThumbnailUrl } from '$lib/utils';
import { timeToSeconds } from '$lib/utils/date-time';
import { getAltText } from '$lib/utils/thumbnail-util';
import { AssetMediaSize, AssetVisibility } from '@immich/sdk';
import { AssetMediaSize, AssetVisibility, type UserResponseDto } from '@immich/sdk';
import {
mdiArchiveArrowDownOutline,
mdiCameraBurst,
@@ -46,6 +46,7 @@
imageClass?: ClassValue;
brokenAssetClass?: ClassValue;
dimmed?: boolean;
albumUsers?: UserResponseDto[];
onClick?: (asset: TimelineAsset) => void;
onSelect?: (asset: TimelineAsset) => void;
onMouseEvent?: (event: { isMouseOver: boolean; selectedGroupIndex: number }) => void;
@@ -64,6 +65,7 @@
readonly = false,
showArchiveIcon = false,
showStackedIcon = true,
albumUsers = [],
onClick = undefined,
onSelect = undefined,
onMouseEvent = undefined,
@@ -85,6 +87,8 @@
let width = $derived(thumbnailSize || thumbnailWidth || 235);
let height = $derived(thumbnailSize || thumbnailHeight || 235);
let assetOwner = $derived(albumUsers?.find((user) => user.id === asset.ownerId) ?? null);
const onIconClickedHandler = (e?: MouseEvent) => {
e?.stopPropagation();
e?.preventDefault();
@@ -122,6 +126,7 @@
const onMouseLeave = () => {
mouseOver = false;
onMouseEvent?.({ isMouseOver: false, selectedGroupIndex: groupIndex });
};
let timer: ReturnType<typeof setTimeout> | null = null;
@@ -268,6 +273,14 @@
</div>
{/if}
{#if !!assetOwner}
<div class="absolute bottom-1 end-2 max-w-[50%]">
<p class="text-xs font-medium text-white drop-shadow-lg max-w-[100%] truncate">
{assetOwner.name}
</p>
</div>
{/if}
{#if !authManager.isSharedLink && showArchiveIcon && asset.visibility === AssetVisibility.Archive}
<div class={['absolute start-2', asset.isFavorite ? 'bottom-10' : 'bottom-2']}>
<Icon data-icon-archive icon={mdiArchiveArrowDownOutline} size="24" class="text-white" />
@@ -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>
@@ -2,13 +2,14 @@
import StorageTemplateSettings from '$lib/components/admin-settings/StorageTemplateSettings.svelte';
import FormatMessage from '$lib/elements/FormatMessage.svelte';
import { user } from '$lib/stores/user.store';
import { Link } from '@immich/ui';
</script>
<div class="flex flex-col">
<p>
<FormatMessage key="admin.storage_template_onboarding_description_v2">
{#snippet children({ message })}
<a class="underline" href="https://docs.immich.app/administration/storage-template">{message}</a>
<Link href="https://docs.immich.app/administration/storage-template">{message}</Link>
{/snippet}
</FormatMessage>
</p>
@@ -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}
@@ -46,8 +46,7 @@ export class AlbumModalRowConverter {
): AlbumModalRow[] {
// only show recent albums if no search was entered, or we're in the normal albums (non-shared) modal.
const recentAlbumsToShow = !this.shared && search.length === 0 ? recentAlbums : [];
const rows: AlbumModalRow[] = [];
rows.push({ type: AlbumModalRowType.NEW_ALBUM, selected: selectedRowIndex === 0 });
const rows: AlbumModalRow[] = [{ type: AlbumModalRowType.NEW_ALBUM, selected: selectedRowIndex === 0 }];
const filteredAlbums = sortAlbums(
search.length > 0 && albums.length > 0
@@ -376,7 +376,7 @@
}
}}
>
{#snippet children({ feature }: { feature: Feature<Geometry, GeoJsonProperties> })}
{#snippet children({ feature }: { feature: Feature })}
{#if useLocationPin}
<Icon icon={mdiMapMarker} size="50px" class="text-primary -translate-y-[50%]" />
{:else}
@@ -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">
@@ -23,7 +23,7 @@
import { mobileDevice } from '$lib/stores/mobile-device.svelte';
import { isAssetViewerRoute, navigate } from '$lib/utils/navigation';
import { getTimes, type ScrubberListener } from '$lib/utils/timeline-util';
import { type AlbumResponseDto, type PersonResponseDto } from '@immich/sdk';
import { type AlbumResponseDto, type PersonResponseDto, type UserResponseDto } from '@immich/sdk';
import { DateTime } from 'luxon';
import { onDestroy, onMount, type Snippet } from 'svelte';
import type { UpdatePayload } from 'vite';
@@ -49,6 +49,7 @@
showArchiveIcon?: boolean;
isShared?: boolean;
album?: AlbumResponseDto | null;
albumUsers?: UserResponseDto[];
person?: PersonResponseDto | null;
isShowDeleteConfirmation?: boolean;
onSelect?: (asset: TimelineAsset) => void;
@@ -81,6 +82,7 @@
showArchiveIcon = false,
isShared = false,
album = null,
albumUsers = [],
person = null,
isShowDeleteConfirmation = $bindable(false),
onSelect = () => {},
@@ -186,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;
}
@@ -702,6 +704,7 @@
showStackedIcon={withStacked}
{showArchiveIcon}
{asset}
{albumUsers}
{groupIndex}
onClick={(asset) => {
if (typeof onThumbnailClick === 'function') {
@@ -80,10 +80,7 @@
const toggleArchive = async () => {
const visibility = assetInteraction.isAllArchived ? AssetVisibility.Timeline : AssetVisibility.Archive;
const ids = await archiveAssets(assetInteraction.selectedAssets, visibility);
timelineManager.updateAssetOperation(ids, (asset) => {
asset.visibility = visibility;
return { remove: false };
});
timelineManager.update(ids, (asset) => (asset.visibility = visibility));
deselectAllAssets();
};
@@ -39,7 +39,7 @@
checked={selectAllSubItems}
onCheckedChange={handleSelectAllSubItems}
/>
<Label label={title} for={title} class="font-mono text-primary text-lg" />
<Label label={title} for="permission-{title}" class="font-mono text-primary text-lg" />
</div>
<div class="mx-6 mt-3 grid grid-cols-3 gap-2">
{#each subItems as item (item)}
@@ -50,7 +50,7 @@
checked={selectedItems.includes(item)}
onCheckedChange={() => handleToggleItem(item)}
/>
<Label label={item} for={item} class="text-sm font-mono" />
<Label label={item} for="permission-{item}" class="text-sm font-mono" />
</div>
{/each}
</div>
+1 -1
View File
@@ -25,7 +25,7 @@ export enum AppRoute {
ADMIN_MAINTENANCE_SETTINGS = '/admin/maintenance',
ADMIN_MAINTENANCE_INTEGRITY_REPORT = '/admin/maintenance/integrity-report/',
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];
@@ -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();
@@ -2,6 +2,7 @@ import { browser } from '$app/environment';
import { Theme } from '$lib/constants';
import { eventManager } from '$lib/managers/event-manager.svelte';
import { PersistedLocalStorage } from '$lib/utils/persisted';
import { theme as uiTheme, type Theme as UiTheme } from '@immich/ui';
export interface ThemeSetting {
value: Theme;
@@ -71,6 +72,8 @@ class ThemeManager {
this.#theme.current = theme;
uiTheme.value = theme.value as unknown as UiTheme;
eventManager.emit('ThemeChange', theme);
}
}
@@ -6,7 +6,7 @@ import { plainDateTimeCompare } from '$lib/utils/timeline-util';
import { SvelteSet } from 'svelte/reactivity';
import type { MonthGroup } from './month-group.svelte';
import type { AssetOperation, Direction, MoveAsset, TimelineAsset } from './types';
import type { Direction, MoveAsset, TimelineAsset } from './types';
import { ViewerAsset } from './viewer-asset.svelte';
export class DayGroup {
@@ -101,7 +101,7 @@ export class DayGroup {
return this.viewerAssets.map((viewerAsset) => viewerAsset.asset);
}
runAssetOperation(ids: Set<string>, operation: AssetOperation) {
runAssetCallback(ids: Set<string>, callback: (asset: TimelineAsset) => void | { remove?: boolean }) {
if (ids.size === 0) {
return {
moveAssets: [] as MoveAsset[],
@@ -122,7 +122,8 @@ export class DayGroup {
const asset = this.viewerAssets[index].asset!;
const oldTime = { ...asset.localDateTime };
let { remove } = operation(asset);
const callbackResult = callback(asset);
let remove = (callbackResult as { remove?: boolean } | undefined)?.remove ?? false;
const newTime = asset.localDateTime;
if (oldTime.year !== newTime.year || oldTime.month !== newTime.month || oldTime.day !== newTime.day) {
const { year, month, day } = newTime;
@@ -1,104 +0,0 @@
import { setDifference, type TimelineDate } from '$lib/utils/timeline-util';
import { AssetOrder } from '@immich/sdk';
import { SvelteSet } from 'svelte/reactivity';
import { GroupInsertionCache } from '../group-insertion-cache.svelte';
import { MonthGroup } from '../month-group.svelte';
import type { TimelineManager } from '../timeline-manager.svelte';
import type { AssetOperation, TimelineAsset } from '../types';
import { updateGeometry } from './layout-support.svelte';
import { getMonthGroupByDate } from './search-support.svelte';
export function addAssetsToMonthGroups(
timelineManager: TimelineManager,
assets: TimelineAsset[],
options: { order: AssetOrder },
) {
if (assets.length === 0) {
return;
}
const addContext = new GroupInsertionCache();
const updatedMonthGroups = new SvelteSet<MonthGroup>();
const monthCount = timelineManager.months.length;
for (const asset of assets) {
let month = getMonthGroupByDate(timelineManager, asset.localDateTime);
if (!month) {
month = new MonthGroup(timelineManager, asset.localDateTime, 1, options.order);
month.isLoaded = true;
timelineManager.months.push(month);
}
month.addTimelineAsset(asset, addContext);
updatedMonthGroups.add(month);
}
if (timelineManager.months.length !== monthCount) {
timelineManager.months.sort((a, b) => {
return a.yearMonth.year === b.yearMonth.year
? b.yearMonth.month - a.yearMonth.month
: b.yearMonth.year - a.yearMonth.year;
});
}
for (const group of addContext.existingDayGroups) {
group.sortAssets(options.order);
}
for (const monthGroup of addContext.bucketsWithNewDayGroups) {
monthGroup.sortDayGroups();
}
for (const month of addContext.updatedBuckets) {
month.sortDayGroups();
updateGeometry(timelineManager, month, { invalidateHeight: true });
}
timelineManager.updateIntersections();
}
export function runAssetOperation(
timelineManager: TimelineManager,
ids: Set<string>,
operation: AssetOperation,
options: { order: AssetOrder },
) {
if (ids.size === 0) {
return { processedIds: new SvelteSet(), unprocessedIds: ids, changedGeometry: false };
}
const changedMonthGroups = new SvelteSet<MonthGroup>();
let idsToProcess = new SvelteSet(ids);
const idsProcessed = new SvelteSet<string>();
const combinedMoveAssets: { asset: TimelineAsset; date: TimelineDate }[][] = [];
for (const month of timelineManager.months) {
if (idsToProcess.size > 0) {
const { moveAssets, processedIds, changedGeometry } = month.runAssetOperation(idsToProcess, operation);
if (moveAssets.length > 0) {
combinedMoveAssets.push(moveAssets);
}
idsToProcess = setDifference(idsToProcess, processedIds);
for (const id of processedIds) {
idsProcessed.add(id);
}
if (changedGeometry) {
changedMonthGroups.add(month);
}
}
}
if (combinedMoveAssets.length > 0) {
addAssetsToMonthGroups(
timelineManager,
combinedMoveAssets.flat().map((a) => a.asset),
options,
);
}
const changedGeometry = changedMonthGroups.size > 0;
for (const month of changedMonthGroups) {
updateGeometry(timelineManager, month, { invalidateHeight: true });
}
if (changedGeometry) {
timelineManager.updateIntersections();
}
return { unprocessedIds: idsToProcess, processedIds: idsProcessed, changedGeometry };
}
@@ -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;
}
@@ -21,7 +21,7 @@ import { SvelteSet } from 'svelte/reactivity';
import { DayGroup } from './day-group.svelte';
import { GroupInsertionCache } from './group-insertion-cache.svelte';
import type { TimelineManager } from './timeline-manager.svelte';
import type { AssetDescriptor, AssetOperation, Direction, MoveAsset, TimelineAsset } from './types';
import type { AssetDescriptor, Direction, MoveAsset, TimelineAsset } from './types';
import { ViewerAsset } from './viewer-asset.svelte';
export class MonthGroup {
@@ -50,12 +50,13 @@ export class MonthGroup {
readonly yearMonth: TimelineYearMonth;
constructor(
store: TimelineManager,
timelineManager: TimelineManager,
yearMonth: TimelineYearMonth,
initialCount: number,
loaded: boolean,
order: AssetOrder = AssetOrder.Desc,
) {
this.timelineManager = store;
this.timelineManager = timelineManager;
this.#initialCount = initialCount;
this.#sortOrder = order;
@@ -72,6 +73,9 @@ export class MonthGroup {
},
this.#handleLoadError,
);
if (loaded) {
this.isLoaded = true;
}
}
set intersecting(newValue: boolean) {
@@ -112,7 +116,7 @@ export class MonthGroup {
return this.dayGroups.sort((a, b) => b.day - a.day);
}
runAssetOperation(ids: Set<string>, operation: AssetOperation) {
runAssetCallback(ids: Set<string>, callback: (asset: TimelineAsset) => void | { remove?: boolean }) {
if (ids.size === 0) {
return {
moveAssets: [] as MoveAsset[],
@@ -130,7 +134,7 @@ export class MonthGroup {
while (index--) {
if (idsToProcess.size > 0) {
const group = dayGroups[index];
const { moveAssets, processedIds, changedGeometry } = group.runAssetOperation(ids, operation);
const { moveAssets, processedIds, changedGeometry } = group.runAssetCallback(ids, callback);
if (moveAssets.length > 0) {
combinedMoveAssets.push(moveAssets);
}
@@ -278,10 +278,11 @@ describe('TimelineManager', () => {
});
it('updates existing asset', () => {
const updateAssetsSpy = vi.spyOn(timelineManager, 'upsertAssets');
const asset = deriveLocalDateTimeFromFileCreatedAt(timelineAssetFactory.build());
timelineManager.upsertAssets([asset]);
timelineManager.upsertAssets([asset]);
expect(updateAssetsSpy).toBeCalledWith([asset]);
expect(timelineManager.assetCount).toEqual(1);
});
@@ -523,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 });
});
@@ -691,4 +693,42 @@ describe('TimelineManager', () => {
expect(discoveredAssets.size).toBe(assetCount);
});
});
describe('showAssetOwners', () => {
const LS_KEY = 'album-show-asset-owners';
beforeEach(() => {
// ensure clean state
globalThis.localStorage?.removeItem(LS_KEY);
});
it('defaults to false', () => {
const timelineManager = new TimelineManager();
expect(timelineManager.showAssetOwners).toBe(false);
});
it('setShowAssetOwners updates value', () => {
const timelineManager = new TimelineManager();
timelineManager.setShowAssetOwners(true);
expect(timelineManager.showAssetOwners).toBe(true);
timelineManager.setShowAssetOwners(false);
expect(timelineManager.showAssetOwners).toBe(false);
});
it('toggleShowAssetOwners flips value', () => {
const timelineManager = new TimelineManager();
expect(timelineManager.showAssetOwners).toBe(false);
timelineManager.toggleShowAssetOwners();
expect(timelineManager.showAssetOwners).toBe(true);
timelineManager.toggleShowAssetOwners();
expect(timelineManager.showAssetOwners).toBe(false);
});
it('persists across instances via localStorage', () => {
const a = new TimelineManager();
a.setShowAssetOwners(true);
const b = new TimelineManager();
expect(b.showAssetOwners).toBe(true);
});
});
});
@@ -1,12 +1,9 @@
import { VirtualScrollManager } from '$lib/managers/VirtualScrollManager/VirtualScrollManager.svelte';
import { authManager } from '$lib/managers/auth-manager.svelte';
import { GroupInsertionCache } from '$lib/managers/timeline-manager/group-insertion-cache.svelte';
import { updateIntersectionMonthGroup } from '$lib/managers/timeline-manager/internal/intersection-support.svelte';
import { updateGeometry } from '$lib/managers/timeline-manager/internal/layout-support.svelte';
import { loadFromTimeBuckets } from '$lib/managers/timeline-manager/internal/load-support.svelte';
import {
addAssetsToMonthGroups,
runAssetOperation,
} from '$lib/managers/timeline-manager/internal/operations-support.svelte';
import {
findClosestGroupForDate,
findMonthGroupForAsset as findMonthGroupForAssetUtil,
@@ -17,17 +14,24 @@ import {
} from '$lib/managers/timeline-manager/internal/search-support.svelte';
import { WebsocketSupport } from '$lib/managers/timeline-manager/internal/websocket-support.svelte';
import { CancellableTask } from '$lib/utils/cancellable-task';
import { toTimelineAsset, type TimelineDateTime, type TimelineYearMonth } from '$lib/utils/timeline-util';
import { AssetOrder, getAssetInfo, getTimeBuckets } from '@immich/sdk';
import { PersistedLocalStorage } from '$lib/utils/persisted';
import {
isAssetResponseDto,
setDifference,
toTimelineAsset,
type TimelineDateTime,
type TimelineYearMonth,
} from '$lib/utils/timeline-util';
import { AssetOrder, getAssetInfo, getTimeBuckets, type AssetResponseDto } from '@immich/sdk';
import { clamp, isEqual } from 'lodash-es';
import { SvelteDate, SvelteMap, SvelteSet } from 'svelte/reactivity';
import { SvelteDate, SvelteSet } from 'svelte/reactivity';
import { DayGroup } from './day-group.svelte';
import { isMismatched, updateObject } from './internal/utils.svelte';
import { MonthGroup } from './month-group.svelte';
import type {
AssetDescriptor,
AssetOperation,
Direction,
MoveAsset,
ScrubberMonth,
TimelineAsset,
TimelineManagerOptions,
@@ -88,6 +92,19 @@ export class TimelineManager extends VirtualScrollManager {
#options: TimelineManagerOptions = TimelineManager.#INIT_OPTIONS;
#updatingIntersections = false;
#scrollableElement: HTMLElement | undefined = $state();
#showAssetOwners = new PersistedLocalStorage<boolean>('album-show-asset-owners', false);
get showAssetOwners() {
return this.#showAssetOwners.current;
}
setShowAssetOwners(value: boolean) {
this.#showAssetOwners.current = value;
}
toggleShowAssetOwners() {
this.#showAssetOwners.current = !this.#showAssetOwners.current;
}
constructor() {
super();
@@ -218,6 +235,7 @@ export class TimelineManager extends VirtualScrollManager {
this,
{ year: date.getUTCFullYear(), month: date.getUTCMonth() + 1 },
timeBucket.count,
false,
this.#options.order,
);
});
@@ -323,30 +341,33 @@ export class TimelineManager extends VirtualScrollManager {
upsertAssets(assets: TimelineAsset[]) {
const notUpdated = this.#updateAssets(assets);
const notExcluded = notUpdated.filter((asset) => !this.isExcluded(asset));
addAssetsToMonthGroups(this, [...notExcluded], { order: this.#options.order ?? AssetOrder.Desc });
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;
}
@@ -400,38 +421,107 @@ export class TimelineManager extends VirtualScrollManager {
return randomDay.viewerAssets[randomAssetIndex - accumulatedCount].asset;
}
updateAssetOperation(ids: string[], operation: AssetOperation) {
runAssetOperation(this, new SvelteSet(ids), operation, { order: this.#options.order ?? AssetOrder.Desc });
}
#updateAssets(assets: TimelineAsset[]) {
const lookup = new SvelteMap<string, TimelineAsset>(assets.map((asset) => [asset.id, asset]));
const { unprocessedIds } = runAssetOperation(
this,
new SvelteSet(lookup.keys()),
(asset) => {
updateObject(asset, lookup.get(asset.id));
return { remove: false };
},
{ order: this.#options.order ?? AssetOrder.Desc },
);
const result: TimelineAsset[] = [];
for (const id of unprocessedIds.values()) {
result.push(lookup.get(id)!);
}
return result;
/**
* Executes callback on assets, handling moves between groups and removals due to filter criteria.
*/
update(ids: string[], callback: (asset: TimelineAsset) => void) {
// eslint-disable-next-line svelte/prefer-svelte-reactivity
return this.#runAssetCallback(new Set(ids), callback);
}
removeAssets(ids: string[]) {
const { unprocessedIds } = runAssetOperation(
this,
new SvelteSet(ids),
() => {
return { remove: true };
},
{ order: this.#options.order ?? AssetOrder.Desc },
);
return [...unprocessedIds];
// eslint-disable-next-line svelte/prefer-svelte-reactivity
const result = this.#runAssetCallback(new Set(ids), () => ({ remove: true }));
return [...result.notUpdated];
}
protected upsertSegmentForAsset(asset: TimelineAsset) {
let month = getMonthGroupByDate(this, asset.localDateTime);
if (!month) {
month = new MonthGroup(this, asset.localDateTime, 1, true, this.#options.order);
this.months.push(month);
}
return month;
}
/**
* Adds assets to existing segments, creating new segments as needed.
*
* This is an internal method that assumes the provided assets are not already
* present in the timeline. For updating existing assets, use updateAssetOperation().
*/
protected addAssetsUpsertSegments(assets: TimelineAsset[]) {
if (assets.length === 0) {
return;
}
const context = new GroupInsertionCache();
const monthCount = this.months.length;
for (const asset of assets) {
this.upsertSegmentForAsset(asset).addTimelineAsset(asset, context);
}
if (this.months.length !== monthCount) {
this.postCreateSegments();
}
this.postUpsert(context);
}
#updateAssets(assets: TimelineAsset[]) {
// eslint-disable-next-line svelte/prefer-svelte-reactivity
const cache = new Map<string, TimelineAsset>(assets.map((asset) => [asset.id, asset]));
// eslint-disable-next-line svelte/prefer-svelte-reactivity
const idsToUpdate = new Set(cache.keys());
const result = this.#runAssetCallback(idsToUpdate, (asset) => void updateObject(asset, cache.get(asset.id)));
const notUpdated: TimelineAsset[] = [];
for (const assetId of result.notUpdated) {
notUpdated.push(cache.get(assetId)!);
}
return notUpdated;
}
#runAssetCallback(ids: Set<string>, callback: (asset: TimelineAsset) => void | { remove?: boolean }) {
if (ids.size === 0) {
// eslint-disable-next-line svelte/prefer-svelte-reactivity
return { updated: new Set<string>(), notUpdated: ids, changedGeometry: false };
}
// eslint-disable-next-line svelte/prefer-svelte-reactivity
const changedMonthGroups = new Set<MonthGroup>();
// eslint-disable-next-line svelte/prefer-svelte-reactivity
let notUpdated = new Set(ids);
// eslint-disable-next-line svelte/prefer-svelte-reactivity
const updated = new Set<string>();
const assetsToMoveSegments: MoveAsset[][] = [];
for (const month of this.months) {
if (notUpdated.size === 0) {
break;
}
const result = month.runAssetCallback(notUpdated, callback);
if (result.moveAssets.length > 0) {
assetsToMoveSegments.push(result.moveAssets);
}
if (result.changedGeometry) {
changedMonthGroups.add(month);
}
notUpdated = setDifference(notUpdated, result.processedIds);
for (const id of result.processedIds) {
updated.add(id);
}
}
const assetsToAdd = [];
for (const segment of assetsToMoveSegments) {
for (const moveAsset of segment) {
assetsToAdd.push(moveAsset.asset);
}
}
this.addAssetsUpsertSegments(assetsToAdd);
const changedGeometry = changedMonthGroups.size > 0;
for (const month of changedMonthGroups) {
updateGeometry(this, month, { invalidateHeight: true });
}
if (changedGeometry) {
this.updateIntersections();
}
return { updated, notUpdated, changedGeometry };
}
override refreshLayout() {
@@ -446,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');
@@ -493,4 +583,28 @@ export class TimelineManager extends VirtualScrollManager {
getAssetOrder() {
return this.#options.order ?? AssetOrder.Desc;
}
protected postCreateSegments(): void {
this.months.sort((a, b) => {
return a.yearMonth.year === b.yearMonth.year
? b.yearMonth.month - a.yearMonth.month
: b.yearMonth.year - a.yearMonth.year;
});
}
protected postUpsert(context: GroupInsertionCache): void {
for (const group of context.existingDayGroups) {
group.sortAssets(this.#options.order);
}
for (const monthGroup of context.bucketsWithNewDayGroups) {
monthGroup.sortDayGroups();
}
for (const month of context.updatedBuckets) {
month.sortDayGroups();
updateGeometry(this, month, { invalidateHeight: true });
}
this.updateIntersections();
}
}
@@ -37,8 +37,6 @@ export type TimelineAsset = {
longitude?: number | null;
};
export type AssetOperation = (asset: TimelineAsset) => { remove: boolean };
export type MoveAsset = { asset: TimelineAsset; date: TimelineDate };
export interface Viewport {
+1 -1
View File
@@ -14,7 +14,7 @@
const { onClose, baseTag }: Props = $props();
let tagValue = $state(baseTag?.value ? `${baseTag.value}/` : '');
let tagValue = $state(baseTag?.path ? `${baseTag.path}/` : '');
const createTag = async () => {
const [tag] = await upsertTags({ tagUpsertDto: { tags: [tagValue] } });
+1 -1
View File
@@ -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>
+31 -21
View File
@@ -23,17 +23,22 @@ import { modalManager, toastManager, type ActionItem } from '@immich/ui';
import { mdiPencilOutline, mdiPlusBoxOutline, mdiSync, mdiTrashCanOutline } from '@mdi/js';
import type { MessageFormatter } from 'svelte-i18n';
export const getLibrariesActions = ($t: MessageFormatter) => {
export const getLibrariesActions = ($t: MessageFormatter, libraries: LibraryResponseDto[]) => {
const ScanAll: ActionItem = {
title: $t('scan_all_libraries'),
type: $t('command'),
icon: mdiSync,
onAction: () => void handleScanAllLibraries(),
onAction: () => handleScanAllLibraries(),
shortcuts: { shift: true, key: 'r' },
$if: () => libraries.length > 0,
};
const Create: ActionItem = {
title: $t('create_library'),
type: $t('command'),
icon: mdiPlusBoxOutline,
onAction: () => void handleCreateLibrary(),
onAction: () => handleCreateLibrary(),
shortcuts: { shift: true, key: 'n' },
};
return { ScanAll, Create };
@@ -42,33 +47,41 @@ export const getLibrariesActions = ($t: MessageFormatter) => {
export const getLibraryActions = ($t: MessageFormatter, library: LibraryResponseDto) => {
const Rename: ActionItem = {
icon: mdiPencilOutline,
type: $t('command'),
title: $t('rename'),
onAction: () => void modalManager.show(LibraryRenameModal, { library }),
onAction: () => modalManager.show(LibraryRenameModal, { library }),
shortcuts: { key: 'r' },
};
const Delete: ActionItem = {
icon: mdiTrashCanOutline,
type: $t('command'),
title: $t('delete'),
color: 'danger',
onAction: () => void handleDeleteLibrary(library),
onAction: () => handleDeleteLibrary(library),
shortcuts: { key: 'Backspace' },
};
const AddFolder: ActionItem = {
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' },
};
return { Rename, Delete, AddFolder, AddExclusionPattern, Scan };
@@ -77,14 +90,16 @@ export const getLibraryActions = ($t: MessageFormatter, library: LibraryResponse
export const getLibraryFolderActions = ($t: MessageFormatter, library: LibraryResponseDto, folder: string) => {
const Edit: ActionItem = {
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 };
@@ -97,14 +112,16 @@ export const getLibraryExclusionPatternActions = (
) => {
const Edit: ActionItem = {
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 };
@@ -256,7 +273,7 @@ const handleDeleteLibraryFolder = async (library: LibraryResponseDto, folder: st
});
if (!confirmed) {
return false;
return;
}
try {
@@ -268,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) => {
@@ -328,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 {
@@ -344,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
View 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));
};
+6 -8
View File
@@ -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;
}
};
+13 -1
View File
@@ -17,21 +17,33 @@ export const getSystemConfigActions = (
) => {
const CopyToClipboard: ActionItem = {
title: $t('copy_to_clipboard'),
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' },
};
const Download: ActionItem = {
title: $t('export_as_json'),
description: $t('admin.export_config_as_json_description'),
type: $t('command'),
icon: mdiDownload,
onAction: () => handleDownloadConfig(config),
shortcuts: [
{ shift: true, key: 's' },
{ shift: true, key: 'd' },
],
};
const Upload: ActionItem = {
title: $t('import_from_json'),
description: $t('admin.import_config_from_json_description'),
type: $t('command'),
icon: mdiUpload,
$if: () => !featureFlags.configFile,
onAction: () => handleUploadConfig(),
shortcuts: { shift: true, key: 'u' },
};
return { CopyToClipboard, Download, Upload };
+27 -15
View File
@@ -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,14 +30,17 @@ import {
mdiPlusBoxOutline,
mdiTrashCanOutline,
} from '@mdi/js';
import { DateTime } from 'luxon';
import type { MessageFormatter } from 'svelte-i18n';
import { get } from 'svelte/store';
export const getUserAdminsActions = ($t: MessageFormatter) => {
const Create: ActionItem = {
title: $t('create_user'),
type: $t('command'),
icon: mdiPlusBoxOutline,
onAction: () => void modalManager.show(UserCreateModal, {}),
onAction: () => modalManager.show(UserCreateModal, {}),
shortcuts: { shift: true, key: 'n' },
};
return { Create };
@@ -45,36 +50,47 @@ export const getUserAdminActions = ($t: MessageFormatter, user: UserAdminRespons
const Update: ActionItem = {
icon: mdiPencilOutline,
title: $t('edit'),
onAction: () => void modalManager.show(UserEditModal, { user }),
onAction: () => modalManager.show(UserEditModal, { user }),
};
const Delete: ActionItem = {
icon: mdiTrashCanOutline,
title: $t('delete'),
type: $t('command'),
color: 'danger',
$if: () => get(authUser).id !== user.id && !user.deletedAt,
onAction: () => void modalManager.show(UserDeleteConfirmModal, { user }),
onAction: () => modalManager.show(UserDeleteConfirmModal, { user }),
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: () => void modalManager.show(UserRestoreConfirmModal, { user }),
onAction: () => modalManager.show(UserRestoreConfirmModal, { user }),
};
const ResetPassword: ActionItem = {
icon: mdiLockReset,
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 };
@@ -155,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 {
@@ -169,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;
}
};
+3 -3
View File
@@ -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_SETTINGS} 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
View 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);
});
});
});
+11 -1
View File
@@ -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;
}
+6 -1
View File
@@ -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 } };
+10 -9
View File
@@ -79,14 +79,15 @@ const undoDeleteAssets = async (onUndoDelete: OnUndoDelete, assets: TimelineAsse
*/
export function updateStackedAssetInTimeline(timelineManager: TimelineManager, { stack, toDeleteIds }: StackResponse) {
if (stack != undefined) {
timelineManager.updateAssetOperation([stack.primaryAssetId], (asset) => {
asset.stack = {
id: stack.id,
primaryAssetId: stack.primaryAssetId,
assetCount: stack.assets.length,
};
return { remove: false };
});
timelineManager.update(
[stack.primaryAssetId],
(asset) =>
(asset.stack = {
id: stack.id,
primaryAssetId: stack.primaryAssetId,
assetCount: stack.assets.length,
}),
);
timelineManager.removeAssets(toDeleteIds);
}
@@ -101,7 +102,7 @@ export function updateStackedAssetInTimeline(timelineManager: TimelineManager, {
* @param assets - The array of asset response DTOs to update in the timeline manager.
*/
export function updateUnstackedAssetInTimeline(timelineManager: TimelineManager, assets: TimelineAsset[]) {
timelineManager.updateAssetOperation(
timelineManager.update(
assets.map((asset) => asset.id),
(asset) => {
asset.stack = null;
+8 -3
View File
@@ -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];
+10 -2
View File
@@ -62,8 +62,16 @@ export class TreeNode extends Map<string, TreeNode> {
const child = this.values().next().value!;
child.value = joinPaths(this.value, child.value);
child.parent = this.parent;
this.parent.delete(this.value);
this.parent.set(child.value, child);
const entries = Array.from(this.parent.entries());
this.parent.clear();
for (const [key, value] of entries) {
if (key === this.value) {
this.parent.set(child.value, child);
} else {
this.parent.set(key, value);
}
}
}
for (const child of this.values()) {
@@ -66,6 +66,8 @@
} from '@immich/sdk';
import { Button, Icon, IconButton, modalManager, toastManager } from '@immich/ui';
import {
mdiAccountEye,
mdiAccountEyeOutline,
mdiArrowLeft,
mdiCogOutline,
mdiDeleteOutline,
@@ -101,6 +103,9 @@
let isShowActivity = $state(false);
let albumOrder: AssetOrder | undefined = $state(data.album.order);
let timelineManager = $state<TimelineManager>() as TimelineManager;
let showAlbumUsers = $derived(timelineManager?.showAssetOwners ?? false);
const assetInteraction = new AssetInteraction();
const timelineInteraction = new AssetInteraction();
@@ -290,13 +295,17 @@
let album = $derived(data.album);
let albumId = $derived(album.id);
const containsEditors = $derived(album?.shared && album.albumUsers.some(({ role }) => role === AlbumUserRole.Editor));
const albumUsers = $derived(
showAlbumUsers && containsEditors ? [album.owner, ...album.albumUsers.map(({ user }) => user)] : [],
);
$effect(() => {
if (!album.isActivityEnabled && activityManager.commentCount === 0) {
isShowActivity = false;
}
});
let timelineManager = $state<TimelineManager>() as TimelineManager;
const options = $derived.by(() => {
if (viewMode === AlbumPageViewMode.SELECT_ASSETS) {
return {
@@ -418,6 +427,7 @@
<Timeline
enableRouting={viewMode === AlbumPageViewMode.SELECT_ASSETS ? false : true}
{album}
{albumUsers}
bind:timelineManager
{options}
assetInteraction={currentAssetIntersection}
@@ -547,11 +557,7 @@
{#if assetInteraction.isAllUserOwned}
<FavoriteAction
removeFavorite={assetInteraction.isAllFavorite}
onFavorite={(ids, isFavorite) =>
timelineManager.updateAssetOperation(ids, (asset) => {
asset.isFavorite = isFavorite;
return { remove: false };
})}
onFavorite={(ids, isFavorite) => timelineManager.update(ids, (asset) => (asset.isFavorite = isFavorite))}
></FavoriteAction>
{/if}
<ButtonContextMenu icon={mdiDotsVertical} title={$t('menu')} offset={{ x: 175, y: 25 }}>
@@ -570,11 +576,7 @@
<ArchiveAction
menuItem
unarchive={assetInteraction.isAllArchived}
onArchive={(ids, visibility) =>
timelineManager.updateAssetOperation(ids, (asset) => {
asset.visibility = visibility;
return { remove: false };
})}
onArchive={(ids, visibility) => timelineManager.update(ids, (asset) => (asset.visibility = visibility))}
/>
<SetVisibilityAction menuItem onVisibilitySet={handleSetVisibility} />
{/if}
@@ -657,6 +659,13 @@
color="secondary"
offset={{ x: 175, y: 25 }}
>
{#if containsEditors}
<MenuOption
icon={showAlbumUsers ? mdiAccountEye : mdiAccountEyeOutline}
text={$t('view_asset_owners')}
onClick={() => timelineManager.toggleShowAssetOwners()}
/>
{/if}
{#if album.assetCount > 0}
<MenuOption
icon={mdiImageOutline}
@@ -66,11 +66,7 @@
>
<ArchiveAction
unarchive
onArchive={(ids, visibility) =>
timelineManager.updateAssetOperation(ids, (asset) => {
asset.visibility = visibility;
return { remove: false };
})}
onArchive={(ids, visibility) => timelineManager.update(ids, (asset) => (asset.visibility = visibility))}
/>
<CreateSharedLink />
<SelectAllAssets {timelineManager} {assetInteraction} />
@@ -80,11 +76,7 @@
</ButtonContextMenu>
<FavoriteAction
removeFavorite={assetInteraction.isAllFavorite}
onFavorite={(ids, isFavorite) =>
timelineManager.updateAssetOperation(ids, (asset) => {
asset.isFavorite = isFavorite;
return { remove: false };
})}
onFavorite={(ids, isFavorite) => timelineManager.update(ids, (asset) => (asset.isFavorite = isFavorite))}
/>
<ButtonContextMenu icon={mdiDotsVertical} title={$t('menu')}>
<DownloadAction menuItem />
@@ -85,11 +85,7 @@
<ArchiveAction
menuItem
unarchive={assetInteraction.isAllArchived}
onArchive={(ids, visibility) =>
timelineManager.updateAssetOperation(ids, (asset) => {
asset.visibility = visibility;
return { remove: false };
})}
onArchive={(ids, visibility) => timelineManager.update(ids, (asset) => (asset.visibility = visibility))}
/>
{#if $preferences.tags.enabled}
<TagAction menuItem />
@@ -492,11 +492,7 @@
</ButtonContextMenu>
<FavoriteAction
removeFavorite={assetInteraction.isAllFavorite}
onFavorite={(ids, isFavorite) =>
timelineManager.updateAssetOperation(ids, (asset) => {
asset.isFavorite = isFavorite;
return { remove: false };
})}
onFavorite={(ids, isFavorite) => timelineManager.update(ids, (asset) => (asset.isFavorite = isFavorite))}
/>
<ButtonContextMenu icon={mdiDotsVertical} title={$t('menu')}>
<DownloadAction menuItem filename="{person.name || 'immich'}.zip" />
@@ -511,11 +507,7 @@
<ArchiveAction
menuItem
unarchive={assetInteraction.isAllArchived}
onArchive={(ids, visibility) =>
timelineManager.updateAssetOperation(ids, (asset) => {
asset.visibility = visibility;
return { remove: false };
})}
onArchive={(ids, visibility) => timelineManager.update(ids, (asset) => (asset.visibility = visibility))}
/>
{#if $preferences.tags.enabled && assetInteraction.isAllUserOwned}
<TagAction menuItem />
@@ -120,11 +120,7 @@
</ButtonContextMenu>
<FavoriteAction
removeFavorite={assetInteraction.isAllFavorite}
onFavorite={(ids, isFavorite) =>
timelineManager.updateAssetOperation(ids, (asset) => {
asset.isFavorite = isFavorite;
return { remove: false };
})}
onFavorite={(ids, isFavorite) => timelineManager.update(ids, (asset) => (asset.isFavorite = isFavorite))}
></FavoriteAction>
<ButtonContextMenu icon={mdiDotsVertical} title={$t('menu')}>
<DownloadAction menuItem />
@@ -148,11 +144,7 @@
<ChangeLocation menuItem />
<ArchiveAction
menuItem
onArchive={(ids, visibility) =>
timelineManager.updateAssetOperation(ids, (asset) => {
asset.visibility = visibility;
return { remove: false };
})}
onArchive={(ids, visibility) => timelineManager.update(ids, (asset) => (asset.visibility = visibility))}
/>
{#if $preferences.tags.enabled}
<TagAction menuItem />
@@ -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>
+60 -2
View File
@@ -1,5 +1,5 @@
<script lang="ts">
import { afterNavigate, beforeNavigate } from '$app/navigation';
import { afterNavigate, beforeNavigate, goto } from '$app/navigation';
import { page } from '$app/state';
import { shortcut } from '$lib/actions/shortcut';
import DownloadPanel from '$lib/components/asset-viewer/download-panel.svelte';
@@ -11,15 +11,18 @@
import { AppRoute } from '$lib/constants';
import { eventManager } from '$lib/managers/event-manager.svelte';
import { serverConfigManager } from '$lib/managers/server-config-manager.svelte';
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 { modalManager, setTranslations } 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';
import '../app.css';
@@ -50,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();
@@ -59,6 +64,10 @@
eventManager.emit('AppInit');
beforeNavigate(({ from, to }) => {
if (sidebarStore.isOpen) {
sidebarStore.reset();
}
if (isAssetViewerRoute(from) && isAssetViewerRoute(to)) {
return;
}
@@ -120,9 +129,58 @@
});
}
});
const userCommands: ActionItem[] = [
{
title: $t('theme'),
description: $t('toggle_theme_description'),
type: $t('command'),
icon: mdiThemeLightDark,
onAction: () => themeManager.toggleTheme(),
shortcuts: { shift: true, key: 't' },
isGlobal: true,
},
];
const adminCommands: ActionItem[] = [
{
title: $t('users'),
description: $t('admin.users_page_description'),
icon: mdiAccountMultipleOutline,
onAction: () => goto(AppRoute.ADMIN_USERS),
},
{
title: $t('settings'),
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'),
icon: mdiBookshelf,
onAction: () => goto(AppRoute.ADMIN_LIBRARY_MANAGEMENT),
},
{
title: $t('server_stats'),
description: $t('admin.server_stats_page_description'),
icon: mdiServer,
onAction: () => goto(AppRoute.ADMIN_STATS),
},
].map((route) => ({ ...route, type: $t('page'), isGlobal: true, $if: () => $user?.isAdmin }));
const commands = $derived([...userCommands, ...adminCommands]);
</script>
<OnEvents {onReleaseEvent} />
<CommandPaletteContext {commands} />
<svelte:head>
<title>{page.data.meta?.title || 'Web'} - Immich</title>
+3
View File
@@ -2,6 +2,7 @@ import { goto } from '$app/navigation';
import { serverConfigManager } from '$lib/managers/server-config-manager.svelte';
import { maintenanceCreateUrl, maintenanceReturnUrl, maintenanceShouldRedirect } from '$lib/utils/maintenance';
import { init } from '$lib/utils/server';
import { commandPaletteManager } from '@immich/ui';
import type { LayoutLoad } from './$types';
export const ssr = false;
@@ -21,6 +22,8 @@ export const load = (async ({ fetch, url }) => {
error = initError;
}
commandPaletteManager.enable();
return {
error,
meta: {
@@ -1,104 +0,0 @@
<script lang="ts">
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, HStack, modalManager, Text } 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' } }));
}
};
onMount(async () => {
while (running) {
jobs = await getQueuesLegacy();
await asyncTimeout(5000);
}
});
onDestroy(() => {
running = false;
});
</script>
<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={() => modalManager.show(JobCreateModal, {})}
size="small"
variant="ghost"
color="secondary"
>
<Text class="hidden md:block">{$t('admin.create_job')}</Text>
</Button>
<Button
leadingIcon={mdiCog}
href="{AppRoute.ADMIN_SETTINGS}?isOpen=job"
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>
+3 -16
View File
@@ -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';
@@ -9,7 +8,7 @@
import { locale } from '$lib/stores/preferences.store';
import { getBytesWithUnit } from '$lib/utils/byte-units';
import { getLibrary, getLibraryStatistics, getUserAdmin, type LibraryResponseDto } from '@immich/sdk';
import { Button } from '@immich/ui';
import { Button, CommandPaletteContext } from '@immich/ui';
import { t } from 'svelte-i18n';
import { fade } from 'svelte/transition';
import type { PageData } from './$types';
@@ -49,7 +48,7 @@
delete owners[id];
};
const { Create, ScanAll } = $derived(getLibrariesActions($t));
const { Create, ScanAll } = $derived(getLibrariesActions($t, libraries));
</script>
<OnEvents
@@ -58,19 +57,13 @@
onLibraryDelete={handleDeleteLibrary}
/>
<AdminPageLayout breadcrumbs={[{ title: data.meta.title }]}>
{#snippet buttons()}
<div class="flex justify-end gap-2">
{#if libraries.length > 0}
<HeaderButton action={ScanAll} />
{/if}
<HeaderButton action={Create} />
</div>
{/snippet}
<CommandPaletteContext commands={[Create, ScanAll]} />
<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';
@@ -15,7 +15,18 @@
getLibraryFolderActions,
} from '$lib/services/library.service';
import { getBytesWithUnit } from '$lib/utils/byte-units';
import { Card, CardBody, CardHeader, CardTitle, Code, Container, Heading, Icon, modalManager } from '@immich/ui';
import {
Card,
CardBody,
CardHeader,
CardTitle,
Code,
CommandPaletteContext,
Container,
Heading,
Icon,
modalManager,
} from '@immich/ui';
import { mdiCameraIris, mdiChartPie, mdiFilterMinusOutline, mdiFolderOutline, mdiPlayCircle } from '@mdi/js';
import { t } from 'svelte-i18n';
import type { PageData } from './$types';
@@ -39,19 +50,12 @@
onLibraryDelete={({ id }) => id === library.id && goto(AppRoute.ADMIN_LIBRARY_MANAGEMENT)}
/>
<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>
@@ -67,7 +71,7 @@
<Icon icon={mdiFolderOutline} size="1.5rem" />
<CardTitle>{$t('folders')}</CardTitle>
</div>
<HeaderButton action={AddFolder} />
<HeaderActionButton action={AddFolder} />
</div>
</CardHeader>
<CardBody>
@@ -107,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
View 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
View 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;
@@ -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>
@@ -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;
@@ -18,7 +18,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';
@@ -27,7 +26,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, HStack } from '@immich/ui';
import { Alert, CommandPaletteContext } from '@immich/ui';
import {
mdiAccountOutline,
mdiBackupRestore,
@@ -215,24 +214,15 @@
);
</script>
<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}
<CommandPaletteContext commands={[CopyToClipboard, Upload, Download]} />
<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}>
+4 -8
View File
@@ -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, 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';
@@ -43,12 +42,9 @@
{onUserAdminDeleted}
/>
<AdminPageLayout breadcrumbs={[{ title: data.meta.title }]}>
{#snippet buttons()}
<HStack gap={1}>
<HeaderButton action={Create} />
</HStack>
{/snippet}
<CommandPaletteContext commands={[Create]} />
<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">
+8 -22
View File
@@ -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';
@@ -22,11 +20,12 @@
CardHeader,
CardTitle,
Code,
CommandPaletteContext,
Container,
getByteUnitString,
Heading,
HStack,
Icon,
MenuItemType,
Stack,
Text,
} from '@immich/ui';
@@ -41,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);
@@ -93,9 +91,6 @@
await goto(AppRoute.ADMIN_USERS);
}
};
const getDeleteDate = (deletedAt: string): Date =>
DateTime.fromISO(deletedAt).plus({ days: serverConfigManager.value.userDeleteDelay }).toJSDate();
</script>
<OnEvents
@@ -105,21 +100,12 @@
{onUserAdminDeleted}
/>
<CommandPaletteContext commands={[ResetPassword, ResetPinCode, Update, Delete, Restore]} />
<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}