mirror of
https://github.com/immich-app/immich.git
synced 2026-05-18 03:10:24 +03:00
merge: remote-tracking branch 'immich/main' into feat/integrity-checks-izzy
This commit is contained in:
@@ -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}
|
||||
+46
-50
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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}
|
||||
|
||||
|
||||
+52
-39
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user