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>