mirror of
https://github.com/immich-app/immich.git
synced 2026-05-18 03:10:24 +03:00
server is striped
This commit is contained in:
+1
-1
@@ -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
@@ -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,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?';
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
};
|
||||
@@ -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();
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
-181
@@ -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>
|
||||
-111
@@ -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}
|
||||
-43
@@ -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>
|
||||
-33
@@ -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>
|
||||
-43
@@ -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
Reference in New Issue
Block a user