mirror of
https://github.com/immich-app/immich.git
synced 2026-05-18 03:10:24 +03:00
refactor(web): co-locate single-use components in /routes (#27921)
* co-locate single use components to /routes * revert accidentally changed paths * fix mangled path * fmt * fix accidentally moved multi-use components
This commit is contained in:
@@ -1,16 +0,0 @@
|
||||
<script lang="ts">
|
||||
import FormatMessage from '$lib/elements/FormatMessage.svelte';
|
||||
import { Link } from '@immich/ui';
|
||||
|
||||
type Props = {
|
||||
href: string;
|
||||
};
|
||||
|
||||
const { href }: Props = $props();
|
||||
</script>
|
||||
|
||||
<FormatMessage key="link_to_docs">
|
||||
{#snippet children({ message })}
|
||||
<Link {href}>{message}</Link>
|
||||
{/snippet}
|
||||
</FormatMessage>
|
||||
@@ -1,194 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { cleanClass } from '$lib';
|
||||
import QueueCardBadge from '$lib/components/QueueCardBadge.svelte';
|
||||
import QueueCardButton from '$lib/components/QueueCardButton.svelte';
|
||||
import Badge from '$lib/elements/Badge.svelte';
|
||||
import { Route } from '$lib/route';
|
||||
import { asQueueItem } from '$lib/services/queue.service';
|
||||
import { locale } from '$lib/stores/preferences.store';
|
||||
import { transformToTitleCase } from '$lib/utils';
|
||||
import { QueueCommand, type QueueCommandDto, type QueueResponseDto } from '@immich/sdk';
|
||||
import { Icon, IconButton, Link } from '@immich/ui';
|
||||
import {
|
||||
mdiAlertCircle,
|
||||
mdiAllInclusive,
|
||||
mdiChartLine,
|
||||
mdiClose,
|
||||
mdiFastForward,
|
||||
mdiImageRefreshOutline,
|
||||
mdiPause,
|
||||
mdiPlay,
|
||||
mdiSelectionSearch,
|
||||
} from '@mdi/js';
|
||||
import { type Component } from 'svelte';
|
||||
import { t } from 'svelte-i18n';
|
||||
|
||||
interface Props {
|
||||
queue: QueueResponseDto;
|
||||
description?: Component;
|
||||
disabled?: boolean;
|
||||
allText?: string;
|
||||
refreshText?: string;
|
||||
missingText: string;
|
||||
onCommand: (command: QueueCommandDto) => void;
|
||||
}
|
||||
|
||||
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(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';
|
||||
</script>
|
||||
|
||||
<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 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-2 text-xl font-semibold text-primary">
|
||||
<Link class="flex items-center gap-2 hover:underline" href={Route.viewQueue(queue)} underline={false}>
|
||||
<Icon {icon} size="1.25em" class="hidden shrink-0 sm:block" />
|
||||
<span>{transformToTitleCase(title)}</span>
|
||||
</Link>
|
||||
<IconButton
|
||||
color="primary"
|
||||
icon={mdiChartLine}
|
||||
aria-label={$t('view_details')}
|
||||
size="small"
|
||||
variant="ghost"
|
||||
href={Route.viewQueue(queue)}
|
||||
/>
|
||||
<div class="flex gap-2">
|
||||
{#if statistics.failed > 0}
|
||||
<Badge>
|
||||
<div class="flex flex-row gap-1">
|
||||
<span class="text-sm">
|
||||
{$t('admin.jobs_failed', { values: { jobCount: statistics.failed.toLocaleString($locale) } })}
|
||||
</span>
|
||||
<IconButton
|
||||
color="primary"
|
||||
icon={mdiClose}
|
||||
aria-label={$t('clear_message')}
|
||||
size="tiny"
|
||||
shape="round"
|
||||
onclick={() => onCommand({ command: QueueCommand.ClearFailed, force: false })}
|
||||
/>
|
||||
</div>
|
||||
</Badge>
|
||||
{/if}
|
||||
{#if statistics.delayed > 0}
|
||||
<Badge>
|
||||
<span class="text-sm">
|
||||
{$t('admin.jobs_delayed', { values: { jobCount: statistics.delayed.toLocaleString($locale) } })}
|
||||
</span>
|
||||
</Badge>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if subtitle}
|
||||
<div class="whitespace-pre-line text-sm dark:text-white">{subtitle}</div>
|
||||
{/if}
|
||||
|
||||
{#if description}
|
||||
{@const SvelteComponent = description}
|
||||
<div class="text-sm dark:text-white">
|
||||
<SvelteComponent />
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="mt-2 flex w-full max-w-md flex-col sm:flex-row">
|
||||
<div
|
||||
class={cleanClass(
|
||||
commonClasses,
|
||||
'rounded-t-lg bg-immich-primary text-white dark:bg-immich-dark-primary dark:text-immich-dark-gray sm:rounded-s-lg sm:rounded-e-none',
|
||||
)}
|
||||
>
|
||||
<p>{$t('active')}</p>
|
||||
<p class="text-2xl">
|
||||
{statistics.active.toLocaleString($locale)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class={cleanClass(
|
||||
commonClasses,
|
||||
'flex-row-reverse rounded-b-lg bg-gray-200 text-immich-dark-bg dark:bg-gray-700 dark:text-immich-gray sm:rounded-s-none sm:rounded-e-lg',
|
||||
)}
|
||||
>
|
||||
<p class="text-2xl">
|
||||
{waitingCount.toLocaleString($locale)}
|
||||
</p>
|
||||
<p>{$t('waiting')}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex w-full flex-row overflow-hidden sm:w-32 sm:flex-col">
|
||||
{#if disabled}
|
||||
<QueueCardButton
|
||||
disabled={true}
|
||||
color="light-gray"
|
||||
onClick={() => onCommand({ command: QueueCommand.Start, force: false })}
|
||||
>
|
||||
<Icon icon={mdiAlertCircle} size="36" />
|
||||
<span>{$t('disabled')}</span>
|
||||
</QueueCardButton>
|
||||
{/if}
|
||||
|
||||
{#if !disabled && !isIdle}
|
||||
{#if waitingCount > 0}
|
||||
<QueueCardButton color="gray" onClick={() => onCommand({ command: QueueCommand.Empty, force: false })}>
|
||||
<Icon icon={mdiClose} size="24" />
|
||||
<span>{$t('clear')}</span>
|
||||
</QueueCardButton>
|
||||
{/if}
|
||||
{#if queue.isPaused}
|
||||
{@const size = waitingCount > 0 ? '24' : '48'}
|
||||
<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>{$t('resume')}</span>
|
||||
</QueueCardButton>
|
||||
{:else}
|
||||
<QueueCardButton color="light-gray" onClick={() => onCommand({ command: QueueCommand.Pause, force: false })}>
|
||||
<Icon icon={mdiPause} size="24" />
|
||||
<span>{$t('pause')}</span>
|
||||
</QueueCardButton>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
{#if !disabled && multipleButtons && isIdle}
|
||||
{#if allText}
|
||||
<QueueCardButton color="dark-gray" onClick={() => onCommand({ command: QueueCommand.Start, force: true })}>
|
||||
<Icon icon={mdiAllInclusive} size="24" />
|
||||
<span>{allText}</span>
|
||||
</QueueCardButton>
|
||||
{/if}
|
||||
{#if refreshText}
|
||||
<QueueCardButton color="gray" onClick={() => onCommand({ command: QueueCommand.Start, force: undefined })}>
|
||||
<Icon icon={mdiImageRefreshOutline} size="24" />
|
||||
<span>{refreshText}</span>
|
||||
</QueueCardButton>
|
||||
{/if}
|
||||
<QueueCardButton color="light-gray" onClick={() => onCommand({ command: QueueCommand.Start, force: false })}>
|
||||
<Icon icon={mdiSelectionSearch} size="24" />
|
||||
<span>{missingText}</span>
|
||||
</QueueCardButton>
|
||||
{/if}
|
||||
|
||||
{#if !disabled && !multipleButtons && isIdle}
|
||||
<QueueCardButton color="light-gray" onClick={() => onCommand({ command: QueueCommand.Start, force: false })}>
|
||||
<Icon icon={mdiPlay} size="48" />
|
||||
<span>{missingText}</span>
|
||||
</QueueCardButton>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,25 +0,0 @@
|
||||
<script lang="ts">
|
||||
import type { Snippet } from 'svelte';
|
||||
import { tv } from 'tailwind-variants';
|
||||
|
||||
type Props = {
|
||||
color: 'success' | 'warning';
|
||||
children?: Snippet;
|
||||
};
|
||||
|
||||
let { color, children }: Props = $props();
|
||||
|
||||
const styles = tv({
|
||||
base: 'w-full p-2 text-center text-sm ',
|
||||
variants: {
|
||||
color: {
|
||||
success: 'bg-green-500/70 text-gray-900 dark:bg-green-700/90 dark:text-gray-100',
|
||||
warning: 'bg-orange-400/70 text-gray-900 dark:bg-orange-900 dark:text-gray-100',
|
||||
},
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class={styles({ color })}>
|
||||
{@render children?.()}
|
||||
</div>
|
||||
@@ -1,36 +0,0 @@
|
||||
<script lang="ts" module>
|
||||
export type Colors = 'light-gray' | 'gray' | 'dark-gray';
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
import type { Snippet } from 'svelte';
|
||||
import { tv } from 'tailwind-variants';
|
||||
|
||||
interface Props {
|
||||
color: Colors;
|
||||
disabled?: boolean;
|
||||
children?: Snippet;
|
||||
onClick?: () => void;
|
||||
}
|
||||
|
||||
let { color, disabled = false, onClick = () => {}, children }: Props = $props();
|
||||
|
||||
const styles = tv({
|
||||
base: 'flex h-full w-full flex-col place-content-center place-items-center gap-2 px-8 py-2 text-xs text-gray-600 transition-colors dark:text-gray-200 ',
|
||||
variants: {
|
||||
color: {
|
||||
'light-gray': 'bg-gray-300/80 dark:bg-gray-700',
|
||||
gray: 'bg-gray-300/90 dark:bg-gray-700/90',
|
||||
'dark-gray': 'bg-gray-300 dark:bg-gray-700/80',
|
||||
},
|
||||
disabled: {
|
||||
true: 'cursor-not-allowed',
|
||||
false: 'hover:bg-immich-primary hover:text-white dark:hover:bg-immich-dark-primary dark:hover:text-black',
|
||||
},
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<button type="button" {disabled} class={styles({ disabled, color })} onclick={onClick}>
|
||||
{@render children?.()}
|
||||
</button>
|
||||
@@ -1,167 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { cleanClass } from '$lib';
|
||||
import { queueManager } from '$lib/managers/queue-manager.svelte';
|
||||
import type { QueueSnapshot } from '$lib/types';
|
||||
import type { QueueResponseDto } from '@immich/sdk';
|
||||
import { LoadingSpinner, Theme, themeManager } 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(themeManager.value === Theme.Dark);
|
||||
let plot: uPlot;
|
||||
|
||||
const axisOptions: Axis = {
|
||||
stroke: () => (isDark ? '#ccc' : 'black'),
|
||||
ticks: {
|
||||
show: false,
|
||||
stroke: () => (isDark ? '#444' : '#ddd'),
|
||||
},
|
||||
grid: {
|
||||
show: true,
|
||||
stroke: () => (isDark ? '#444' : '#ddd'),
|
||||
},
|
||||
};
|
||||
|
||||
const seriesOptions: uPlot.Series = {
|
||||
spanGaps: false,
|
||||
points: {
|
||||
show: false,
|
||||
},
|
||||
width: 2,
|
||||
pxAlign: 0,
|
||||
};
|
||||
|
||||
const options: uPlot.Options = {
|
||||
legend: {
|
||||
show: false,
|
||||
},
|
||||
cursor: {
|
||||
show: false,
|
||||
lock: true,
|
||||
drag: {
|
||||
setScale: false,
|
||||
},
|
||||
},
|
||||
width: 200,
|
||||
height: 200,
|
||||
ms: 1,
|
||||
pxAlign: 0,
|
||||
scales: {
|
||||
y: {
|
||||
distr: 1,
|
||||
},
|
||||
},
|
||||
series: [
|
||||
{},
|
||||
{
|
||||
stroke: '#d94a4a',
|
||||
...seriesOptions,
|
||||
},
|
||||
{
|
||||
stroke: '#4250af',
|
||||
...seriesOptions,
|
||||
},
|
||||
{
|
||||
stroke: '#1075db',
|
||||
...seriesOptions,
|
||||
},
|
||||
],
|
||||
|
||||
axes: [
|
||||
{
|
||||
...axisOptions,
|
||||
size: 40,
|
||||
ticks: { show: true },
|
||||
values: (plot, values) => {
|
||||
return values.map((value) => {
|
||||
if (!value) {
|
||||
return '';
|
||||
}
|
||||
return DateTime.fromMillis(value).toFormat('hh:mm:ss');
|
||||
});
|
||||
},
|
||||
},
|
||||
{
|
||||
...axisOptions,
|
||||
size: 60,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const onThemeChange = () => plot?.redraw(false);
|
||||
|
||||
$effect(() => themeManager.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={cleanClass('w-full', className)} bind:this={chartElement}>
|
||||
{#if data[0].length === 0}
|
||||
<LoadingSpinner size="giant" />
|
||||
{/if}
|
||||
</div>
|
||||
@@ -1,132 +0,0 @@
|
||||
<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.primary($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>
|
||||
@@ -1,17 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { OpenQueryParam } from '$lib/constants';
|
||||
import FormatMessage from '$lib/elements/FormatMessage.svelte';
|
||||
import { Route } from '$lib/route';
|
||||
import { t } from 'svelte-i18n';
|
||||
</script>
|
||||
|
||||
<FormatMessage
|
||||
key="admin.storage_template_migration_description"
|
||||
values={{ template: $t('admin.storage_template_settings') }}
|
||||
>
|
||||
{#snippet children({ message })}
|
||||
<a href={Route.systemSettings({ isOpen: OpenQueryParam.STORAGE_TEMPLATE })} class="text-primary">
|
||||
{message}
|
||||
</a>
|
||||
{/snippet}
|
||||
</FormatMessage>
|
||||
@@ -1,41 +0,0 @@
|
||||
<script lang="ts">
|
||||
import OnEvents from '$lib/components/OnEvents.svelte';
|
||||
import { authManager } from '$lib/managers/auth-manager.svelte';
|
||||
import VersionAnnouncementModal from '$lib/modals/VersionAnnouncementModal.svelte';
|
||||
import type { ReleaseEvent } from '$lib/types';
|
||||
import { getReleaseType, semverToName } from '$lib/utils';
|
||||
import { modalManager } from '@immich/ui';
|
||||
|
||||
let modal = $state<{
|
||||
onClose: Promise<void>;
|
||||
close: () => Promise<void>;
|
||||
}>();
|
||||
|
||||
const onReleaseEvent = async (release: ReleaseEvent) => {
|
||||
if (!release.isAvailable || !authManager.user.isAdmin) {
|
||||
return;
|
||||
}
|
||||
|
||||
const releaseVersion = semverToName(release.releaseVersion);
|
||||
const serverVersion = semverToName(release.serverVersion);
|
||||
const type = getReleaseType(release.serverVersion, release.releaseVersion);
|
||||
|
||||
if (type === 'none' || type === 'patch' || localStorage.getItem('appVersion') === releaseVersion) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await modal?.close();
|
||||
|
||||
modal = modalManager.open(VersionAnnouncementModal, { serverVersion, releaseVersion });
|
||||
|
||||
void modal.onClose.then(() => {
|
||||
localStorage.setItem('appVersion', releaseVersion);
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error [VersionAnnouncementBox]:', error);
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<OnEvents {onReleaseEvent} />
|
||||
@@ -1,302 +0,0 @@
|
||||
<script lang="ts">
|
||||
import SettingAccordion from '$lib/components/shared-components/settings/setting-accordion.svelte';
|
||||
import SettingInputField from '$lib/components/shared-components/settings/setting-input-field.svelte';
|
||||
import SettingSelect from '$lib/components/shared-components/settings/setting-select.svelte';
|
||||
import SettingSwitch from '$lib/components/shared-components/settings/setting-switch.svelte';
|
||||
import SettingButtonsRow from '$lib/components/shared-components/settings/SystemConfigButtonRow.svelte';
|
||||
import { SettingInputFieldType } from '$lib/constants';
|
||||
import FormatMessage from '$lib/elements/FormatMessage.svelte';
|
||||
import { featureFlagsManager } from '$lib/managers/feature-flags-manager.svelte';
|
||||
import { systemConfigManager } from '$lib/managers/system-config-manager.svelte';
|
||||
import AuthDisableLoginConfirmModal from '$lib/modals/AuthDisableLoginConfirmModal.svelte';
|
||||
import { handleError } from '$lib/utils/handle-error';
|
||||
import { OAuthTokenEndpointAuthMethod, unlinkAllOAuthAccountsAdmin } from '@immich/sdk';
|
||||
import { Button, Link, modalManager, Text, toastManager } from '@immich/ui';
|
||||
import { mdiRestart } from '@mdi/js';
|
||||
import { t } from 'svelte-i18n';
|
||||
import { fade } from 'svelte/transition';
|
||||
|
||||
const disabled = $derived(featureFlagsManager.value.configFile);
|
||||
const config = $derived(systemConfigManager.value);
|
||||
let configToEdit = $state(systemConfigManager.cloneValue());
|
||||
|
||||
const handleToggleOverride = () => {
|
||||
// click runs before bind
|
||||
const previouslyEnabled = configToEdit.oauth.mobileOverrideEnabled;
|
||||
if (!previouslyEnabled && !configToEdit.oauth.mobileRedirectUri) {
|
||||
configToEdit.oauth.mobileRedirectUri = globalThis.location.origin + '/api/oauth/mobile-redirect';
|
||||
}
|
||||
};
|
||||
|
||||
const onBeforeSave = async () => {
|
||||
const allMethodsDisabled = !configToEdit.oauth.enabled && !configToEdit.passwordLogin.enabled;
|
||||
|
||||
if (allMethodsDisabled) {
|
||||
const confirmed = await modalManager.show(AuthDisableLoginConfirmModal);
|
||||
if (!confirmed) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
const handleUnlinkAllOAuthAccounts = async () => {
|
||||
const confirmed = await modalManager.showDialog({
|
||||
icon: mdiRestart,
|
||||
title: $t('admin.unlink_all_oauth_accounts'),
|
||||
prompt: $t('admin.unlink_all_oauth_accounts_prompt'),
|
||||
confirmColor: 'danger',
|
||||
});
|
||||
|
||||
if (!confirmed) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await unlinkAllOAuthAccountsAdmin();
|
||||
toastManager.primary();
|
||||
} catch (error) {
|
||||
handleError(error, $t('errors.something_went_wrong'));
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<div>
|
||||
<div in:fade={{ duration: 500 }}>
|
||||
<form autocomplete="off" onsubmit={(e) => e.preventDefault()}>
|
||||
<div class="ms-4 mt-4 flex flex-col">
|
||||
<SettingAccordion
|
||||
key="oauth"
|
||||
title={$t('admin.oauth_settings')}
|
||||
subtitle={$t('admin.oauth_settings_description')}
|
||||
>
|
||||
<div class="ms-4 mt-4 flex flex-col gap-4">
|
||||
<Text size="small">
|
||||
<FormatMessage key="admin.oauth_settings_more_details">
|
||||
{#snippet children({ message })}
|
||||
<Link href="https://docs.immich.app/administration/oauth">{message}</Link>
|
||||
{/snippet}
|
||||
</FormatMessage>
|
||||
</Text>
|
||||
|
||||
<SettingSwitch
|
||||
{disabled}
|
||||
title={$t('admin.oauth_enable_description')}
|
||||
bind:checked={configToEdit.oauth.enabled}
|
||||
/>
|
||||
|
||||
{#if configToEdit.oauth.enabled}
|
||||
<hr />
|
||||
|
||||
<div class="flex items-center gap-2 justify-between">
|
||||
<Text size="small">{$t('admin.unlink_all_oauth_accounts_description')}</Text>
|
||||
<Button size="small" onclick={handleUnlinkAllOAuthAccounts}
|
||||
>{$t('admin.unlink_all_oauth_accounts')}</Button
|
||||
>
|
||||
</div>
|
||||
|
||||
<SettingInputField
|
||||
inputType={SettingInputFieldType.TEXT}
|
||||
label="issuer_url"
|
||||
bind:value={configToEdit.oauth.issuerUrl}
|
||||
required={true}
|
||||
disabled={disabled || !configToEdit.oauth.enabled}
|
||||
isEdited={!(configToEdit.oauth.issuerUrl === config.oauth.issuerUrl)}
|
||||
/>
|
||||
|
||||
<SettingInputField
|
||||
inputType={SettingInputFieldType.TEXT}
|
||||
label="client_id"
|
||||
bind:value={configToEdit.oauth.clientId}
|
||||
required={true}
|
||||
disabled={disabled || !configToEdit.oauth.enabled}
|
||||
isEdited={!(configToEdit.oauth.clientId === config.oauth.clientId)}
|
||||
/>
|
||||
|
||||
<SettingInputField
|
||||
inputType={SettingInputFieldType.TEXT}
|
||||
label="client_secret"
|
||||
description={$t('admin.oauth_client_secret_description')}
|
||||
bind:value={configToEdit.oauth.clientSecret}
|
||||
disabled={disabled || !configToEdit.oauth.enabled}
|
||||
isEdited={!(configToEdit.oauth.clientSecret === config.oauth.clientSecret)}
|
||||
/>
|
||||
|
||||
{#if configToEdit.oauth.clientSecret}
|
||||
<SettingSelect
|
||||
label="token_endpoint_auth_method"
|
||||
bind:value={configToEdit.oauth.tokenEndpointAuthMethod}
|
||||
disabled={disabled || !configToEdit.oauth.enabled || !configToEdit.oauth.clientSecret}
|
||||
isEdited={!(configToEdit.oauth.tokenEndpointAuthMethod === config.oauth.tokenEndpointAuthMethod)}
|
||||
options={[
|
||||
{ value: OAuthTokenEndpointAuthMethod.ClientSecretPost, text: 'client_secret_post' },
|
||||
{ value: OAuthTokenEndpointAuthMethod.ClientSecretBasic, text: 'client_secret_basic' },
|
||||
]}
|
||||
name="tokenEndpointAuthMethod"
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<SettingInputField
|
||||
inputType={SettingInputFieldType.TEXT}
|
||||
label="scope"
|
||||
bind:value={configToEdit.oauth.scope}
|
||||
required={true}
|
||||
disabled={disabled || !configToEdit.oauth.enabled}
|
||||
isEdited={!(configToEdit.oauth.scope === config.oauth.scope)}
|
||||
/>
|
||||
|
||||
<SettingInputField
|
||||
inputType={SettingInputFieldType.TEXT}
|
||||
label="id_token_signed_response_alg"
|
||||
bind:value={configToEdit.oauth.signingAlgorithm}
|
||||
required={true}
|
||||
disabled={disabled || !configToEdit.oauth.enabled}
|
||||
isEdited={!(configToEdit.oauth.signingAlgorithm === config.oauth.signingAlgorithm)}
|
||||
/>
|
||||
|
||||
<SettingInputField
|
||||
inputType={SettingInputFieldType.TEXT}
|
||||
label="userinfo_signed_response_alg"
|
||||
bind:value={configToEdit.oauth.profileSigningAlgorithm}
|
||||
required={true}
|
||||
disabled={disabled || !configToEdit.oauth.enabled}
|
||||
isEdited={!(configToEdit.oauth.profileSigningAlgorithm === config.oauth.profileSigningAlgorithm)}
|
||||
/>
|
||||
|
||||
<SettingInputField
|
||||
inputType={SettingInputFieldType.TEXT}
|
||||
label="prompt"
|
||||
description={$t('admin.oauth_prompt_description')}
|
||||
bind:value={configToEdit.oauth.prompt}
|
||||
required={true}
|
||||
disabled={disabled || !configToEdit.oauth.enabled}
|
||||
isEdited={!(configToEdit.oauth.prompt === config.oauth.prompt)}
|
||||
/>
|
||||
|
||||
<SettingInputField
|
||||
inputType={SettingInputFieldType.NUMBER}
|
||||
label={$t('admin.oauth_timeout')}
|
||||
description={$t('admin.oauth_timeout_description')}
|
||||
required={true}
|
||||
bind:value={configToEdit.oauth.timeout}
|
||||
disabled={disabled || !configToEdit.oauth.enabled}
|
||||
isEdited={!(configToEdit.oauth.timeout === config.oauth.timeout)}
|
||||
/>
|
||||
|
||||
<SettingSwitch
|
||||
title={$t('admin.oauth_allow_insecure_requests')}
|
||||
subtitle={$t('admin.oauth_allow_insecure_requests_description')}
|
||||
bind:checked={configToEdit.oauth.allowInsecureRequests}
|
||||
disabled={disabled || !configToEdit.oauth.enabled}
|
||||
isEdited={!(configToEdit.oauth.allowInsecureRequests === config.oauth.allowInsecureRequests)}
|
||||
/>
|
||||
|
||||
<SettingInputField
|
||||
inputType={SettingInputFieldType.TEXT}
|
||||
label={$t('admin.oauth_storage_label_claim')}
|
||||
description={$t('admin.oauth_storage_label_claim_description')}
|
||||
bind:value={configToEdit.oauth.storageLabelClaim}
|
||||
required={true}
|
||||
disabled={disabled || !configToEdit.oauth.enabled}
|
||||
isEdited={!(configToEdit.oauth.storageLabelClaim === config.oauth.storageLabelClaim)}
|
||||
/>
|
||||
|
||||
<SettingInputField
|
||||
inputType={SettingInputFieldType.TEXT}
|
||||
label={$t('admin.oauth_role_claim')}
|
||||
description={$t('admin.oauth_role_claim_description')}
|
||||
bind:value={configToEdit.oauth.roleClaim}
|
||||
required={true}
|
||||
disabled={disabled || !configToEdit.oauth.enabled}
|
||||
isEdited={!(configToEdit.oauth.roleClaim === config.oauth.roleClaim)}
|
||||
/>
|
||||
|
||||
<SettingInputField
|
||||
inputType={SettingInputFieldType.TEXT}
|
||||
label={$t('admin.oauth_storage_quota_claim')}
|
||||
description={$t('admin.oauth_storage_quota_claim_description')}
|
||||
bind:value={configToEdit.oauth.storageQuotaClaim}
|
||||
required={true}
|
||||
disabled={disabled || !configToEdit.oauth.enabled}
|
||||
isEdited={!(configToEdit.oauth.storageQuotaClaim === config.oauth.storageQuotaClaim)}
|
||||
/>
|
||||
|
||||
<SettingInputField
|
||||
inputType={SettingInputFieldType.NUMBER}
|
||||
label={$t('admin.oauth_storage_quota_default')}
|
||||
description={$t('admin.oauth_storage_quota_default_description')}
|
||||
bind:value={configToEdit.oauth.defaultStorageQuota}
|
||||
required={false}
|
||||
disabled={disabled || !configToEdit.oauth.enabled}
|
||||
isEdited={!(configToEdit.oauth.defaultStorageQuota === config.oauth.defaultStorageQuota)}
|
||||
/>
|
||||
|
||||
<SettingInputField
|
||||
inputType={SettingInputFieldType.TEXT}
|
||||
label={$t('admin.oauth_button_text')}
|
||||
bind:value={configToEdit.oauth.buttonText}
|
||||
required={false}
|
||||
disabled={disabled || !configToEdit.oauth.enabled}
|
||||
isEdited={!(configToEdit.oauth.buttonText === config.oauth.buttonText)}
|
||||
/>
|
||||
|
||||
<SettingSwitch
|
||||
title={$t('admin.oauth_auto_register')}
|
||||
subtitle={$t('admin.oauth_auto_register_description')}
|
||||
bind:checked={configToEdit.oauth.autoRegister}
|
||||
disabled={disabled || !configToEdit.oauth.enabled}
|
||||
/>
|
||||
|
||||
<SettingSwitch
|
||||
title={$t('admin.oauth_auto_launch')}
|
||||
subtitle={$t('admin.oauth_auto_launch_description')}
|
||||
disabled={disabled || !configToEdit.oauth.enabled}
|
||||
bind:checked={configToEdit.oauth.autoLaunch}
|
||||
/>
|
||||
|
||||
<SettingSwitch
|
||||
title={$t('admin.oauth_mobile_redirect_uri_override')}
|
||||
subtitle={$t('admin.oauth_mobile_redirect_uri_override_description', {
|
||||
values: { callback: 'app.immich:///oauth-callback' },
|
||||
})}
|
||||
disabled={disabled || !configToEdit.oauth.enabled}
|
||||
onToggle={() => handleToggleOverride()}
|
||||
bind:checked={configToEdit.oauth.mobileOverrideEnabled}
|
||||
/>
|
||||
|
||||
{#if configToEdit.oauth.mobileOverrideEnabled}
|
||||
<SettingInputField
|
||||
inputType={SettingInputFieldType.TEXT}
|
||||
label={$t('admin.oauth_mobile_redirect_uri')}
|
||||
bind:value={configToEdit.oauth.mobileRedirectUri}
|
||||
required={true}
|
||||
disabled={disabled || !configToEdit.oauth.enabled}
|
||||
isEdited={!(configToEdit.oauth.mobileRedirectUri === config.oauth.mobileRedirectUri)}
|
||||
/>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
</SettingAccordion>
|
||||
|
||||
<SettingAccordion
|
||||
key="password"
|
||||
title={$t('admin.password_settings')}
|
||||
subtitle={$t('admin.password_settings_description')}
|
||||
>
|
||||
<div class="ms-4 mt-4 flex flex-col gap-4">
|
||||
<div class="ms-4 mt-4 flex flex-col">
|
||||
<SettingSwitch
|
||||
title={$t('admin.password_enable_description')}
|
||||
{disabled}
|
||||
bind:checked={configToEdit.passwordLogin.enabled}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</SettingAccordion>
|
||||
|
||||
<SettingButtonsRow bind:configToEdit keys={['passwordLogin', 'oauth']} {onBeforeSave} {disabled} />
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,79 +0,0 @@
|
||||
<script lang="ts">
|
||||
import SettingButtonsRow from '$lib/components/shared-components/settings/SystemConfigButtonRow.svelte';
|
||||
import SettingInputField from '$lib/components/shared-components/settings/setting-input-field.svelte';
|
||||
import SettingSelect from '$lib/components/shared-components/settings/setting-select.svelte';
|
||||
import SettingSwitch from '$lib/components/shared-components/settings/setting-switch.svelte';
|
||||
import { SettingInputFieldType } from '$lib/constants';
|
||||
import FormatMessage from '$lib/elements/FormatMessage.svelte';
|
||||
import { featureFlagsManager } from '$lib/managers/feature-flags-manager.svelte';
|
||||
import { systemConfigManager } from '$lib/managers/system-config-manager.svelte';
|
||||
import { Link } from '@immich/ui';
|
||||
import { t } from 'svelte-i18n';
|
||||
import { fade } from 'svelte/transition';
|
||||
|
||||
const disabled = $derived(featureFlagsManager.value.configFile);
|
||||
const config = $derived(systemConfigManager.value);
|
||||
let configToEdit = $state(systemConfigManager.cloneValue());
|
||||
|
||||
let cronExpressionOptions = $derived([
|
||||
{ text: $t('interval.night_at_midnight'), value: '0 0 * * *' },
|
||||
{ text: $t('interval.night_at_twoam'), value: '0 02 * * *' },
|
||||
{ text: $t('interval.day_at_onepm'), value: '0 13 * * *' },
|
||||
{ text: $t('interval.hours', { values: { hours: 6 } }), value: '0 */6 * * *' },
|
||||
]);
|
||||
</script>
|
||||
|
||||
<div>
|
||||
<div in:fade={{ duration: 500 }}>
|
||||
<form autocomplete="off" onsubmit={(event) => event.preventDefault()}>
|
||||
<div class="ms-4 mt-4 flex flex-col gap-4">
|
||||
<SettingSwitch
|
||||
title={$t('admin.backup_database_enable_description')}
|
||||
{disabled}
|
||||
bind:checked={configToEdit.backup.database.enabled}
|
||||
/>
|
||||
|
||||
<SettingSelect
|
||||
options={cronExpressionOptions}
|
||||
disabled={disabled || !configToEdit.backup.database.enabled}
|
||||
name="expression"
|
||||
label={$t('admin.cron_expression_presets')}
|
||||
bind:value={configToEdit.backup.database.cronExpression}
|
||||
/>
|
||||
|
||||
<SettingInputField
|
||||
inputType={SettingInputFieldType.TEXT}
|
||||
required={true}
|
||||
disabled={disabled || !configToEdit.backup.database.enabled}
|
||||
label={$t('admin.cron_expression')}
|
||||
bind:value={configToEdit.backup.database.cronExpression}
|
||||
isEdited={configToEdit.backup.database.cronExpression !== config.backup.database.cronExpression}
|
||||
>
|
||||
{#snippet descriptionSnippet()}
|
||||
<p class="text-sm dark:text-immich-dark-fg">
|
||||
<FormatMessage key="admin.cron_expression_description">
|
||||
{#snippet children({ message })}
|
||||
<Link href="https://crontab.guru/#{configToEdit.backup.database.cronExpression.replaceAll(' ', '_')}">
|
||||
{message}
|
||||
<br />
|
||||
</Link>
|
||||
{/snippet}
|
||||
</FormatMessage>
|
||||
</p>
|
||||
{/snippet}
|
||||
</SettingInputField>
|
||||
|
||||
<SettingInputField
|
||||
inputType={SettingInputFieldType.NUMBER}
|
||||
required={true}
|
||||
label={$t('admin.backup_keep_last_amount')}
|
||||
disabled={disabled || !configToEdit.backup.database.enabled}
|
||||
bind:value={configToEdit.backup.database.keepLastAmount}
|
||||
isEdited={configToEdit.backup.database.keepLastAmount !== config.backup.database.keepLastAmount}
|
||||
/>
|
||||
|
||||
<SettingButtonsRow {disabled} bind:configToEdit keys={['backup']} />
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,398 +0,0 @@
|
||||
<script lang="ts">
|
||||
import SettingAccordion from '$lib/components/shared-components/settings/setting-accordion.svelte';
|
||||
import SettingCheckboxes from '$lib/components/shared-components/settings/setting-checkboxes.svelte';
|
||||
import SettingInputField from '$lib/components/shared-components/settings/setting-input-field.svelte';
|
||||
import SettingSelect from '$lib/components/shared-components/settings/setting-select.svelte';
|
||||
import SettingSwitch from '$lib/components/shared-components/settings/setting-switch.svelte';
|
||||
import SettingButtonsRow from '$lib/components/shared-components/settings/SystemConfigButtonRow.svelte';
|
||||
import { SettingInputFieldType } from '$lib/constants';
|
||||
import FormatMessage from '$lib/elements/FormatMessage.svelte';
|
||||
import { featureFlagsManager } from '$lib/managers/feature-flags-manager.svelte';
|
||||
import { systemConfigManager } from '$lib/managers/system-config-manager.svelte';
|
||||
import {
|
||||
AudioCodec,
|
||||
CQMode,
|
||||
ToneMapping,
|
||||
TranscodeHWAccel,
|
||||
TranscodePolicy,
|
||||
VideoCodec,
|
||||
VideoContainer,
|
||||
} from '@immich/sdk';
|
||||
import { Icon, Link } from '@immich/ui';
|
||||
import { mdiHelpCircleOutline } from '@mdi/js';
|
||||
import { isEqual, sortBy } from 'lodash-es';
|
||||
import { t } from 'svelte-i18n';
|
||||
import { fade } from 'svelte/transition';
|
||||
|
||||
const disabled = $derived(featureFlagsManager.value.configFile);
|
||||
const config = $derived(systemConfigManager.value);
|
||||
let configToEdit = $state(systemConfigManager.cloneValue());
|
||||
</script>
|
||||
|
||||
<div>
|
||||
<div in:fade={{ duration: 500 }}>
|
||||
<form autocomplete="off" onsubmit={(event) => event.preventDefault()}>
|
||||
<div class="ms-4 mt-4 flex flex-col gap-4">
|
||||
<p class="text-sm dark:text-immich-dark-fg">
|
||||
<Icon icon={mdiHelpCircleOutline} class="inline" size="15" />
|
||||
<FormatMessage key="admin.transcoding_codecs_learn_more">
|
||||
{#snippet children({ tag, message })}
|
||||
{#if tag === 'h264-link'}
|
||||
<Link href="https://trac.ffmpeg.org/wiki/Encode/H.264">{message}</Link>
|
||||
{:else if tag === 'hevc-link'}
|
||||
<Link href="https://trac.ffmpeg.org/wiki/Encode/H.265">{message}</Link>
|
||||
{:else if tag === 'vp9-link'}
|
||||
<Link href="https://trac.ffmpeg.org/wiki/Encode/VP9">{message}</Link>
|
||||
{/if}
|
||||
{/snippet}
|
||||
</FormatMessage>
|
||||
</p>
|
||||
|
||||
<SettingAccordion
|
||||
key="transcoding-policy"
|
||||
title={$t('admin.transcoding_policy')}
|
||||
subtitle={$t('admin.transcoding_policy_description')}
|
||||
>
|
||||
<div class="ms-4 mt-4 flex flex-col gap-4">
|
||||
<SettingSelect
|
||||
label={$t('admin.transcoding_transcode_policy')}
|
||||
{disabled}
|
||||
desc={$t('admin.transcoding_transcode_policy_description')}
|
||||
bind:value={configToEdit.ffmpeg.transcode}
|
||||
name="transcode"
|
||||
options={[
|
||||
{ value: TranscodePolicy.All, text: $t('all_videos') },
|
||||
{
|
||||
value: TranscodePolicy.Optimal,
|
||||
text: $t('admin.transcoding_optimal_description'),
|
||||
},
|
||||
{
|
||||
value: TranscodePolicy.Bitrate,
|
||||
text: $t('admin.transcoding_bitrate_description'),
|
||||
},
|
||||
{
|
||||
value: TranscodePolicy.Required,
|
||||
text: $t('admin.transcoding_required_description'),
|
||||
},
|
||||
{
|
||||
value: TranscodePolicy.Disabled,
|
||||
text: $t('admin.transcoding_disabled_description'),
|
||||
},
|
||||
]}
|
||||
isEdited={configToEdit.ffmpeg.transcode !== config.ffmpeg.transcode}
|
||||
/>
|
||||
|
||||
<SettingCheckboxes
|
||||
label={$t('admin.transcoding_accepted_video_codecs')}
|
||||
{disabled}
|
||||
desc={$t('admin.transcoding_accepted_video_codecs_description')}
|
||||
bind:value={configToEdit.ffmpeg.acceptedVideoCodecs}
|
||||
name="videoCodecs"
|
||||
options={[
|
||||
{ value: VideoCodec.H264, text: 'H.264' },
|
||||
{ value: VideoCodec.Hevc, text: 'HEVC' },
|
||||
{ value: VideoCodec.Vp9, text: 'VP9' },
|
||||
{ value: VideoCodec.Av1, text: 'AV1' },
|
||||
]}
|
||||
isEdited={!isEqual(
|
||||
sortBy(configToEdit.ffmpeg.acceptedVideoCodecs),
|
||||
sortBy(config.ffmpeg.acceptedVideoCodecs),
|
||||
)}
|
||||
/>
|
||||
|
||||
<SettingCheckboxes
|
||||
label={$t('admin.transcoding_accepted_audio_codecs')}
|
||||
{disabled}
|
||||
desc={$t('admin.transcoding_accepted_audio_codecs_description')}
|
||||
bind:value={configToEdit.ffmpeg.acceptedAudioCodecs}
|
||||
name="audioCodecs"
|
||||
options={[
|
||||
{ value: AudioCodec.Aac, text: 'AAC' },
|
||||
{ value: AudioCodec.Mp3, text: 'MP3' },
|
||||
{ value: AudioCodec.Opus, text: 'Opus' },
|
||||
{ value: AudioCodec.PcmS16Le, text: 'PCM (16 bit)' },
|
||||
]}
|
||||
isEdited={!isEqual(
|
||||
sortBy(configToEdit.ffmpeg.acceptedAudioCodecs),
|
||||
sortBy(config.ffmpeg.acceptedAudioCodecs),
|
||||
)}
|
||||
/>
|
||||
|
||||
<SettingCheckboxes
|
||||
label={$t('admin.transcoding_accepted_containers')}
|
||||
{disabled}
|
||||
desc={$t('admin.transcoding_accepted_containers_description')}
|
||||
bind:value={configToEdit.ffmpeg.acceptedContainers}
|
||||
name="videoContainers"
|
||||
options={[
|
||||
{ value: VideoContainer.Mov, text: 'MOV' },
|
||||
{ value: VideoContainer.Ogg, text: 'Ogg' },
|
||||
{ value: VideoContainer.Webm, text: 'WebM' },
|
||||
]}
|
||||
isEdited={!isEqual(
|
||||
sortBy(configToEdit.ffmpeg.acceptedContainers),
|
||||
sortBy(config.ffmpeg.acceptedContainers),
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</SettingAccordion>
|
||||
|
||||
<SettingAccordion
|
||||
key="encoding-options"
|
||||
title={$t('admin.transcoding_encoding_options')}
|
||||
subtitle={$t('admin.transcoding_encoding_options_description')}
|
||||
>
|
||||
<div class="ms-4 mt-4 flex flex-col gap-4">
|
||||
<SettingSelect
|
||||
label={$t('admin.transcoding_video_codec')}
|
||||
{disabled}
|
||||
desc={$t('admin.transcoding_video_codec_description')}
|
||||
bind:value={configToEdit.ffmpeg.targetVideoCodec}
|
||||
options={[
|
||||
{ value: VideoCodec.H264, text: 'h264' },
|
||||
{ value: VideoCodec.Hevc, text: 'hevc' },
|
||||
{ value: VideoCodec.Vp9, text: 'vp9' },
|
||||
{ value: VideoCodec.Av1, text: 'av1' },
|
||||
]}
|
||||
name="vcodec"
|
||||
isEdited={configToEdit.ffmpeg.targetVideoCodec !== config.ffmpeg.targetVideoCodec}
|
||||
onSelect={() => (configToEdit.ffmpeg.acceptedVideoCodecs = [configToEdit.ffmpeg.targetVideoCodec])}
|
||||
/>
|
||||
|
||||
<!-- PCM is excluded here since it's a bad choice for users storage-wise -->
|
||||
<SettingSelect
|
||||
label={$t('admin.transcoding_audio_codec')}
|
||||
{disabled}
|
||||
desc={$t('admin.transcoding_audio_codec_description')}
|
||||
bind:value={configToEdit.ffmpeg.targetAudioCodec}
|
||||
options={[
|
||||
{ value: AudioCodec.Aac, text: 'aac' },
|
||||
{ value: AudioCodec.Mp3, text: 'mp3' },
|
||||
{ value: AudioCodec.Opus, text: 'opus' },
|
||||
]}
|
||||
name="acodec"
|
||||
isEdited={configToEdit.ffmpeg.targetAudioCodec !== config.ffmpeg.targetAudioCodec}
|
||||
onSelect={() =>
|
||||
configToEdit.ffmpeg.acceptedAudioCodecs.includes(configToEdit.ffmpeg.targetAudioCodec)
|
||||
? null
|
||||
: configToEdit.ffmpeg.acceptedAudioCodecs.push(configToEdit.ffmpeg.targetAudioCodec)}
|
||||
/>
|
||||
|
||||
<SettingSelect
|
||||
label={$t('admin.transcoding_target_resolution')}
|
||||
{disabled}
|
||||
desc={$t('admin.transcoding_target_resolution_description')}
|
||||
bind:value={configToEdit.ffmpeg.targetResolution}
|
||||
options={[
|
||||
{ value: '2160', text: '4k' },
|
||||
{ value: '1440', text: '1440p' },
|
||||
{ value: '1080', text: '1080p' },
|
||||
{ value: '720', text: '720p' },
|
||||
{ value: '480', text: '480p' },
|
||||
{ value: 'original', text: $t('original') },
|
||||
]}
|
||||
name="resolution"
|
||||
isEdited={configToEdit.ffmpeg.targetResolution !== config.ffmpeg.targetResolution}
|
||||
/>
|
||||
|
||||
<SettingInputField
|
||||
inputType={SettingInputFieldType.NUMBER}
|
||||
{disabled}
|
||||
label={$t('admin.transcoding_constant_rate_factor')}
|
||||
description={$t('admin.transcoding_constant_rate_factor_description')}
|
||||
bind:value={configToEdit.ffmpeg.crf}
|
||||
required={true}
|
||||
isEdited={configToEdit.ffmpeg.crf !== config.ffmpeg.crf}
|
||||
/>
|
||||
|
||||
<SettingSelect
|
||||
label={$t('admin.transcoding_preset_preset')}
|
||||
{disabled}
|
||||
desc={$t('admin.transcoding_preset_preset_description')}
|
||||
bind:value={configToEdit.ffmpeg.preset}
|
||||
name="preset"
|
||||
options={[
|
||||
{ value: 'ultrafast', text: 'ultrafast' },
|
||||
{ value: 'superfast', text: 'superfast' },
|
||||
{ value: 'veryfast', text: 'veryfast' },
|
||||
{ value: 'faster', text: 'faster' },
|
||||
{ value: 'fast', text: 'fast' },
|
||||
{ value: 'medium', text: 'medium' },
|
||||
{ value: 'slow', text: 'slow' },
|
||||
{ value: 'slower', text: 'slower' },
|
||||
{ value: 'veryslow', text: 'veryslow' },
|
||||
]}
|
||||
isEdited={configToEdit.ffmpeg.preset !== config.ffmpeg.preset}
|
||||
/>
|
||||
|
||||
<SettingInputField
|
||||
inputType={SettingInputFieldType.TEXT}
|
||||
{disabled}
|
||||
label={$t('admin.transcoding_max_bitrate')}
|
||||
description={$t('admin.transcoding_max_bitrate_description')}
|
||||
bind:value={configToEdit.ffmpeg.maxBitrate}
|
||||
isEdited={configToEdit.ffmpeg.maxBitrate !== config.ffmpeg.maxBitrate}
|
||||
/>
|
||||
|
||||
<SettingInputField
|
||||
inputType={SettingInputFieldType.NUMBER}
|
||||
{disabled}
|
||||
label={$t('admin.transcoding_threads')}
|
||||
description={$t('admin.transcoding_threads_description')}
|
||||
bind:value={configToEdit.ffmpeg.threads}
|
||||
isEdited={configToEdit.ffmpeg.threads !== config.ffmpeg.threads}
|
||||
/>
|
||||
|
||||
<SettingSelect
|
||||
label={$t('admin.transcoding_tone_mapping')}
|
||||
{disabled}
|
||||
desc={$t('admin.transcoding_tone_mapping_description')}
|
||||
bind:value={configToEdit.ffmpeg.tonemap}
|
||||
name="tonemap"
|
||||
options={[
|
||||
{
|
||||
value: ToneMapping.Hable,
|
||||
text: 'Hable',
|
||||
},
|
||||
{
|
||||
value: ToneMapping.Mobius,
|
||||
text: 'Mobius',
|
||||
},
|
||||
{
|
||||
value: ToneMapping.Reinhard,
|
||||
text: 'Reinhard',
|
||||
},
|
||||
{
|
||||
value: ToneMapping.Disabled,
|
||||
text: $t('disabled'),
|
||||
},
|
||||
]}
|
||||
isEdited={configToEdit.ffmpeg.tonemap !== config.ffmpeg.tonemap}
|
||||
/>
|
||||
|
||||
<SettingSwitch
|
||||
title={$t('admin.transcoding_two_pass_encoding')}
|
||||
{disabled}
|
||||
subtitle={$t('admin.transcoding_two_pass_encoding_setting_description')}
|
||||
bind:checked={configToEdit.ffmpeg.twoPass}
|
||||
isEdited={configToEdit.ffmpeg.twoPass !== config.ffmpeg.twoPass}
|
||||
/>
|
||||
</div>
|
||||
</SettingAccordion>
|
||||
|
||||
<SettingAccordion
|
||||
key="hardware-acceleration"
|
||||
title={$t('admin.transcoding_hardware_acceleration')}
|
||||
subtitle={$t('admin.transcoding_hardware_acceleration_description')}
|
||||
>
|
||||
<div class="ms-4 mt-4 flex flex-col gap-4">
|
||||
<SettingSelect
|
||||
label={$t('admin.transcoding_acceleration_api')}
|
||||
{disabled}
|
||||
desc={$t('admin.transcoding_acceleration_api_description')}
|
||||
bind:value={configToEdit.ffmpeg.accel}
|
||||
name="accel"
|
||||
options={[
|
||||
{ value: TranscodeHWAccel.Nvenc, text: $t('admin.transcoding_acceleration_nvenc') },
|
||||
{
|
||||
value: TranscodeHWAccel.Qsv,
|
||||
text: $t('admin.transcoding_acceleration_qsv'),
|
||||
},
|
||||
{
|
||||
value: TranscodeHWAccel.Vaapi,
|
||||
text: $t('admin.transcoding_acceleration_vaapi'),
|
||||
},
|
||||
{
|
||||
value: TranscodeHWAccel.Rkmpp,
|
||||
text: $t('admin.transcoding_acceleration_rkmpp'),
|
||||
},
|
||||
{
|
||||
value: TranscodeHWAccel.Disabled,
|
||||
text: $t('disabled'),
|
||||
},
|
||||
]}
|
||||
isEdited={configToEdit.ffmpeg.accel !== config.ffmpeg.accel}
|
||||
/>
|
||||
|
||||
<SettingSwitch
|
||||
title={$t('admin.transcoding_hardware_decoding')}
|
||||
{disabled}
|
||||
subtitle={$t('admin.transcoding_hardware_decoding_setting_description')}
|
||||
bind:checked={configToEdit.ffmpeg.accelDecode}
|
||||
isEdited={configToEdit.ffmpeg.accelDecode !== config.ffmpeg.accelDecode}
|
||||
/>
|
||||
|
||||
<SettingSelect
|
||||
label={$t('admin.transcoding_constant_quality_mode')}
|
||||
desc={$t('admin.transcoding_constant_quality_mode_description')}
|
||||
bind:value={configToEdit.ffmpeg.cqMode}
|
||||
options={[
|
||||
{ value: CQMode.Auto, text: 'Auto' },
|
||||
{ value: CQMode.Icq, text: 'ICQ' },
|
||||
{ value: CQMode.Cqp, text: 'CQP' },
|
||||
]}
|
||||
isEdited={configToEdit.ffmpeg.cqMode !== config.ffmpeg.cqMode}
|
||||
{disabled}
|
||||
/>
|
||||
|
||||
<SettingSwitch
|
||||
title={$t('admin.transcoding_temporal_aq')}
|
||||
{disabled}
|
||||
subtitle={$t('admin.transcoding_temporal_aq_description')}
|
||||
bind:checked={configToEdit.ffmpeg.temporalAQ}
|
||||
isEdited={configToEdit.ffmpeg.temporalAQ !== config.ffmpeg.temporalAQ}
|
||||
/>
|
||||
|
||||
<SettingInputField
|
||||
inputType={SettingInputFieldType.TEXT}
|
||||
label={$t('admin.transcoding_preferred_hardware_device')}
|
||||
description={$t('admin.transcoding_preferred_hardware_device_description')}
|
||||
bind:value={configToEdit.ffmpeg.preferredHwDevice}
|
||||
isEdited={configToEdit.ffmpeg.preferredHwDevice !== config.ffmpeg.preferredHwDevice}
|
||||
{disabled}
|
||||
/>
|
||||
</div>
|
||||
</SettingAccordion>
|
||||
|
||||
<SettingAccordion
|
||||
key="advanced-options"
|
||||
title={$t('advanced')}
|
||||
subtitle={$t('admin.transcoding_advanced_options_description')}
|
||||
>
|
||||
<div class="ms-4 mt-4 flex flex-col gap-4">
|
||||
<SettingInputField
|
||||
inputType={SettingInputFieldType.NUMBER}
|
||||
label={$t('admin.transcoding_max_b_frames')}
|
||||
description={$t('admin.transcoding_max_b_frames_description')}
|
||||
bind:value={configToEdit.ffmpeg.bframes}
|
||||
isEdited={configToEdit.ffmpeg.bframes !== config.ffmpeg.bframes}
|
||||
{disabled}
|
||||
/>
|
||||
|
||||
<SettingInputField
|
||||
inputType={SettingInputFieldType.NUMBER}
|
||||
label={$t('admin.transcoding_reference_frames')}
|
||||
description={$t('admin.transcoding_reference_frames_description')}
|
||||
bind:value={configToEdit.ffmpeg.refs}
|
||||
isEdited={configToEdit.ffmpeg.refs !== config.ffmpeg.refs}
|
||||
{disabled}
|
||||
/>
|
||||
|
||||
<SettingInputField
|
||||
inputType={SettingInputFieldType.NUMBER}
|
||||
label={$t('admin.transcoding_max_keyframe_interval')}
|
||||
description={$t('admin.transcoding_max_keyframe_interval_description')}
|
||||
bind:value={configToEdit.ffmpeg.gopSize}
|
||||
isEdited={configToEdit.ffmpeg.gopSize !== config.ffmpeg.gopSize}
|
||||
{disabled}
|
||||
/>
|
||||
</div>
|
||||
</SettingAccordion>
|
||||
</div>
|
||||
|
||||
<div class="ms-4">
|
||||
<SettingButtonsRow bind:configToEdit keys={['ffmpeg']} {disabled} />
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,224 +0,0 @@
|
||||
<script lang="ts">
|
||||
import SettingSelect from '$lib/components/shared-components/settings/setting-select.svelte';
|
||||
import { Colorspace, ImageFormat } from '@immich/sdk';
|
||||
import { fade } from 'svelte/transition';
|
||||
|
||||
import SettingAccordion from '$lib/components/shared-components/settings/setting-accordion.svelte';
|
||||
import SettingInputField from '$lib/components/shared-components/settings/setting-input-field.svelte';
|
||||
import SettingSwitch from '$lib/components/shared-components/settings/setting-switch.svelte';
|
||||
import SettingButtonsRow from '$lib/components/shared-components/settings/SystemConfigButtonRow.svelte';
|
||||
import { SettingInputFieldType } from '$lib/constants';
|
||||
import { featureFlagsManager } from '$lib/managers/feature-flags-manager.svelte';
|
||||
import { systemConfigManager } from '$lib/managers/system-config-manager.svelte';
|
||||
import { t } from 'svelte-i18n';
|
||||
|
||||
const disabled = $derived(featureFlagsManager.value.configFile);
|
||||
const config = $derived(systemConfigManager.value);
|
||||
let configToEdit = $state(systemConfigManager.cloneValue());
|
||||
</script>
|
||||
|
||||
<div>
|
||||
<div in:fade={{ duration: 500 }}>
|
||||
<form autocomplete="off" onsubmit={(event) => event.preventDefault()}>
|
||||
<div class="ms-4 mt-4">
|
||||
<SettingAccordion
|
||||
key="thumbnail-settings"
|
||||
title={$t('admin.image_thumbnail_title')}
|
||||
subtitle={$t('admin.image_thumbnail_description')}
|
||||
>
|
||||
<SettingSelect
|
||||
label={$t('admin.image_format')}
|
||||
desc={$t('admin.image_format_description')}
|
||||
bind:value={configToEdit.image.thumbnail.format}
|
||||
options={[
|
||||
{ value: ImageFormat.Jpeg, text: 'JPEG' },
|
||||
{ value: ImageFormat.Webp, text: 'WebP' },
|
||||
]}
|
||||
name="format"
|
||||
isEdited={configToEdit.image.thumbnail.format !== config.image.thumbnail.format}
|
||||
{disabled}
|
||||
onSelect={(value) => {
|
||||
if (value === ImageFormat.Webp) {
|
||||
configToEdit.image.thumbnail.progressive = false;
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
<SettingSelect
|
||||
label={$t('admin.image_resolution')}
|
||||
desc={$t('admin.image_resolution_description')}
|
||||
number
|
||||
bind:value={configToEdit.image.thumbnail.size}
|
||||
options={[
|
||||
{ value: 1080, text: '1080p' },
|
||||
{ value: 720, text: '720p' },
|
||||
{ value: 480, text: '480p' },
|
||||
{ value: 250, text: '250p' },
|
||||
{ value: 200, text: '200p' },
|
||||
]}
|
||||
name="resolution"
|
||||
isEdited={configToEdit.image.thumbnail.size !== config.image.thumbnail.size}
|
||||
{disabled}
|
||||
/>
|
||||
|
||||
<SettingInputField
|
||||
inputType={SettingInputFieldType.NUMBER}
|
||||
label={$t('admin.image_quality')}
|
||||
description={$t('admin.image_thumbnail_quality_description')}
|
||||
bind:value={configToEdit.image.thumbnail.quality}
|
||||
isEdited={configToEdit.image.thumbnail.quality !== config.image.thumbnail.quality}
|
||||
{disabled}
|
||||
/>
|
||||
|
||||
<SettingSwitch
|
||||
title={$t('admin.image_progressive')}
|
||||
subtitle={$t('admin.image_progressive_description')}
|
||||
checked={configToEdit.image.thumbnail.progressive}
|
||||
onToggle={(isChecked) => (configToEdit.image.thumbnail.progressive = isChecked)}
|
||||
isEdited={configToEdit.image.thumbnail.progressive !== config.image.thumbnail.progressive}
|
||||
disabled={disabled || configToEdit.image.thumbnail.format === ImageFormat.Webp}
|
||||
/>
|
||||
</SettingAccordion>
|
||||
|
||||
<SettingAccordion
|
||||
key="preview-settings"
|
||||
title={$t('admin.image_preview_title')}
|
||||
subtitle={$t('admin.image_preview_description')}
|
||||
>
|
||||
<SettingSelect
|
||||
label={$t('admin.image_format')}
|
||||
desc={$t('admin.image_format_description')}
|
||||
bind:value={configToEdit.image.preview.format}
|
||||
options={[
|
||||
{ value: ImageFormat.Jpeg, text: 'JPEG' },
|
||||
{ value: ImageFormat.Webp, text: 'WebP' },
|
||||
]}
|
||||
name="format"
|
||||
isEdited={configToEdit.image.preview.format !== config.image.preview.format}
|
||||
{disabled}
|
||||
onSelect={(value) => {
|
||||
if (value === ImageFormat.Webp) {
|
||||
configToEdit.image.preview.progressive = false;
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
<SettingSelect
|
||||
label={$t('admin.image_resolution')}
|
||||
desc={$t('admin.image_resolution_description')}
|
||||
number
|
||||
bind:value={configToEdit.image.preview.size}
|
||||
options={[
|
||||
{ value: 2160, text: '4K' },
|
||||
{ value: 1440, text: '1440p' },
|
||||
{ value: 1080, text: '1080p' },
|
||||
{ value: 720, text: '720p' },
|
||||
]}
|
||||
name="resolution"
|
||||
isEdited={configToEdit.image.preview.size !== config.image.preview.size}
|
||||
{disabled}
|
||||
/>
|
||||
|
||||
<SettingInputField
|
||||
inputType={SettingInputFieldType.NUMBER}
|
||||
label={$t('admin.image_quality')}
|
||||
description={$t('admin.image_preview_quality_description')}
|
||||
bind:value={configToEdit.image.preview.quality}
|
||||
isEdited={configToEdit.image.preview.quality !== config.image.preview.quality}
|
||||
{disabled}
|
||||
/>
|
||||
|
||||
<SettingSwitch
|
||||
title={$t('admin.image_progressive')}
|
||||
subtitle={$t('admin.image_progressive_description')}
|
||||
checked={configToEdit.image.preview.progressive}
|
||||
onToggle={(isChecked) => (configToEdit.image.preview.progressive = isChecked)}
|
||||
isEdited={configToEdit.image.preview.progressive !== config.image.preview.progressive}
|
||||
disabled={disabled || configToEdit.image.preview.format === ImageFormat.Webp}
|
||||
/>
|
||||
</SettingAccordion>
|
||||
|
||||
<SettingAccordion
|
||||
key="fullsize-settings"
|
||||
title={$t('admin.image_fullsize_title')}
|
||||
subtitle={$t('admin.image_fullsize_description')}
|
||||
>
|
||||
<SettingSwitch
|
||||
title={$t('admin.image_fullsize_enabled')}
|
||||
subtitle={$t('admin.image_fullsize_enabled_description')}
|
||||
checked={configToEdit.image.fullsize.enabled}
|
||||
onToggle={(isChecked) => (configToEdit.image.fullsize.enabled = isChecked)}
|
||||
isEdited={configToEdit.image.fullsize.enabled !== config.image.fullsize.enabled}
|
||||
{disabled}
|
||||
/>
|
||||
|
||||
<hr class="my-4" />
|
||||
|
||||
<SettingSelect
|
||||
label={$t('admin.image_format')}
|
||||
desc={$t('admin.image_format_description')}
|
||||
bind:value={configToEdit.image.fullsize.format}
|
||||
options={[
|
||||
{ value: ImageFormat.Jpeg, text: 'JPEG' },
|
||||
{ value: ImageFormat.Webp, text: 'WebP' },
|
||||
]}
|
||||
name="format"
|
||||
isEdited={configToEdit.image.fullsize.format !== config.image.fullsize.format}
|
||||
disabled={disabled || !configToEdit.image.fullsize.enabled}
|
||||
onSelect={(value) => {
|
||||
if (value === ImageFormat.Webp) {
|
||||
configToEdit.image.fullsize.progressive = false;
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
<SettingInputField
|
||||
inputType={SettingInputFieldType.NUMBER}
|
||||
label={$t('admin.image_quality')}
|
||||
description={$t('admin.image_fullsize_quality_description')}
|
||||
bind:value={configToEdit.image.fullsize.quality}
|
||||
isEdited={configToEdit.image.fullsize.quality !== config.image.fullsize.quality}
|
||||
disabled={disabled || !configToEdit.image.fullsize.enabled}
|
||||
/>
|
||||
|
||||
<SettingSwitch
|
||||
title={$t('admin.image_progressive')}
|
||||
subtitle={$t('admin.image_progressive_description')}
|
||||
checked={configToEdit.image.fullsize.progressive}
|
||||
onToggle={(isChecked) => (configToEdit.image.fullsize.progressive = isChecked)}
|
||||
isEdited={configToEdit.image.fullsize.progressive !== config.image.fullsize.progressive}
|
||||
disabled={disabled ||
|
||||
!configToEdit.image.fullsize.enabled ||
|
||||
configToEdit.image.fullsize.format === ImageFormat.Webp}
|
||||
/>
|
||||
</SettingAccordion>
|
||||
|
||||
<div class="mt-4">
|
||||
<SettingSwitch
|
||||
title={$t('admin.image_prefer_wide_gamut')}
|
||||
subtitle={$t('admin.image_prefer_wide_gamut_setting_description')}
|
||||
checked={configToEdit.image.colorspace === Colorspace.P3}
|
||||
onToggle={(isChecked) => (configToEdit.image.colorspace = isChecked ? Colorspace.P3 : Colorspace.Srgb)}
|
||||
isEdited={configToEdit.image.colorspace !== config.image.colorspace}
|
||||
{disabled}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="mt-4">
|
||||
<SettingSwitch
|
||||
title={$t('admin.image_prefer_embedded_preview')}
|
||||
subtitle={$t('admin.image_prefer_embedded_preview_setting_description')}
|
||||
checked={configToEdit.image.extractEmbedded}
|
||||
onToggle={() => (configToEdit.image.extractEmbedded = !configToEdit.image.extractEmbedded)}
|
||||
isEdited={configToEdit.image.extractEmbedded !== config.image.extractEmbedded}
|
||||
{disabled}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="ms-4 mt-4">
|
||||
<SettingButtonsRow bind:configToEdit keys={['image']} {disabled} />
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,88 +0,0 @@
|
||||
<script lang="ts">
|
||||
import SettingButtonsRow from '$lib/components/shared-components/settings/SystemConfigButtonRow.svelte';
|
||||
import SettingInputField from '$lib/components/shared-components/settings/setting-input-field.svelte';
|
||||
import { SettingInputFieldType } from '$lib/constants';
|
||||
import { featureFlagsManager } from '$lib/managers/feature-flags-manager.svelte';
|
||||
import { systemConfigManager } from '$lib/managers/system-config-manager.svelte';
|
||||
import { QueueName, type SystemConfigJobDto } from '@immich/sdk';
|
||||
import { t } from 'svelte-i18n';
|
||||
import { fade } from 'svelte/transition';
|
||||
|
||||
const disabled = $derived(featureFlagsManager.value.configFile);
|
||||
const config = $derived(systemConfigManager.value);
|
||||
let configToEdit = $state(systemConfigManager.cloneValue());
|
||||
|
||||
const queueNames = [
|
||||
QueueName.ThumbnailGeneration,
|
||||
QueueName.MetadataExtraction,
|
||||
QueueName.Library,
|
||||
QueueName.Sidecar,
|
||||
QueueName.SmartSearch,
|
||||
QueueName.FaceDetection,
|
||||
QueueName.FacialRecognition,
|
||||
QueueName.VideoConversion,
|
||||
QueueName.StorageTemplateMigration,
|
||||
QueueName.Migration,
|
||||
QueueName.Ocr,
|
||||
];
|
||||
|
||||
function isSystemConfigJobDto(jobName: string): jobName is keyof SystemConfigJobDto {
|
||||
return jobName in configToEdit.job;
|
||||
}
|
||||
|
||||
const queueTitles: Record<QueueName, string> = $derived({
|
||||
[QueueName.ThumbnailGeneration]: $t('admin.thumbnail_generation_job'),
|
||||
[QueueName.MetadataExtraction]: $t('admin.metadata_extraction_job'),
|
||||
[QueueName.Sidecar]: $t('admin.sidecar_job'),
|
||||
[QueueName.SmartSearch]: $t('admin.machine_learning_smart_search'),
|
||||
[QueueName.DuplicateDetection]: $t('admin.machine_learning_duplicate_detection'),
|
||||
[QueueName.FaceDetection]: $t('admin.face_detection'),
|
||||
[QueueName.FacialRecognition]: $t('admin.machine_learning_facial_recognition'),
|
||||
[QueueName.VideoConversion]: $t('admin.video_conversion_job'),
|
||||
[QueueName.StorageTemplateMigration]: $t('admin.storage_template_migration'),
|
||||
[QueueName.Migration]: $t('admin.migration_job'),
|
||||
[QueueName.BackgroundTask]: $t('admin.background_task_job'),
|
||||
[QueueName.Search]: $t('search'),
|
||||
[QueueName.Library]: $t('external_libraries'),
|
||||
[QueueName.Notifications]: $t('notifications'),
|
||||
[QueueName.BackupDatabase]: $t('admin.backup_database'),
|
||||
[QueueName.Ocr]: $t('admin.machine_learning_ocr'),
|
||||
[QueueName.Workflow]: $t('workflows'),
|
||||
[QueueName.Editor]: $t('editor'),
|
||||
});
|
||||
</script>
|
||||
|
||||
<div>
|
||||
<div in:fade={{ duration: 500 }}>
|
||||
<form autocomplete="off" onsubmit={(event) => event.preventDefault()}>
|
||||
{#each queueNames as queueName (queueName)}
|
||||
<div class="ms-4 mt-4 flex flex-col gap-4">
|
||||
{#if isSystemConfigJobDto(queueName)}
|
||||
<SettingInputField
|
||||
inputType={SettingInputFieldType.NUMBER}
|
||||
{disabled}
|
||||
label={$t('admin.job_concurrency', { values: { job: queueTitles[queueName] } })}
|
||||
description=""
|
||||
bind:value={configToEdit.job[queueName].concurrency}
|
||||
required={true}
|
||||
isEdited={!(configToEdit.job[queueName].concurrency == config.job[queueName].concurrency)}
|
||||
/>
|
||||
{:else}
|
||||
<SettingInputField
|
||||
inputType={SettingInputFieldType.NUMBER}
|
||||
label={$t('admin.job_concurrency', { values: { job: queueTitles[queueName] } })}
|
||||
description=""
|
||||
value={1}
|
||||
disabled={true}
|
||||
title={$t('admin.job_not_concurrency_safe')}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
|
||||
<div class="ms-4">
|
||||
<SettingButtonsRow bind:configToEdit keys={['job']} {disabled} />
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,94 +0,0 @@
|
||||
<script lang="ts">
|
||||
import SettingAccordion from '$lib/components/shared-components/settings/setting-accordion.svelte';
|
||||
import SettingInputField from '$lib/components/shared-components/settings/setting-input-field.svelte';
|
||||
import SettingSelect from '$lib/components/shared-components/settings/setting-select.svelte';
|
||||
import SettingSwitch from '$lib/components/shared-components/settings/setting-switch.svelte';
|
||||
import SettingButtonsRow from '$lib/components/shared-components/settings/SystemConfigButtonRow.svelte';
|
||||
import { SettingInputFieldType } from '$lib/constants';
|
||||
import FormatMessage from '$lib/elements/FormatMessage.svelte';
|
||||
import { featureFlagsManager } from '$lib/managers/feature-flags-manager.svelte';
|
||||
import { systemConfigManager } from '$lib/managers/system-config-manager.svelte';
|
||||
import { Link } from '@immich/ui';
|
||||
import { t } from 'svelte-i18n';
|
||||
import { fade } from 'svelte/transition';
|
||||
|
||||
const disabled = $derived(featureFlagsManager.value.configFile);
|
||||
const config = $derived(systemConfigManager.value);
|
||||
let configToEdit = $state(systemConfigManager.cloneValue());
|
||||
|
||||
let cronExpressionOptions = $derived([
|
||||
{ text: $t('interval.night_at_midnight'), value: '0 0 * * *' },
|
||||
{ text: $t('interval.night_at_twoam'), value: '0 2 * * *' },
|
||||
{ text: $t('interval.day_at_onepm'), value: '0 13 * * *' },
|
||||
{ text: $t('interval.hours', { values: { hours: 6 } }), value: '0 */6 * * *' },
|
||||
]);
|
||||
</script>
|
||||
|
||||
<div>
|
||||
<div in:fade={{ duration: 500 }}>
|
||||
<form autocomplete="off" onsubmit={(event) => event.preventDefault()}>
|
||||
<div class="ms-4 mt-4 flex flex-col gap-4">
|
||||
<SettingAccordion
|
||||
key="library-watching"
|
||||
title={$t('admin.library_watching_settings')}
|
||||
subtitle={$t('admin.library_watching_settings_description')}
|
||||
>
|
||||
<div class="ms-4 mt-4 flex flex-col gap-4">
|
||||
<SettingSwitch
|
||||
title={$t('admin.library_watching_enable_description')}
|
||||
{disabled}
|
||||
bind:checked={configToEdit.library.watch.enabled}
|
||||
/>
|
||||
</div>
|
||||
</SettingAccordion>
|
||||
|
||||
<SettingAccordion
|
||||
key="library-scanning"
|
||||
title={$t('admin.library_scanning')}
|
||||
subtitle={$t('admin.library_scanning_description')}
|
||||
>
|
||||
<div class="ms-4 mt-4 flex flex-col gap-4">
|
||||
<SettingSwitch
|
||||
title={$t('admin.library_scanning_enable_description')}
|
||||
{disabled}
|
||||
bind:checked={configToEdit.library.scan.enabled}
|
||||
/>
|
||||
|
||||
<SettingSelect
|
||||
options={cronExpressionOptions}
|
||||
disabled={disabled || !configToEdit.library.scan.enabled}
|
||||
name="expression"
|
||||
label={$t('admin.cron_expression_presets')}
|
||||
bind:value={configToEdit.library.scan.cronExpression}
|
||||
/>
|
||||
|
||||
<SettingInputField
|
||||
inputType={SettingInputFieldType.TEXT}
|
||||
required={true}
|
||||
disabled={disabled || !configToEdit.library.scan.enabled}
|
||||
label={$t('admin.cron_expression')}
|
||||
bind:value={configToEdit.library.scan.cronExpression}
|
||||
isEdited={configToEdit.library.scan.cronExpression !== config.library.scan.cronExpression}
|
||||
>
|
||||
{#snippet descriptionSnippet()}
|
||||
<p class="text-sm dark:text-immich-dark-fg">
|
||||
<FormatMessage key="admin.cron_expression_description">
|
||||
{#snippet children({ message })}
|
||||
<Link
|
||||
href="https://crontab.guru/#{configToEdit.library.scan.cronExpression.replaceAll(' ', '_')}"
|
||||
>
|
||||
{message}
|
||||
</Link>
|
||||
{/snippet}
|
||||
</FormatMessage>
|
||||
</p>
|
||||
{/snippet}
|
||||
</SettingInputField>
|
||||
</div>
|
||||
</SettingAccordion>
|
||||
|
||||
<SettingButtonsRow bind:configToEdit keys={['library']} {disabled} />
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,46 +0,0 @@
|
||||
<script lang="ts">
|
||||
import SettingButtonsRow from '$lib/components/shared-components/settings/SystemConfigButtonRow.svelte';
|
||||
import SettingSelect from '$lib/components/shared-components/settings/setting-select.svelte';
|
||||
import SettingSwitch from '$lib/components/shared-components/settings/setting-switch.svelte';
|
||||
import { featureFlagsManager } from '$lib/managers/feature-flags-manager.svelte';
|
||||
import { systemConfigManager } from '$lib/managers/system-config-manager.svelte';
|
||||
import { LogLevel } from '@immich/sdk';
|
||||
import { t } from 'svelte-i18n';
|
||||
import { fade } from 'svelte/transition';
|
||||
|
||||
const disabled = $derived(featureFlagsManager.value.configFile);
|
||||
const config = $derived(systemConfigManager.value);
|
||||
let configToEdit = $state(systemConfigManager.cloneValue());
|
||||
</script>
|
||||
|
||||
<div>
|
||||
<div in:fade={{ duration: 500 }}>
|
||||
<form autocomplete="off" onsubmit={(event) => event.preventDefault()}>
|
||||
<div class="ms-4 mt-4 flex flex-col gap-4">
|
||||
<SettingSwitch
|
||||
title={$t('admin.logging_enable_description')}
|
||||
{disabled}
|
||||
bind:checked={configToEdit.logging.enabled}
|
||||
/>
|
||||
<SettingSelect
|
||||
label={$t('level')}
|
||||
desc={$t('admin.logging_level_description')}
|
||||
bind:value={configToEdit.logging.level}
|
||||
options={[
|
||||
{ value: LogLevel.Fatal, text: 'Fatal' },
|
||||
{ value: LogLevel.Error, text: 'Error' },
|
||||
{ value: LogLevel.Warn, text: 'Warn' },
|
||||
{ value: LogLevel.Log, text: 'Log' },
|
||||
{ value: LogLevel.Debug, text: 'Debug' },
|
||||
{ value: LogLevel.Verbose, text: 'Verbose' },
|
||||
]}
|
||||
name="level"
|
||||
isEdited={configToEdit.logging.level !== config.logging.level}
|
||||
disabled={disabled || !configToEdit.logging.enabled}
|
||||
/>
|
||||
|
||||
<SettingButtonsRow bind:configToEdit keys={['logging']} {disabled} />
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,331 +0,0 @@
|
||||
<script lang="ts">
|
||||
import SettingAccordion from '$lib/components/shared-components/settings/setting-accordion.svelte';
|
||||
import SettingInputField from '$lib/components/shared-components/settings/setting-input-field.svelte';
|
||||
import SettingSelect from '$lib/components/shared-components/settings/setting-select.svelte';
|
||||
import SettingSwitch from '$lib/components/shared-components/settings/setting-switch.svelte';
|
||||
import SettingButtonsRow from '$lib/components/shared-components/settings/SystemConfigButtonRow.svelte';
|
||||
import { SettingInputFieldType } from '$lib/constants';
|
||||
import FormatMessage from '$lib/elements/FormatMessage.svelte';
|
||||
import { featureFlagsManager } from '$lib/managers/feature-flags-manager.svelte';
|
||||
import { systemConfigManager } from '$lib/managers/system-config-manager.svelte';
|
||||
import { Button, IconButton } from '@immich/ui';
|
||||
import { mdiPlus, mdiTrashCanOutline } from '@mdi/js';
|
||||
import { isEqual } from 'lodash-es';
|
||||
import { t } from 'svelte-i18n';
|
||||
import { fade } from 'svelte/transition';
|
||||
|
||||
const disabled = $derived(featureFlagsManager.value.configFile);
|
||||
const config = $derived(systemConfigManager.value);
|
||||
let configToEdit = $state(systemConfigManager.cloneValue());
|
||||
</script>
|
||||
|
||||
<div class="mt-2">
|
||||
<div in:fade={{ duration: 500 }}>
|
||||
<form autocomplete="off" class="mx-4 mt-4" onsubmit={(event) => event.preventDefault()}>
|
||||
<div class="flex flex-col gap-4">
|
||||
<SettingSwitch
|
||||
title={$t('admin.machine_learning_enabled')}
|
||||
subtitle={$t('admin.machine_learning_enabled_description')}
|
||||
{disabled}
|
||||
bind:checked={configToEdit.machineLearning.enabled}
|
||||
/>
|
||||
|
||||
<hr />
|
||||
|
||||
<div>
|
||||
{#each configToEdit.machineLearning.urls as _, i (i)}
|
||||
<SettingInputField
|
||||
inputType={SettingInputFieldType.TEXT}
|
||||
label={i === 0 ? $t('url') : undefined}
|
||||
description={i === 0 ? $t('admin.machine_learning_url_description') : undefined}
|
||||
bind:value={configToEdit.machineLearning.urls[i]}
|
||||
required={i === 0}
|
||||
disabled={disabled || !configToEdit.machineLearning.enabled}
|
||||
isEdited={i === 0 && !isEqual(configToEdit.machineLearning.urls, config.machineLearning.urls)}
|
||||
>
|
||||
{#snippet trailingSnippet()}
|
||||
{#if configToEdit.machineLearning.urls.length > 1}
|
||||
<IconButton
|
||||
aria-label=""
|
||||
onclick={() => configToEdit.machineLearning.urls.splice(i, 1)}
|
||||
icon={mdiTrashCanOutline}
|
||||
color="danger"
|
||||
/>
|
||||
{/if}
|
||||
{/snippet}
|
||||
</SettingInputField>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end">
|
||||
<Button
|
||||
class="mb-2"
|
||||
size="small"
|
||||
shape="round"
|
||||
leadingIcon={mdiPlus}
|
||||
onclick={() => configToEdit.machineLearning.urls.push('')}
|
||||
disabled={disabled || !configToEdit.machineLearning.enabled}>{$t('add_url')}</Button
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<SettingAccordion
|
||||
key="availability-checks"
|
||||
title={$t('admin.machine_learning_availability_checks')}
|
||||
subtitle={$t('admin.machine_learning_availability_checks_description')}
|
||||
>
|
||||
<div class="ms-4 mt-4 flex flex-col gap-4">
|
||||
<SettingSwitch
|
||||
title={$t('admin.machine_learning_availability_checks_enabled')}
|
||||
bind:checked={configToEdit.machineLearning.availabilityChecks.enabled}
|
||||
disabled={disabled || !configToEdit.machineLearning.enabled}
|
||||
/>
|
||||
|
||||
<hr />
|
||||
|
||||
<SettingInputField
|
||||
inputType={SettingInputFieldType.NUMBER}
|
||||
label={$t('admin.machine_learning_availability_checks_interval')}
|
||||
bind:value={configToEdit.machineLearning.availabilityChecks.interval}
|
||||
description={$t('admin.machine_learning_availability_checks_interval_description')}
|
||||
disabled={disabled ||
|
||||
!configToEdit.machineLearning.enabled ||
|
||||
!configToEdit.machineLearning.availabilityChecks.enabled}
|
||||
isEdited={configToEdit.machineLearning.availabilityChecks.interval !==
|
||||
config.machineLearning.availabilityChecks.interval}
|
||||
/>
|
||||
|
||||
<SettingInputField
|
||||
inputType={SettingInputFieldType.NUMBER}
|
||||
label={$t('admin.machine_learning_availability_checks_timeout')}
|
||||
bind:value={configToEdit.machineLearning.availabilityChecks.timeout}
|
||||
description={$t('admin.machine_learning_availability_checks_timeout_description')}
|
||||
disabled={disabled ||
|
||||
!configToEdit.machineLearning.enabled ||
|
||||
!configToEdit.machineLearning.availabilityChecks.enabled}
|
||||
isEdited={configToEdit.machineLearning.availabilityChecks.timeout !==
|
||||
config.machineLearning.availabilityChecks.timeout}
|
||||
/>
|
||||
</div>
|
||||
</SettingAccordion>
|
||||
|
||||
<SettingAccordion
|
||||
key="smart-search"
|
||||
title={$t('admin.machine_learning_smart_search')}
|
||||
subtitle={$t('admin.machine_learning_smart_search_description')}
|
||||
>
|
||||
<div class="ms-4 mt-4 flex flex-col gap-4">
|
||||
<SettingSwitch
|
||||
title={$t('admin.machine_learning_smart_search_enabled')}
|
||||
subtitle={$t('admin.machine_learning_smart_search_enabled_description')}
|
||||
bind:checked={configToEdit.machineLearning.clip.enabled}
|
||||
disabled={disabled || !configToEdit.machineLearning.enabled}
|
||||
/>
|
||||
|
||||
<hr />
|
||||
|
||||
<SettingInputField
|
||||
inputType={SettingInputFieldType.TEXT}
|
||||
label={$t('admin.machine_learning_clip_model')}
|
||||
bind:value={configToEdit.machineLearning.clip.modelName}
|
||||
required={true}
|
||||
disabled={disabled || !configToEdit.machineLearning.enabled || !configToEdit.machineLearning.clip.enabled}
|
||||
isEdited={configToEdit.machineLearning.clip.modelName !== config.machineLearning.clip.modelName}
|
||||
>
|
||||
{#snippet descriptionSnippet()}
|
||||
<p class="immich-form-label pb-2 text-sm">
|
||||
<FormatMessage key="admin.machine_learning_clip_model_description">
|
||||
{#snippet children({ message })}
|
||||
<a target="_blank" href="https://huggingface.co/immich-app"><u>{message}</u></a>
|
||||
{/snippet}
|
||||
</FormatMessage>
|
||||
</p>
|
||||
{/snippet}
|
||||
</SettingInputField>
|
||||
</div>
|
||||
</SettingAccordion>
|
||||
|
||||
<SettingAccordion
|
||||
key="duplicate-detection"
|
||||
title={$t('admin.machine_learning_duplicate_detection')}
|
||||
subtitle={$t('admin.machine_learning_duplicate_detection_setting_description')}
|
||||
>
|
||||
<div class="ms-4 mt-4 flex flex-col gap-4">
|
||||
<SettingSwitch
|
||||
title={$t('admin.machine_learning_duplicate_detection_enabled')}
|
||||
subtitle={$t('admin.machine_learning_duplicate_detection_enabled_description')}
|
||||
bind:checked={configToEdit.machineLearning.duplicateDetection.enabled}
|
||||
disabled={disabled || !configToEdit.machineLearning.enabled || !configToEdit.machineLearning.clip.enabled}
|
||||
/>
|
||||
|
||||
<hr />
|
||||
|
||||
<SettingInputField
|
||||
inputType={SettingInputFieldType.NUMBER}
|
||||
label={$t('admin.machine_learning_max_detection_distance')}
|
||||
bind:value={configToEdit.machineLearning.duplicateDetection.maxDistance}
|
||||
step="0.0005"
|
||||
min={0.001}
|
||||
max={0.1}
|
||||
description={$t('admin.machine_learning_max_detection_distance_description')}
|
||||
disabled={disabled || !featureFlagsManager.value.duplicateDetection}
|
||||
isEdited={configToEdit.machineLearning.duplicateDetection.maxDistance !==
|
||||
config.machineLearning.duplicateDetection.maxDistance}
|
||||
/>
|
||||
</div>
|
||||
</SettingAccordion>
|
||||
|
||||
<SettingAccordion
|
||||
key="facial-recognition"
|
||||
title={$t('admin.machine_learning_facial_recognition')}
|
||||
subtitle={$t('admin.machine_learning_facial_recognition_description')}
|
||||
>
|
||||
<div class="ms-4 mt-4 flex flex-col gap-4">
|
||||
<SettingSwitch
|
||||
title={$t('admin.machine_learning_facial_recognition_setting')}
|
||||
subtitle={$t('admin.machine_learning_facial_recognition_setting_description')}
|
||||
bind:checked={configToEdit.machineLearning.facialRecognition.enabled}
|
||||
disabled={disabled || !configToEdit.machineLearning.enabled}
|
||||
/>
|
||||
|
||||
<hr />
|
||||
|
||||
<SettingSelect
|
||||
label={$t('admin.machine_learning_facial_recognition_model')}
|
||||
desc={$t('admin.machine_learning_facial_recognition_model_description')}
|
||||
name="facial-recognition-model"
|
||||
bind:value={configToEdit.machineLearning.facialRecognition.modelName}
|
||||
options={[
|
||||
{ value: 'antelopev2', text: 'antelopev2' },
|
||||
{ value: 'buffalo_l', text: 'buffalo_l' },
|
||||
{ value: 'buffalo_m', text: 'buffalo_m' },
|
||||
{ value: 'buffalo_s', text: 'buffalo_s' },
|
||||
]}
|
||||
disabled={disabled ||
|
||||
!configToEdit.machineLearning.enabled ||
|
||||
!configToEdit.machineLearning.facialRecognition.enabled}
|
||||
isEdited={configToEdit.machineLearning.facialRecognition.modelName !==
|
||||
config.machineLearning.facialRecognition.modelName}
|
||||
/>
|
||||
|
||||
<SettingInputField
|
||||
inputType={SettingInputFieldType.NUMBER}
|
||||
label={$t('admin.machine_learning_min_detection_score')}
|
||||
description={$t('admin.machine_learning_min_detection_score_description')}
|
||||
bind:value={configToEdit.machineLearning.facialRecognition.minScore}
|
||||
step="0.01"
|
||||
min={0.1}
|
||||
max={1}
|
||||
disabled={disabled ||
|
||||
!configToEdit.machineLearning.enabled ||
|
||||
!configToEdit.machineLearning.facialRecognition.enabled}
|
||||
isEdited={configToEdit.machineLearning.facialRecognition.minScore !==
|
||||
config.machineLearning.facialRecognition.minScore}
|
||||
/>
|
||||
|
||||
<SettingInputField
|
||||
inputType={SettingInputFieldType.NUMBER}
|
||||
label={$t('admin.machine_learning_max_recognition_distance')}
|
||||
description={$t('admin.machine_learning_max_recognition_distance_description')}
|
||||
bind:value={configToEdit.machineLearning.facialRecognition.maxDistance}
|
||||
step="0.01"
|
||||
min={0.1}
|
||||
max={2}
|
||||
disabled={disabled ||
|
||||
!configToEdit.machineLearning.enabled ||
|
||||
!configToEdit.machineLearning.facialRecognition.enabled}
|
||||
isEdited={configToEdit.machineLearning.facialRecognition.maxDistance !==
|
||||
config.machineLearning.facialRecognition.maxDistance}
|
||||
/>
|
||||
|
||||
<SettingInputField
|
||||
inputType={SettingInputFieldType.NUMBER}
|
||||
label={$t('admin.machine_learning_min_recognized_faces')}
|
||||
description={$t('admin.machine_learning_min_recognized_faces_description')}
|
||||
bind:value={configToEdit.machineLearning.facialRecognition.minFaces}
|
||||
step="1"
|
||||
min={1}
|
||||
disabled={disabled ||
|
||||
!configToEdit.machineLearning.enabled ||
|
||||
!configToEdit.machineLearning.facialRecognition.enabled}
|
||||
isEdited={configToEdit.machineLearning.facialRecognition.minFaces !==
|
||||
config.machineLearning.facialRecognition.minFaces}
|
||||
/>
|
||||
</div>
|
||||
</SettingAccordion>
|
||||
|
||||
<SettingAccordion
|
||||
key="ocr"
|
||||
title={$t('admin.machine_learning_ocr')}
|
||||
subtitle={$t('admin.machine_learning_ocr_description')}
|
||||
>
|
||||
<div class="ml-4 mt-4 flex flex-col gap-4">
|
||||
<SettingSwitch
|
||||
title={$t('admin.machine_learning_ocr_enabled')}
|
||||
subtitle={$t('admin.machine_learning_ocr_enabled_description')}
|
||||
bind:checked={configToEdit.machineLearning.ocr.enabled}
|
||||
disabled={disabled || !configToEdit.machineLearning.enabled}
|
||||
/>
|
||||
|
||||
<hr />
|
||||
|
||||
<SettingSelect
|
||||
label={$t('admin.machine_learning_ocr_model')}
|
||||
desc={$t('admin.machine_learning_ocr_model_description')}
|
||||
name="ocr-model"
|
||||
bind:value={configToEdit.machineLearning.ocr.modelName}
|
||||
options={[
|
||||
{ text: 'PP-OCRv5_server (Chinese, Japanese and English)', value: 'PP-OCRv5_server' },
|
||||
{ text: 'PP-OCRv5_mobile (Chinese, Japanese and English)', value: 'PP-OCRv5_mobile' },
|
||||
{ text: 'PP-OCRv5_mobile (English-only)', value: 'EN__PP-OCRv5_mobile' },
|
||||
{ text: 'PP-OCRv5_mobile (Greek and English)', value: 'EL__PP-OCRv5_mobile' },
|
||||
{ text: 'PP-OCRv5_mobile (Korean and English)', value: 'KOREAN__PP-OCRv5_mobile' },
|
||||
{ text: 'PP-OCRv5_mobile (Latin script languages)', value: 'LATIN__PP-OCRv5_mobile' },
|
||||
{ text: 'PP-OCRv5_mobile (Russian, Belarusian, Ukrainian and English)', value: 'ESLAV__PP-OCRv5_mobile' },
|
||||
{ text: 'PP-OCRv5_mobile (Thai and English)', value: 'TH__PP-OCRv5_mobile' },
|
||||
]}
|
||||
disabled={disabled || !configToEdit.machineLearning.enabled || !configToEdit.machineLearning.ocr.enabled}
|
||||
isEdited={configToEdit.machineLearning.ocr.modelName !== config.machineLearning.ocr.modelName}
|
||||
/>
|
||||
|
||||
<SettingInputField
|
||||
inputType={SettingInputFieldType.NUMBER}
|
||||
label={$t('admin.machine_learning_ocr_min_detection_score')}
|
||||
description={$t('admin.machine_learning_ocr_min_detection_score_description')}
|
||||
bind:value={configToEdit.machineLearning.ocr.minDetectionScore}
|
||||
step="0.1"
|
||||
min={0.1}
|
||||
max={1}
|
||||
disabled={disabled || !configToEdit.machineLearning.enabled || !configToEdit.machineLearning.ocr.enabled}
|
||||
isEdited={configToEdit.machineLearning.ocr.minDetectionScore !==
|
||||
config.machineLearning.ocr.minDetectionScore}
|
||||
/>
|
||||
|
||||
<SettingInputField
|
||||
inputType={SettingInputFieldType.NUMBER}
|
||||
label={$t('admin.machine_learning_ocr_min_recognition_score')}
|
||||
description={$t('admin.machine_learning_ocr_min_score_recognition_description')}
|
||||
bind:value={configToEdit.machineLearning.ocr.minRecognitionScore}
|
||||
step="0.1"
|
||||
min={0.1}
|
||||
max={1}
|
||||
disabled={disabled || !configToEdit.machineLearning.enabled || !configToEdit.machineLearning.ocr.enabled}
|
||||
isEdited={configToEdit.machineLearning.ocr.minRecognitionScore !==
|
||||
config.machineLearning.ocr.minRecognitionScore}
|
||||
/>
|
||||
|
||||
<SettingInputField
|
||||
inputType={SettingInputFieldType.NUMBER}
|
||||
label={$t('admin.machine_learning_ocr_max_resolution')}
|
||||
description={$t('admin.machine_learning_ocr_max_resolution_description')}
|
||||
bind:value={configToEdit.machineLearning.ocr.maxResolution}
|
||||
min={1}
|
||||
disabled={disabled || !configToEdit.machineLearning.enabled || !configToEdit.machineLearning.ocr.enabled}
|
||||
isEdited={configToEdit.machineLearning.ocr.maxResolution !== config.machineLearning.ocr.maxResolution}
|
||||
/>
|
||||
</div>
|
||||
</SettingAccordion>
|
||||
<SettingButtonsRow bind:configToEdit keys={['machineLearning']} {disabled} />
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,76 +0,0 @@
|
||||
<script lang="ts">
|
||||
import SettingAccordion from '$lib/components/shared-components/settings/setting-accordion.svelte';
|
||||
import SettingInputField from '$lib/components/shared-components/settings/setting-input-field.svelte';
|
||||
import SettingSwitch from '$lib/components/shared-components/settings/setting-switch.svelte';
|
||||
import SettingButtonsRow from '$lib/components/shared-components/settings/SystemConfigButtonRow.svelte';
|
||||
import { SettingInputFieldType } from '$lib/constants';
|
||||
import FormatMessage from '$lib/elements/FormatMessage.svelte';
|
||||
import { featureFlagsManager } from '$lib/managers/feature-flags-manager.svelte';
|
||||
import { systemConfigManager } from '$lib/managers/system-config-manager.svelte';
|
||||
import { Link } from '@immich/ui';
|
||||
import { t } from 'svelte-i18n';
|
||||
import { fade } from 'svelte/transition';
|
||||
|
||||
const disabled = $derived(featureFlagsManager.value.configFile);
|
||||
const config = $derived(systemConfigManager.value);
|
||||
let configToEdit = $state(systemConfigManager.cloneValue());
|
||||
</script>
|
||||
|
||||
<div class="mt-2">
|
||||
<div in:fade={{ duration: 500 }}>
|
||||
<form autocomplete="off" onsubmit={(event) => event.preventDefault()}>
|
||||
<div class="flex flex-col gap-4">
|
||||
<SettingAccordion key="map" title={$t('admin.map_settings')} subtitle={$t('admin.map_settings_description')}>
|
||||
<div class="ms-4 mt-4 flex flex-col gap-4">
|
||||
<SettingSwitch
|
||||
title={$t('admin.map_enable_description')}
|
||||
subtitle={$t('admin.map_implications')}
|
||||
{disabled}
|
||||
bind:checked={configToEdit.map.enabled}
|
||||
/>
|
||||
|
||||
<hr />
|
||||
|
||||
<SettingInputField
|
||||
inputType={SettingInputFieldType.TEXT}
|
||||
label={$t('admin.map_light_style')}
|
||||
description={$t('admin.map_style_description')}
|
||||
bind:value={configToEdit.map.lightStyle}
|
||||
disabled={disabled || !configToEdit.map.enabled}
|
||||
isEdited={configToEdit.map.lightStyle !== config.map.lightStyle}
|
||||
/>
|
||||
<SettingInputField
|
||||
inputType={SettingInputFieldType.TEXT}
|
||||
label={$t('admin.map_dark_style')}
|
||||
description={$t('admin.map_style_description')}
|
||||
bind:value={configToEdit.map.darkStyle}
|
||||
disabled={disabled || !configToEdit.map.enabled}
|
||||
isEdited={configToEdit.map.darkStyle !== config.map.darkStyle}
|
||||
/>
|
||||
</div></SettingAccordion
|
||||
>
|
||||
|
||||
<SettingAccordion key="reverse-geocoding" title={$t('admin.map_reverse_geocoding_settings')}>
|
||||
{#snippet subtitleSnippet()}
|
||||
<p class="text-sm dark:text-immich-dark-fg">
|
||||
<FormatMessage key="admin.map_manage_reverse_geocoding_settings">
|
||||
{#snippet children({ message })}
|
||||
<Link href="https://docs.immich.app/features/reverse-geocoding">{message}</Link>
|
||||
{/snippet}
|
||||
</FormatMessage>
|
||||
</p>
|
||||
{/snippet}
|
||||
<div class="ms-4 mt-4 flex flex-col gap-4">
|
||||
<SettingSwitch
|
||||
title={$t('admin.map_reverse_geocoding_enable_description')}
|
||||
{disabled}
|
||||
bind:checked={configToEdit.reverseGeocoding.enabled}
|
||||
/>
|
||||
</div></SettingAccordion
|
||||
>
|
||||
|
||||
<SettingButtonsRow bind:configToEdit keys={['map', 'reverseGeocoding']} {disabled} />
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,28 +0,0 @@
|
||||
<script lang="ts">
|
||||
import SettingButtonsRow from '$lib/components/shared-components/settings/SystemConfigButtonRow.svelte';
|
||||
import SettingSwitch from '$lib/components/shared-components/settings/setting-switch.svelte';
|
||||
import { featureFlagsManager } from '$lib/managers/feature-flags-manager.svelte';
|
||||
import { systemConfigManager } from '$lib/managers/system-config-manager.svelte';
|
||||
import { t } from 'svelte-i18n';
|
||||
import { fade } from 'svelte/transition';
|
||||
|
||||
const disabled = $derived(featureFlagsManager.value.configFile);
|
||||
let configToEdit = $state(systemConfigManager.cloneValue());
|
||||
</script>
|
||||
|
||||
<div class="mt-2">
|
||||
<div in:fade={{ duration: 500 }}>
|
||||
<form autocomplete="off" class="mx-4 mt-4" onsubmit={(event) => event.preventDefault()}>
|
||||
<div class="ms-4 mt-4 flex flex-col gap-4">
|
||||
<SettingSwitch
|
||||
title={$t('admin.metadata_faces_import_setting')}
|
||||
subtitle={$t('admin.metadata_faces_import_setting_description')}
|
||||
bind:checked={configToEdit.metadata.faces.import}
|
||||
{disabled}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<SettingButtonsRow bind:configToEdit keys={['metadata']} {disabled} />
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,27 +0,0 @@
|
||||
<script lang="ts">
|
||||
import SettingButtonsRow from '$lib/components/shared-components/settings/SystemConfigButtonRow.svelte';
|
||||
import SettingSwitch from '$lib/components/shared-components/settings/setting-switch.svelte';
|
||||
import { featureFlagsManager } from '$lib/managers/feature-flags-manager.svelte';
|
||||
import { systemConfigManager } from '$lib/managers/system-config-manager.svelte';
|
||||
import { t } from 'svelte-i18n';
|
||||
import { fade } from 'svelte/transition';
|
||||
|
||||
const disabled = $derived(featureFlagsManager.value.configFile);
|
||||
let configToEdit = $state(systemConfigManager.cloneValue());
|
||||
</script>
|
||||
|
||||
<div>
|
||||
<div in:fade={{ duration: 500 }}>
|
||||
<form autocomplete="off" onsubmit={(event) => event.preventDefault()}>
|
||||
<div class="ms-4 mt-4">
|
||||
<SettingSwitch
|
||||
title={$t('admin.version_check_enabled_description')}
|
||||
subtitle={$t('admin.version_check_implications', { values: { server: 'version.immich.cloud' } })}
|
||||
bind:checked={configToEdit.newVersionCheck.enabled}
|
||||
{disabled}
|
||||
/>
|
||||
<SettingButtonsRow bind:configToEdit keys={['newVersionCheck']} {disabled} />
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,64 +0,0 @@
|
||||
<script lang="ts">
|
||||
import SettingButtonsRow from '$lib/components/shared-components/settings/SystemConfigButtonRow.svelte';
|
||||
import SettingInputField from '$lib/components/shared-components/settings/setting-input-field.svelte';
|
||||
import SettingSwitch from '$lib/components/shared-components/settings/setting-switch.svelte';
|
||||
import { SettingInputFieldType } from '$lib/constants';
|
||||
import { featureFlagsManager } from '$lib/managers/feature-flags-manager.svelte';
|
||||
import { systemConfigManager } from '$lib/managers/system-config-manager.svelte';
|
||||
import { t } from 'svelte-i18n';
|
||||
import { fade } from 'svelte/transition';
|
||||
|
||||
const disabled = $derived(featureFlagsManager.value.configFile);
|
||||
const config = $derived(systemConfigManager.value);
|
||||
let configToEdit = $state(systemConfigManager.cloneValue());
|
||||
</script>
|
||||
|
||||
<div class="mt-2">
|
||||
<div in:fade={{ duration: 500 }}>
|
||||
<form autocomplete="off" class="mx-4 mt-4" onsubmit={(event) => event.preventDefault()}>
|
||||
<div class="ms-4 mt-4 flex flex-col gap-4">
|
||||
<SettingInputField
|
||||
inputType={SettingInputFieldType.TEXT}
|
||||
label={$t('admin.nightly_tasks_start_time_setting')}
|
||||
description={$t('admin.nightly_tasks_start_time_setting_description')}
|
||||
bind:value={configToEdit.nightlyTasks.startTime}
|
||||
required={true}
|
||||
{disabled}
|
||||
isEdited={!(configToEdit.nightlyTasks.startTime === config.nightlyTasks.startTime)}
|
||||
/>
|
||||
<SettingSwitch
|
||||
title={$t('admin.nightly_tasks_database_cleanup_setting')}
|
||||
subtitle={$t('admin.nightly_tasks_database_cleanup_setting_description')}
|
||||
bind:checked={configToEdit.nightlyTasks.databaseCleanup}
|
||||
{disabled}
|
||||
/>
|
||||
<SettingSwitch
|
||||
title={$t('admin.nightly_tasks_missing_thumbnails_setting')}
|
||||
subtitle={$t('admin.nightly_tasks_missing_thumbnails_setting_description')}
|
||||
bind:checked={configToEdit.nightlyTasks.missingThumbnails}
|
||||
{disabled}
|
||||
/>
|
||||
<SettingSwitch
|
||||
title={$t('admin.nightly_tasks_cluster_new_faces_setting')}
|
||||
subtitle={$t('admin.nightly_tasks_cluster_faces_setting_description')}
|
||||
bind:checked={configToEdit.nightlyTasks.clusterNewFaces}
|
||||
{disabled}
|
||||
/>
|
||||
<SettingSwitch
|
||||
title={$t('admin.nightly_tasks_generate_memories_setting')}
|
||||
subtitle={$t('admin.nightly_tasks_generate_memories_setting_description')}
|
||||
bind:checked={configToEdit.nightlyTasks.generateMemories}
|
||||
{disabled}
|
||||
/>
|
||||
<SettingSwitch
|
||||
title={$t('admin.nightly_tasks_sync_quota_usage_setting')}
|
||||
subtitle={$t('admin.nightly_tasks_sync_quota_usage_setting_description')}
|
||||
bind:checked={configToEdit.nightlyTasks.syncQuotaUsage}
|
||||
{disabled}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<SettingButtonsRow bind:configToEdit keys={['nightlyTasks']} {disabled} />
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,166 +0,0 @@
|
||||
<script lang="ts">
|
||||
import TemplateSettings from '$lib/components/admin-settings/TemplateSettings.svelte';
|
||||
import SettingAccordion from '$lib/components/shared-components/settings/setting-accordion.svelte';
|
||||
import SettingInputField from '$lib/components/shared-components/settings/setting-input-field.svelte';
|
||||
import SettingSwitch from '$lib/components/shared-components/settings/setting-switch.svelte';
|
||||
import SettingButtonsRow from '$lib/components/shared-components/settings/SystemConfigButtonRow.svelte';
|
||||
import { SettingInputFieldType } from '$lib/constants';
|
||||
import { authManager } from '$lib/managers/auth-manager.svelte';
|
||||
import { featureFlagsManager } from '$lib/managers/feature-flags-manager.svelte';
|
||||
import { systemConfigManager } from '$lib/managers/system-config-manager.svelte';
|
||||
import { handleSystemConfigSave } from '$lib/services/system-config.service';
|
||||
import { handleError } from '$lib/utils/handle-error';
|
||||
import { sendTestEmailAdmin } from '@immich/sdk';
|
||||
import { Button, toastManager } from '@immich/ui';
|
||||
import { t } from 'svelte-i18n';
|
||||
import { fade } from 'svelte/transition';
|
||||
|
||||
const disabled = $derived(featureFlagsManager.value.configFile);
|
||||
const config = $derived(systemConfigManager.value);
|
||||
let configToEdit = $state(systemConfigManager.cloneValue());
|
||||
|
||||
let isSending = $state(false);
|
||||
|
||||
const handleSendTestEmail = async () => {
|
||||
if (isSending) {
|
||||
return;
|
||||
}
|
||||
|
||||
isSending = true;
|
||||
|
||||
try {
|
||||
await sendTestEmailAdmin({
|
||||
systemConfigSmtpDto: {
|
||||
enabled: configToEdit.notifications.smtp.enabled,
|
||||
transport: {
|
||||
host: configToEdit.notifications.smtp.transport.host,
|
||||
port: configToEdit.notifications.smtp.transport.port,
|
||||
secure: configToEdit.notifications.smtp.transport.secure,
|
||||
username: configToEdit.notifications.smtp.transport.username,
|
||||
password: configToEdit.notifications.smtp.transport.password,
|
||||
ignoreCert: configToEdit.notifications.smtp.transport.ignoreCert,
|
||||
},
|
||||
from: configToEdit.notifications.smtp.from,
|
||||
replyTo: configToEdit.notifications.smtp.from,
|
||||
},
|
||||
});
|
||||
|
||||
toastManager.primary(
|
||||
$t('admin.notification_email_test_email_sent', { values: { email: authManager.user.email } }),
|
||||
);
|
||||
|
||||
if (!disabled) {
|
||||
await handleSystemConfigSave({ notifications: configToEdit.notifications });
|
||||
}
|
||||
} catch (error) {
|
||||
handleError(error, $t('admin.notification_email_test_email_failed'));
|
||||
} finally {
|
||||
isSending = false;
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<div>
|
||||
<div in:fade={{ duration: 500 }}>
|
||||
<form autocomplete="off" class="mt-4" onsubmit={(event) => event.preventDefault()}>
|
||||
<div class="flex flex-col gap-4">
|
||||
<SettingAccordion key="email" title={$t('email')} subtitle={$t('admin.notification_email_setting_description')}>
|
||||
<div class="ms-4 mt-4 flex flex-col gap-4">
|
||||
<SettingSwitch
|
||||
title={$t('admin.notification_enable_email_notifications')}
|
||||
{disabled}
|
||||
bind:checked={configToEdit.notifications.smtp.enabled}
|
||||
/>
|
||||
|
||||
<hr />
|
||||
|
||||
<SettingInputField
|
||||
inputType={SettingInputFieldType.TEXT}
|
||||
required
|
||||
label={$t('host')}
|
||||
description={$t('admin.notification_email_host_description')}
|
||||
disabled={disabled || !configToEdit.notifications.smtp.enabled}
|
||||
bind:value={configToEdit.notifications.smtp.transport.host}
|
||||
isEdited={configToEdit.notifications.smtp.transport.host !== config.notifications.smtp.transport.host}
|
||||
/>
|
||||
|
||||
<SettingInputField
|
||||
inputType={SettingInputFieldType.NUMBER}
|
||||
required
|
||||
label={$t('port')}
|
||||
description={$t('admin.notification_email_port_description')}
|
||||
disabled={disabled || !configToEdit.notifications.smtp.enabled}
|
||||
bind:value={configToEdit.notifications.smtp.transport.port}
|
||||
isEdited={configToEdit.notifications.smtp.transport.port !== config.notifications.smtp.transport.port}
|
||||
/>
|
||||
|
||||
<SettingInputField
|
||||
inputType={SettingInputFieldType.TEXT}
|
||||
label={$t('username')}
|
||||
description={$t('admin.notification_email_username_description')}
|
||||
disabled={disabled || !configToEdit.notifications.smtp.enabled}
|
||||
bind:value={configToEdit.notifications.smtp.transport.username}
|
||||
isEdited={configToEdit.notifications.smtp.transport.username !==
|
||||
config.notifications.smtp.transport.username}
|
||||
/>
|
||||
|
||||
<SettingInputField
|
||||
inputType={SettingInputFieldType.PASSWORD}
|
||||
label={$t('password')}
|
||||
description={$t('admin.notification_email_password_description')}
|
||||
disabled={disabled || !configToEdit.notifications.smtp.enabled}
|
||||
bind:value={configToEdit.notifications.smtp.transport.password}
|
||||
isEdited={configToEdit.notifications.smtp.transport.password !==
|
||||
config.notifications.smtp.transport.password}
|
||||
/>
|
||||
|
||||
<SettingSwitch
|
||||
title={$t('admin.notification_email_secure')}
|
||||
subtitle={$t('admin.notification_email_secure_description')}
|
||||
disabled={disabled || !configToEdit.notifications.smtp.enabled}
|
||||
bind:checked={configToEdit.notifications.smtp.transport.secure}
|
||||
/>
|
||||
|
||||
<SettingSwitch
|
||||
title={$t('admin.notification_email_ignore_certificate_errors')}
|
||||
subtitle={$t('admin.notification_email_ignore_certificate_errors_description')}
|
||||
disabled={disabled || !configToEdit.notifications.smtp.enabled}
|
||||
bind:checked={configToEdit.notifications.smtp.transport.ignoreCert}
|
||||
/>
|
||||
|
||||
<hr />
|
||||
|
||||
<SettingInputField
|
||||
inputType={SettingInputFieldType.TEXT}
|
||||
required
|
||||
label={$t('admin.notification_email_from_address')}
|
||||
description={$t('admin.notification_email_from_address_description')}
|
||||
disabled={disabled || !configToEdit.notifications.smtp.enabled}
|
||||
bind:value={configToEdit.notifications.smtp.from}
|
||||
isEdited={configToEdit.notifications.smtp.from !== config.notifications.smtp.from}
|
||||
/>
|
||||
|
||||
<div class="flex gap-2 place-items-center">
|
||||
<Button
|
||||
size="small"
|
||||
shape="round"
|
||||
loading={isSending}
|
||||
disabled={!configToEdit.notifications.smtp.enabled}
|
||||
onclick={handleSendTestEmail}
|
||||
>
|
||||
{#if disabled}
|
||||
{$t('admin.notification_email_test_email')}
|
||||
{:else}
|
||||
{$t('admin.notification_email_sent_test_email_button')}
|
||||
{/if}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</SettingAccordion>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<TemplateSettings bind:config={configToEdit} />
|
||||
|
||||
<SettingButtonsRow bind:configToEdit keys={['notifications', 'templates']} {disabled} />
|
||||
</div>
|
||||
@@ -1,49 +0,0 @@
|
||||
<script lang="ts">
|
||||
import SettingButtonsRow from '$lib/components/shared-components/settings/SystemConfigButtonRow.svelte';
|
||||
import SettingInputField from '$lib/components/shared-components/settings/setting-input-field.svelte';
|
||||
import SettingSwitch from '$lib/components/shared-components/settings/setting-switch.svelte';
|
||||
import { SettingInputFieldType } from '$lib/constants';
|
||||
import { featureFlagsManager } from '$lib/managers/feature-flags-manager.svelte';
|
||||
import { systemConfigManager } from '$lib/managers/system-config-manager.svelte';
|
||||
import { t } from 'svelte-i18n';
|
||||
import { fade } from 'svelte/transition';
|
||||
|
||||
const disabled = $derived(featureFlagsManager.value.configFile);
|
||||
const config = $derived(systemConfigManager.value);
|
||||
let configToEdit = $state(systemConfigManager.cloneValue());
|
||||
</script>
|
||||
|
||||
<div>
|
||||
<div in:fade={{ duration: 500 }}>
|
||||
<form autocomplete="off" onsubmit={(event) => event.preventDefault()}>
|
||||
<div class="mt-4 ms-4">
|
||||
<SettingInputField
|
||||
inputType={SettingInputFieldType.TEXT}
|
||||
label={$t('admin.server_external_domain_settings')}
|
||||
description={$t('admin.server_external_domain_settings_description')}
|
||||
bind:value={configToEdit.server.externalDomain}
|
||||
isEdited={configToEdit.server.externalDomain !== config.server.externalDomain}
|
||||
/>
|
||||
|
||||
<SettingInputField
|
||||
inputType={SettingInputFieldType.TEXT}
|
||||
label={$t('admin.server_welcome_message')}
|
||||
description={$t('admin.server_welcome_message_description')}
|
||||
bind:value={configToEdit.server.loginPageMessage}
|
||||
isEdited={configToEdit.server.loginPageMessage !== config.server.loginPageMessage}
|
||||
/>
|
||||
|
||||
<SettingSwitch
|
||||
title={$t('admin.server_public_users')}
|
||||
subtitle={$t('admin.server_public_users_description')}
|
||||
{disabled}
|
||||
bind:checked={configToEdit.server.publicUsers}
|
||||
/>
|
||||
|
||||
<div class="ms-4">
|
||||
<SettingButtonsRow bind:configToEdit keys={['server']} {disabled} />
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,106 +0,0 @@
|
||||
<script lang="ts">
|
||||
import SettingAccordion from '$lib/components/shared-components/settings/setting-accordion.svelte';
|
||||
import SettingTextarea from '$lib/components/shared-components/settings/setting-textarea.svelte';
|
||||
import FormatMessage from '$lib/elements/FormatMessage.svelte';
|
||||
import { systemConfigManager } from '$lib/managers/system-config-manager.svelte';
|
||||
import EmailTemplatePreviewModal from '$lib/modals/EmailTemplatePreviewModal.svelte';
|
||||
import { handleError } from '$lib/utils/handle-error';
|
||||
import { type SystemConfigDto, type SystemConfigTemplateEmailsDto, getNotificationTemplateAdmin } from '@immich/sdk';
|
||||
import { Button, Icon, LoadingSpinner, modalManager } from '@immich/ui';
|
||||
import { mdiEyeOutline } from '@mdi/js';
|
||||
import { t } from 'svelte-i18n';
|
||||
import { fade } from 'svelte/transition';
|
||||
|
||||
interface Props {
|
||||
config: SystemConfigDto;
|
||||
}
|
||||
|
||||
let { config = $bindable() }: Props = $props();
|
||||
|
||||
let loadingPreview = $state(false);
|
||||
|
||||
const getTemplate = async (name: string, template: string) => {
|
||||
try {
|
||||
loadingPreview = true;
|
||||
const { html } = await getNotificationTemplateAdmin({ name, templateDto: { template } });
|
||||
await modalManager.show(EmailTemplatePreviewModal, { html });
|
||||
} catch (error) {
|
||||
handleError(error, 'Could not load template.');
|
||||
} finally {
|
||||
loadingPreview = false;
|
||||
}
|
||||
};
|
||||
|
||||
const templateConfigs = [
|
||||
{
|
||||
label: $t('admin.template_email_welcome'),
|
||||
templateKey: 'welcomeTemplate' as const,
|
||||
descriptionTags: '{username}, {password}, {displayName}, {baseUrl}',
|
||||
templateName: 'welcome',
|
||||
},
|
||||
{
|
||||
label: $t('admin.template_email_invite_album'),
|
||||
templateKey: 'albumInviteTemplate' as const,
|
||||
descriptionTags: '{senderName}, {recipientName}, {albumId}, {albumName}, {baseUrl}',
|
||||
templateName: 'album-invite',
|
||||
},
|
||||
{
|
||||
label: $t('admin.template_email_update_album'),
|
||||
templateKey: 'albumUpdateTemplate' as const,
|
||||
descriptionTags: '{recipientName}, {albumId}, {albumName}, {baseUrl}',
|
||||
templateName: 'album-update',
|
||||
},
|
||||
];
|
||||
|
||||
const isEdited = (templateKey: keyof SystemConfigTemplateEmailsDto) =>
|
||||
config.templates.email[templateKey] !== systemConfigManager.value.templates.email[templateKey];
|
||||
|
||||
const onsubmit = (event: Event) => {
|
||||
event.preventDefault();
|
||||
};
|
||||
</script>
|
||||
|
||||
<div in:fade={{ duration: 500 }}>
|
||||
<form autocomplete="off" {onsubmit} class="mt-4">
|
||||
<div class="flex flex-col gap-4">
|
||||
<SettingAccordion
|
||||
key="templates"
|
||||
title={$t('admin.template_email_settings')}
|
||||
subtitle={$t('admin.template_settings_description')}
|
||||
>
|
||||
<div class="ms-4 mt-4 flex flex-col gap-4">
|
||||
<p class="text-sm dark:text-immich-dark-fg">
|
||||
<FormatMessage key="admin.template_email_if_empty">
|
||||
{$t('admin.template_email_if_empty')}
|
||||
</FormatMessage>
|
||||
</p>
|
||||
<hr />
|
||||
{#if loadingPreview}
|
||||
<LoadingSpinner />
|
||||
{/if}
|
||||
|
||||
{#each templateConfigs as { label, templateKey, descriptionTags, templateName } (templateKey)}
|
||||
<SettingTextarea
|
||||
{label}
|
||||
description={$t('admin.template_email_available_tags', { values: { tags: descriptionTags } })}
|
||||
bind:value={config.templates.email[templateKey]}
|
||||
isEdited={isEdited(templateKey)}
|
||||
disabled={!config.notifications.smtp.enabled}
|
||||
/>
|
||||
<div class="flex justify-between">
|
||||
<Button
|
||||
size="small"
|
||||
shape="round"
|
||||
onclick={() => getTemplate(templateName, config.templates.email[templateKey])}
|
||||
title={$t('admin.template_email_preview')}
|
||||
>
|
||||
<Icon icon={mdiEyeOutline} class="me-1" />
|
||||
{$t('admin.template_email_preview')}
|
||||
</Button>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</SettingAccordion>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
@@ -1,30 +0,0 @@
|
||||
<script lang="ts">
|
||||
import SettingButtonsRow from '$lib/components/shared-components/settings/SystemConfigButtonRow.svelte';
|
||||
import SettingTextarea from '$lib/components/shared-components/settings/setting-textarea.svelte';
|
||||
import { featureFlagsManager } from '$lib/managers/feature-flags-manager.svelte';
|
||||
import { systemConfigManager } from '$lib/managers/system-config-manager.svelte';
|
||||
import { t } from 'svelte-i18n';
|
||||
import { fade } from 'svelte/transition';
|
||||
|
||||
const disabled = $derived(featureFlagsManager.value.configFile);
|
||||
const config = $derived(systemConfigManager.value);
|
||||
let configToEdit = $state(systemConfigManager.cloneValue());
|
||||
</script>
|
||||
|
||||
<div>
|
||||
<div in:fade={{ duration: 500 }}>
|
||||
<form autocomplete="off" onsubmit={(event) => event.preventDefault()}>
|
||||
<div class="ms-4 mt-4 flex flex-col gap-4">
|
||||
<SettingTextarea
|
||||
{disabled}
|
||||
label={$t('admin.theme_custom_css_settings')}
|
||||
description={$t('admin.theme_custom_css_settings_description')}
|
||||
bind:value={configToEdit.theme.customCss}
|
||||
isEdited={configToEdit.theme.customCss !== config.theme.customCss}
|
||||
/>
|
||||
|
||||
<SettingButtonsRow bind:configToEdit keys={['theme']} {disabled} />
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,42 +0,0 @@
|
||||
<script lang="ts">
|
||||
import SettingButtonsRow from '$lib/components/shared-components/settings/SystemConfigButtonRow.svelte';
|
||||
import SettingInputField from '$lib/components/shared-components/settings/setting-input-field.svelte';
|
||||
import SettingSwitch from '$lib/components/shared-components/settings/setting-switch.svelte';
|
||||
import { SettingInputFieldType } from '$lib/constants';
|
||||
import { featureFlagsManager } from '$lib/managers/feature-flags-manager.svelte';
|
||||
import { systemConfigManager } from '$lib/managers/system-config-manager.svelte';
|
||||
import { t } from 'svelte-i18n';
|
||||
import { fade } from 'svelte/transition';
|
||||
|
||||
const disabled = $derived(featureFlagsManager.value.configFile);
|
||||
const config = $derived(systemConfigManager.value);
|
||||
let configToEdit = $state(systemConfigManager.cloneValue());
|
||||
</script>
|
||||
|
||||
<div>
|
||||
<div in:fade={{ duration: 500 }}>
|
||||
<form autocomplete="off" onsubmit={(event) => event.preventDefault()}>
|
||||
<div class="ms-4 mt-4 flex flex-col gap-4">
|
||||
<SettingSwitch
|
||||
title={$t('admin.trash_enabled_description')}
|
||||
{disabled}
|
||||
bind:checked={configToEdit.trash.enabled}
|
||||
/>
|
||||
|
||||
<hr />
|
||||
|
||||
<SettingInputField
|
||||
inputType={SettingInputFieldType.NUMBER}
|
||||
label={$t('admin.trash_number_of_days')}
|
||||
description={$t('admin.trash_number_of_days_description')}
|
||||
bind:value={configToEdit.trash.days}
|
||||
required={true}
|
||||
disabled={disabled || !configToEdit.trash.enabled}
|
||||
isEdited={configToEdit.trash.days !== config.trash.days}
|
||||
/>
|
||||
|
||||
<SettingButtonsRow bind:configToEdit keys={['trash']} {disabled} />
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,35 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { fade } from 'svelte/transition';
|
||||
|
||||
import SettingButtonsRow from '$lib/components/shared-components/settings/SystemConfigButtonRow.svelte';
|
||||
import SettingInputField from '$lib/components/shared-components/settings/setting-input-field.svelte';
|
||||
import { SettingInputFieldType } from '$lib/constants';
|
||||
import { featureFlagsManager } from '$lib/managers/feature-flags-manager.svelte';
|
||||
import { systemConfigManager } from '$lib/managers/system-config-manager.svelte';
|
||||
import { t } from 'svelte-i18n';
|
||||
|
||||
const disabled = $derived(featureFlagsManager.value.configFile);
|
||||
const config = $derived(systemConfigManager.value);
|
||||
let configToEdit = $state(systemConfigManager.cloneValue());
|
||||
</script>
|
||||
|
||||
<div>
|
||||
<div in:fade={{ duration: 500 }}>
|
||||
<form autocomplete="off" onsubmit={(e) => e.preventDefault()}>
|
||||
<div class="ms-4 mt-4 flex flex-col gap-4">
|
||||
<SettingInputField
|
||||
inputType={SettingInputFieldType.NUMBER}
|
||||
min={1}
|
||||
label={$t('admin.user_delete_delay_settings')}
|
||||
description={$t('admin.user_delete_delay_settings_description')}
|
||||
bind:value={configToEdit.user.deleteDelay}
|
||||
isEdited={configToEdit.user.deleteDelay !== config.user.deleteDelay}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="ms-4">
|
||||
<SettingButtonsRow bind:configToEdit keys={['user']} {disabled} />
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,18 +0,0 @@
|
||||
import AlbumDescription from '$lib/components/album-page/album-description.svelte';
|
||||
import '@testing-library/jest-dom';
|
||||
import { render, screen } from '@testing-library/svelte';
|
||||
import { describe } from 'vitest';
|
||||
|
||||
describe('AlbumDescription component', () => {
|
||||
it('shows an AutogrowTextarea component when isOwned is true', () => {
|
||||
render(AlbumDescription, { isOwned: true, id: '', description: '' });
|
||||
const autogrowTextarea = screen.getByTestId('autogrow-textarea');
|
||||
expect(autogrowTextarea).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not show an AutogrowTextarea component when isOwned is false', () => {
|
||||
render(AlbumDescription, { isOwned: false, id: '', description: '' });
|
||||
const autogrowTextarea = screen.queryByTestId('autogrow-textarea');
|
||||
expect(autogrowTextarea).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -1,50 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { shortcut } from '$lib/actions/shortcut';
|
||||
import { eventManager } from '$lib/managers/event-manager.svelte';
|
||||
import { handleError } from '$lib/utils/handle-error';
|
||||
import { updateAlbumInfo } from '@immich/sdk';
|
||||
import { Textarea } from '@immich/ui';
|
||||
import { t } from 'svelte-i18n';
|
||||
import { fromAction } from 'svelte/attachments';
|
||||
|
||||
interface Props {
|
||||
id: string;
|
||||
description: string;
|
||||
isOwned: boolean;
|
||||
}
|
||||
|
||||
let { id, description = $bindable(), isOwned }: Props = $props();
|
||||
|
||||
const handleFocusOut = async () => {
|
||||
try {
|
||||
const response = await updateAlbumInfo({
|
||||
id,
|
||||
updateAlbumDto: {
|
||||
description,
|
||||
},
|
||||
});
|
||||
eventManager.emit('AlbumUpdate', response);
|
||||
} catch (error) {
|
||||
handleError(error, $t('errors.unable_to_save_album'));
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
{#if isOwned}
|
||||
<Textarea
|
||||
bind:value={description}
|
||||
variant="ghost"
|
||||
onfocusout={handleFocusOut}
|
||||
placeholder={$t('add_a_description')}
|
||||
data-testid="autogrow-textarea"
|
||||
class="max-h-32"
|
||||
{@attach fromAction(shortcut, () => ({
|
||||
shortcut: { key: 'Enter', ctrl: true },
|
||||
onShortcut: (e) => e.currentTarget.blur(),
|
||||
}))}
|
||||
/>
|
||||
{:else if description}
|
||||
<p class="wrap-break-words whitespace-pre-line w-full text-black dark:text-white text-base">
|
||||
{description}
|
||||
</p>
|
||||
{/if}
|
||||
@@ -1,58 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { shortcut } from '$lib/actions/shortcut';
|
||||
import { eventManager } from '$lib/managers/event-manager.svelte';
|
||||
import { handleError } from '$lib/utils/handle-error';
|
||||
import { updateAlbumInfo } from '@immich/sdk';
|
||||
import { Textarea } from '@immich/ui';
|
||||
import { t } from 'svelte-i18n';
|
||||
import { fromAction } from 'svelte/attachments';
|
||||
|
||||
type Props = {
|
||||
id: string;
|
||||
albumName: string;
|
||||
isOwned: boolean;
|
||||
onUpdate: (albumName: string) => void;
|
||||
};
|
||||
|
||||
let { id, albumName = $bindable(), isOwned, onUpdate }: Props = $props();
|
||||
|
||||
let newAlbumName = $derived(albumName);
|
||||
|
||||
const handleUpdate = async () => {
|
||||
newAlbumName = newAlbumName.replaceAll('\n', ' ').trim();
|
||||
|
||||
if (newAlbumName === albumName) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await updateAlbumInfo({ id, updateAlbumDto: { albumName: newAlbumName } });
|
||||
({ albumName } = response);
|
||||
eventManager.emit('AlbumUpdate', response);
|
||||
onUpdate(albumName);
|
||||
} catch (error) {
|
||||
handleError(error, $t('errors.unable_to_save_album'));
|
||||
}
|
||||
};
|
||||
|
||||
const textClasses = 'text-2xl lg:text-6xl text-primary';
|
||||
</script>
|
||||
|
||||
<div class="mb-2">
|
||||
{#if isOwned}
|
||||
<Textarea
|
||||
bind:value={newAlbumName}
|
||||
variant="ghost"
|
||||
title={$t('edit_title')}
|
||||
onblur={handleUpdate}
|
||||
placeholder={$t('add_a_title')}
|
||||
class={textClasses}
|
||||
{@attach fromAction(shortcut, () => ({
|
||||
shortcut: { key: 'Enter' },
|
||||
onShortcut: (event) => event.currentTarget.blur(),
|
||||
}))}
|
||||
/>
|
||||
{:else}
|
||||
<div class={textClasses}>{newAlbumName}</div>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -1,219 +0,0 @@
|
||||
<script lang="ts">
|
||||
import Dropdown from '$lib/elements/Dropdown.svelte';
|
||||
import GroupTab from '$lib/elements/GroupTab.svelte';
|
||||
import SearchBar from '$lib/elements/SearchBar.svelte';
|
||||
import {
|
||||
AlbumFilter,
|
||||
AlbumGroupBy,
|
||||
AlbumSortBy,
|
||||
AlbumViewMode,
|
||||
albumViewSettings,
|
||||
SortOrder,
|
||||
} from '$lib/stores/preferences.store';
|
||||
import {
|
||||
type AlbumGroupOptionMetadata,
|
||||
type AlbumSortOptionMetadata,
|
||||
collapseAllAlbumGroups,
|
||||
createAlbumAndRedirect,
|
||||
expandAllAlbumGroups,
|
||||
findFilterOption,
|
||||
findGroupOptionMetadata,
|
||||
findSortOptionMetadata,
|
||||
getSelectedAlbumGroupOption,
|
||||
groupOptionsMetadata,
|
||||
sortOptionsMetadata,
|
||||
} from '$lib/utils/album-utils';
|
||||
import { Button, IconButton, Text } from '@immich/ui';
|
||||
import {
|
||||
mdiArrowDownThin,
|
||||
mdiArrowUpThin,
|
||||
mdiFolderArrowDownOutline,
|
||||
mdiFolderArrowUpOutline,
|
||||
mdiFolderRemoveOutline,
|
||||
mdiFormatListBulletedSquare,
|
||||
mdiPlusBoxOutline,
|
||||
mdiUnfoldLessHorizontal,
|
||||
mdiUnfoldMoreHorizontal,
|
||||
mdiViewGridOutline,
|
||||
} from '@mdi/js';
|
||||
import { t } from 'svelte-i18n';
|
||||
import { fly } from 'svelte/transition';
|
||||
|
||||
interface Props {
|
||||
albumGroups: string[];
|
||||
searchQuery: string;
|
||||
}
|
||||
|
||||
let { albumGroups, searchQuery = $bindable() }: Props = $props();
|
||||
|
||||
const flipOrdering = (ordering: string) => {
|
||||
return ordering === SortOrder.Asc ? SortOrder.Desc : SortOrder.Asc;
|
||||
};
|
||||
|
||||
const handleChangeAlbumFilter = (filter: string, defaultFilter: AlbumFilter) => {
|
||||
$albumViewSettings.filter =
|
||||
Object.keys(albumFilterNames).find((key) => albumFilterNames[key as AlbumFilter] === filter) ?? defaultFilter;
|
||||
};
|
||||
|
||||
const handleChangeGroupBy = ({ id, defaultOrder }: AlbumGroupOptionMetadata) => {
|
||||
if ($albumViewSettings.groupBy === id) {
|
||||
$albumViewSettings.groupOrder = flipOrdering($albumViewSettings.groupOrder);
|
||||
} else {
|
||||
$albumViewSettings.groupBy = id;
|
||||
$albumViewSettings.groupOrder = defaultOrder;
|
||||
}
|
||||
};
|
||||
|
||||
const handleChangeSortBy = ({ id, defaultOrder }: AlbumSortOptionMetadata) => {
|
||||
if ($albumViewSettings.sortBy === id) {
|
||||
$albumViewSettings.sortOrder = flipOrdering($albumViewSettings.sortOrder);
|
||||
} else {
|
||||
$albumViewSettings.sortBy = id;
|
||||
$albumViewSettings.sortOrder = defaultOrder;
|
||||
}
|
||||
};
|
||||
|
||||
const handleChangeListMode = () => {
|
||||
$albumViewSettings.view =
|
||||
$albumViewSettings.view === AlbumViewMode.Cover ? AlbumViewMode.List : AlbumViewMode.Cover;
|
||||
};
|
||||
|
||||
let groupIcon = $derived.by(() => {
|
||||
if (selectedGroupOption?.id === AlbumGroupBy.None) {
|
||||
return mdiFolderRemoveOutline;
|
||||
}
|
||||
return $albumViewSettings.groupOrder === SortOrder.Desc ? mdiFolderArrowDownOutline : mdiFolderArrowUpOutline;
|
||||
});
|
||||
|
||||
let albumFilterNames: Record<AlbumFilter, string> = $derived({
|
||||
[AlbumFilter.All]: $t('all'),
|
||||
[AlbumFilter.Owned]: $t('owned'),
|
||||
[AlbumFilter.Shared]: $t('shared'),
|
||||
});
|
||||
|
||||
let selectedFilterOption = $derived(albumFilterNames[findFilterOption($albumViewSettings.filter)]);
|
||||
let selectedSortOption = $derived(findSortOptionMetadata($albumViewSettings.sortBy));
|
||||
let selectedGroupOption = $derived(findGroupOptionMetadata($albumViewSettings.groupBy));
|
||||
let sortIcon = $derived($albumViewSettings.sortOrder === SortOrder.Desc ? mdiArrowDownThin : mdiArrowUpThin);
|
||||
|
||||
let albumSortByNames: Record<AlbumSortBy, string> = $derived({
|
||||
[AlbumSortBy.Title]: $t('sort_title'),
|
||||
[AlbumSortBy.ItemCount]: $t('sort_items'),
|
||||
[AlbumSortBy.DateModified]: $t('sort_modified'),
|
||||
[AlbumSortBy.DateCreated]: $t('sort_created'),
|
||||
[AlbumSortBy.MostRecentPhoto]: $t('sort_recent'),
|
||||
[AlbumSortBy.OldestPhoto]: $t('sort_oldest'),
|
||||
});
|
||||
|
||||
let albumGroupByNames: Record<AlbumGroupBy, string> = $derived({
|
||||
[AlbumGroupBy.None]: $t('group_no'),
|
||||
[AlbumGroupBy.Owner]: $t('group_owner'),
|
||||
[AlbumGroupBy.Year]: $t('group_year'),
|
||||
});
|
||||
</script>
|
||||
|
||||
<!-- Filter Albums by Sharing Status (All, Owned, Shared) -->
|
||||
<div class="hidden xl:block h-10">
|
||||
<GroupTab
|
||||
label={$t('show_albums')}
|
||||
filters={Object.values(albumFilterNames)}
|
||||
selected={selectedFilterOption}
|
||||
onSelect={(selected) => handleChangeAlbumFilter(selected, AlbumFilter.All)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Search Albums -->
|
||||
<div class="hidden xl:block h-10 xl:w-60 2xl:w-80">
|
||||
<SearchBar placeholder={$t('search_albums')} bind:name={searchQuery} showLoadingSpinner={false} />
|
||||
</div>
|
||||
|
||||
<!-- Create Album -->
|
||||
<Button
|
||||
leadingIcon={mdiPlusBoxOutline}
|
||||
onclick={() => createAlbumAndRedirect()}
|
||||
size="small"
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
>
|
||||
<p class="hidden md:block">{$t('create_album')}</p>
|
||||
</Button>
|
||||
|
||||
<!-- Sort Albums -->
|
||||
<Dropdown
|
||||
title={$t('sort_albums_by')}
|
||||
options={Object.values(sortOptionsMetadata)}
|
||||
selectedOption={selectedSortOption}
|
||||
onSelect={handleChangeSortBy}
|
||||
render={({ id }) => ({
|
||||
title: albumSortByNames[id],
|
||||
icon: sortIcon,
|
||||
})}
|
||||
/>
|
||||
|
||||
<!-- Group Albums -->
|
||||
<Dropdown
|
||||
title={$t('group_albums_by')}
|
||||
options={Object.values(groupOptionsMetadata)}
|
||||
selectedOption={selectedGroupOption}
|
||||
onSelect={handleChangeGroupBy}
|
||||
render={({ id, isDisabled }) => ({
|
||||
title: albumGroupByNames[id],
|
||||
icon: groupIcon,
|
||||
disabled: isDisabled(),
|
||||
})}
|
||||
/>
|
||||
|
||||
{#if getSelectedAlbumGroupOption($albumViewSettings) !== AlbumGroupBy.None}
|
||||
<span in:fly={{ x: -50, duration: 250 }}>
|
||||
<!-- Expand Album Groups -->
|
||||
<div class="hidden xl:flex gap-0">
|
||||
<div class="block">
|
||||
<IconButton
|
||||
title={$t('expand_all')}
|
||||
onclick={() => expandAllAlbumGroups()}
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
shape="round"
|
||||
icon={mdiUnfoldMoreHorizontal}
|
||||
aria-label={$t('expand_all')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Collapse Album Groups -->
|
||||
<div class="block">
|
||||
<IconButton
|
||||
title={$t('collapse_all')}
|
||||
onclick={() => collapseAllAlbumGroups(albumGroups)}
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
shape="round"
|
||||
icon={mdiUnfoldLessHorizontal}
|
||||
aria-label={$t('collapse_all')}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</span>
|
||||
{/if}
|
||||
|
||||
<!-- Cover/List Display Toggle -->
|
||||
{#if $albumViewSettings.view === AlbumViewMode.List}
|
||||
<Button
|
||||
leadingIcon={mdiViewGridOutline}
|
||||
onclick={() => handleChangeListMode()}
|
||||
size="small"
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
>
|
||||
<Text class="hidden md:block">{$t('covers')}</Text>
|
||||
</Button>
|
||||
{:else}
|
||||
<Button
|
||||
leadingIcon={mdiFormatListBulletedSquare}
|
||||
onclick={() => handleChangeListMode()}
|
||||
size="small"
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
>
|
||||
<Text class="hidden md:block">{$t('list')}</Text>
|
||||
</Button>
|
||||
{/if}
|
||||
@@ -1,59 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { type DownloadProgress, downloadManager } from '$lib/managers/download-manager.svelte';
|
||||
import { locale } from '$lib/stores/preferences.store';
|
||||
import { Heading, IconButton } from '@immich/ui';
|
||||
import { mdiClose } from '@mdi/js';
|
||||
import { t } from 'svelte-i18n';
|
||||
import { fly, slide } from 'svelte/transition';
|
||||
import { getByteUnitString } from '../../utils/byte-units';
|
||||
|
||||
const abort = (downloadKey: string, download: DownloadProgress) => {
|
||||
download.abort?.abort();
|
||||
downloadManager.clear(downloadKey);
|
||||
};
|
||||
</script>
|
||||
|
||||
{#if downloadManager.isDownloading}
|
||||
<div
|
||||
transition:fly={{ x: -100, duration: 350 }}
|
||||
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">
|
||||
{#each Object.keys(downloadManager.assets) as downloadKey (downloadKey)}
|
||||
{@const download = downloadManager.assets[downloadKey]}
|
||||
<div class="mb-2 flex place-items-center" transition:slide>
|
||||
<div class="w-full pe-10">
|
||||
<div class="flex place-items-center justify-between gap-2 text-xs font-medium">
|
||||
<p class="truncate">{downloadKey}</p>
|
||||
{#if download.total}
|
||||
<p class="whitespace-nowrap">{getByteUnitString(download.total, $locale)}</p>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="flex place-items-center gap-2">
|
||||
<div class="h-2.5 w-full rounded-full bg-neutral-200 dark:bg-neutral-600">
|
||||
<div class="h-2.5 rounded-full bg-primary" style={`width: ${download.percentage}%`}></div>
|
||||
</div>
|
||||
<p class="min-w-16 whitespace-nowrap text-right">
|
||||
<span class="text-primary">
|
||||
{(download.percentage / 100).toLocaleString($locale, { style: 'percent' })}
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="absolute end-4">
|
||||
<IconButton
|
||||
color="secondary"
|
||||
variant="outline"
|
||||
shape="round"
|
||||
aria-label={$t('close')}
|
||||
onclick={() => abort(downloadKey, download)}
|
||||
size="tiny"
|
||||
icon={mdiClose}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -1,49 +0,0 @@
|
||||
<script lang="ts">
|
||||
import SearchPeople from '$lib/components/faces-page/people-search.svelte';
|
||||
import { type PersonResponseDto } from '@immich/sdk';
|
||||
import { Button } from '@immich/ui';
|
||||
import { t } from 'svelte-i18n';
|
||||
import ImageThumbnail from '../assets/thumbnail/image-thumbnail.svelte';
|
||||
|
||||
interface Props {
|
||||
person: PersonResponseDto;
|
||||
name: string;
|
||||
suggestedPeople: PersonResponseDto[];
|
||||
thumbnailData: string;
|
||||
isSearchingPeople: boolean;
|
||||
onChange: (name: string) => void;
|
||||
}
|
||||
|
||||
let {
|
||||
person,
|
||||
name = $bindable(),
|
||||
suggestedPeople = $bindable(),
|
||||
thumbnailData,
|
||||
isSearchingPeople = $bindable(),
|
||||
onChange,
|
||||
}: Props = $props();
|
||||
|
||||
const onsubmit = (event: Event) => {
|
||||
event.preventDefault();
|
||||
onChange(name);
|
||||
};
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="flex w-full h-14 place-items-center {suggestedPeople.length > 0
|
||||
? 'rounded-t-lg dark:border-immich-dark-gray'
|
||||
: 'rounded-lg'} bg-gray-100 p-2 dark:bg-gray-700 border border-gray-200 dark:border-immich-dark-gray"
|
||||
>
|
||||
<ImageThumbnail circle shadow url={thumbnailData} altText={person.name} widthStyle="2rem" heightStyle="2rem" />
|
||||
<form class="ms-4 flex w-full justify-between gap-16" autocomplete="off" {onsubmit}>
|
||||
<SearchPeople
|
||||
bind:searchName={name}
|
||||
bind:searchedPeopleLocal={suggestedPeople}
|
||||
type="input"
|
||||
numberPeopleToSearch={5}
|
||||
inputClass="w-full gap-2 bg-gray-100 dark:bg-gray-700 dark:text-white"
|
||||
bind:showLoadingSpinner={isSearchingPeople}
|
||||
/>
|
||||
<Button size="small" shape="round" type="submit">{$t('done')}</Button>
|
||||
</form>
|
||||
</div>
|
||||
@@ -1,68 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { getPeopleThumbnailUrl } from '$lib/utils';
|
||||
import { type PersonResponseDto } from '@immich/sdk';
|
||||
import ImageThumbnail from '../assets/thumbnail/image-thumbnail.svelte';
|
||||
|
||||
interface Props {
|
||||
person: PersonResponseDto;
|
||||
selectable?: boolean;
|
||||
selected?: boolean;
|
||||
thumbnailSize?: number | null;
|
||||
circle?: boolean;
|
||||
border?: boolean;
|
||||
onClick?: (person: PersonResponseDto) => void;
|
||||
}
|
||||
|
||||
let {
|
||||
person,
|
||||
selectable = false,
|
||||
selected = false,
|
||||
thumbnailSize = null,
|
||||
circle = false,
|
||||
border = false,
|
||||
onClick = () => {},
|
||||
}: Props = $props();
|
||||
</script>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="relative rounded-lg transition-all"
|
||||
onclick={() => onClick(person)}
|
||||
disabled={!selectable}
|
||||
style:width={thumbnailSize ? thumbnailSize + 'px' : '100%'}
|
||||
style:height={thumbnailSize ? thumbnailSize + 'px' : '100%'}
|
||||
>
|
||||
<div
|
||||
class="h-full w-full border-2 brightness-90 filter"
|
||||
class:rounded-full={circle}
|
||||
class:rounded-lg={!circle}
|
||||
class:border-transparent={!border}
|
||||
class:dark:border-immich-dark-primary={border}
|
||||
class:border-immich-primary={border}
|
||||
>
|
||||
<ImageThumbnail {circle} url={getPeopleThumbnailUrl(person)} altText={person.name} widthStyle="100%" shadow />
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="absolute start-0 top-0 h-full w-full bg-immich-primary/30 opacity-0"
|
||||
class:hover:opacity-100={selectable}
|
||||
class:rounded-full={circle}
|
||||
class:rounded-lg={!circle}
|
||||
></div>
|
||||
|
||||
{#if selected}
|
||||
<div
|
||||
class="absolute start-0 top-0 h-full w-full bg-blue-500/80"
|
||||
class:rounded-full={circle}
|
||||
class:rounded-lg={!circle}
|
||||
></div>
|
||||
{/if}
|
||||
|
||||
{#if person.name}
|
||||
<span
|
||||
class="w-100 text-white-shadow absolute bottom-2 start-0 w-full text-ellipsis px-1 text-center font-medium text-white hover:cursor-pointer"
|
||||
>
|
||||
{person.name}
|
||||
</span>
|
||||
{/if}
|
||||
</button>
|
||||
@@ -1,88 +0,0 @@
|
||||
import { getIntersectionObserverMock } from '$lib/__mocks__/intersection-observer.mock';
|
||||
import { personFactory } from '@test-data/factories/person-factory';
|
||||
import { render } from '@testing-library/svelte';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { vi } from 'vitest';
|
||||
import ManagePeopleVisibilityWrapper from './manage-people-visibility.test-wrapper.svelte';
|
||||
|
||||
describe('ManagePeopleVisibility component', () => {
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal('IntersectionObserver', getIntersectionObserverMock());
|
||||
});
|
||||
|
||||
it('keeps toggled hidden state when loading more people', async () => {
|
||||
const onClose = vi.fn();
|
||||
const onUpdate = vi.fn();
|
||||
const loadNextPage = vi.fn();
|
||||
|
||||
const [personA, personB, personC] = [
|
||||
personFactory.build({ id: 'a', isHidden: false }),
|
||||
personFactory.build({ id: 'b', isHidden: false }),
|
||||
personFactory.build({ id: 'c', isHidden: true }),
|
||||
];
|
||||
|
||||
const { container, rerender } = render(ManagePeopleVisibilityWrapper, {
|
||||
props: {
|
||||
people: [personA, personB],
|
||||
totalPeopleCount: 3,
|
||||
onClose,
|
||||
onUpdate,
|
||||
loadNextPage,
|
||||
},
|
||||
});
|
||||
const user = userEvent.setup();
|
||||
|
||||
let personButtons = container.querySelectorAll('button[aria-pressed]');
|
||||
expect(personButtons).toHaveLength(2);
|
||||
|
||||
await user.click(personButtons[0]);
|
||||
expect(personButtons[0].getAttribute('aria-pressed')).toBe('true');
|
||||
|
||||
await rerender({
|
||||
people: [personA, personB, personC],
|
||||
totalPeopleCount: 3,
|
||||
onClose,
|
||||
onUpdate,
|
||||
loadNextPage,
|
||||
});
|
||||
|
||||
personButtons = container.querySelectorAll('button[aria-pressed]');
|
||||
expect(personButtons).toHaveLength(3);
|
||||
expect(personButtons[0].getAttribute('aria-pressed')).toBe('true');
|
||||
expect(personButtons[2].getAttribute('aria-pressed')).toBe('true');
|
||||
});
|
||||
|
||||
it('shows newly loaded hidden people as hidden', async () => {
|
||||
const onClose = vi.fn();
|
||||
const onUpdate = vi.fn();
|
||||
const loadNextPage = vi.fn();
|
||||
|
||||
const [personA, personB, personC] = [
|
||||
personFactory.build({ id: 'a', isHidden: false }),
|
||||
personFactory.build({ id: 'b', isHidden: false }),
|
||||
personFactory.build({ id: 'c', isHidden: true }),
|
||||
];
|
||||
|
||||
const { container, rerender } = render(ManagePeopleVisibilityWrapper, {
|
||||
props: {
|
||||
people: [personA, personB],
|
||||
totalPeopleCount: 3,
|
||||
onClose,
|
||||
onUpdate,
|
||||
loadNextPage,
|
||||
},
|
||||
});
|
||||
|
||||
await rerender({
|
||||
people: [personA, personB, personC],
|
||||
totalPeopleCount: 3,
|
||||
onClose,
|
||||
onUpdate,
|
||||
loadNextPage,
|
||||
});
|
||||
|
||||
const personButtons = container.querySelectorAll('button[aria-pressed]');
|
||||
expect(personButtons).toHaveLength(3);
|
||||
expect(personButtons[2].getAttribute('aria-pressed')).toBe('true');
|
||||
});
|
||||
});
|
||||
@@ -1,178 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { shortcut } from '$lib/actions/shortcut';
|
||||
import ImageThumbnail from '$lib/components/assets/thumbnail/image-thumbnail.svelte';
|
||||
import PeopleInfiniteScroll from '$lib/components/faces-page/people-infinite-scroll.svelte';
|
||||
import { ToggleVisibility } from '$lib/constants';
|
||||
import { locale } from '$lib/stores/preferences.store';
|
||||
import { getPeopleThumbnailUrl } from '$lib/utils';
|
||||
import { handleError } from '$lib/utils/handle-error';
|
||||
import { updatePeople, type PersonResponseDto } from '@immich/sdk';
|
||||
import { Button, IconButton, toastManager } from '@immich/ui';
|
||||
import { mdiClose, mdiEye, mdiEyeOff, mdiEyeSettings, mdiRestart } from '@mdi/js';
|
||||
import { t } from 'svelte-i18n';
|
||||
import { SvelteMap } from 'svelte/reactivity';
|
||||
|
||||
interface Props {
|
||||
people: PersonResponseDto[];
|
||||
totalPeopleCount: number;
|
||||
titleId?: string | undefined;
|
||||
onClose: () => void;
|
||||
onUpdate: (people: PersonResponseDto[]) => void;
|
||||
loadNextPage: () => void;
|
||||
}
|
||||
|
||||
let { people, totalPeopleCount, titleId = undefined, onClose, onUpdate, loadNextPage }: Props = $props();
|
||||
|
||||
let toggleVisibility = $state(ToggleVisibility.SHOW_ALL);
|
||||
let showLoadingSpinner = $state(false);
|
||||
const overrides = new SvelteMap<string, boolean>();
|
||||
|
||||
const getNextVisibility = (toggleVisibility: ToggleVisibility) => {
|
||||
if (toggleVisibility === ToggleVisibility.SHOW_ALL) {
|
||||
return ToggleVisibility.HIDE_UNNANEMD;
|
||||
} else if (toggleVisibility === ToggleVisibility.HIDE_UNNANEMD) {
|
||||
return ToggleVisibility.HIDE_ALL;
|
||||
} else {
|
||||
return ToggleVisibility.SHOW_ALL;
|
||||
}
|
||||
};
|
||||
|
||||
const handleToggleVisibility = () => {
|
||||
toggleVisibility = getNextVisibility(toggleVisibility);
|
||||
|
||||
for (const person of people) {
|
||||
let isHidden = overrides.get(person.id) ?? person.isHidden;
|
||||
|
||||
if (toggleVisibility === ToggleVisibility.HIDE_ALL) {
|
||||
isHidden = true;
|
||||
} else if (toggleVisibility === ToggleVisibility.SHOW_ALL) {
|
||||
isHidden = false;
|
||||
} else if (toggleVisibility === ToggleVisibility.HIDE_UNNANEMD && !person.name) {
|
||||
isHidden = true;
|
||||
}
|
||||
|
||||
setHiddenOverride(person, isHidden);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSaveVisibility = async () => {
|
||||
showLoadingSpinner = true;
|
||||
const changed = Array.from(overrides, ([id, isHidden]) => ({ id, isHidden }));
|
||||
|
||||
try {
|
||||
if (changed.length > 0) {
|
||||
const results = await updatePeople({ peopleUpdateDto: { people: changed } });
|
||||
const successCount = results.filter(({ success }) => success).length;
|
||||
const failCount = results.length - successCount;
|
||||
if (failCount > 0) {
|
||||
toastManager.warning($t('errors.unable_to_change_visibility', { values: { count: failCount } }));
|
||||
}
|
||||
toastManager.primary($t('visibility_changed', { values: { count: successCount } }));
|
||||
}
|
||||
|
||||
for (const person of people) {
|
||||
const isHidden = overrides.get(person.id);
|
||||
if (isHidden !== undefined) {
|
||||
person.isHidden = isHidden;
|
||||
}
|
||||
}
|
||||
overrides.clear();
|
||||
|
||||
onUpdate(people);
|
||||
onClose();
|
||||
} catch (error) {
|
||||
handleError(error, $t('errors.unable_to_change_visibility', { values: { count: changed.length } }));
|
||||
} finally {
|
||||
showLoadingSpinner = false;
|
||||
}
|
||||
};
|
||||
|
||||
const setHiddenOverride = (person: PersonResponseDto, isHidden: boolean) => {
|
||||
if (isHidden === person.isHidden) {
|
||||
overrides.delete(person.id);
|
||||
return;
|
||||
}
|
||||
overrides.set(person.id, isHidden);
|
||||
};
|
||||
|
||||
let toggleButtonOptions: Record<ToggleVisibility, { icon: string; label: string }> = $derived({
|
||||
[ToggleVisibility.HIDE_ALL]: { icon: mdiEyeOff, label: $t('hide_all_people') },
|
||||
[ToggleVisibility.HIDE_UNNANEMD]: { icon: mdiEyeSettings, label: $t('hide_unnamed_people') },
|
||||
[ToggleVisibility.SHOW_ALL]: { icon: mdiEye, label: $t('show_all_people') },
|
||||
});
|
||||
let toggleButton = $derived(toggleButtonOptions[getNextVisibility(toggleVisibility)]);
|
||||
</script>
|
||||
|
||||
<svelte:document use:shortcut={{ shortcut: { key: 'Escape' }, onShortcut: onClose }} />
|
||||
|
||||
<div class="h-full overflow-y-auto">
|
||||
<div
|
||||
class="sticky top-0 z-1 flex h-16 w-full items-center justify-between border-b bg-white p-1 dark:border-immich-dark-gray dark:bg-black dark:text-immich-dark-fg md:p-8"
|
||||
>
|
||||
<div class="flex items-center">
|
||||
<IconButton
|
||||
shape="round"
|
||||
color="secondary"
|
||||
variant="ghost"
|
||||
aria-label={$t('close')}
|
||||
icon={mdiClose}
|
||||
onclick={onClose}
|
||||
/>
|
||||
<div class="flex gap-2 items-center">
|
||||
<p id={titleId} class="ms-2">{$t('show_and_hide_people')}</p>
|
||||
<p class="text-sm text-gray-400 dark:text-gray-600">({totalPeopleCount.toLocaleString($locale)})</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center justify-end">
|
||||
<div class="flex items-center md:me-4">
|
||||
<IconButton
|
||||
shape="round"
|
||||
color="secondary"
|
||||
variant="ghost"
|
||||
aria-label={$t('reset_people_visibility')}
|
||||
icon={mdiRestart}
|
||||
onclick={() => overrides.clear()}
|
||||
/>
|
||||
<IconButton
|
||||
shape="round"
|
||||
color="secondary"
|
||||
variant="ghost"
|
||||
aria-label={toggleButton.label}
|
||||
icon={toggleButton.icon}
|
||||
onclick={handleToggleVisibility}
|
||||
/>
|
||||
</div>
|
||||
<Button loading={showLoadingSpinner} onclick={handleSaveVisibility} size="small">{$t('done')}</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap gap-1 p-2 pb-8 md:px-8">
|
||||
<PeopleInfiniteScroll {people} hasNextPage={true} {loadNextPage}>
|
||||
{#snippet children({ person })}
|
||||
{@const hidden = overrides.get(person.id) ?? person.isHidden}
|
||||
<button
|
||||
type="button"
|
||||
class="group relative w-full h-full"
|
||||
onclick={() => setHiddenOverride(person, !hidden)}
|
||||
aria-pressed={hidden}
|
||||
aria-label={person.name ? $t('hide_named_person', { values: { name: person.name } }) : $t('hide_person')}
|
||||
>
|
||||
<ImageThumbnail
|
||||
{hidden}
|
||||
shadow
|
||||
url={getPeopleThumbnailUrl(person)}
|
||||
altText={person.name}
|
||||
widthStyle="100%"
|
||||
hiddenIconClass="text-white group-hover:text-black transition-colors"
|
||||
preload={false}
|
||||
/>
|
||||
{#if person.name}
|
||||
<span class="absolute bottom-2 start-0 w-full select-text px-1 text-center font-medium text-white">
|
||||
{person.name}
|
||||
</span>
|
||||
{/if}
|
||||
</button>
|
||||
{/snippet}
|
||||
</PeopleInfiniteScroll>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,20 +0,0 @@
|
||||
<script lang="ts">
|
||||
import type { PersonResponseDto } from '@immich/sdk';
|
||||
import { TooltipProvider } from '@immich/ui';
|
||||
import ManagePeopleVisibility from './manage-people-visibility.svelte';
|
||||
|
||||
interface Props {
|
||||
people: PersonResponseDto[];
|
||||
totalPeopleCount: number;
|
||||
titleId?: string | undefined;
|
||||
onClose: () => void;
|
||||
onUpdate: (people: PersonResponseDto[]) => void;
|
||||
loadNextPage: () => void;
|
||||
}
|
||||
|
||||
let props: Props = $props();
|
||||
</script>
|
||||
|
||||
<TooltipProvider>
|
||||
<ManagePeopleVisibility {...props} />
|
||||
</TooltipProvider>
|
||||
@@ -1,144 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { Route } from '$lib/route';
|
||||
import { handleError } from '$lib/utils/handle-error';
|
||||
import { getAllPeople, getPerson, mergePerson, type PersonResponseDto } from '@immich/sdk';
|
||||
import { Button, Icon, IconButton, modalManager, toastManager } from '@immich/ui';
|
||||
import { mdiCallMerge, mdiMerge, mdiSwapHorizontal } from '@mdi/js';
|
||||
import { onMount } from 'svelte';
|
||||
import { t } from 'svelte-i18n';
|
||||
import { flip } from 'svelte/animate';
|
||||
import { quintOut } from 'svelte/easing';
|
||||
import { fly } from 'svelte/transition';
|
||||
import ControlAppBar from '../shared-components/control-app-bar.svelte';
|
||||
import FaceThumbnail from './face-thumbnail.svelte';
|
||||
import PeopleList from './people-list.svelte';
|
||||
|
||||
interface Props {
|
||||
person: PersonResponseDto;
|
||||
onBack: () => void;
|
||||
onMerge: (mergedPerson: PersonResponseDto) => void;
|
||||
}
|
||||
|
||||
let { person = $bindable(), onBack, onMerge }: Props = $props();
|
||||
|
||||
let people: PersonResponseDto[] = $state([]);
|
||||
let selectedPeople: PersonResponseDto[] = $state([]);
|
||||
let screenHeight: number = $state(0);
|
||||
|
||||
let hasSelection = $derived(selectedPeople.length > 0);
|
||||
let peopleToNotShow = $derived([...selectedPeople, person]);
|
||||
|
||||
const handleSearch = async (sortFaces: boolean = false) => {
|
||||
const data = await getAllPeople({ withHidden: false, closestPersonId: sortFaces ? person.id : undefined });
|
||||
people = data.people;
|
||||
};
|
||||
|
||||
onMount(handleSearch);
|
||||
|
||||
const handleSwapPeople = async () => {
|
||||
[person, selectedPeople[0]] = [selectedPeople[0], person];
|
||||
await goto(Route.viewPerson(person, { previousRoute: Route.people(), action: 'merge' }));
|
||||
};
|
||||
|
||||
const onSelect = async (selected: PersonResponseDto) => {
|
||||
if (selectedPeople.includes(selected)) {
|
||||
selectedPeople = selectedPeople.filter((person) => person.id !== selected.id);
|
||||
return;
|
||||
}
|
||||
|
||||
if (selectedPeople.length >= 5) {
|
||||
toastManager.warning($t('merge_people_limit'));
|
||||
return;
|
||||
}
|
||||
|
||||
selectedPeople = [selected, ...selectedPeople];
|
||||
|
||||
if (selectedPeople.length === 1 && !person.name && selected.name) {
|
||||
await handleSwapPeople();
|
||||
}
|
||||
};
|
||||
|
||||
const handleMerge = async () => {
|
||||
const isConfirm = await modalManager.showDialog({ prompt: $t('merge_people_prompt') });
|
||||
if (!isConfirm) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
let results = await mergePerson({
|
||||
id: person.id,
|
||||
mergePersonDto: { ids: selectedPeople.map(({ id }) => id) },
|
||||
});
|
||||
const mergedPerson = await getPerson({ id: person.id });
|
||||
const count = results.filter(({ success }) => success).length;
|
||||
toastManager.primary($t('merged_people_count', { values: { count } }));
|
||||
onMerge(mergedPerson);
|
||||
} catch (error) {
|
||||
handleError(error, $t('cannot_merge_people'));
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<svelte:window bind:innerHeight={screenHeight} />
|
||||
|
||||
<section
|
||||
transition:fly={{ y: 500, duration: 100, easing: quintOut }}
|
||||
class="absolute start-0 top-0 h-full w-full bg-light"
|
||||
>
|
||||
<ControlAppBar onClose={onBack}>
|
||||
{#snippet leading()}
|
||||
{#if hasSelection}
|
||||
{$t('selected_count', { values: { count: selectedPeople.length } })}
|
||||
{:else}
|
||||
{$t('merge_people')}
|
||||
{/if}
|
||||
<div></div>
|
||||
{/snippet}
|
||||
{#snippet trailing()}
|
||||
<Button leadingIcon={mdiMerge} size="small" shape="round" disabled={!hasSelection} onclick={handleMerge}>
|
||||
{$t('merge')}
|
||||
</Button>
|
||||
{/snippet}
|
||||
</ControlAppBar>
|
||||
<section class="px-17.5 pt-25">
|
||||
<section id="merge-face-selector">
|
||||
<div class="mb-10 h-50 place-content-center place-items-center">
|
||||
<p class="mb-4 text-center uppercase dark:text-white">{$t('choose_matching_people_to_merge')}</p>
|
||||
|
||||
<div class="grid grid-flow-col-dense place-content-center place-items-center gap-4">
|
||||
{#each selectedPeople as person (person.id)}
|
||||
<div animate:flip={{ duration: 250, easing: quintOut }}>
|
||||
<FaceThumbnail border circle {person} selectable thumbnailSize={120} onClick={() => onSelect(person)} />
|
||||
</div>
|
||||
{/each}
|
||||
|
||||
{#if hasSelection}
|
||||
<div class="relative h-full">
|
||||
<div class="flex flex-col h-full justify-between">
|
||||
<div class="flex h-full items-center justify-center">
|
||||
<Icon icon={mdiCallMerge} size="48" class="rotate-90 dark:text-white" />
|
||||
</div>
|
||||
{#if selectedPeople.length === 1}
|
||||
<div class="absolute bottom-2">
|
||||
<IconButton
|
||||
shape="round"
|
||||
color="secondary"
|
||||
variant="ghost"
|
||||
aria-label={$t('swap_merge_direction')}
|
||||
icon={mdiSwapHorizontal}
|
||||
size="large"
|
||||
onclick={handleSwapPeople}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
<FaceThumbnail {person} border circle selectable={false} thumbnailSize={180} />
|
||||
</div>
|
||||
</div>
|
||||
<PeopleList {people} {peopleToNotShow} {screenHeight} {onSelect} {handleSearch} />
|
||||
</section>
|
||||
</section>
|
||||
</section>
|
||||
@@ -1,88 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { focusOutside } from '$lib/actions/focus-outside';
|
||||
import ActionMenuItem from '$lib/components/ActionMenuItem.svelte';
|
||||
import ButtonContextMenu from '$lib/components/shared-components/context-menu/button-context-menu.svelte';
|
||||
import { Route } from '$lib/route';
|
||||
import { getPersonActions } from '$lib/services/person.service';
|
||||
import { getPeopleThumbnailUrl } from '$lib/utils';
|
||||
import { type PersonResponseDto } from '@immich/sdk';
|
||||
import { Icon } from '@immich/ui';
|
||||
import {
|
||||
mdiAccountMultipleCheckOutline,
|
||||
mdiDotsVertical,
|
||||
mdiEyeOffOutline,
|
||||
mdiHeart,
|
||||
mdiHeartMinusOutline,
|
||||
mdiHeartOutline,
|
||||
} from '@mdi/js';
|
||||
import { t } from 'svelte-i18n';
|
||||
import ImageThumbnail from '../assets/thumbnail/image-thumbnail.svelte';
|
||||
import MenuOption from '../shared-components/context-menu/menu-option.svelte';
|
||||
|
||||
type Props = {
|
||||
person: PersonResponseDto;
|
||||
onMergePeople: () => void;
|
||||
onHidePerson: () => void;
|
||||
onToggleFavorite: () => void;
|
||||
};
|
||||
|
||||
let { person, onMergePeople, onHidePerson, onToggleFavorite }: Props = $props();
|
||||
|
||||
let showVerticalDots = $state(false);
|
||||
|
||||
const { SetDateOfBirth } = $derived(getPersonActions($t, person));
|
||||
</script>
|
||||
|
||||
<div
|
||||
id="people-card"
|
||||
class="relative"
|
||||
onmouseenter={() => (showVerticalDots = true)}
|
||||
onmouseleave={() => (showVerticalDots = false)}
|
||||
role="group"
|
||||
use:focusOutside={{ onFocusOut: () => (showVerticalDots = false) }}
|
||||
>
|
||||
<a
|
||||
href={Route.viewPerson(person, { previousRoute: Route.people() })}
|
||||
draggable="false"
|
||||
onfocus={() => (showVerticalDots = true)}
|
||||
>
|
||||
<div class="w-full h-full rounded-xl brightness-95 filter">
|
||||
<ImageThumbnail
|
||||
shadow
|
||||
url={getPeopleThumbnailUrl(person)}
|
||||
altText={person.name}
|
||||
title={person.name}
|
||||
widthStyle="100%"
|
||||
circle
|
||||
preload={false}
|
||||
/>
|
||||
{#if person.isFavorite}
|
||||
<div class="absolute top-4 start-4">
|
||||
<Icon icon={mdiHeart} size="24" class="text-white" />
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</a>
|
||||
|
||||
{#if showVerticalDots}
|
||||
<div class="absolute top-2 end-2 z-1">
|
||||
<ButtonContextMenu
|
||||
buttonClass="icon-white-drop-shadow"
|
||||
color="secondary"
|
||||
size="medium"
|
||||
variant="filled"
|
||||
icon={mdiDotsVertical}
|
||||
title={$t('show_person_options')}
|
||||
>
|
||||
<MenuOption onClick={onHidePerson} icon={mdiEyeOffOutline} text={$t('hide_person')} />
|
||||
<ActionMenuItem action={SetDateOfBirth} />
|
||||
<MenuOption onClick={onMergePeople} icon={mdiAccountMultipleCheckOutline} text={$t('merge_people')} />
|
||||
<MenuOption
|
||||
onClick={onToggleFavorite}
|
||||
icon={person.isFavorite ? mdiHeartMinusOutline : mdiHeartOutline}
|
||||
text={person.isFavorite ? $t('unfavorite') : $t('to_favorite')}
|
||||
/>
|
||||
</ButtonContextMenu>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -1,40 +0,0 @@
|
||||
<script lang="ts">
|
||||
import type { PersonResponseDto } from '@immich/sdk';
|
||||
|
||||
interface Props {
|
||||
people: PersonResponseDto[];
|
||||
hasNextPage?: boolean | undefined;
|
||||
loadNextPage: () => void;
|
||||
children?: import('svelte').Snippet<[{ person: PersonResponseDto; index: number }]>;
|
||||
}
|
||||
|
||||
let { people, hasNextPage = undefined, loadNextPage, children }: Props = $props();
|
||||
|
||||
let lastPersonContainer: HTMLElement | undefined = $state();
|
||||
|
||||
const intersectionObserver = new IntersectionObserver((entries) => {
|
||||
const entry = entries.find((entry) => entry.target === lastPersonContainer);
|
||||
if (entry?.isIntersecting) {
|
||||
loadNextPage();
|
||||
}
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (lastPersonContainer) {
|
||||
intersectionObserver.disconnect();
|
||||
intersectionObserver.observe(lastPersonContainer);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="w-full grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-5 xl:grid-cols-7 2xl:grid-cols-10 gap-1">
|
||||
{#each people as person, index (person.id)}
|
||||
{#if hasNextPage && index === people.length - 1}
|
||||
<div bind:this={lastPersonContainer}>
|
||||
{@render children?.({ person, index })}
|
||||
</div>
|
||||
{:else}
|
||||
{@render children?.({ person, index })}
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
@@ -1,58 +0,0 @@
|
||||
<script lang="ts">
|
||||
import SearchPeople from '$lib/components/faces-page/people-search.svelte';
|
||||
import { type PersonResponseDto } from '@immich/sdk';
|
||||
import { t } from 'svelte-i18n';
|
||||
import FaceThumbnail from './face-thumbnail.svelte';
|
||||
import { mdiSwapVertical } from '@mdi/js';
|
||||
import { IconButton } from '@immich/ui';
|
||||
|
||||
interface Props {
|
||||
screenHeight: number;
|
||||
people: PersonResponseDto[];
|
||||
peopleToNotShow: PersonResponseDto[];
|
||||
onSelect: (person: PersonResponseDto) => void;
|
||||
handleSearch?: (sortFaces: boolean) => void;
|
||||
}
|
||||
|
||||
let { screenHeight, people, peopleToNotShow, onSelect, handleSearch }: Props = $props();
|
||||
let searchedPeopleLocal: PersonResponseDto[] = $state([]);
|
||||
let sortBySimilarirty = $state(false);
|
||||
let name = $state('');
|
||||
|
||||
const showPeople = $derived(
|
||||
(name ? searchedPeopleLocal : people).filter(
|
||||
(person) => !peopleToNotShow.some((unselectedPerson) => unselectedPerson.id === person.id),
|
||||
),
|
||||
);
|
||||
</script>
|
||||
|
||||
<div class="w-40 sm:w-48 md:w-full h-14 flex gap-4 place-items-center">
|
||||
<div class="md:w-96">
|
||||
<SearchPeople type="searchBar" placeholder={$t('search_people')} bind:searchName={name} bind:searchedPeopleLocal />
|
||||
</div>
|
||||
|
||||
{#if handleSearch}
|
||||
<IconButton
|
||||
shape="round"
|
||||
color="secondary"
|
||||
variant="ghost"
|
||||
icon={mdiSwapVertical}
|
||||
onclick={() => {
|
||||
sortBySimilarirty = !sortBySimilarirty;
|
||||
handleSearch(sortBySimilarirty);
|
||||
}}
|
||||
aria-label={$t('sort_people_by_similarity')}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="immich-scrollbar overflow-y-auto rounded-3xl bg-gray-200 p-10 dark:bg-immich-dark-gray mt-6"
|
||||
style:max-height={screenHeight - 400 + 'px'}
|
||||
>
|
||||
<div class="grid-col-2 grid gap-8 md:grid-cols-3 lg:grid-cols-6 xl:grid-cols-8 2xl:grid-cols-10">
|
||||
{#each showPeople as person (person.id)}
|
||||
<FaceThumbnail {person} onClick={() => onSelect(person)} circle border selectable />
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,171 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { timeBeforeShowLoadingSpinner } from '$lib/constants';
|
||||
import { handleError } from '$lib/utils/handle-error';
|
||||
import {
|
||||
createPerson,
|
||||
getAllPeople,
|
||||
reassignFaces,
|
||||
type AssetFaceUpdateItem,
|
||||
type PersonResponseDto,
|
||||
} from '@immich/sdk';
|
||||
import { Button, toastManager } from '@immich/ui';
|
||||
import { mdiMerge, mdiPlus } from '@mdi/js';
|
||||
import { onMount, type Snippet } from 'svelte';
|
||||
import { t } from 'svelte-i18n';
|
||||
import { quintOut } from 'svelte/easing';
|
||||
import { fly } from 'svelte/transition';
|
||||
import ControlAppBar from '../shared-components/control-app-bar.svelte';
|
||||
import FaceThumbnail from './face-thumbnail.svelte';
|
||||
import PeopleList from './people-list.svelte';
|
||||
|
||||
interface Props {
|
||||
assetIds: string[];
|
||||
personAssets: PersonResponseDto;
|
||||
onConfirm: () => void;
|
||||
onClose: () => void;
|
||||
header?: Snippet;
|
||||
merge?: Snippet;
|
||||
}
|
||||
|
||||
let { assetIds, personAssets, onConfirm, onClose, header, merge }: Props = $props();
|
||||
|
||||
let people: PersonResponseDto[] = $state([]);
|
||||
let selectedPerson: PersonResponseDto | null = $state(null);
|
||||
let disableButtons = $state(false);
|
||||
let showLoadingSpinnerCreate = $state(false);
|
||||
let showLoadingSpinnerReassign = $state(false);
|
||||
let hasSelection = $state(false);
|
||||
let screenHeight: number = $state(0);
|
||||
|
||||
let peopleToNotShow = $derived(selectedPerson ? [personAssets, selectedPerson] : [personAssets]);
|
||||
|
||||
const selectedPeople: AssetFaceUpdateItem[] = [];
|
||||
|
||||
for (const assetId of assetIds) {
|
||||
selectedPeople.push({ assetId, personId: personAssets.id });
|
||||
}
|
||||
|
||||
onMount(async () => {
|
||||
const data = await getAllPeople({ withHidden: false });
|
||||
people = data.people;
|
||||
});
|
||||
|
||||
const handleSelectedPerson = (person: PersonResponseDto) => {
|
||||
if (selectedPerson && selectedPerson.id === person.id) {
|
||||
handleRemoveSelectedPerson();
|
||||
return;
|
||||
}
|
||||
selectedPerson = person;
|
||||
hasSelection = true;
|
||||
};
|
||||
|
||||
const handleRemoveSelectedPerson = () => {
|
||||
selectedPerson = null;
|
||||
hasSelection = false;
|
||||
};
|
||||
|
||||
const handleCreate = async () => {
|
||||
const timeout = setTimeout(() => (showLoadingSpinnerCreate = true), timeBeforeShowLoadingSpinner);
|
||||
|
||||
try {
|
||||
disableButtons = true;
|
||||
const data = await createPerson({ personCreateDto: {} });
|
||||
await reassignFaces({ id: data.id, assetFaceUpdateDto: { data: selectedPeople } });
|
||||
toastManager.primary($t('reassigned_assets_to_new_person', { values: { count: assetIds.length } }));
|
||||
} catch (error) {
|
||||
handleError(error, $t('errors.unable_to_reassign_assets_new_person'));
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
|
||||
showLoadingSpinnerCreate = false;
|
||||
onConfirm();
|
||||
};
|
||||
|
||||
const handleReassign = async () => {
|
||||
const timeout = setTimeout(() => (showLoadingSpinnerReassign = true), timeBeforeShowLoadingSpinner);
|
||||
try {
|
||||
disableButtons = true;
|
||||
if (selectedPerson) {
|
||||
await reassignFaces({ id: selectedPerson.id, assetFaceUpdateDto: { data: selectedPeople } });
|
||||
toastManager.primary(
|
||||
$t('reassigned_assets_to_existing_person', {
|
||||
values: { count: assetIds.length, name: selectedPerson.name || null },
|
||||
}),
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
handleError(
|
||||
error,
|
||||
$t('errors.unable_to_reassign_assets_existing_person', { values: { name: selectedPerson?.name || null } }),
|
||||
);
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
|
||||
showLoadingSpinnerReassign = false;
|
||||
onConfirm();
|
||||
};
|
||||
</script>
|
||||
|
||||
<svelte:window bind:innerHeight={screenHeight} />
|
||||
|
||||
<section
|
||||
transition:fly={{ y: 500, duration: 100, easing: quintOut }}
|
||||
class="absolute start-0 top-0 h-full w-full bg-light"
|
||||
>
|
||||
<ControlAppBar {onClose}>
|
||||
{#snippet leading()}
|
||||
{@render header?.()}
|
||||
<div></div>
|
||||
{/snippet}
|
||||
{#snippet trailing()}
|
||||
<div class="flex gap-4">
|
||||
<Button
|
||||
shape="round"
|
||||
title={$t('create_new_person_hint')}
|
||||
leadingIcon={mdiPlus}
|
||||
loading={showLoadingSpinnerCreate}
|
||||
size="small"
|
||||
disabled={disableButtons || hasSelection}
|
||||
onclick={handleCreate}
|
||||
>
|
||||
{$t('create_new_person')}</Button
|
||||
>
|
||||
<Button
|
||||
size="small"
|
||||
shape="round"
|
||||
title={$t('reassing_hint')}
|
||||
leadingIcon={mdiMerge}
|
||||
loading={showLoadingSpinnerReassign}
|
||||
disabled={disableButtons || !hasSelection}
|
||||
onclick={handleReassign}
|
||||
>
|
||||
{$t('reassign')}
|
||||
</Button>
|
||||
</div>
|
||||
{/snippet}
|
||||
</ControlAppBar>
|
||||
{@render merge?.()}
|
||||
<section class="px-17.5 pt-25">
|
||||
<section id="merge-face-selector relative">
|
||||
{#if selectedPerson !== null}
|
||||
<div class="mb-10 h-50 place-content-center place-items-center">
|
||||
<p class="mb-4 text-center uppercase dark:text-white">Choose matching faces to re assign</p>
|
||||
|
||||
<div class="grid grid-flow-col-dense place-content-center place-items-center gap-4">
|
||||
<FaceThumbnail
|
||||
person={selectedPerson}
|
||||
border
|
||||
circle
|
||||
selectable
|
||||
thumbnailSize={180}
|
||||
onClick={handleRemoveSelectedPerson}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
<PeopleList {people} {peopleToNotShow} {screenHeight} onSelect={handleSelectedPerson} />
|
||||
</section>
|
||||
</section>
|
||||
</section>
|
||||
@@ -1,91 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { copyToClipboard } from '$lib/utils';
|
||||
import {
|
||||
Card,
|
||||
CardBody,
|
||||
CardFooter,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
Icon,
|
||||
IconButton,
|
||||
Link,
|
||||
Logo,
|
||||
Text,
|
||||
VStack,
|
||||
} from '@immich/ui';
|
||||
import { mdiAlarmLight, mdiCodeTags, mdiContentCopy, mdiMessage, mdiPartyPopper } from '@mdi/js';
|
||||
import { t } from 'svelte-i18n';
|
||||
|
||||
interface Props {
|
||||
error?: { message: string; code?: string | number; stack?: string } | undefined | null;
|
||||
}
|
||||
|
||||
let { error = undefined }: Props = $props();
|
||||
|
||||
const handleCopy = async () => {
|
||||
if (!error) {
|
||||
return;
|
||||
}
|
||||
|
||||
await copyToClipboard(`${error.message} - ${error.code}\n${error.stack}`);
|
||||
};
|
||||
</script>
|
||||
|
||||
<div class="flex flex-col h-dvh w-dvw">
|
||||
<section>
|
||||
<div class="flex place-items-center border-b px-6 py-4 dark:border-b-immich-dark-gray">
|
||||
<Link href="/photos">
|
||||
<Logo variant="inline" />
|
||||
</Link>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="flex flex-1 w-full place-content-center place-items-center overflow-hidden bg-black/30">
|
||||
<div class="max-w-[95vw]">
|
||||
<Card color="secondary">
|
||||
<CardHeader class="flex-row justify-between gap-12">
|
||||
<CardTitle tag="h1" size="medium" class="text-primary flex place-items-center gap-4">
|
||||
<Icon icon={mdiAlarmLight} color="red" size="32" />
|
||||
{$t('error_title')}
|
||||
</CardTitle>
|
||||
<IconButton
|
||||
shape="round"
|
||||
color="primary"
|
||||
icon={mdiContentCopy}
|
||||
aria-label={$t('copy_error')}
|
||||
onclick={handleCopy}
|
||||
/>
|
||||
</CardHeader>
|
||||
|
||||
<CardBody class="flex flex-col gap-2">
|
||||
<Text color="danger">{error?.message} (HTTP {error?.code})</Text>
|
||||
{#if error?.stack}
|
||||
<label for="stacktrace">{$t('stacktrace')}</label>
|
||||
<pre id="stacktrace" class="text-xs">{error.stack}</pre>
|
||||
{/if}
|
||||
</CardBody>
|
||||
|
||||
<CardFooter class="items-start">
|
||||
<Link href="https://discord.immich.app" class="flex grow basis-0 justify-center">
|
||||
<VStack>
|
||||
<Icon icon={mdiMessage} size="24" />
|
||||
<Text size="small" class="text-center">{$t('get_help')}</Text>
|
||||
</VStack>
|
||||
</Link>
|
||||
<Link href="https://github.com/immich-app/immich/releases" class="flex grow basis-0 justify-center">
|
||||
<VStack>
|
||||
<Icon icon={mdiPartyPopper} size="24" />
|
||||
<Text size="small" class="text-center">{$t('read_changelog')}</Text>
|
||||
</VStack>
|
||||
</Link>
|
||||
<Link href="https://docs.immich.app/guides/docker-help" class="flex grow basis-0 justify-center">
|
||||
<VStack>
|
||||
<Icon icon={mdiCodeTags} size="24" />
|
||||
<Text size="small" class="text-center">{$t('check_logs')}</Text>
|
||||
</VStack>
|
||||
</Link>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,19 +0,0 @@
|
||||
<script lang="ts">
|
||||
import RestoreFlowDetectInstall from '$lib/components/maintenance/restore-flow/RestoreFlowDetectInstall.svelte';
|
||||
import RestoreFlowSelectBackup from '$lib/components/maintenance/restore-flow/RestoreFlowSelectBackup.svelte';
|
||||
|
||||
type Props = {
|
||||
end: () => void;
|
||||
expectedVersion: string;
|
||||
};
|
||||
|
||||
const { end, expectedVersion }: Props = $props();
|
||||
|
||||
let stage = $state(0);
|
||||
</script>
|
||||
|
||||
{#if stage === 0}
|
||||
<RestoreFlowDetectInstall next={() => stage++} {end} />
|
||||
{:else}
|
||||
<RestoreFlowSelectBackup previous={() => stage--} {end} {expectedVersion} />
|
||||
{/if}
|
||||
@@ -1,98 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { detectPriorInstall, type MaintenanceDetectInstallResponseDto } from '@immich/sdk';
|
||||
import { Button, Heading, HStack, Icon, Stack, Text } from '@immich/ui';
|
||||
import { mdiAlert, mdiArrowRight, mdiCheck, mdiClose, mdiRefresh } from '@mdi/js';
|
||||
import { onMount } from 'svelte';
|
||||
import { t } from 'svelte-i18n';
|
||||
|
||||
type Props = {
|
||||
next: () => void;
|
||||
end: () => void;
|
||||
};
|
||||
|
||||
const { next, end }: Props = $props();
|
||||
|
||||
let detectedInstall: MaintenanceDetectInstallResponseDto | undefined = $state();
|
||||
|
||||
const reload = async () => {
|
||||
detectedInstall = await detectPriorInstall();
|
||||
};
|
||||
|
||||
const getLibraryFolderCheckStatus = (writable: boolean, readable: boolean) => {
|
||||
if (writable) {
|
||||
return $t('maintenance_restore_library_folder_pass');
|
||||
} else if (readable) {
|
||||
return $t('maintenance_restore_library_folder_write_fail');
|
||||
} else {
|
||||
return $t('maintenance_restore_library_folder_read_fail');
|
||||
}
|
||||
};
|
||||
|
||||
onMount(() => reload());
|
||||
</script>
|
||||
|
||||
<Heading size="large" color="primary" tag="h1">{$t('maintenance_restore_library')}</Heading>
|
||||
<Text>{$t('maintenance_restore_library_description')}</Text>
|
||||
<div class="bg-white dark:bg-black w-full m-4 p-6 rounded-xl border border-light-300">
|
||||
<Stack>
|
||||
{#if detectedInstall}
|
||||
{#each detectedInstall.storage as { folder, readable, writable } (folder)}
|
||||
<HStack>
|
||||
<Icon icon={writable ? mdiCheck : mdiClose} class={writable ? 'text-success' : 'text-danger'} />
|
||||
<Text>{folder} ({getLibraryFolderCheckStatus(writable, readable)})</Text>
|
||||
</HStack>
|
||||
{/each}
|
||||
{#each detectedInstall.storage as { folder, files } (folder)}
|
||||
{#if folder !== 'backups'}
|
||||
<HStack class="items-start">
|
||||
<Icon
|
||||
class={`mt-1 ${files ? 'text-success' : folder === 'profile' || folder === 'upload' ? 'text-danger' : 'text-warning'}`}
|
||||
icon={files ? mdiCheck : folder === 'profile' || folder === 'upload' ? mdiClose : mdiAlert}
|
||||
/>
|
||||
<Stack gap={0} class="items-start">
|
||||
<Text>
|
||||
{#if files}
|
||||
{$t('maintenance_restore_library_folder_has_files', {
|
||||
values: {
|
||||
folder,
|
||||
count: files,
|
||||
},
|
||||
})}
|
||||
{:else}
|
||||
{$t('maintenance_restore_library_folder_no_files', {
|
||||
values: {
|
||||
folder,
|
||||
},
|
||||
})}
|
||||
{/if}
|
||||
</Text>
|
||||
{#if !files}
|
||||
{#if folder === 'profile' || folder === 'upload'}
|
||||
<Text variant="italic">{$t('maintenance_restore_library_hint_missing_files')}</Text>
|
||||
{/if}
|
||||
{#if folder === 'encoded-video' || folder === 'thumbs'}
|
||||
<Text variant="italic">{$t('maintenance_restore_library_hint_regenerate_later')}</Text>
|
||||
{/if}
|
||||
{#if folder === 'library'}
|
||||
<Text variant="italic">{$t('maintenance_restore_library_hint_storage_template_missing_files')}</Text>
|
||||
{/if}
|
||||
{/if}
|
||||
</Stack>
|
||||
</HStack>
|
||||
{/if}
|
||||
{/each}
|
||||
|
||||
<Button leadingIcon={mdiRefresh} variant="ghost" onclick={reload}>{$t('refresh')}</Button>
|
||||
{:else}
|
||||
<HStack>
|
||||
<Icon icon={mdiRefresh} color="rgb(var(--immich-ui-primary))" />
|
||||
<Text>{$t('maintenance_restore_library_loading')}</Text>
|
||||
</HStack>
|
||||
{/if}
|
||||
</Stack>
|
||||
</div>
|
||||
<Text>{$t('maintenance_restore_library_confirm')}</Text>
|
||||
<HStack>
|
||||
<Button onclick={end} variant="ghost">{$t('cancel')}</Button>
|
||||
<Button onclick={next} trailingIcon={mdiArrowRight}>{$t('next')}</Button>
|
||||
</HStack>
|
||||
@@ -1,23 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { Button, Heading, HStack, Scrollable } from '@immich/ui';
|
||||
import { mdiArrowLeft } from '@mdi/js';
|
||||
import { t } from 'svelte-i18n';
|
||||
import MaintenanceBackupsList from '../MaintenanceBackupsList.svelte';
|
||||
|
||||
type Props = {
|
||||
previous: () => void;
|
||||
end: () => void;
|
||||
expectedVersion: string;
|
||||
};
|
||||
|
||||
const { previous, end, expectedVersion }: Props = $props();
|
||||
</script>
|
||||
|
||||
<Heading size="large" color="primary" tag="h1">{$t('maintenance_restore_from_backup')}</Heading>
|
||||
<Scrollable class="max-h-120 bg-white dark:bg-black p-4 rounded-2xl border border-light-300 w-full">
|
||||
<MaintenanceBackupsList {expectedVersion} />
|
||||
</Scrollable>
|
||||
<HStack>
|
||||
<Button onclick={end} variant="ghost">{$t('cancel')}</Button>
|
||||
<Button onclick={previous} variant="ghost" leadingIcon={mdiArrowLeft}>{$t('back')}</Button>
|
||||
</HStack>
|
||||
@@ -1,57 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { assetViewerFadeDuration } from '$lib/constants';
|
||||
import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
|
||||
import { getAssetMediaUrl } from '$lib/utils';
|
||||
import { getAltText } from '$lib/utils/thumbnail-util';
|
||||
import { AssetMediaSize } from '@immich/sdk';
|
||||
import DelayedLoadingSpinner from '$lib/components/DelayedLoadingSpinner.svelte';
|
||||
import { onMount } from 'svelte';
|
||||
import { fade } from 'svelte/transition';
|
||||
|
||||
interface Props {
|
||||
asset: TimelineAsset;
|
||||
onImageLoad: () => void;
|
||||
}
|
||||
|
||||
const { asset, onImageLoad }: Props = $props();
|
||||
|
||||
let assetFileUrl: string = $state('');
|
||||
let imageLoaded: boolean = $state(false);
|
||||
let loader = $state<HTMLImageElement>();
|
||||
|
||||
const onLoadCallback = () => {
|
||||
imageLoaded = true;
|
||||
assetFileUrl = imageLoaderUrl;
|
||||
onImageLoad();
|
||||
};
|
||||
|
||||
onMount(() => {
|
||||
if (loader?.complete) {
|
||||
onLoadCallback();
|
||||
}
|
||||
loader?.addEventListener('load', onLoadCallback);
|
||||
return () => {
|
||||
loader?.removeEventListener('load', onLoadCallback);
|
||||
};
|
||||
});
|
||||
|
||||
const imageLoaderUrl = $derived(getAssetMediaUrl({ id: asset.id, size: AssetMediaSize.Preview }));
|
||||
</script>
|
||||
|
||||
{#if !imageLoaded}
|
||||
<!-- svelte-ignore a11y_missing_attribute -->
|
||||
<img bind:this={loader} style="display:none" src={imageLoaderUrl} aria-hidden="true" />
|
||||
{/if}
|
||||
|
||||
{#if !imageLoaded}
|
||||
<DelayedLoadingSpinner />
|
||||
{:else if imageLoaded}
|
||||
<div transition:fade={{ duration: assetViewerFadeDuration }} class="h-full w-full">
|
||||
<img
|
||||
class="h-full w-full rounded-2xl object-contain transition-all"
|
||||
src={assetFileUrl}
|
||||
alt={$getAltText(asset)}
|
||||
draggable="false"
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -1,41 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { assetViewerFadeDuration } from '$lib/constants';
|
||||
import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
|
||||
import { autoPlayVideo } from '$lib/stores/preferences.store';
|
||||
import { getAssetMediaUrl, getAssetPlaybackUrl } from '$lib/utils';
|
||||
import { AssetMediaSize } from '@immich/sdk';
|
||||
import { onMount } from 'svelte';
|
||||
import { fade } from 'svelte/transition';
|
||||
|
||||
interface Props {
|
||||
asset: TimelineAsset;
|
||||
videoPlayer: HTMLVideoElement | undefined;
|
||||
videoViewerMuted?: boolean;
|
||||
videoViewerVolume?: number;
|
||||
}
|
||||
|
||||
let { asset, videoPlayer = $bindable(), videoViewerVolume, videoViewerMuted }: Props = $props();
|
||||
|
||||
let showVideo: boolean = $state(false);
|
||||
|
||||
onMount(() => {
|
||||
// Show video after mount to ensure fading in.
|
||||
showVideo = true;
|
||||
});
|
||||
</script>
|
||||
|
||||
{#if showVideo}
|
||||
<div class="h-full w-full bg-pink-9000" transition:fade={{ duration: assetViewerFadeDuration }}>
|
||||
<video
|
||||
bind:this={videoPlayer}
|
||||
autoplay={$autoPlayVideo}
|
||||
playsinline
|
||||
class="h-full w-full rounded-2xl object-contain transition-all"
|
||||
src={getAssetPlaybackUrl({ id: asset.id })}
|
||||
poster={getAssetMediaUrl({ id: asset.id, size: AssetMediaSize.Preview })}
|
||||
draggable="false"
|
||||
muted={videoViewerMuted}
|
||||
volume={videoViewerVolume}
|
||||
></video>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -1,684 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { afterNavigate, goto } from '$app/navigation';
|
||||
import { page } from '$app/state';
|
||||
import { shortcuts } from '$lib/actions/shortcut';
|
||||
import MemoryPhotoViewer from '$lib/components/memory-page/memory-photo-viewer.svelte';
|
||||
import MemoryVideoViewer from '$lib/components/memory-page/memory-video-viewer.svelte';
|
||||
import ButtonContextMenu from '$lib/components/shared-components/context-menu/button-context-menu.svelte';
|
||||
import MenuOption from '$lib/components/shared-components/context-menu/menu-option.svelte';
|
||||
import ControlAppBar from '$lib/components/shared-components/control-app-bar.svelte';
|
||||
import GalleryViewer from '$lib/components/shared-components/gallery-viewer/gallery-viewer.svelte';
|
||||
import ArchiveAction from '$lib/components/timeline/actions/ArchiveAction.svelte';
|
||||
import ChangeDate from '$lib/components/timeline/actions/ChangeDateAction.svelte';
|
||||
import ChangeDescription from '$lib/components/timeline/actions/ChangeDescriptionAction.svelte';
|
||||
import ChangeLocation from '$lib/components/timeline/actions/ChangeLocationAction.svelte';
|
||||
import CreateSharedLink from '$lib/components/timeline/actions/CreateSharedLinkAction.svelte';
|
||||
import DeleteAssets from '$lib/components/timeline/actions/DeleteAssetsAction.svelte';
|
||||
import DownloadAction from '$lib/components/timeline/actions/DownloadAction.svelte';
|
||||
import FavoriteAction from '$lib/components/timeline/actions/FavoriteAction.svelte';
|
||||
import TagAction from '$lib/components/timeline/actions/TagAction.svelte';
|
||||
import AssetSelectControlBar from '$lib/components/timeline/AssetSelectControlBar.svelte';
|
||||
import { QueryParameter } from '$lib/constants';
|
||||
import { assetMultiSelectManager } from '$lib/managers/asset-multi-select-manager.svelte';
|
||||
import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte';
|
||||
import { authManager } from '$lib/managers/auth-manager.svelte';
|
||||
import { memoryManager, type MemoryAsset } from '$lib/managers/memory-manager.svelte';
|
||||
import type { TimelineAsset, Viewport } from '$lib/managers/timeline-manager/types';
|
||||
import { Route } from '$lib/route';
|
||||
import { getAssetBulkActions } from '$lib/services/asset.service';
|
||||
import { locale, videoViewerMuted, videoViewerVolume } from '$lib/stores/preferences.store';
|
||||
import { getAssetMediaUrl, handlePromiseError, memoryLaneTitle } from '$lib/utils';
|
||||
import { fromISODateTimeUTC, toTimelineAsset } from '$lib/utils/timeline-util';
|
||||
import { AssetMediaSize, AssetTypeEnum, getAssetInfo } from '@immich/sdk';
|
||||
import { ActionButton, IconButton, toastManager } from '@immich/ui';
|
||||
import {
|
||||
mdiCardsOutline,
|
||||
mdiChevronDown,
|
||||
mdiChevronLeft,
|
||||
mdiChevronRight,
|
||||
mdiChevronUp,
|
||||
mdiDotsVertical,
|
||||
mdiHeart,
|
||||
mdiHeartOutline,
|
||||
mdiImageMinusOutline,
|
||||
mdiImageSearch,
|
||||
mdiPause,
|
||||
mdiPlay,
|
||||
mdiSelectAll,
|
||||
mdiVolumeHigh,
|
||||
mdiVolumeOff,
|
||||
} from '@mdi/js';
|
||||
import type { NavigationTarget, Page } from '@sveltejs/kit';
|
||||
import { DateTime } from 'luxon';
|
||||
import { t } from 'svelte-i18n';
|
||||
import type { Attachment } from 'svelte/attachments';
|
||||
import { Tween } from 'svelte/motion';
|
||||
|
||||
let memoryGallery: HTMLElement | undefined = $state();
|
||||
let memoryWrapper: HTMLElement | undefined = $state();
|
||||
let galleryInView = $state(false);
|
||||
let galleryFirstLoad = $state(true);
|
||||
let playerInitialized = $state(false);
|
||||
let paused = $state(false);
|
||||
let current = $state<MemoryAsset | undefined>(undefined);
|
||||
const currentAssetId = $derived(current?.asset.id);
|
||||
const currentMemoryAssetFull = $derived.by(async () =>
|
||||
currentAssetId ? await getAssetInfo({ ...authManager.params, id: currentAssetId }) : undefined,
|
||||
);
|
||||
let currentTimelineAssets = $derived(current?.memory.assets ?? []);
|
||||
let viewerAssets = $derived([
|
||||
...(current?.previousMemory?.assets ?? []),
|
||||
...(current?.memory.assets ?? []),
|
||||
...(current?.nextMemory?.assets ?? []),
|
||||
]);
|
||||
|
||||
let isSaved = $derived(current?.memory.isSaved);
|
||||
let viewerHeight = $state(0);
|
||||
|
||||
const viewport: Viewport = $state({ width: 0, height: 0 });
|
||||
// need to include padding in the viewport for gallery
|
||||
const galleryViewport: Viewport = $derived({ height: viewport.height, width: viewport.width - 32 });
|
||||
let progressBarController: Tween<number> | undefined = $state(undefined);
|
||||
let videoPlayer: HTMLVideoElement | undefined = $state();
|
||||
const asHref = (asset: { id: string }) => `?${QueryParameter.ID}=${asset.id}`;
|
||||
|
||||
const handleNavigate = async (asset?: { id: string }) => {
|
||||
if (assetViewerManager.isViewing) {
|
||||
return asset;
|
||||
}
|
||||
|
||||
if (!asset) {
|
||||
return;
|
||||
}
|
||||
|
||||
await goto(asHref(asset));
|
||||
};
|
||||
|
||||
const setProgressDuration = (asset: TimelineAsset) => {
|
||||
if (asset.isVideo) {
|
||||
const timeParts = asset.duration!.split(':').map(Number);
|
||||
const durationInMilliseconds = (timeParts[0] * 3600 + timeParts[1] * 60 + timeParts[2]) * 1000;
|
||||
progressBarController = new Tween<number>(0, {
|
||||
duration: (from: number, to: number) => (to ? durationInMilliseconds * (to - from) : 0),
|
||||
});
|
||||
} else {
|
||||
progressBarController = new Tween<number>(0, {
|
||||
duration: (from: number, to: number) =>
|
||||
to ? authManager.preferences.memories.duration * 1000 * (to - from) : 0,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleNextAsset = () => handleNavigate(current?.next?.asset);
|
||||
const handlePreviousAsset = () => handleNavigate(current?.previous?.asset);
|
||||
const handleNextMemory = () => handleNavigate(current?.nextMemory?.assets[0]);
|
||||
const handlePreviousMemory = () => handleNavigate(current?.previousMemory?.assets[0]);
|
||||
const handleEscape = async () => goto(Route.photos());
|
||||
const handleSelectAll = () =>
|
||||
assetMultiSelectManager.selectAssets(current?.memory.assets.map((a) => toTimelineAsset(a)) || []);
|
||||
|
||||
const handleAction = async (callingContext: string, action: 'reset' | 'pause' | 'play') => {
|
||||
// leaving these log statements here as comments. Very useful to figure out what's going on during dev!
|
||||
// console.log(`handleAction[${callingContext}] called with: ${action}`);
|
||||
if (!progressBarController) {
|
||||
// console.log(`handleAction[${callingContext}] NOT READY!`);
|
||||
return;
|
||||
}
|
||||
|
||||
switch (action) {
|
||||
case 'play': {
|
||||
try {
|
||||
paused = false;
|
||||
await videoPlayer?.play();
|
||||
await progressBarController.set(1);
|
||||
} catch (error) {
|
||||
// this may happen if browser blocks auto-play of the video on first page load. This can either be a setting
|
||||
// or just default in certain browsers on page load without any DOM interaction by user.
|
||||
console.error(`handleAction[${callingContext}] videoPlayer play problem: ${error}`);
|
||||
paused = true;
|
||||
await progressBarController.set(0);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'pause': {
|
||||
paused = true;
|
||||
videoPlayer?.pause();
|
||||
await progressBarController.set(progressBarController.current);
|
||||
break;
|
||||
}
|
||||
|
||||
case 'reset': {
|
||||
paused = false;
|
||||
videoPlayer?.pause();
|
||||
await progressBarController.set(0);
|
||||
break;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleProgress = async (progress: number) => {
|
||||
if (!progressBarController) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (progress === 1 && !paused) {
|
||||
await (current?.next ? handleNextAsset() : handlePromiseError(handleAction('handleProgressLast', 'pause')));
|
||||
}
|
||||
};
|
||||
|
||||
const toProgressPercentage = (index: number) => {
|
||||
if (!progressBarController || current?.assetIndex === undefined) {
|
||||
return 0;
|
||||
}
|
||||
if (index < current?.assetIndex) {
|
||||
return 100;
|
||||
}
|
||||
if (index > current?.assetIndex) {
|
||||
return 0;
|
||||
}
|
||||
return progressBarController.current * 100;
|
||||
};
|
||||
|
||||
const handleDeleteOrArchiveAssets = (ids: string[]) => {
|
||||
if (!current) {
|
||||
return;
|
||||
}
|
||||
memoryManager.hideAssetsFromMemory(ids);
|
||||
init(page);
|
||||
};
|
||||
|
||||
const handleDeleteMemoryAsset = async () => {
|
||||
if (!current) {
|
||||
return;
|
||||
}
|
||||
|
||||
await memoryManager.deleteAssetFromMemory(current.asset.id);
|
||||
init(page);
|
||||
};
|
||||
|
||||
const handleDeleteMemory = async () => {
|
||||
if (!current) {
|
||||
return;
|
||||
}
|
||||
|
||||
await memoryManager.deleteMemory(current.memory.id);
|
||||
toastManager.primary($t('removed_memory'));
|
||||
init(page);
|
||||
};
|
||||
|
||||
const handleSaveMemory = async () => {
|
||||
if (!current) {
|
||||
return;
|
||||
}
|
||||
|
||||
const newSavedState = !current.memory.isSaved;
|
||||
await memoryManager.updateMemorySaved(current.memory.id, newSavedState);
|
||||
toastManager.primary(newSavedState ? $t('added_to_favorites') : $t('removed_from_favorites'));
|
||||
init(page);
|
||||
};
|
||||
|
||||
const handleGalleryScrollsIntoView = () => {
|
||||
galleryInView = true;
|
||||
handlePromiseError(handleAction('galleryInView', 'pause'));
|
||||
};
|
||||
|
||||
const handleGalleryScrollsOutOfView = () => {
|
||||
galleryInView = false;
|
||||
// only call play after the first page load. When page first loads the gallery will not be visible
|
||||
// and calling play here will result in duplicate invocation.
|
||||
if (!galleryFirstLoad) {
|
||||
handlePromiseError(handleAction('galleryOutOfView', 'play'));
|
||||
}
|
||||
galleryFirstLoad = false;
|
||||
};
|
||||
|
||||
const galleryObserver: Attachment<HTMLElement> = (element) => {
|
||||
const observer = new IntersectionObserver(
|
||||
([entry]) => {
|
||||
if (entry?.isIntersecting) {
|
||||
handleGalleryScrollsIntoView();
|
||||
} else {
|
||||
handleGalleryScrollsOutOfView();
|
||||
}
|
||||
},
|
||||
{ rootMargin: '0px 0px -200px 0px' },
|
||||
);
|
||||
|
||||
observer.observe(element);
|
||||
return () => observer.disconnect();
|
||||
};
|
||||
|
||||
const loadFromParams = (page: Page | NavigationTarget | null) => {
|
||||
const assetId = page?.params?.assetId ?? page?.url.searchParams.get(QueryParameter.ID) ?? undefined;
|
||||
return memoryManager.getMemoryAsset(assetId);
|
||||
};
|
||||
|
||||
const init = (target: Page | NavigationTarget | null) => {
|
||||
if (memoryManager.memories.length === 0) {
|
||||
return handlePromiseError(goto(Route.photos()));
|
||||
}
|
||||
|
||||
current = loadFromParams(target);
|
||||
// Adjust the progress bar duration to the video length
|
||||
if (current) {
|
||||
setProgressDuration(current.asset);
|
||||
}
|
||||
playerInitialized = false;
|
||||
};
|
||||
|
||||
const resetAndPlay = () => {
|
||||
handlePromiseError(handleAction('resetAndPlay', 'reset'));
|
||||
handlePromiseError(handleAction('resetAndPlay', 'play'));
|
||||
};
|
||||
|
||||
const initPlayer = () => {
|
||||
const isVideo = current && current.asset.isVideo;
|
||||
const isVideoAssetButPlayerHasNotLoadedYet = isVideo && !videoPlayer;
|
||||
if (playerInitialized || isVideoAssetButPlayerHasNotLoadedYet) {
|
||||
return;
|
||||
}
|
||||
if (assetViewerManager.isViewing) {
|
||||
handlePromiseError(handleAction('initPlayer[AssetViewOpen]', 'pause'));
|
||||
} else if (isVideo) {
|
||||
// Image assets will start playing when the image is loaded. Only autostart video assets.
|
||||
resetAndPlay();
|
||||
}
|
||||
playerInitialized = true;
|
||||
};
|
||||
|
||||
afterNavigate(({ from, to }) => {
|
||||
memoryManager.ready().then(
|
||||
() => {
|
||||
let target;
|
||||
if (to?.params?.assetId) {
|
||||
target = to;
|
||||
} else if (from?.params?.assetId) {
|
||||
target = from;
|
||||
} else {
|
||||
target = page;
|
||||
}
|
||||
|
||||
init(target);
|
||||
initPlayer();
|
||||
},
|
||||
(error) => {
|
||||
console.error(`Error loading memories: ${error}`);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (progressBarController) {
|
||||
handlePromiseError(handleProgress(progressBarController.current));
|
||||
}
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (videoPlayer) {
|
||||
videoPlayer.muted = $videoViewerMuted;
|
||||
initPlayer();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<svelte:document
|
||||
use:shortcuts={assetViewerManager.isViewing
|
||||
? []
|
||||
: [
|
||||
{ shortcut: { key: 'ArrowRight' }, onShortcut: () => handleNextAsset() },
|
||||
{ shortcut: { key: 'd' }, onShortcut: () => handleNextAsset() },
|
||||
{ shortcut: { key: 'ArrowLeft' }, onShortcut: () => handlePreviousAsset() },
|
||||
{ shortcut: { key: 'a' }, onShortcut: () => handlePreviousAsset() },
|
||||
{ shortcut: { key: 'Escape' }, onShortcut: () => handleEscape() },
|
||||
]}
|
||||
/>
|
||||
|
||||
{#if assetMultiSelectManager.selectionActive}
|
||||
<div class="sticky top-0 z-1 dark">
|
||||
<AssetSelectControlBar forceDark>
|
||||
{@const Actions = getAssetBulkActions($t)}
|
||||
<CreateSharedLink />
|
||||
<IconButton
|
||||
shape="round"
|
||||
color="secondary"
|
||||
variant="ghost"
|
||||
aria-label={$t('select_all')}
|
||||
icon={mdiSelectAll}
|
||||
onclick={handleSelectAll}
|
||||
/>
|
||||
|
||||
<ActionButton action={Actions.AddToAlbum} />
|
||||
|
||||
<FavoriteAction removeFavorite={assetMultiSelectManager.isAllFavorite} />
|
||||
|
||||
<ButtonContextMenu icon={mdiDotsVertical} title={$t('menu')}>
|
||||
<DownloadAction menuItem />
|
||||
<ChangeDate menuItem />
|
||||
<ChangeDescription menuItem />
|
||||
<ChangeLocation menuItem />
|
||||
<ArchiveAction
|
||||
menuItem
|
||||
unarchive={assetMultiSelectManager.isAllArchived}
|
||||
onArchive={handleDeleteOrArchiveAssets}
|
||||
/>
|
||||
{#if authManager.preferences.tags.enabled && assetMultiSelectManager.isAllUserOwned}
|
||||
<TagAction menuItem />
|
||||
{/if}
|
||||
<DeleteAssets menuItem onAssetDelete={handleDeleteOrArchiveAssets} />
|
||||
</ButtonContextMenu>
|
||||
</AssetSelectControlBar>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<section
|
||||
id="memory-viewer"
|
||||
class="w-full bg-immich-dark-gray"
|
||||
bind:this={memoryWrapper}
|
||||
bind:clientHeight={viewport.height}
|
||||
bind:clientWidth={viewport.width}
|
||||
>
|
||||
{#if current}
|
||||
<ControlAppBar onClose={() => goto(Route.photos())} forceDark multiRow>
|
||||
{#snippet leading()}
|
||||
{#if current}
|
||||
<p class="text-lg">
|
||||
{$memoryLaneTitle(current.memory)}
|
||||
</p>
|
||||
{/if}
|
||||
{/snippet}
|
||||
|
||||
<div class="flex place-content-center place-items-center gap-2 overflow-hidden">
|
||||
<div class="w-12.5 dark">
|
||||
<IconButton
|
||||
shape="round"
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
aria-label={paused ? $t('play_memories') : $t('pause_memories')}
|
||||
icon={paused ? mdiPlay : mdiPause}
|
||||
onclick={() => handlePromiseError(handleAction('PlayPauseButtonClick', paused ? 'play' : 'pause'))}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{#each current.memory.assets as asset, index (asset.id)}
|
||||
<a class="relative w-full py-2" href={asHref(asset)} aria-label={$t('view')}>
|
||||
<span class="absolute start-0 h-0.5 w-full bg-gray-500"></span>
|
||||
<span class="absolute start-0 h-0.5 bg-white" style:width={`${toProgressPercentage(index)}%`}></span>
|
||||
</a>
|
||||
{/each}
|
||||
|
||||
<div>
|
||||
<p class="text-small">
|
||||
{(current.assetIndex + 1).toLocaleString($locale)}/{current.memory.assets.length.toLocaleString($locale)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{#if currentTimelineAssets.some((asset) => asset.type === AssetTypeEnum.Video)}
|
||||
<div class="w-12.5 dark">
|
||||
<IconButton
|
||||
shape="round"
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
aria-label={$videoViewerMuted ? $t('unmute_memories') : $t('mute_memories')}
|
||||
icon={$videoViewerMuted ? mdiVolumeOff : mdiVolumeHigh}
|
||||
onclick={() => ($videoViewerMuted = !$videoViewerMuted)}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</ControlAppBar>
|
||||
|
||||
{#if galleryInView}
|
||||
<div
|
||||
class="fixed top-10 start-1/2 -translate-x-1/2 transition-opacity dark z-1"
|
||||
class:opacity-0={!galleryInView}
|
||||
class:opacity-100={galleryInView}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => memoryWrapper?.scrollIntoView({ behavior: 'smooth' })}
|
||||
disabled={!galleryInView}
|
||||
>
|
||||
<IconButton
|
||||
shape="round"
|
||||
color="secondary"
|
||||
aria-label={$t('hide_gallery')}
|
||||
icon={mdiChevronUp}
|
||||
onclick={() => {}}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
<!-- Viewer -->
|
||||
<section class="overflow-hidden pt-32 md:pt-20" bind:clientHeight={viewerHeight}>
|
||||
<div
|
||||
class="ms-[-100%] box-border flex h-[calc(100vh-224px)] md:h-[calc(100vh-180px)] w-[300%] items-center justify-center gap-10 overflow-hidden"
|
||||
>
|
||||
<!-- PREVIOUS MEMORY -->
|
||||
<div class="h-1/2 w-[20vw] rounded-2xl {current.previousMemory ? 'opacity-25 hover:opacity-70' : 'opacity-0'}">
|
||||
<button
|
||||
type="button"
|
||||
class="relative h-full w-full rounded-2xl"
|
||||
disabled={!current.previousMemory}
|
||||
onclick={handlePreviousMemory}
|
||||
>
|
||||
{#if current.previousMemory && current.previousMemory.assets.length > 0}
|
||||
<img
|
||||
class="h-full w-full rounded-2xl object-cover"
|
||||
src={getAssetMediaUrl({ id: current.previousMemory.assets[0].id, size: AssetMediaSize.Preview })}
|
||||
alt={$t('previous_memory')}
|
||||
draggable="false"
|
||||
/>
|
||||
{:else}
|
||||
<enhanced:img
|
||||
class="h-full w-full rounded-2xl object-cover"
|
||||
src="$lib/assets/no-thumbnail.png"
|
||||
sizes="min(271px,186px)"
|
||||
alt={$t('previous_memory')}
|
||||
draggable="false"
|
||||
/>
|
||||
{/if}
|
||||
|
||||
{#if current.previousMemory}
|
||||
<div class="absolute bottom-4 end-4 text-start text-white">
|
||||
<p class="uppercase text-xs font-semibold text-gray-200">{$t('previous')}</p>
|
||||
<p class="text-xl">{$memoryLaneTitle(current.previousMemory)}</p>
|
||||
</div>
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- CURRENT MEMORY -->
|
||||
<div
|
||||
class="main-view relative flex h-full w-[70vw] place-content-center place-items-center rounded-2xl bg-black"
|
||||
>
|
||||
<div class="relative h-full w-full rounded-2xl bg-black">
|
||||
{#key current.asset.id}
|
||||
{#if current.asset.isVideo}
|
||||
<MemoryVideoViewer
|
||||
asset={current.asset}
|
||||
bind:videoPlayer
|
||||
videoViewerMuted={$videoViewerMuted}
|
||||
videoViewerVolume={$videoViewerVolume}
|
||||
/>
|
||||
{:else}
|
||||
<MemoryPhotoViewer asset={current.asset} onImageLoad={resetAndPlay} />
|
||||
{/if}
|
||||
{/key}
|
||||
|
||||
<div
|
||||
class="absolute bottom-0 end-0 p-2 transition-all flex h-full justify-between flex-col items-end gap-2 dark"
|
||||
class:opacity-0={galleryInView}
|
||||
class:opacity-100={!galleryInView}
|
||||
>
|
||||
<div class="flex items-center">
|
||||
<IconButton
|
||||
icon={isSaved ? mdiHeart : mdiHeartOutline}
|
||||
shape="round"
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
aria-label={isSaved ? $t('unfavorite') : $t('favorite')}
|
||||
onclick={() => handleSaveMemory()}
|
||||
class="w-12 h-12"
|
||||
/>
|
||||
<!-- <IconButton
|
||||
icon={mdiShareVariantOutline}
|
||||
shape="round"
|
||||
variant="ghost"
|
||||
size="giant"
|
||||
color="secondary"
|
||||
aria-label={$t('share')}
|
||||
/> -->
|
||||
<ButtonContextMenu
|
||||
icon={mdiDotsVertical}
|
||||
title={$t('menu')}
|
||||
onclick={() => handlePromiseError(handleAction('ContextMenuClick', 'pause'))}
|
||||
direction="left"
|
||||
size="medium"
|
||||
align="bottom-right"
|
||||
>
|
||||
<MenuOption onClick={() => handleDeleteMemory()} text={$t('remove_memory')} icon={mdiCardsOutline} />
|
||||
<MenuOption
|
||||
onClick={() => handleDeleteMemoryAsset()}
|
||||
text={$t('remove_photo_from_memory')}
|
||||
icon={mdiImageMinusOutline}
|
||||
/>
|
||||
<!-- shortcut={{ key: 'l', shift: shared }} -->
|
||||
</ButtonContextMenu>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
{#await currentMemoryAssetFull then asset}
|
||||
{#if asset}
|
||||
<IconButton
|
||||
href={Route.photos({ at: asset.stack?.primaryAssetId ?? asset.id })}
|
||||
icon={mdiImageSearch}
|
||||
aria-label={$t('view_in_timeline')}
|
||||
color="secondary"
|
||||
variant="ghost"
|
||||
shape="round"
|
||||
/>
|
||||
{/if}
|
||||
{/await}
|
||||
</div>
|
||||
</div>
|
||||
<!-- CONTROL BUTTONS -->
|
||||
{#if current.previous}
|
||||
<div class="absolute top-1/2 start-0 ms-4 dark">
|
||||
<IconButton
|
||||
shape="round"
|
||||
aria-label={$t('previous_memory')}
|
||||
icon={mdiChevronLeft}
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
size="giant"
|
||||
onclick={handlePreviousAsset}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if current.next}
|
||||
<div class="absolute top-1/2 end-0 me-4 dark">
|
||||
<IconButton
|
||||
shape="round"
|
||||
aria-label={$t('next_memory')}
|
||||
icon={mdiChevronRight}
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
size="giant"
|
||||
onclick={handleNextAsset}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="absolute start-8 top-4 text-sm font-medium text-white">
|
||||
<p>
|
||||
{fromISODateTimeUTC(current.memory.assets[0].localDateTime).toLocaleString(DateTime.DATE_FULL, {
|
||||
locale: $locale,
|
||||
})}
|
||||
</p>
|
||||
<p>
|
||||
{#await currentMemoryAssetFull then asset}
|
||||
{asset?.exifInfo?.city || ''}
|
||||
{asset?.exifInfo?.country || ''}
|
||||
{/await}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- NEXT MEMORY -->
|
||||
<div class="h-1/2 w-[20vw] rounded-2xl {current.nextMemory ? 'opacity-25 hover:opacity-70' : 'opacity-0'}">
|
||||
<button
|
||||
type="button"
|
||||
class="relative h-full w-full rounded-2xl"
|
||||
onclick={handleNextMemory}
|
||||
disabled={!current.nextMemory}
|
||||
>
|
||||
{#if current.nextMemory && current.nextMemory.assets.length > 0}
|
||||
<img
|
||||
class="h-full w-full rounded-2xl object-cover"
|
||||
src={getAssetMediaUrl({ id: current.nextMemory.assets[0].id, size: AssetMediaSize.Preview })}
|
||||
alt={$t('next_memory')}
|
||||
draggable="false"
|
||||
/>
|
||||
{:else}
|
||||
<enhanced:img
|
||||
class="h-full w-full rounded-2xl object-cover"
|
||||
src="$lib/assets/no-thumbnail.png"
|
||||
sizes="min(271px,186px)"
|
||||
alt={$t('next_memory')}
|
||||
draggable="false"
|
||||
/>
|
||||
{/if}
|
||||
|
||||
{#if current.nextMemory}
|
||||
<div class="absolute bottom-4 start-4 text-start text-white">
|
||||
<p class="uppercase text-xs font-semibold text-gray-200">{$t('up_next')}</p>
|
||||
<p class="text-xl">{$memoryLaneTitle(current.nextMemory)}</p>
|
||||
</div>
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
{#if current}
|
||||
<!-- GALLERY VIEWER -->
|
||||
<section class="bg-immich-dark-gray p-4">
|
||||
<div
|
||||
class="sticky mb-10 flex place-content-center place-items-center transition-all dark"
|
||||
class:opacity-0={galleryInView}
|
||||
class:opacity-100={!galleryInView}
|
||||
>
|
||||
<IconButton
|
||||
shape="round"
|
||||
color="secondary"
|
||||
aria-label={$t('show_gallery')}
|
||||
icon={mdiChevronDown}
|
||||
onclick={() => memoryGallery?.scrollIntoView({ behavior: 'smooth' })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div id="gallery-memory" {@attach galleryObserver} bind:this={memoryGallery}>
|
||||
<GalleryViewer
|
||||
assets={currentTimelineAssets}
|
||||
{viewerAssets}
|
||||
viewport={galleryViewport}
|
||||
assetInteraction={assetMultiSelectManager}
|
||||
slidingWindowOffset={viewerHeight}
|
||||
arrowNavigation={false}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.main-view {
|
||||
box-shadow:
|
||||
0 4px 4px 0 rgba(0, 0, 0, 0.3),
|
||||
0 8px 12px 6px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
</style>
|
||||
@@ -1,56 +0,0 @@
|
||||
<script lang="ts">
|
||||
import FormatMessage from '$lib/elements/FormatMessage.svelte';
|
||||
import { Icon, Link, Stack } from '@immich/ui';
|
||||
import { mdiAlertCircleOutline } from '@mdi/js';
|
||||
import type { Translations } from 'svelte-i18n';
|
||||
|
||||
const messageKeys = [
|
||||
'admin.backup_onboarding_3_description',
|
||||
'admin.backup_onboarding_2_description',
|
||||
'admin.backup_onboarding_1_description',
|
||||
];
|
||||
</script>
|
||||
|
||||
<div class="flex flex-col">
|
||||
<Stack gap={2}>
|
||||
<div class="flex items-start gap-4 p-6 my-10 bg-gray-100 dark:bg-gray-800/40 rounded-xl border border-gray-700/50">
|
||||
<Icon icon={mdiAlertCircleOutline} size="36" class="text-warning shrink-0 mt-0.5" />
|
||||
<div class="text-gray-800 dark:text-gray-300 leading-relaxed">
|
||||
<FormatMessage key="admin.backup_onboarding_description">
|
||||
{#snippet children({ message })}
|
||||
<Link href="https://www.backblaze.com/blog/the-3-2-1-backup-strategy/">
|
||||
{message}
|
||||
</Link>
|
||||
{/snippet}
|
||||
</FormatMessage>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-1">
|
||||
<h2 class="mb-6"><FormatMessage key="admin.backup_onboarding_parts_title" /></h2>
|
||||
|
||||
<div class="space-y-6">
|
||||
{#each messageKeys as keyString, index (index)}
|
||||
<div class="flex items-start gap-6">
|
||||
<div class="shrink-0 w-12 h-12 rounded-full bg-primary/90 flex items-center justify-center">
|
||||
<span class="text-light text-xl font-semibold">{3 - index}</span>
|
||||
</div>
|
||||
<div class="leading-relaxed pt-2">
|
||||
<FormatMessage key={keyString as Translations} />
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="text-sm text-gray-600 dark:text-gray-400 leading-relaxed mt-4">
|
||||
<p>
|
||||
<FormatMessage key="admin.backup_onboarding_footer">
|
||||
{#snippet children({ message })}
|
||||
<Link href="https://docs.immich.app/administration/backup-and-restore/">{message}</Link>
|
||||
{/snippet}
|
||||
</FormatMessage>
|
||||
</p>
|
||||
</div>
|
||||
</Stack>
|
||||
</div>
|
||||
@@ -1,82 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { Button, Icon } from '@immich/ui';
|
||||
import { mdiArrowLeft, mdiArrowRight, mdiCheck } from '@mdi/js';
|
||||
import type { Snippet } from 'svelte';
|
||||
import { t } from 'svelte-i18n';
|
||||
import { fade } from 'svelte/transition';
|
||||
|
||||
interface Props {
|
||||
title?: string | undefined;
|
||||
icon?: string | undefined;
|
||||
children?: Snippet;
|
||||
previousTitle?: string | undefined;
|
||||
nextTitle?: string | undefined;
|
||||
onNext?: () => void;
|
||||
onPrevious?: () => void;
|
||||
onLeave?: () => void;
|
||||
}
|
||||
|
||||
let {
|
||||
title = undefined,
|
||||
icon = undefined,
|
||||
children,
|
||||
previousTitle,
|
||||
nextTitle,
|
||||
onLeave,
|
||||
onNext,
|
||||
onPrevious,
|
||||
}: Props = $props();
|
||||
</script>
|
||||
|
||||
<div
|
||||
id="onboarding-card"
|
||||
class="flex w-full max-w-4xl flex-col gap-4 rounded-3xl border-2 border-gray-500 px-8 py-8 dark:border-gray-700 dark:bg-immich-dark-gray text-black dark:text-immich-dark-fg bg-gray-50"
|
||||
in:fade={{ duration: 250 }}
|
||||
>
|
||||
{#if title || icon}
|
||||
<div class="flex gap-2 items-center justify-center w-fit">
|
||||
{#if icon}
|
||||
<Icon {icon} size="30" class="text-primary" />
|
||||
{/if}
|
||||
{#if title}
|
||||
<p class="text-xl text-primary font-medium">
|
||||
{title}
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
{@render children?.()}
|
||||
|
||||
<div class="flex pt-4">
|
||||
{#if previousTitle}
|
||||
<div class="w-full flex place-content-start">
|
||||
<Button
|
||||
shape="round"
|
||||
leadingIcon={mdiArrowLeft}
|
||||
class="flex gap-2 place-content-center"
|
||||
onclick={() => {
|
||||
onLeave?.();
|
||||
onPrevious?.();
|
||||
}}
|
||||
>
|
||||
<p>{previousTitle}</p>
|
||||
</Button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="flex w-full place-content-end">
|
||||
<Button
|
||||
shape="round"
|
||||
trailingIcon={nextTitle ? mdiArrowRight : mdiCheck}
|
||||
onclick={() => {
|
||||
onLeave?.();
|
||||
onNext?.();
|
||||
}}
|
||||
>
|
||||
<span class="flex place-content-center place-items-center gap-2">
|
||||
{nextTitle ?? $t('done')}
|
||||
</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,23 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { authManager } from '$lib/managers/auth-manager.svelte';
|
||||
import { serverConfigManager } from '$lib/managers/server-config-manager.svelte';
|
||||
import { OnboardingRole } from '$lib/types';
|
||||
import { Logo } from '@immich/ui';
|
||||
import { t } from 'svelte-i18n';
|
||||
|
||||
let userRole = $derived(
|
||||
authManager.user.isAdmin && !serverConfigManager.value.isOnboarded ? OnboardingRole.SERVER : OnboardingRole.USER,
|
||||
);
|
||||
</script>
|
||||
|
||||
<div class="gap-4">
|
||||
<Logo variant="icon" size="giant" class="mb-2" />
|
||||
<p class="font-medium mb-6 text-6xl text-primary">
|
||||
{$t('onboarding_welcome_user', { values: { user: authManager.user.name } })}
|
||||
</p>
|
||||
<p class="text-3xl pb-6 font-light">
|
||||
{userRole == OnboardingRole.SERVER
|
||||
? $t('onboarding_server_welcome_description')
|
||||
: $t('onboarding_user_welcome_description')}
|
||||
</p>
|
||||
</div>
|
||||
@@ -1,12 +0,0 @@
|
||||
<script lang="ts">
|
||||
import SettingsLanguageSelector from '$lib/components/shared-components/settings/settings-language-selector.svelte';
|
||||
import { t } from 'svelte-i18n';
|
||||
</script>
|
||||
|
||||
<div class="flex flex-col gap-4">
|
||||
<p>
|
||||
{$t('onboarding_locale_description')}
|
||||
</p>
|
||||
|
||||
<SettingsLanguageSelector />
|
||||
</div>
|
||||
@@ -1,31 +0,0 @@
|
||||
<script lang="ts">
|
||||
import AppDownloadModal from '$lib/modals/AppDownloadModal.svelte';
|
||||
import ObtainiumConfigModal from '$lib/modals/ObtainiumConfigModal.svelte';
|
||||
import { Button, HStack, modalManager } from '@immich/ui';
|
||||
import { mdiCellphoneArrowDownVariant, mdiLinkEdit } from '@mdi/js';
|
||||
import { t } from 'svelte-i18n';
|
||||
</script>
|
||||
|
||||
<p>{$t('mobile_app_download_onboarding_note')}</p>
|
||||
|
||||
<HStack>
|
||||
<Button
|
||||
size="medium"
|
||||
shape="semi-round"
|
||||
fullWidth
|
||||
onclick={() => modalManager.show(AppDownloadModal, {})}
|
||||
leadingIcon={mdiCellphoneArrowDownVariant}
|
||||
>
|
||||
{$t('app_stores')}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
size="medium"
|
||||
shape="semi-round"
|
||||
fullWidth
|
||||
onclick={() => modalManager.show(ObtainiumConfigModal, {})}
|
||||
leadingIcon={mdiLinkEdit}
|
||||
>
|
||||
{$t('obtainium_configurator')}
|
||||
</Button>
|
||||
</HStack>
|
||||
@@ -1,30 +0,0 @@
|
||||
<script lang="ts">
|
||||
import SettingSwitch from '$lib/components/shared-components/settings/setting-switch.svelte';
|
||||
import { systemConfigManager } from '$lib/managers/system-config-manager.svelte';
|
||||
import { handleSystemConfigSave } from '$lib/services/system-config.service';
|
||||
import { onDestroy } from 'svelte';
|
||||
import { t } from 'svelte-i18n';
|
||||
|
||||
const configToEdit = $state(systemConfigManager.cloneValue());
|
||||
|
||||
onDestroy(async () => {
|
||||
await handleSystemConfigSave({ map: configToEdit.map, newVersionCheck: configToEdit.newVersionCheck });
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="flex flex-col gap-4">
|
||||
<p>
|
||||
{$t('onboarding_privacy_description')}
|
||||
</p>
|
||||
|
||||
<SettingSwitch
|
||||
title={$t('admin.map_settings')}
|
||||
subtitle={$t('admin.map_implications')}
|
||||
bind:checked={configToEdit.map.enabled}
|
||||
/>
|
||||
<SettingSwitch
|
||||
title={$t('admin.version_check_settings')}
|
||||
subtitle={$t('admin.version_check_implications', { values: { server: 'version.immich.cloud' } })}
|
||||
bind:checked={configToEdit.newVersionCheck.enabled}
|
||||
/>
|
||||
</div>
|
||||
@@ -1,20 +0,0 @@
|
||||
<script lang="ts">
|
||||
import StorageTemplateSettings from '$lib/components/admin-settings/StorageTemplateSettings.svelte';
|
||||
import FormatMessage from '$lib/elements/FormatMessage.svelte';
|
||||
import { authManager } from '$lib/managers/auth-manager.svelte';
|
||||
import { Link } from '@immich/ui';
|
||||
</script>
|
||||
|
||||
<div class="flex flex-col">
|
||||
<p>
|
||||
<FormatMessage key="admin.storage_template_onboarding_description_v2">
|
||||
{#snippet children({ message })}
|
||||
<Link href="https://docs.immich.app/administration/storage-template">{message}</Link>
|
||||
{/snippet}
|
||||
</FormatMessage>
|
||||
</p>
|
||||
|
||||
{#if authManager.authenticated}
|
||||
<StorageTemplateSettings minified duration={0} saveOnClose />
|
||||
{/if}
|
||||
</div>
|
||||
@@ -1,36 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { moonPath, moonViewBox, sunPath, sunViewBox } from '$lib/assets/svg-paths';
|
||||
import { Icon, themeManager, ThemePreference } from '@immich/ui';
|
||||
import { t } from 'svelte-i18n';
|
||||
</script>
|
||||
|
||||
<div class="flex flex-col gap-4">
|
||||
<p>{$t('onboarding_theme_description')}</p>
|
||||
|
||||
<div class="flex gap-4">
|
||||
<button
|
||||
type="button"
|
||||
class="w-1/2 aspect-square bg-light dark:bg-dark rounded-3xl transition-all shadow-sm hover:shadow-xl border-[3px] border-immich-primary dark:border dark:border-transparent"
|
||||
onclick={() => themeManager.setPreference(ThemePreference.Light)}
|
||||
>
|
||||
<div
|
||||
class="flex flex-col place-items-center place-content-center justify-around h-full w-full text-immich-primary"
|
||||
>
|
||||
<Icon icon={sunPath} viewBox={sunViewBox} size="96" />
|
||||
<p class="font-semibold text-4xl">{$t('light')}</p>
|
||||
</div>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="w-1/2 aspect-square bg-dark dark:bg-light rounded-3xl transition-all shadow-sm hover:shadow-xl dark:border-[3px] dark:border-immich-dark-primary border border-transparent"
|
||||
onclick={() => themeManager.setPreference(ThemePreference.Dark)}
|
||||
>
|
||||
<div
|
||||
class="flex flex-col place-items-center place-content-center justify-around h-full w-full text-immich-dark-primary"
|
||||
>
|
||||
<Icon icon={moonPath} viewBox={moonViewBox} size="96" />
|
||||
<p class="font-semibold text-4xl">{$t('dark')}</p>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,27 +0,0 @@
|
||||
<script lang="ts">
|
||||
import SettingSwitch from '$lib/components/shared-components/settings/setting-switch.svelte';
|
||||
import { authManager } from '$lib/managers/auth-manager.svelte';
|
||||
import { handleError } from '$lib/utils/handle-error';
|
||||
import { updateMyPreferences } from '@immich/sdk';
|
||||
import { onDestroy } from 'svelte';
|
||||
import { t } from 'svelte-i18n';
|
||||
|
||||
let gCastEnabled = $state(authManager.authenticated ? authManager.preferences.cast.gCastEnabled : false);
|
||||
|
||||
onDestroy(async () => {
|
||||
try {
|
||||
const response = await updateMyPreferences({ userPreferencesUpdateDto: { cast: { gCastEnabled } } });
|
||||
authManager.setPreferences(response);
|
||||
} catch (error) {
|
||||
handleError(error, $t('errors.unable_to_update_settings'));
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="flex flex-col gap-4">
|
||||
<p>
|
||||
{$t('onboarding_privacy_description')}
|
||||
</p>
|
||||
|
||||
<SettingSwitch title={$t('gcast_enabled')} subtitle={$t('gcast_enabled_description')} bind:checked={gCastEnabled} />
|
||||
</div>
|
||||
@@ -1,63 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { Route } from '$lib/route';
|
||||
import { placesViewSettings } from '$lib/stores/preferences.store';
|
||||
import { getAssetMediaUrl } from '$lib/utils';
|
||||
import { type PlacesGroup, isPlacesGroupCollapsed, togglePlacesGroupCollapsing } from '$lib/utils/places-utils';
|
||||
import { AssetMediaSize, type AssetResponseDto } from '@immich/sdk';
|
||||
import { Icon } from '@immich/ui';
|
||||
import { mdiChevronRight } from '@mdi/js';
|
||||
import { t } from 'svelte-i18n';
|
||||
|
||||
interface Props {
|
||||
places: AssetResponseDto[];
|
||||
group?: PlacesGroup | undefined;
|
||||
}
|
||||
|
||||
let { places, group = undefined }: Props = $props();
|
||||
|
||||
let isCollapsed = $derived(!!group && isPlacesGroupCollapsed($placesViewSettings, group.id));
|
||||
let iconRotation = $derived(isCollapsed ? 'rotate-0' : 'rotate-90');
|
||||
</script>
|
||||
|
||||
{#if group}
|
||||
<div class="grid">
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => togglePlacesGroupCollapsing(group.id)}
|
||||
class="w-fit mt-2 pt-2 pe-2 mb-2 dark:text-immich-dark-fg"
|
||||
aria-expanded={!isCollapsed}
|
||||
>
|
||||
<Icon icon={mdiChevronRight} size="24" class="inline-block -mt-2.5 transition-all duration-250 {iconRotation}" />
|
||||
<span class="font-bold text-3xl text-black dark:text-white">{group.name}</span>
|
||||
<span class="ms-1.5">({$t('places_count', { values: { count: places.length } })})</span>
|
||||
</button>
|
||||
<hr class="dark:border-immich-dark-gray" />
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="mt-4">
|
||||
{#if !isCollapsed}
|
||||
<div class="flex flex-row flex-wrap gap-4">
|
||||
{#each places as item (item.id)}
|
||||
{@const city = item.exifInfo?.city}
|
||||
<a class="relative" href={Route.search({ city })} draggable="false">
|
||||
<div
|
||||
class="flex w-[calc((100vw-(72px+5rem))/2)] max-w-39 justify-center overflow-hidden rounded-xl brightness-75 filter"
|
||||
>
|
||||
<img
|
||||
src={getAssetMediaUrl({ id: item.id, size: AssetMediaSize.Thumbnail })}
|
||||
alt={city}
|
||||
class="object-cover w-39 h-39"
|
||||
loading="lazy"
|
||||
/>
|
||||
</div>
|
||||
<span
|
||||
class="absolute bottom-2 w-full text-ellipsis px-1 text-center text-sm font-medium capitalize text-white backdrop-blur-[1px] hover:cursor-pointer"
|
||||
>
|
||||
{city}
|
||||
</span>
|
||||
</a>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -1,95 +0,0 @@
|
||||
<script lang="ts">
|
||||
import Dropdown from '$lib/elements/Dropdown.svelte';
|
||||
import SearchBar from '$lib/elements/SearchBar.svelte';
|
||||
import { PlacesGroupBy, placesViewSettings } from '$lib/stores/preferences.store';
|
||||
import {
|
||||
type PlacesGroupOptionMetadata,
|
||||
collapseAllPlacesGroups,
|
||||
expandAllPlacesGroups,
|
||||
findGroupOptionMetadata,
|
||||
getSelectedPlacesGroupOption,
|
||||
groupOptionsMetadata,
|
||||
} from '$lib/utils/places-utils';
|
||||
import { IconButton } from '@immich/ui';
|
||||
import {
|
||||
mdiFolderArrowUpOutline,
|
||||
mdiFolderRemoveOutline,
|
||||
mdiUnfoldLessHorizontal,
|
||||
mdiUnfoldMoreHorizontal,
|
||||
} from '@mdi/js';
|
||||
import { t } from 'svelte-i18n';
|
||||
import { fly } from 'svelte/transition';
|
||||
|
||||
interface Props {
|
||||
placesGroups: string[];
|
||||
searchQuery: string;
|
||||
}
|
||||
|
||||
let { placesGroups, searchQuery = $bindable() }: Props = $props();
|
||||
|
||||
const handleChangeGroupBy = ({ id }: PlacesGroupOptionMetadata) => {
|
||||
$placesViewSettings.groupBy = id;
|
||||
};
|
||||
|
||||
let groupIcon = $derived.by(() => {
|
||||
return selectedGroupOption.id === PlacesGroupBy.None ? mdiFolderRemoveOutline : mdiFolderArrowUpOutline; // OR mdiFolderArrowDownOutline
|
||||
});
|
||||
|
||||
let selectedGroupOption = $derived(findGroupOptionMetadata($placesViewSettings.groupBy));
|
||||
|
||||
let placesGroupByNames: Record<PlacesGroupBy, string> = $derived({
|
||||
[PlacesGroupBy.None]: $t('group_no'),
|
||||
[PlacesGroupBy.Country]: $t('group_country'),
|
||||
});
|
||||
</script>
|
||||
|
||||
<!-- Search Places -->
|
||||
<div class="hidden md:block h-10 xl:w-60 2xl:w-80">
|
||||
<SearchBar placeholder={$t('search_places')} bind:name={searchQuery} showLoadingSpinner={false} />
|
||||
</div>
|
||||
|
||||
<!-- Group Places -->
|
||||
<Dropdown
|
||||
position="bottom-right"
|
||||
title={$t('group_places_by')}
|
||||
options={Object.values(groupOptionsMetadata)}
|
||||
selectedOption={selectedGroupOption}
|
||||
onSelect={handleChangeGroupBy}
|
||||
render={({ id, isDisabled }) => ({
|
||||
title: placesGroupByNames[id],
|
||||
icon: groupIcon,
|
||||
disabled: isDisabled(),
|
||||
})}
|
||||
/>
|
||||
|
||||
{#if getSelectedPlacesGroupOption($placesViewSettings) !== PlacesGroupBy.None}
|
||||
<span in:fly={{ x: -50, duration: 250 }}>
|
||||
<!-- Expand Countries Groups -->
|
||||
<div class="hidden xl:flex gap-0">
|
||||
<div class="block">
|
||||
<IconButton
|
||||
title={$t('expand_all')}
|
||||
onclick={() => expandAllPlacesGroups()}
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
shape="round"
|
||||
icon={mdiUnfoldMoreHorizontal}
|
||||
aria-label={$t('expand_all')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Collapse Countries Groups -->
|
||||
<div class="block">
|
||||
<IconButton
|
||||
title={$t('collapse_all')}
|
||||
onclick={() => collapseAllPlacesGroups(placesGroups)}
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
shape="round"
|
||||
icon={mdiUnfoldLessHorizontal}
|
||||
aria-label={$t('collapse_all')}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</span>
|
||||
{/if}
|
||||
@@ -1,110 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { PlacesGroupBy, type PlacesViewSettings } from '$lib/stores/preferences.store';
|
||||
import { normalizeSearchString } from '$lib/utils/string-utils';
|
||||
import { type AssetResponseDto } from '@immich/sdk';
|
||||
import { mdiMapMarkerOff } from '@mdi/js';
|
||||
import { groupBy } from 'lodash-es';
|
||||
import PlacesCardGroup from './places-card-group.svelte';
|
||||
|
||||
import { type PlacesGroup, getSelectedPlacesGroupOption } from '$lib/utils/places-utils';
|
||||
import { Icon } from '@immich/ui';
|
||||
import { t } from 'svelte-i18n';
|
||||
|
||||
interface Props {
|
||||
places?: AssetResponseDto[];
|
||||
searchQuery?: string;
|
||||
searchResultCount: number;
|
||||
userSettings: PlacesViewSettings;
|
||||
placesGroupIds?: string[];
|
||||
}
|
||||
|
||||
let {
|
||||
places = $bindable([]),
|
||||
searchQuery = '',
|
||||
// eslint-disable-next-line no-useless-assignment
|
||||
searchResultCount = $bindable(0),
|
||||
userSettings,
|
||||
// eslint-disable-next-line no-useless-assignment
|
||||
placesGroupIds = $bindable([]),
|
||||
}: Props = $props();
|
||||
|
||||
interface PlacesGroupOption {
|
||||
[option: string]: (places: AssetResponseDto[]) => PlacesGroup[];
|
||||
}
|
||||
|
||||
const groupOptions: PlacesGroupOption = {
|
||||
/** No grouping */
|
||||
[PlacesGroupBy.None]: (places): PlacesGroup[] => {
|
||||
return [
|
||||
{
|
||||
id: $t('places'),
|
||||
name: $t('places'),
|
||||
places,
|
||||
},
|
||||
];
|
||||
},
|
||||
|
||||
/** Group by year */
|
||||
[PlacesGroupBy.Country]: (places): PlacesGroup[] => {
|
||||
const unknownCountry = $t('unknown_country');
|
||||
|
||||
const groupedByCountry = groupBy(places, (place) => {
|
||||
return place.exifInfo?.country ?? unknownCountry;
|
||||
});
|
||||
|
||||
const sortedByCountryName = Object.entries(groupedByCountry).sort(([a], [b]) => {
|
||||
// We make sure empty albums stay at the end of the list
|
||||
if (a === unknownCountry) {
|
||||
return 1;
|
||||
} else if (b === unknownCountry) {
|
||||
return -1;
|
||||
} else {
|
||||
return a.localeCompare(b);
|
||||
}
|
||||
});
|
||||
|
||||
return sortedByCountryName.map(([country, places]) => ({
|
||||
id: country,
|
||||
name: country,
|
||||
places,
|
||||
}));
|
||||
},
|
||||
};
|
||||
|
||||
const filteredPlaces = $derived.by(() => {
|
||||
const searchQueryNormalized = normalizeSearchString(searchQuery);
|
||||
return searchQueryNormalized
|
||||
? places.filter((place) => normalizeSearchString(place.exifInfo?.city ?? '').includes(searchQueryNormalized))
|
||||
: places;
|
||||
});
|
||||
|
||||
const placesGroupOption: string = $derived(getSelectedPlacesGroupOption(userSettings));
|
||||
const groupingFunction = $derived(groupOptions[placesGroupOption] ?? groupOptions[PlacesGroupBy.None]);
|
||||
const groupedPlaces: PlacesGroup[] = $derived(groupingFunction(filteredPlaces));
|
||||
|
||||
$effect(() => {
|
||||
searchResultCount = filteredPlaces.length;
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
placesGroupIds = groupedPlaces.map(({ id }) => id);
|
||||
});
|
||||
</script>
|
||||
|
||||
{#if places.length > 0}
|
||||
<!-- Album Cards -->
|
||||
{#if placesGroupOption === PlacesGroupBy.None}
|
||||
<PlacesCardGroup places={groupedPlaces[0].places} />
|
||||
{:else}
|
||||
{#each groupedPlaces as placeGroup (placeGroup.id)}
|
||||
<PlacesCardGroup places={placeGroup.places} group={placeGroup} />
|
||||
{/each}
|
||||
{/if}
|
||||
{:else}
|
||||
<div class="flex min-h-[calc(66vh-11rem)] w-full place-content-center items-center dark:text-white">
|
||||
<div class="flex flex-col content-center items-center text-center">
|
||||
<Icon icon={mdiMapMarkerOff} size="3.5em" />
|
||||
<p class="mt-5 text-3xl font-medium">{$t('no_places')}</p>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -1,238 +0,0 @@
|
||||
<script lang="ts">
|
||||
import ServerStatisticsCard from '$lib/components/server-statistics/ServerStatisticsCard.svelte';
|
||||
import { locale } from '$lib/stores/preferences.store';
|
||||
import { getBytesWithUnit } from '$lib/utils/byte-units';
|
||||
import type { ServerStatsResponseDto, UserAdminResponseDto } from '@immich/sdk';
|
||||
import {
|
||||
Code,
|
||||
FormatBytes,
|
||||
Icon,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHeader,
|
||||
TableHeading,
|
||||
TableRow,
|
||||
Text,
|
||||
} from '@immich/ui';
|
||||
import { mdiCameraIris, mdiChartPie, mdiPlayCircle } from '@mdi/js';
|
||||
import { t } from 'svelte-i18n';
|
||||
|
||||
type Props = {
|
||||
statsPromise: Promise<ServerStatsResponseDto>;
|
||||
users: UserAdminResponseDto[];
|
||||
};
|
||||
|
||||
const { statsPromise, users }: Props = $props();
|
||||
|
||||
const photosPromise = $derived.by(() => statsPromise.then((data) => ({ value: data.photos })));
|
||||
|
||||
const videosPromise = $derived.by(() => statsPromise.then((data) => ({ value: data.videos })));
|
||||
|
||||
const storagePromise = $derived.by(() =>
|
||||
statsPromise.then((data) => {
|
||||
const TiB = 1024 ** 4;
|
||||
const [value, unit] = getBytesWithUnit(data.usage, data.usage > TiB ? 2 : 0);
|
||||
return { value, unit };
|
||||
}),
|
||||
);
|
||||
|
||||
const getStorageUsageWithUnit = (usage: number) => {
|
||||
const TiB = 1024 ** 4;
|
||||
return getBytesWithUnit(usage, usage > TiB ? 2 : 0);
|
||||
};
|
||||
|
||||
const zeros = (value: number, maxLength = 13) => {
|
||||
const valueLength = value.toString().length;
|
||||
const zeroLength = maxLength - valueLength;
|
||||
|
||||
return '0'.repeat(zeroLength);
|
||||
};
|
||||
|
||||
const getUserStatsPromise = async (userId: string) => {
|
||||
const stats = await statsPromise;
|
||||
return stats.usageByUser.find((userStats) => userStats.userId === userId);
|
||||
};
|
||||
</script>
|
||||
|
||||
{#snippet placeholder()}
|
||||
<TableCell class="w-1/4"><span class="skeleton-loader inline-block h-4 w-16"></span></TableCell>
|
||||
<TableCell class="w-1/4"><span class="skeleton-loader inline-block h-4 w-16"></span></TableCell>
|
||||
<TableCell class="w-1/4"><span class="skeleton-loader inline-block h-4 w-24"></span></TableCell>
|
||||
{/snippet}
|
||||
|
||||
<div class="flex flex-col gap-5 my-4">
|
||||
<div>
|
||||
<Text class="mb-2" fontWeight="medium">{$t('total_usage')}</Text>
|
||||
|
||||
<div class="hidden justify-between lg:flex gap-4">
|
||||
<ServerStatisticsCard icon={mdiCameraIris} title={$t('photos')} valuePromise={photosPromise} />
|
||||
<ServerStatisticsCard icon={mdiPlayCircle} title={$t('videos')} valuePromise={videosPromise} />
|
||||
<ServerStatisticsCard icon={mdiChartPie} title={$t('storage')} valuePromise={storagePromise} />
|
||||
</div>
|
||||
|
||||
<div class="mt-5 flex lg:hidden">
|
||||
<div class="flex flex-col justify-between rounded-3xl bg-subtle p-5 dark:bg-immich-dark-gray">
|
||||
<div class="flex flex-wrap gap-x-12">
|
||||
<div class="flex flex-1 place-items-center gap-4 text-primary">
|
||||
<Icon icon={mdiCameraIris} size="25" />
|
||||
<Text size="medium" fontWeight="medium">{$t('photos')}</Text>
|
||||
</div>
|
||||
|
||||
<div class="relative text-center font-mono text-2xl font-medium">
|
||||
{#await statsPromise}
|
||||
<span class="text-gray-300 dark:text-gray-600 shimmer-text">{zeros(0)}</span>
|
||||
{:then stats}
|
||||
<span class="text-light-300">{zeros(stats.photos)}</span><span class="text-primary">{stats.photos}</span>
|
||||
{:catch}
|
||||
<span class="text-gray-300 dark:text-gray-600">{zeros(0)}</span>
|
||||
{/await}
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-x-12">
|
||||
<div class="flex flex-1 place-items-center gap-4 text-primary">
|
||||
<Icon icon={mdiPlayCircle} size="25" />
|
||||
<Text size="medium" fontWeight="medium">{$t('videos')}</Text>
|
||||
</div>
|
||||
|
||||
<div class="relative text-center font-mono text-2xl font-medium">
|
||||
{#await statsPromise}
|
||||
<span class="text-gray-300 dark:text-gray-600 shimmer-text">{zeros(0)}</span>
|
||||
{:then stats}
|
||||
<span class="text-light-300">{zeros(stats.videos)}</span><span class="text-primary">{stats.videos}</span>
|
||||
{:catch}
|
||||
<span class="text-gray-300 dark:text-gray-600">{zeros(0)}</span>
|
||||
{/await}
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-x-5">
|
||||
<div class="flex flex-1 flex-nowrap place-items-center gap-4 text-primary">
|
||||
<Icon icon={mdiChartPie} size="25" />
|
||||
<Text size="medium" fontWeight="medium">{$t('storage')}</Text>
|
||||
</div>
|
||||
|
||||
<div class="relative flex text-center font-mono text-2xl font-medium">
|
||||
{#await statsPromise}
|
||||
<span class="text-gray-300 dark:text-gray-600 shimmer-text">{zeros(0)}</span>
|
||||
{:then stats}
|
||||
{@const storageUsageWithUnit = getStorageUsageWithUnit(stats.usage)}
|
||||
<span class="text-light-300">{zeros(storageUsageWithUnit[0])}</span><span class="text-primary"
|
||||
>{storageUsageWithUnit[0]}</span
|
||||
>
|
||||
|
||||
<div class="absolute -end-1.5 -bottom-4">
|
||||
<Code color="muted" class="text-xs font-light font-mono">{storageUsageWithUnit[1]}</Code>
|
||||
</div>
|
||||
{:catch}
|
||||
<span class="text-gray-300 dark:text-gray-600">{zeros(0)}</span>
|
||||
{/await}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Text class="mb-2 mt-4" fontWeight="medium">{$t('user_usage_detail')}</Text>
|
||||
<Table striped size="small">
|
||||
<TableHeader>
|
||||
<TableHeading class="w-1/4">{$t('user')}</TableHeading>
|
||||
<TableHeading class="w-1/4">{$t('photos')}</TableHeading>
|
||||
<TableHeading class="w-1/4">{$t('videos')}</TableHeading>
|
||||
<TableHeading class="w-1/4">{$t('usage')}</TableHeading>
|
||||
</TableHeader>
|
||||
<TableBody class="block max-h-80 overflow-y-auto">
|
||||
{#each users as user (user.id)}
|
||||
<TableRow>
|
||||
<TableCell class="w-1/4">{user.name}</TableCell>
|
||||
{#await getUserStatsPromise(user.id)}
|
||||
{@render placeholder()}
|
||||
{:then userStats}
|
||||
{#if userStats}
|
||||
<TableCell class="w-1/4">
|
||||
{userStats.photos.toLocaleString($locale)} (<FormatBytes bytes={userStats.usagePhotos} />)</TableCell
|
||||
>
|
||||
<TableCell class="w-1/4">
|
||||
{userStats.videos.toLocaleString($locale)} (<FormatBytes
|
||||
bytes={userStats.usageVideos}
|
||||
precision={0}
|
||||
/>)</TableCell
|
||||
>
|
||||
<TableCell class="w-1/4">
|
||||
<FormatBytes bytes={userStats.usage} precision={0} />
|
||||
{#if userStats.quotaSizeInBytes !== null}
|
||||
/ <FormatBytes bytes={userStats.quotaSizeInBytes} precision={0} />
|
||||
{/if}
|
||||
<span class="text-primary">
|
||||
{#if userStats.quotaSizeInBytes !== null && userStats.quotaSizeInBytes >= 0}
|
||||
({(userStats.quotaSizeInBytes === 0
|
||||
? 1
|
||||
: userStats.usage / userStats.quotaSizeInBytes
|
||||
).toLocaleString($locale, {
|
||||
style: 'percent',
|
||||
maximumFractionDigits: 0,
|
||||
})})
|
||||
{:else}
|
||||
({$t('unlimited')})
|
||||
{/if}
|
||||
</span>
|
||||
</TableCell>
|
||||
{:else}
|
||||
{@render placeholder()}
|
||||
{/if}
|
||||
{/await}
|
||||
</TableRow>
|
||||
{/each}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.skeleton-loader {
|
||||
position: relative;
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
background-color: rgba(156, 163, 175, 0.35);
|
||||
}
|
||||
|
||||
.skeleton-loader::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background-repeat: no-repeat;
|
||||
background-image: linear-gradient(
|
||||
90deg,
|
||||
rgba(255, 255, 255, 0),
|
||||
rgba(255, 255, 255, 0.8) 50%,
|
||||
rgba(255, 255, 255, 0)
|
||||
);
|
||||
background-size: 200% 100%;
|
||||
background-position: 200% 0;
|
||||
animation: skeleton-animation 2000ms infinite;
|
||||
}
|
||||
|
||||
@keyframes skeleton-animation {
|
||||
from {
|
||||
background-position: 200% 0;
|
||||
}
|
||||
to {
|
||||
background-position: -200% 0;
|
||||
}
|
||||
}
|
||||
|
||||
.shimmer-text {
|
||||
mask-image: linear-gradient(90deg, rgba(0, 0, 0, 1) 0%, rgba(0, 0, 0, 0.3) 50%, rgba(0, 0, 0, 1) 100%);
|
||||
mask-size: 200% 100%;
|
||||
animation: shimmer 2.25s infinite linear;
|
||||
}
|
||||
|
||||
@keyframes shimmer {
|
||||
from {
|
||||
mask-position: 200% 0;
|
||||
}
|
||||
to {
|
||||
mask-position: -200% 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,191 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { page } from '$app/state';
|
||||
import { shouldIgnoreEvent } from '$lib/actions/shortcut';
|
||||
import { authManager } from '$lib/managers/auth-manager.svelte';
|
||||
import { dragAndDropFilesStore } from '$lib/stores/drag-and-drop-files.store';
|
||||
import { fileUploadHandler } from '$lib/utils/file-uploader';
|
||||
import { isAlbumsRoute, isLockedFolderRoute } from '$lib/utils/navigation';
|
||||
import { Logo } from '@immich/ui';
|
||||
import { t } from 'svelte-i18n';
|
||||
import { fade } from 'svelte/transition';
|
||||
|
||||
let albumId = $derived(isAlbumsRoute(page.route?.id) ? page.params.albumId : undefined);
|
||||
let isInLockedFolder = $derived(isLockedFolderRoute(page.route.id));
|
||||
|
||||
let dragStartTarget: EventTarget | null = $state(null);
|
||||
let isInternalDrag = false;
|
||||
|
||||
const onDragEnter = (e: DragEvent) => {
|
||||
if (e.dataTransfer && e.dataTransfer.types.includes('Files')) {
|
||||
dragStartTarget = e.target;
|
||||
}
|
||||
};
|
||||
|
||||
const onDragLeave = (e: DragEvent) => {
|
||||
if (dragStartTarget === e.target) {
|
||||
dragStartTarget = null;
|
||||
}
|
||||
};
|
||||
|
||||
const onDrop = async (e: DragEvent) => {
|
||||
dragStartTarget = null;
|
||||
await handleDataTransfer(e.dataTransfer);
|
||||
};
|
||||
|
||||
const onPaste = (event: ClipboardEvent) => {
|
||||
if (shouldIgnoreEvent(event)) {
|
||||
return;
|
||||
}
|
||||
|
||||
return handleDataTransfer(event.clipboardData);
|
||||
};
|
||||
|
||||
const handleDataTransfer = async (dataTransfer?: DataTransfer | null) => {
|
||||
if (!dataTransfer) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!browserSupportsDirectoryUpload()) {
|
||||
return handleFiles(dataTransfer.files);
|
||||
}
|
||||
|
||||
const entries: FileSystemEntry[] = [];
|
||||
const files: File[] = [];
|
||||
for (const item of dataTransfer.items) {
|
||||
// eslint-disable-next-line tscompat/tscompat
|
||||
const entry = item.webkitGetAsEntry();
|
||||
if (entry) {
|
||||
entries.push(entry);
|
||||
continue;
|
||||
}
|
||||
|
||||
const file = item.getAsFile();
|
||||
if (file) {
|
||||
files.push(file);
|
||||
}
|
||||
}
|
||||
|
||||
const directoryFiles = await getAllFilesFromTransferEntries(entries);
|
||||
return handleFiles([...files, ...directoryFiles]);
|
||||
};
|
||||
|
||||
// eslint-disable-next-line tscompat/tscompat
|
||||
const browserSupportsDirectoryUpload = () => typeof DataTransferItem.prototype.webkitGetAsEntry === 'function';
|
||||
|
||||
const getAllFilesFromTransferEntries = async (transferEntries: FileSystemEntry[]): Promise<File[]> => {
|
||||
const allFiles: File[] = [];
|
||||
let entriesToCheckForSubDirectories = [...transferEntries];
|
||||
while (entriesToCheckForSubDirectories.length > 0) {
|
||||
const currentEntry = entriesToCheckForSubDirectories.pop();
|
||||
|
||||
if (isFileSystemDirectoryEntry(currentEntry)) {
|
||||
entriesToCheckForSubDirectories = entriesToCheckForSubDirectories.concat(
|
||||
await getContentsFromFileSystemDirectoryEntry(currentEntry),
|
||||
);
|
||||
} else if (isFileSystemFileEntry(currentEntry)) {
|
||||
allFiles.push(await getFileFromFileSystemEntry(currentEntry));
|
||||
}
|
||||
}
|
||||
|
||||
return allFiles;
|
||||
};
|
||||
|
||||
const isFileSystemDirectoryEntry = (entry?: FileSystemEntry): entry is FileSystemDirectoryEntry =>
|
||||
!!entry && entry.isDirectory;
|
||||
const isFileSystemFileEntry = (entry?: FileSystemEntry): entry is FileSystemFileEntry => !!entry && entry.isFile;
|
||||
|
||||
const getFileFromFileSystemEntry = async (fileSystemFileEntry: FileSystemFileEntry): Promise<File> => {
|
||||
return new Promise((resolve, reject) => {
|
||||
fileSystemFileEntry.file(resolve, reject);
|
||||
});
|
||||
};
|
||||
|
||||
const readEntriesAsync = (reader: FileSystemDirectoryReader) => {
|
||||
return new Promise<FileSystemEntry[]>((resolve, reject) => {
|
||||
reader.readEntries(resolve, reject);
|
||||
});
|
||||
};
|
||||
|
||||
const getContentsFromFileSystemDirectoryEntry = async (
|
||||
fileSystemDirectoryEntry: FileSystemDirectoryEntry,
|
||||
): Promise<FileSystemEntry[]> => {
|
||||
const reader = fileSystemDirectoryEntry.createReader();
|
||||
const files: FileSystemEntry[] = [];
|
||||
let entries: FileSystemEntry[];
|
||||
|
||||
do {
|
||||
entries = await readEntriesAsync(reader);
|
||||
files.push(...entries);
|
||||
} while (entries.length > 0);
|
||||
|
||||
return files;
|
||||
};
|
||||
|
||||
const handleFiles = async (files?: FileList | File[]) => {
|
||||
if (!files) {
|
||||
return;
|
||||
}
|
||||
|
||||
const filesArray: File[] = Array.from<File>(files);
|
||||
if (authManager.isSharedLink) {
|
||||
dragAndDropFilesStore.set({ isDragging: true, files: filesArray });
|
||||
} else {
|
||||
await fileUploadHandler({ files: filesArray, albumId, isLockedAssets: isInLockedFolder });
|
||||
}
|
||||
};
|
||||
|
||||
const ondragstart = () => {
|
||||
isInternalDrag = true;
|
||||
};
|
||||
|
||||
const ondragend = () => {
|
||||
isInternalDrag = false;
|
||||
};
|
||||
|
||||
const ondragenter = (e: DragEvent) => {
|
||||
if (isInternalDrag) {
|
||||
return;
|
||||
}
|
||||
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
onDragEnter(e);
|
||||
};
|
||||
|
||||
const ondragleave = (e: DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
onDragLeave(e);
|
||||
};
|
||||
|
||||
const ondrop = async (e: DragEvent) => {
|
||||
if (isInternalDrag) {
|
||||
return;
|
||||
}
|
||||
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
await onDrop(e);
|
||||
};
|
||||
|
||||
const onDragOver = (e: DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
};
|
||||
</script>
|
||||
|
||||
<svelte:window onpaste={onPaste} />
|
||||
|
||||
<svelte:body {ondragstart} {ondragend} {ondragenter} {ondragleave} {ondrop} />
|
||||
|
||||
{#if dragStartTarget}
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div
|
||||
class="fixed inset-0 flex h-full w-full flex-col items-center justify-center bg-gray-100/90 text-immich-dark-gray dark:bg-immich-dark-bg/90 dark:text-immich-gray"
|
||||
transition:fade={{ duration: 250 }}
|
||||
ondragover={onDragOver}
|
||||
>
|
||||
<Logo variant="icon" size="giant" class="m-16 animate-bounce" />
|
||||
<div class="text-2xl">{$t('drop_files_to_upload')}</div>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -1,180 +0,0 @@
|
||||
<script lang="ts">
|
||||
import ActionMenuItem from '$lib/components/ActionMenuItem.svelte';
|
||||
import ButtonContextMenu from '$lib/components/shared-components/context-menu/button-context-menu.svelte';
|
||||
import type { SelectionBBox } from '$lib/components/shared-components/map/types';
|
||||
import ArchiveAction from '$lib/components/timeline/actions/ArchiveAction.svelte';
|
||||
import ChangeDate from '$lib/components/timeline/actions/ChangeDateAction.svelte';
|
||||
import ChangeDescription from '$lib/components/timeline/actions/ChangeDescriptionAction.svelte';
|
||||
import ChangeLocation from '$lib/components/timeline/actions/ChangeLocationAction.svelte';
|
||||
import CreateSharedLink from '$lib/components/timeline/actions/CreateSharedLinkAction.svelte';
|
||||
import DeleteAssets from '$lib/components/timeline/actions/DeleteAssetsAction.svelte';
|
||||
import DownloadAction from '$lib/components/timeline/actions/DownloadAction.svelte';
|
||||
import FavoriteAction from '$lib/components/timeline/actions/FavoriteAction.svelte';
|
||||
import LinkLivePhotoAction from '$lib/components/timeline/actions/LinkLivePhotoAction.svelte';
|
||||
import SelectAllAssets from '$lib/components/timeline/actions/SelectAllAction.svelte';
|
||||
import SetVisibilityAction from '$lib/components/timeline/actions/SetVisibilityAction.svelte';
|
||||
import StackAction from '$lib/components/timeline/actions/StackAction.svelte';
|
||||
import TagAction from '$lib/components/timeline/actions/TagAction.svelte';
|
||||
import AssetSelectControlBar from '$lib/components/timeline/AssetSelectControlBar.svelte';
|
||||
import Timeline from '$lib/components/timeline/Timeline.svelte';
|
||||
import Portal from '$lib/elements/Portal.svelte';
|
||||
import { assetMultiSelectManager } from '$lib/managers/asset-multi-select-manager.svelte';
|
||||
import { authManager } from '$lib/managers/auth-manager.svelte';
|
||||
import { TimelineManager } from '$lib/managers/timeline-manager/timeline-manager.svelte';
|
||||
import { getAssetBulkActions } from '$lib/services/asset.service';
|
||||
import { mapSettings } from '$lib/stores/preferences.store';
|
||||
import {
|
||||
updateStackedAssetInTimeline,
|
||||
updateUnstackedAssetInTimeline,
|
||||
type OnLink,
|
||||
type OnUnlink,
|
||||
} from '$lib/utils/actions';
|
||||
import { AssetVisibility } from '@immich/sdk';
|
||||
import { ActionButton, CloseButton, CommandPaletteDefaultProvider, Icon } from '@immich/ui';
|
||||
import { mdiDotsVertical, mdiImageMultiple } from '@mdi/js';
|
||||
import { ceil, floor } from 'lodash-es';
|
||||
import { t } from 'svelte-i18n';
|
||||
|
||||
interface Props {
|
||||
bbox: SelectionBBox;
|
||||
selectedClusterIds: Set<string>;
|
||||
assetCount: number;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
let { bbox, selectedClusterIds, assetCount, onClose }: Props = $props();
|
||||
|
||||
let timelineManager = $state<TimelineManager>() as TimelineManager;
|
||||
let selectedAssets = $derived(assetMultiSelectManager.assets);
|
||||
let isAssetStackSelected = $derived(selectedAssets.length === 1 && !!selectedAssets[0].stack);
|
||||
let isLinkActionAvailable = $derived.by(() => {
|
||||
const isLivePhoto = selectedAssets.length === 1 && !!selectedAssets[0].livePhotoVideoId;
|
||||
const isLivePhotoCandidate =
|
||||
selectedAssets.length === 2 &&
|
||||
selectedAssets.some((asset) => asset.isImage) &&
|
||||
selectedAssets.some((asset) => asset.isVideo);
|
||||
|
||||
return assetMultiSelectManager.isAllUserOwned && (isLivePhoto || isLivePhotoCandidate);
|
||||
});
|
||||
|
||||
const handleLink: OnLink = ({ still, motion }) => {
|
||||
timelineManager.removeAssets([motion.id]);
|
||||
timelineManager.upsertAssets([still]);
|
||||
};
|
||||
|
||||
const handleUnlink: OnUnlink = ({ still, motion }) => {
|
||||
timelineManager.upsertAssets([motion]);
|
||||
timelineManager.upsertAssets([still]);
|
||||
};
|
||||
|
||||
const handleSetVisibility = (assetIds: string[]) => {
|
||||
timelineManager.removeAssets(assetIds);
|
||||
assetMultiSelectManager.clear();
|
||||
};
|
||||
|
||||
const handleEscape = () => {
|
||||
assetMultiSelectManager.clear();
|
||||
};
|
||||
|
||||
const timelineBoundingBox = $derived(
|
||||
`${floor(bbox.west, 6)},${floor(bbox.south, 6)},${ceil(bbox.east, 6)},${ceil(bbox.north, 6)}`,
|
||||
);
|
||||
|
||||
const timelineOptions = $derived({
|
||||
bbox: timelineBoundingBox,
|
||||
visibility: $mapSettings.includeArchived ? undefined : AssetVisibility.Timeline,
|
||||
isFavorite: $mapSettings.onlyFavorites || undefined,
|
||||
withPartners: $mapSettings.withPartners || undefined,
|
||||
assetFilter: selectedClusterIds,
|
||||
});
|
||||
|
||||
$effect.pre(() => {
|
||||
void timelineOptions;
|
||||
assetMultiSelectManager.clear();
|
||||
});
|
||||
</script>
|
||||
|
||||
<aside class="h-full w-full overflow-hidden bg-immich-bg dark:bg-immich-dark-bg flex flex-col contain-content">
|
||||
<div class="flex items-center justify-between border-b border-gray-200 dark:border-immich-dark-gray pb-1 pe-1">
|
||||
<div class="flex items-center gap-2">
|
||||
<Icon icon={mdiImageMultiple} size="20" />
|
||||
<p class="text-sm font-medium text-immich-fg dark:text-immich-dark-fg">
|
||||
{$t('assets_count', { values: { count: assetCount } })}
|
||||
</p>
|
||||
</div>
|
||||
<CloseButton onclick={onClose} />
|
||||
</div>
|
||||
|
||||
<div class="min-h-0 flex-1">
|
||||
<Timeline
|
||||
bind:timelineManager
|
||||
enableRouting={false}
|
||||
options={timelineOptions}
|
||||
onEscape={handleEscape}
|
||||
assetInteraction={assetMultiSelectManager}
|
||||
showArchiveIcon
|
||||
/>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
{#if assetMultiSelectManager.selectionActive}
|
||||
{@const Actions = getAssetBulkActions($t)}
|
||||
<CommandPaletteDefaultProvider name={$t('assets')} actions={Object.values(Actions)} />
|
||||
|
||||
<Portal target="body">
|
||||
<AssetSelectControlBar>
|
||||
<CreateSharedLink />
|
||||
<SelectAllAssets {timelineManager} assetInteraction={assetMultiSelectManager} />
|
||||
<ActionButton action={Actions.AddToAlbum} />
|
||||
|
||||
{#if assetMultiSelectManager.isAllUserOwned}
|
||||
<FavoriteAction
|
||||
removeFavorite={assetMultiSelectManager.isAllFavorite}
|
||||
onFavorite={(ids, isFavorite) => timelineManager.update(ids, (asset) => (asset.isFavorite = isFavorite))}
|
||||
/>
|
||||
|
||||
<ButtonContextMenu icon={mdiDotsVertical} title={$t('menu')}>
|
||||
<DownloadAction menuItem />
|
||||
{#if assetMultiSelectManager.assets.length > 1 || isAssetStackSelected}
|
||||
<StackAction
|
||||
unstack={isAssetStackSelected}
|
||||
onStack={(result) => updateStackedAssetInTimeline(timelineManager, result)}
|
||||
onUnstack={(assets) => updateUnstackedAssetInTimeline(timelineManager, assets)}
|
||||
/>
|
||||
{/if}
|
||||
{#if isLinkActionAvailable}
|
||||
<LinkLivePhotoAction
|
||||
menuItem
|
||||
unlink={assetMultiSelectManager.assets.length === 1}
|
||||
onLink={handleLink}
|
||||
onUnlink={handleUnlink}
|
||||
/>
|
||||
{/if}
|
||||
<ChangeDate menuItem />
|
||||
<ChangeDescription menuItem />
|
||||
<ChangeLocation menuItem />
|
||||
<ArchiveAction
|
||||
menuItem
|
||||
unarchive={assetMultiSelectManager.isAllArchived}
|
||||
onArchive={(ids, visibility) => timelineManager.update(ids, (asset) => (asset.visibility = visibility))}
|
||||
/>
|
||||
{#if authManager.preferences.tags.enabled}
|
||||
<TagAction menuItem />
|
||||
{/if}
|
||||
<DeleteAssets
|
||||
menuItem
|
||||
onAssetDelete={(assetIds) => timelineManager.removeAssets(assetIds)}
|
||||
onUndoDelete={(assets) => timelineManager.upsertAssets(assets)}
|
||||
/>
|
||||
<SetVisibilityAction menuItem onVisibilitySet={handleSetVisibility} />
|
||||
<hr />
|
||||
<ActionMenuItem action={Actions.RegenerateThumbnailJob} />
|
||||
<ActionMenuItem action={Actions.RefreshMetadataJob} />
|
||||
<ActionMenuItem action={Actions.TranscodeVideoJob} />
|
||||
</ButtonContextMenu>
|
||||
{:else}
|
||||
<DownloadAction />
|
||||
{/if}
|
||||
</AssetSelectControlBar>
|
||||
</Portal>
|
||||
{/if}
|
||||
@@ -1,32 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { cubicOut } from 'svelte/easing';
|
||||
import { tweened } from 'svelte/motion';
|
||||
|
||||
let showing = $state(false);
|
||||
|
||||
// delay showing any progress for a little bit so very fast loads
|
||||
// do not cause flicker
|
||||
const delay = 100;
|
||||
|
||||
const progress = tweened(0, {
|
||||
duration: 1000,
|
||||
easing: cubicOut,
|
||||
});
|
||||
|
||||
function animate() {
|
||||
showing = true;
|
||||
void progress.set(90);
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
const timer = setTimeout(animate, delay);
|
||||
return () => clearTimeout(timer);
|
||||
});
|
||||
</script>
|
||||
|
||||
{#if showing}
|
||||
<div class="absolute start-0 top-0 h-[3px] w-dvw bg-white">
|
||||
<span class="absolute h-[3px] bg-immich-primary" style:width={`${$progress}%`}></span>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -1,67 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { Checkbox, Label } from '@immich/ui';
|
||||
import { t } from 'svelte-i18n';
|
||||
import { quintOut } from 'svelte/easing';
|
||||
import { fly } from 'svelte/transition';
|
||||
|
||||
interface Props {
|
||||
value: string[];
|
||||
options: { value: string; text: string }[];
|
||||
label?: string;
|
||||
desc?: string;
|
||||
name?: string;
|
||||
isEdited?: boolean;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
let {
|
||||
value = $bindable(),
|
||||
options,
|
||||
label = '',
|
||||
desc = '',
|
||||
name = '',
|
||||
isEdited = false,
|
||||
disabled = false,
|
||||
}: Props = $props();
|
||||
|
||||
function handleCheckboxChange(option: string) {
|
||||
value = value.includes(option) ? value.filter((item) => item !== option) : [...value, option];
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="mb-4 w-full">
|
||||
<div class="flex h-6.5 place-items-center gap-1">
|
||||
<label class="font-medium text-primary text-sm" for="{name}-select">
|
||||
{label}
|
||||
</label>
|
||||
|
||||
{#if isEdited}
|
||||
<div
|
||||
transition:fly={{ x: 10, duration: 200, easing: quintOut }}
|
||||
class="rounded-full bg-orange-100 px-2 text-[10px] text-orange-900"
|
||||
>
|
||||
{$t('unsaved_change')}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if desc}
|
||||
<p class="immich-form-label pb-2 text-sm" id="{name}-desc">
|
||||
{desc}
|
||||
</p>
|
||||
{/if}
|
||||
<div class="flex flex-col gap-2">
|
||||
{#each options as option (option.value)}
|
||||
<div class="flex gap-2 items-center">
|
||||
<Checkbox
|
||||
size="tiny"
|
||||
id="{option.value}-checkbox"
|
||||
checked={value.includes(option.value)}
|
||||
{disabled}
|
||||
onCheckedChange={() => handleCheckboxChange(option.value)}
|
||||
/>
|
||||
<Label label={option.text} for="{option.value}-checkbox" size="small" />
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,50 +0,0 @@
|
||||
<script lang="ts">
|
||||
import Combobox, { type ComboBoxOption } from '$lib/components/shared-components/combobox.svelte';
|
||||
import { Label, Text } from '@immich/ui';
|
||||
import type { Snippet } from 'svelte';
|
||||
import { t } from 'svelte-i18n';
|
||||
import { quintOut } from 'svelte/easing';
|
||||
import { fly } from 'svelte/transition';
|
||||
|
||||
interface Props {
|
||||
title: string;
|
||||
comboboxPlaceholder: string;
|
||||
subtitle?: string;
|
||||
isEdited?: boolean;
|
||||
options: ComboBoxOption[];
|
||||
selectedOption: ComboBoxOption;
|
||||
onSelect: (combobox: ComboBoxOption | undefined) => void;
|
||||
children?: Snippet;
|
||||
}
|
||||
|
||||
let {
|
||||
title,
|
||||
comboboxPlaceholder,
|
||||
subtitle = '',
|
||||
isEdited = false,
|
||||
options,
|
||||
selectedOption,
|
||||
onSelect,
|
||||
children,
|
||||
}: Props = $props();
|
||||
</script>
|
||||
|
||||
<div>
|
||||
<div class="flex h-6.5 place-items-center gap-1">
|
||||
<Label size="small">{title}</Label>
|
||||
{#if isEdited}
|
||||
<div
|
||||
transition:fly={{ x: 10, duration: 200, easing: quintOut }}
|
||||
class="rounded-full bg-orange-100 px-2 text-[10px] text-orange-900"
|
||||
>
|
||||
{$t('unsaved_change')}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<Text size="small" color="muted">{subtitle}</Text>
|
||||
<div class="flex items-center mt-2 max-w-[300px]">
|
||||
<Combobox label={title} hideLabel={true} {selectedOption} {options} placeholder={comboboxPlaceholder} {onSelect} />
|
||||
{@render children?.()}
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,84 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { Icon } from '@immich/ui';
|
||||
import { mdiChevronDown } from '@mdi/js';
|
||||
import { t } from 'svelte-i18n';
|
||||
import { quintOut } from 'svelte/easing';
|
||||
import { fly } from 'svelte/transition';
|
||||
|
||||
interface Props {
|
||||
value: string | number | undefined;
|
||||
options: { value: string | number; text: string }[];
|
||||
label?: string;
|
||||
desc?: string;
|
||||
name?: string;
|
||||
isEdited?: boolean;
|
||||
number?: boolean;
|
||||
disabled?: boolean;
|
||||
onSelect?: (setting: string | number) => void;
|
||||
}
|
||||
|
||||
let {
|
||||
value = $bindable(),
|
||||
options,
|
||||
label = '',
|
||||
desc = '',
|
||||
name = '',
|
||||
isEdited = false,
|
||||
number = false,
|
||||
disabled = false,
|
||||
onSelect = () => {},
|
||||
}: Props = $props();
|
||||
|
||||
const handleChange = (e: Event) => {
|
||||
value = (e.target as HTMLInputElement).value;
|
||||
if (number) {
|
||||
value = Number.parseInt(value);
|
||||
}
|
||||
onSelect(value);
|
||||
};
|
||||
</script>
|
||||
|
||||
<div class="mb-4 w-full">
|
||||
<div class="flex h-6.5 place-items-center gap-1">
|
||||
<label class="font-medium text-primary text-sm" for="{name}-select">{label}</label>
|
||||
|
||||
{#if isEdited}
|
||||
<div
|
||||
transition:fly={{ x: 10, duration: 200, easing: quintOut }}
|
||||
class="rounded-full bg-orange-100 px-2 text-[10px] text-orange-900"
|
||||
>
|
||||
{$t('unsaved_change')}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if desc}
|
||||
<p class="immich-form-label pb-2 text-sm" id="{name}-desc">
|
||||
{desc}
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
<div class="grid">
|
||||
<Icon
|
||||
icon={mdiChevronDown}
|
||||
size="1.2em"
|
||||
aria-hidden
|
||||
class="pointer-events-none end-1 relative col-start-1 row-start-1 self-center justify-self-end {disabled
|
||||
? 'text-immich-bg'
|
||||
: 'text-immich-fg dark:text-immich-bg'}"
|
||||
/>
|
||||
<select
|
||||
class="immich-form-input w-full appearance-none row-start-1 col-start-1 pe-6!"
|
||||
{disabled}
|
||||
aria-describedby={desc ? `${name}-desc` : undefined}
|
||||
{name}
|
||||
id="{name}-select"
|
||||
bind:value
|
||||
onchange={handleChange}
|
||||
>
|
||||
{#each options as option (option.value)}
|
||||
<option value={option.value}>{option.text}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,68 +0,0 @@
|
||||
<script lang="ts">
|
||||
import type { Snippet } from 'svelte';
|
||||
import { t } from 'svelte-i18n';
|
||||
import { quintOut } from 'svelte/easing';
|
||||
import { fly } from 'svelte/transition';
|
||||
|
||||
interface Props {
|
||||
value: string;
|
||||
label?: string;
|
||||
description?: string;
|
||||
required?: boolean;
|
||||
disabled?: boolean;
|
||||
isEdited?: boolean;
|
||||
descriptionSnippet?: Snippet;
|
||||
}
|
||||
|
||||
let {
|
||||
value = $bindable(),
|
||||
label = '',
|
||||
description = '',
|
||||
required = false,
|
||||
disabled = false,
|
||||
isEdited = false,
|
||||
descriptionSnippet,
|
||||
}: Props = $props();
|
||||
|
||||
const handleInput = (e: Event) => {
|
||||
value = (e.target as HTMLInputElement).value;
|
||||
};
|
||||
</script>
|
||||
|
||||
<div class="mb-4 w-full">
|
||||
<div class="flex h-6.5 place-items-center gap-1">
|
||||
<label class="font-medium text-primary text-sm" for={label}>{label}</label>
|
||||
{#if required}
|
||||
<div class="text-red-400">*</div>
|
||||
{/if}
|
||||
|
||||
{#if isEdited}
|
||||
<div
|
||||
transition:fly={{ x: 10, duration: 200, easing: quintOut }}
|
||||
class="rounded-full bg-orange-100 px-2 text-[10px] text-orange-900"
|
||||
>
|
||||
{$t('unsaved_change')}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if description}
|
||||
<p class="immich-form-label pb-2 text-sm" id="{label}-desc">
|
||||
{description}
|
||||
</p>
|
||||
{:else}
|
||||
{@render descriptionSnippet?.()}
|
||||
{/if}
|
||||
|
||||
<textarea
|
||||
class="immich-form-input w-full pb-2"
|
||||
aria-describedby={description ? `${label}-desc` : undefined}
|
||||
aria-labelledby="{label}-label"
|
||||
id={label}
|
||||
name={label}
|
||||
{required}
|
||||
{value}
|
||||
oninput={handleInput}
|
||||
{disabled}
|
||||
></textarea>
|
||||
</div>
|
||||
@@ -1,53 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { Logo } from '@immich/ui';
|
||||
import { t } from 'svelte-i18n';
|
||||
|
||||
interface Props {
|
||||
centered?: boolean;
|
||||
logoSize?: 'sm' | 'lg';
|
||||
}
|
||||
|
||||
let { centered = false, logoSize = 'sm' }: Props = $props();
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="flex gap-1 mt-2 place-items-center dark:bg-immich-dark-primary/10 bg-gray-200/50 p-2 rounded-lg bg-clip-padding border border-transparent relative supporter-effect"
|
||||
class:place-content-center={centered}
|
||||
>
|
||||
<Logo variant="icon" size={logoSize === 'sm' ? 'tiny' : 'small'} />
|
||||
<p class="dark:text-gray-100">{$t('purchase_account_info')}</p>
|
||||
</div>
|
||||
|
||||
<style lang="postcss">
|
||||
@reference "tailwindcss";
|
||||
|
||||
.supporter-effect::after {
|
||||
@apply absolute inset-0 rounded-lg opacity-0 transition-opacity content-[''];
|
||||
}
|
||||
|
||||
.supporter-effect:hover::after {
|
||||
@apply opacity-100;
|
||||
background: linear-gradient(
|
||||
to right,
|
||||
rgba(16, 132, 254, 0.25),
|
||||
rgba(229, 125, 175, 0.25),
|
||||
rgba(254, 36, 29, 0.25),
|
||||
rgba(255, 183, 0, 0.25),
|
||||
rgba(22, 193, 68, 0.25)
|
||||
);
|
||||
animation: gradient 10s ease infinite;
|
||||
background-size: 400% 400%;
|
||||
}
|
||||
|
||||
@keyframes gradient {
|
||||
0% {
|
||||
background-position: 0% 50%;
|
||||
}
|
||||
50% {
|
||||
background-position: 100% 50%;
|
||||
}
|
||||
100% {
|
||||
background-position: 0% 50%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,116 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { Route } from '$lib/route';
|
||||
import { locale } from '$lib/stores/preferences.store';
|
||||
import { uploadAssetsStore } from '$lib/stores/upload';
|
||||
import type { UploadAsset } from '$lib/types';
|
||||
import { UploadState } from '$lib/types';
|
||||
import { getByteUnitString } from '$lib/utils/byte-units';
|
||||
import { fileUploadHandler } from '$lib/utils/file-uploader';
|
||||
import { Icon } from '@immich/ui';
|
||||
import {
|
||||
mdiAlertCircle,
|
||||
mdiCheckCircle,
|
||||
mdiCircleOutline,
|
||||
mdiClose,
|
||||
mdiLoading,
|
||||
mdiOpenInNew,
|
||||
mdiRestart,
|
||||
mdiTrashCan,
|
||||
} from '@mdi/js';
|
||||
import { t } from 'svelte-i18n';
|
||||
import { fade } from 'svelte/transition';
|
||||
|
||||
interface Props {
|
||||
uploadAsset: UploadAsset;
|
||||
}
|
||||
|
||||
let { uploadAsset }: Props = $props();
|
||||
|
||||
const handleDismiss = (uploadAsset: UploadAsset) => {
|
||||
uploadAssetsStore.removeItem(uploadAsset.id);
|
||||
};
|
||||
|
||||
const handleRetry = async (uploadAsset: UploadAsset) => {
|
||||
uploadAssetsStore.removeItem(uploadAsset.id);
|
||||
await fileUploadHandler({ files: [uploadAsset.file], albumId: uploadAsset.albumId });
|
||||
};
|
||||
</script>
|
||||
|
||||
<div
|
||||
in:fade={{ duration: 250 }}
|
||||
out:fade={{ duration: 100 }}
|
||||
class="flex flex-col rounded-xl text-xs p-2 gap-1 border border-gray-300 dark:border-subtle bg-primary/10"
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="flex items-center justify-center">
|
||||
{#if uploadAsset.state === UploadState.PENDING}
|
||||
<Icon icon={mdiCircleOutline} size="24" class="text-primary" title={$t('pending')} />
|
||||
{:else if uploadAsset.state === UploadState.STARTED}
|
||||
<Icon icon={mdiLoading} size="24" spin class="text-primary" title={$t('asset_skipped')} />
|
||||
{:else if uploadAsset.state === UploadState.ERROR}
|
||||
<Icon icon={mdiAlertCircle} size="24" class="text-danger" title={$t('error')} />
|
||||
{:else if uploadAsset.state === UploadState.DUPLICATED}
|
||||
{#if uploadAsset.isTrashed}
|
||||
<Icon icon={mdiTrashCan} size="24" class="text-gray-500" title={$t('asset_skipped_in_trash')} />
|
||||
{:else}
|
||||
<Icon icon={mdiAlertCircle} size="24" class="text-warning" title={$t('asset_skipped')} />
|
||||
{/if}
|
||||
{:else if uploadAsset.state === UploadState.DONE}
|
||||
<Icon icon={mdiCheckCircle} size="24" class="text-success" title={$t('asset_uploaded')} />
|
||||
{/if}
|
||||
</div>
|
||||
<!-- <span>[{getByteUnitString(uploadAsset.file.size, $locale)}]</span> -->
|
||||
<span class="grow break-all">{uploadAsset.file.name}</span>
|
||||
|
||||
{#if uploadAsset.state === UploadState.DUPLICATED && uploadAsset.assetId}
|
||||
<div class="flex items-center justify-between gap-1">
|
||||
<a
|
||||
href={uploadAsset.isTrashed
|
||||
? Route.viewTrashedAsset({ id: uploadAsset.assetId })
|
||||
: Route.viewAsset({ id: uploadAsset.assetId })}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class=""
|
||||
aria-hidden="true"
|
||||
tabindex={-1}
|
||||
>
|
||||
<Icon icon={mdiOpenInNew} size="20" />
|
||||
</a>
|
||||
<button type="button" onclick={() => handleDismiss(uploadAsset)} class="" aria-hidden="true" tabindex={-1}>
|
||||
<Icon icon={mdiClose} size="20" />
|
||||
</button>
|
||||
</div>
|
||||
{:else if uploadAsset.state === UploadState.ERROR}
|
||||
<div class="flex items-center justify-between gap-1">
|
||||
<button type="button" onclick={() => handleRetry(uploadAsset)} class="" aria-hidden="true" tabindex={-1}>
|
||||
<Icon icon={mdiRestart} size="20" />
|
||||
</button>
|
||||
<button type="button" onclick={() => handleDismiss(uploadAsset)} class="" aria-hidden="true" tabindex={-1}>
|
||||
<Icon icon={mdiClose} size="20" />
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if uploadAsset.state === UploadState.STARTED}
|
||||
<div class="text-black relative mt-[5px] h-4.5 w-full rounded-md bg-gray-300 dark:bg-gray-700">
|
||||
<div class="h-4.5 rounded-md bg-immich-primary transition-all" style={`width: ${uploadAsset.progress}%`}></div>
|
||||
<p class="absolute top-0.5 h-full w-full text-center text-white text-[10px]">
|
||||
{#if uploadAsset.message === $t('asset_hashing')}
|
||||
{uploadAsset.message}
|
||||
{:else}
|
||||
{uploadAsset.message}
|
||||
{uploadAsset.progress}% - {getByteUnitString(uploadAsset.speed || 0, $locale)}/s - {uploadAsset.eta}s
|
||||
{/if}
|
||||
</p>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if uploadAsset.state === UploadState.ERROR}
|
||||
<div class="flex flex-row justify-between">
|
||||
<p class="w-full rounded-md text-justify text-danger">
|
||||
{uploadAsset.error}
|
||||
</p>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -1,161 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { locale } from '$lib/stores/preferences.store';
|
||||
import { uploadAssetsStore } from '$lib/stores/upload';
|
||||
import { uploadExecutionQueue } from '$lib/utils/file-uploader';
|
||||
import { Icon, IconButton, toastManager } from '@immich/ui';
|
||||
import { mdiCancel, mdiCloudUploadOutline, mdiCog, mdiWindowMinimize } from '@mdi/js';
|
||||
import { t } from 'svelte-i18n';
|
||||
import { quartInOut } from 'svelte/easing';
|
||||
import { fade, scale } from 'svelte/transition';
|
||||
import UploadAssetPreview from './upload-asset-preview.svelte';
|
||||
|
||||
let showDetail = $state(false);
|
||||
let showOptions = $state(false);
|
||||
let concurrency = $state(uploadExecutionQueue.concurrency);
|
||||
|
||||
let { stats, isDismissible, isUploading, remainingUploads } = uploadAssetsStore;
|
||||
|
||||
$effect(() => {
|
||||
if ($isUploading) {
|
||||
showDetail = true;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
{#if $isUploading}
|
||||
<div
|
||||
in:fade={{ duration: 250 }}
|
||||
out:fade={{ duration: 250 }}
|
||||
onoutroend={() => {
|
||||
if ($stats.errors > 0) {
|
||||
toastManager.danger($t('upload_errors', { values: { count: $stats.errors } }));
|
||||
} else if ($stats.success > 0) {
|
||||
toastManager.primary($t('upload_success'));
|
||||
}
|
||||
if ($stats.duplicates > 0) {
|
||||
toastManager.warning($t('upload_skipped_duplicates', { values: { count: $stats.duplicates } }));
|
||||
}
|
||||
uploadAssetsStore.reset();
|
||||
}}
|
||||
class="fixed bottom-6 end-16"
|
||||
>
|
||||
{#if showDetail}
|
||||
<div
|
||||
in:scale={{ duration: 250, easing: quartInOut }}
|
||||
class="w-81 rounded-xl border border-gray-200 dark:border-subtle p-4 text-sm shadow-xs bg-subtle"
|
||||
>
|
||||
<div class="place-item-center mb-4 flex justify-between">
|
||||
<div class="flex flex-col gap-1">
|
||||
<p class="immich-form-label text-xm">
|
||||
{$t('upload_progress', {
|
||||
values: {
|
||||
remaining: $remainingUploads,
|
||||
processed: $stats.total - $remainingUploads,
|
||||
total: $stats.total,
|
||||
},
|
||||
})}
|
||||
</p>
|
||||
<p class="immich-form-label text-xs">
|
||||
{$t('upload_status_uploaded')}
|
||||
<span class="text-success">{$stats.success.toLocaleString($locale)}</span>
|
||||
-
|
||||
{$t('upload_status_errors')}
|
||||
<span class="text-danger">{$stats.errors.toLocaleString($locale)}</span>
|
||||
-
|
||||
{$t('upload_status_duplicates')}
|
||||
<span class="text-warning">{$stats.duplicates.toLocaleString($locale)}</span>
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex flex-col items-end">
|
||||
<div class="flex flex-row">
|
||||
<IconButton
|
||||
variant="ghost"
|
||||
shape="round"
|
||||
color="secondary"
|
||||
icon={mdiCog}
|
||||
size="small"
|
||||
onclick={() => (showOptions = !showOptions)}
|
||||
aria-label={$t('toggle_settings')}
|
||||
/>
|
||||
<IconButton
|
||||
variant="ghost"
|
||||
shape="round"
|
||||
color="secondary"
|
||||
aria-label={$t('minimize')}
|
||||
icon={mdiWindowMinimize}
|
||||
size="small"
|
||||
onclick={() => (showDetail = false)}
|
||||
/>
|
||||
</div>
|
||||
{#if $isDismissible}
|
||||
<IconButton
|
||||
variant="ghost"
|
||||
shape="round"
|
||||
color="secondary"
|
||||
aria-label={$t('dismiss_all_errors')}
|
||||
icon={mdiCancel}
|
||||
size="small"
|
||||
onclick={() => uploadAssetsStore.dismissErrors()}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{#if showOptions}
|
||||
<div class="immich-scrollbar mb-4 max-h-100 overflow-y-auto rounded-lg">
|
||||
<div class="flex h-6.5 place-items-center gap-1">
|
||||
<label class="immich-form-label" for="upload-concurrency">{$t('upload_concurrency')}</label>
|
||||
</div>
|
||||
<input
|
||||
class="immich-form-input w-full"
|
||||
aria-labelledby={$t('upload_concurrency')}
|
||||
id="upload-concurrency"
|
||||
name={$t('upload_concurrency')}
|
||||
type="number"
|
||||
min="1"
|
||||
max="50"
|
||||
step="1"
|
||||
bind:value={concurrency}
|
||||
onchange={() => (uploadExecutionQueue.concurrency = concurrency)}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
<div class="immich-scrollbar flex max-h-[400px] flex-col gap-2 overflow-y-auto rounded-lg">
|
||||
{#each $uploadAssetsStore as uploadAsset (uploadAsset.id)}
|
||||
<UploadAssetPreview {uploadAsset} />
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="rounded-full">
|
||||
<button
|
||||
type="button"
|
||||
in:scale={{ duration: 250, easing: quartInOut }}
|
||||
onclick={() => (showDetail = true)}
|
||||
class="absolute -start-4 -top-4 flex h-10 w-10 place-content-center place-items-center rounded-full bg-primary p-5 text-xs text-light"
|
||||
>
|
||||
{$remainingUploads.toLocaleString($locale)}
|
||||
</button>
|
||||
{#if $stats.errors > 0}
|
||||
<button
|
||||
type="button"
|
||||
in:scale={{ duration: 250, easing: quartInOut }}
|
||||
onclick={() => (showDetail = true)}
|
||||
class="absolute -end-4 -top-4 flex h-10 w-10 place-content-center place-items-center rounded-full bg-danger p-5 text-xs text-light"
|
||||
>
|
||||
{$stats.errors.toLocaleString($locale)}
|
||||
</button>
|
||||
{/if}
|
||||
<button
|
||||
type="button"
|
||||
in:scale={{ duration: 250, easing: quartInOut }}
|
||||
onclick={() => (showDetail = true)}
|
||||
class="flex h-16 w-16 place-content-center place-items-center rounded-full bg-subtle p-5 text-sm text-primary shadow-lg"
|
||||
>
|
||||
<div class="animate-pulse">
|
||||
<Icon icon={mdiCloudUploadOutline} size="30" />
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
@@ -1,120 +0,0 @@
|
||||
<script lang="ts">
|
||||
import ShareCover from '$lib/components/sharedlinks-page/covers/share-cover.svelte';
|
||||
import { Route } from '$lib/route';
|
||||
import { getSharedLinkActions } from '$lib/services/shared-link.service';
|
||||
import { locale } from '$lib/stores/preferences.store';
|
||||
import { SharedLinkType, type SharedLinkResponseDto } from '@immich/sdk';
|
||||
import { ActionButton, ContextMenuButton, MenuItemType, Text } from '@immich/ui';
|
||||
import { DateTime, type ToRelativeUnit } from 'luxon';
|
||||
import { t } from 'svelte-i18n';
|
||||
|
||||
interface Props {
|
||||
sharedLink: SharedLinkResponseDto;
|
||||
}
|
||||
|
||||
let { sharedLink }: Props = $props();
|
||||
|
||||
let now = DateTime.now();
|
||||
let expiresAt = $derived(sharedLink.expiresAt ? DateTime.fromISO(sharedLink.expiresAt) : undefined);
|
||||
let isExpired = $derived(expiresAt ? now > expiresAt : false);
|
||||
|
||||
const getCountDownExpirationDate = (expiresAtDate: DateTime, now: DateTime) => {
|
||||
const relativeUnits: ToRelativeUnit[] = ['days', 'hours', 'minutes', 'seconds'];
|
||||
const expirationCountdown = expiresAtDate.diff(now, relativeUnits).toObject();
|
||||
|
||||
for (const unit of relativeUnits) {
|
||||
const value = expirationCountdown[unit];
|
||||
if (value && value > 0) {
|
||||
return expiresAtDate.toRelativeCalendar({ base: now, locale: $locale, unit });
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
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
|
||||
class="flex w-full border-b border-gray-200 transition-all hover:border-immich-primary dark:border-gray-600 dark:text-immich-gray dark:hover:border-immich-dark-primary"
|
||||
>
|
||||
<svelte:element
|
||||
this={isExpired ? 'div' : 'a'}
|
||||
href={isExpired ? undefined : Route.viewSharedLink(sharedLink)}
|
||||
class="flex gap-4 w-full py-4"
|
||||
>
|
||||
<ShareCover class="transition-all duration-300 hover:shadow-lg" {sharedLink} />
|
||||
|
||||
<div class="flex flex-col gap-4 justify-between">
|
||||
<div class="flex flex-col">
|
||||
<Text size="tiny" color={isExpired ? 'danger' : 'muted'} fontWeight="medium">
|
||||
{#if isExpired}
|
||||
{$t('expired')}
|
||||
{:else if expiresAt}
|
||||
{$t('expires_date', { values: { date: getCountDownExpirationDate(expiresAt, now) } })}
|
||||
{:else}
|
||||
{$t('expires_date', { values: { date: '∞' } })}
|
||||
{/if}
|
||||
</Text>
|
||||
|
||||
<Text size="large" color="primary" class="flex place-items-center gap-2 break-all" fontWeight="medium">
|
||||
{#if sharedLink.type === SharedLinkType.Album}
|
||||
{sharedLink.album?.albumName}
|
||||
{:else if sharedLink.type === SharedLinkType.Individual}
|
||||
{$t('individual_share')}
|
||||
{/if}
|
||||
</Text>
|
||||
|
||||
{#if sharedLink.description}
|
||||
<Text size="small" class="line-clamp-1">{sharedLink.description}</Text>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
{#each capabilities as capability, index (index)}
|
||||
<Text size="small" color="primary" fontWeight="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">
|
||||
<div class="sm:flex hidden">
|
||||
<ActionButton action={Edit} />
|
||||
<ActionButton action={Copy} />
|
||||
<ActionButton action={Delete} />
|
||||
</div>
|
||||
|
||||
<div class="sm:hidden">
|
||||
<ContextMenuButton
|
||||
aria-label={$t('shared_link_options')}
|
||||
position="top-right"
|
||||
items={[Edit, Copy, MenuItemType.Divider, Delete]}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,62 +0,0 @@
|
||||
import ShareCover from '$lib/components/sharedlinks-page/covers/share-cover.svelte';
|
||||
import { getAssetMediaUrl } from '$lib/utils';
|
||||
import { albumFactory } from '@test-data/factories/album-factory';
|
||||
import { assetFactory } from '@test-data/factories/asset-factory';
|
||||
import { sharedLinkFactory } from '@test-data/factories/shared-link-factory';
|
||||
import { render, screen } from '@testing-library/svelte';
|
||||
|
||||
vi.mock('$lib/utils');
|
||||
|
||||
describe('ShareCover component', () => {
|
||||
it('renders an image when the shared link is an album', () => {
|
||||
const component = render(ShareCover, {
|
||||
sharedLink: sharedLinkFactory.build({ album: albumFactory.build({ albumName: '123' }) }),
|
||||
preload: false,
|
||||
class: 'text',
|
||||
});
|
||||
const img = component.getByTestId('album-image') as HTMLImageElement;
|
||||
expect(img.alt).toBe('123');
|
||||
expect(img.getAttribute('loading')).toBe('lazy');
|
||||
expect(img.className).toBe('size-full rounded-xl object-cover aspect-square text');
|
||||
});
|
||||
|
||||
it('renders an image when the shared link is an individual share', () => {
|
||||
vi.mocked(getAssetMediaUrl).mockReturnValue('/asdf');
|
||||
const component = render(ShareCover, {
|
||||
sharedLink: sharedLinkFactory.build({ assets: [assetFactory.build({ id: 'someId' })] }),
|
||||
preload: false,
|
||||
class: 'text',
|
||||
});
|
||||
const img = component.getByTestId('album-image') as HTMLImageElement;
|
||||
expect(img.alt).toBe('individual_share');
|
||||
expect(img.getAttribute('loading')).toBe('lazy');
|
||||
expect(img.className).toBe('size-full rounded-xl object-cover aspect-square text');
|
||||
expect(img.getAttribute('src')).toBe('/asdf');
|
||||
expect(getAssetMediaUrl).toHaveBeenCalledWith({ id: 'someId' });
|
||||
});
|
||||
|
||||
it('renders an image when the shared link has no album or assets', () => {
|
||||
const component = render(ShareCover, {
|
||||
sharedLink: sharedLinkFactory.build(),
|
||||
preload: false,
|
||||
class: 'text',
|
||||
});
|
||||
const img = component.getByTestId('album-image') as HTMLImageElement;
|
||||
expect(img.alt).toBe('unnamed_share');
|
||||
expect(img.getAttribute('loading')).toBe('lazy');
|
||||
expect(img.className).toBe('size-full rounded-xl object-cover aspect-square text');
|
||||
});
|
||||
|
||||
it.skip('renders fallback image when asset is not resized', () => {
|
||||
const sharedLink = sharedLinkFactory.build({ assets: [assetFactory.build()] });
|
||||
render(ShareCover, {
|
||||
sharedLink,
|
||||
preload: false,
|
||||
});
|
||||
|
||||
// TODO emit image error event and check if fallback image is rendered
|
||||
|
||||
const img = screen.getByTestId<HTMLImageElement>('album-image');
|
||||
expect(img.alt).toBe('unnamed_share');
|
||||
});
|
||||
});
|
||||
@@ -1,31 +0,0 @@
|
||||
<script lang="ts">
|
||||
import AlbumCover from '$lib/components/album-page/album-cover.svelte';
|
||||
import AssetCover from '$lib/components/sharedlinks-page/covers/asset-cover.svelte';
|
||||
import NoCover from '$lib/components/sharedlinks-page/covers/no-cover.svelte';
|
||||
import { getAssetMediaUrl } from '$lib/utils';
|
||||
import type { SharedLinkResponseDto } from '@immich/sdk';
|
||||
import { t } from 'svelte-i18n';
|
||||
|
||||
interface Props {
|
||||
sharedLink: SharedLinkResponseDto;
|
||||
preload?: boolean;
|
||||
class?: string;
|
||||
}
|
||||
|
||||
let { sharedLink, preload = false, class: className = '' }: Props = $props();
|
||||
</script>
|
||||
|
||||
<div class="relative shrink-0 size-22">
|
||||
{#if sharedLink?.album}
|
||||
<AlbumCover album={sharedLink.album} class={className} {preload} />
|
||||
{:else if sharedLink.assets[0]}
|
||||
<AssetCover
|
||||
alt={$t('individual_share')}
|
||||
class={className}
|
||||
{preload}
|
||||
src={getAssetMediaUrl({ id: sharedLink.assets[0].id })}
|
||||
/>
|
||||
{:else}
|
||||
<NoCover alt={$t('unnamed_share')} class={className} {preload} />
|
||||
{/if}
|
||||
</div>
|
||||
@@ -1,44 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { assetMultiSelectManager } from '$lib/managers/asset-multi-select-manager.svelte';
|
||||
import type { OnRestore } from '$lib/utils/actions';
|
||||
import { handleError } from '$lib/utils/handle-error';
|
||||
import { restoreAssets } from '@immich/sdk';
|
||||
import { Button, toastManager } from '@immich/ui';
|
||||
import { mdiHistory } from '@mdi/js';
|
||||
import { t } from 'svelte-i18n';
|
||||
|
||||
interface Props {
|
||||
onRestore: OnRestore | undefined;
|
||||
}
|
||||
|
||||
let { onRestore }: Props = $props();
|
||||
|
||||
let loading = $state(false);
|
||||
|
||||
const handleRestore = async () => {
|
||||
loading = true;
|
||||
|
||||
try {
|
||||
const ids = assetMultiSelectManager.assets.map((a) => a.id);
|
||||
await restoreAssets({ bulkIdsDto: { ids } });
|
||||
onRestore?.(ids);
|
||||
toastManager.primary($t('assets_restored_count', { values: { count: ids.length } }));
|
||||
assetMultiSelectManager.clear();
|
||||
} catch (error) {
|
||||
handleError(error, $t('errors.unable_to_restore_assets'));
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<Button
|
||||
leadingIcon={mdiHistory}
|
||||
disabled={loading}
|
||||
size="medium"
|
||||
color="secondary"
|
||||
variant="ghost"
|
||||
onclick={handleRestore}
|
||||
>
|
||||
{$t('restore')}
|
||||
</Button>
|
||||
@@ -1,64 +0,0 @@
|
||||
<script lang="ts">
|
||||
import PinCodeResetModal from '$lib/modals/PinCodeResetModal.svelte';
|
||||
import { handleError } from '$lib/utils/handle-error';
|
||||
import { changePinCode } from '@immich/sdk';
|
||||
import { Button, Field, Heading, modalManager, PinInput, Text, toastManager } from '@immich/ui';
|
||||
import { t } from 'svelte-i18n';
|
||||
|
||||
let currentPinCode = $state('');
|
||||
let newPinCode = $state('');
|
||||
let confirmPinCode = $state('');
|
||||
let isLoading = $state(false);
|
||||
let canSubmit = $derived(currentPinCode.length === 6 && confirmPinCode.length === 6 && newPinCode === confirmPinCode);
|
||||
|
||||
const handleSubmit = async (event: Event) => {
|
||||
event.preventDefault();
|
||||
await handleChangePinCode();
|
||||
};
|
||||
|
||||
const handleChangePinCode = async () => {
|
||||
isLoading = true;
|
||||
try {
|
||||
await changePinCode({ pinCodeChangeDto: { pinCode: currentPinCode, newPinCode } });
|
||||
resetForm();
|
||||
toastManager.primary($t('pin_code_changed_successfully'));
|
||||
} catch (error) {
|
||||
handleError(error, $t('unable_to_change_pin_code'));
|
||||
} finally {
|
||||
isLoading = false;
|
||||
}
|
||||
};
|
||||
|
||||
const resetForm = () => {
|
||||
currentPinCode = '';
|
||||
newPinCode = '';
|
||||
confirmPinCode = '';
|
||||
};
|
||||
</script>
|
||||
|
||||
<form autocomplete="off" onsubmit={handleSubmit}>
|
||||
<div class="flex flex-col gap-6 place-items-center place-content-center">
|
||||
<Heading>{$t('change_pin_code')}</Heading>
|
||||
<Field label={$t('current_pin_code')}>
|
||||
<PinInput bind:value={currentPinCode} />
|
||||
</Field>
|
||||
<Field label={$t('new_pin_code')}>
|
||||
<PinInput bind:value={newPinCode} />
|
||||
</Field>
|
||||
<Field label={$t('confirm_new_pin_code')}>
|
||||
<PinInput bind:value={confirmPinCode} />
|
||||
</Field>
|
||||
<button type="button" onclick={() => modalManager.show(PinCodeResetModal, {})}>
|
||||
<Text color="muted" class="underline" size="small">{$t('forgot_pin_code_question')}</Text>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end gap-2 mt-4">
|
||||
<Button shape="round" color="secondary" type="button" size="small" onclick={resetForm}>
|
||||
{$t('clear')}
|
||||
</Button>
|
||||
<Button shape="round" type="submit" size="small" loading={isLoading} disabled={!canSubmit}>
|
||||
{$t('save')}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
@@ -1,33 +0,0 @@
|
||||
<script lang="ts">
|
||||
import OnEvents from '$lib/components/OnEvents.svelte';
|
||||
import PinCodeChangeForm from '$lib/components/user-settings-page/PinCodeChangeForm.svelte';
|
||||
import PinCodeCreateForm from '$lib/components/user-settings-page/PinCodeCreateForm.svelte';
|
||||
import { getAuthStatus } from '@immich/sdk';
|
||||
import { onMount } from 'svelte';
|
||||
import { fade } from 'svelte/transition';
|
||||
|
||||
let hasPinCode = $state(false);
|
||||
|
||||
onMount(async () => {
|
||||
const { pinCode } = await getAuthStatus();
|
||||
hasPinCode = pinCode;
|
||||
});
|
||||
|
||||
const onUserPinCodeReset = () => {
|
||||
hasPinCode = false;
|
||||
};
|
||||
</script>
|
||||
|
||||
<OnEvents {onUserPinCodeReset} />
|
||||
|
||||
<section class="my-4 sm:ms-8">
|
||||
{#if hasPinCode}
|
||||
<div in:fade={{ duration: 200 }}>
|
||||
<PinCodeChangeForm />
|
||||
</div>
|
||||
{:else}
|
||||
<div in:fade={{ duration: 200 }}>
|
||||
<PinCodeCreateForm onCreated={() => (hasPinCode = true)} />
|
||||
</div>
|
||||
{/if}
|
||||
</section>
|
||||
@@ -1,120 +0,0 @@
|
||||
<script lang="ts">
|
||||
import type { ComboBoxOption } from '$lib/components/shared-components/combobox.svelte';
|
||||
import SettingCombobox from '$lib/components/shared-components/settings/setting-combobox.svelte';
|
||||
import SettingsLanguageSelector from '$lib/components/shared-components/settings/settings-language-selector.svelte';
|
||||
import { fallbackLocale, locales } from '$lib/constants';
|
||||
import {
|
||||
alwaysLoadOriginalFile,
|
||||
alwaysLoadOriginalVideo,
|
||||
autoPlayVideo,
|
||||
locale,
|
||||
loopVideo,
|
||||
playVideoThumbnailOnHover,
|
||||
showDeleteModal,
|
||||
} from '$lib/stores/preferences.store';
|
||||
import { createDateFormatter, findLocale } from '$lib/utils';
|
||||
import { Field, Switch, Text, Theme, themeManager, ThemePreference } from '@immich/ui';
|
||||
import { onMount } from 'svelte';
|
||||
import { t } from 'svelte-i18n';
|
||||
import { fade } from 'svelte/transition';
|
||||
|
||||
let time = $state(new Date());
|
||||
|
||||
onMount(() => {
|
||||
const interval = setInterval(() => {
|
||||
time = new Date();
|
||||
}, 1000);
|
||||
|
||||
return () => {
|
||||
clearInterval(interval);
|
||||
};
|
||||
});
|
||||
|
||||
const getAllLanguages = (): ComboBoxOption[] => {
|
||||
return locales
|
||||
.filter(({ code }) => Intl.NumberFormat.supportedLocalesOf(code).length > 0)
|
||||
.map((locale) => ({
|
||||
label: locale.name,
|
||||
value: locale.code,
|
||||
}));
|
||||
};
|
||||
|
||||
const handleToggleLocaleBrowser = () => {
|
||||
$locale = $locale === 'default' ? fallbackLocale.code : 'default';
|
||||
};
|
||||
|
||||
const handleLocaleChange = (newLocale: string | undefined) => {
|
||||
if (newLocale) {
|
||||
$locale = newLocale;
|
||||
}
|
||||
};
|
||||
let editedLocale = $derived(findLocale($locale).code);
|
||||
let selectedDate: string = $derived(createDateFormatter(editedLocale).formatDateTime(time));
|
||||
let selectedOption = $derived({
|
||||
value: findLocale(editedLocale).code || fallbackLocale.code,
|
||||
label: findLocale(editedLocale).name || fallbackLocale.name,
|
||||
});
|
||||
|
||||
const handleToggleSystemTheme = (checked: boolean) => {
|
||||
const current = themeManager.value === Theme.Dark ? ThemePreference.Dark : ThemePreference.Light;
|
||||
themeManager.setPreference(checked ? ThemePreference.System : current);
|
||||
};
|
||||
</script>
|
||||
|
||||
<section class="my-4">
|
||||
<div in:fade={{ duration: 500 }}>
|
||||
<div class="sm:ms-8 flex flex-col gap-6">
|
||||
<Field label={$t('theme_selection')} description={$t('theme_selection_description')}>
|
||||
<Switch
|
||||
checked={themeManager.preference === ThemePreference.System}
|
||||
onCheckedChange={handleToggleSystemTheme}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<SettingsLanguageSelector showSettingDescription />
|
||||
|
||||
<Field label={$t('use_browser_locale')} description={$t('use_browser_locale_description')}>
|
||||
<Switch checked={$locale == 'default'} onCheckedChange={handleToggleLocaleBrowser} />
|
||||
<Text size="small" class="mt-2 font-mono text-sm">{selectedDate}</Text>
|
||||
</Field>
|
||||
|
||||
{#if $locale !== 'default'}
|
||||
<SettingCombobox
|
||||
comboboxPlaceholder={$t('searching_locales')}
|
||||
{selectedOption}
|
||||
options={getAllLanguages()}
|
||||
title={$t('custom_locale')}
|
||||
subtitle={$t('custom_locale_description')}
|
||||
onSelect={(combobox) => handleLocaleChange(combobox?.value)}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<Field label={$t('display_original_photos')} description={$t('display_original_photos_setting_description')}>
|
||||
<Switch bind:checked={$alwaysLoadOriginalFile} />
|
||||
</Field>
|
||||
|
||||
<Field label={$t('video_hover_setting')} description={$t('video_hover_setting_description')}>
|
||||
<Switch bind:checked={$playVideoThumbnailOnHover} />
|
||||
</Field>
|
||||
|
||||
<Field
|
||||
label={$t('setting_video_viewer_auto_play_title')}
|
||||
description={$t('setting_video_viewer_auto_play_subtitle')}
|
||||
>
|
||||
<Switch bind:checked={$autoPlayVideo} />
|
||||
</Field>
|
||||
|
||||
<Field label={$t('loop_videos')} description={$t('loop_videos_description')}>
|
||||
<Switch bind:checked={$loopVideo} />
|
||||
</Field>
|
||||
|
||||
<Field label={$t('play_original_video')} description={$t('play_original_video_setting_description')}>
|
||||
<Switch bind:checked={$alwaysLoadOriginalVideo} />
|
||||
</Field>
|
||||
|
||||
<Field label={$t('permanent_deletion_warning')} description={$t('permanent_deletion_warning_setting_description')}
|
||||
><Switch bind:checked={$showDeleteModal} />
|
||||
</Field>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
@@ -1,54 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { handleChangePassword } from '$lib/services/user.service';
|
||||
import { Button, Field, PasswordInput, Switch } from '@immich/ui';
|
||||
import { t } from 'svelte-i18n';
|
||||
import { fade } from 'svelte/transition';
|
||||
|
||||
let password = $state('');
|
||||
let newPassword = $state('');
|
||||
let confirmPassword = $state('');
|
||||
let invalidateSessions = $state(false);
|
||||
|
||||
const onsubmit = async (event: Event) => {
|
||||
event.preventDefault();
|
||||
const success = await handleChangePassword({ password, newPassword, invalidateSessions });
|
||||
if (success) {
|
||||
password = '';
|
||||
newPassword = '';
|
||||
confirmPassword = '';
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<section class="my-4">
|
||||
<div in:fade={{ duration: 500 }}>
|
||||
<form autocomplete="off" {onsubmit}>
|
||||
<div class="sm:ms-8 flex flex-col gap-4">
|
||||
<Field label={$t('password')} required>
|
||||
<PasswordInput bind:value={password} autocomplete="current-password" />
|
||||
</Field>
|
||||
|
||||
<Field label={$t('new_password')} required>
|
||||
<PasswordInput bind:value={newPassword} autocomplete="new-password" />
|
||||
</Field>
|
||||
|
||||
<Field label={$t('confirm_password')} required>
|
||||
<PasswordInput bind:value={confirmPassword} autocomplete="new-password" />
|
||||
</Field>
|
||||
|
||||
<Field label={$t('log_out_all_devices')} description={$t('change_password_form_log_out_description')} required>
|
||||
<Switch bind:checked={invalidateSessions} />
|
||||
</Field>
|
||||
|
||||
<div class="flex justify-end">
|
||||
<Button
|
||||
shape="round"
|
||||
type="submit"
|
||||
size="small"
|
||||
disabled={!(password && newPassword && newPassword === confirmPassword)}>{$t('save')}</Button
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</section>
|
||||
@@ -1,89 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { handleError } from '$lib/utils/handle-error';
|
||||
import { deleteAllSessions, deleteSession, getSessions, type SessionResponseDto } from '@immich/sdk';
|
||||
import { Button, modalManager, Text, toastManager } from '@immich/ui';
|
||||
import { t } from 'svelte-i18n';
|
||||
import { fade } from 'svelte/transition';
|
||||
import DeviceCard from './device-card.svelte';
|
||||
|
||||
interface Props {
|
||||
devices: SessionResponseDto[];
|
||||
}
|
||||
|
||||
let { devices = $bindable() }: Props = $props();
|
||||
|
||||
const refresh = () => getSessions().then((_devices) => (devices = _devices));
|
||||
|
||||
let currentSession = $derived(devices.find((device) => device.current));
|
||||
let otherSessions = $derived(devices.filter((device) => !device.current));
|
||||
|
||||
const handleDelete = async (device: SessionResponseDto) => {
|
||||
const isConfirmed = await modalManager.showDialog({ prompt: $t('logout_this_device_confirmation') });
|
||||
if (!isConfirmed) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await deleteSession({ id: device.id });
|
||||
toastManager.primary($t('logged_out_device'));
|
||||
} catch (error) {
|
||||
handleError(error, $t('errors.unable_to_log_out_device'));
|
||||
} finally {
|
||||
await refresh();
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteAll = async () => {
|
||||
const isConfirmed = await modalManager.showDialog({ prompt: $t('logout_all_device_confirmation') });
|
||||
if (!isConfirmed) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await deleteAllSessions();
|
||||
toastManager.primary($t('logged_out_all_devices'));
|
||||
} catch (error) {
|
||||
handleError(error, $t('errors.unable_to_log_out_all_devices'));
|
||||
} finally {
|
||||
await refresh();
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<section class="my-4">
|
||||
<div in:fade={{ duration: 500 }}>
|
||||
<div class="sm:ms-8 flex flex-col gap-4">
|
||||
{#if currentSession}
|
||||
<div class="mb-6">
|
||||
<Text class="mb-2" fontWeight="medium" size="tiny" color="primary">
|
||||
{$t('current_device')}
|
||||
</Text>
|
||||
<DeviceCard session={currentSession} />
|
||||
</div>
|
||||
{/if}
|
||||
{#if otherSessions.length > 0}
|
||||
<div class="mb-6">
|
||||
<Text class="mb-2" fontWeight="medium" size="tiny" color="primary">
|
||||
{$t('other_devices')}
|
||||
</Text>
|
||||
{#each otherSessions as session, index (session.id)}
|
||||
<DeviceCard {session} onDelete={() => handleDelete(session)} />
|
||||
{#if index !== otherSessions.length - 1}
|
||||
<hr class="my-3" />
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<div class="my-3">
|
||||
<hr />
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end">
|
||||
<Button shape="round" color="danger" size="small" onclick={handleDeleteAll}
|
||||
>{$t('log_out_all_devices')}</Button
|
||||
>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
@@ -1,61 +0,0 @@
|
||||
<script lang="ts">
|
||||
import SettingInputField from '$lib/components/shared-components/settings/setting-input-field.svelte';
|
||||
import SettingSwitch from '$lib/components/shared-components/settings/setting-switch.svelte';
|
||||
import { SettingInputFieldType } from '$lib/constants';
|
||||
import { authManager } from '$lib/managers/auth-manager.svelte';
|
||||
import { ByteUnit, convertFromBytes, convertToBytes } from '$lib/utils/byte-units';
|
||||
import { handleError } from '$lib/utils/handle-error';
|
||||
import { updateMyPreferences } from '@immich/sdk';
|
||||
import { Button, toastManager } from '@immich/ui';
|
||||
import { t } from 'svelte-i18n';
|
||||
import { fade } from 'svelte/transition';
|
||||
|
||||
let archiveSize = $state(convertFromBytes(authManager.preferences.download.archiveSize || 4, ByteUnit.GiB));
|
||||
let includeEmbeddedVideos = $state(authManager.preferences.download.includeEmbeddedVideos || false);
|
||||
|
||||
const handleSave = async () => {
|
||||
try {
|
||||
const response = await updateMyPreferences({
|
||||
userPreferencesUpdateDto: {
|
||||
download: {
|
||||
archiveSize: Math.floor(convertToBytes(archiveSize, ByteUnit.GiB)),
|
||||
includeEmbeddedVideos,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
authManager.setPreferences(response);
|
||||
|
||||
toastManager.primary($t('saved_settings'));
|
||||
} catch (error) {
|
||||
handleError(error, $t('errors.unable_to_update_settings'));
|
||||
}
|
||||
};
|
||||
|
||||
const onsubmit = (event: Event) => {
|
||||
event.preventDefault();
|
||||
};
|
||||
</script>
|
||||
|
||||
<section class="my-4">
|
||||
<div in:fade={{ duration: 500 }}>
|
||||
<form autocomplete="off" {onsubmit}>
|
||||
<div class="sm:ms-8 flex flex-col gap-4">
|
||||
<SettingInputField
|
||||
inputType={SettingInputFieldType.NUMBER}
|
||||
label={$t('archive_size')}
|
||||
description={$t('archive_size_description')}
|
||||
bind:value={archiveSize}
|
||||
/>
|
||||
<SettingSwitch
|
||||
title={$t('download_include_embedded_motion_videos')}
|
||||
subtitle={$t('download_include_embedded_motion_videos_description')}
|
||||
bind:checked={includeEmbeddedVideos}
|
||||
></SettingSwitch>
|
||||
<div class="flex justify-end">
|
||||
<Button shape="round" type="submit" size="small" onclick={() => handleSave()}>{$t('save')}</Button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</section>
|
||||
@@ -1,174 +0,0 @@
|
||||
<script lang="ts">
|
||||
import SettingAccordion from '$lib/components/shared-components/settings/setting-accordion.svelte';
|
||||
import { authManager } from '$lib/managers/auth-manager.svelte';
|
||||
import { handleError } from '$lib/utils/handle-error';
|
||||
import { AssetOrder, updateMyPreferences } from '@immich/sdk';
|
||||
import { Button, Field, NumberInput, Select, Switch, toastManager } from '@immich/ui';
|
||||
import { t } from 'svelte-i18n';
|
||||
import { fade } from 'svelte/transition';
|
||||
|
||||
// Albums
|
||||
let defaultAssetOrder = $state(authManager.preferences.albums?.defaultAssetOrder ?? AssetOrder.Desc);
|
||||
|
||||
// Folders
|
||||
let foldersEnabled = $state(authManager.preferences.folders?.enabled ?? false);
|
||||
let foldersSidebar = $state(authManager.preferences.folders?.sidebarWeb ?? false);
|
||||
|
||||
// Memories
|
||||
let memoriesEnabled = $state(authManager.preferences.memories?.enabled ?? true);
|
||||
let memoriesDuration = $state(authManager.preferences.memories?.duration ?? 5);
|
||||
|
||||
// People
|
||||
let peopleEnabled = $state(authManager.preferences.people?.enabled ?? false);
|
||||
let peopleSidebar = $state(authManager.preferences.people?.sidebarWeb ?? false);
|
||||
|
||||
// Ratings
|
||||
let ratingsEnabled = $state(authManager.preferences.ratings?.enabled ?? false);
|
||||
|
||||
// Shared links
|
||||
let sharedLinksEnabled = $state(authManager.preferences.sharedLinks?.enabled ?? true);
|
||||
let sharedLinkSidebar = $state(authManager.preferences.sharedLinks?.sidebarWeb ?? false);
|
||||
|
||||
// Tags
|
||||
let tagsEnabled = $state(authManager.preferences.tags?.enabled ?? false);
|
||||
let tagsSidebar = $state(authManager.preferences.tags?.sidebarWeb ?? false);
|
||||
|
||||
// Cast
|
||||
let gCastEnabled = $state(authManager.preferences.cast?.gCastEnabled ?? false);
|
||||
|
||||
const handleSave = async () => {
|
||||
try {
|
||||
const response = await updateMyPreferences({
|
||||
userPreferencesUpdateDto: {
|
||||
albums: { defaultAssetOrder },
|
||||
folders: { enabled: foldersEnabled, sidebarWeb: foldersSidebar },
|
||||
memories: { enabled: memoriesEnabled, duration: memoriesDuration },
|
||||
people: { enabled: peopleEnabled, sidebarWeb: peopleSidebar },
|
||||
ratings: { enabled: ratingsEnabled },
|
||||
sharedLinks: { enabled: sharedLinksEnabled, sidebarWeb: sharedLinkSidebar },
|
||||
tags: { enabled: tagsEnabled, sidebarWeb: tagsSidebar },
|
||||
cast: { gCastEnabled },
|
||||
},
|
||||
});
|
||||
|
||||
authManager.setPreferences(response);
|
||||
toastManager.primary($t('saved_settings'));
|
||||
} catch (error) {
|
||||
handleError(error, $t('errors.unable_to_update_settings'));
|
||||
}
|
||||
};
|
||||
|
||||
const onsubmit = (event: Event) => {
|
||||
event.preventDefault();
|
||||
};
|
||||
</script>
|
||||
|
||||
<section class="my-4">
|
||||
<div in:fade={{ duration: 500 }}>
|
||||
<form autocomplete="off" {onsubmit}>
|
||||
<div class="sm:ms-4 md:ms-8 flex flex-col">
|
||||
<SettingAccordion key="albums" title={$t('albums')} subtitle={$t('albums_feature_description')}>
|
||||
<div class="sm:ms-4 mt-4 flex flex-col gap-4">
|
||||
<Field label={$t('albums_default_sort_order')} description={$t('albums_default_sort_order_description')}>
|
||||
<Select
|
||||
options={[
|
||||
{ label: $t('oldest_first'), value: AssetOrder.Asc },
|
||||
{ label: $t('newest_first'), value: AssetOrder.Desc },
|
||||
]}
|
||||
bind:value={defaultAssetOrder}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
</SettingAccordion>
|
||||
|
||||
<SettingAccordion key="folders" title={$t('folders')} subtitle={$t('folders_feature_description')}>
|
||||
<div class="sm:ms-4 mt-4 flex flex-col gap-4">
|
||||
<Field label={$t('enable')}>
|
||||
<Switch bind:checked={foldersEnabled} />
|
||||
</Field>
|
||||
|
||||
{#if foldersEnabled}
|
||||
<Field label={$t('sidebar')} description={$t('sidebar_display_description')}>
|
||||
<Switch bind:checked={foldersSidebar} />
|
||||
</Field>
|
||||
{/if}
|
||||
</div>
|
||||
</SettingAccordion>
|
||||
|
||||
<SettingAccordion key="memories" title={$t('time_based_memories')} subtitle={$t('photos_from_previous_years')}>
|
||||
<div class="sm:ms-4 mt-4 flex flex-col gap-4">
|
||||
<Field label={$t('enable')}>
|
||||
<Switch bind:checked={memoriesEnabled} />
|
||||
</Field>
|
||||
|
||||
<Field label={$t('duration')} description={$t('time_based_memories_duration')}>
|
||||
<NumberInput bind:value={memoriesDuration} />
|
||||
</Field>
|
||||
</div>
|
||||
</SettingAccordion>
|
||||
|
||||
<SettingAccordion key="people" title={$t('people')} subtitle={$t('people_feature_description')}>
|
||||
<div class="sm:ms-4 mt-4 flex flex-col gap-4">
|
||||
<Field label={$t('enable')}>
|
||||
<Switch bind:checked={peopleEnabled} />
|
||||
</Field>
|
||||
|
||||
{#if peopleEnabled}
|
||||
<Field label={$t('sidebar')} description={$t('sidebar_display_description')}>
|
||||
<Switch bind:checked={peopleSidebar} />
|
||||
</Field>
|
||||
{/if}
|
||||
</div>
|
||||
</SettingAccordion>
|
||||
|
||||
<SettingAccordion key="rating" title={$t('rating')} subtitle={$t('rating_description')}>
|
||||
<div class="sm:ms-4 mt-4 flex flex-col gap-4">
|
||||
<Field label={$t('enable')}>
|
||||
<Switch bind:checked={ratingsEnabled} />
|
||||
</Field>
|
||||
</div>
|
||||
</SettingAccordion>
|
||||
|
||||
<SettingAccordion key="shared-links" title={$t('shared_links')} subtitle={$t('shared_links_description')}>
|
||||
<div class="sm:ms-4 mt-4 flex flex-col gap-4">
|
||||
<Field label={$t('enable')}>
|
||||
<Switch bind:checked={sharedLinksEnabled} />
|
||||
</Field>
|
||||
|
||||
{#if sharedLinksEnabled}
|
||||
<Field label={$t('sidebar')} description={$t('sidebar_display_description')}>
|
||||
<Switch bind:checked={sharedLinkSidebar} />
|
||||
</Field>
|
||||
{/if}
|
||||
</div>
|
||||
</SettingAccordion>
|
||||
|
||||
<SettingAccordion key="tags" title={$t('tags')} subtitle={$t('tag_feature_description')}>
|
||||
<div class="sm:ms-4 mt-4 flex flex-col gap-4">
|
||||
<Field label={$t('enable')}>
|
||||
<Switch bind:checked={tagsEnabled} />
|
||||
</Field>
|
||||
|
||||
{#if tagsEnabled}
|
||||
<Field label={$t('sidebar')} description={$t('sidebar_display_description')}>
|
||||
<Switch bind:checked={tagsSidebar} />
|
||||
</Field>
|
||||
{/if}
|
||||
</div>
|
||||
</SettingAccordion>
|
||||
|
||||
<SettingAccordion key="cast" title={$t('cast')} subtitle={$t('cast_description')}>
|
||||
<div class="sm:ms-4 mt-4 flex flex-col gap-4">
|
||||
<Field label={$t('gcast_enabled')} description={$t('gcast_enabled_description')}>
|
||||
<Switch bind:checked={gCastEnabled} />
|
||||
</Field>
|
||||
</div>
|
||||
</SettingAccordion>
|
||||
|
||||
<div class="flex justify-end mt-4">
|
||||
<Button shape="round" type="submit" size="small" onclick={() => handleSave()}>{$t('save')}</Button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</section>
|
||||
@@ -1,61 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { authManager } from '$lib/managers/auth-manager.svelte';
|
||||
import { handleError } from '$lib/utils/handle-error';
|
||||
import { updateMyPreferences } from '@immich/sdk';
|
||||
import { Button, Field, Switch, toastManager } from '@immich/ui';
|
||||
import { t } from 'svelte-i18n';
|
||||
import { fade } from 'svelte/transition';
|
||||
|
||||
let emailNotificationsEnabled = $state(authManager.preferences.emailNotifications?.enabled ?? true);
|
||||
let albumInviteNotificationEnabled = $state(authManager.preferences.emailNotifications?.albumInvite ?? true);
|
||||
let albumUpdateNotificationEnabled = $state(authManager.preferences.emailNotifications?.albumUpdate ?? true);
|
||||
|
||||
const handleSave = async () => {
|
||||
try {
|
||||
const response = await updateMyPreferences({
|
||||
userPreferencesUpdateDto: {
|
||||
emailNotifications: {
|
||||
enabled: emailNotificationsEnabled,
|
||||
albumInvite: emailNotificationsEnabled && albumInviteNotificationEnabled,
|
||||
albumUpdate: emailNotificationsEnabled && albumUpdateNotificationEnabled,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
authManager.setPreferences(response);
|
||||
toastManager.primary($t('saved_settings'));
|
||||
} catch (error) {
|
||||
handleError(error, $t('errors.unable_to_update_settings'));
|
||||
}
|
||||
};
|
||||
|
||||
const onsubmit = (event: Event) => {
|
||||
event.preventDefault();
|
||||
};
|
||||
|
||||
const disabled = $derived(!emailNotificationsEnabled);
|
||||
</script>
|
||||
|
||||
<section class="my-4">
|
||||
<div in:fade={{ duration: 500 }}>
|
||||
<form autocomplete="off" {onsubmit}>
|
||||
<div class="sm:ms-8 flex flex-col gap-6">
|
||||
<Field label={$t('enable')} description={$t('notification_toggle_setting_description')}>
|
||||
<Switch bind:checked={emailNotificationsEnabled} />
|
||||
</Field>
|
||||
|
||||
<Field label={$t('album_added')} description={$t('album_added_notification_setting_description')} {disabled}>
|
||||
<Switch bind:checked={albumInviteNotificationEnabled} />
|
||||
</Field>
|
||||
|
||||
<Field label={$t('album_updated')} description={$t('album_updated_setting_description')} {disabled}>
|
||||
<Switch bind:checked={albumUpdateNotificationEnabled} />
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end mt-4">
|
||||
<Button shape="round" type="submit" size="small" onclick={() => handleSave()}>{$t('save')}</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</section>
|
||||
@@ -1,60 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { authManager } from '$lib/managers/auth-manager.svelte';
|
||||
import { featureFlagsManager } from '$lib/managers/feature-flags-manager.svelte';
|
||||
import { oauth } from '$lib/utils';
|
||||
import { handleError } from '$lib/utils/handle-error';
|
||||
import { Button, LoadingSpinner, toastManager } from '@immich/ui';
|
||||
import { onMount } from 'svelte';
|
||||
import { t } from 'svelte-i18n';
|
||||
import { fade } from 'svelte/transition';
|
||||
|
||||
let loading = $state(true);
|
||||
|
||||
onMount(async () => {
|
||||
if (oauth.isCallback(globalThis.location)) {
|
||||
try {
|
||||
loading = true;
|
||||
const response = await oauth.link(globalThis.location);
|
||||
authManager.setUser(response);
|
||||
toastManager.primary($t('linked_oauth_account'));
|
||||
} catch (error) {
|
||||
handleError(error, $t('errors.unable_to_link_oauth_account'));
|
||||
} finally {
|
||||
await goto('?open=oauth');
|
||||
}
|
||||
}
|
||||
|
||||
loading = false;
|
||||
});
|
||||
|
||||
const handleUnlink = async () => {
|
||||
try {
|
||||
const response = await oauth.unlink();
|
||||
authManager.setUser(response);
|
||||
toastManager.primary($t('unlinked_oauth_account'));
|
||||
} catch (error) {
|
||||
handleError(error, $t('errors.unable_to_unlink_account'));
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<section class="my-4">
|
||||
<div in:fade={{ duration: 500 }}>
|
||||
<div class="sm:ms-8 flex justify-end">
|
||||
{#if loading}
|
||||
<div class="flex place-content-center place-items-center">
|
||||
<LoadingSpinner />
|
||||
</div>
|
||||
{:else if featureFlagsManager.value.oauth}
|
||||
{#if authManager.user.oauthId}
|
||||
<Button shape="round" size="small" onclick={() => handleUnlink()}>{$t('unlink_oauth')}</Button>
|
||||
{:else}
|
||||
<Button shape="round" size="small" onclick={() => oauth.authorize(globalThis.location)}
|
||||
>{$t('link_to_oauth')}</Button
|
||||
>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
@@ -1,194 +0,0 @@
|
||||
<script lang="ts">
|
||||
import SettingSwitch from '$lib/components/shared-components/settings/setting-switch.svelte';
|
||||
import UserAvatar from '$lib/components/shared-components/user-avatar.svelte';
|
||||
import PartnerSelectionModal from '$lib/modals/PartnerSelectionModal.svelte';
|
||||
import { handleError } from '$lib/utils/handle-error';
|
||||
import {
|
||||
createPartner,
|
||||
getPartners,
|
||||
PartnerDirection,
|
||||
removePartner,
|
||||
updatePartner,
|
||||
type PartnerResponseDto,
|
||||
type UserResponseDto,
|
||||
} from '@immich/sdk';
|
||||
import { Button, Icon, IconButton, modalManager, Text } from '@immich/ui';
|
||||
import { mdiCheck, mdiClose } from '@mdi/js';
|
||||
import { onMount } from 'svelte';
|
||||
import { t } from 'svelte-i18n';
|
||||
|
||||
interface PartnerSharing {
|
||||
user: UserResponseDto;
|
||||
sharedByMe: boolean;
|
||||
sharedWithMe: boolean;
|
||||
inTimeline: boolean;
|
||||
}
|
||||
|
||||
let partners: Array<PartnerSharing> = $state([]);
|
||||
|
||||
onMount(async () => {
|
||||
await refreshPartners();
|
||||
});
|
||||
|
||||
const refreshPartners = async () => {
|
||||
partners = [];
|
||||
|
||||
const [sharedBy, sharedWith] = await Promise.all([
|
||||
getPartners({ direction: PartnerDirection.SharedBy }),
|
||||
getPartners({ direction: PartnerDirection.SharedWith }),
|
||||
]);
|
||||
|
||||
for (const candidate of sharedBy) {
|
||||
partners = [
|
||||
...partners,
|
||||
{
|
||||
user: candidate,
|
||||
sharedByMe: true,
|
||||
sharedWithMe: false,
|
||||
inTimeline: candidate.inTimeline ?? false,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
for (const candidate of sharedWith) {
|
||||
const existIndex = partners.findIndex((p) => candidate.id === p.user.id);
|
||||
|
||||
if (existIndex === -1) {
|
||||
partners = [
|
||||
...partners,
|
||||
{
|
||||
user: candidate,
|
||||
sharedByMe: false,
|
||||
sharedWithMe: true,
|
||||
inTimeline: candidate.inTimeline ?? false,
|
||||
},
|
||||
];
|
||||
} else {
|
||||
partners[existIndex].sharedWithMe = true;
|
||||
partners[existIndex].inTimeline = candidate.inTimeline ?? false;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemovePartner = async (partner: PartnerResponseDto) => {
|
||||
const isConfirmed = await modalManager.showDialog({
|
||||
title: $t('stop_photo_sharing'),
|
||||
prompt: $t('stop_photo_sharing_description', { values: { partner: partner.name } }),
|
||||
});
|
||||
|
||||
if (!isConfirmed) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await removePartner({ id: partner.id });
|
||||
await refreshPartners();
|
||||
} catch (error) {
|
||||
handleError(error, $t('errors.unable_to_remove_partner'));
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreatePartners = async () => {
|
||||
const users = await modalManager.show(PartnerSelectionModal, {});
|
||||
|
||||
if (!users) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
for (const user of users) {
|
||||
await createPartner({ partnerCreateDto: { sharedWithId: user.id } });
|
||||
}
|
||||
|
||||
await refreshPartners();
|
||||
} catch (error) {
|
||||
handleError(error, $t('errors.unable_to_add_partners'));
|
||||
}
|
||||
};
|
||||
|
||||
const handleShowOnTimelineChanged = async (partner: PartnerSharing, inTimeline: boolean) => {
|
||||
try {
|
||||
await updatePartner({ id: partner.user.id, partnerUpdateDto: { inTimeline } });
|
||||
|
||||
partner.inTimeline = inTimeline;
|
||||
} catch (error) {
|
||||
handleError(error, $t('errors.unable_to_update_timeline_display_status'));
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<section class="my-4">
|
||||
{#if partners.length > 0}
|
||||
{#each partners as partner (partner.user.id)}
|
||||
<div class="rounded-2xl border border-gray-200 dark:border-gray-800 mt-6 bg-slate-50 dark:bg-gray-900 p-5">
|
||||
<div class="flex gap-4 rounded-lg pb-4 transition-all justify-between">
|
||||
<div class="flex gap-4">
|
||||
<UserAvatar user={partner.user} size="md" />
|
||||
<div class="text-start">
|
||||
<p class="text-immich-fg dark:text-immich-dark-fg">
|
||||
{partner.user.name}
|
||||
</p>
|
||||
<p class="text-sm text-immich-fg/75 dark:text-immich-dark-fg/75">
|
||||
{partner.user.email}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if partner.sharedByMe}
|
||||
<IconButton
|
||||
shape="round"
|
||||
color="secondary"
|
||||
variant="ghost"
|
||||
onclick={() => handleRemovePartner(partner.user)}
|
||||
icon={mdiClose}
|
||||
size="small"
|
||||
aria-label={$t('stop_sharing_photos_with_user')}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="dark:text-gray-200 text-immich-dark-gray">
|
||||
<!-- I am sharing my assets with this user -->
|
||||
{#if partner.sharedByMe}
|
||||
<hr class="my-4 border border-gray-200 dark:border-gray-700" />
|
||||
<Text class="my-4" size="small" fontWeight="medium">
|
||||
{$t('shared_with_partner', { values: { partner: partner.user.name } })}
|
||||
</Text>
|
||||
<Text size="tiny" fontWeight="medium"
|
||||
>{$t('partner_can_access', { values: { partner: partner.user.name } })}</Text
|
||||
>
|
||||
<ul class="text-sm">
|
||||
<li class="flex gap-2 place-items-center py-1 mt-2">
|
||||
<Icon icon={mdiCheck} />
|
||||
{$t('partner_can_access_assets')}
|
||||
</li>
|
||||
<li class="flex gap-2 place-items-center py-1">
|
||||
<Icon icon={mdiCheck} />
|
||||
{$t('partner_can_access_location')}
|
||||
</li>
|
||||
</ul>
|
||||
{/if}
|
||||
|
||||
<!-- this user is sharing assets with me -->
|
||||
{#if partner.sharedWithMe}
|
||||
<hr class="my-4 border border-gray-200 dark:border-gray-700" />
|
||||
<Text class="my-4" size="small" fontWeight="medium">
|
||||
{$t('shared_from_partner', { values: { partner: partner.user.name } })}
|
||||
</Text>
|
||||
|
||||
<SettingSwitch
|
||||
title={$t('show_in_timeline')}
|
||||
subtitle={$t('show_in_timeline_setting_description')}
|
||||
bind:checked={partner.inTimeline}
|
||||
onToggle={(isChecked) => handleShowOnTimelineChanged(partner, isChecked)}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
{/if}
|
||||
|
||||
<div class="flex justify-end mt-5">
|
||||
<Button shape="round" size="small" onclick={() => handleCreatePartners()}>{$t('add_partner')}</Button>
|
||||
</div>
|
||||
</section>
|
||||
@@ -1,75 +0,0 @@
|
||||
<script lang="ts">
|
||||
import OnEvents from '$lib/components/OnEvents.svelte';
|
||||
import TableButton from '$lib/components/TableButton.svelte';
|
||||
import { dateFormats } from '$lib/constants';
|
||||
import { getApiKeyActions, getApiKeysActions } from '$lib/services/api-key.service';
|
||||
import { locale } from '$lib/stores/preferences.store';
|
||||
import { getApiKeys, type ApiKeyResponseDto } from '@immich/sdk';
|
||||
import { Button, Table, TableBody, TableCell, TableHeader, TableHeading, TableRow, Text } from '@immich/ui';
|
||||
import { t } from 'svelte-i18n';
|
||||
import { fade } from 'svelte/transition';
|
||||
|
||||
type Props = {
|
||||
keys: ApiKeyResponseDto[];
|
||||
};
|
||||
|
||||
let { keys = $bindable() }: Props = $props();
|
||||
|
||||
const onApiKeyCreate = async () => {
|
||||
keys = await getApiKeys();
|
||||
};
|
||||
|
||||
const onApiKeyUpdate = (update: ApiKeyResponseDto) => {
|
||||
keys = keys.map((key) => (key.id === update.id ? update : key));
|
||||
};
|
||||
|
||||
const onApiKeyDelete = ({ id }: ApiKeyResponseDto) => {
|
||||
keys = keys.filter((key) => key.id !== id);
|
||||
};
|
||||
|
||||
const { Create } = $derived(getApiKeysActions($t));
|
||||
</script>
|
||||
|
||||
<OnEvents {onApiKeyCreate} {onApiKeyUpdate} {onApiKeyDelete} />
|
||||
|
||||
<section class="my-4">
|
||||
<div class="sm:ms-8 flex flex-col gap-2" in:fade={{ duration: 500 }}>
|
||||
<div class="mb-2 flex justify-end">
|
||||
<Button leadingIcon={Create.icon} shape="round" size="small" onclick={() => Create.onAction(Create)}>
|
||||
{Create.title}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{#if keys.length > 0}
|
||||
<Table class="mt-4" striped spacing="small" size="small">
|
||||
<TableHeader>
|
||||
<TableHeading>{$t('name')}</TableHeading>
|
||||
<TableHeading>{$t('permission')}</TableHeading>
|
||||
<TableHeading>{$t('created')}</TableHeading>
|
||||
<TableHeading>{$t('action')}</TableHeading>
|
||||
</TableHeader>
|
||||
|
||||
<TableBody>
|
||||
{#each keys as key (key.id)}
|
||||
{@const { Update, Delete } = getApiKeyActions($t, key)}
|
||||
<TableRow>
|
||||
<TableCell>{key.name}</TableCell>
|
||||
<TableCell>
|
||||
<Text
|
||||
class="font-mono overflow-hidden line-clamp-3"
|
||||
size="small"
|
||||
title={JSON.stringify(key.permissions, null, 2)}>{key.permissions}</Text
|
||||
>
|
||||
</TableCell>
|
||||
<TableCell>{new Date(key.createdAt).toLocaleDateString($locale, dateFormats.settings)}</TableCell>
|
||||
<TableCell class="flex flex-row flex-wrap justify-center gap-x-2 gap-y-1">
|
||||
<TableButton action={Update} size="small" />
|
||||
<TableButton action={Delete} size="small" />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
{/each}
|
||||
</TableBody>
|
||||
</Table>
|
||||
{/if}
|
||||
</div>
|
||||
</section>
|
||||
@@ -1,59 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { authManager } from '$lib/managers/auth-manager.svelte';
|
||||
import { handleError } from '$lib/utils/handle-error';
|
||||
import { updateMyUser } from '@immich/sdk';
|
||||
import { Button, Field, Input, toastManager } from '@immich/ui';
|
||||
import { cloneDeep } from 'lodash-es';
|
||||
import { t } from 'svelte-i18n';
|
||||
import { createBubbler, preventDefault } from 'svelte/legacy';
|
||||
import { fade } from 'svelte/transition';
|
||||
|
||||
let editedUser = $state(cloneDeep(authManager.user));
|
||||
const bubble = createBubbler();
|
||||
|
||||
const handleSaveProfile = async () => {
|
||||
try {
|
||||
const data = await updateMyUser({
|
||||
userUpdateMeDto: {
|
||||
email: editedUser.email,
|
||||
name: editedUser.name,
|
||||
},
|
||||
});
|
||||
|
||||
Object.assign(editedUser, data);
|
||||
authManager.setUser(data);
|
||||
|
||||
toastManager.primary($t('saved_profile'));
|
||||
} catch (error) {
|
||||
handleError(error, $t('errors.unable_to_save_profile'));
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<section class="my-4">
|
||||
<div in:fade={{ duration: 500 }}>
|
||||
<form autocomplete="off" onsubmit={preventDefault(bubble('submit'))}>
|
||||
<div class="sm:ms-8 flex flex-col gap-4">
|
||||
<Field label={$t('user_id')} disabled>
|
||||
<Input bind:value={editedUser.id} />
|
||||
</Field>
|
||||
|
||||
<Field label={$t('email')} required>
|
||||
<Input type="email" bind:value={editedUser.email} />
|
||||
</Field>
|
||||
|
||||
<Field label={$t('name')} required>
|
||||
<Input bind:value={editedUser.name} />
|
||||
</Field>
|
||||
|
||||
<Field label={$t('storage_label')} disabled>
|
||||
<Input value={editedUser.storageLabel || ''} />
|
||||
</Field>
|
||||
|
||||
<div class="flex justify-end">
|
||||
<Button shape="round" type="submit" size="small" onclick={() => handleSaveProfile()}>{$t('save')}</Button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</section>
|
||||
@@ -1,183 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { fade } from 'svelte/transition';
|
||||
|
||||
import PurchaseContent from '$lib/components/shared-components/purchasing/purchase-content.svelte';
|
||||
import SettingSwitch from '$lib/components/shared-components/settings/setting-switch.svelte';
|
||||
import { dateFormats } from '$lib/constants';
|
||||
import { authManager } from '$lib/managers/auth-manager.svelte';
|
||||
import { locale } from '$lib/stores/preferences.store';
|
||||
import { handleError } from '$lib/utils/handle-error';
|
||||
import { setSupportBadgeVisibility } from '$lib/utils/purchase-utils';
|
||||
import {
|
||||
deleteUserLicense as deleteIndividualProductKey,
|
||||
deleteServerLicense as deleteServerProductKey,
|
||||
getAboutInfo,
|
||||
getMyUser,
|
||||
getServerLicense,
|
||||
isHttpError,
|
||||
type LicenseResponseDto,
|
||||
} from '@immich/sdk';
|
||||
import { Button, Icon, modalManager } from '@immich/ui';
|
||||
import { mdiKey } from '@mdi/js';
|
||||
import { onMount } from 'svelte';
|
||||
import { t } from 'svelte-i18n';
|
||||
|
||||
let isServerProduct = $state(false);
|
||||
let serverPurchaseInfo: LicenseResponseDto | null = $state(null);
|
||||
|
||||
const checkPurchaseInfo = async () => {
|
||||
const serverInfo = await getAboutInfo();
|
||||
isServerProduct = serverInfo.licensed;
|
||||
|
||||
const response = await getMyUser();
|
||||
if (response.license) {
|
||||
authManager.setUser(response);
|
||||
}
|
||||
|
||||
if (isServerProduct && authManager.user.isAdmin) {
|
||||
serverPurchaseInfo = await getServerPurchaseInfo();
|
||||
}
|
||||
};
|
||||
|
||||
const getServerPurchaseInfo = async () => {
|
||||
try {
|
||||
return await getServerLicense();
|
||||
} catch (error) {
|
||||
if (isHttpError(error) && error.status === 404) {
|
||||
return null;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
onMount(async () => {
|
||||
if (!authManager.isPurchased) {
|
||||
return;
|
||||
}
|
||||
|
||||
await checkPurchaseInfo();
|
||||
});
|
||||
|
||||
const removeIndividualProductKey = async () => {
|
||||
try {
|
||||
const isConfirmed = await modalManager.showDialog({
|
||||
title: $t('purchase_remove_product_key'),
|
||||
prompt: $t('purchase_remove_product_key_prompt'),
|
||||
confirmText: $t('remove'),
|
||||
});
|
||||
|
||||
if (!isConfirmed) {
|
||||
return;
|
||||
}
|
||||
|
||||
await deleteIndividualProductKey();
|
||||
authManager.isPurchased = false;
|
||||
} catch (error) {
|
||||
handleError(error, $t('errors.failed_to_remove_product_key'));
|
||||
}
|
||||
};
|
||||
|
||||
const removeServerProductKey = async () => {
|
||||
try {
|
||||
const isConfirmed = await modalManager.showDialog({
|
||||
title: $t('purchase_remove_server_product_key'),
|
||||
prompt: $t('purchase_remove_server_product_key_prompt'),
|
||||
confirmText: $t('remove'),
|
||||
});
|
||||
|
||||
if (!isConfirmed) {
|
||||
return;
|
||||
}
|
||||
|
||||
await deleteServerProductKey();
|
||||
authManager.isPurchased = false;
|
||||
} catch (error) {
|
||||
handleError(error, $t('errors.failed_to_remove_product_key'));
|
||||
}
|
||||
};
|
||||
|
||||
const onProductActivated = async () => {
|
||||
authManager.isPurchased = true;
|
||||
await checkPurchaseInfo();
|
||||
};
|
||||
</script>
|
||||
|
||||
<section class="my-4">
|
||||
<div class="sm:ms-8" in:fade={{ duration: 500 }}>
|
||||
{#if authManager.isPurchased}
|
||||
<!-- BADGE TOGGLE -->
|
||||
<div class="mb-4">
|
||||
<SettingSwitch
|
||||
title={$t('show_supporter_badge')}
|
||||
subtitle={$t('show_supporter_badge_description')}
|
||||
bind:checked={authManager.preferences.purchase.showSupportBadge}
|
||||
onToggle={setSupportBadgeVisibility}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- PRODUCT KEY INFO CARD -->
|
||||
{#if isServerProduct}
|
||||
<div
|
||||
class="bg-gray-50 border border-immich-dark-primary/20 dark:bg-immich-dark-primary/15 p-6 pe-12 rounded-xl flex place-content-center gap-4"
|
||||
>
|
||||
<Icon icon={mdiKey} size="56" class="text-primary" />
|
||||
|
||||
<div>
|
||||
<p class="text-primary font-semibold text-lg">
|
||||
{$t('purchase_server_title')}
|
||||
</p>
|
||||
|
||||
{#if authManager.user.isAdmin && serverPurchaseInfo?.activatedAt}
|
||||
<p class="dark:text-white text-sm mt-1 col-start-2">
|
||||
{$t('purchase_activated_time', {
|
||||
values: {
|
||||
date: new Date(serverPurchaseInfo.activatedAt).toLocaleString($locale, dateFormats.settings),
|
||||
},
|
||||
})}
|
||||
</p>
|
||||
{:else}
|
||||
<p class="dark:text-white">{$t('purchase_settings_server_activated')}</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if authManager.user.isAdmin}
|
||||
<div class="text-right mt-4">
|
||||
<Button shape="round" size="small" color="danger" onclick={removeServerProductKey}
|
||||
>{$t('purchase_button_remove_key')}</Button
|
||||
>
|
||||
</div>
|
||||
{/if}
|
||||
{:else}
|
||||
<div
|
||||
class="bg-gray-50 border border-immich-dark-primary/20 dark:bg-immich-dark-primary/15 p-6 pe-12 rounded-xl flex place-content-center gap-4"
|
||||
>
|
||||
<Icon icon={mdiKey} size="56" class="text-primary" />
|
||||
|
||||
<div>
|
||||
<p class="text-primary font-semibold text-lg">
|
||||
{$t('purchase_individual_title')}
|
||||
</p>
|
||||
{#if authManager.user.license?.activatedAt}
|
||||
<p class="dark:text-white text-sm mt-1 col-start-2">
|
||||
{$t('purchase_activated_time', {
|
||||
values: {
|
||||
date: new Date(authManager.user.license?.activatedAt).toLocaleString($locale, dateFormats.settings),
|
||||
},
|
||||
})}
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="text-right mt-4">
|
||||
<Button shape="round" size="small" color="danger" onclick={removeIndividualProductKey}
|
||||
>{$t('purchase_button_remove_key')}</Button
|
||||
>
|
||||
</div>
|
||||
{/if}
|
||||
{:else}
|
||||
<PurchaseContent onActivate={onProductActivated} showTitle={false} />
|
||||
{/if}
|
||||
</div>
|
||||
</section>
|
||||
@@ -1,163 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { page } from '$app/stores';
|
||||
import ChangePinCodeSettings from '$lib/components/user-settings-page/PinCodeSettings.svelte';
|
||||
import DownloadSettings from '$lib/components/user-settings-page/download-settings.svelte';
|
||||
import FeatureSettings from '$lib/components/user-settings-page/feature-settings.svelte';
|
||||
import NotificationsSettings from '$lib/components/user-settings-page/notifications-settings.svelte';
|
||||
import UserPurchaseSettings from '$lib/components/user-settings-page/user-purchase-settings.svelte';
|
||||
import UserUsageStatistic from '$lib/components/user-settings-page/user-usage-statistic.svelte';
|
||||
import { OpenQueryParam, QueryParameter } from '$lib/constants';
|
||||
import { featureFlagsManager } from '$lib/managers/feature-flags-manager.svelte';
|
||||
import { oauth } from '$lib/utils';
|
||||
import { type ApiKeyResponseDto, type SessionResponseDto } from '@immich/sdk';
|
||||
import {
|
||||
mdiAccountGroupOutline,
|
||||
mdiAccountOutline,
|
||||
mdiApi,
|
||||
mdiBellOutline,
|
||||
mdiCogOutline,
|
||||
mdiDevices,
|
||||
mdiDownload,
|
||||
mdiFeatureSearchOutline,
|
||||
mdiFormTextboxPassword,
|
||||
mdiKeyOutline,
|
||||
mdiLockSmart,
|
||||
mdiServerOutline,
|
||||
mdiTwoFactorAuthentication,
|
||||
} from '@mdi/js';
|
||||
import { t } from 'svelte-i18n';
|
||||
import SettingAccordionState from '../shared-components/settings/setting-accordion-state.svelte';
|
||||
import SettingAccordion from '../shared-components/settings/setting-accordion.svelte';
|
||||
import AppSettings from './app-settings.svelte';
|
||||
import ChangePasswordSettings from './change-password-settings.svelte';
|
||||
import DeviceList from './device-list.svelte';
|
||||
import OAuthSettings from './oauth-settings.svelte';
|
||||
import PartnerSettings from './partner-settings.svelte';
|
||||
import UserApiKeyList from './user-api-key-list.svelte';
|
||||
import UserProfileSettings from './user-profile-settings.svelte';
|
||||
|
||||
interface Props {
|
||||
keys?: ApiKeyResponseDto[];
|
||||
sessions?: SessionResponseDto[];
|
||||
}
|
||||
|
||||
let { keys = $bindable([]), sessions = $bindable([]) }: Props = $props();
|
||||
|
||||
let oauthOpen =
|
||||
oauth.isCallback(globalThis.location) ||
|
||||
$page.url.searchParams.get(QueryParameter.OPEN_SETTING) === OpenQueryParam.OAUTH;
|
||||
</script>
|
||||
|
||||
<SettingAccordionState queryParam={QueryParameter.IS_OPEN}>
|
||||
<SettingAccordion
|
||||
icon={mdiCogOutline}
|
||||
key="app-settings"
|
||||
title={$t('app_settings')}
|
||||
subtitle={$t('manage_the_app_settings')}
|
||||
>
|
||||
<AppSettings />
|
||||
</SettingAccordion>
|
||||
|
||||
<SettingAccordion icon={mdiAccountOutline} key="account" title={$t('account')} subtitle={$t('manage_your_account')}>
|
||||
<UserProfileSettings />
|
||||
</SettingAccordion>
|
||||
|
||||
<SettingAccordion
|
||||
icon={mdiServerOutline}
|
||||
key="user-usage-info"
|
||||
title={$t('user_usage_stats')}
|
||||
subtitle={$t('user_usage_stats_description')}
|
||||
>
|
||||
<UserUsageStatistic />
|
||||
</SettingAccordion>
|
||||
|
||||
<SettingAccordion icon={mdiApi} key="api-keys" title={$t('api_keys')} subtitle={$t('manage_your_api_keys')}>
|
||||
<UserApiKeyList bind:keys />
|
||||
</SettingAccordion>
|
||||
|
||||
<SettingAccordion
|
||||
icon={mdiDevices}
|
||||
key="authorized-devices"
|
||||
title={$t('authorized_devices')}
|
||||
subtitle={$t('manage_your_devices')}
|
||||
>
|
||||
<DeviceList bind:devices={sessions} />
|
||||
</SettingAccordion>
|
||||
|
||||
<SettingAccordion
|
||||
icon={mdiDownload}
|
||||
key="download-settings"
|
||||
title={$t('download_settings')}
|
||||
subtitle={$t('download_settings_description')}
|
||||
>
|
||||
<DownloadSettings />
|
||||
</SettingAccordion>
|
||||
|
||||
<SettingAccordion
|
||||
icon={mdiFeatureSearchOutline}
|
||||
key="feature"
|
||||
title={$t('features')}
|
||||
subtitle={$t('features_setting_description')}
|
||||
>
|
||||
<FeatureSettings />
|
||||
</SettingAccordion>
|
||||
|
||||
<SettingAccordion
|
||||
icon={mdiBellOutline}
|
||||
key={OpenQueryParam.NOTIFICATIONS}
|
||||
title={$t('notifications')}
|
||||
subtitle={$t('notifications_setting_description')}
|
||||
>
|
||||
<NotificationsSettings />
|
||||
</SettingAccordion>
|
||||
|
||||
{#if featureFlagsManager.value.oauth}
|
||||
<SettingAccordion
|
||||
icon={mdiTwoFactorAuthentication}
|
||||
key={OpenQueryParam.OAUTH}
|
||||
title={$t('oauth')}
|
||||
subtitle={$t('manage_your_oauth_connection')}
|
||||
isOpen={oauthOpen || undefined}
|
||||
>
|
||||
<OAuthSettings />
|
||||
</SettingAccordion>
|
||||
{/if}
|
||||
|
||||
<SettingAccordion
|
||||
icon={mdiFormTextboxPassword}
|
||||
key="password"
|
||||
title={$t('password')}
|
||||
subtitle={$t('change_your_password')}
|
||||
>
|
||||
<ChangePasswordSettings />
|
||||
</SettingAccordion>
|
||||
|
||||
<SettingAccordion
|
||||
icon={mdiAccountGroupOutline}
|
||||
key="partner-sharing"
|
||||
title={$t('partner_sharing')}
|
||||
subtitle={$t('manage_sharing_with_partners')}
|
||||
>
|
||||
<PartnerSettings />
|
||||
</SettingAccordion>
|
||||
|
||||
<SettingAccordion
|
||||
icon={mdiLockSmart}
|
||||
key="user-pin-code-settings"
|
||||
title={$t('user_pin_code_settings')}
|
||||
subtitle={$t('user_pin_code_settings_description')}
|
||||
autoScrollTo={true}
|
||||
>
|
||||
<ChangePinCodeSettings />
|
||||
</SettingAccordion>
|
||||
|
||||
<SettingAccordion
|
||||
icon={mdiKeyOutline}
|
||||
key={OpenQueryParam.PURCHASE_SETTINGS}
|
||||
title={$t('user_purchase_settings')}
|
||||
subtitle={$t('user_purchase_settings_description')}
|
||||
autoScrollTo={true}
|
||||
>
|
||||
<UserPurchaseSettings />
|
||||
</SettingAccordion>
|
||||
</SettingAccordionState>
|
||||
@@ -1,98 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { locale } from '$lib/stores/preferences.store';
|
||||
import {
|
||||
AssetVisibility,
|
||||
getAlbumStatistics,
|
||||
getAssetStatistics,
|
||||
type AlbumStatisticsResponseDto,
|
||||
type AssetStatsResponseDto,
|
||||
} from '@immich/sdk';
|
||||
import { Heading, Table, TableBody, TableCell, TableHeader, TableHeading, TableRow } from '@immich/ui';
|
||||
import { onMount } from 'svelte';
|
||||
import { t } from 'svelte-i18n';
|
||||
|
||||
let timelineStats: AssetStatsResponseDto = $state({
|
||||
videos: 0,
|
||||
images: 0,
|
||||
total: 0,
|
||||
});
|
||||
|
||||
let favoriteStats: AssetStatsResponseDto = $state({
|
||||
videos: 0,
|
||||
images: 0,
|
||||
total: 0,
|
||||
});
|
||||
|
||||
let archiveStats: AssetStatsResponseDto = $state({
|
||||
videos: 0,
|
||||
images: 0,
|
||||
total: 0,
|
||||
});
|
||||
|
||||
let trashStats: AssetStatsResponseDto = $state({
|
||||
videos: 0,
|
||||
images: 0,
|
||||
total: 0,
|
||||
});
|
||||
|
||||
let albumStats: AlbumStatisticsResponseDto = $state({
|
||||
owned: 0,
|
||||
shared: 0,
|
||||
notShared: 0,
|
||||
});
|
||||
|
||||
const getUsage = async () => {
|
||||
[timelineStats, favoriteStats, archiveStats, trashStats, albumStats] = await Promise.all([
|
||||
getAssetStatistics({ visibility: AssetVisibility.Timeline }),
|
||||
getAssetStatistics({ isFavorite: true }),
|
||||
getAssetStatistics({ visibility: AssetVisibility.Archive }),
|
||||
getAssetStatistics({ isTrashed: true }),
|
||||
getAlbumStatistics(),
|
||||
]);
|
||||
};
|
||||
|
||||
onMount(async () => {
|
||||
await getUsage();
|
||||
});
|
||||
</script>
|
||||
|
||||
{#snippet row(viewName: string, stats: AssetStatsResponseDto)}
|
||||
<TableRow>
|
||||
<TableCell class="w-1/4">{viewName}</TableCell>
|
||||
<TableCell class="w-1/4">{stats.images.toLocaleString($locale)}</TableCell>
|
||||
<TableCell class="w-1/4">{stats.videos.toLocaleString($locale)}</TableCell>
|
||||
<TableCell class="w-1/4">{stats.total.toLocaleString($locale)}</TableCell>
|
||||
</TableRow>
|
||||
{/snippet}
|
||||
|
||||
<section class="my-4 w-full">
|
||||
<Heading size="tiny">{$t('photos_and_videos')}</Heading>
|
||||
<Table striped spacing="small" class="mt-4" size="small">
|
||||
<TableHeader>
|
||||
<TableHeading class="w-1/4">{$t('view_name')}</TableHeading>
|
||||
<TableHeading class="w-1/4">{$t('photos')}</TableHeading>
|
||||
<TableHeading class="w-1/4">{$t('videos')}</TableHeading>
|
||||
<TableHeading class="w-1/4">{$t('total')}</TableHeading>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{@render row($t('timeline'), timelineStats)}
|
||||
{@render row($t('favorites'), favoriteStats)}
|
||||
{@render row($t('archive'), archiveStats)}
|
||||
{@render row($t('trash'), trashStats)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
|
||||
<Heading size="tiny" class="mt-8">{$t('albums')}</Heading>
|
||||
<Table striped spacing="small" class="mt-4" size="small">
|
||||
<TableHeader>
|
||||
<TableHeading class="w-1/2">{$t('owned')}</TableHeading>
|
||||
<TableHeading class="w-1/2">{$t('shared')}</TableHeading>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
<TableRow>
|
||||
<TableCell class="w-1/2">{albumStats.owned.toLocaleString($locale)}</TableCell>
|
||||
<TableCell class="w-1/2">{albumStats.shared.toLocaleString($locale)}</TableCell>
|
||||
</TableRow>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</section>
|
||||
@@ -1,16 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { Icon, Text } from '@immich/ui';
|
||||
import { mdiCheck, mdiClose } from '@mdi/js';
|
||||
|
||||
interface Props {
|
||||
title: string;
|
||||
state: boolean;
|
||||
}
|
||||
|
||||
let { title, state }: Props = $props();
|
||||
</script>
|
||||
|
||||
<div class="flex justify-between items-center">
|
||||
<Text class="text-sm" fontWeight="medium">{title}</Text>
|
||||
<Icon icon={state ? mdiCheck : mdiClose} class={state ? 'text-primary' : 'text-danger'} size="24" />
|
||||
</div>
|
||||
@@ -1,240 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { locale } from '$lib/stores/preferences.store';
|
||||
import { getAssetMediaUrl } from '$lib/utils';
|
||||
import { getAssetResolution, getFileSize } from '$lib/utils/asset-utils';
|
||||
import { getAltText } from '$lib/utils/thumbnail-util';
|
||||
import { fromISODateTime, fromISODateTimeUTC, toTimelineAsset } from '$lib/utils/timeline-util';
|
||||
import { type AssetResponseDto, getAllAlbums } from '@immich/sdk';
|
||||
import { Icon } from '@immich/ui';
|
||||
import {
|
||||
mdiBookmarkOutline,
|
||||
mdiCalendar,
|
||||
mdiClock,
|
||||
mdiFile,
|
||||
mdiFitToScreen,
|
||||
mdiFolderOutline,
|
||||
mdiHeart,
|
||||
mdiImageMultipleOutline,
|
||||
mdiImageOutline,
|
||||
mdiMagnifyPlus,
|
||||
mdiMapMarkerOutline,
|
||||
} from '@mdi/js';
|
||||
import { t } from 'svelte-i18n';
|
||||
import InfoRow from './info-row.svelte';
|
||||
|
||||
interface Props {
|
||||
assets: AssetResponseDto[];
|
||||
asset: AssetResponseDto;
|
||||
isSelected: boolean;
|
||||
onSelectAsset: (asset: AssetResponseDto) => void;
|
||||
onViewAsset: (asset: AssetResponseDto) => void;
|
||||
}
|
||||
|
||||
let { assets, asset, isSelected, onSelectAsset, onViewAsset }: Props = $props();
|
||||
|
||||
let isFromExternalLibrary = $derived(!!asset.libraryId);
|
||||
|
||||
let locationParts = $derived([asset.exifInfo?.city, asset.exifInfo?.state, asset.exifInfo?.country].filter(Boolean));
|
||||
|
||||
let timeZone = $derived(asset.exifInfo?.timeZone);
|
||||
let dateTime = $derived(
|
||||
timeZone && asset.exifInfo?.dateTimeOriginal
|
||||
? fromISODateTime(asset.exifInfo.dateTimeOriginal, timeZone)
|
||||
: fromISODateTimeUTC(asset.localDateTime),
|
||||
);
|
||||
|
||||
const isDifferent = (getter: (asset: AssetResponseDto) => string | undefined): boolean => {
|
||||
return new Set(assets.map((asset) => getter(asset))).size > 1;
|
||||
};
|
||||
|
||||
const hasDifferentValues = $derived({
|
||||
fileName: isDifferent((a) => a.originalFileName),
|
||||
fileSize: isDifferent((a) => getFileSize(a)),
|
||||
resolution: isDifferent((a) => getAssetResolution(a)),
|
||||
originalPath: isDifferent((a) => a.originalPath ?? $t('unknown')),
|
||||
date: isDifferent((a) => {
|
||||
const tz = a.exifInfo?.timeZone;
|
||||
const dt =
|
||||
tz && a.exifInfo?.dateTimeOriginal
|
||||
? fromISODateTime(a.exifInfo.dateTimeOriginal, tz)
|
||||
: fromISODateTimeUTC(a.localDateTime);
|
||||
return dt?.toLocaleString({ month: 'short', day: 'numeric', year: 'numeric' }, { locale: $locale });
|
||||
}),
|
||||
time: isDifferent((a) => {
|
||||
const tz = a.exifInfo?.timeZone;
|
||||
const dt =
|
||||
tz && a.exifInfo?.dateTimeOriginal
|
||||
? fromISODateTime(a.exifInfo.dateTimeOriginal, tz)
|
||||
: fromISODateTimeUTC(a.localDateTime);
|
||||
return dt?.toLocaleString(
|
||||
{
|
||||
hour: 'numeric',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
timeZoneName: tz ? 'shortOffset' : undefined,
|
||||
},
|
||||
{ locale: $locale },
|
||||
);
|
||||
}),
|
||||
location: isDifferent(
|
||||
(a) => [a.exifInfo?.city, a.exifInfo?.state, a.exifInfo?.country].filter(Boolean).join(', ') || 'unknown',
|
||||
),
|
||||
});
|
||||
|
||||
const getBasePath = (fullpath: string, fileName: string): string => {
|
||||
if (fileName && fullpath.endsWith(fileName)) {
|
||||
return fullpath.slice(0, -(fileName.length + 1));
|
||||
}
|
||||
return fullpath;
|
||||
};
|
||||
|
||||
function truncateMiddle(path: string, maxLength: number = 50): string {
|
||||
if (path.length <= maxLength) {
|
||||
return path;
|
||||
}
|
||||
|
||||
const start = Math.floor(maxLength / 2) - 2;
|
||||
const end = Math.floor(maxLength / 2) - 2;
|
||||
|
||||
return path.slice(0, Math.max(0, start)) + '...' + path.slice(Math.max(0, path.length - end));
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="min-w-60 transition-colors border rounded-lg flex-1">
|
||||
<div class="relative w-full">
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => onSelectAsset(asset)}
|
||||
class="block relative w-full"
|
||||
aria-pressed={isSelected}
|
||||
aria-label={$t('keep')}
|
||||
>
|
||||
<!-- THUMBNAIL-->
|
||||
<img
|
||||
src={getAssetMediaUrl({ id: asset.id })}
|
||||
alt={$getAltText(toTimelineAsset(asset))}
|
||||
class="h-60 object-cover w-full rounded-t-md"
|
||||
draggable="false"
|
||||
/>
|
||||
|
||||
<!-- FAVORITE ICON -->
|
||||
{#if asset.isFavorite}
|
||||
<div class="absolute bottom-2 start-2">
|
||||
<Icon icon={mdiHeart} size="24" class="text-white" />
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- OVERLAY CHIP -->
|
||||
<div
|
||||
class="absolute bottom-1 end-3 px-4 py-1 rounded-xl text-xs transition-colors {isSelected
|
||||
? 'bg-green-400/90'
|
||||
: 'bg-red-300/90'} text-black"
|
||||
>
|
||||
{isSelected ? $t('keep') : $t('to_trash')}
|
||||
</div>
|
||||
|
||||
<!-- EXTERNAL LIBRARY / STACK COUNT CHIP -->
|
||||
<div class="absolute top-2 end-3">
|
||||
{#if isFromExternalLibrary}
|
||||
<div class="bg-immich-primary/90 px-2 py-1 rounded-xl text-xs text-white">
|
||||
{$t('external')}
|
||||
</div>
|
||||
{/if}
|
||||
{#if asset.stack?.assetCount}
|
||||
<div class="bg-immich-primary/90 px-2 py-1 my-0.5 rounded-xl text-xs text-white">
|
||||
<div class="flex items-center justify-center">
|
||||
<div class="me-1">{asset.stack.assetCount}</div>
|
||||
<Icon icon={mdiImageMultipleOutline} size="18" />
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => onViewAsset(asset)}
|
||||
class="absolute rounded-full top-1 start-1 text-gray-200 p-2 hover:text-white bg-black/35 hover:bg-black/50"
|
||||
title={$t('view')}
|
||||
>
|
||||
<Icon aria-label={$t('view')} icon={mdiMagnifyPlus} flipped size="18" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="grid place-items-start gap-y-2 py-2 text-sm transition-colors rounded-b-lg {isSelected
|
||||
? 'bg-success/15 dark:bg-[#001a06]'
|
||||
: 'bg-transparent'}"
|
||||
>
|
||||
<InfoRow
|
||||
icon={mdiImageOutline}
|
||||
highlight={hasDifferentValues.fileName}
|
||||
title={$t('file_name_with_value', { values: { file_name: asset.originalFileName ?? '' } })}
|
||||
>
|
||||
{asset.originalFileName}
|
||||
</InfoRow>
|
||||
|
||||
<InfoRow
|
||||
icon={mdiFolderOutline}
|
||||
highlight={hasDifferentValues.originalPath}
|
||||
title={$t('full_path', { values: { path: asset.originalPath } })}
|
||||
>
|
||||
{truncateMiddle(getBasePath(asset.originalPath, asset.originalFileName)) || $t('unknown')}
|
||||
</InfoRow>
|
||||
|
||||
<InfoRow icon={mdiFile} highlight={hasDifferentValues.fileSize} title={$t('file_size')}>
|
||||
{getFileSize(asset)}
|
||||
</InfoRow>
|
||||
|
||||
<InfoRow icon={mdiFitToScreen} highlight={hasDifferentValues.resolution} title={$t('resolution')}>
|
||||
{getAssetResolution(asset)}
|
||||
</InfoRow>
|
||||
|
||||
<InfoRow icon={mdiCalendar} highlight={hasDifferentValues.date} title={$t('date')}>
|
||||
{#if dateTime}
|
||||
{dateTime.toLocaleString(
|
||||
{
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
},
|
||||
{ locale: $locale },
|
||||
)}
|
||||
{:else}
|
||||
{$t('unknown')}
|
||||
{/if}
|
||||
</InfoRow>
|
||||
|
||||
<InfoRow icon={mdiClock} highlight={hasDifferentValues.time} title={$t('time')}>
|
||||
{#if dateTime}
|
||||
{dateTime.toLocaleString(
|
||||
{
|
||||
hour: 'numeric',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
timeZoneName: timeZone ? 'shortOffset' : undefined,
|
||||
},
|
||||
{ locale: $locale },
|
||||
)}
|
||||
{:else}
|
||||
{$t('unknown')}
|
||||
{/if}
|
||||
</InfoRow>
|
||||
|
||||
<InfoRow icon={mdiMapMarkerOutline} highlight={hasDifferentValues.location} title={$t('location')}>
|
||||
{#if locationParts.length > 0}
|
||||
{locationParts.join(', ')}
|
||||
{:else}
|
||||
{$t('unknown')}
|
||||
{/if}
|
||||
</InfoRow>
|
||||
|
||||
<InfoRow icon={mdiBookmarkOutline} borderBottom={false} title={$t('albums')}>
|
||||
{#await getAllAlbums({ assetId: asset.id })}
|
||||
{$t('scanning_for_album')}
|
||||
{:then albums}
|
||||
{$t('in_albums', { values: { count: albums.length } })}
|
||||
{/await}
|
||||
</InfoRow>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,183 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { shortcuts } from '$lib/actions/shortcut';
|
||||
import DuplicateAsset from '$lib/components/utilities-page/duplicates/duplicate-asset.svelte';
|
||||
import Portal from '$lib/elements/Portal.svelte';
|
||||
import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte';
|
||||
import { authManager } from '$lib/managers/auth-manager.svelte';
|
||||
import { handlePromiseError } from '$lib/utils';
|
||||
import { getNextAsset, getPreviousAsset } from '$lib/utils/asset-utils';
|
||||
import { navigate } from '$lib/utils/navigation';
|
||||
import { getAssetInfo, type AssetResponseDto } from '@immich/sdk';
|
||||
import { Button } from '@immich/ui';
|
||||
import { mdiCheck, mdiImageMultipleOutline, mdiTrashCanOutline } from '@mdi/js';
|
||||
import { onDestroy, onMount } from 'svelte';
|
||||
import { t } from 'svelte-i18n';
|
||||
import { SvelteSet } from 'svelte/reactivity';
|
||||
|
||||
interface Props {
|
||||
assets: AssetResponseDto[];
|
||||
suggestedKeepAssetIds: string[];
|
||||
onResolve: (duplicateAssetIds: string[], trashIds: string[]) => void;
|
||||
onStack: (assets: AssetResponseDto[]) => void;
|
||||
}
|
||||
|
||||
let { assets, suggestedKeepAssetIds, onResolve, onStack }: Props = $props();
|
||||
// eslint-disable-next-line svelte/no-unnecessary-state-wrap
|
||||
let selectedAssetIds = $state(new SvelteSet<string>());
|
||||
let trashCount = $derived(assets.length - selectedAssetIds.size);
|
||||
|
||||
onMount(() => {
|
||||
if (suggestedKeepAssetIds.length > 0) {
|
||||
for (const id of suggestedKeepAssetIds) {
|
||||
selectedAssetIds.add(id);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (assets.length > 0) {
|
||||
selectedAssetIds.add(assets[0].id);
|
||||
}
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
assetViewerManager.showAssetViewer(false);
|
||||
});
|
||||
|
||||
const onRandom = async () => {
|
||||
if (assets.length <= 0) {
|
||||
return;
|
||||
}
|
||||
const index = Math.floor(Math.random() * assets.length);
|
||||
const asset = assets[index];
|
||||
await onViewAsset(asset);
|
||||
return { id: asset.id };
|
||||
};
|
||||
|
||||
const onSelectAsset = (asset: AssetResponseDto) => {
|
||||
if (selectedAssetIds.has(asset.id)) {
|
||||
selectedAssetIds.delete(asset.id);
|
||||
} else {
|
||||
selectedAssetIds.add(asset.id);
|
||||
}
|
||||
};
|
||||
|
||||
const onSelectNone = () => {
|
||||
selectedAssetIds.clear();
|
||||
};
|
||||
|
||||
const onSelectAll = () => {
|
||||
selectedAssetIds = new SvelteSet(assets.map((asset) => asset.id));
|
||||
};
|
||||
|
||||
const onViewAsset = async ({ id }: AssetResponseDto) => {
|
||||
const asset = await getAssetInfo({ ...authManager.params, id });
|
||||
assetViewerManager.setAsset(asset);
|
||||
await navigate({ targetRoute: 'current', assetId: asset.id });
|
||||
};
|
||||
|
||||
const handleResolve = () => {
|
||||
const trashIds = assets.map((asset) => asset.id).filter((id) => !selectedAssetIds.has(id));
|
||||
const duplicateAssetIds = assets.map((asset) => asset.id);
|
||||
onResolve(duplicateAssetIds, trashIds);
|
||||
};
|
||||
|
||||
const handleStack = () => {
|
||||
onStack(assets);
|
||||
};
|
||||
|
||||
const assetCursor = $derived({
|
||||
current: assetViewerManager.asset!,
|
||||
nextAsset: getNextAsset(assets, assetViewerManager.asset),
|
||||
previousAsset: getPreviousAsset(assets, assetViewerManager.asset),
|
||||
});
|
||||
</script>
|
||||
|
||||
<svelte:document
|
||||
use:shortcuts={[
|
||||
{ shortcut: { key: 'a' }, onShortcut: onSelectAll },
|
||||
{
|
||||
shortcut: { key: 's' },
|
||||
onShortcut: () => onViewAsset(assets[0]),
|
||||
},
|
||||
{ shortcut: { key: 'd' }, onShortcut: onSelectNone },
|
||||
{ shortcut: { key: 'c', shift: true }, onShortcut: handleResolve },
|
||||
{ shortcut: { key: 's', shift: true }, onShortcut: handleStack },
|
||||
]}
|
||||
/>
|
||||
|
||||
<div class="rounded-3xl border dark:border-2 border-gray-300 dark:border-gray-700 max-w-256 mx-auto mb-4 py-6 px-0.2">
|
||||
<div class="flex flex-wrap gap-y-6 mb-4 px-6 w-full place-content-end justify-between">
|
||||
<!-- MARK ALL BUTTONS -->
|
||||
<div class="flex text-xs text-black">
|
||||
<Button class="rounded-s-full" size="small" color="primary" leadingIcon={mdiCheck} onclick={onSelectAll}
|
||||
>{$t('select_keep_all')}</Button
|
||||
>
|
||||
<Button
|
||||
class="rounded-e-full"
|
||||
size="small"
|
||||
color="secondary"
|
||||
leadingIcon={mdiTrashCanOutline}
|
||||
onclick={onSelectNone}>{$t('select_trash_all')}</Button
|
||||
>
|
||||
</div>
|
||||
|
||||
<!-- CONFIRM BUTTONS -->
|
||||
<div class="flex text-xs text-black">
|
||||
{#if trashCount === 0}
|
||||
<Button
|
||||
size="small"
|
||||
leadingIcon={mdiCheck}
|
||||
color="success"
|
||||
class="flex place-items-center rounded-s-full gap-2"
|
||||
onclick={handleResolve}
|
||||
>
|
||||
{$t('keep_all')}
|
||||
</Button>
|
||||
{:else}
|
||||
<Button
|
||||
size="small"
|
||||
color="danger"
|
||||
leadingIcon={mdiTrashCanOutline}
|
||||
class="rounded-s-full"
|
||||
onclick={handleResolve}
|
||||
>
|
||||
{trashCount === assets.length ? $t('trash_all') : $t('trash_count', { values: { count: trashCount } })}
|
||||
</Button>
|
||||
{/if}
|
||||
<Button
|
||||
size="small"
|
||||
color="primary"
|
||||
leadingIcon={mdiImageMultipleOutline}
|
||||
class="rounded-e-full"
|
||||
onclick={handleStack}
|
||||
disabled={selectedAssetIds.size !== 1}
|
||||
>
|
||||
{$t('stack')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="overflow-x-auto p-2">
|
||||
<div class="flex flex-nowrap gap-1 place-items-start justify-center min-w-full w-fit mx-auto">
|
||||
{#each assets as asset (asset.id)}
|
||||
<DuplicateAsset {assets} {asset} {onSelectAsset} isSelected={selectedAssetIds.has(asset.id)} {onViewAsset} />
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if assetViewerManager.isViewing}
|
||||
{#await import('$lib/components/asset-viewer/asset-viewer.svelte') then { default: AssetViewer }}
|
||||
<Portal target="body">
|
||||
<AssetViewer
|
||||
cursor={assetCursor}
|
||||
showNavigation={assets.length > 1}
|
||||
{onRandom}
|
||||
onClose={() => {
|
||||
assetViewerManager.showAssetViewer(false);
|
||||
handlePromiseError(navigate({ targetRoute: 'current', assetId: null }));
|
||||
}}
|
||||
/>
|
||||
</Portal>
|
||||
{/await}
|
||||
{/if}
|
||||
@@ -1,27 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { Icon, Text } from '@immich/ui';
|
||||
import type { Snippet } from 'svelte';
|
||||
|
||||
interface Props {
|
||||
icon: string;
|
||||
children?: Snippet;
|
||||
borderBottom?: boolean;
|
||||
highlight?: boolean;
|
||||
title?: string;
|
||||
}
|
||||
|
||||
let { icon, children, borderBottom = true, highlight = false, title }: Props = $props();
|
||||
</script>
|
||||
|
||||
<div class="grid grid-cols-[25px_1fr] w-full px-1 py-0.5" class:border-b={borderBottom} {title}>
|
||||
<Icon {icon} size="18" class="text-dark/25 {highlight ? 'text-primary/75' : ''}" />
|
||||
<div class="justify-self-end text-end rounded px-1 transition-colors w-full overflow-hidden">
|
||||
<Text
|
||||
size="tiny"
|
||||
fontWeight={highlight ? 'semi-bold' : 'normal'}
|
||||
class={`${highlight ? 'text-primary' : ''} text-ellipsis w-full overflow-hidden`}
|
||||
>
|
||||
{@render children?.()}
|
||||
</Text>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,37 +0,0 @@
|
||||
<script lang="ts">
|
||||
import Thumbnail from '$lib/components/assets/thumbnail/thumbnail.svelte';
|
||||
import { getFileSize } from '$lib/utils/asset-utils';
|
||||
import { toTimelineAsset } from '$lib/utils/timeline-util';
|
||||
import { type AssetResponseDto } from '@immich/sdk';
|
||||
|
||||
interface Props {
|
||||
asset: AssetResponseDto;
|
||||
onViewAsset: (asset: AssetResponseDto) => void;
|
||||
}
|
||||
|
||||
let { asset, onViewAsset }: Props = $props();
|
||||
|
||||
let assetData = $derived(JSON.stringify(asset, null, 2));
|
||||
|
||||
let boxWidth = $state(300);
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="w-full aspect-square rounded-xl border-4 transition-colors font-semibold text-xs bg-gray-200 dark:bg-gray-800 border-gray-200 dark:border-gray-800"
|
||||
bind:clientWidth={boxWidth}
|
||||
title={assetData}
|
||||
>
|
||||
<div class="relative w-full h-full overflow-hidden rounded-lg">
|
||||
<Thumbnail asset={toTimelineAsset(asset)} readonly onClick={() => onViewAsset(asset)} thumbnailSize={boxWidth} />
|
||||
|
||||
{#if !!asset.libraryId}
|
||||
<div class="absolute bottom-1 end-3 px-4 py-1 rounded-xl text-xs transition-colors bg-red-500">External</div>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="text-center mt-4 px-4 text-sm font-normal truncate" title={asset.originalFileName}>
|
||||
{asset.originalFileName}
|
||||
</div>
|
||||
<div class="text-center">
|
||||
<p class="text-primary text-xl font-semibold py-3">{getFileSize(asset, 1)}</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,57 +0,0 @@
|
||||
<script lang="ts">
|
||||
import AppDownloadModal from '$lib/modals/AppDownloadModal.svelte';
|
||||
import ObtainiumConfigModal from '$lib/modals/ObtainiumConfigModal.svelte';
|
||||
import { Route } from '$lib/route';
|
||||
import { Icon, modalManager, Text } from '@immich/ui';
|
||||
import {
|
||||
mdiCellphoneArrowDownVariant,
|
||||
mdiContentDuplicate,
|
||||
mdiCrosshairsGps,
|
||||
mdiImageSizeSelectLarge,
|
||||
mdiLinkEdit,
|
||||
} from '@mdi/js';
|
||||
import { t } from 'svelte-i18n';
|
||||
|
||||
const links = [
|
||||
{ href: Route.duplicatesUtility(), icon: mdiContentDuplicate, label: $t('review_duplicates') },
|
||||
{ href: Route.largeFileUtility(), icon: mdiImageSizeSelectLarge, label: $t('review_large_files') },
|
||||
{ href: Route.geolocationUtility(), icon: mdiCrosshairsGps, label: $t('manage_geolocation') },
|
||||
// { href: Route.workflows(), icon: mdiStateMachine, label: $t('workflows') },
|
||||
];
|
||||
</script>
|
||||
|
||||
<div class="border border-gray-300 dark:border-immich-dark-gray rounded-3xl pt-1 pb-6 dark:text-white">
|
||||
<Text size="tiny" color="muted" fontWeight="medium" class="p-4">{$t('organize_your_library')}</Text>
|
||||
|
||||
{#each links as link (link.href)}
|
||||
<a href={link.href} class="w-full hover:bg-gray-100 dark:hover:bg-immich-dark-gray flex items-center gap-4 p-4">
|
||||
<span><Icon icon={link.icon} class="text-primary" size="24" /> </span>
|
||||
{link.label}
|
||||
</a>
|
||||
{/each}
|
||||
</div>
|
||||
<br />
|
||||
<div class="border border-gray-300 dark:border-immich-dark-gray rounded-3xl pt-1 pb-6 dark:text-white">
|
||||
<Text size="tiny" color="muted" fontWeight="medium" class="p-4">{$t('download')}</Text>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => modalManager.show(ObtainiumConfigModal, {})}
|
||||
class="w-full hover:bg-gray-100 dark:hover:bg-immich-dark-gray flex items-center gap-4 p-4"
|
||||
>
|
||||
<span>
|
||||
<Icon icon={mdiLinkEdit} class="text-immich-primary dark:text-immich-dark-primary" size="24" />
|
||||
</span>
|
||||
{$t('obtainium_configurator')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => modalManager.show(AppDownloadModal, {})}
|
||||
class="w-full hover:bg-gray-100 dark:hover:bg-immich-dark-gray flex items-center gap-4 p-4"
|
||||
>
|
||||
<span>
|
||||
<Icon icon={mdiCellphoneArrowDownVariant} class="text-immich-primary dark:text-immich-dark-primary" size="24" />
|
||||
</span>
|
||||
{$t('app_download_links')}
|
||||
</button>
|
||||
</div>
|
||||
@@ -1,161 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { getComponentDefaultValue, getComponentFromSchema } from '$lib/utils/workflow';
|
||||
import { Field, Input, MultiSelect, Select, Switch, Text } from '@immich/ui';
|
||||
import WorkflowPickerField from './WorkflowPickerField.svelte';
|
||||
|
||||
type Props = {
|
||||
schema: object | null;
|
||||
config: Record<string, unknown>;
|
||||
configKey?: string;
|
||||
};
|
||||
|
||||
let { schema = null, config = $bindable({}), configKey }: Props = $props();
|
||||
|
||||
const components = $derived(getComponentFromSchema(schema));
|
||||
|
||||
// Get the actual config object to work with
|
||||
const actualConfig = $derived(configKey ? (config[configKey] as Record<string, unknown>) || {} : config);
|
||||
|
||||
// Update function that handles nested config
|
||||
const updateConfig = (key: string, value: unknown) => {
|
||||
config = configKey ? { ...config, [configKey]: { ...actualConfig, [key]: value } } : { ...config, [key]: value };
|
||||
};
|
||||
|
||||
const updateConfigBatch = (updates: Record<string, unknown>) => {
|
||||
config = configKey ? { ...config, [configKey]: { ...actualConfig, ...updates } } : { ...config, ...updates };
|
||||
};
|
||||
|
||||
// Derive which keys need initialization (missing from actualConfig)
|
||||
const uninitializedKeys = $derived.by(() => {
|
||||
if (!components) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return Object.entries(components)
|
||||
.filter(([key]) => actualConfig[key] === undefined)
|
||||
.map(([key, component]) => ({ key, component, defaultValue: getComponentDefaultValue(component) }));
|
||||
});
|
||||
|
||||
// Derive the batch updates needed
|
||||
const pendingUpdates = $derived.by(() => {
|
||||
const updates: Record<string, unknown> = {};
|
||||
for (const { key, defaultValue } of uninitializedKeys) {
|
||||
updates[key] = defaultValue;
|
||||
}
|
||||
return updates;
|
||||
});
|
||||
|
||||
// Initialize config namespace if needed
|
||||
$effect(() => {
|
||||
if (configKey && !config[configKey]) {
|
||||
config = { ...config, [configKey]: {} };
|
||||
}
|
||||
});
|
||||
|
||||
// Apply pending config updates
|
||||
$effect(() => {
|
||||
if (Object.keys(pendingUpdates).length > 0) {
|
||||
updateConfigBatch(pendingUpdates);
|
||||
}
|
||||
});
|
||||
|
||||
const isPickerField = (subType: string | undefined) => subType === 'album-picker' || subType === 'people-picker';
|
||||
</script>
|
||||
|
||||
{#if components}
|
||||
<div class="flex flex-col gap-2">
|
||||
{#each Object.entries(components) as [key, component] (key)}
|
||||
{@const label = component.title || component.label || key}
|
||||
|
||||
<div class="flex flex-col gap-1 border bg-light p-4 rounded-xl">
|
||||
<!-- Select component -->
|
||||
{#if component.type === 'select'}
|
||||
{#if isPickerField(component.subType)}
|
||||
<WorkflowPickerField
|
||||
{component}
|
||||
configKey={key}
|
||||
value={actualConfig[key] as string | string[]}
|
||||
onchange={(value) => updateConfig(key, value)}
|
||||
/>
|
||||
{:else}
|
||||
{@const options = component.options?.map((opt) => {
|
||||
return { label: opt.label, value: String(opt.value) };
|
||||
}) || [{ label: 'N/A', value: '' }]}
|
||||
<Field
|
||||
{label}
|
||||
required={component.required}
|
||||
description={component.description}
|
||||
requiredIndicator={component.required}
|
||||
>
|
||||
<Select {options} onChange={(value) => updateConfig(key, value)} value={actualConfig[key] as string} />
|
||||
</Field>
|
||||
{/if}
|
||||
|
||||
<!-- MultiSelect component -->
|
||||
{:else if component.type === 'multiselect'}
|
||||
{#if isPickerField(component.subType)}
|
||||
<WorkflowPickerField
|
||||
{component}
|
||||
configKey={key}
|
||||
value={actualConfig[key] as string | string[]}
|
||||
onchange={(value) => updateConfig(key, value)}
|
||||
/>
|
||||
{:else}
|
||||
{@const options = component.options?.map((opt) => {
|
||||
return { label: opt.label, value: String(opt.value) };
|
||||
}) || [{ label: 'N/A', value: '' }]}
|
||||
<Field
|
||||
{label}
|
||||
required={component.required}
|
||||
description={component.description}
|
||||
requiredIndicator={component.required}
|
||||
>
|
||||
<MultiSelect
|
||||
{options}
|
||||
values={(actualConfig[key] as string[]) ?? []}
|
||||
onChange={(values) => updateConfig(key, values)}
|
||||
/>
|
||||
</Field>
|
||||
{/if}
|
||||
|
||||
<!-- Switch component -->
|
||||
{:else if component.type === 'switch'}
|
||||
{@const checked = Boolean(actualConfig[key])}
|
||||
<Field
|
||||
{label}
|
||||
description={component.description}
|
||||
requiredIndicator={component.required}
|
||||
required={component.required}
|
||||
>
|
||||
<Switch {checked} onCheckedChange={(check) => updateConfig(key, check)} />
|
||||
</Field>
|
||||
|
||||
<!-- Text input -->
|
||||
{:else if isPickerField(component.subType)}
|
||||
<WorkflowPickerField
|
||||
{component}
|
||||
configKey={key}
|
||||
value={actualConfig[key] as string | string[]}
|
||||
onchange={(value) => updateConfig(key, value)}
|
||||
/>
|
||||
{:else}
|
||||
<Field
|
||||
{label}
|
||||
description={component.description}
|
||||
requiredIndicator={component.required}
|
||||
required={component.required}
|
||||
>
|
||||
<Input
|
||||
id={key}
|
||||
value={actualConfig[key] as string}
|
||||
oninput={(e) => updateConfig(key, e.currentTarget.value)}
|
||||
required={component.required}
|
||||
/>
|
||||
</Field>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{:else}
|
||||
<Text size="small" color="muted">No configuration required</Text>
|
||||
{/if}
|
||||
@@ -1,42 +0,0 @@
|
||||
<script lang="ts">
|
||||
type Props = {
|
||||
animated?: boolean;
|
||||
};
|
||||
|
||||
let { animated = true }: Props = $props();
|
||||
</script>
|
||||
|
||||
<div class="flex justify-center py-2">
|
||||
<div class="relative h-12 w-0.5">
|
||||
<div class="absolute inset-0 bg-linear-to-b from-primary/30 via-primary/50 to-primary/30"></div>
|
||||
{#if animated}
|
||||
<div class="absolute inset-0 bg-linear-to-b from-transparent via-primary to-transparent flow-pulse"></div>
|
||||
{/if}
|
||||
<div class="absolute left-1/2 top-0 -translate-x-1/2 -translate-y-1/2">
|
||||
<div class="h-2 w-2 rounded-full bg-primary shadow-sm shadow-primary/50"></div>
|
||||
</div>
|
||||
<div class="absolute left-1/2 bottom-0 -translate-x-1/2 translate-y-1/2">
|
||||
<div class="h-2 w-2 rounded-full bg-primary shadow-sm shadow-primary/50"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
@keyframes flow {
|
||||
0% {
|
||||
transform: translateY(-25%);
|
||||
opacity: 0;
|
||||
}
|
||||
50% {
|
||||
opacity: 1;
|
||||
}
|
||||
100% {
|
||||
transform: translateY(25%);
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.flow-pulse {
|
||||
animation: flow 2s ease-in-out infinite;
|
||||
}
|
||||
</style>
|
||||
@@ -1,79 +0,0 @@
|
||||
<script lang="ts">
|
||||
import type { WorkflowPayload } from '$lib/services/workflow.service';
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
CardBody,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
Icon,
|
||||
Theme,
|
||||
themeManager,
|
||||
VStack,
|
||||
} from '@immich/ui';
|
||||
import { mdiCodeJson } from '@mdi/js';
|
||||
import { JSONEditor, Mode, type Content, type OnChangeStatus } from 'svelte-jsoneditor';
|
||||
|
||||
type Props = {
|
||||
jsonContent: WorkflowPayload;
|
||||
onApply: () => void;
|
||||
onContentChange: (content: WorkflowPayload) => void;
|
||||
};
|
||||
|
||||
let { jsonContent, onApply, onContentChange }: Props = $props();
|
||||
|
||||
let content: Content = $derived({ json: jsonContent });
|
||||
let canApply = $state(false);
|
||||
let editorClass = $derived(themeManager.value === Theme.Dark ? 'jse-theme-dark' : '');
|
||||
|
||||
const handleChange = (updated: Content, _: Content, status: OnChangeStatus) => {
|
||||
if (status.contentErrors) {
|
||||
return;
|
||||
}
|
||||
|
||||
canApply = true;
|
||||
|
||||
if ('text' in updated && updated.text !== undefined) {
|
||||
try {
|
||||
const parsed = JSON.parse(updated.text);
|
||||
onContentChange(parsed);
|
||||
} catch (error_) {
|
||||
console.error('Invalid JSON in text mode:', error_);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleApply = () => {
|
||||
onApply();
|
||||
canApply = false;
|
||||
};
|
||||
</script>
|
||||
|
||||
<VStack gap={4}>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<div class="flex items-start gap-3">
|
||||
<Icon icon={mdiCodeJson} size="20" class="mt-1" />
|
||||
<div class="flex flex-col">
|
||||
<CardTitle>Workflow JSON</CardTitle>
|
||||
<CardDescription>Edit the workflow configuration directly in JSON format</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
<Button size="small" color="primary" onclick={handleApply} disabled={!canApply}>Apply Changes</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardBody>
|
||||
<VStack gap={2}>
|
||||
<div class="w-full h-[600px] rounded-lg overflow-hidden border {editorClass}">
|
||||
<JSONEditor {content} onChange={handleChange} mainMenuBar={false} mode={Mode.text} />
|
||||
</div>
|
||||
</VStack>
|
||||
</CardBody>
|
||||
</Card>
|
||||
</VStack>
|
||||
|
||||
<style>
|
||||
@import 'svelte-jsoneditor/themes/jse-theme-dark.css';
|
||||
</style>
|
||||
@@ -1,104 +0,0 @@
|
||||
<script lang="ts">
|
||||
import WorkflowPickerItemCard from '$lib/components/workflows/WorkflowPickerItemCard.svelte';
|
||||
import AlbumPickerModal from '$lib/modals/AlbumPickerModal.svelte';
|
||||
import PeoplePickerModal from '$lib/modals/PeoplePickerModal.svelte';
|
||||
import { fetchPickerMetadata, type PickerMetadata } from '$lib/services/workflow.service';
|
||||
import type { ComponentConfig } from '$lib/utils/workflow';
|
||||
import type { AlbumResponseDto, PersonResponseDto } from '@immich/sdk';
|
||||
import { Button, Field, modalManager } from '@immich/ui';
|
||||
import { mdiPlus } from '@mdi/js';
|
||||
import { t } from 'svelte-i18n';
|
||||
|
||||
type Props = {
|
||||
component: ComponentConfig;
|
||||
configKey: string;
|
||||
value: string | string[] | undefined;
|
||||
onchange: (value: string | string[]) => void;
|
||||
};
|
||||
|
||||
let { component, configKey, value = $bindable(), onchange }: Props = $props();
|
||||
|
||||
const label = $derived(component.title || component.label || configKey);
|
||||
const subType = $derived(component.subType as 'album-picker' | 'people-picker');
|
||||
const isAlbum = $derived(subType === 'album-picker');
|
||||
const multiple = $derived(component.type === 'multiselect' || Array.isArray(value));
|
||||
|
||||
let pickerMetadata = $state<PickerMetadata | undefined>();
|
||||
|
||||
$effect(() => {
|
||||
if (!value) {
|
||||
pickerMetadata = undefined;
|
||||
return;
|
||||
}
|
||||
|
||||
if (!pickerMetadata) {
|
||||
void loadMetadata();
|
||||
}
|
||||
});
|
||||
|
||||
const loadMetadata = async () => {
|
||||
pickerMetadata = await fetchPickerMetadata(value, subType);
|
||||
};
|
||||
|
||||
const handlePicker = async () => {
|
||||
if (isAlbum) {
|
||||
const albums = await modalManager.show(AlbumPickerModal);
|
||||
if (albums && albums.length > 0) {
|
||||
const newValue = multiple ? albums.map((album) => album.id) : albums[0].id;
|
||||
onchange(newValue);
|
||||
pickerMetadata = multiple ? albums : albums[0];
|
||||
}
|
||||
} else {
|
||||
const currentIds = (Array.isArray(value) ? value : []) as string[];
|
||||
const excludedIds = multiple ? currentIds : [];
|
||||
const people = await modalManager.show(PeoplePickerModal, { multiple, excludedIds });
|
||||
if (people && people.length > 0) {
|
||||
const newValue = multiple ? people.map((person) => person.id) : people[0].id;
|
||||
onchange(newValue);
|
||||
pickerMetadata = multiple ? people : people[0];
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const removeSelection = () => {
|
||||
onchange(multiple ? [] : '');
|
||||
pickerMetadata = undefined;
|
||||
};
|
||||
|
||||
const removeItemFromSelection = (itemId: string) => {
|
||||
if (!Array.isArray(value)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const newValue = value.filter((id) => id !== itemId);
|
||||
onchange(newValue);
|
||||
|
||||
if (Array.isArray(pickerMetadata)) {
|
||||
pickerMetadata = pickerMetadata.filter((item) => item.id !== itemId) as AlbumResponseDto[] | PersonResponseDto[];
|
||||
}
|
||||
};
|
||||
|
||||
const getButtonText = () => {
|
||||
if (isAlbum) {
|
||||
return multiple ? $t('select_albums') : $t('select_album');
|
||||
}
|
||||
return multiple ? $t('select_people') : $t('select_person');
|
||||
};
|
||||
</script>
|
||||
|
||||
<Field {label} required={component.required} description={component.description} requiredIndicator={component.required}>
|
||||
<div class="flex flex-col gap-3">
|
||||
{#if pickerMetadata && !Array.isArray(pickerMetadata)}
|
||||
<WorkflowPickerItemCard item={pickerMetadata} {isAlbum} onRemove={removeSelection} />
|
||||
{:else if pickerMetadata && Array.isArray(pickerMetadata) && pickerMetadata.length > 0}
|
||||
<div class="flex flex-col gap-2">
|
||||
{#each pickerMetadata as item (item.id)}
|
||||
<WorkflowPickerItemCard {item} {isAlbum} onRemove={() => removeItemFromSelection(item.id)} />
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
<Button size="small" variant="outline" leadingIcon={mdiPlus} onclick={handlePicker}>
|
||||
{getButtonText()}
|
||||
</Button>
|
||||
</div>
|
||||
</Field>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user