server is striped

This commit is contained in:
Alex Tran
2026-02-06 22:00:56 +00:00
parent 5ccf4a596c
commit a6ba86b04d
823 changed files with 12705 additions and 153814 deletions
+1 -1
View File
@@ -2,7 +2,7 @@
cd /usr/src/app || exit
pnpm --filter @immich/sdk build
pnpm --filter @server/sdk build
COUNT=0
UPSTREAM="${IMMICH_SERVER_URL:-http://immich-server:2283/}"
+4 -38
View File
@@ -1,6 +1,6 @@
{
"name": "immich-web",
"version": "2.5.5",
"name": "web",
"version": "0.1.0",
"license": "GNU Affero General Public License version 3",
"type": "module",
"scripts": {
@@ -25,44 +25,15 @@
},
"dependencies": {
"@formatjs/icu-messageformat-parser": "^3.0.0",
"@immich/justified-layout-wasm": "^0.4.3",
"@immich/sdk": "file:../open-api/typescript-sdk",
"@server/sdk": "file:../open-api/typescript-sdk",
"@immich/ui": "^0.61.3",
"@mapbox/mapbox-gl-rtl-text": "0.2.3",
"@mdi/js": "^7.4.47",
"@photo-sphere-viewer/core": "^5.14.0",
"@photo-sphere-viewer/equirectangular-video-adapter": "^5.14.0",
"@photo-sphere-viewer/markers-plugin": "^5.14.0",
"@photo-sphere-viewer/resolution-plugin": "^5.14.0",
"@photo-sphere-viewer/settings-plugin": "^5.14.0",
"@photo-sphere-viewer/video-plugin": "^5.14.0",
"@types/geojson": "^7946.0.16",
"@zoom-image/core": "^0.41.0",
"@zoom-image/svelte": "^0.3.0",
"dom-to-image": "^2.6.0",
"fabric": "^6.5.4",
"geo-coordinates-parser": "^1.7.4",
"geojson": "^0.5.0",
"handlebars": "^4.7.8",
"happy-dom": "^20.0.0",
"intl-messageformat": "^11.0.0",
"justified-layout": "^4.1.0",
"lodash-es": "^4.17.21",
"luxon": "^3.4.4",
"maplibre-gl": "^5.6.2",
"pmtiles": "^4.3.0",
"qrcode": "^1.5.4",
"simple-icons": "^15.15.0",
"socket.io-client": "~4.8.0",
"svelte-gestures": "^5.2.2",
"svelte-i18n": "^4.0.1",
"svelte-jsoneditor": "^3.10.0",
"svelte-maplibre": "^1.2.5",
"svelte-persisted-store": "^0.12.0",
"tabbable": "^6.2.0",
"thumbhash": "^0.1.1",
"transformation-matrix": "^3.1.0",
"uplot": "^1.6.32"
"tabbable": "^6.2.0"
},
"devDependencies": {
"@eslint/js": "^9.36.0",
@@ -77,12 +48,7 @@
"@testing-library/jest-dom": "^6.4.2",
"@testing-library/svelte": "^5.2.8",
"@testing-library/user-event": "^14.5.2",
"@types/chromecast-caf-sender": "^1.0.11",
"@types/dom-to-image": "^2.6.7",
"@types/justified-layout": "^4.1.4",
"@types/lodash-es": "^4.17.12",
"@types/luxon": "^3.4.2",
"@types/qrcode": "^1.5.5",
"@vitest/coverage-v8": "^3.0.0",
"dotenv": "^17.0.0",
"eslint": "^9.36.0",
+1 -1
View File
@@ -1,4 +1,4 @@
import { isHttpError, type ApiHttpError } from '@immich/sdk';
import { isHttpError, type ApiHttpError } from '@server/sdk';
import type { HandleClientError } from '@sveltejs/kit';
const DEFAULT_MESSAGE = 'Hmm, not sure about that. Check the logs or open a ticket?';
+3 -3
View File
@@ -1,8 +1,8 @@
import * as sdk from '@immich/sdk';
import * as sdk from '@server/sdk';
import type { Mock, MockedObject } from 'vitest';
vi.mock('@immich/sdk', async (originalImport) => {
const module = await originalImport<typeof import('@immich/sdk')>();
vi.mock('@server/sdk', async (originalImport) => {
const module = await originalImport<typeof import('@server/sdk')>();
const mocks: Record<string, Mock> = {};
for (const [key, value] of Object.entries(module)) {
-118
View File
@@ -1,118 +0,0 @@
export interface DragAndDropOptions {
index: number;
onDragStart?: (index: number) => void;
onDragEnter?: (index: number) => void;
onDrop?: (e: DragEvent, index: number) => void;
onDragEnd?: () => void;
isDragging?: boolean;
isDragOver?: boolean;
}
export function dragAndDrop(node: HTMLElement, options: DragAndDropOptions) {
let { index, onDragStart, onDragEnter, onDrop, onDragEnd, isDragging, isDragOver } = options;
const isFormElement = (element: HTMLElement) => {
return element.tagName === 'INPUT' || element.tagName === 'TEXTAREA' || element.tagName === 'SELECT';
};
const handleDragStart = (e: DragEvent) => {
// Prevent drag if it originated from an input, textarea, or select element
const target = e.target as HTMLElement;
if (isFormElement(target)) {
e.preventDefault();
return;
}
onDragStart?.(index);
};
const handleDragEnter = () => {
onDragEnter?.(index);
};
const handleDragOver = (e: DragEvent) => {
e.preventDefault();
};
const handleDrop = (e: DragEvent) => {
onDrop?.(e, index);
};
const handleDragEnd = () => {
onDragEnd?.();
};
// Disable draggable when focusing on form elements (fixes Firefox input interaction)
const handleFocusIn = (e: FocusEvent) => {
const target = e.target as HTMLElement;
if (isFormElement(target)) {
node.setAttribute('draggable', 'false');
}
};
const handleFocusOut = (e: FocusEvent) => {
const target = e.target as HTMLElement;
if (isFormElement(target)) {
node.setAttribute('draggable', 'true');
}
};
node.setAttribute('draggable', 'true');
node.setAttribute('role', 'button');
node.setAttribute('tabindex', '0');
node.addEventListener('dragstart', handleDragStart);
node.addEventListener('dragenter', handleDragEnter);
node.addEventListener('dragover', handleDragOver);
node.addEventListener('drop', handleDrop);
node.addEventListener('dragend', handleDragEnd);
node.addEventListener('focusin', handleFocusIn);
node.addEventListener('focusout', handleFocusOut);
// Update classes based on drag state
const updateClasses = (dragging: boolean, dragOver: boolean) => {
// Remove all drag-related classes first
node.classList.remove('opacity-50', 'border-gray-400', 'dark:border-gray-500', 'border-solid');
// Add back only the active ones
if (dragging) {
node.classList.add('opacity-50');
}
if (dragOver) {
node.classList.add('border-gray-400', 'dark:border-gray-500', 'border-solid');
node.classList.remove('border-transparent');
} else {
node.classList.add('border-transparent');
}
};
updateClasses(isDragging || false, isDragOver || false);
return {
update(newOptions: DragAndDropOptions) {
index = newOptions.index;
onDragStart = newOptions.onDragStart;
onDragEnter = newOptions.onDragEnter;
onDrop = newOptions.onDrop;
onDragEnd = newOptions.onDragEnd;
const newIsDragging = newOptions.isDragging || false;
const newIsDragOver = newOptions.isDragOver || false;
if (newIsDragging !== isDragging || newIsDragOver !== isDragOver) {
isDragging = newIsDragging;
isDragOver = newIsDragOver;
updateClasses(isDragging, isDragOver);
}
},
destroy() {
node.removeEventListener('dragstart', handleDragStart);
node.removeEventListener('dragenter', handleDragEnter);
node.removeEventListener('dragover', handleDragOver);
node.removeEventListener('drop', handleDrop);
node.removeEventListener('dragend', handleDragEnd);
node.removeEventListener('focusin', handleFocusIn);
node.removeEventListener('focusout', handleFocusOut);
},
};
}
-29
View File
@@ -1,29 +0,0 @@
import { decodeBase64 } from '$lib/utils';
import { thumbHashToRGBA } from 'thumbhash';
/**
* Renders a thumbnail onto a canvas from a base64 encoded hash.
*/
export function thumbhash(canvas: HTMLCanvasElement, options: { base64ThumbHash: string }) {
render(canvas, options);
return {
update(newOptions: { base64ThumbHash: string }) {
render(canvas, newOptions);
},
};
}
const render = (canvas: HTMLCanvasElement, options: { base64ThumbHash: string }) => {
const ctx = canvas.getContext('2d');
if (!ctx) {
return;
}
const { w, h, rgba } = thumbHashToRGBA(decodeBase64(options.base64ThumbHash));
const pixels = ctx.createImageData(w, h);
canvas.width = w;
canvas.height = h;
pixels.data.set(rgba);
ctx.putImageData(pixels, 0, 0);
};
-35
View File
@@ -1,35 +0,0 @@
import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte';
import { createZoomImageWheel } from '@zoom-image/core';
export const zoomImageAction = (node: HTMLElement, options?: { disabled?: boolean }) => {
const zoomInstance = createZoomImageWheel(node, { maxZoom: 10, initialState: assetViewerManager.zoomState });
const unsubscribes = [
assetViewerManager.on({ ZoomChange: (state) => zoomInstance.setState(state) }),
zoomInstance.subscribe(({ state }) => assetViewerManager.onZoomChange(state)),
];
const stopIfDisabled = (event: Event) => {
if (options?.disabled) {
event.stopImmediatePropagation();
}
};
node.addEventListener('wheel', stopIfDisabled, { capture: true });
node.addEventListener('pointerdown', stopIfDisabled, { capture: true });
node.style.overflow = 'visible';
return {
update(newOptions?: { disabled?: boolean }) {
options = newOptions;
},
destroy() {
for (const unsubscribe of unsubscribes) {
unsubscribe();
}
node.removeEventListener('wheel', stopIfDisabled, { capture: true });
node.removeEventListener('pointerdown', stopIfDisabled, { capture: true });
zoomInstance.cleanup();
},
};
};
-33
View File
@@ -1,33 +0,0 @@
<script lang="ts">
import HeaderActionButton from '$lib/components/HeaderActionButton.svelte';
import { Card, CardBody, CardHeader, CardTitle, Icon, type ActionItem, type IconLike } from '@immich/ui';
import type { Snippet } from 'svelte';
type Props = {
icon: IconLike;
title: string;
headerAction?: ActionItem;
children?: Snippet;
};
const { icon, title, headerAction, children }: Props = $props();
</script>
<Card color="secondary">
<CardHeader>
<div class="flex w-full justify-between items-center px-4 py-2">
<div class="flex gap-2 text-primary">
<Icon {icon} size="1.5rem" />
<CardTitle>{title}</CardTitle>
</div>
{#if headerAction}
<HeaderActionButton action={headerAction} />
{/if}
</div>
</CardHeader>
<CardBody>
<div class="px-4 pb-7">
{@render children?.()}
</div>
</CardBody>
</Card>
@@ -1,6 +1,6 @@
<script lang="ts">
import ApiKeyGrid from '$lib/components/user-settings-page/user-api-key-grid.svelte';
import { Permission } from '@immich/sdk';
import { Permission } from '@server/sdk';
import { Checkbox, IconButton, Input, Label } from '@immich/ui';
import { mdiClose } from '@mdi/js';
import { t } from 'svelte-i18n';
@@ -1,24 +0,0 @@
<script lang="ts">
import { assetViewerManager, type Events } from '$lib/managers/asset-viewer-manager.svelte';
import type { EventCallback, EventMap } from '$lib/utils/base-event-manager.svelte';
import { onMount } from 'svelte';
type Props = {
[K in keyof Events as `on${K}`]?: EventCallback<Events, K>;
};
const props: Props = $props();
onMount(() => {
const events: EventMap<Events> = {};
for (const [name, listener] of Object.entries(props)) {
if (listener) {
const event = name.slice(2) as keyof Events;
events[event] = listener as EventCallback<Events, typeof event>;
}
}
return assetViewerManager.on(events);
});
</script>
-187
View File
@@ -1,187 +0,0 @@
<script lang="ts">
import QueueCardBadge from '$lib/components/QueueCardBadge.svelte';
import QueueCardButton from '$lib/components/QueueCardButton.svelte';
import Badge from '$lib/elements/Badge.svelte';
import { 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="{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="{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,23 +0,0 @@
<script lang="ts" module>
export type Color = 'success' | 'warning';
</script>
<script lang="ts">
import type { Snippet } from 'svelte';
interface Props {
color: Color;
children?: Snippet;
}
let { color, children }: Props = $props();
const colorClasses: Record<Color, string> = {
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="w-full p-2 text-center text-sm {colorClasses[color]}">
{@render children?.()}
</div>
@@ -1,37 +0,0 @@
<script lang="ts" module>
export type Colors = 'light-gray' | 'gray' | 'dark-gray';
</script>
<script lang="ts">
import type { Snippet } from 'svelte';
interface Props {
color: Colors;
disabled?: boolean;
children?: Snippet;
onClick?: () => void;
}
let { color, disabled = false, onClick = () => {}, children }: Props = $props();
const colorClasses: Record<Colors, string> = {
'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',
};
const hoverClasses = disabled
? 'cursor-not-allowed'
: 'hover:bg-immich-primary hover:text-white dark:hover:bg-immich-dark-primary dark:hover:text-black';
</script>
<button
type="button"
{disabled}
class="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 {colorClasses[
color
]} {hoverClasses}"
onclick={onClick}
>
{@render children?.()}
</button>
-165
View File
@@ -1,165 +0,0 @@
<script lang="ts">
import { queueManager } from '$lib/managers/queue-manager.svelte';
import type { QueueSnapshot } from '$lib/types';
import type { QueueResponseDto } from '@immich/sdk';
import { LoadingSpinner, Theme, theme } from '@immich/ui';
import { DateTime } from 'luxon';
import { onMount } from 'svelte';
import uPlot, { type AlignedData, type Axis } from 'uplot';
import 'uplot/dist/uPlot.min.css';
type Props = {
queue: QueueResponseDto;
class?: string;
};
const { queue, class: className = '' }: Props = $props();
type Data = number | null;
type NormalizedData = [
Data[], // timestamps
Data[], // failed counts
Data[], // active counts
Data[], // waiting counts
];
const normalizeData = (snapshots: QueueSnapshot[]) => {
const items: NormalizedData = [[], [], [], []];
for (const { timestamp, snapshot } of snapshots) {
items[0].push(timestamp);
const statistics = (snapshot || []).find(({ name }) => name === queue.name)?.statistics;
if (statistics) {
items[1].push(statistics.failed);
items[2].push(statistics.active);
items[3].push(statistics.waiting + statistics.paused);
} else {
items[0].push(timestamp);
items[1].push(null);
items[2].push(null);
items[3].push(null);
}
}
items[0].push(Date.now() + 5000);
items[1].push(items[1].at(-1) ?? 0);
items[2].push(items[2].at(-1) ?? 0);
items[3].push(items[3].at(-1) ?? 0);
return items;
};
const data = $derived(normalizeData(queueManager.snapshots));
let chartElement: HTMLDivElement | undefined = $state();
let isDark = $derived(theme.value === Theme.Dark);
let plot: uPlot;
const axisOptions: Axis = {
stroke: () => (isDark ? '#ccc' : 'black'),
ticks: {
show: false,
stroke: () => (isDark ? '#444' : '#ddd'),
},
grid: {
show: true,
stroke: () => (isDark ? '#444' : '#ddd'),
},
};
const seriesOptions: uPlot.Series = {
spanGaps: false,
points: {
show: false,
},
width: 2,
};
const options: uPlot.Options = {
legend: {
show: false,
},
cursor: {
show: false,
lock: true,
drag: {
setScale: false,
},
},
width: 200,
height: 200,
ms: 1,
pxAlign: true,
scales: {
y: {
distr: 1,
},
},
series: [
{},
{
stroke: '#d94a4a',
...seriesOptions,
},
{
stroke: '#4250af',
...seriesOptions,
},
{
stroke: '#1075db',
...seriesOptions,
},
],
axes: [
{
...axisOptions,
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(() => theme.value && onThemeChange());
onMount(() => {
plot = new uPlot(options, data as AlignedData, chartElement);
});
const update = () => {
if (plot && chartElement && data[0].length > 0) {
const now = Date.now();
const scale = { min: now - chartElement!.clientWidth * 100, max: now };
plot.setData(data as AlignedData, false);
plot.setScale('x', scale);
plot.setSize({ width: chartElement.clientWidth, height: chartElement.clientHeight });
}
requestAnimationFrame(update);
};
requestAnimationFrame(update);
</script>
<div class="w-full {className}" bind:this={chartElement}>
{#if data[0].length === 0}
<LoadingSpinner size="giant" />
{/if}
</div>
-132
View File
@@ -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.success($t('admin.cleared_jobs', { values: { job: item.title } }));
break;
}
}
} catch (error) {
handleError(error, $t('admin.failed_job_command', { values: { command: dto.command, job: item.title } }));
}
};
</script>
<div class="flex flex-col gap-7 mt-10">
{#each queueList as [queueName, props] (queueName)}
{@const queue = queues.find(({ name }) => name === queueName)}
{#if queue}
<QueueCard {queue} onCommand={(command) => handleCommand(queueName, command)} {...props} />
{/if}
{/each}
</div>
@@ -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,24 +0,0 @@
<script lang="ts">
import { Label, Link, Text } from '@immich/ui';
type Props = {
id: string;
title: string;
version?: string;
versionHref?: string;
class?: string;
};
const { id, title, version, versionHref, class: className }: Props = $props();
</script>
<div class={className}>
<Label size="small" color="primary" for={id}>{title}</Label>
<Text size="small" color="muted" {id}>
{#if versionHref}
<Link href={versionHref}>{version}</Link>
{:else}
{version}
{/if}
</Text>
</div>
@@ -1,75 +0,0 @@
<script lang="ts">
import SettingSelect from '$lib/components/shared-components/settings/setting-select.svelte';
import { locale } from '$lib/stores/preferences.store';
import { minBy, uniqBy } from 'lodash-es';
import { DateTime, Duration } from 'luxon';
import { t } from 'svelte-i18n';
type Props = {
createdAt?: string;
expiresAt: string | null;
};
let { createdAt = DateTime.now().toISO(), expiresAt = $bindable() }: Props = $props();
const expirationOptions: [number, Intl.RelativeTimeFormatUnit][] = [
[30, 'minutes'],
[1, 'hour'],
[6, 'hours'],
[1, 'day'],
[7, 'days'],
[30, 'days'],
[3, 'months'],
[1, 'year'],
];
const relativeTime = $derived(new Intl.RelativeTimeFormat($locale));
const expiredDateOptions = $derived([
{ text: $t('never'), value: 0 },
...expirationOptions
.map(([value, unit]) => ({
text: relativeTime.format(value, unit),
value: Duration.fromObject({ [unit]: value }).toMillis(),
}))
.filter(({ value: millis }) => DateTime.fromISO(createdAt).plus(millis) > DateTime.now()),
]);
const getExpirationOption = (createdAt: string, expiresAt: string | null) => {
if (!expiresAt) {
return expiredDateOptions[0];
}
const delta = DateTime.fromISO(expiresAt).diff(DateTime.fromISO(createdAt)).toMillis();
const closestOption = minBy(expiredDateOptions, ({ value }) => Math.abs(delta - value));
if (!closestOption) {
return expiredDateOptions[0];
}
// allow a generous epsilon to compensate for potential API delays
if (Math.abs(closestOption.value - delta) > 10_000) {
const interval = DateTime.fromMillis(closestOption.value) as DateTime<true>;
return { text: interval.toRelative({ locale: $locale }), value: closestOption.value };
}
return closestOption;
};
const onSelect = (option: number | string) => {
const expirationOption = Number(option);
expiresAt = expirationOption === 0 ? null : DateTime.fromISO(createdAt).plus(expirationOption).toISO();
};
let expirationOption = $derived(getExpirationOption(createdAt, expiresAt).value);
</script>
<div class="mt-2">
<SettingSelect
bind:value={expirationOption}
{onSelect}
options={uniqBy([...expiredDateOptions, getExpirationOption(createdAt, expiresAt)], 'value')}
label={$t('expire_after')}
number={true}
/>
</div>
@@ -1,34 +0,0 @@
import { render } from '@testing-library/svelte';
import userEvent from '@testing-library/user-event';
import SharedLinkFormFields from './SharedLinkFormFields.svelte';
describe('SharedLinkFormFields component', () => {
const isChecked = (element: Element) =>
element instanceof HTMLInputElement ? element.checked : element.getAttribute('aria-checked') === 'true';
it('turns downloads off when metadata is disabled', async () => {
const { container } = render(SharedLinkFormFields, {
props: {
slug: '',
password: '',
description: '',
allowDownload: true,
allowUpload: false,
showMetadata: true,
expiresAt: null,
},
});
const user = userEvent.setup();
const switches = Array.from(container.querySelectorAll('[role="switch"], input[type="checkbox"]'));
expect(switches).toHaveLength(3);
const [showMetadataSwitch, allowDownloadSwitch] = switches;
expect(isChecked(allowDownloadSwitch)).toBe(true);
await user.click(showMetadataSwitch);
expect(isChecked(showMetadataSwitch)).toBe(false);
expect(isChecked(allowDownloadSwitch)).toBe(false);
});
});
@@ -1,65 +0,0 @@
<script lang="ts">
import SharedLinkExpiration from '$lib/components/SharedLinkExpiration.svelte';
import { Field, Input, PasswordInput, Switch, Text } from '@immich/ui';
import { t } from 'svelte-i18n';
type Props = {
slug: string;
password: string;
description: string;
allowDownload: boolean;
allowUpload: boolean;
showMetadata: boolean;
expiresAt: string | null;
createdAt?: string;
};
let {
slug = $bindable(),
password = $bindable(),
description = $bindable(),
allowDownload = $bindable(),
allowUpload = $bindable(),
showMetadata = $bindable(),
expiresAt = $bindable(),
createdAt,
}: Props = $props();
$effect(() => {
if (!showMetadata && allowDownload) {
allowDownload = false;
}
});
</script>
<div class="flex flex-col gap-4 mt-4">
<div>
<Field label={$t('custom_url')} description={$t('shared_link_custom_url_description')}>
<Input bind:value={slug} autocomplete="off" />
</Field>
{#if slug}
<Text size="tiny" color="muted" class="pt-2 break-all">/s/{encodeURIComponent(slug)}</Text>
{/if}
</div>
<Field label={$t('password')} description={$t('shared_link_password_description')}>
<PasswordInput bind:value={password} autocomplete="new-password" />
</Field>
<Field label={$t('description')}>
<Input bind:value={description} autocomplete="off" />
</Field>
<SharedLinkExpiration {createdAt} bind:expiresAt />
<Field label={$t('show_metadata')}>
<Switch bind:checked={showMetadata} />
</Field>
<Field label={$t('allow_public_user_to_download')} disabled={!showMetadata}>
<Switch bind:checked={allowDownload} />
</Field>
<Field label={$t('allow_public_user_to_upload')}>
<Switch bind:checked={allowUpload} />
</Field>
</div>
@@ -1,41 +0,0 @@
<script lang="ts">
import OnEvents from '$lib/components/OnEvents.svelte';
import VersionAnnouncementModal from '$lib/modals/VersionAnnouncementModal.svelte';
import { user } from '$lib/stores/user.store';
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 || !$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,47 +0,0 @@
<script lang="ts">
import BreadcrumbActionPage from '$lib/components/BreadcrumbActionPage.svelte';
import NavigationBar from '$lib/components/shared-components/navigation-bar/navigation-bar.svelte';
import BottomInfo from '$lib/components/shared-components/side-bar/bottom-info.svelte';
import { Route } from '$lib/route';
import { sidebarStore } from '$lib/stores/sidebar.svelte';
import type { HeaderButtonActionItem } from '$lib/types';
import { AppShell, AppShellHeader, AppShellSidebar, MenuItemType, NavbarItem, type BreadcrumbItem } from '@immich/ui';
import { mdiAccountMultipleOutline, mdiBookshelf, mdiCog, mdiServer, mdiTrayFull, mdiWrench } from '@mdi/js';
import type { Snippet } from 'svelte';
import { t } from 'svelte-i18n';
type Props = {
breadcrumbs: BreadcrumbItem[];
actions?: Array<HeaderButtonActionItem | MenuItemType>;
children?: Snippet;
};
let { breadcrumbs, actions, children }: Props = $props();
</script>
<AppShell>
<AppShellHeader>
<NavigationBar noBorder />
</AppShellHeader>
<AppShellSidebar
bind:open={sidebarStore.isOpen}
class="border-none shadow-none h-full flex flex-col justify-between gap-2"
>
<div class="flex flex-col pt-8 pe-4 gap-1">
<NavbarItem title={$t('users')} href={Route.users()} icon={mdiAccountMultipleOutline} />
<NavbarItem title={$t('external_libraries')} href={Route.libraries()} icon={mdiBookshelf} />
<NavbarItem title={$t('admin.queues')} href={Route.queues()} icon={mdiTrayFull} />
<NavbarItem title={$t('settings')} href={Route.systemSettings()} icon={mdiCog} />
<NavbarItem title={$t('admin.maintenance_settings')} href={Route.systemMaintenance()} icon={mdiWrench} />
<NavbarItem title={$t('server_stats')} href={Route.systemStatistics()} icon={mdiServer} />
</div>
<div class="mb-2 me-4">
<BottomInfo />
</div>
</AppShellSidebar>
<BreadcrumbActionPage {breadcrumbs} {actions}>
{@render children?.()}
</BreadcrumbActionPage>
</AppShell>
@@ -11,9 +11,8 @@
Link,
Logo,
Text,
VStack,
} from '@immich/ui';
import { mdiAlarmLight, mdiCodeTags, mdiContentCopy, mdiMessage, mdiPartyPopper } from '@mdi/js';
import { mdiAlarmLight, mdiContentCopy } from '@mdi/js';
import { t } from 'svelte-i18n';
interface Props {
@@ -34,7 +33,7 @@
<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">
<Link href="/">
<Logo variant="inline" />
</Link>
</div>
@@ -66,24 +65,7 @@
</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>
<Text size="small" class="text-center">{$t('check_logs')}</Text>
</CardFooter>
</Card>
</div>
@@ -4,13 +4,10 @@
<script lang="ts">
import { useActions, type ActionArray } from '$lib/actions/use-actions';
import NavigationBar from '$lib/components/shared-components/navigation-bar/navigation-bar.svelte';
import UserSidebar from '$lib/components/shared-components/side-bar/user-sidebar.svelte';
import type { HeaderButtonActionItem } from '$lib/types';
import { openFileUploadDialog } from '$lib/utils/file-uploader';
import { Button, ContextMenuButton, HStack, isMenuItemType, type MenuItemType } from '@immich/ui';
import { Button, HStack, isMenuItemType, type MenuItemType } from '@immich/ui';
import type { Snippet } from 'svelte';
import { t } from 'svelte-i18n';
interface Props {
hideNavbar?: boolean;
@@ -46,11 +43,6 @@
let hasTitleClass = $derived(title ? 'top-16 h-[calc(100%-(--spacing(16)))]' : 'top-0 h-full');
</script>
<header>
{#if !hideNavbar}
<NavigationBar onUploadClick={() => openFileUploadDialog()} />
{/if}
</header>
<div
tabindex="-1"
class="relative z-0 grid grid-cols-[--spacing(0)_auto] overflow-hidden sidebar:grid-cols-[--spacing(64)_auto]
@@ -99,8 +91,6 @@
{/each}
</HStack>
</div>
<ContextMenuButton aria-label={$t('open')} items={actions} class="md:hidden" />
{/if}
</div>
{/if}
@@ -1,14 +0,0 @@
<script lang="ts">
import { page } from '$app/state';
</script>
<svelte:head>
<title>Oops! Error - Immich</title>
</svelte:head>
<section class="flex flex-col px-4 h-dvh w-dvw place-content-center place-items-center">
<h1 class="py-10 text-4xl text-primary">Page not found :/</h1>
{#if page.error?.message}
<h2 class="text-xl text-immich-fg dark:text-immich-dark-fg">{page.error.message}</h2>
{/if}
</section>
@@ -1,108 +0,0 @@
<script lang="ts">
import AlbumViewer from '$lib/components/album-page/album-viewer.svelte';
import IndividualSharedViewer from '$lib/components/share-page/individual-shared-viewer.svelte';
import ControlAppBar from '$lib/components/shared-components/control-app-bar.svelte';
import ThemeButton from '$lib/components/shared-components/theme-button.svelte';
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
import { user } from '$lib/stores/user.store';
import { setSharedLink } from '$lib/utils';
import { handleError } from '$lib/utils/handle-error';
import { navigate } from '$lib/utils/navigation';
import { getMySharedLink, SharedLinkType, type AssetResponseDto, type SharedLinkResponseDto } from '@immich/sdk';
import { Button, Logo, PasswordInput } from '@immich/ui';
import { tick } from 'svelte';
import { t } from 'svelte-i18n';
type Props = {
data: {
meta: {
title: string;
description?: string;
imageUrl?: string;
};
sharedLink?: SharedLinkResponseDto;
key?: string;
slug?: string;
asset?: AssetResponseDto;
passwordRequired?: boolean;
};
};
const { data }: Props = $props();
let { gridScrollTarget } = assetViewingStore;
let { sharedLink, passwordRequired, key, slug, meta } = $state(data);
let { title, description } = $state(meta);
let isOwned = $derived($user ? $user.id === sharedLink?.userId : false);
let password = $state('');
const handlePasswordSubmit = async () => {
try {
sharedLink = await getMySharedLink({ password, key, slug });
setSharedLink(sharedLink);
passwordRequired = false;
title = (sharedLink.album ? sharedLink.album.albumName : $t('public_share')) + ' - Immich';
description =
sharedLink.description ||
$t('shared_photos_and_videos_count', { values: { assetCount: sharedLink.assets.length } });
await tick();
await navigate(
{ targetRoute: 'current', assetId: null, assetGridRouteSearchParams: $gridScrollTarget },
{ forceNavigate: true, replaceState: true },
);
} catch (error) {
handleError(error, $t('errors.unable_to_get_shared_link'));
}
};
const onsubmit = async (event: Event) => {
event.preventDefault();
await handlePasswordSubmit();
};
</script>
<svelte:head>
<title>{title}</title>
<meta name="description" content={description} />
</svelte:head>
{#if passwordRequired}
<main
class="relative h-dvh overflow-hidden px-6 max-md:pt-(--navbar-height-md) pt-(--navbar-height) sm:px-12 md:px-24 lg:px-40"
>
<div class="flex flex-col items-center justify-center mt-20">
<div class="text-2xl font-bold text-primary">{$t('password_required')}</div>
<div class="mt-4 text-lg text-primary">
{$t('sharing_enter_password')}
</div>
<div class="mt-4">
<form class="flex gap-x-2" novalidate {onsubmit}>
<PasswordInput autocomplete="off" bind:value={password} placeholder="Password" />
<Button type="submit">{$t('submit')}</Button>
</form>
</div>
</div>
</main>
<header>
<ControlAppBar showBackButton={false}>
{#snippet leading()}
<a data-sveltekit-preload-data="hover" class="ms-4" href="/">
<Logo variant="inline" />
</a>
{/snippet}
{#snippet trailing()}
<ThemeButton />
{/snippet}
</ControlAppBar>
</header>
{/if}
{#if !passwordRequired && sharedLink?.type == SharedLinkType.Album}
<AlbumViewer {sharedLink} />
{/if}
{#if !passwordRequired && sharedLink?.type == SharedLinkType.Individual}
<div class="immich-scrollbar">
<IndividualSharedViewer {sharedLink} {isOwned} />
</div>
{/if}
@@ -1,81 +0,0 @@
import NumberRangeInput from '$lib/components/shared-components/number-range-input.svelte';
import { render, type RenderResult } from '@testing-library/svelte';
import userEvent from '@testing-library/user-event';
import type { Mock } from 'vitest';
describe('NumberRangeInput component', () => {
const user = userEvent.setup();
let sut: RenderResult<NumberRangeInput>;
let input: HTMLInputElement;
let onInput: Mock;
let onKeyDown: Mock;
beforeEach(() => {
onInput = vi.fn();
onKeyDown = vi.fn();
sut = render(NumberRangeInput, {
id: '',
min: -90,
max: 90,
onInput,
onKeyDown,
});
input = sut.getByRole('spinbutton') as HTMLInputElement;
});
it('updates value', async () => {
expect(input.value).toBe('');
await sut.rerender({ value: 10 });
expect(input.value).toBe('10');
expect(onInput).not.toHaveBeenCalled();
expect(onKeyDown).not.toHaveBeenCalled();
});
it('restricts minimum value', async () => {
await user.type(input, '-91');
expect(input.value).toBe('-90');
expect(onInput).toHaveBeenCalled();
expect(onKeyDown).toHaveBeenCalled();
});
it('restricts maximum value', async () => {
await user.type(input, '09990');
expect(input.value).toBe('90');
expect(onInput).toHaveBeenCalled();
expect(onKeyDown).toHaveBeenCalled();
});
it('allows entering negative numbers', async () => {
await user.type(input, '-10');
expect(input.value).toBe('-10');
expect(onInput).toHaveBeenCalled();
expect(onKeyDown).toHaveBeenCalled();
});
it('allows entering zero', async () => {
await user.type(input, '0');
expect(input.value).toBe('0');
expect(onInput).toHaveBeenCalled();
expect(onKeyDown).toHaveBeenCalled();
});
it('allows entering decimal numbers', async () => {
await user.type(input, '-0.09001');
expect(input.value).toBe('-0.09001');
expect(onInput).toHaveBeenCalled();
expect(onKeyDown).toHaveBeenCalled();
});
it('ignores text input', async () => {
await user.type(input, 'test');
expect(input.value).toBe('');
expect(onInput).toHaveBeenCalled();
expect(onKeyDown).toHaveBeenCalled();
});
it('test', async () => {
await user.type(input, 'd');
expect(onInput).not.toHaveBeenCalled();
expect(onKeyDown).toHaveBeenCalled();
});
});
@@ -1,181 +0,0 @@
import {
type AlbumModalRow,
AlbumModalRowConverter,
AlbumModalRowType,
} from '$lib/components/shared-components/album-selection/album-selection-utils';
import { AlbumSortBy, SortOrder } from '$lib/stores/preferences.store';
import type { AlbumResponseDto } from '@immich/sdk';
import { albumFactory } from '@test-data/factories/album-factory';
// Some helper functions to make tests below more readable
const createNewAlbumRow = (selected: boolean) => ({
type: AlbumModalRowType.NEW_ALBUM,
selected,
});
const createMessageRow = (message: string): AlbumModalRow => ({
type: AlbumModalRowType.MESSAGE,
text: message,
});
const createSectionRow = (message: string): AlbumModalRow => ({
type: AlbumModalRowType.SECTION,
text: message,
});
const createAlbumRow = (album: AlbumResponseDto, selected: boolean) => ({
type: AlbumModalRowType.ALBUM_ITEM,
album,
selected,
multiSelected: false,
});
describe('Album Modal', () => {
it('non-shared with no albums configured yet shows message and new', () => {
const converter = new AlbumModalRowConverter(false, AlbumSortBy.MostRecentPhoto, SortOrder.Desc);
const modalRows = converter.toModalRows('', [], [], -1, []);
expect(modalRows).toStrictEqual([createNewAlbumRow(false), createMessageRow('no_albums_yet')]);
});
it('non-shared with no matching albums shows message and new', () => {
const converter = new AlbumModalRowConverter(false, AlbumSortBy.MostRecentPhoto, SortOrder.Desc);
const modalRows = converter.toModalRows(
'matches_nothing',
[],
[albumFactory.build({ albumName: 'Holidays' })],
-1,
[],
);
expect(modalRows).toStrictEqual([createNewAlbumRow(false), createMessageRow('no_albums_with_name_yet')]);
});
it('non-shared displays single albums', () => {
const converter = new AlbumModalRowConverter(false, AlbumSortBy.MostRecentPhoto, SortOrder.Desc);
const holidayAlbum = albumFactory.build({ albumName: 'Holidays' });
const modalRows = converter.toModalRows('', [], [holidayAlbum], -1, []);
expect(modalRows).toStrictEqual([
createNewAlbumRow(false),
createSectionRow('ALL_ALBUMS'),
createAlbumRow(holidayAlbum, false),
]);
});
it('non-shared displays multiple albums and recents', () => {
const converter = new AlbumModalRowConverter(false, AlbumSortBy.MostRecentPhoto, SortOrder.Desc);
const holidayAlbum = albumFactory.build({ albumName: 'Holidays' });
const constructionAlbum = albumFactory.build({ albumName: 'Construction' });
const birthdayAlbum = albumFactory.build({ albumName: 'Birthday' });
const christmasAlbum = albumFactory.build({ albumName: 'Christmas' });
const modalRows = converter.toModalRows(
'',
[holidayAlbum, constructionAlbum],
[holidayAlbum, constructionAlbum, birthdayAlbum, christmasAlbum],
-1,
[],
);
expect(modalRows).toStrictEqual([
createNewAlbumRow(false),
createSectionRow('RECENT'),
createAlbumRow(holidayAlbum, false),
createAlbumRow(constructionAlbum, false),
createSectionRow('ALL_ALBUMS'),
createAlbumRow(holidayAlbum, false),
createAlbumRow(constructionAlbum, false),
createAlbumRow(birthdayAlbum, false),
createAlbumRow(christmasAlbum, false),
]);
});
it('shared only displays albums and no recents', () => {
const converter = new AlbumModalRowConverter(true, AlbumSortBy.MostRecentPhoto, SortOrder.Desc);
const holidayAlbum = albumFactory.build({ albumName: 'Holidays' });
const constructionAlbum = albumFactory.build({ albumName: 'Construction' });
const birthdayAlbum = albumFactory.build({ albumName: 'Birthday' });
const christmasAlbum = albumFactory.build({ albumName: 'Christmas' });
const modalRows = converter.toModalRows(
'',
[holidayAlbum, constructionAlbum],
[holidayAlbum, constructionAlbum, birthdayAlbum, christmasAlbum],
-1,
[],
);
expect(modalRows).toStrictEqual([
createNewAlbumRow(false),
createAlbumRow(holidayAlbum, false),
createAlbumRow(constructionAlbum, false),
createAlbumRow(birthdayAlbum, false),
createAlbumRow(christmasAlbum, false),
]);
});
it('search changes messaging and removes recent and non-matching albums', () => {
const converter = new AlbumModalRowConverter(false, AlbumSortBy.MostRecentPhoto, SortOrder.Desc);
const holidayAlbum = albumFactory.build({ albumName: 'Holidays' });
const constructionAlbum = albumFactory.build({ albumName: 'Construction' });
const birthdayAlbum = albumFactory.build({ albumName: 'Birthday' });
const christmasAlbum = albumFactory.build({ albumName: 'Christmas' });
const modalRows = converter.toModalRows(
'Cons',
[holidayAlbum, constructionAlbum],
[holidayAlbum, constructionAlbum, birthdayAlbum, christmasAlbum],
-1,
[],
);
expect(modalRows).toStrictEqual([
createNewAlbumRow(false),
createSectionRow('ALBUMS'),
createAlbumRow(constructionAlbum, false),
]);
});
it('selection can select new album row', () => {
const converter = new AlbumModalRowConverter(false, AlbumSortBy.MostRecentPhoto, SortOrder.Desc);
const holidayAlbum = albumFactory.build({ albumName: 'Holidays' });
const constructionAlbum = albumFactory.build({ albumName: 'Construction' });
const modalRows = converter.toModalRows('', [holidayAlbum], [holidayAlbum, constructionAlbum], 0, []);
expect(modalRows).toStrictEqual([
createNewAlbumRow(true),
createSectionRow('RECENT'),
createAlbumRow(holidayAlbum, false),
createSectionRow('ALL_ALBUMS'),
createAlbumRow(holidayAlbum, false),
createAlbumRow(constructionAlbum, false),
]);
});
it('selection can select recent row', () => {
const converter = new AlbumModalRowConverter(false, AlbumSortBy.MostRecentPhoto, SortOrder.Desc);
const holidayAlbum = albumFactory.build({ albumName: 'Holidays' });
const constructionAlbum = albumFactory.build({ albumName: 'Construction' });
const modalRows = converter.toModalRows('', [holidayAlbum], [holidayAlbum, constructionAlbum], 1, []);
expect(modalRows).toStrictEqual([
createNewAlbumRow(false),
createSectionRow('RECENT'),
createAlbumRow(holidayAlbum, true),
createSectionRow('ALL_ALBUMS'),
createAlbumRow(holidayAlbum, false),
createAlbumRow(constructionAlbum, false),
]);
});
it('selection can select last row', () => {
const converter = new AlbumModalRowConverter(false, AlbumSortBy.MostRecentPhoto, SortOrder.Desc);
const holidayAlbum = albumFactory.build({ albumName: 'Holidays' });
const constructionAlbum = albumFactory.build({ albumName: 'Construction' });
const modalRows = converter.toModalRows('', [holidayAlbum], [holidayAlbum, constructionAlbum], 3, []);
expect(modalRows).toStrictEqual([
createNewAlbumRow(false),
createSectionRow('RECENT'),
createAlbumRow(holidayAlbum, false),
createSectionRow('ALL_ALBUMS'),
createAlbumRow(holidayAlbum, false),
createAlbumRow(constructionAlbum, true),
]);
});
});
@@ -1,97 +0,0 @@
import { sortAlbums } from '$lib/utils/album-utils';
import { normalizeSearchString } from '$lib/utils/string-utils';
import type { AlbumResponseDto } from '@immich/sdk';
import { t } from 'svelte-i18n';
import { get } from 'svelte/store';
export const SCROLL_PROPERTIES: ScrollIntoViewOptions = { block: 'center', behavior: 'smooth' };
export enum AlbumModalRowType {
SECTION = 'section',
MESSAGE = 'message',
NEW_ALBUM = 'newAlbum',
ALBUM_ITEM = 'albumItem',
}
export type AlbumModalRow = {
type: AlbumModalRowType;
selected?: boolean;
multiSelected?: boolean;
text?: string;
album?: AlbumResponseDto;
};
export const isSelectableRowType = (type: AlbumModalRowType) =>
type === AlbumModalRowType.NEW_ALBUM || type === AlbumModalRowType.ALBUM_ITEM;
const $t = get(t);
export class AlbumModalRowConverter {
private readonly shared: boolean;
private readonly sortBy: string;
private readonly orderBy: string;
constructor(shared: boolean, sortBy: string, orderBy: string) {
this.shared = shared;
this.sortBy = sortBy;
this.orderBy = orderBy;
}
toModalRows(
search: string,
recentAlbums: AlbumResponseDto[],
albums: AlbumResponseDto[],
selectedRowIndex: number,
multiSelectedAlbumIds: string[],
): AlbumModalRow[] {
// only show recent albums if no search was entered, or we're in the normal albums (non-shared) modal.
const recentAlbumsToShow = !this.shared && search.length === 0 ? recentAlbums : [];
const rows: AlbumModalRow[] = [{ type: AlbumModalRowType.NEW_ALBUM, selected: selectedRowIndex === 0 }];
const filteredAlbums = sortAlbums(
search.length > 0 && albums.length > 0
? albums.filter((album) => {
return normalizeSearchString(album.albumName).includes(normalizeSearchString(search));
})
: albums,
{ sortBy: this.sortBy, orderBy: this.orderBy },
);
if (filteredAlbums.length > 0) {
if (recentAlbumsToShow.length > 0) {
rows.push({ type: AlbumModalRowType.SECTION, text: $t('recent').toUpperCase() });
const selectedOffsetDueToNewAlbumRow = 1;
for (const [i, album] of recentAlbums.entries()) {
rows.push({
type: AlbumModalRowType.ALBUM_ITEM,
selected: selectedRowIndex === i + selectedOffsetDueToNewAlbumRow,
multiSelected: multiSelectedAlbumIds.includes(album.id),
album,
});
}
}
if (!this.shared) {
rows.push({
type: AlbumModalRowType.SECTION,
text: (search.length === 0 ? $t('all_albums') : $t('albums')).toUpperCase(),
});
}
const selectedOffsetDueToNewAndRecents = 1 + recentAlbumsToShow.length;
for (const [i, album] of filteredAlbums.entries()) {
rows.push({
type: AlbumModalRowType.ALBUM_ITEM,
selected: selectedRowIndex === i + selectedOffsetDueToNewAndRecents,
multiSelected: multiSelectedAlbumIds.includes(album.id),
album,
});
}
} else if (albums.length > 0) {
rows.push({ type: AlbumModalRowType.MESSAGE, text: $t('no_albums_with_name_yet') });
} else {
rows.push({ type: AlbumModalRowType.MESSAGE, text: $t('no_albums_yet') });
}
return rows;
}
}
@@ -1,40 +0,0 @@
<script lang="ts">
import { SCROLL_PROPERTIES } from '$lib/components/shared-components/album-selection/album-selection-utils';
import { Icon } from '@immich/ui';
import { mdiPlus } from '@mdi/js';
import { t } from 'svelte-i18n';
import type { Action } from 'svelte/action';
interface Props {
searchQuery?: string;
selected: boolean;
onNewAlbum: (search: string) => void;
}
let { searchQuery = '', selected = false, onNewAlbum }: Props = $props();
const scrollIntoViewIfSelected: Action = (node) => {
$effect(() => {
if (selected) {
node.scrollIntoView(SCROLL_PROPERTIES);
}
});
};
</script>
<button
type="button"
onclick={() => onNewAlbum(searchQuery)}
use:scrollIntoViewIfSelected
class="flex w-full items-center gap-4 px-6 py-2 transition-colors hover:bg-gray-200 dark:hover:bg-gray-700 rounded-xl"
class:bg-gray-200={selected}
class:dark:bg-gray-700={selected}
>
<div class="flex h-12 w-12 items-center justify-center">
<Icon icon={mdiPlus} size="30" />
</div>
<p class="">
{$t('new_album')}
{#if searchQuery.length > 0}<b>{searchQuery}</b>{/if}
</p>
</button>
@@ -1,232 +0,0 @@
<script lang="ts">
import { clickOutside } from '$lib/actions/click-outside';
import { listNavigation } from '$lib/actions/list-navigation';
import CoordinatesInput from '$lib/components/shared-components/coordinates-input.svelte';
import type Map from '$lib/components/shared-components/map/map.svelte';
import { timeDebounceOnSearch, timeToLoadTheMap } from '$lib/constants';
import SearchBar from '$lib/elements/SearchBar.svelte';
import { lastChosenLocation } from '$lib/stores/asset-editor.store';
import { delay } from '$lib/utils/asset-utils';
import { handleError } from '$lib/utils/handle-error';
import { searchPlaces, type AssetResponseDto, type PlacesResponseDto } from '@immich/sdk';
import { ConfirmModal, LoadingSpinner } from '@immich/ui';
import { mdiMapMarkerMultipleOutline } from '@mdi/js';
import { t } from 'svelte-i18n';
import { get } from 'svelte/store';
interface Point {
lng: number;
lat: number;
}
interface Props {
asset?: AssetResponseDto | undefined;
point?: Point;
onClose: (point?: Point) => void;
}
let { asset = undefined, point: initialPoint, onClose }: Props = $props();
let places: PlacesResponseDto[] = $state([]);
let suggestedPlaces: PlacesResponseDto[] = $derived(places.slice(0, 5));
let searchWord: string = $state('');
let latestSearchTimeout: number;
let showLoadingSpinner = $state(false);
let suggestionContainer: HTMLDivElement | undefined = $state();
let hideSuggestion = $state(false);
let mapElement = $state<ReturnType<typeof Map>>();
let previousLocation = get(lastChosenLocation);
let assetLat = $derived(initialPoint?.lat ?? asset?.exifInfo?.latitude ?? undefined);
let assetLng = $derived(initialPoint?.lng ?? asset?.exifInfo?.longitude ?? undefined);
let mapLat = $derived(assetLat ?? previousLocation?.lat ?? undefined);
let mapLng = $derived(assetLng ?? previousLocation?.lng ?? undefined);
let zoom = $derived(mapLat && mapLng ? 12.5 : 1);
$effect(() => {
if (mapElement && initialPoint) {
mapElement.addClipMapMarker(initialPoint.lng, initialPoint.lat);
}
});
$effect(() => {
if (searchWord === '') {
suggestedPlaces = [];
}
});
let point: Point | null = $state(initialPoint ?? null);
const handleConfirm = (confirmed?: boolean) => {
if (point && confirmed) {
lastChosenLocation.set(point);
onClose(point);
} else {
onClose();
}
};
const getLocation = (name: string, admin1Name?: string, admin2Name?: string): string => {
return `${name}${admin1Name ? ', ' + admin1Name : ''}${admin2Name ? ', ' + admin2Name : ''}`;
};
const handleSearchPlaces = () => {
if (latestSearchTimeout) {
clearTimeout(latestSearchTimeout);
}
showLoadingSpinner = true;
// eslint-disable-next-line unicorn/prefer-global-this
const searchTimeout = window.setTimeout(() => {
if (searchWord === '') {
places = [];
showLoadingSpinner = false;
return;
}
// Try to parse coordinate pair from search input in the format `LATITUDE, LONGITUDE` as floats
const coordinateParts = searchWord.split(',').map((part) => part.trim());
if (coordinateParts.length === 2) {
const coordinateLat = Number.parseFloat(coordinateParts[0]);
const coordinateLng = Number.parseFloat(coordinateParts[1]);
if (
!Number.isNaN(coordinateLat) &&
!Number.isNaN(coordinateLng) &&
coordinateLat >= -90 &&
coordinateLat <= 90 &&
coordinateLng >= -180 &&
coordinateLng <= 180
) {
places = [];
showLoadingSpinner = false;
handleUseSuggested(coordinateLat, coordinateLng);
return;
}
}
searchPlaces({ name: searchWord })
.then((searchResult) => {
// skip result when a newer search is happening
if (latestSearchTimeout === searchTimeout) {
places = searchResult;
showLoadingSpinner = false;
}
})
.catch((error) => {
// skip error when a newer search is happening
if (latestSearchTimeout === searchTimeout) {
places = [];
handleError(error, $t('errors.cant_search_places'));
showLoadingSpinner = false;
}
});
}, timeDebounceOnSearch);
latestSearchTimeout = searchTimeout;
};
const handleUseSuggested = (latitude: number, longitude: number) => {
hideSuggestion = true;
point = { lng: longitude, lat: latitude };
mapElement?.addClipMapMarker(longitude, latitude);
};
const onUpdate = (lat: number, lng: number) => {
point = { lat, lng };
mapElement?.addClipMapMarker(lng, lat);
};
</script>
<ConfirmModal
confirmColor="primary"
title={$t('change_location')}
icon={mdiMapMarkerMultipleOutline}
size="medium"
onClose={handleConfirm}
>
{#snippet prompt()}
<div class="flex flex-col w-full h-full gap-2">
<div class="relative w-64 sm:w-96 z-1">
{#if suggestionContainer}
<div use:listNavigation={suggestionContainer}>
<button type="button" class="w-full" onclick={() => (hideSuggestion = false)}>
<SearchBar
placeholder={$t('search_places')}
bind:name={searchWord}
{showLoadingSpinner}
onReset={() => (suggestedPlaces = [])}
onSearch={handleSearchPlaces}
roundedBottom={suggestedPlaces.length === 0 || hideSuggestion}
/>
</button>
</div>
{/if}
<div
class="absolute w-full"
id="suggestion"
bind:this={suggestionContainer}
use:clickOutside={{ onOutclick: () => (hideSuggestion = true) }}
>
{#if !hideSuggestion}
{#each suggestedPlaces as place, index (place.latitude + place.longitude)}
<button
type="button"
class=" flex w-full border-t border-gray-400 dark:border-immich-dark-gray h-14 place-items-center bg-gray-200 p-2 dark:bg-gray-700 hover:bg-gray-300 hover:dark:bg-[#232932] focus:bg-gray-300 focus:dark:bg-[#232932] {index ===
suggestedPlaces.length - 1
? 'rounded-b-lg border-b'
: ''}"
onclick={() => handleUseSuggested(place.latitude, place.longitude)}
>
<p class="ms-4 text-sm text-gray-700 dark:text-gray-100 truncate">
{getLocation(place.name, place.admin1name, place.admin2name)}
</p>
</button>
{/each}
{/if}
</div>
</div>
<span>{$t('pick_a_location')}</span>
<div class="h-125 min-h-75 w-full z-0">
{#await import('$lib/components/shared-components/map/map.svelte')}
{#await delay(timeToLoadTheMap) then}
<!-- show the loading spinner only if loading the map takes too much time -->
<div class="flex items-center justify-center h-full w-full">
<LoadingSpinner />
</div>
{/await}
{:then { default: Map }}
<Map
bind:this={mapElement}
mapMarkers={assetLat !== undefined && assetLng !== undefined && asset
? [
{
id: asset.id,
lat: assetLat,
lon: assetLng,
city: asset.exifInfo?.city ?? null,
state: asset.exifInfo?.state ?? null,
country: asset.exifInfo?.country ?? null,
},
]
: []}
{zoom}
center={mapLat && mapLng ? { lat: mapLat, lng: mapLng } : undefined}
simplified={true}
clickable={true}
onClickPoint={(selected) => (point = selected)}
showSettings={false}
rounded
/>
{/await}
</div>
<div class="grid sm:grid-cols-2 gap-4 text-sm text-start mt-4">
<CoordinatesInput lat={point ? point.lat : assetLat} lng={point ? point.lng : assetLng} {onUpdate} />
</div>
</div>
{/snippet}
</ConfirmModal>
@@ -1,205 +0,0 @@
<script lang="ts">
import { contextMenuNavigation } from '$lib/actions/context-menu-navigation';
import { shortcuts } from '$lib/actions/shortcut';
import ContextMenu from '$lib/components/shared-components/context-menu/context-menu.svelte';
import { languageManager } from '$lib/managers/language-manager.svelte';
import { optionClickCallbackStore, selectedIdStore } from '$lib/stores/context-menu.store';
import {
getContextMenuPositionFromBoundingRect,
getContextMenuPositionFromEvent,
type Align,
} from '$lib/utils/context-menu';
import { generateId } from '$lib/utils/generate-id';
import { IconButton, type Color, type Size, type Variants } from '@immich/ui';
import type { Snippet } from 'svelte';
import type { HTMLAttributes } from 'svelte/elements';
type Props = {
icon: string;
title: string;
/**
* The alignment of the context menu relative to the button.
*/
align?: Align;
/**
* The direction in which the context menu should open.
*/
// TODO change to start vs end
direction?: 'left' | 'right';
color?: Color;
size?: Size | undefined;
variant?: Variants | undefined;
/**
* Additional classes to apply to the button.
*/
buttonClass?: string | undefined;
hideContent?: boolean;
children?: Snippet;
offset?: {
x: number;
y: number;
};
} & HTMLAttributes<HTMLDivElement>;
let {
icon,
title,
align = 'top-left',
direction = 'right',
color = 'secondary',
size = undefined,
variant = 'ghost',
buttonClass = undefined,
hideContent = false,
children,
offset,
...restProps
}: Props = $props();
let isOpen = $state(false);
let contextMenuPosition = $state({ x: 0, y: 0 });
let menuContainer: HTMLUListElement | undefined = $state();
let buttonContainer: HTMLDivElement | undefined = $state();
const id = generateId();
const buttonId = `context-menu-button-${id}`;
const menuId = `context-menu-${id}`;
const openDropdown = (event: KeyboardEvent | MouseEvent) => {
let layoutAlign = align;
if (languageManager.rtl) {
if (align.includes('left')) {
layoutAlign = align.replace('left', 'right') as Align;
} else if (align.includes('right')) {
layoutAlign = align.replace('right', 'left') as Align;
}
}
contextMenuPosition = getContextMenuPositionFromEvent(event, layoutAlign);
isOpen = true;
menuContainer?.focus();
};
const handleClick = (event: MouseEvent) => {
if (isOpen) {
closeDropdown();
return;
}
openDropdown(event);
};
const onEscape = (event: KeyboardEvent) => {
if (isOpen) {
// if the dropdown is open, stop the event from propagating
event.stopPropagation();
}
closeDropdown();
};
const onResize = () => {
if (!isOpen || !buttonContainer) {
return;
}
contextMenuPosition = getContextMenuPositionFromBoundingRect(buttonContainer.getBoundingClientRect(), align);
};
const closeDropdown = () => {
if (!isOpen) {
return;
}
focusButton();
isOpen = false;
$selectedIdStore = undefined;
};
const handleOptionClick = () => {
closeDropdown();
};
const handleDocumentClick = (event: MouseEvent) => {
if (!isOpen) {
return;
}
const target = event.target as Node | null;
if (buttonContainer?.contains(target)) {
return;
}
closeDropdown();
};
const focusButton = () => {
const button = buttonContainer?.querySelector(`#${buttonId}`) as HTMLButtonElement | null;
button?.focus();
};
$effect(() => {
if (isOpen) {
$optionClickCallbackStore = handleOptionClick;
}
});
</script>
<svelte:window onresize={onResize} />
<svelte:document onclick={handleDocumentClick} />
<div
use:contextMenuNavigation={{
closeDropdown,
container: menuContainer,
isOpen,
onEscape,
openDropdown,
selectedId: $selectedIdStore,
selectionChanged: (id) => ($selectedIdStore = id),
}}
onresize={onResize}
{...restProps}
>
<div bind:this={buttonContainer}>
<IconButton
{color}
{icon}
{size}
shape="round"
{variant}
aria-label={title}
aria-controls={menuId}
aria-expanded={isOpen}
aria-haspopup={true}
class={buttonClass}
id={buttonId}
onclick={handleClick}
/>
</div>
{#if isOpen || !hideContent}
<div
use:shortcuts={[
{
shortcut: { key: 'Tab' },
onShortcut: closeDropdown,
preventDefault: false,
},
{
shortcut: { key: 'Tab', shift: true },
onShortcut: closeDropdown,
preventDefault: false,
},
]}
>
<ContextMenu
{direction}
ariaActiveDescendant={$selectedIdStore}
ariaLabelledBy={buttonId}
bind:menuElement={menuContainer}
id={menuId}
isVisible={isOpen}
x={contextMenuPosition.x - (offset?.x ?? 0)}
y={contextMenuPosition.y + (offset?.y ?? 0)}
>
{@render children?.()}
</ContextMenu>
</div>
{/if}
</div>
@@ -1,91 +0,0 @@
<script lang="ts">
import { clickOutside } from '$lib/actions/click-outside';
import { languageManager } from '$lib/managers/language-manager.svelte';
import type { Snippet } from 'svelte';
import { quintOut } from 'svelte/easing';
import { slide } from 'svelte/transition';
interface Props {
isVisible?: boolean;
direction?: 'left' | 'right';
x?: number;
y?: number;
id?: string | undefined;
ariaLabel?: string | undefined;
ariaLabelledBy?: string | undefined;
ariaActiveDescendant?: string | undefined;
menuElement?: HTMLUListElement | undefined;
onClose?: (() => void) | undefined;
children?: Snippet;
}
let {
isVisible = false,
direction = 'right',
x = 0,
y = 0,
id = undefined,
ariaLabel = undefined,
ariaLabelledBy = undefined,
ariaActiveDescendant = undefined,
menuElement = $bindable(),
onClose = undefined,
children,
}: Props = $props();
const swap = (direction: string) => (direction === 'left' ? 'right' : 'left');
const layoutDirection = $derived(languageManager.rtl ? swap(direction) : direction);
const position = $derived.by(() => {
if (!menuElement) {
return { left: 0, top: 0 };
}
const rect = menuElement.getBoundingClientRect();
const directionWidth = layoutDirection === 'left' ? rect.width : 0;
const menuHeight = Math.min(menuElement.clientHeight, height) || 0;
const left = Math.max(8, Math.min(window.innerWidth - rect.width, x - directionWidth));
const top = Math.max(8, Math.min(window.innerHeight - menuHeight, y));
return { left, top };
});
// We need to bind clientHeight since the bounding box may return a height
// of zero when starting the 'slide' animation.
let height: number = $state(0);
let isTransitioned = $state(false);
</script>
<div
bind:clientHeight={height}
class="fixed min-w-50 w-max max-w-75 overflow-hidden rounded-lg shadow-lg z-1"
style:left="{position.left}px"
style:top="{position.top}px"
transition:slide={{ duration: 250, easing: quintOut }}
use:clickOutside={{ onOutclick: onClose }}
onintroend={() => {
isTransitioned = true;
}}
onoutrostart={() => {
isTransitioned = false;
}}
>
<ul
{id}
aria-activedescendant={ariaActiveDescendant ?? ''}
aria-label={ariaLabel}
aria-labelledby={ariaLabelledBy}
bind:this={menuElement}
class="{isVisible
? 'max-h-dvh'
: 'max-h-0'} flex flex-col transition-all duration-250 ease-in-out outline-none {isTransitioned
? 'overflow-auto'
: ''}"
role="menu"
tabindex="-1"
>
{@render children?.()}
</ul>
</div>
@@ -1,79 +0,0 @@
<script lang="ts">
import type { Shortcut } from '$lib/actions/shortcut';
import { shortcut as bindShortcut, shortcutLabel as computeShortcutLabel } from '$lib/actions/shortcut';
import { optionClickCallbackStore, selectedIdStore } from '$lib/stores/context-menu.store';
import { generateId } from '$lib/utils/generate-id';
import { Icon, type IconLike } from '@immich/ui';
interface Props {
text: string;
subtitle?: string;
icon?: IconLike;
activeColor?: string;
textColor?: string;
onClick: () => void;
shortcut?: Shortcut | null;
shortcutLabel?: string;
}
let {
text,
subtitle = '',
icon,
activeColor = 'bg-slate-300',
textColor = 'text-immich-fg dark:text-immich-dark-bg',
onClick,
shortcut = null,
shortcutLabel = '',
}: Props = $props();
let id: string = generateId();
let isActive = $derived($selectedIdStore === id);
const handleClick = () => {
$optionClickCallbackStore?.();
onClick();
};
if (shortcut && !shortcutLabel) {
shortcutLabel = computeShortcutLabel(shortcut);
}
const bindShortcutIfSet = shortcut
? (n: HTMLElement) => bindShortcut(n, { shortcut, onShortcut: onClick })
: () => {};
</script>
<svelte:document use:bindShortcutIfSet />
<!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_mouse_events_have_key_events -->
<li
{id}
onclick={handleClick}
onmouseover={() => ($selectedIdStore = id)}
onmouseleave={() => ($selectedIdStore = undefined)}
class="w-full p-4 text-start text-sm font-medium {textColor} focus:outline-none focus:ring-2 focus:ring-inset cursor-pointer border-gray-200 flex gap-2 items-center {isActive
? activeColor
: 'bg-slate-100'}"
role="menuitem"
>
{#if icon}
<Icon {icon} aria-hidden size="18" />
{/if}
<div class="w-full">
<div class="flex justify-between">
{text}
{#if shortcutLabel}
<span class="text-gray-500 ps-4">
{shortcutLabel}
</span>
{/if}
</div>
{#if subtitle}
<p class="text-xs text-gray-500">
{subtitle}
</p>
{/if}
</div>
</li>
@@ -1,111 +0,0 @@
<script lang="ts">
import { contextMenuNavigation } from '$lib/actions/context-menu-navigation';
import { shortcuts } from '$lib/actions/shortcut';
import ContextMenu from '$lib/components/shared-components/context-menu/context-menu.svelte';
import { optionClickCallbackStore, selectedIdStore } from '$lib/stores/context-menu.store';
import { generateId } from '$lib/utils/generate-id';
import { tick, type Snippet } from 'svelte';
interface Props {
title: string;
direction?: 'left' | 'right';
x?: number;
y?: number;
isOpen?: boolean;
onClose: (() => unknown) | undefined;
children?: Snippet;
}
let { title, direction = 'right', x = 0, y = 0, isOpen = false, onClose, children }: Props = $props();
let uniqueKey = $state({});
let menuContainer: HTMLUListElement | undefined = $state();
let triggerElement: HTMLElement | undefined = $state(undefined);
const id = generateId();
const menuId = `context-menu-${id}`;
const reopenContextMenu = async (event: MouseEvent) => {
const contextMenuEvent = new MouseEvent('contextmenu', {
bubbles: true,
cancelable: true,
// eslint-disable-next-line unicorn/prefer-global-this
view: window,
clientX: event.x,
clientY: event.y,
});
const elements = document.elementsFromPoint(event.x, event.y);
if (menuContainer && elements.includes(menuContainer)) {
// User end-clicked on the context menu itself, we keep the context
// menu as is
return;
}
closeContextMenu();
await tick();
uniqueKey = {};
// Event will bubble through the DOM tree
const sectionIndex = elements.indexOf(event.target as Element);
elements.at(sectionIndex + 1)?.dispatchEvent(contextMenuEvent);
};
const closeContextMenu = () => {
triggerElement?.focus();
onClose?.();
};
$effect(() => {
if (isOpen && menuContainer) {
triggerElement = document.activeElement as HTMLElement;
menuContainer.focus();
$optionClickCallbackStore = closeContextMenu;
}
});
const oncontextmenu = async (event: MouseEvent) => {
event.preventDefault();
await reopenContextMenu(event);
};
</script>
{#key uniqueKey}
{#if isOpen}
<div
use:contextMenuNavigation={{
closeDropdown: closeContextMenu,
container: menuContainer,
isOpen,
selectedId: $selectedIdStore,
selectionChanged: (id) => ($selectedIdStore = id),
}}
use:shortcuts={[
{
shortcut: { key: 'Tab' },
onShortcut: closeContextMenu,
},
{
shortcut: { key: 'Tab', shift: true },
onShortcut: closeContextMenu,
},
]}
>
<section class="fixed start-0 top-0 flex h-dvh w-dvw" {oncontextmenu} role="presentation">
<ContextMenu
{direction}
{x}
{y}
ariaActiveDescendant={$selectedIdStore}
ariaLabel={title}
bind:menuElement={menuContainer}
id={menuId}
isVisible
onClose={closeContextMenu}
>
{@render children?.()}
</ContextMenu>
</section>
</div>
{/if}
{/key}
@@ -1,104 +0,0 @@
<script lang="ts">
import { browser } from '$app/environment';
import { isSelectingAllAssets } from '$lib/stores/assets-store.svelte';
import { IconButton } from '@immich/ui';
import { mdiClose } from '@mdi/js';
import { onDestroy, onMount, type Snippet } from 'svelte';
import { t } from 'svelte-i18n';
import { fly } from 'svelte/transition';
interface Props {
showBackButton?: boolean;
backIcon?: string;
tailwindClasses?: string;
forceDark?: boolean;
multiRow?: boolean;
onClose?: () => void;
leading?: Snippet;
children?: Snippet;
trailing?: Snippet;
}
let {
showBackButton = true,
backIcon = mdiClose,
tailwindClasses = '',
forceDark = false,
multiRow = false,
onClose = () => {},
leading,
children,
trailing,
}: Props = $props();
let appBarBorder = $state('bg-light border border-transparent');
const onScroll = () => {
if (window.scrollY > 80) {
appBarBorder = 'border border-gray-200 bg-gray-50 dark:border-gray-600';
if (forceDark) {
appBarBorder = 'border border-gray-600';
}
} else {
appBarBorder = 'bg-light border border-transparent';
}
};
const handleClose = () => {
$isSelectingAllAssets = false;
onClose();
};
onMount(() => {
if (browser) {
document.addEventListener('scroll', onScroll, { passive: true });
}
});
onDestroy(() => {
if (browser) {
document.removeEventListener('scroll', onScroll);
}
});
</script>
<div in:fly={{ y: 10, duration: 200 }} class="absolute top-0 w-full bg-transparent">
<nav
id="asset-selection-app-bar"
class={[
'grid',
multiRow && 'grid-cols-[100%] md:grid-cols-[25%_50%_25%]',
!multiRow && 'grid-cols-[10%_80%_10%] sm:grid-cols-[25%_50%_25%]',
'justify-between lg:grid-cols-[25%_50%_25%]',
appBarBorder,
'mx-2 my-2 place-items-center rounded-lg p-2 max-md:p-0 transition-all',
tailwindClasses,
forceDark ? 'bg-immich-dark-gray! text-white' : 'bg-subtle dark:bg-immich-dark-gray',
]}
>
<div class="flex place-items-center sm:gap-6 justify-self-start dark:text-immich-dark-fg {forceDark ? 'dark' : ''}">
{#if showBackButton}
<IconButton
aria-label={$t('close')}
onclick={handleClose}
color="secondary"
shape="round"
variant="ghost"
icon={backIcon}
size="large"
/>
{/if}
{@render leading?.()}
</div>
<div class="w-full">
{@render children?.()}
</div>
<div class="max-[350px]:me-0 max-[350px]:gap-0 me-4 flex place-items-center gap-1 justify-self-end">
{@render trailing?.()}
</div>
</nav>
</div>
@@ -1,55 +0,0 @@
<script lang="ts">
import NumberRangeInput from '$lib/components/shared-components/number-range-input.svelte';
import { generateId } from '$lib/utils/generate-id';
import { convert } from 'geo-coordinates-parser';
import { t } from 'svelte-i18n';
interface Props {
lat?: number;
lng?: number;
onUpdate: (lat: number, lng: number) => void;
}
let { lat = $bindable(), lng = $bindable(), onUpdate }: Props = $props();
const id = generateId();
const onInput = () => {
if (lat != null && lng != null) {
onUpdate(lat, lng);
}
};
const onKeyDown = (event: KeyboardEvent) => {
event.stopPropagation();
};
const onPaste = (event: ClipboardEvent) => {
const pastedText = event.clipboardData?.getData('text/plain');
if (!pastedText) {
return;
}
try {
const parsed = convert(pastedText);
if (parsed) {
event.preventDefault();
lat = parsed.decimalLatitude;
lng = parsed.decimalLongitude;
onInput();
}
} catch {
// Invalid coordinate format, do nothing (let the default paste behavior occur)
}
};
</script>
<div>
<label class="immich-form-label" for="latitude-input-{id}">{$t('latitude')}</label>
<NumberRangeInput id="latitude-input-{id}" min={-90} max={90} {onKeyDown} {onInput} {onPaste} bind:value={lat} />
</div>
<div>
<label class="immich-form-label" for="longitude-input-{id}">{$t('longitude')}</label>
<NumberRangeInput id="longitude-input-{id}" min={-180} max={180} {onKeyDown} {onInput} {onPaste} bind:value={lng} />
</div>
@@ -1,174 +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);
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 ondragenter = (e: DragEvent) => {
e.preventDefault();
e.stopPropagation();
onDragEnter(e);
};
const ondragleave = (e: DragEvent) => {
e.preventDefault();
e.stopPropagation();
onDragLeave(e);
};
const ondrop = async (e: DragEvent) => {
e.preventDefault();
e.stopPropagation();
await onDrop(e);
};
const onDragOver = (e: DragEvent) => {
e.preventDefault();
e.stopPropagation();
};
</script>
<svelte:window onpaste={onPaste} />
<svelte:body {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,34 +0,0 @@
<script lang="ts">
import empty1Url from '$lib/assets/empty-1.svg';
interface Props {
onClick?: undefined | (() => unknown);
text: string;
fullWidth?: boolean;
src?: string;
title?: string;
class?: string;
}
let { onClick = undefined, text, fullWidth = false, src = empty1Url, title, class: className }: Props = $props();
let width = $derived(fullWidth ? 'w-full' : 'w-1/2');
const hoverClasses = onClick
? `border dark:border-immich-dark-gray hover:bg-immich-primary/5 dark:hover:bg-immich-dark-primary/25`
: '';
</script>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<svelte:element
this={onClick ? 'button' : 'div'}
onclick={onClick}
class="{width} {className} flex flex-col place-content-center place-items-center rounded-3xl bg-gray-50 p-5 dark:bg-immich-dark-gray {hoverClasses}"
>
<img {src} alt="" width="500" draggable="false" />
{#if title}
<h2 class="text-xl font-medium my-4">{title}</h2>
{/if}
<p class="text-immich-text-gray-500 dark:text-immich-dark-fg font-light text-center">{text}</p>
</svelte:element>
@@ -1,417 +0,0 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { shortcuts, type ShortcutOptions } from '$lib/actions/shortcut';
import type { Action } from '$lib/components/asset-viewer/actions/action';
import Thumbnail from '$lib/components/assets/thumbnail/thumbnail.svelte';
import { AssetAction } from '$lib/constants';
import Portal from '$lib/elements/Portal.svelte';
import { featureFlagsManager } from '$lib/managers/feature-flags-manager.svelte';
import type { TimelineAsset, Viewport } from '$lib/managers/timeline-manager/types';
import AssetDeleteConfirmModal from '$lib/modals/AssetDeleteConfirmModal.svelte';
import ShortcutsModal from '$lib/modals/ShortcutsModal.svelte';
import { Route } from '$lib/route';
import type { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
import { showDeleteModal } from '$lib/stores/preferences.store';
import { handlePromiseError } from '$lib/utils';
import { deleteAssets } from '$lib/utils/actions';
import {
archiveAssets,
cancelMultiselect,
getNextAsset,
getPreviousAsset,
navigateToAsset,
} from '$lib/utils/asset-utils';
import { moveFocus } from '$lib/utils/focus-util';
import { handleError } from '$lib/utils/handle-error';
import { getJustifiedLayoutFromAssets } from '$lib/utils/layout-utils';
import { navigate } from '$lib/utils/navigation';
import { isTimelineAsset, toTimelineAsset } from '$lib/utils/timeline-util';
import { AssetVisibility, type AssetResponseDto } from '@immich/sdk';
import { modalManager } from '@immich/ui';
import { debounce } from 'lodash-es';
import { t } from 'svelte-i18n';
type Props = {
assets: AssetResponseDto[];
assetInteraction: AssetInteraction;
disableAssetSelect?: boolean;
showArchiveIcon?: boolean;
viewport: Viewport;
onIntersected?: (() => void) | undefined;
showAssetName?: boolean;
onReload?: (() => void) | undefined;
pageHeaderOffset?: number;
slidingWindowOffset?: number;
arrowNavigation?: boolean;
};
let {
assets = $bindable(),
assetInteraction,
disableAssetSelect = false,
showArchiveIcon = false,
viewport,
onIntersected = undefined,
showAssetName = false,
onReload = undefined,
slidingWindowOffset = 0,
pageHeaderOffset = 0,
arrowNavigation = true,
}: Props = $props();
let { isViewing: isViewerOpen, asset: viewingAsset } = assetViewingStore;
const geometry = $derived(
getJustifiedLayoutFromAssets(assets, {
spacing: 2,
heightTolerance: 0.5,
rowHeight: Math.floor(viewport.width) < 850 ? 100 : 235,
rowWidth: Math.floor(viewport.width),
}),
);
const getStyle = (i: number) => {
const geo = geometry;
return `top: ${geo.getTop(i)}px; left: ${geo.getLeft(i)}px; width: ${geo.getWidth(i)}px; height: ${geo.getHeight(i)}px;`;
};
const isIntersecting = (i: number) => {
const geo = geometry;
const window = slidingWindow;
const top = geo.getTop(i);
return top + pageHeaderOffset < window.bottom && top + geo.getHeight(i) > window.top;
};
let shiftKeyIsDown = $state(false);
let lastAssetMouseEvent: TimelineAsset | null = $state(null);
let scrollTop = $state(0);
let slidingWindow = $derived.by(() => {
const top = (scrollTop || 0) - slidingWindowOffset;
const bottom = top + viewport.height + slidingWindowOffset;
return {
top,
bottom,
};
});
const updateCurrentAsset = (asset: AssetResponseDto) => {
const index = assets.findIndex((oldAsset) => oldAsset.id === asset.id);
assets[index] = asset;
};
const updateSlidingWindow = () => (scrollTop = document.scrollingElement?.scrollTop ?? 0);
const debouncedOnIntersected = debounce(() => onIntersected?.(), 750, { maxWait: 100, leading: true });
let lastIntersectedHeight = 0;
$effect(() => {
// Intersect if there's only one viewport worth of assets left to scroll.
if (geometry.containerHeight - slidingWindow.bottom <= viewport.height) {
// Notify we got to (near) the end of scroll.
const intersectedHeight = geometry.containerHeight;
if (lastIntersectedHeight !== intersectedHeight) {
debouncedOnIntersected();
lastIntersectedHeight = intersectedHeight;
}
}
});
const selectAllAssets = () => {
assetInteraction.selectAssets(assets.map((a) => toTimelineAsset(a)));
};
const deselectAllAssets = () => {
cancelMultiselect(assetInteraction);
};
const onKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Shift') {
event.preventDefault();
shiftKeyIsDown = true;
}
};
const onKeyUp = (event: KeyboardEvent) => {
if (event.key === 'Shift') {
event.preventDefault();
shiftKeyIsDown = false;
}
};
const handleSelectAssets = (asset: TimelineAsset) => {
if (!asset) {
return;
}
const deselect = assetInteraction.hasSelectedAsset(asset.id);
// Select/deselect already loaded assets
if (deselect) {
for (const candidate of assetInteraction.assetSelectionCandidates) {
assetInteraction.removeAssetFromMultiselectGroup(candidate.id);
}
assetInteraction.removeAssetFromMultiselectGroup(asset.id);
} else {
for (const candidate of assetInteraction.assetSelectionCandidates) {
assetInteraction.selectAsset(candidate);
}
assetInteraction.selectAsset(asset);
}
assetInteraction.clearAssetSelectionCandidates();
assetInteraction.setAssetSelectionStart(deselect ? null : asset);
};
const handleSelectAssetCandidates = (asset: TimelineAsset | null) => {
if (asset) {
selectAssetCandidates(asset);
}
lastAssetMouseEvent = asset;
};
const selectAssetCandidates = (endAsset: TimelineAsset) => {
if (!shiftKeyIsDown) {
return;
}
const startAsset = assetInteraction.assetSelectionStart;
if (!startAsset) {
return;
}
let start = assets.findIndex((a) => a.id === startAsset.id);
let end = assets.findIndex((a) => a.id === endAsset.id);
if (start > end) {
[start, end] = [end, start];
}
assetInteraction.setAssetSelectionCandidates(assets.slice(start, end + 1).map((a) => toTimelineAsset(a)));
};
const onSelectStart = (event: Event) => {
if (assetInteraction.selectionActive && shiftKeyIsDown) {
event.preventDefault();
}
};
const onDelete = () => {
const hasTrashedAsset = assetInteraction.selectedAssets.some((asset) => asset.isTrashed);
handlePromiseError(trashOrDelete(hasTrashedAsset));
};
const trashOrDelete = async (force: boolean = false) => {
const forceOrNoTrash = force || !featureFlagsManager.value.trash;
const selectedAssets = assetInteraction.selectedAssets;
if ($showDeleteModal && forceOrNoTrash) {
const confirmed = await modalManager.show(AssetDeleteConfirmModal, { size: selectedAssets.length });
if (!confirmed) {
return;
}
}
await deleteAssets(
forceOrNoTrash,
(assetIds) => (assets = assets.filter((asset) => !assetIds.includes(asset.id))),
selectedAssets,
onReload,
);
assetInteraction.clearMultiselect();
};
const toggleArchive = async () => {
const ids = await archiveAssets(
assetInteraction.selectedAssets,
assetInteraction.isAllArchived ? AssetVisibility.Timeline : AssetVisibility.Archive,
);
if (ids) {
assets = assets.filter((asset) => !ids.includes(asset.id));
deselectAllAssets();
}
};
const focusNextAsset = () => moveFocus((element) => element.dataset.thumbnailFocusContainer !== undefined, 'next');
const focusPreviousAsset = () =>
moveFocus((element) => element.dataset.thumbnailFocusContainer !== undefined, 'previous');
let isShortcutModalOpen = false;
const handleOpenShortcutModal = async () => {
if (isShortcutModalOpen) {
return;
}
isShortcutModalOpen = true;
await modalManager.show(ShortcutsModal, {});
isShortcutModalOpen = false;
};
const shortcutList = $derived(
(() => {
if ($isViewerOpen) {
return [];
}
const shortcuts: ShortcutOptions[] = [
{ shortcut: { key: '?', shift: true }, onShortcut: handleOpenShortcutModal },
{ shortcut: { key: '/' }, onShortcut: () => goto(Route.explore()) },
{ shortcut: { key: 'A', ctrl: true }, onShortcut: () => selectAllAssets() },
...(arrowNavigation
? [
{ shortcut: { key: 'ArrowRight' }, preventDefault: false, onShortcut: focusNextAsset },
{ shortcut: { key: 'ArrowLeft' }, preventDefault: false, onShortcut: focusPreviousAsset },
]
: []),
];
if (assetInteraction.selectionActive) {
shortcuts.push(
{ shortcut: { key: 'Escape' }, onShortcut: deselectAllAssets },
{ shortcut: { key: 'Delete' }, onShortcut: onDelete },
{ shortcut: { key: 'Delete', shift: true }, onShortcut: () => trashOrDelete(true) },
{ shortcut: { key: 'D', ctrl: true }, onShortcut: () => deselectAllAssets() },
{ shortcut: { key: 'a', shift: true }, onShortcut: toggleArchive },
);
}
return shortcuts;
})(),
);
const handleRandom = async (): Promise<{ id: string } | undefined> => {
if (assets.length === 0) {
return;
}
try {
const randomIndex = Math.floor(Math.random() * assets.length);
const asset = assets[randomIndex];
await navigateToAsset(asset);
return asset;
} catch (error) {
handleError(error, $t('errors.cannot_navigate_next_asset'));
return;
}
};
const handleAction = async (action: Action) => {
switch (action.type) {
case AssetAction.ARCHIVE:
case AssetAction.DELETE:
case AssetAction.TRASH: {
const nextAsset = assetCursor.nextAsset ?? assetCursor.previousAsset;
assets.splice(
assets.findIndex((currentAsset) => currentAsset.id === action.asset.id),
1,
);
if (assets.length === 0) {
return await goto(Route.photos());
}
if (nextAsset) {
await navigateToAsset(nextAsset);
}
break;
}
}
};
const assetMouseEventHandler = (asset: TimelineAsset | null) => {
if (assetInteraction.selectionActive) {
handleSelectAssetCandidates(asset);
}
};
$effect(() => {
if (!lastAssetMouseEvent) {
assetInteraction.clearAssetSelectionCandidates();
}
});
$effect(() => {
if (!shiftKeyIsDown) {
assetInteraction.clearAssetSelectionCandidates();
}
});
$effect(() => {
if (shiftKeyIsDown && lastAssetMouseEvent) {
selectAssetCandidates(lastAssetMouseEvent);
}
});
const assetCursor = $derived({
current: $viewingAsset,
nextAsset: getNextAsset(assets, $viewingAsset),
previousAsset: getPreviousAsset(assets, $viewingAsset),
});
</script>
<svelte:document
onkeydown={onKeyDown}
onkeyup={onKeyUp}
onselectstart={onSelectStart}
use:shortcuts={shortcutList}
onscroll={() => updateSlidingWindow()}
/>
{#if assets.length > 0}
<div
style:position="relative"
style:height={geometry.containerHeight + 'px'}
style:width={geometry.containerWidth + 'px'}
>
{#each assets as asset, i (asset.id + '-' + i)}
{#if isIntersecting(i)}
{@const currentAsset = toTimelineAsset(asset)}
<div class="absolute" style:overflow="clip" style={getStyle(i)}>
<Thumbnail
readonly={disableAssetSelect}
onClick={() => {
if (assetInteraction.selectionActive) {
handleSelectAssets(currentAsset);
return;
}
void navigateToAsset(asset);
}}
onSelect={() => handleSelectAssets(currentAsset)}
onMouseEvent={() => assetMouseEventHandler(currentAsset)}
{showArchiveIcon}
asset={currentAsset}
selected={assetInteraction.hasSelectedAsset(currentAsset.id)}
selectionCandidate={assetInteraction.hasSelectionCandidate(currentAsset.id)}
thumbnailWidth={geometry.getWidth(i)}
thumbnailHeight={geometry.getHeight(i)}
/>
{#if showAssetName && !isTimelineAsset(asset)}
<div
class="absolute text-center p-1 text-xs font-mono font-semibold w-full bottom-0 bg-linear-to-t bg-slate-50/75 dark:bg-slate-800/75 overflow-clip text-ellipsis whitespace-pre-wrap"
>
{asset.originalFileName}
</div>
{/if}
</div>
{/if}
{/each}
</div>
{/if}
<!-- Overlay Asset Viewer -->
{#if $isViewerOpen}
<Portal target="body">
{#await import('$lib/components/asset-viewer/asset-viewer.svelte') then { default: AssetViewer }}
<AssetViewer
cursor={assetCursor}
onAction={handleAction}
onRandom={handleRandom}
onAssetChange={updateCurrentAsset}
onClose={() => {
assetViewingStore.showAssetViewer(false);
handlePromiseError(navigate({ targetRoute: 'current', assetId: null }));
}}
/>
{/await}
</Portal>
{/if}
@@ -1,409 +0,0 @@
<script lang="ts" module>
import mapboxRtlUrl from '@mapbox/mapbox-gl-rtl-text/mapbox-gl-rtl-text.min.js?url';
import { addProtocol, setRTLTextPlugin } from 'maplibre-gl';
import { Protocol } from 'pmtiles';
let protocol = new Protocol();
void addProtocol('pmtiles', protocol.tile);
void setRTLTextPlugin(mapboxRtlUrl, true);
</script>
<script lang="ts">
import { afterNavigate } from '$app/navigation';
import OnEvents from '$lib/components/OnEvents.svelte';
import { Theme } from '$lib/constants';
import { serverConfigManager } from '$lib/managers/server-config-manager.svelte';
import { themeManager } from '$lib/managers/theme-manager.svelte';
import MapSettingsModal from '$lib/modals/MapSettingsModal.svelte';
import { mapSettings } from '$lib/stores/preferences.store';
import { getAssetMediaUrl, handlePromiseError } from '$lib/utils';
import { getMapMarkers, type MapMarkerResponseDto } from '@immich/sdk';
import { Icon, modalManager } from '@immich/ui';
import { mdiCog, mdiMap, mdiMapMarker } from '@mdi/js';
import type { Feature, GeoJsonProperties, Geometry, Point } from 'geojson';
import { isEqual, omit } from 'lodash-es';
import { DateTime, Duration } from 'luxon';
import {
GlobeControl,
LngLat,
LngLatBounds,
Marker,
type GeoJSONSource,
type LngLatLike,
type Map,
type MapMouseEvent,
} from 'maplibre-gl';
import { onDestroy, onMount, untrack } from 'svelte';
import { t } from 'svelte-i18n';
import {
AttributionControl,
Control,
ControlButton,
ControlGroup,
FullscreenControl,
GeoJSON,
GeolocateControl,
MapLibre,
MarkerLayer,
NavigationControl,
Popup,
ScaleControl,
} from 'svelte-maplibre';
interface Props {
mapMarkers?: MapMarkerResponseDto[];
showSettings?: boolean;
zoom?: number | undefined;
center?: LngLatLike | undefined;
hash?: boolean;
simplified?: boolean;
clickable?: boolean;
useLocationPin?: boolean;
onOpenInMapView?: (() => Promise<void> | void) | undefined;
onSelect?: (assetIds: string[]) => void;
onClickPoint?: ({ lat, lng }: { lat: number; lng: number }) => void;
popup?: import('svelte').Snippet<[{ marker: MapMarkerResponseDto }]>;
rounded?: boolean;
showSimpleControls?: boolean;
autoFitBounds?: boolean;
}
let {
mapMarkers = $bindable(),
showSettings = true,
zoom = undefined,
center = $bindable(undefined),
hash = false,
simplified = false,
clickable = false,
useLocationPin = false,
onOpenInMapView = undefined,
onSelect = () => {},
onClickPoint = () => {},
popup,
rounded = false,
showSimpleControls = true,
autoFitBounds = true,
}: Props = $props();
// Calculate initial bounds from markers once during initialization
const initialBounds = (() => {
if (!autoFitBounds || center || zoom !== undefined || !mapMarkers || mapMarkers.length === 0) {
return undefined;
}
const bounds = new LngLatBounds();
for (const marker of mapMarkers) {
bounds.extend([marker.lon, marker.lat]);
}
return bounds;
})();
let map: Map | undefined = $state();
let marker: Marker | null = null;
let abortController: AbortController;
const theme = $derived($mapSettings.allowDarkMode ? themeManager.value : Theme.LIGHT);
const styleUrl = $derived(
theme === Theme.DARK ? serverConfigManager.value.mapDarkStyleUrl : serverConfigManager.value.mapLightStyleUrl,
);
export function addClipMapMarker(lng: number, lat: number) {
if (map) {
if (marker) {
marker.remove();
}
center = { lng, lat };
marker = new Marker().setLngLat([lng, lat]).addTo(map);
}
}
function handleAssetClick(assetId: string, map: Map | null) {
if (!map) {
return;
}
onSelect([assetId]);
}
async function handleClusterClick(clusterId: number, map: Map | null) {
if (!map) {
return;
}
const mapSource = map?.getSource('geojson') as GeoJSONSource;
const leaves = await mapSource.getClusterLeaves(clusterId, 10_000, 0);
const ids = leaves.map((leaf) => leaf.properties?.id);
onSelect(ids);
}
function handleMapClick(event: MapMouseEvent) {
if (clickable) {
const { lng, lat } = event.lngLat;
onClickPoint({ lng, lat });
if (marker) {
marker.remove();
}
if (map) {
marker = new Marker().setLngLat([lng, lat]).addTo(map);
}
}
}
type FeaturePoint = Feature<Point, { id: string; city: string | null; state: string | null; country: string | null }>;
const asFeature = (marker: MapMarkerResponseDto): FeaturePoint => {
return {
type: 'Feature',
geometry: { type: 'Point', coordinates: [marker.lon, marker.lat] },
properties: {
id: marker.id,
city: marker.city,
state: marker.state,
country: marker.country,
},
};
};
const asMarker = (feature: Feature<Geometry, GeoJsonProperties>): MapMarkerResponseDto => {
const featurePoint = feature as FeaturePoint;
const coords = LngLat.convert(featurePoint.geometry.coordinates as [number, number]);
return {
lat: coords.lat,
lon: coords.lng,
id: featurePoint.properties.id,
city: featurePoint.properties.city,
state: featurePoint.properties.state,
country: featurePoint.properties.country,
};
};
function getFileCreatedDates() {
const { relativeDate, dateAfter, dateBefore } = $mapSettings;
if (relativeDate) {
const duration = Duration.fromISO(relativeDate);
return {
fileCreatedAfter: duration.isValid ? DateTime.now().minus(duration).toISO() : undefined,
};
}
try {
return {
fileCreatedAfter: dateAfter ? new Date(dateAfter).toISOString() : undefined,
fileCreatedBefore: dateBefore ? new Date(dateBefore).toISOString() : undefined,
};
} catch {
$mapSettings.dateAfter = '';
$mapSettings.dateBefore = '';
return {};
}
}
async function loadMapMarkers() {
if (abortController) {
abortController.abort();
}
abortController = new AbortController();
const { includeArchived, onlyFavorites, withPartners, withSharedAlbums } = $mapSettings;
const { fileCreatedAfter, fileCreatedBefore } = getFileCreatedDates();
return await getMapMarkers(
{
isArchived: includeArchived || undefined,
isFavorite: onlyFavorites || undefined,
fileCreatedAfter: fileCreatedAfter || undefined,
fileCreatedBefore,
withPartners: withPartners || undefined,
withSharedAlbums: withSharedAlbums || undefined,
},
{
signal: abortController.signal,
},
);
}
const handleSettingsClick = async () => {
const settings = await modalManager.show(MapSettingsModal, { settings: { ...$mapSettings } });
if (settings) {
const shouldUpdate = !isEqual(omit(settings, 'allowDarkMode'), omit($mapSettings, 'allowDarkMode'));
$mapSettings = settings;
if (shouldUpdate) {
mapMarkers = await loadMapMarkers();
}
}
};
afterNavigate(() => {
if (map) {
map.resize();
if (globalThis.location.hash) {
const hashChangeEvent = new HashChangeEvent('hashchange');
globalThis.dispatchEvent(hashChangeEvent);
}
}
});
onMount(async () => {
if (!mapMarkers) {
mapMarkers = await loadMapMarkers();
}
});
onDestroy(() => {
abortController?.abort();
});
$effect(() => {
map?.setStyle(styleUrl, {
transformStyle: (previousStyle, nextStyle) => {
if (previousStyle) {
// Preserves the custom map markers from the previous style when the theme is switched
// Required until https://github.com/dimfeld/svelte-maplibre/issues/146 is fixed
const customLayers = previousStyle.layers.filter((l) => l.type == 'fill' && l.source == 'geojson');
const layers = nextStyle.layers.concat(customLayers);
const sources = nextStyle.sources;
for (const [key, value] of Object.entries(previousStyle.sources || {})) {
if (key.startsWith('geojson')) {
sources[key] = value;
}
}
return {
...nextStyle,
sources,
layers,
};
}
return nextStyle;
},
});
});
$effect(() => {
if (!center || !zoom) {
return;
}
untrack(() => map?.jumpTo({ center, zoom }));
});
const onAssetsDelete = async () => {
mapMarkers = await loadMapMarkers();
};
</script>
<OnEvents {onAssetsDelete} />
<!-- We handle style loading ourselves so we set style blank here -->
<MapLibre
{hash}
style=""
class="h-full {rounded ? 'rounded-2xl' : 'rounded-none'}"
{zoom}
{center}
bounds={initialBounds}
fitBoundsOptions={{ padding: 50, maxZoom: 15 }}
attributionControl={false}
diffStyleUpdates={true}
onload={(event: Map) => {
event.setMaxZoom(18);
event.on('click', handleMapClick);
if (!simplified) {
event.addControl(new GlobeControl(), 'top-left');
}
}}
bind:map
>
{#snippet children({ map }: { map: Map })}
{#if showSimpleControls}
<NavigationControl position="top-left" showCompass={!simplified} />
{#if !simplified}
<GeolocateControl position="top-left" />
<FullscreenControl position="top-left" />
<ScaleControl />
<AttributionControl compact={false} />
{/if}
{/if}
{#if showSettings}
<Control>
<ControlGroup>
<ControlButton onclick={handleSettingsClick}>
<Icon icon={mdiCog} size="100%" class="text-black/80" />
</ControlButton>
</ControlGroup>
</Control>
{/if}
{#if onOpenInMapView && showSimpleControls}
<Control position="top-right">
<ControlGroup>
<ControlButton onclick={() => onOpenInMapView()}>
<Icon title={$t('open_in_map_view')} icon={mdiMap} size="100%" class="text-black/80" />
</ControlButton>
</ControlGroup>
</Control>
{/if}
<GeoJSON
data={{
type: 'FeatureCollection',
features: mapMarkers?.map((marker) => asFeature(marker)) ?? [],
}}
id="geojson"
cluster={{ radius: 35, maxZoom: 18 }}
>
<MarkerLayer
applyToClusters
asButton
onclick={(event) => handlePromiseError(handleClusterClick(event.feature.properties?.cluster_id, map))}
>
{#snippet children({ feature })}
<div
class="rounded-full w-10 h-10 bg-immich-primary text-white flex justify-center items-center font-mono font-bold shadow-lg hover:bg-immich-dark-primary transition-all duration-200 hover:text-immich-dark-bg opacity-90"
>
{feature.properties?.point_count?.toLocaleString()}
</div>
{/snippet}
</MarkerLayer>
<MarkerLayer
applyToClusters={false}
asButton
onclick={(event) => {
if (!popup) {
handleAssetClick(event.feature.properties?.id, map);
}
}}
>
{#snippet children({ feature }: { feature: Feature })}
{#if useLocationPin}
<Icon icon={mdiMapMarker} size="50px" class="text-primary -translate-y-[50%]" />
{:else}
<img
src={getAssetMediaUrl({ id: feature.properties?.id })}
class="rounded-full w-15 h-15 border-2 border-immich-primary shadow-lg hover:border-immich-dark-primary transition-all duration-200 hover:scale-150 object-cover bg-immich-primary"
alt={feature.properties?.city && feature.properties.country
? $t('map_marker_for_images', {
values: { city: feature.properties.city, country: feature.properties.country },
})
: $t('map_marker_with_image')}
/>
{/if}
{#if popup}
<Popup offset={[0, -30]} openOn="click" closeOnClickOutside>
{@render popup?.({ marker: asMarker(feature) })}
</Popup>
{/if}
{/snippet}
</MarkerLayer>
</GeoJSON>
{/snippet}
</MapLibre>
@@ -1,15 +1,10 @@
<script lang="ts">
import { page } from '$app/state';
import { focusTrap } from '$lib/actions/focus-trap';
import AvatarEditModal from '$lib/modals/AvatarEditModal.svelte';
import HelpAndFeedbackModal from '$lib/modals/HelpAndFeedbackModal.svelte';
import { Route } from '$lib/route';
import { user } from '$lib/stores/user.store';
import { userInteraction } from '$lib/stores/user.svelte';
import { getAboutInfo, type ServerAboutResponseDto } from '@immich/sdk';
import { Button, Icon, IconButton, modalManager } from '@immich/ui';
import { mdiCog, mdiLogout, mdiPencil, mdiWrench } from '@mdi/js';
import { onMount } from 'svelte';
import { mdiCog, mdiLogout, mdiPencil } from '@mdi/js';
import { t } from 'svelte-i18n';
import { fade } from 'svelte/transition';
import UserAvatar from '../user-avatar.svelte';
@@ -20,12 +15,6 @@
}
let { onLogout, onClose = () => {} }: Props = $props();
let info: ServerAboutResponseDto | undefined = $state();
onMount(async () => {
info = userInteraction.aboutInfo ?? (await getAboutInfo());
});
</script>
<div
@@ -76,23 +65,6 @@
{$t('account_settings')}
</div>
</Button>
{#if $user.isAdmin}
<Button
href={Route.systemSettings()}
onclick={onClose}
shape="round"
variant="ghost"
size="small"
color="secondary"
aria-current={page.url.pathname.includes('/admin') ? 'page' : undefined}
class="border dark:border-immich-dark-gray dark:bg-gray-500 dark:hover:bg-immich-dark-primary/50 hover:bg-immich-primary/10 dark:text-white"
>
<div class="flex place-content-center place-items-center text-center gap-2 px-2">
<Icon icon={mdiWrench} size="18" aria-hidden />
{$t('administration')}
</div>
</Button>
{/if}
</div>
</div>
@@ -104,18 +76,5 @@
variant="ghost"
color="secondary">{$t('sign_out')}</Button
>
<button
type="button"
class="text-center mt-4 underline text-xs text-primary"
onclick={async () => {
onClose();
if (info) {
await modalManager.show(HelpAndFeedbackModal, { info });
}
}}
>
{$t('support_and_feedback')}
</button>
</div>
</div>
@@ -3,54 +3,29 @@
</script>
<script lang="ts">
import { page } from '$app/state';
import { clickOutside } from '$lib/actions/click-outside';
import ActionButton from '$lib/components/ActionButton.svelte';
import NotificationPanel from '$lib/components/shared-components/navigation-bar/notification-panel.svelte';
import SearchBar from '$lib/components/shared-components/search-bar/search-bar.svelte';
import SkipLink from '$lib/elements/SkipLink.svelte';
import { authManager } from '$lib/managers/auth-manager.svelte';
import { featureFlagsManager } from '$lib/managers/feature-flags-manager.svelte';
import { Route } from '$lib/route';
import { getGlobalActions } from '$lib/services/app.service';
import { mediaQueryManager } from '$lib/stores/media-query-manager.svelte';
import { notificationManager } from '$lib/stores/notification-manager.svelte';
import { sidebarStore } from '$lib/stores/sidebar.svelte';
import { user } from '$lib/stores/user.store';
import { Button, IconButton, Logo } from '@immich/ui';
import { mdiBellBadge, mdiBellOutline, mdiMagnify, mdiMenu, mdiTrayArrowUp } from '@mdi/js';
import { onMount } from 'svelte';
import { IconButton, Logo } from '@immich/ui';
import { mdiMenu } from '@mdi/js';
import { t } from 'svelte-i18n';
import ThemeButton from '../theme-button.svelte';
import UserAvatar from '../user-avatar.svelte';
import AccountInfoPanel from './account-info-panel.svelte';
type Props = {
onUploadClick?: () => void;
// TODO: remove once this is only used in <AppShellHeader>
noBorder?: boolean;
};
let { onUploadClick, noBorder = false }: Props = $props();
let { noBorder = false }: Props = $props();
let shouldShowAccountInfoPanel = $state(false);
let shouldShowNotificationPanel = $state(false);
let innerWidth: number = $state(0);
const hasUnreadNotifications = $derived(notificationManager.notifications.length > 0);
onMount(async () => {
try {
await notificationManager.refresh();
} catch (error) {
console.error('Failed to load notifications on mount', error);
}
});
const { Cast } = $derived(getGlobalActions($t));
</script>
<svelte:window bind:innerWidth />
<nav id="dashboard-navbar" class="max-md:h-(--navbar-height-md) h-(--navbar-height) w-dvw text-sm">
<SkipLink text={$t('skip_to_content')} />
<div
@@ -72,96 +47,19 @@
}}
onmousedown={(event: MouseEvent) => {
if (sidebarStore.isOpen) {
// stops event from reaching the default handler when clicking outside of the sidebar
event.stopPropagation();
}
}}
class="sidebar:hidden"
/>
<a data-sveltekit-preload-data="hover" href={Route.photos()}>
<a data-sveltekit-preload-data="hover" href={Route.userSettings()}>
<Logo variant={mediaQueryManager.isFullSidebar ? 'inline' : 'icon'} class="max-md:h-12" />
</a>
</div>
<div class="flex justify-between gap-4 lg:gap-8 pe-6">
<div class="hidden w-full max-w-5xl flex-1 tall:ps-0 sm:block">
{#if featureFlagsManager.value.search}
<SearchBar grayTheme={true} />
{/if}
</div>
<section class="flex place-items-center justify-end gap-1 md:gap-2 w-full sm:w-auto">
{#if featureFlagsManager.value.search}
<IconButton
color="secondary"
shape="round"
variant="ghost"
size="medium"
icon={mdiMagnify}
href={Route.search()}
id="search-button"
class="sm:hidden"
aria-label={$t('go_to_search')}
/>
{/if}
{#if !page.url.pathname.includes('/admin') && onUploadClick}
<Button
leadingIcon={mdiTrayArrowUp}
onclick={onUploadClick}
class="hidden lg:flex"
variant="ghost"
size="medium"
color="secondary"
>{$t('upload')}
</Button>
<IconButton
color="secondary"
shape="round"
variant="ghost"
size="medium"
onclick={onUploadClick}
title={$t('upload')}
aria-label={$t('upload')}
icon={mdiTrayArrowUp}
class="lg:hidden"
/>
{/if}
<div class="flex justify-end gap-4 lg:gap-8 pe-6">
<section class="flex place-items-center justify-end gap-1 md:gap-2">
<ThemeButton />
<div
use:clickOutside={{
onOutclick: () => (shouldShowNotificationPanel = false),
onEscape: () => (shouldShowNotificationPanel = false),
}}
>
<div class="relative">
<IconButton
shape="round"
color={hasUnreadNotifications ? 'primary' : 'secondary'}
variant="ghost"
size="medium"
icon={hasUnreadNotifications ? mdiBellBadge : mdiBellOutline}
onclick={() => (shouldShowNotificationPanel = !shouldShowNotificationPanel)}
aria-label={$t('notifications')}
/>
{#if hasUnreadNotifications}
<div
class="pointer-events-none absolute border top-0 right-1 flex h-5 w-5 items-center justify-center rounded-full bg-primary text-[10px] font-bold text-light"
>
{notificationManager.notifications.length}
</div>
{/if}
</div>
{#if shouldShowNotificationPanel}
<NotificationPanel />
{/if}
</div>
<ActionButton action={Cast} />
<div
use:clickOutside={{
onOutclick: () => (shouldShowAccountInfoPanel = false),
@@ -1,133 +0,0 @@
<script lang="ts">
import { NotificationLevel, NotificationType, type NotificationDto } from '@immich/sdk';
import { IconButton, Stack, Text } from '@immich/ui';
import {
mdiBackupRestore,
mdiImageAlbum,
mdiImagePlus,
mdiInformationOutline,
mdiMessageBadgeOutline,
mdiSync,
} from '@mdi/js';
import { DateTime } from 'luxon';
interface Props {
notification: NotificationDto;
onclick: (notification: NotificationDto) => void;
}
let { notification, onclick }: Props = $props();
const getAlertColor = (level: NotificationLevel) => {
switch (level) {
case NotificationLevel.Error: {
return 'danger';
}
case NotificationLevel.Warning: {
return 'warning';
}
case NotificationLevel.Info: {
return 'primary';
}
case NotificationLevel.Success: {
return 'success';
}
default: {
return 'primary';
}
}
};
const getIconBgColor = (level: NotificationLevel) => {
switch (level) {
case NotificationLevel.Error: {
return 'bg-red-500 dark:bg-red-300 dark:hover:bg-red-200';
}
case NotificationLevel.Warning: {
return 'bg-amber-500 dark:bg-amber-200 dark:hover:bg-amber-200';
}
case NotificationLevel.Info: {
return 'bg-blue-500 dark:bg-blue-200 dark:hover:bg-blue-200';
}
case NotificationLevel.Success: {
return 'bg-green-500 dark:bg-green-200 dark:hover:bg-green-200';
}
}
};
const getIconType = (type: NotificationType) => {
switch (type) {
case NotificationType.BackupFailed: {
return mdiBackupRestore;
}
case NotificationType.JobFailed: {
return mdiSync;
}
case NotificationType.SystemMessage: {
return mdiMessageBadgeOutline;
}
case NotificationType.Custom: {
return mdiInformationOutline;
}
case NotificationType.AlbumInvite: {
return mdiImageAlbum;
}
case NotificationType.AlbumUpdate: {
return mdiImagePlus;
}
default: {
return mdiInformationOutline;
}
}
};
const formatRelativeTime = (dateString: string): string => {
try {
const date = DateTime.fromISO(dateString);
if (!date.isValid) {
return dateString; // Return original string if parsing fails
}
// Use Luxon's toRelative with the current locale
return date.setLocale('en').toRelative() || dateString;
} catch (error) {
console.error('Error formatting relative time:', error);
return dateString; // Fallback to original string on error
}
};
</script>
<button
class="min-h-20 p-2 py-3 hover:bg-immich-primary/10 dark:hover:bg-immich-dark-primary/10 border-b border-gray-200 dark:border-immich-dark-gray w-full"
type="button"
onclick={() => onclick(notification)}
title={notification.createdAt}
>
<div class="grid grid-cols-[56px_1fr_32px] items-center gap-2">
<div class="flex place-items-center place-content-center">
<IconButton
icon={getIconType(notification.type)}
color={getAlertColor(notification.level)}
aria-label={notification.title}
shape="round"
class={getIconBgColor(notification.level)}
size="small"
></IconButton>
</div>
<Stack class="text-left" gap={1}>
<Text size="tiny" class="text-black dark:text-white text-base" fontWeight="semi-bold">{notification.title}</Text>
{#if notification.description}
<Text class="overflow-hidden text-gray-600 dark:text-gray-300">{notification.description}</Text>
{/if}
<Text size="tiny" color="muted">{formatRelativeTime(notification.createdAt)}</Text>
</Stack>
{#if !notification.readAt}
<div class="w-2 h-2 rounded-full bg-primary text-right justify-self-center"></div>
{/if}
</div>
</button>
@@ -1,109 +0,0 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { focusTrap } from '$lib/actions/focus-trap';
import NotificationItem from '$lib/components/shared-components/navigation-bar/notification-item.svelte';
import { notificationManager } from '$lib/stores/notification-manager.svelte';
import { handleError } from '$lib/utils/handle-error';
import { NotificationType, type NotificationDto } from '@immich/sdk';
import { Button, Icon, Scrollable, Stack, Text, toastManager } from '@immich/ui';
import { mdiBellOutline, mdiCheckAll } from '@mdi/js';
import { t } from 'svelte-i18n';
import { flip } from 'svelte/animate';
import { fade } from 'svelte/transition';
const noUnreadNotifications = $derived(notificationManager.notifications.length === 0);
const markAsRead = async (id: string) => {
try {
await notificationManager.markAsRead(id);
} catch (error) {
handleError(error, $t('errors.failed_to_update_notification_status'));
}
};
const markAllAsRead = async () => {
try {
await notificationManager.markAllAsRead();
toastManager.info($t('marked_all_as_read'));
} catch (error) {
handleError(error, $t('errors.failed_to_update_notification_status'));
}
};
const handleNotificationAction = async (notification: NotificationDto) => {
switch (notification.type) {
case NotificationType.AlbumInvite:
case NotificationType.AlbumUpdate: {
if (!notification.data) {
return;
}
if (typeof notification.data !== 'string') {
return;
}
const data = JSON.parse(notification.data);
if (data?.albumId) {
await goto(`/albums/${data.albumId}`);
}
break;
}
default: {
break;
}
}
};
const onclick = async (notification: NotificationDto) => {
await markAsRead(notification.id);
await handleNotificationAction(notification);
};
</script>
<div
in:fade={{ duration: 100 }}
out:fade={{ duration: 100 }}
id="notification-panel"
class="absolute right-6 top-17.5 z-1 w-[min(360px,100vw-50px)] rounded-3xl bg-gray-100 border border-gray-200 shadow-lg dark:border dark:border-light dark:bg-immich-dark-gray text-light"
use:focusTrap
>
<Stack class="max-h-125">
<div class="flex justify-between items-center mt-4 mx-4">
<Text size="medium" color="secondary" fontWeight="semi-bold">{$t('notifications')}</Text>
<div>
<Button
variant="ghost"
disabled={noUnreadNotifications}
leadingIcon={mdiCheckAll}
size="small"
color="primary"
onclick={() => markAllAsRead()}>{$t('mark_all_as_read')}</Button
>
</div>
</div>
<hr class="dark:border-black" />
{#if noUnreadNotifications}
<Stack
class="py-12 flex flex-col place-items-center place-content-center text-gray-700 dark:text-gray-300"
gap={1}
>
<Icon icon={mdiBellOutline} size="20"></Icon>
<Text>{$t('no_notifications')}</Text>
</Stack>
{:else}
<Scrollable class="pb-6">
<Stack gap={0}>
{#each notificationManager.notifications as notification (notification.id)}
<div animate:flip={{ duration: 400 }}>
<NotificationItem {notification} {onclick} />
</div>
{/each}
</Stack>
</Scrollable>
{/if}
</Stack>
</div>
@@ -1,5 +1,5 @@
<script lang="ts">
import { clamp } from 'lodash-es';
const clamp = (value: number, lower: number, upper: number) => Math.min(Math.max(value, lower), upper);
import type { ClipboardEventHandler, KeyboardEventHandler } from 'svelte/elements';
interface Props {
@@ -1,95 +0,0 @@
<script lang="ts">
import { ProgressBarStatus } from '$lib/constants';
import { handlePromiseError } from '$lib/utils';
import { onMount } from 'svelte';
import { tweened } from 'svelte/motion';
interface Props {
/**
* Autoplay on mount
* @default false
*/
autoplay?: boolean;
/**
* Progress bar status
*/
status?: ProgressBarStatus;
hidden?: boolean;
duration?: number;
onDone: () => void;
onPlaying?: () => void;
onPaused?: () => void;
}
let {
autoplay = false,
status = $bindable(),
hidden = false,
duration = 5,
onDone,
onPlaying = () => {},
onPaused = () => {},
}: Props = $props();
const onChange = async (progressDuration: number) => {
progress = setDuration(progressDuration);
await play();
};
let progress = setDuration(duration);
$effect(() => {
handlePromiseError(onChange(duration));
});
$effect(() => {
if ($progress === 1) {
onDone();
}
});
onMount(async () => {
if (autoplay) {
status = ProgressBarStatus.Playing;
await play();
} else {
status = ProgressBarStatus.Paused;
await progress.set(0);
}
});
export const play = async () => {
status = ProgressBarStatus.Playing;
onPlaying();
await progress.set(1);
};
export const pause = async () => {
status = ProgressBarStatus.Paused;
onPaused();
await progress.set($progress);
};
export const restart = async () => {
await progress.set(0);
if (status !== ProgressBarStatus.Paused) {
await play();
}
};
export const resetProgress = async () => {
await progress.set(0);
};
function setDuration(newDuration: number) {
return tweened<number>(0, {
duration: (from: number, to: number) => (to ? newDuration * 1000 * (to - from) : 0),
});
}
</script>
{#if !hidden}
<span class="absolute start-0 h-[3px] bg-immich-primary shadow-2xl" style:width={`${$progress * 100}%`}></span>
{/if}
@@ -1,43 +0,0 @@
<script lang="ts">
import { ImmichProduct } from '$lib/constants';
import { getLicenseLink as getProductLink } from '$lib/utils/license-utils';
import { Button, Icon } from '@immich/ui';
import { mdiAccount, mdiCheckCircleOutline } from '@mdi/js';
import { t } from 'svelte-i18n';
</script>
<!-- Individual Purchase Option -->
<div
class="border border-gray-300 dark:border-gray-800 w-[min(375px,100%)] p-8 rounded-3xl bg-gray-100 dark:bg-gray-900"
>
<div class="text-primary">
<Icon icon={mdiAccount} size="56" />
<p class="font-semibold text-lg mt-1">{$t('purchase_individual_title')}</p>
</div>
<div class="mt-4 dark:text-immich-gray">
<p class="text-6xl font-bold">$25</p>
<p>{$t('purchase_per_user')}</p>
</div>
<div class="flex flex-col justify-between h-50 dark:text-immich-gray">
<div class="mt-6 flex flex-col gap-1">
<div class="grid grid-cols-[36px_auto]">
<Icon icon={mdiCheckCircleOutline} size="24" class="text-green-500 self-center" />
<p class="self-center">{$t('purchase_individual_description_1')}</p>
</div>
<div class="grid grid-cols-[36px_auto]">
<Icon icon={mdiCheckCircleOutline} size="24" class="text-green-500 self-center" />
<p class="self-center">{$t('purchase_lifetime_description')}</p>
</div>
<div class="grid grid-cols-[36px_auto]">
<Icon icon={mdiCheckCircleOutline} size="24" class="text-green-500 self-center" />
<p class="self-center">{$t('purchase_individual_description_2')}</p>
</div>
</div>
<Button shape="round" href={getProductLink(ImmichProduct.Client)} fullWidth>{$t('purchase_button_select')}</Button>
</div>
</div>
@@ -1,33 +0,0 @@
<script lang="ts">
import SettingSwitch from '$lib/components/shared-components/settings/setting-switch.svelte';
import { preferences } from '$lib/stores/user.store';
import { setSupportBadgeVisibility } from '$lib/utils/purchase-utils';
import { Button, Icon } from '@immich/ui';
import { mdiPartyPopper } from '@mdi/js';
import { t } from 'svelte-i18n';
interface Props {
onDone: () => void;
}
let { onDone }: Props = $props();
</script>
<div class="m-auto w-3/4 text-center flex flex-col place-content-center place-items-center my-6">
<Icon icon={mdiPartyPopper} class="text-primary" size="96" />
<p class="text-4xl mt-8 font-bold">{$t('purchase_activated_title')}</p>
<p class="text-lg mt-6">{$t('purchase_activated_subtitle')}</p>
<div class="mb-4 w-full mt-6 border rounded-xl p-4 bg-gray-50 dark:bg-gray-900 dark:border-gray-600">
<SettingSwitch
title={$t('show_supporter_badge')}
subtitle={$t('show_supporter_badge_description')}
bind:checked={$preferences.purchase.showSupportBadge}
onToggle={setSupportBadgeVisibility}
/>
</div>
<div class="mt-6 w-full">
<Button fullWidth shape="round" onclick={onDone}>{$t('ok')}</Button>
</div>
</div>
@@ -1,84 +0,0 @@
<script lang="ts">
import { purchaseStore } from '$lib/stores/purchase.store';
import { handleError } from '$lib/utils/handle-error';
import { activateProduct, getActivationKey } from '$lib/utils/license-utils';
import { Button, Heading, LoadingSpinner } from '@immich/ui';
import { t } from 'svelte-i18n';
import UserPurchaseOptionCard from './individual-purchase-option-card.svelte';
import ServerPurchaseOptionCard from './server-purchase-option-card.svelte';
interface Props {
onActivate: () => void;
showTitle?: boolean;
showMessage?: boolean;
}
let { onActivate, showTitle = true, showMessage = true }: Props = $props();
let productKey = $state('');
let isLoading = $state(false);
const activate = async () => {
try {
productKey = productKey.trim();
isLoading = true;
const activationKey = await getActivationKey(productKey);
await activateProduct(productKey, activationKey);
onActivate();
purchaseStore.setPurchaseStatus(true);
} catch (error) {
handleError(error, $t('purchase_failed_activation'));
} finally {
isLoading = false;
}
};
</script>
<section>
{#if showTitle}
<Heading color="primary" tag="h1" class="text-4xl font-bold tracking-wider">
{$t('purchase_option_title')}
</Heading>
{/if}
{#if showMessage}
<div class="mt-2">
<p>
{$t('purchase_panel_info_1')}
</p>
<br />
<p>
{$t('purchase_panel_info_2')}
</p>
<div></div>
</div>
{/if}
<div class="flex flex-col sm:flex-row gap-6 mt-4 justify-between">
<ServerPurchaseOptionCard />
<UserPurchaseOptionCard />
</div>
<div class="mt-6">
<p class="dark:text-immich-gray">{$t('purchase_input_suggestion')}</p>
<form class="mt-2 flex gap-2" onsubmit={activate}>
<input
class="immich-form-input w-full"
id="purchaseKey"
type="text"
bind:value={productKey}
required
placeholder="IMCL-0KEY-0CAN-00BE-FOUD-FROM-YOUR-EMAIL-INBX"
disabled={isLoading}
/>
<Button type="submit"
>{#if isLoading}
<LoadingSpinner />
{:else}
{$t('purchase_button_activate')}
{/if}</Button
>
</form>
</div>
</section>
@@ -1,43 +0,0 @@
<script lang="ts">
import { ImmichProduct } from '$lib/constants';
import { getLicenseLink } from '$lib/utils/license-utils';
import { Button, Icon } from '@immich/ui';
import { mdiCheckCircleOutline, mdiServer } from '@mdi/js';
import { t } from 'svelte-i18n';
</script>
<!-- SERVER Purchase Options -->
<div
class="border border-gray-300 dark:border-gray-800 w-[min(375px,100%)] p-8 rounded-3xl bg-gray-100 dark:bg-gray-900"
>
<div class="text-primary">
<Icon icon={mdiServer} size="56" />
<p class="font-semibold text-lg mt-1">{$t('purchase_server_title')}</p>
</div>
<div class="mt-4 dark:text-immich-gray">
<p class="text-6xl font-bold">$100</p>
<p>{$t('purchase_per_server')}</p>
</div>
<div class="flex flex-col justify-between h-50 dark:text-immich-gray">
<div class="mt-6 flex flex-col gap-1">
<div class="grid grid-cols-[36px_auto]">
<Icon icon={mdiCheckCircleOutline} size="24" class="text-green-500 self-center" />
<p class="self-center">{$t('purchase_server_description_1')}</p>
</div>
<div class="grid grid-cols-[36px_auto]">
<Icon icon={mdiCheckCircleOutline} size="24" class="text-green-500 self-center" />
<p class="self-center">{$t('purchase_lifetime_description')}</p>
</div>
<div class="grid grid-cols-[36px_auto]">
<Icon icon={mdiCheckCircleOutline} size="24" class="text-green-500 self-center" />
<p class="self-center">{$t('purchase_server_description_2')}</p>
</div>
</div>
<Button shape="round" href={getLicenseLink(ImmichProduct.Server)} fullWidth>{$t('purchase_button_select')}</Button>
</div>
</div>
@@ -1,20 +0,0 @@
<script lang="ts">
import QRCode from 'qrcode';
import { t } from 'svelte-i18n';
type Props = {
value: string;
width: number;
alt?: string;
};
const { value, width, alt = $t('alt_text_qr_code') }: Props = $props();
let promise = $derived(QRCode.toDataURL(value, { margin: 4, width }));
</script>
<div style="width: {width}px; height: {width}px">
{#await promise then url}
<img src={url} {alt} class="h-full w-full" />
{/await}
</div>
@@ -1,386 +0,0 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { focusOutside } from '$lib/actions/focus-outside';
import { shortcuts } from '$lib/actions/shortcut';
import SearchFilterModal from '$lib/modals/SearchFilterModal.svelte';
import { Route } from '$lib/route';
import { searchStore } from '$lib/stores/search.svelte';
import { handlePromiseError } from '$lib/utils';
import { generateId } from '$lib/utils/generate-id';
import type { MetadataSearchDto, SmartSearchDto } from '@immich/sdk';
import { Button, IconButton, modalManager } from '@immich/ui';
import { mdiClose, mdiMagnify, mdiTune } from '@mdi/js';
import { onDestroy, onMount, tick } from 'svelte';
import { t } from 'svelte-i18n';
import SearchHistoryBox from './search-history-box.svelte';
interface Props {
value?: string;
grayTheme: boolean;
searchQuery?: MetadataSearchDto | SmartSearchDto;
}
let { value = $bindable(''), grayTheme, searchQuery = {} }: Props = $props();
let showClearIcon = $derived(value.length > 0);
let input = $state<HTMLInputElement>();
let searchHistoryBox = $state<ReturnType<typeof SearchHistoryBox>>();
let showSuggestions = $state(false);
let isSearchSuggestions = $state(false);
let selectedId: string | undefined = $state();
let close: (() => Promise<void>) | undefined;
let showSearchTypeDropdown = $state(false);
let currentSearchType = $state('smart');
const listboxId = generateId();
const searchTypeId = generateId();
onDestroy(() => {
searchStore.isSearchEnabled = false;
});
const handleSearch = async (payload: SmartSearchDto | MetadataSearchDto) => {
closeDropdown();
searchStore.isSearchEnabled = false;
await goto(Route.search(payload));
};
const clearSearchTerm = (searchTerm: string) => {
input?.focus();
searchStore.savedSearchTerms = searchStore.savedSearchTerms.filter((item) => item !== searchTerm);
};
const saveSearchTerm = (saveValue: string) => {
const filteredSearchTerms = searchStore.savedSearchTerms.filter(
(item) => item.toLowerCase() !== saveValue.toLowerCase(),
);
searchStore.savedSearchTerms = [saveValue, ...filteredSearchTerms];
if (searchStore.savedSearchTerms.length > 5) {
searchStore.savedSearchTerms = searchStore.savedSearchTerms.slice(0, 5);
}
};
const clearAllSearchTerms = () => {
input?.focus();
searchStore.savedSearchTerms = [];
};
const onFocusIn = () => {
searchStore.isSearchEnabled = true;
getSearchType();
};
const onFocusOut = () => {
searchStore.isSearchEnabled = false;
};
const buildSearchPayload = (term: string): SmartSearchDto | MetadataSearchDto => {
const searchType = getSearchType();
switch (searchType) {
case 'smart': {
return { query: term };
}
case 'metadata': {
return { originalFileName: term };
}
case 'description': {
return { description: term };
}
case 'ocr': {
return { ocr: term };
}
default: {
return { query: term };
}
}
};
const onHistoryTermClick = async (searchTerm: string) => {
value = searchTerm;
await handleSearch(buildSearchPayload(searchTerm));
};
const onFilterClick = async () => {
value = '';
if (close) {
await close();
close = undefined;
searchStore.isSearchEnabled = false;
return;
}
const result = modalManager.open(SearchFilterModal, { searchQuery });
close = () => result.close();
closeDropdown();
const searchResult = await result.onClose;
close = undefined;
searchStore.isSearchEnabled = false;
// Refresh search type after modal closes
getSearchType();
if (!searchResult) {
return;
}
await handleSearch(searchResult);
};
const onSubmit = () => {
handlePromiseError(handleSearch(buildSearchPayload(value)));
saveSearchTerm(value);
};
const onClear = () => {
value = '';
input?.focus();
};
const onEscape = () => {
closeDropdown();
closeSearchTypeDropdown();
};
const onArrow = async (direction: 1 | -1) => {
openDropdown();
await tick();
searchHistoryBox?.moveSelection(direction);
};
const onEnter = (event: KeyboardEvent) => {
if (selectedId) {
event.preventDefault();
searchHistoryBox?.selectActiveOption();
}
};
const onInput = () => {
openDropdown();
searchHistoryBox?.clearSelection();
};
const openDropdown = () => {
showSuggestions = true;
};
const closeDropdown = () => {
showSuggestions = false;
searchHistoryBox?.clearSelection();
};
const toggleSearchTypeDropdown = () => {
showSearchTypeDropdown = !showSearchTypeDropdown;
};
const closeSearchTypeDropdown = () => {
showSearchTypeDropdown = false;
};
const selectSearchType = (type: string) => {
localStorage.setItem('searchQueryType', type);
currentSearchType = type;
showSearchTypeDropdown = false;
};
const onsubmit = (event: Event) => {
event.preventDefault();
onSubmit();
};
function getSearchType() {
const searchType = localStorage.getItem('searchQueryType');
switch (searchType) {
case 'smart':
case 'metadata':
case 'description':
case 'ocr': {
currentSearchType = searchType;
return searchType;
}
default: {
currentSearchType = 'smart';
return 'smart';
}
}
}
function getSearchTypeText(): string {
switch (currentSearchType) {
case 'smart': {
return $t('context');
}
case 'metadata': {
return $t('filename');
}
case 'description': {
return $t('description');
}
case 'ocr': {
return $t('ocr');
}
default: {
return $t('context');
}
}
}
onMount(() => {
getSearchType();
});
const searchTypes = [
{ value: 'smart', label: () => $t('context') },
{ value: 'metadata', label: () => $t('filename') },
{ value: 'description', label: () => $t('description') },
{ value: 'ocr', label: () => $t('ocr') },
] as const;
</script>
<svelte:document
use:shortcuts={[
{ shortcut: { key: 'Escape' }, onShortcut: onEscape },
{ shortcut: { ctrl: true, key: 'k' }, onShortcut: () => input?.select() },
{ shortcut: { ctrl: true, shift: true, key: 'k' }, onShortcut: onFilterClick },
]}
/>
<div class="w-full relative z-auto" use:focusOutside={{ onFocusOut }} tabindex="-1">
<form
draggable="false"
autocomplete="off"
class="select-text text-sm"
action={Route.search()}
onreset={() => (value = '')}
{onsubmit}
onfocusin={onFocusIn}
role="search"
>
<div use:focusOutside={{ onFocusOut: closeDropdown }} tabindex="-1">
<label for="main-search-bar" class="sr-only">{$t('search_your_photos')}</label>
<input
type="text"
name="q"
id="main-search-bar"
class="w-full transition-all border-2 ps-14 py-4 max-md:py-2 text-immich-fg/75 dark:text-immich-dark-fg
{showClearIcon ? 'pe-22.5' : 'pe-14'}
{grayTheme ? 'dark:bg-immich-dark-gray' : 'dark:bg-immich-dark-bg'}
{showSuggestions && isSearchSuggestions ? 'rounded-t-3xl' : 'rounded-3xl bg-gray-200'}
{searchStore.isSearchEnabled ? 'border-gray-200 dark:border-gray-700 bg-white' : 'border-transparent'}"
placeholder={$t('search_your_photos')}
required
pattern="^(?!m:$).*$"
bind:value
bind:this={input}
onfocus={openDropdown}
oninput={onInput}
role="combobox"
aria-controls={listboxId}
aria-activedescendant={selectedId ?? ''}
aria-expanded={showSuggestions && isSearchSuggestions}
aria-autocomplete="list"
aria-describedby={searchTypeId}
use:shortcuts={[
{ shortcut: { key: 'Escape' }, onShortcut: onEscape },
{ shortcut: { ctrl: true, shift: true, key: 'k' }, onShortcut: onFilterClick },
{ shortcut: { key: 'ArrowUp' }, onShortcut: () => onArrow(-1) },
{ shortcut: { key: 'ArrowDown' }, onShortcut: () => onArrow(1) },
{ shortcut: { key: 'Enter' }, onShortcut: onEnter, preventDefault: false },
{ shortcut: { key: 'ArrowDown', alt: true }, onShortcut: openDropdown },
]}
/>
<!-- SEARCH HISTORY BOX -->
<SearchHistoryBox
bind:this={searchHistoryBox}
bind:isSearchSuggestions
id={listboxId}
searchQuery={value}
isOpen={showSuggestions}
onClearAllSearchTerms={clearAllSearchTerms}
onClearSearchTerm={(searchTerm) => clearSearchTerm(searchTerm)}
onSelectSearchTerm={(searchTerm) => handlePromiseError(onHistoryTermClick(searchTerm))}
onActiveSelectionChange={(id) => (selectedId = id)}
/>
</div>
{#if searchStore.isSearchEnabled}
<div
id={searchTypeId}
class="absolute inset-y-0 flex items-center end-16"
class:max-md:hidden={value}
class:end-28={value.length > 0}
>
<div class="relative" use:focusOutside={{ onFocusOut: closeSearchTypeDropdown }}>
<Button
class="bg-immich-primary text-white dark:bg-immich-dark-primary/90 dark:text-black/75 rounded-full px-3 py-1 text-xs hover:opacity-80 transition-opacity cursor-pointer"
onclick={toggleSearchTypeDropdown}
aria-expanded={showSearchTypeDropdown}
aria-haspopup="listbox"
>
{getSearchTypeText()}
</Button>
{#if showSearchTypeDropdown}
<div
class="absolute top-full right-0 mt-1 bg-white dark:bg-immich-dark-gray border border-gray-200 dark:border-gray-600 rounded-lg shadow-lg py-1 min-w-32 z-9999"
>
{#each searchTypes as searchType (searchType.value)}
<button
type="button"
tabindex="0"
class="w-full text-left px-3 py-2 text-xs hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors
{currentSearchType === searchType.value ? 'bg-gray-100 dark:bg-gray-700' : ''}"
onclick={() => selectSearchType(searchType.value)}
>
{searchType.label()}
</button>
{/each}
</div>
{/if}
</div>
</div>
{/if}
{#if showClearIcon}
<div class="absolute inset-y-0 end-0 flex items-center pe-2">
<IconButton
onclick={onClear}
icon={mdiClose}
aria-label={$t('clear')}
size="medium"
color="secondary"
variant="ghost"
shape="round"
/>
</div>
{/if}
<div class="absolute inset-y-0 start-0 flex items-center ps-2">
<IconButton
type="submit"
aria-label={$t('search')}
icon={mdiMagnify}
size="medium"
onclick={() => {}}
shape="round"
color="secondary"
variant="ghost"
/>
</div>
</form>
<div class="absolute inset-y-0 {showClearIcon ? 'end-14' : 'end-2'} flex items-center ps-6 transition-all">
<IconButton
aria-label={$t('show_search_options')}
shape="round"
icon={mdiTune}
onclick={onFilterClick}
size="medium"
color="secondary"
variant="ghost"
/>
</div>
</div>
@@ -1,117 +0,0 @@
<script lang="ts" module>
export interface SearchCameraFilter {
make?: string;
model?: string;
lensModel?: string;
}
</script>
<script lang="ts">
import Combobox, { asComboboxOptions, asSelectedOption } from '$lib/components/shared-components/combobox.svelte';
import { handlePromiseError } from '$lib/utils';
import { SearchSuggestionType, getSearchSuggestions } from '@immich/sdk';
import { Text } from '@immich/ui';
import { t } from 'svelte-i18n';
interface Props {
filters: SearchCameraFilter;
}
let { filters = $bindable() }: Props = $props();
let makes: string[] = $state([]);
let models: string[] = $state([]);
let lensModels: string[] = $state([]);
async function updateMakes() {
const results: Array<string | null> = await getSearchSuggestions({
$type: SearchSuggestionType.CameraMake,
includeNull: true,
});
makes = results.map((result) => result ?? '');
if (filters.make && !makes.includes(filters.make)) {
filters.make = undefined;
}
}
async function updateModels(make?: string) {
const results: Array<string | null> = await getSearchSuggestions({
$type: SearchSuggestionType.CameraModel,
make,
includeNull: true,
});
models = results.map((result) => result ?? '');
if (filters.model && !models.includes(filters.model)) {
filters.model = undefined;
}
}
async function updateLensModels(make?: string, model?: string) {
const results: Array<string | null> = await getSearchSuggestions({
$type: SearchSuggestionType.CameraLensModel,
make,
model,
includeNull: true,
});
lensModels = results.map((result) => result ?? '');
if (filters.lensModel && !lensModels.includes(filters.lensModel)) {
filters.lensModel = undefined;
}
}
const makeFilter = $derived(filters.make);
const modelFilter = $derived(filters.model);
const lensModelFilter = $derived(filters.lensModel);
// TODO replace by async $derived, at the latest when it's in stable https://svelte.dev/docs/svelte/await-expressions
$effect(() => {
handlePromiseError(updateMakes());
});
$effect(() => {
handlePromiseError(updateModels(makeFilter));
});
$effect(() => {
handlePromiseError(updateLensModels(makeFilter, modelFilter));
});
</script>
<div id="camera-selection">
<Text fontWeight="medium">{$t('camera')}</Text>
<div class="grid grid-auto-fit-40 gap-5 mt-1">
<div class="w-full">
<Combobox
label={$t('make')}
onSelect={(option) => (filters.make = option?.value)}
options={asComboboxOptions(makes)}
placeholder={$t('search_camera_make')}
selectedOption={asSelectedOption(makeFilter)}
/>
</div>
<div class="w-full">
<Combobox
label={$t('model')}
onSelect={(option) => (filters.model = option?.value)}
options={asComboboxOptions(models)}
placeholder={$t('search_camera_model')}
selectedOption={asSelectedOption(modelFilter)}
/>
</div>
<div class="w-full">
<Combobox
label={$t('lens_model')}
onSelect={(option) => (filters.lensModel = option?.value)}
options={asComboboxOptions(lensModels)}
placeholder={$t('search_camera_lens_model')}
selectedOption={asSelectedOption(lensModelFilter)}
/>
</div>
</div>
</div>
@@ -1,37 +0,0 @@
<script lang="ts" module>
export interface SearchDateFilter {
takenBefore?: DateTime;
takenAfter?: DateTime;
}
</script>
<script lang="ts">
import { DatePicker, Text } from '@immich/ui';
import type { DateTime } from 'luxon';
import { t } from 'svelte-i18n';
interface Props {
filters: SearchDateFilter;
}
let { filters = $bindable() }: Props = $props();
let invalid = $derived(filters.takenAfter && filters.takenBefore && filters.takenAfter > filters.takenBefore);
</script>
<div class="flex flex-col gap-1">
<div id="date-range-selection" class="grid grid-auto-fit-40 gap-5">
<div>
<Text class="mb-2" fontWeight="medium">{$t('start_date')}</Text>
<DatePicker bind:value={filters.takenAfter} />
</div>
<div>
<Text class="mb-2" fontWeight="medium">{$t('end_date')}</Text>
<DatePicker bind:value={filters.takenBefore} />
</div>
</div>
{#if invalid}
<Text color="danger">{$t('start_date_before_end_date')}</Text>
{/if}
</div>
@@ -1,40 +0,0 @@
<script lang="ts" module>
export interface SearchDisplayFilters {
isNotInAlbum: boolean;
isArchive: boolean;
isFavorite: boolean;
}
</script>
<script lang="ts">
import { Checkbox, Label, Text } from '@immich/ui';
import { t } from 'svelte-i18n';
interface Props {
filters: SearchDisplayFilters;
}
let { filters = $bindable() }: Props = $props();
</script>
<div id="display-options-selection">
<fieldset>
<Text class="mb-2" fontWeight="medium">{$t('display_options')}</Text>
<div class="flex flex-wrap gap-x-5 gap-y-2 mt-1">
<div class="flex items-center gap-2">
<Checkbox id="not-in-album-checkbox" size="tiny" bind:checked={filters.isNotInAlbum} />
<Label label={$t('not_in_any_album')} for="not-in-album-checkbox" class="text-sm font-normal" />
</div>
<div class="flex items-center gap-2">
<Checkbox id="archive-checkbox" size="tiny" bind:checked={filters.isArchive} />
<Label label={$t('archive')} for="archive-checkbox" class="text-sm font-normal" />
</div>
<div class="flex items-center gap-2">
<Checkbox id="favorites-checkbox" size="tiny" bind:checked={filters.isFavorite} />
<Label label={$t('favorites')} for="favorites-checkbox" class="text-sm font-normal" />
</div>
</div>
</fieldset>
</div>
@@ -1,151 +0,0 @@
<script lang="ts">
import { searchStore } from '$lib/stores/search.svelte';
import { Icon, IconButton, Text } from '@immich/ui';
import { mdiClose, mdiMagnify } from '@mdi/js';
import { t } from 'svelte-i18n';
import { fly } from 'svelte/transition';
interface Props {
id: string;
searchQuery?: string;
isSearchSuggestions?: boolean;
isOpen?: boolean;
onSelectSearchTerm: (searchTerm: string) => void;
onClearSearchTerm: (searchTerm: string) => void;
onClearAllSearchTerms: () => void;
onActiveSelectionChange: (selectedId: string | undefined) => void;
}
let {
id,
searchQuery = '',
isSearchSuggestions = $bindable(false),
isOpen = false,
onSelectSearchTerm,
onClearSearchTerm,
onClearAllSearchTerms,
onActiveSelectionChange,
}: Props = $props();
let filteredSearchTerms = $derived(
searchStore.savedSearchTerms.filter((term) => term.toLowerCase().includes(searchQuery.toLowerCase())),
);
$effect(() => {
isSearchSuggestions = filteredSearchTerms.length > 0;
});
let showClearAll = $derived(searchQuery === '');
let suggestionCount = $derived(showClearAll ? filteredSearchTerms.length + 1 : filteredSearchTerms.length);
let selectedIndex: number | undefined = $state(undefined);
let element = $state<HTMLDivElement>();
export function moveSelection(increment: 1 | -1) {
if (!isSearchSuggestions) {
return;
} else if (selectedIndex === undefined) {
selectedIndex = increment === 1 ? 0 : suggestionCount - 1;
} else if (selectedIndex + increment < 0 || selectedIndex + increment >= suggestionCount) {
clearSelection();
} else {
selectedIndex = (selectedIndex + increment + suggestionCount) % suggestionCount;
}
onActiveSelectionChange(getId(selectedIndex));
}
export function clearSelection() {
selectedIndex = undefined;
onActiveSelectionChange(undefined);
}
export function selectActiveOption() {
if (selectedIndex === undefined) {
return;
}
const selectedElement = element?.querySelector(`#${getId(selectedIndex)}`) as HTMLElement;
selectedElement?.click();
}
const handleClearAll = () => {
clearSelection();
onClearAllSearchTerms();
};
const handleClearSingle = (searchTerm: string) => {
clearSelection();
onClearSearchTerm(searchTerm);
};
const handleSelect = (searchTerm: string) => {
clearSelection();
onSelectSearchTerm(searchTerm);
};
const getId = (index: number | undefined) => {
if (index === undefined) {
return undefined;
}
return `${id}-${index}`;
};
</script>
<div role="listbox" {id} aria-label={$t('recent_searches')} bind:this={element}>
{#if isOpen && isSearchSuggestions}
<div
transition:fly={{ y: 25, duration: 150 }}
class="absolute w-full rounded-b-3xl border-2 border-t-0 border-gray-200 bg-white pb-5 shadow-2xl transition-all dark:border-gray-700 dark:bg-immich-dark-gray dark:text-gray-300 z-1"
>
<div class="flex items-center justify-between px-5 pt-5 text-xs">
<Text class="py-2" color="muted" aria-hidden={true}>{$t('recent_searches')}</Text>
{#if showClearAll}
<button
id={getId(0)}
type="button"
class="rounded-lg p-2 font-semibold text-primary aria-selected:bg-immich-primary/25 hover:bg-immich-primary/25"
role="option"
onclick={() => handleClearAll()}
tabindex="-1"
aria-selected={selectedIndex === 0}
aria-label={$t('clear_all_recent_searches')}
>
{$t('clear_all')}
</button>
{/if}
</div>
{#each filteredSearchTerms as savedSearchTerm, i (i)}
{@const index = showClearAll ? i + 1 : i}
<div class="flex w-full items-center justify-between text-sm text-black dark:text-gray-300">
<div class="relative w-full items-center">
<!-- svelte-ignore a11y_click_events_have_key_events -->
<div
id={getId(index)}
class="relative flex w-full cursor-pointer gap-3 py-3 ps-5 hover:bg-gray-100 aria-selected:bg-gray-100 dark:aria-selected:bg-gray-500/30 dark:hover:bg-gray-500/30"
onclick={() => handleSelect(savedSearchTerm)}
role="option"
tabindex="-1"
aria-selected={selectedIndex === index}
aria-label={savedSearchTerm}
>
<Icon icon={mdiMagnify} size="1.5em" aria-hidden />
{savedSearchTerm}
</div>
<div aria-hidden={true} class="absolute end-5 top-0 items-center justify-center py-3">
<IconButton
shape="round"
color="secondary"
variant="ghost"
icon={mdiClose}
aria-label={$t('remove')}
size="medium"
tabindex={-1}
onclick={() => handleClearSingle(savedSearchTerm)}
/>
</div>
</div>
</div>
{/each}
</div>
{/if}
</div>
@@ -1,111 +0,0 @@
<script lang="ts" module>
export interface SearchLocationFilter {
country?: string;
state?: string;
city?: string;
}
</script>
<script lang="ts">
import Combobox, { asComboboxOptions, asSelectedOption } from '$lib/components/shared-components/combobox.svelte';
import { handlePromiseError } from '$lib/utils';
import { getSearchSuggestions, SearchSuggestionType } from '@immich/sdk';
import { Text } from '@immich/ui';
import { onMount } from 'svelte';
import { t } from 'svelte-i18n';
interface Props {
filters: SearchLocationFilter;
}
let { filters = $bindable() }: Props = $props();
let countries: string[] = $state([]);
let states: string[] = $state([]);
let cities: string[] = $state([]);
async function updateCountries() {
const results: Array<string | null> = await getSearchSuggestions({
$type: SearchSuggestionType.Country,
includeNull: true,
});
countries = results.map((result) => result ?? '');
if (filters.country && !countries.includes(filters.country)) {
filters.country = undefined;
}
}
async function updateStates(country?: string) {
const results: Array<string | null> = await getSearchSuggestions({
$type: SearchSuggestionType.State,
country,
includeNull: true,
});
states = results.map((result) => result ?? '');
if (filters.state && !states.includes(filters.state)) {
filters.state = undefined;
}
}
async function updateCities(country?: string, state?: string) {
const results: Array<string | null> = await getSearchSuggestions({
$type: SearchSuggestionType.City,
country,
state,
});
cities = results.map((result) => result ?? '');
if (filters.city && !cities.includes(filters.city)) {
filters.city = undefined;
}
}
let countryFilter = $derived(filters.country);
let stateFilter = $derived(filters.state);
// TODO replace by async $derived, at the latest when it's in stable https://svelte.dev/docs/svelte/await-expressions
$effect(() => handlePromiseError(updateStates(countryFilter)));
$effect(() => handlePromiseError(updateCities(countryFilter, stateFilter)));
onMount(() => updateCountries());
</script>
<div id="location-selection">
<Text fontWeight="medium">{$t('place')}</Text>
<div class="grid grid-auto-fit-40 gap-5 mt-1">
<div class="w-full">
<Combobox
label={$t('country')}
onSelect={(option) => (filters.country = option?.value)}
options={asComboboxOptions(countries)}
placeholder={$t('search_country')}
selectedOption={asSelectedOption(filters.country)}
/>
</div>
<div class="w-full">
<Combobox
label={$t('state')}
onSelect={(option) => (filters.state = option?.value)}
options={asComboboxOptions(states)}
placeholder={$t('search_state')}
selectedOption={asSelectedOption(filters.state)}
/>
</div>
<div class="w-full">
<Combobox
label={$t('city')}
onSelect={(option) => (filters.city = option?.value)}
options={asComboboxOptions(cities)}
placeholder={$t('search_city')}
selectedOption={asSelectedOption(filters.city)}
/>
</div>
</div>
</div>
@@ -1,36 +0,0 @@
<script lang="ts">
import { MediaType } from '$lib/constants';
import RadioButton from '$lib/elements/RadioButton.svelte';
import { Text } from '@immich/ui';
import { t } from 'svelte-i18n';
interface Props {
filteredMedia: MediaType;
}
let { filteredMedia = $bindable() }: Props = $props();
</script>
<div id="media-type-selection">
<fieldset>
<Text class="mb-2" fontWeight="medium">{$t('media_type')}</Text>
<div class="flex flex-wrap gap-x-5 gap-y-2 mt-1">
<RadioButton name="media-type" id="type-all" bind:group={filteredMedia} label={$t('all')} value={MediaType.All} />
<RadioButton
name="media-type"
id="type-image"
bind:group={filteredMedia}
label={$t('image')}
value={MediaType.Image}
/>
<RadioButton
name="media-type"
id="type-video"
bind:group={filteredMedia}
label={$t('video')}
value={MediaType.Video}
/>
</div>
</fieldset>
</div>
@@ -1,106 +0,0 @@
<script lang="ts">
import ImageThumbnail from '$lib/components/assets/thumbnail/image-thumbnail.svelte';
import SingleGridRow from '$lib/components/shared-components/single-grid-row.svelte';
import SearchBar from '$lib/elements/SearchBar.svelte';
import { getPeopleThumbnailUrl } from '$lib/utils';
import { handleError } from '$lib/utils/handle-error';
import { getAllPeople, type PersonResponseDto } from '@immich/sdk';
import { Button, LoadingSpinner, Text } from '@immich/ui';
import { mdiArrowRight, mdiClose } from '@mdi/js';
import { t } from 'svelte-i18n';
import type { SvelteSet } from 'svelte/reactivity';
interface Props {
selectedPeople: SvelteSet<string>;
}
let { selectedPeople = $bindable() }: Props = $props();
let peoplePromise = getPeople();
let showAllPeople = $state(false);
let name = $state('');
let numberOfPeople = $state(1);
function orderBySelectedPeopleFirst(people: PersonResponseDto[]) {
return [
...people.filter((p) => selectedPeople.has(p.id)), //
...people.filter((p) => !selectedPeople.has(p.id)),
];
}
async function getPeople() {
try {
const res = await getAllPeople({ withHidden: false });
return orderBySelectedPeopleFirst(res.people);
} catch (error) {
handleError(error, $t('errors.failed_to_get_people'));
}
}
function togglePersonSelection(id: string) {
if (selectedPeople.has(id)) {
selectedPeople.delete(id);
} else {
selectedPeople.add(id);
}
}
const filterPeople = (list: PersonResponseDto[], name: string) => {
const nameLower = name.toLowerCase();
return name ? list.filter((p) => p.name.toLowerCase().includes(nameLower)) : list;
};
</script>
{#await peoplePromise}
<div id="spinner" class="flex h-54 items-center justify-center -mb-4">
<LoadingSpinner size="large" />
</div>
{:then people}
{#if people && people.length > 0}
{@const peopleList = showAllPeople
? filterPeople(people, name)
: filterPeople(people, name).slice(0, numberOfPeople)}
<div id="people-selection" class="max-h-60 -mb-4 overflow-y-auto immich-scrollbar">
<div class="flex items-center w-full justify-between gap-6">
<Text class="py-3" fontWeight="medium">{$t('people')}</Text>
<SearchBar bind:name placeholder={$t('filter_people')} showLoadingSpinner={false} />
</div>
<SingleGridRow
class="grid grid-auto-fill-20 gap-1 mt-2 overflow-y-auto immich-scrollbar space-between"
bind:itemCount={numberOfPeople}
>
{#each peopleList as person (person.id)}
<button
type="button"
class="flex flex-col items-center rounded-3xl border-2 hover:bg-subtle dark:hover:bg-immich-dark-primary/20 p-2 transition-all {selectedPeople.has(
person.id,
)
? 'dark:border-slate-500 border-slate-400 bg-slate-200 dark:bg-slate-800 dark:text-white'
: 'border-transparent'}"
onclick={() => togglePersonSelection(person.id)}
>
<ImageThumbnail circle shadow url={getPeopleThumbnailUrl(person)} altText={person.name} widthStyle="100%" />
<p class="mt-2 line-clamp-2 text-sm font-medium dark:text-white">{person.name}</p>
</button>
{/each}
</SingleGridRow>
{#if showAllPeople || people.length > peopleList.length}
<div class="flex justify-center mt-2">
<Button
color="primary"
variant="ghost"
shape="round"
leadingIcon={showAllPeople ? mdiClose : mdiArrowRight}
class="flex gap-2 place-items-center"
onclick={() => (showAllPeople = !showAllPeople)}
>
{showAllPeople ? $t('collapse') : $t('see_all_people')}
</Button>
</div>
{/if}
</div>
{/if}
{/await}
@@ -1,32 +0,0 @@
<script lang="ts">
import { Text } from '@immich/ui';
import { t } from 'svelte-i18n';
import Combobox from '../combobox.svelte';
interface Props {
rating?: number;
}
let { rating = $bindable() }: Props = $props();
const options = [
{ value: '0', label: $t('rating_count', { values: { count: 0 } }) },
{ value: '1', label: $t('rating_count', { values: { count: 1 } }) },
{ value: '2', label: $t('rating_count', { values: { count: 2 } }) },
{ value: '3', label: $t('rating_count', { values: { count: 3 } }) },
{ value: '4', label: $t('rating_count', { values: { count: 4 } }) },
{ value: '5', label: $t('rating_count', { values: { count: 5 } }) },
];
</script>
<div class="flex flex-col">
<Text class="mb-2" fontWeight="medium">{$t('rating')}</Text>
<Combobox
label={$t('rating')}
placeholder={$t('search_rating')}
hideLabel
{options}
selectedOption={rating === undefined ? undefined : options[rating]}
onSelect={(r) => (rating = r === undefined ? undefined : Number.parseInt(r.value))}
/>
</div>
@@ -1,98 +0,0 @@
<script lang="ts">
import Combobox, { type ComboBoxOption } from '$lib/components/shared-components/combobox.svelte';
import { preferences } from '$lib/stores/user.store';
import { getAllTags, type TagResponseDto } from '@immich/sdk';
import { Checkbox, Icon, Label, Text } from '@immich/ui';
import { mdiClose } from '@mdi/js';
import { onMount } from 'svelte';
import { t } from 'svelte-i18n';
import { SvelteSet } from 'svelte/reactivity';
interface Props {
selectedTags: SvelteSet<string> | null;
}
let { selectedTags = $bindable() }: Props = $props();
let allTags: TagResponseDto[] = $state([]);
let tagMap = $derived(Object.fromEntries(allTags.map((tag) => [tag.id, tag])));
let selectedOption = $state(undefined);
onMount(async () => {
allTags = await getAllTags();
});
const handleSelect = (option?: ComboBoxOption) => {
if (!option || !option.id || selectedTags === null) {
return;
}
selectedTags.add(option.value);
selectedOption = undefined;
};
const handleRemove = (tag: string) => {
if (selectedTags === null) {
return;
}
selectedTags.delete(tag);
};
</script>
{#if $preferences?.tags?.enabled}
<div id="location-selection">
<form autocomplete="off" id="create-tag-form">
<div class="mb-4 flex flex-col">
<Text class="py-3" fontWeight="medium">{$t('tags')}</Text>
<Combobox
disabled={selectedTags === null}
hideLabel
onSelect={handleSelect}
label={$t('tags')}
defaultFirstOption
options={allTags.map((tag) => ({ id: tag.id, label: tag.value, value: tag.id }))}
bind:selectedOption
placeholder={$t('search_tags')}
/>
</div>
<div class="flex items-center gap-2">
<Checkbox
id="untagged-checkbox"
size="tiny"
checked={selectedTags === null}
onCheckedChange={(checked) => {
selectedTags = checked ? null : new SvelteSet();
}}
/>
<Label label={$t('untagged')} for="untagged-checkbox" class="text-sm font-normal" />
</div>
</form>
<section class="flex flex-wrap pt-2 gap-1">
{#each selectedTags ?? [] as tagId (tagId)}
{@const tag = tagMap[tagId]}
{#if tag}
<div class="flex group transition-all">
<span
class="inline-block h-min whitespace-nowrap ps-3 pe-1 group-hover:ps-3 py-1 text-center align-baseline leading-none text-gray-100 dark:text-immich-dark-gray bg-primary roudned-s-full hover:bg-immich-primary/80 dark:hover:bg-immich-dark-primary/80 transition-all"
>
<p class="text-sm">
{tag.value}
</p>
</span>
<button
type="button"
class="text-gray-100 dark:text-immich-dark-gray bg-immich-primary/95 dark:bg-immich-dark-primary/95 rounded-e-full place-items-center place-content-center pe-2 ps-1 py-1 hover:bg-immich-primary/80 dark:hover:bg-immich-dark-primary/80 transition-all"
title={$t('remove_tag')}
onclick={() => handleRemove(tagId)}
>
<Icon icon={mdiClose} />
</button>
</div>
{/if}
{/each}
</section>
</div>
{/if}
@@ -1,59 +0,0 @@
<script lang="ts">
import RadioButton from '$lib/elements/RadioButton.svelte';
import { featureFlagsManager } from '$lib/managers/feature-flags-manager.svelte';
import { Field, Input, Text } from '@immich/ui';
import { t } from 'svelte-i18n';
interface Props {
query: string | undefined;
queryType?: 'smart' | 'metadata' | 'description' | 'ocr';
}
let { query = $bindable(), queryType = $bindable('smart') }: Props = $props();
</script>
<section>
<fieldset>
<Text class="mb-2" fontWeight="medium">{$t('search_type')}</Text>
<div class="flex flex-wrap gap-x-5 gap-y-2 my-2">
{#if featureFlagsManager.value.smartSearch}
<RadioButton name="query-type" id="context-radio" label={$t('context')} bind:group={queryType} value="smart" />
{/if}
<RadioButton
name="query-type"
id="file-name-radio"
label={$t('file_name_or_extension')}
bind:group={queryType}
value="metadata"
/>
<RadioButton
name="query-type"
id="description-radio"
label={$t('description')}
bind:group={queryType}
value="description"
/>
{#if featureFlagsManager.value.ocr}
<RadioButton name="query-type" id="ocr-radio" label={$t('ocr')} bind:group={queryType} value="ocr" />
{/if}
</div>
</fieldset>
{#if queryType === 'smart'}
<Field label={$t('search_by_context')}>
<Input type="text" placeholder={$t('sunrise_on_the_beach')} bind:value={query} />
</Field>
{:else if queryType === 'metadata'}
<Field label={$t('search_by_filename')}>
<Input type="text" placeholder={$t('search_by_filename_example')} bind:value={query} />
</Field>
{:else if queryType === 'description'}
<Field label={$t('search_by_description')}>
<Input type="text" placeholder={$t('search_by_description_example')} bind:value={query} />
</Field>
{:else if queryType === 'ocr'}
<Field label={$t('search_by_ocr')}>
<Input type="text" placeholder={$t('search_by_ocr_example')} bind:value={query} />
</Field>
{/if}
</section>
@@ -1,9 +1,8 @@
<script lang="ts">
import { systemConfigManager } from '$lib/managers/system-config-manager.svelte';
import { handleSystemConfigSave } from '$lib/services/system-config.service';
import type { SystemConfigDto } from '@immich/sdk';
import { Button, toastManager } from '@immich/ui';
import { isEqual, pick } from 'lodash-es';
import type { SystemConfigDto } from '@server/sdk';
import { t } from 'svelte-i18n';
type Props = {
@@ -1,15 +0,0 @@
<script lang="ts">
import PurchaseInfo from './purchase-info.svelte';
import ServerStatus from './server-status.svelte';
import StorageSpace from './storage-space.svelte';
</script>
<div class="mt-auto">
<StorageSpace />
</div>
<PurchaseInfo />
<div class="mb-6 mt-2">
<ServerStatus />
</div>
@@ -1,178 +0,0 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { OpenQueryParam } from '$lib/constants';
import Portal from '$lib/elements/Portal.svelte';
import PurchaseModal from '$lib/modals/PurchaseModal.svelte';
import { Route } from '$lib/route';
import { purchaseStore } from '$lib/stores/purchase.store';
import { preferences } from '$lib/stores/user.store';
import { getAccountAge } from '$lib/utils/auth';
import { handleError } from '$lib/utils/handle-error';
import { getButtonVisibility } from '$lib/utils/purchase-utils';
import { updateMyPreferences } from '@immich/sdk';
import { Button, Icon, IconButton, Logo, modalManager, SupporterBadge } from '@immich/ui';
import { mdiClose, mdiInformationOutline } from '@mdi/js';
import { t } from 'svelte-i18n';
import { SvelteDate } from 'svelte/reactivity';
import { fade } from 'svelte/transition';
let showMessage = $state(false);
let hoverMessage = $state(false);
let hoverButton = $state(false);
let showBuyButton = $state(getButtonVisibility());
const { isPurchased } = purchaseStore;
const openPurchaseModal = async () => {
await modalManager.show(PurchaseModal);
showMessage = false;
};
const onButtonHover = () => {
showMessage = true;
hoverButton = true;
};
const hideButton = async (always: boolean) => {
const hideBuyButtonUntil = new SvelteDate();
if (always) {
hideBuyButtonUntil.setFullYear(2124); // see ya in 100 years
} else {
hideBuyButtonUntil.setDate(hideBuyButtonUntil.getDate() + 30);
}
try {
const response = await updateMyPreferences({
userPreferencesUpdateDto: {
purchase: {
hideBuyButtonUntil: hideBuyButtonUntil.toISOString(),
},
},
});
preferences.set(response);
showBuyButton = getButtonVisibility();
showMessage = false;
} catch (error) {
handleError(error, $t('errors.error_hiding_buy_button'));
}
};
$effect(() => {
if (showMessage && !hoverMessage && !hoverButton) {
setTimeout(() => {
if (!hoverMessage && !hoverButton) {
showMessage = false;
}
}, 300);
}
});
</script>
<div class="license-status ps-4 text-sm">
{#if $isPurchased && $preferences.purchase.showSupportBadge}
<button
onclick={() => goto(Route.userSettings({ isOpen: OpenQueryParam.PURCHASE_SETTINGS }))}
class="w-full mt-2"
type="button"
>
<SupporterBadge size="small" effect="always" />
</button>
{:else if !$isPurchased && showBuyButton && getAccountAge() > 14}
<button
type="button"
onclick={openPurchaseModal}
onmouseover={onButtonHover}
onmouseleave={() => (hoverButton = false)}
onfocus={onButtonHover}
onblur={() => (hoverButton = false)}
class="p-2 flex justify-between place-items-center place-content-center border border-immich-primary/20 dark:border-immich-dark-primary/10 mt-2 rounded-lg shadow-md dark:bg-immich-dark-primary/10 min-w-52 w-full"
>
<div class="flex justify-between w-full place-items-center place-content-center">
<div class="flex place-items-center place-content-center gap-1">
<Logo variant="icon" size="tiny" />
<p class="flex text-primary font-medium">
{$t('purchase_button_buy_immich')}
</p>
</div>
<div>
<Icon icon={mdiInformationOutline} class="hidden sidebar:flex text-primary font-medium" size="18" />
</div>
</div>
</button>
{/if}
</div>
<Portal target="body">
{#if showMessage}
<dialog
open
class="hidden sidebar:block w-125 absolute bottom-19 start-64 bg-gray-50 dark:border-gray-800 border border-gray-200 dark:bg-immich-dark-gray dark:text-white text-black rounded-3xl shadow-2xl px-8 py-6"
transition:fade={{ duration: 150 }}
onmouseover={() => (hoverMessage = true)}
onmouseleave={() => (hoverMessage = false)}
onfocus={() => (hoverMessage = true)}
onblur={() => (hoverMessage = false)}
>
<div class="flex justify-between place-items-center">
<div class="h-10 w-10">
<Logo variant="icon" size="small" />
</div>
<IconButton
shape="round"
color="secondary"
variant="ghost"
icon={mdiClose}
onclick={() => {
showMessage = false;
}}
aria-label={$t('close')}
size="medium"
class="text-immich-dark-gray/85 dark:text-immich-gray"
/>
</div>
<h1 class="text-lg font-medium my-3 text-primary">
{$t('purchase_panel_title')}
</h1>
<div class="text-gray-800 dark:text-white my-4">
<p>
{$t('purchase_panel_info_1')}
</p>
<br />
<p>
{$t('purchase_panel_info_2')}
</p>
</div>
<Button shape="round" class="mt-2" fullWidth onclick={openPurchaseModal}
>{$t('purchase_button_buy_immich')}</Button
>
<div class="mt-3 flex gap-4">
<Button shape="round" size="small" fullWidth color="secondary" variant="ghost" onclick={() => hideButton(true)}>
{$t('purchase_button_never_show_again')}
</Button>
<Button
shape="round"
size="small"
fullWidth
color="secondary"
variant="ghost"
onclick={() => hideButton(false)}
>
{$t('purchase_button_reminder')}
</Button>
</div>
</dialog>
{/if}
</Portal>
<style>
dialog {
margin: 0;
}
</style>
@@ -1,31 +0,0 @@
import { sdkMock } from '$lib/__mocks__/sdk.mock';
import RecentAlbums from '$lib/components/shared-components/side-bar/recent-albums.svelte';
import { albumFactory } from '@test-data/factories/album-factory';
import { render, screen } from '@testing-library/svelte';
import { tick } from 'svelte';
describe('RecentAlbums component', () => {
it('sorts albums by most recently updated', async () => {
const albums = [
albumFactory.build({ updatedAt: '2024-01-01T00:00:00Z' }),
albumFactory.build({ updatedAt: '2024-01-09T00:00:01Z' }),
albumFactory.build({ updatedAt: '2024-01-10T00:00:00Z' }),
albumFactory.build({ updatedAt: '2024-01-09T00:00:00Z' }),
];
sdkMock.getAllAlbums.mockResolvedValueOnce([...albums]);
render(RecentAlbums);
expect(sdkMock.getAllAlbums).toBeCalledTimes(1);
// wtf
await tick();
await tick();
const links = screen.getAllByRole('link');
expect(links).toHaveLength(3);
expect(links[0]).toHaveAttribute('href', `/albums/${albums[2].id}`);
expect(links[1]).toHaveAttribute('href', `/albums/${albums[1].id}`);
expect(links[2]).toHaveAttribute('href', `/albums/${albums[3].id}`);
});
});
@@ -1,45 +0,0 @@
<script lang="ts">
import { Route } from '$lib/route';
import { userInteraction } from '$lib/stores/user.svelte';
import { getAssetMediaUrl } from '$lib/utils';
import { handleError } from '$lib/utils/handle-error';
import { getAllAlbums, type AlbumResponseDto } from '@immich/sdk';
import { onMount } from 'svelte';
import { t } from 'svelte-i18n';
let albums: AlbumResponseDto[] = $state([]);
onMount(async () => {
if (userInteraction.recentAlbums) {
albums = userInteraction.recentAlbums;
return;
}
try {
const allAlbums = await getAllAlbums({});
albums = allAlbums.sort((a, b) => (a.updatedAt > b.updatedAt ? -1 : 1)).slice(0, 3);
userInteraction.recentAlbums = albums;
} catch (error) {
handleError(error, $t('failed_to_load_assets'));
}
});
</script>
{#each albums as album (album.id)}
<a
href={Route.viewAlbum(album)}
title={album.albumName}
class="flex w-full place-items-center justify-between gap-4 rounded-e-full py-3 transition-[padding] delay-100 duration-100 hover:cursor-pointer hover:bg-subtle hover:text-immich-primary dark:text-immich-dark-fg dark:hover:bg-immich-dark-gray dark:hover:text-immich-dark-primary ps-10 group-hover:sm:px-10 md:px-10"
>
<div>
<div
class="h-6 w-6 bg-cover rounded bg-gray-200 dark:bg-gray-600"
style={album.albumThumbnailAssetId
? `background-image:url('${getAssetMediaUrl({ id: album.albumThumbnailAssetId })}')`
: ''}
></div>
</div>
<div class="grow text-sm font-medium truncate">
{album.albumName}
</div>
</a>
{/each}
@@ -1,115 +0,0 @@
<script lang="ts">
import { releaseManager } from '$lib/managers/release-manager.svelte';
import ServerAboutModal from '$lib/modals/ServerAboutModal.svelte';
import { user } from '$lib/stores/user.store';
import { userInteraction } from '$lib/stores/user.svelte';
import { websocketStore } from '$lib/stores/websocket';
import type { ReleaseEvent } from '$lib/types';
import { semverToName } from '$lib/utils';
import { requestServerInfo } from '$lib/utils/auth';
import {
getAboutInfo,
getVersionHistory,
type ServerAboutResponseDto,
type ServerVersionHistoryResponseDto,
} from '@immich/sdk';
import { Icon, modalManager, Text } from '@immich/ui';
import { mdiAlert, mdiNewBox } from '@mdi/js';
import { onMount } from 'svelte';
import { t } from 'svelte-i18n';
const { serverVersion, connected } = websocketStore;
let info: ServerAboutResponseDto | undefined = $state();
let versions: ServerVersionHistoryResponseDto[] = $state([]);
onMount(async () => {
if (userInteraction.aboutInfo && userInteraction.versions && $serverVersion) {
info = userInteraction.aboutInfo;
versions = userInteraction.versions;
return;
}
await requestServerInfo();
[info, versions] = await Promise.all([getAboutInfo(), getVersionHistory()]);
userInteraction.aboutInfo = info;
userInteraction.versions = versions;
});
let isMain = $derived(info?.sourceRef === 'main' && info.repository === 'immich-app/immich');
let version = $derived(
$serverVersion ? `v${$serverVersion.major}.${$serverVersion.minor}.${$serverVersion.patch}` : null,
);
const getReleaseInfo = (release?: ReleaseEvent) => {
if (!release || !release?.isAvailable || !$user.isAdmin) {
return;
}
const availableVersion = semverToName(release.releaseVersion);
const serverVersion = semverToName(release.serverVersion);
if (serverVersion === availableVersion) {
return;
}
return { availableVersion, releaseUrl: `https://github.com/immich-app/immich/releases/tag/${availableVersion}` };
};
const releaseInfo = $derived(getReleaseInfo(releaseManager.value));
</script>
<div
class="text-sm flex md:flex ps-5 pe-1 place-items-center place-content-center justify-between min-w-52 overflow-hidden dark:text-immich-dark-fg"
>
{#if $connected}
<div class="flex gap-2 place-items-center place-content-center">
<div class="w-1.75 h-1.75 bg-green-500 rounded-full"></div>
<p class="dark:text-immich-gray">{$t('server_online')}</p>
</div>
{:else}
<div class="flex gap-2 place-items-center place-content-center">
<div class="w-1.75 h-1.75 bg-red-500 rounded-full"></div>
<p class="text-red-500">{$t('server_offline')}</p>
</div>
{/if}
<div class="flex justify-between justify-items-center">
{#if $connected && version}
<button
type="button"
onclick={() => info && modalManager.show(ServerAboutModal, { versions, info })}
class="dark:text-immich-gray flex gap-1 place-items-center place-content-center"
>
{#if isMain}
<Icon icon={mdiAlert} size="1.5em" color="#ffcc4d" /> {info?.sourceRef}
{:else}
{version}
{/if}
</button>
{:else}
<p class="text-red-500">{$t('unknown')}</p>
{/if}
</div>
</div>
{#if releaseInfo}
<a
href={releaseInfo.releaseUrl}
target="_blank"
rel="noopener noreferrer"
class="mt-3 p-2.5 ms-4 rounded-lg text-sm min-w-52 border border-gray-200/50 dark:border-gray-700/50 bg-white/50 dark:bg-gray-800/50 hover:border-immich-primary/40 dark:hover:border-immich-dark-primary/40 hover:bg-immich-primary/5 dark:hover:bg-immich-dark-primary/5 transition-all duration-200 group block"
>
<div class="flex items-center justify-between gap-2">
<div class="flex items-center gap-2">
<Icon icon={mdiNewBox} size="16" class="text-immich-primary dark:text-immich-dark-primary opacity-80" />
<Text size="tiny" fontWeight="medium" class="text-gray-700 dark:text-gray-300">
{releaseInfo.availableVersion}
</Text>
</div>
<span
class="text-[11px] text-gray-500 dark:text-gray-400 group-hover:text-immich-primary dark:group-hover:text-immich-dark-primary transition-colors opacity-70 group-hover:opacity-100"
>
{$t('new_update')}!
</span>
</div>
</a>
{/if}
@@ -1,77 +0,0 @@
<script lang="ts">
import { locale } from '$lib/stores/preferences.store';
import { user } from '$lib/stores/user.store';
import { userInteraction } from '$lib/stores/user.svelte';
import { requestServerInfo } from '$lib/utils/auth';
import { getByteUnitString } from '$lib/utils/byte-units';
import { LoadingSpinner } from '@immich/ui';
import { onMount } from 'svelte';
import { t } from 'svelte-i18n';
let usageClasses = $state('');
let hasQuota = $derived($user?.quotaSizeInBytes !== null);
let availableBytes = $derived((hasQuota ? $user?.quotaSizeInBytes : userInteraction.serverInfo?.diskSizeRaw) || 0);
let usedBytes = $derived((hasQuota ? $user?.quotaUsageInBytes : userInteraction.serverInfo?.diskUseRaw) || 0);
let usedPercentage = $derived(Math.min(Math.round((usedBytes / availableBytes) * 100), 100));
const onUpdate = () => {
usageClasses = getUsageClass();
};
const getUsageClass = () => {
if (usedPercentage >= 95) {
return 'bg-red-500';
}
if (usedPercentage > 80) {
return 'bg-yellow-500';
}
return 'bg-primary';
};
$effect(() => {
if ($user) {
onUpdate();
}
});
onMount(async () => {
if (userInteraction.serverInfo && $user) {
return;
}
await requestServerInfo();
});
</script>
<div
class="storage-status p-4 bg-gray-100 dark:bg-immich-dark-primary/10 ms-4 rounded-lg text-sm min-w-52"
title={$t('storage_usage', {
values: {
used: getByteUnitString(usedBytes, $locale, 3),
available: getByteUnitString(availableBytes, $locale, 3),
},
})}
>
<p class="font-medium text-immich-dark-gray dark:text-white mb-2">{$t('storage')}</p>
{#if userInteraction.serverInfo}
<p class="text-gray-500 dark:text-gray-300">
{$t('storage_usage', {
values: {
used: getByteUnitString(usedBytes, $locale),
available: getByteUnitString(availableBytes, $locale),
},
})}
</p>
<div class="mt-4 h-1.75 w-full rounded-full bg-gray-200 dark:bg-gray-700">
<div class="h-1.75 rounded-full {usageClasses}" style="width: {usedPercentage}%"></div>
</div>
{:else}
<div class="mt-2">
<LoadingSpinner />
</div>
{/if}
</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,106 +1,11 @@
<script lang="ts">
import BottomInfo from '$lib/components/shared-components/side-bar/bottom-info.svelte';
import RecentAlbums from '$lib/components/shared-components/side-bar/recent-albums.svelte';
import Sidebar from '$lib/components/sidebar/sidebar.svelte';
import { featureFlagsManager } from '$lib/managers/feature-flags-manager.svelte';
import { Route } from '$lib/route';
import { recentAlbumsDropdown } from '$lib/stores/preferences.store';
import { preferences } from '$lib/stores/user.store';
import { NavbarGroup, NavbarItem } from '@immich/ui';
import {
mdiAccount,
mdiAccountMultiple,
mdiAccountMultipleOutline,
mdiAccountOutline,
mdiArchiveArrowDown,
mdiArchiveArrowDownOutline,
mdiFolderOutline,
mdiHeart,
mdiHeartOutline,
mdiImageAlbum,
mdiImageMultiple,
mdiImageMultipleOutline,
mdiLink,
mdiLock,
mdiLockOutline,
mdiMagnify,
mdiMap,
mdiMapOutline,
mdiTagMultipleOutline,
mdiToolbox,
mdiToolboxOutline,
mdiTrashCan,
mdiTrashCanOutline,
} from '@mdi/js';
import { sidebarStore } from '$lib/stores/sidebar.svelte';
import { AppShellSidebar, NavbarItem } from '@immich/ui';
import { mdiCogOutline } from '@mdi/js';
import { t } from 'svelte-i18n';
import { fly } from 'svelte/transition';
</script>
<Sidebar ariaLabel={$t('primary')}>
<NavbarItem title={$t('photos')} href={Route.photos()} icon={mdiImageMultipleOutline} activeIcon={mdiImageMultiple} />
{#if featureFlagsManager.value.search}
<NavbarItem title={$t('explore')} href={Route.explore()} icon={mdiMagnify} />
{/if}
{#if featureFlagsManager.value.map}
<NavbarItem title={$t('map')} href={Route.map()} icon={mdiMapOutline} activeIcon={mdiMap} />
{/if}
{#if $preferences.people.enabled && $preferences.people.sidebarWeb}
<NavbarItem title={$t('people')} href={Route.people()} icon={mdiAccountOutline} activeIcon={mdiAccount} />
{/if}
{#if $preferences.sharedLinks.enabled && $preferences.sharedLinks.sidebarWeb}
<NavbarItem title={$t('shared_links')} href={Route.sharedLinks()} icon={mdiLink} />
{/if}
<NavbarItem
title={$t('sharing')}
href={Route.sharing()}
icon={mdiAccountMultipleOutline}
activeIcon={mdiAccountMultiple}
/>
<NavbarGroup title={$t('library')} size="tiny" />
<NavbarItem title={$t('favorites')} href={Route.favorites()} icon={mdiHeartOutline} activeIcon={mdiHeart} />
<NavbarItem
title={$t('albums')}
href={Route.albums()}
icon={{ icon: mdiImageAlbum, flipped: true }}
bind:expanded={$recentAlbumsDropdown}
>
{#snippet items()}
<span in:fly={{ y: -20 }} class="hidden md:block">
<RecentAlbums />
</span>
{/snippet}
</NavbarItem>
{#if $preferences.tags.enabled && $preferences.tags.sidebarWeb}
<NavbarItem title={$t('tags')} href={Route.tags()} icon={{ icon: mdiTagMultipleOutline, flipped: true }} />
{/if}
{#if $preferences.folders.enabled && $preferences.folders.sidebarWeb}
<NavbarItem title={$t('folders')} href={Route.folders()} icon={{ icon: mdiFolderOutline, flipped: true }} />
{/if}
<NavbarItem title={$t('utilities')} href={Route.utilities()} icon={mdiToolboxOutline} activeIcon={mdiToolbox} />
<NavbarItem
title={$t('archive')}
href={Route.archive()}
icon={mdiArchiveArrowDownOutline}
activeIcon={mdiArchiveArrowDown}
/>
<NavbarItem title={$t('locked_folder')} href={Route.locked()} icon={mdiLockOutline} activeIcon={mdiLock} />
{#if featureFlagsManager.value.trash}
<NavbarItem title={$t('trash')} href={Route.trash()} icon={mdiTrashCanOutline} activeIcon={mdiTrashCan} />
{/if}
<BottomInfo />
</Sidebar>
<AppShellSidebar bind:open={sidebarStore.isOpen} ariaLabel={$t('primary')}>
<NavbarItem title={$t('settings')} href={Route.userSettings()} icon={mdiCogOutline} />
</AppShellSidebar>
@@ -1,44 +0,0 @@
<script lang="ts">
interface Props {
class?: string;
itemCount?: number;
children?: import('svelte').Snippet<[{ itemCount: number }]>;
}
let { class: className = '', itemCount = $bindable(1), children }: Props = $props();
let container: HTMLElement | undefined = $state();
let contentRect: DOMRectReadOnly | undefined = $state();
const getGridGap = (element: Element) => {
const style = getComputedStyle(element);
return {
columnGap: parsePixels(style.columnGap),
};
};
const parsePixels = (style: string) => Number.parseInt(style, 10) || 0;
const getItemCount = (container: HTMLElement, containerWidth: number) => {
if (!container.firstElementChild) {
return 1;
}
const childContentRect = container.firstElementChild.getBoundingClientRect();
const childWidth = Math.floor(childContentRect.width || Infinity);
const { columnGap } = getGridGap(container);
return Math.floor((containerWidth + columnGap) / (childWidth + columnGap)) || 1;
};
$effect(() => {
if (container && contentRect) {
itemCount = getItemCount(container, contentRect.width);
}
});
</script>
<div class={className} bind:this={container} bind:contentRect>
{@render children?.({ itemCount })}
</div>
@@ -1,68 +0,0 @@
<script lang="ts">
import { TreeNode } from '$lib/utils/tree-utils';
import { Icon, IconButton } from '@immich/ui';
import { mdiArrowUpLeft, mdiChevronRight } from '@mdi/js';
import { t } from 'svelte-i18n';
interface Props {
node: TreeNode;
getLink: (path: string) => string;
title: string;
icon: string;
}
const { node, getLink, title, icon }: Props = $props();
const rootLink = getLink('');
const isRoot = $derived(node.parent === null);
const parentLink = $derived(getLink(node.parent ? node.parent.path : ''));
const parents = $derived(node.parents);
</script>
<nav class="flex items-center py-2">
{#if parentLink}
<div>
<IconButton
shape="round"
color="secondary"
variant="ghost"
icon={mdiArrowUpLeft}
aria-label={$t('to_parent')}
href={parentLink}
class="me-2"
/>
</div>
{/if}
<div
class="bg-gray-50 dark:bg-immich-dark-gray/50 w-full p-2 rounded-2xl border border-gray-100 dark:border-gray-900 overflow-y-auto immich-scrollbar"
>
<ol class="flex gap-2 items-center">
<li>
<IconButton
shape="round"
color="secondary"
variant="ghost"
{icon}
href={rootLink}
aria-label={title}
size="medium"
aria-current={isRoot ? 'page' : undefined}
/>
</li>
{#each parents as parent (parent)}
<li class="flex gap-2 items-center font-mono text-sm text-nowrap text-primary">
<Icon icon={mdiChevronRight} class="text-gray-500 dark:text-gray-300" size="16" aria-hidden />
<a class="underline hover:font-semibold whitespace-pre-wrap" href={getLink(parent.path)}>
{parent.value}
</a>
</li>
{/each}
<li class="flex gap-2 items-center font-mono text-sm text-nowrap text-primary">
<Icon icon={mdiChevronRight} class="text-gray-500 dark:text-gray-300" size="16" aria-hidden />
<p class="cursor-default whitespace-pre-wrap">{node.value}</p>
</li>
</ol>
</div>
</nav>
@@ -1,33 +0,0 @@
<script lang="ts">
import type { TreeNode } from '$lib/utils/tree-utils';
import { Icon } from '@immich/ui';
interface Props {
items: TreeNode[];
icon: string;
onClick: (path: string) => void;
}
let { items, icon, onClick }: Props = $props();
</script>
{#if items.length > 0}
<div
class="w-full grid grid-cols-2 sm:grid-cols-4 lg:grid-cols-6 2xl:grid-cols-8 gap-2 bg-gray-50 dark:bg-immich-dark-gray/50 rounded-2xl border border-gray-100 dark:border-gray-900"
>
<!-- eslint-disable-next-line svelte/require-each-key -->
{#each items as item}
<button
class="flex flex-col place-items-center gap-2 py-2 px-4 hover:bg-immich-primary/10 dark:hover:bg-immich-primary/40 rounded-xl"
onclick={() => onClick(item.value)}
title={item.value}
type="button"
>
<Icon {icon} class="text-primary" size="64" />
<p class="text-sm dark:text-gray-200 text-nowrap text-ellipsis overflow-clip w-full whitespace-pre-wrap">
{item.value}
</p>
</button>
{/each}
</div>
{/if}
@@ -1,21 +0,0 @@
<script lang="ts">
import Tree from '$lib/components/shared-components/tree/tree.svelte';
import { type TreeNode } from '$lib/utils/tree-utils';
interface Props {
tree: TreeNode;
active: string;
icons: { default: string; active: string };
getLink: (path: string) => string;
}
let { tree, active, icons, getLink }: Props = $props();
</script>
<ul class="list-none ms-2">
{#each tree.children as node (node.color ? node.path + node.color : node.path)}
<li>
<Tree {node} {icons} {active} {getLink} />
</li>
{/each}
</ul>
@@ -1,50 +0,0 @@
<script lang="ts">
import TreeItems from '$lib/components/shared-components/tree/tree-items.svelte';
import { TreeNode } from '$lib/utils/tree-utils';
import { Icon } from '@immich/ui';
import { mdiChevronDown, mdiChevronRight } from '@mdi/js';
interface Props {
node: TreeNode;
active: string;
icons: { default: string; active: string };
getLink: (path: string) => string;
}
let { node, active, icons, getLink }: Props = $props();
const isTarget = $derived(active === node.path);
const isActive = $derived(active === node.path || active.startsWith(node.value === '/' ? '/' : `${node.path}/`));
let isOpen = $derived(isActive);
const onclick = (event: MouseEvent) => {
event.preventDefault();
isOpen = !isOpen;
};
</script>
<a
href={getLink(node.path)}
title={node.value}
class={`flex grow place-items-center ps-2 py-1 text-sm rounded-lg hover:bg-slate-200 dark:hover:bg-slate-800 hover:font-semibold ${isTarget ? 'bg-slate-100 dark:bg-slate-700 font-semibold text-primary' : 'dark:text-gray-200'}`}
data-sveltekit-keepfocus
>
{#if node.size > 0}
<button type="button" {onclick}>
<Icon icon={isOpen ? mdiChevronDown : mdiChevronRight} class="text-gray-400" size="20" />
</button>
{/if}
<div class={node.size === 0 ? 'ml-[1.5em] ' : ''}>
<Icon
icon={isActive ? icons.active : icons.default}
class={isActive ? 'text-primary' : 'text-gray-400'}
color={node.color}
size="20"
/>
</div>
<span class="text-nowrap overflow-hidden text-ellipsis font-mono ps-1 pt-1 whitespace-pre-wrap">{node.value}</span>
</a>
{#if isOpen}
<TreeItems tree={node} {icons} {active} {getLink} />
{/if}
@@ -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.success($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}
@@ -4,7 +4,7 @@
<script lang="ts">
import { getProfileImageUrl } from '$lib/utils';
import { type UserAvatarColor } from '@immich/sdk';
import { type UserAvatarColor } from '@server/sdk';
import { t } from 'svelte-i18n';
interface User {
@@ -1,80 +0,0 @@
import SideBarSection from '$lib/components/sidebar/sidebar.svelte';
import { sidebarStore } from '$lib/stores/sidebar.svelte';
import { render, screen } from '@testing-library/svelte';
import { vi } from 'vitest';
const mocks = vi.hoisted(() => {
return {
mediaQueryManager: {
isFullSidebar: false,
},
};
});
vi.mock('$lib/stores/media-query-manager.svelte', () => ({
mediaQueryManager: mocks.mediaQueryManager,
}));
vi.mock('$lib/stores/sidebar.svelte', () => ({
sidebarStore: {
isOpen: false,
reset: vi.fn(),
},
}));
describe('Sidebar component', () => {
beforeEach(() => {
vi.resetAllMocks();
mocks.mediaQueryManager.isFullSidebar = false;
sidebarStore.isOpen = false;
});
it.each`
isFullSidebar | isSidebarOpen | expectedInert
${false} | ${false} | ${true}
${false} | ${true} | ${false}
${true} | ${false} | ${false}
${true} | ${true} | ${false}
`(
'inert is $expectedInert when isFullSidebar=$isFullSidebar and isSidebarOpen=$isSidebarOpen',
({ isFullSidebar, isSidebarOpen, expectedInert }) => {
// setup
mocks.mediaQueryManager.isFullSidebar = isFullSidebar;
sidebarStore.isOpen = isSidebarOpen;
// when
render(SideBarSection);
const parent = screen.getByTestId('sidebar-parent');
// then
expect(parent.inert).toBe(expectedInert);
},
);
it('should set width when sidebar is expanded', () => {
// setup
mocks.mediaQueryManager.isFullSidebar = false;
sidebarStore.isOpen = true;
// when
render(SideBarSection);
const parent = screen.getByTestId('sidebar-parent');
// then
expect(parent.classList).toContain('sidebar:w-64'); // sets the initial width for page load
expect(parent.classList).toContain('w-[min(100vw,16rem)]');
expect(parent.classList).toContain('shadow-2xl');
});
it('should close the sidebar if it is open on initial render', () => {
// setup
mocks.mediaQueryManager.isFullSidebar = false;
sidebarStore.isOpen = true;
// when
render(SideBarSection);
// then
expect(sidebarStore.reset).toHaveBeenCalled();
});
});
@@ -1,51 +0,0 @@
<script lang="ts">
import { clickOutside } from '$lib/actions/click-outside';
import { focusTrap } from '$lib/actions/focus-trap';
import { menuButtonId } from '$lib/components/shared-components/navigation-bar/navigation-bar.svelte';
import { mediaQueryManager } from '$lib/stores/media-query-manager.svelte';
import { sidebarStore } from '$lib/stores/sidebar.svelte';
import { onMount, type Snippet } from 'svelte';
interface Props {
ariaLabel?: string;
children?: Snippet;
}
let { ariaLabel, children }: Props = $props();
const isHidden = $derived(!sidebarStore.isOpen && !mediaQueryManager.isFullSidebar);
const isExpanded = $derived(sidebarStore.isOpen && !mediaQueryManager.isFullSidebar);
onMount(() => {
closeSidebar();
});
const closeSidebar = () => {
if (!isExpanded) {
return;
}
sidebarStore.reset();
if (isHidden) {
document.querySelector<HTMLButtonElement>(`#${menuButtonId}`)?.focus();
}
};
</script>
<nav
id="sidebar"
aria-label={ariaLabel}
tabindex="-1"
class="immich-scrollbar relative z-1 w-0 sidebar:w-64 overflow-y-auto overflow-x-hidden pt-8 transition-all duration-200 bg-light"
class:shadow-2xl={isExpanded}
class:dark:border-e-immich-dark-gray={isExpanded}
class:border-r={isExpanded}
class:w-[min(100vw,16rem)]={sidebarStore.isOpen}
data-testid="sidebar-parent"
inert={isHidden}
use:clickOutside={{ onOutclick: closeSidebar, onEscape: closeSidebar }}
use:focusTrap={{ active: isExpanded }}
>
<div class="pe-6 flex flex-col gap-1 h-max min-h-full">
{@render children?.()}
</div>
</nav>
@@ -1,59 +0,0 @@
<script lang="ts">
import PinCodeInput from '$lib/components/user-settings-page/PinCodeInput.svelte';
import PinCodeResetModal from '$lib/modals/PinCodeResetModal.svelte';
import { handleError } from '$lib/utils/handle-error';
import { changePinCode } from '@immich/sdk';
import { Button, Heading, modalManager, 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.success($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>
<PinCodeInput label={$t('current_pin_code')} bind:value={currentPinCode} tabindexStart={1} pinLength={6} />
<PinCodeInput label={$t('new_pin_code')} bind:value={newPinCode} tabindexStart={7} pinLength={6} />
<PinCodeInput label={$t('confirm_new_pin_code')} bind:value={confirmPinCode} tabindexStart={13} pinLength={6} />
<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,62 +0,0 @@
<script lang="ts">
import PinCodeInput from '$lib/components/user-settings-page/PinCodeInput.svelte';
import { handleError } from '$lib/utils/handle-error';
import { setupPinCode } from '@immich/sdk';
import { Button, Heading, toastManager } from '@immich/ui';
import { t } from 'svelte-i18n';
interface Props {
onCreated?: (pinCode: string) => void;
showLabel?: boolean;
}
let { onCreated, showLabel = true }: Props = $props();
let newPinCode = $state('');
let confirmPinCode = $state('');
let isLoading = $state(false);
let canSubmit = $derived(confirmPinCode.length === 6 && newPinCode === confirmPinCode);
const handleSubmit = async (event: Event) => {
event.preventDefault();
await createPinCode();
};
const createPinCode = async () => {
isLoading = true;
try {
await setupPinCode({ pinCodeSetupDto: { pinCode: newPinCode } });
toastManager.success($t('pin_code_setup_successfully'));
onCreated?.(newPinCode);
resetForm();
} catch (error) {
handleError(error, $t('unable_to_setup_pin_code'));
} finally {
isLoading = false;
}
};
const resetForm = () => {
newPinCode = '';
confirmPinCode = '';
};
</script>
<form autocomplete="off" onsubmit={handleSubmit}>
<div class="flex flex-col gap-6 place-items-center place-content-center">
{#if showLabel}
<Heading>{$t('setup_pin_code')}</Heading>
{/if}
<PinCodeInput label={$t('new_pin_code')} bind:value={newPinCode} tabindexStart={1} pinLength={6} />
<PinCodeInput label={$t('confirm_new_pin_code')} bind:value={confirmPinCode} tabindexStart={7} pinLength={6} />
</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('create')}
</Button>
</div>
</form>
@@ -1,139 +0,0 @@
<script lang="ts">
import { Label } from '@immich/ui';
import { onMount } from 'svelte';
interface Props {
label: string;
value?: string;
pinLength?: number;
tabindexStart?: number;
autofocus?: boolean;
onFilled?: (value: string) => void;
type?: 'text' | 'password';
}
let {
label,
value = $bindable(''),
pinLength = 6,
tabindexStart = 0,
autofocus = false,
onFilled,
type = 'text',
}: Props = $props();
let pinValues = $state(Array.from({ length: pinLength }).fill(''));
let pinCodeInputElements: HTMLInputElement[] = $state([]);
$effect(() => {
if (value === '') {
pinValues = Array.from({ length: pinLength }).fill('');
}
});
onMount(() => {
if (autofocus) {
pinCodeInputElements[0]?.focus();
}
});
const focusNext = (index: number) => {
pinCodeInputElements[Math.min(index + 1, pinLength - 1)]?.focus();
};
const focusPrev = (index: number) => {
if (index > 0) {
pinCodeInputElements[index - 1]?.focus();
}
};
const handleInput = (event: Event, index: number) => {
const target = event.target as HTMLInputElement;
let currentPinValue = target.value;
if (target.value.length > 1) {
currentPinValue = value.slice(0, 1);
}
if (Number.isNaN(Number(value))) {
pinValues[index] = '';
target.value = '';
return;
}
pinValues[index] = currentPinValue;
value = pinValues.join('').trim();
if (value && index < pinLength - 1) {
focusNext(index);
}
if (value.length === pinLength) {
onFilled?.(value);
}
};
function handleKeydown(event: KeyboardEvent & { currentTarget: EventTarget & HTMLInputElement }) {
const target = event.currentTarget as HTMLInputElement;
const index = pinCodeInputElements.indexOf(target);
switch (event.key) {
case 'Tab': {
return;
}
case 'Backspace': {
if (target.value === '' && index > 0) {
focusPrev(index);
pinValues[index - 1] = '';
} else if (target.value !== '') {
pinValues[index] = '';
}
value = pinValues.join('').trim();
return;
}
case 'ArrowLeft': {
if (index > 0) {
focusPrev(index);
}
return;
}
case 'ArrowRight': {
if (index < pinLength - 1) {
focusNext(index);
}
return;
}
default: {
if (Number.isNaN(Number(event.key))) {
event.preventDefault();
}
break;
}
}
}
</script>
<div class="flex flex-col gap-1">
{#if label}
<Label for={pinCodeInputElements[0]?.id}>{label}</Label>
{/if}
<div class="flex gap-2">
{#each { length: pinLength } as _, index (index)}
<input
tabindex={tabindexStart + index}
{type}
inputmode="numeric"
pattern="[0-9]*"
maxlength="1"
bind:this={pinCodeInputElements[index]}
id="pin-code-{index}"
class="h-12 w-10 rounded-xl border-2 border-suble dark:border-gray-700 text-center text-lg font-medium focus:border-immich-primary focus:ring-primary dark:focus:border-primary font-mono bg-white dark:bg-light"
bind:value={pinValues[index]}
onkeydown={handleKeydown}
oninput={(event) => handleInput(event, index)}
aria-label={`PIN digit ${index + 1} of ${pinLength}${label ? ` for ${label}` : ''}`}
/>
{/each}
</div>
</div>
@@ -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>
@@ -4,16 +4,8 @@
import SettingsLanguageSelector from '$lib/components/shared-components/settings/settings-language-selector.svelte';
import { fallbackLocale, locales } from '$lib/constants';
import { themeManager } from '$lib/managers/theme-manager.svelte';
import {
alwaysLoadOriginalFile,
alwaysLoadOriginalVideo,
autoPlayVideo,
locale,
loopVideo,
playVideoThumbnailOnHover,
showDeleteModal,
} from '$lib/stores/preferences.store';
import { createDateFormatter, findLocale } from '$lib/utils';
import { locale, showDeleteModal } from '$lib/stores/preferences.store';
import { findLocale } from '$lib/utils';
import { Field, Switch, Text } from '@immich/ui';
import { onMount } from 'svelte';
import { t } from 'svelte-i18n';
@@ -49,8 +41,20 @@
$locale = newLocale;
}
};
const formatDateTime = (loc: string, date: Date) => {
try {
return new Intl.DateTimeFormat(loc, {
dateStyle: 'medium',
timeStyle: 'medium',
}).format(date);
} catch {
return date.toLocaleString();
}
};
let editedLocale = $derived(findLocale($locale).code);
let selectedDate: string = $derived(createDateFormatter(editedLocale).formatDateTime(time));
let selectedDate: string = $derived(formatDateTime(editedLocale, time));
let selectedOption = $derived({
value: findLocale(editedLocale).code || fallbackLocale.code,
label: findLocale(editedLocale).name || fallbackLocale.name,
@@ -82,29 +86,6 @@
/>
{/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>
@@ -1,6 +1,5 @@
<script lang="ts">
import { locale } from '$lib/stores/preferences.store';
import type { SessionResponseDto } from '@immich/sdk';
import { Icon, IconButton } from '@immich/ui';
import {
mdiAndroid,
@@ -14,6 +13,7 @@
mdiTrashCanOutline,
mdiUbuntu,
} from '@mdi/js';
import type { SessionResponseDto } from '@server/sdk';
import { DateTime, type ToRelativeCalendarOptions } from 'luxon';
import { t } from 'svelte-i18n';
@@ -1,7 +1,7 @@
<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 { deleteAllSessions, deleteSession, getSessions, type SessionResponseDto } from '@server/sdk';
import { t } from 'svelte-i18n';
import { fade } from 'svelte/transition';
import DeviceCard from './device-card.svelte';
@@ -1,60 +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 { preferences } from '$lib/stores/user.store';
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($preferences?.download?.archiveSize || 4, ByteUnit.GiB));
let includeEmbeddedVideos = $state($preferences?.download?.includeEmbeddedVideos || false);
const handleSave = async () => {
try {
const newPreferences = await updateMyPreferences({
userPreferencesUpdateDto: {
download: {
archiveSize: Math.floor(convertToBytes(archiveSize, ByteUnit.GiB)),
includeEmbeddedVideos,
},
},
});
$preferences = newPreferences;
toastManager.success($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,175 +0,0 @@
<script lang="ts">
import SettingAccordion from '$lib/components/shared-components/settings/setting-accordion.svelte';
import { preferences } from '$lib/stores/user.store';
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($preferences?.albums?.defaultAssetOrder ?? AssetOrder.Desc);
// Folders
let foldersEnabled = $state($preferences?.folders?.enabled ?? false);
let foldersSidebar = $state($preferences?.folders?.sidebarWeb ?? false);
// Memories
let memoriesEnabled = $state($preferences?.memories?.enabled ?? true);
let memoriesDuration = $state($preferences?.memories?.duration ?? 5);
// People
let peopleEnabled = $state($preferences?.people?.enabled ?? false);
let peopleSidebar = $state($preferences?.people?.sidebarWeb ?? false);
// Ratings
let ratingsEnabled = $state($preferences?.ratings?.enabled ?? false);
// Shared links
let sharedLinksEnabled = $state($preferences?.sharedLinks?.enabled ?? true);
let sharedLinkSidebar = $state($preferences?.sharedLinks?.sidebarWeb ?? false);
// Tags
let tagsEnabled = $state($preferences?.tags?.enabled ?? false);
let tagsSidebar = $state($preferences?.tags?.sidebarWeb ?? false);
// Cast
let gCastEnabled = $state($preferences?.cast?.gCastEnabled ?? false);
const handleSave = async () => {
try {
const data = 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 },
},
});
$preferences = { ...data };
toastManager.success($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,64 +0,0 @@
<script lang="ts">
import { preferences } from '$lib/stores/user.store';
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($preferences?.emailNotifications?.enabled ?? true);
let albumInviteNotificationEnabled = $state($preferences?.emailNotifications?.albumInvite ?? true);
let albumUpdateNotificationEnabled = $state($preferences?.emailNotifications?.albumUpdate ?? true);
const handleSave = async () => {
try {
const data = await updateMyPreferences({
userPreferencesUpdateDto: {
emailNotifications: {
enabled: emailNotificationsEnabled,
albumInvite: emailNotificationsEnabled && albumInviteNotificationEnabled,
albumUpdate: emailNotificationsEnabled && albumUpdateNotificationEnabled,
},
},
});
$preferences.emailNotifications.enabled = data.emailNotifications.enabled;
$preferences.emailNotifications.albumInvite = data.emailNotifications.albumInvite;
$preferences.emailNotifications.albumUpdate = data.emailNotifications.albumUpdate;
toastManager.success($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,64 +0,0 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { featureFlagsManager } from '$lib/managers/feature-flags-manager.svelte';
import { oauth } from '$lib/utils';
import { handleError } from '$lib/utils/handle-error';
import { type UserAdminResponseDto } from '@immich/sdk';
import { Button, LoadingSpinner, toastManager } from '@immich/ui';
import { onMount } from 'svelte';
import { t } from 'svelte-i18n';
import { fade } from 'svelte/transition';
interface Props {
user: UserAdminResponseDto;
}
let { user = $bindable() }: Props = $props();
let loading = $state(true);
onMount(async () => {
if (oauth.isCallback(globalThis.location)) {
try {
loading = true;
user = await oauth.link(globalThis.location);
toastManager.success($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 {
user = await oauth.unlink();
toastManager.success($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 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,200 +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;
}
interface Props {
user: UserResponseDto;
}
let { user }: Props = $props();
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, { user });
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,5 +1,5 @@
<script lang="ts">
import { Permission } from '@immich/sdk';
import { Permission } from '@server/sdk';
import { Checkbox, Label } from '@immich/ui';
interface Props {
@@ -4,8 +4,8 @@
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 { getApiKeys, type ApiKeyResponseDto } from '@server/sdk';
import { t } from 'svelte-i18n';
import { fade } from 'svelte/transition';
@@ -1,14 +1,13 @@
<script lang="ts">
import { user } from '$lib/stores/user.store';
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 { updateMyUser } from '@server/sdk';
import { t } from 'svelte-i18n';
import { createBubbler, preventDefault } from 'svelte/legacy';
import { fade } from 'svelte/transition';
let editedUser = $state(cloneDeep($user));
let editedUser = $state(structuredClone($user));
const bubble = createBubbler();
const handleSaveProfile = async () => {
@@ -46,10 +45,6 @@
<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>
@@ -1,185 +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 { locale } from '$lib/stores/preferences.store';
import { purchaseStore } from '$lib/stores/purchase.store';
import { preferences, user } from '$lib/stores/user.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';
const { isPurchased } = purchaseStore;
let isServerProduct = $state(false);
let serverPurchaseInfo: LicenseResponseDto | null = $state(null);
const checkPurchaseInfo = async () => {
const serverInfo = await getAboutInfo();
isServerProduct = serverInfo.licensed;
const userInfo = await getMyUser();
if (userInfo.license) {
$user = { ...$user, license: userInfo.license };
}
if (isServerProduct && $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 (!$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();
purchaseStore.setPurchaseStatus(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();
purchaseStore.setPurchaseStatus(false);
} catch (error) {
handleError(error, $t('errors.failed_to_remove_product_key'));
}
};
const onProductActivated = async () => {
purchaseStore.setPurchaseStatus(true);
await checkPurchaseInfo();
};
</script>
<section class="my-4">
<div class="sm:ms-8" in:fade={{ duration: 500 }}>
{#if $isPurchased}
<!-- BADGE TOGGLE -->
<div class="mb-4">
<SettingSwitch
title={$t('show_supporter_badge')}
subtitle={$t('show_supporter_badge_description')}
bind:checked={$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 $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 $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 $user.license?.activatedAt}
<p class="dark:text-white text-sm mt-1 col-start-2">
{$t('purchase_activated_time', {
values: {
date: new Date($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,39 +1,13 @@
<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 { user } from '$lib/stores/user.store';
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 { QueryParameter } from '$lib/constants';
import { mdiAccountOutline, mdiApi, mdiCogOutline, mdiDevices, mdiFormTextboxPassword } from '@mdi/js';
import { type ApiKeyResponseDto, type SessionResponseDto } from '@server/sdk';
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';
@@ -43,10 +17,6 @@
}
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}>
@@ -63,15 +33,6 @@
<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>
@@ -85,45 +46,6 @@
<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 user={$user} />
</SettingAccordion>
{/if}
<SettingAccordion
icon={mdiFormTextboxPassword}
key="password"
@@ -132,33 +54,4 @@
>
<ChangePasswordSettings />
</SettingAccordion>
<SettingAccordion
icon={mdiAccountGroupOutline}
key="partner-sharing"
title={$t('partner_sharing')}
subtitle={$t('manage_sharing_with_partners')}
>
<PartnerSettings user={$user} />
</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>

Some files were not shown because too many files have changed in this diff Show More