Source Code added

This commit is contained in:
Fr4nz D13trich 2026-02-02 15:06:40 +01:00
parent 800376eafd
commit 9efa9bc6dd
3912 changed files with 754770 additions and 2 deletions

View file

@ -0,0 +1,17 @@
import { tick } from 'svelte';
import { vi } from 'vitest';
export const getAnimateMock = () =>
vi.fn().mockImplementation(() => {
let onfinish: (() => void) | null = null;
void tick().then(() => onfinish?.());
return {
set onfinish(fn: () => void) {
onfinish = fn;
},
cancel() {
onfinish = null;
},
};
});

View file

@ -0,0 +1,9 @@
import { vi } from 'vitest';
export const getIntersectionObserverMock = () =>
vi.fn(() => ({
disconnect: vi.fn(),
observe: vi.fn(),
takeRecords: vi.fn(),
unobserve: vi.fn(),
}));

View file

@ -0,0 +1,8 @@
const createObjectURLMock = vi.fn();
Object.defineProperty(URL, 'createObjectURL', {
writable: true,
value: createObjectURLMock,
});
export { createObjectURLMock };

View file

@ -0,0 +1,18 @@
import * as sdk from '@immich/sdk';
import type { Mock, MockedObject } from 'vitest';
vi.mock('@immich/sdk', async (originalImport) => {
const module = await originalImport<typeof import('@immich/sdk')>();
const mocks: Record<string, Mock> = {};
for (const [key, value] of Object.entries(module)) {
if (typeof value === 'function') {
mocks[key] = vi.fn();
}
}
const mock = { ...module, ...mocks };
return { ...mock, default: mock };
});
export const sdkMock = sdk as MockedObject<typeof sdk>;

View file

@ -0,0 +1,9 @@
export const getVisualViewportMock = () => ({
height: window.innerHeight,
width: window.innerWidth,
scale: 1,
offsetLeft: 0,
offsetTop: 0,
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
});

View file

@ -0,0 +1,23 @@
<script lang="ts">
import { focusTrap } from '$lib/actions/focus-trap';
interface Props {
show: boolean;
active?: boolean;
}
let { show = $bindable(), active = $bindable() }: Props = $props();
</script>
<button type="button" onclick={() => (show = true)}>Open</button>
{#if show}
<div use:focusTrap={{ active }}>
<div>
<span>text</span>
<button data-testid="one" type="button" onclick={() => (show = false)}>Close</button>
</div>
<input data-testid="two" disabled />
<input data-testid="three" />
</div>
{/if}

View file

@ -0,0 +1,51 @@
import FocusTrapTest from '$lib/actions/__test__/focus-trap-test.svelte';
import { setDefaultTabbleOptions } from '$lib/utils/focus-util';
import { render, screen } from '@testing-library/svelte';
import userEvent from '@testing-library/user-event';
import { tick } from 'svelte';
setDefaultTabbleOptions({ displayCheck: 'none' });
describe('focusTrap action', () => {
const user = userEvent.setup();
it('sets focus to the first focusable element', async () => {
render(FocusTrapTest, { show: true });
await tick();
expect(document.activeElement).toEqual(screen.getByTestId('one'));
});
it('should not set focus if inactive', async () => {
render(FocusTrapTest, { show: true, active: false });
await tick();
expect(document.activeElement).toBe(document.body);
});
it('supports backward focus wrapping', async () => {
render(FocusTrapTest, { show: true });
await tick();
await user.keyboard('{Shift}{Tab}{/Shift}');
expect(document.activeElement).toEqual(screen.getByTestId('three'));
});
it('supports forward focus wrapping', async () => {
render(FocusTrapTest, { show: true });
await tick();
screen.getByTestId('three').focus();
await user.keyboard('{Tab}');
expect(document.activeElement).toEqual(screen.getByTestId('one'));
});
it('restores focus to the triggering element', async () => {
render(FocusTrapTest, { show: false });
const openButton = screen.getByText('Open');
await user.click(openButton);
await tick();
expect(document.activeElement).toEqual(screen.getByTestId('one'));
screen.getByText('Close').click();
await tick();
expect(document.activeElement).toEqual(openButton);
});
});

View file

@ -0,0 +1,47 @@
import { matchesShortcut } from '$lib/actions/shortcut';
import type { ActionReturn } from 'svelte/action';
interface Options {
onOutclick?: () => void;
onEscape?: () => void;
}
/**
* Calls a function when a click occurs outside of the element, or when the escape key is pressed.
* @param node
* @param options Object containing onOutclick and onEscape functions
* @returns
*/
export function clickOutside(node: HTMLElement, options: Options = {}): ActionReturn {
const { onOutclick, onEscape } = options;
const handleClick = (event: MouseEvent) => {
const targetNode = event.target as Node | null;
if (node.contains(targetNode)) {
return;
}
onOutclick?.();
};
const handleKey = (event: KeyboardEvent) => {
if (!matchesShortcut(event, { key: 'Escape' })) {
return;
}
if (onEscape) {
event.stopPropagation();
onEscape();
}
};
document.addEventListener('mousedown', handleClick, false);
node.addEventListener('keydown', handleKey, false);
return {
destroy() {
document.removeEventListener('mousedown', handleClick, false);
node.removeEventListener('keydown', handleKey, false);
},
};
}

View file

@ -0,0 +1,112 @@
import { shortcuts } from '$lib/actions/shortcut';
import { tick } from 'svelte';
import type { Action } from 'svelte/action';
interface Options {
/**
* A function that is called when the dropdown should be closed.
*/
closeDropdown: () => void;
/**
* The container element that with direct children that should be navigated.
*/
container?: HTMLElement;
/**
* Indicates if the dropdown is open.
*/
isOpen: boolean;
/**
* Override the default behavior for the escape key.
*/
onEscape?: (event: KeyboardEvent) => void;
/**
* A function that is called when the dropdown should be opened.
*/
openDropdown?: (event: KeyboardEvent) => void;
/**
* The id of the currently selected element.
*/
selectedId: string | undefined;
/**
* A function that is called when the selection changes, to notify consumers of the new selected id.
*/
selectionChanged: (id: string | undefined) => void;
}
export const contextMenuNavigation: Action<HTMLElement, Options> = (node, options: Options) => {
const getCurrentElement = () => {
const { container, selectedId: activeId } = options;
return container?.querySelector(`#${activeId}`) as HTMLElement | null;
};
const close = () => {
const { closeDropdown, selectionChanged } = options;
selectionChanged(undefined);
closeDropdown();
};
const moveSelection = async (direction: 'up' | 'down', event: KeyboardEvent) => {
const { selectionChanged, container, openDropdown } = options;
if (openDropdown) {
openDropdown(event);
await tick();
}
if (!container) {
return;
}
const children = Array.from(container.children).filter((child) => child.tagName !== 'HR') as HTMLElement[];
if (children.length === 0) {
return;
}
const currentEl = getCurrentElement();
const currentIndex = currentEl ? children.indexOf(currentEl) : -1;
const directionFactor = (direction === 'up' ? -1 : 1) + (direction === 'up' && currentIndex === -1 ? 1 : 0);
const newIndex = (currentIndex + directionFactor + children.length) % children.length;
const selectedNode = children[newIndex];
selectedNode?.scrollIntoView({ block: 'nearest' });
selectionChanged(selectedNode?.id);
};
const onEscape = (event: KeyboardEvent) => {
const { onEscape } = options;
if (onEscape) {
onEscape(event);
return;
}
event.stopPropagation();
close();
};
const handleClick = (event: KeyboardEvent) => {
const { selectedId, isOpen, closeDropdown } = options;
if (isOpen && !selectedId) {
closeDropdown();
return;
}
if (!selectedId) {
void moveSelection('down', event);
return;
}
const currentEl = getCurrentElement();
currentEl?.click();
};
const { destroy } = shortcuts(node, [
{ shortcut: { key: 'ArrowUp' }, onShortcut: (event) => moveSelection('up', event) },
{ shortcut: { key: 'ArrowDown' }, onShortcut: (event) => moveSelection('down', event) },
{ shortcut: { key: 'Escape' }, onShortcut: (event) => onEscape(event) },
{ shortcut: { key: ' ' }, onShortcut: (event) => handleClick(event) },
{ shortcut: { key: 'Enter' }, onShortcut: (event) => handleClick(event) },
]);
return {
update(newOptions) {
options = newOptions;
},
destroy,
};
};

View file

@ -0,0 +1,118 @@
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);
},
};
}

View file

@ -0,0 +1,29 @@
interface Options {
onFocusOut?: (event: FocusEvent) => void;
}
/**
* Calls a function when focus leaves the element.
* @param node
* @param options Object containing onFocusOut function
*/
export function focusOutside(node: HTMLElement, options: Options = {}) {
const { onFocusOut } = options;
const handleFocusOut = (event: FocusEvent) => {
if (
onFocusOut &&
(!event.relatedTarget || (event.relatedTarget instanceof Node && !node.contains(event.relatedTarget as Node)))
) {
onFocusOut(event);
}
};
node.addEventListener('focusout', handleFocusOut);
return {
destroy() {
node.removeEventListener('focusout', handleFocusOut);
},
};
}

View file

@ -0,0 +1,134 @@
import { getTabbable } from '$lib/utils/focus-util';
import { tick } from 'svelte';
interface Options {
/**
* Set whether the trap is active or not.
*/
active?: boolean;
}
export function focusTrap(container: HTMLElement, options?: Options) {
const triggerElement = document.activeElement;
// Create sentinel nodes
const startSentinel = document.createElement('div');
startSentinel.setAttribute('tabindex', '0');
startSentinel.dataset.focusTrap = 'start';
const backupSentinel = document.createElement('div');
backupSentinel.setAttribute('tabindex', '-1');
backupSentinel.dataset.focusTrap = 'backup';
const endSentinel = document.createElement('div');
endSentinel.setAttribute('tabindex', '0');
endSentinel.dataset.focusTrap = 'end';
// Insert sentinel nodes into the container
container.insertBefore(startSentinel, container.firstChild);
container.insertBefore(backupSentinel, startSentinel.nextSibling);
container.append(endSentinel);
const withDefaults = (options?: Options) => {
return {
active: options?.active ?? true,
};
};
const setInitialFocus = async () => {
// Use tick() to ensure focus trap works correctly inside <Portal />
await tick();
// Get focusable elements, excluding our sentinel nodes
const allTabbable = getTabbable(container, false);
const focusableElement = allTabbable.find((el) => !Object.hasOwn(el.dataset, 'focusTrap'));
if (focusableElement) {
focusableElement.focus();
} else {
backupSentinel.setAttribute('tabindex', '-1');
// No focusable elements found, use backup sentinel as fallback
backupSentinel.focus();
}
};
if (withDefaults(options).active) {
void setInitialFocus();
}
const getFocusableElements = () => {
// Get all tabbable elements except our sentinel nodes
const allTabbable = getTabbable(container);
const focusableElements = allTabbable.filter((el) => !Object.hasOwn(el.dataset, 'focusTrap'));
return [
focusableElements.at(0), //
focusableElements.at(-1),
];
};
// Add focus event listeners to sentinel nodes
const handleStartFocus = () => {
if (withDefaults(options).active) {
const [, lastElement] = getFocusableElements();
// If no elements, stay on backup sentinel
if (lastElement) {
lastElement.focus();
} else {
backupSentinel.focus();
}
}
};
const handleBackupFocus = () => {
// Backup sentinel keeps focus when there are no other focusable elements
if (withDefaults(options).active) {
const [firstElement] = getFocusableElements();
// Only move focus if there are actual focusable elements
if (firstElement) {
firstElement.focus();
}
// Otherwise, focus stays on backup sentinel
}
};
const handleEndFocus = () => {
if (withDefaults(options).active) {
const [firstElement] = getFocusableElements();
// If no elements, move to backup sentinel
if (firstElement) {
firstElement.focus();
} else {
backupSentinel.focus();
}
}
};
startSentinel.addEventListener('focus', handleStartFocus);
backupSentinel.addEventListener('focus', handleBackupFocus);
endSentinel.addEventListener('focus', handleEndFocus);
return {
update(newOptions?: Options) {
options = newOptions;
if (withDefaults(options).active) {
void setInitialFocus();
}
},
destroy() {
// Remove event listeners
startSentinel.removeEventListener('focus', handleStartFocus);
backupSentinel.removeEventListener('focus', handleBackupFocus);
endSentinel.removeEventListener('focus', handleEndFocus);
// Remove sentinel nodes from DOM
startSentinel.remove();
backupSentinel.remove();
endSentinel.remove();
if (triggerElement instanceof HTMLElement) {
triggerElement.focus();
}
},
};
}

View file

@ -0,0 +1,4 @@
/** Focus the given element when it is mounted. */
export const initInput = (element: HTMLInputElement) => {
element.focus();
};

View file

@ -0,0 +1,156 @@
type Config = IntersectionObserverActionProperties & {
observer?: IntersectionObserver;
};
type TrackedProperties = {
root?: Element | Document | null;
threshold?: number | number[];
top?: string;
right?: string;
bottom?: string;
left?: string;
};
type OnIntersectCallback = (entryOrElement: IntersectionObserverEntry | HTMLElement) => unknown;
type OnSeparateCallback = (element: HTMLElement) => unknown;
type IntersectionObserverActionProperties = {
key?: string;
disabled?: boolean;
/** Function to execute when the element leaves the viewport */
onSeparate?: OnSeparateCallback;
/** Function to execute when the element enters the viewport */
onIntersect?: OnIntersectCallback;
root?: Element | Document | null;
threshold?: number | number[];
top?: string;
right?: string;
bottom?: string;
left?: string;
};
type TaskKey = HTMLElement | string;
function isEquivalent(a: TrackedProperties, b: TrackedProperties) {
return (
a?.bottom === b?.bottom &&
a?.top === b?.top &&
a?.left === b?.left &&
a?.right == b?.right &&
a?.threshold === b?.threshold &&
a?.root === b?.root
);
}
const elementToConfig = new Map<TaskKey, Config>();
const observe = (key: HTMLElement | string, target: HTMLElement, properties: IntersectionObserverActionProperties) => {
if (!target.isConnected) {
elementToConfig.get(key)?.observer?.unobserve(target);
return;
}
const {
root,
threshold,
top = '0px',
right = '0px',
bottom = '0px',
left = '0px',
onSeparate,
onIntersect,
} = properties;
const rootMargin = `${top} ${right} ${bottom} ${left}`;
const observer = new IntersectionObserver(
(entries: IntersectionObserverEntry[]) => {
// This IntersectionObserver is limited to observing a single element, the one the
// action is attached to. If there are multiple entries, it means that this
// observer is being notified of multiple events that have occurred quickly together,
// and the latest element is the one we are interested in.
entries.sort((a, b) => a.time - b.time);
const latestEntry = entries.pop();
if (latestEntry?.isIntersecting) {
onIntersect?.(latestEntry);
} else {
onSeparate?.(target);
}
},
{
rootMargin,
threshold,
root,
},
);
observer.observe(target);
elementToConfig.set(key, { ...properties, observer });
};
function configure(key: HTMLElement | string, element: HTMLElement, properties: IntersectionObserverActionProperties) {
if (properties.disabled) {
const config = elementToConfig.get(key);
const { observer } = config || {};
observer?.unobserve(element);
elementToConfig.delete(key);
} else {
elementToConfig.set(key, properties);
observe(key, element, properties);
}
}
function _intersectionObserver(
key: HTMLElement | string,
element: HTMLElement,
properties: IntersectionObserverActionProperties,
) {
configure(key, element, properties);
return {
update(properties: IntersectionObserverActionProperties) {
const config = elementToConfig.get(key);
if (!config) {
return;
}
if (isEquivalent(config, properties)) {
return;
}
configure(key, element, properties);
},
destroy: () => {
const config = elementToConfig.get(key);
const { observer } = config || {};
observer?.unobserve(element);
elementToConfig.delete(key);
},
};
}
/**
* Monitors an element's visibility in the viewport and calls functions when it enters or leaves (based on a threshold).
* @param element
* @param properties One or multiple configurations for the IntersectionObserver(s)
* @returns
*/
export function intersectionObserver(
element: HTMLElement,
properties: IntersectionObserverActionProperties | IntersectionObserverActionProperties[],
) {
// svelte doesn't allow multiple use:action directives of the same kind on the same element,
// so accept an array when multiple configurations are needed.
if (Array.isArray(properties)) {
if (!properties.every((p) => p.key)) {
throw new Error('Multiple configurations must specify key');
}
const observers = properties.map((p) => _intersectionObserver(p.key as string, element, p));
return {
update: (properties: IntersectionObserverActionProperties[]) => {
for (const [i, props] of properties.entries()) {
observers[i].update(props);
}
},
destroy: () => {
for (const observer of observers) {
observer.destroy();
}
},
};
}
return _intersectionObserver(properties.key || element, element, properties);
}

View file

@ -0,0 +1,44 @@
import { shortcuts } from '$lib/actions/shortcut';
import type { Action } from 'svelte/action';
/**
* Enables keyboard navigation (up and down arrows) for a list of elements.
* @param node Element which listens for keyboard events
* @param container Element containing the list of elements
*/
export const listNavigation: Action<HTMLElement, HTMLElement | undefined> = (
node: HTMLElement,
container?: HTMLElement,
) => {
const moveFocus = (direction: 'up' | 'down') => {
if (!container) {
return;
}
const children = Array.from(container?.children);
if (children.length === 0) {
return;
}
const currentIndex = document.activeElement === null ? -1 : children.indexOf(document.activeElement);
const directionFactor = (direction === 'up' ? -1 : 1) + (direction === 'up' && currentIndex === -1 ? 1 : 0);
const newIndex = (currentIndex + directionFactor + children.length) % children.length;
const element = children.at(newIndex);
if (element instanceof HTMLElement) {
element.focus();
}
};
const { destroy } = shortcuts(node, [
{ shortcut: { key: 'ArrowUp' }, onShortcut: () => moveFocus('up'), ignoreInputFields: false },
{ shortcut: { key: 'ArrowDown' }, onShortcut: () => moveFocus('down'), ignoreInputFields: false },
]);
return {
update(newContainer) {
container = newContainer;
},
destroy,
};
};

View file

@ -0,0 +1,43 @@
export type OnResizeCallback = (resizeEvent: { target: HTMLElement; width: number; height: number }) => void;
let observer: ResizeObserver;
let callbacks: WeakMap<HTMLElement, OnResizeCallback>;
/**
* Installs a resizeObserver on the given element - when the element changes
* size, invokes a callback function with the width/height. Intended as a
* replacement for bind:clientWidth and bind:clientHeight in svelte4 which use
* an iframe to measure the size of the element, which can be bad for
* performance and memory usage. In svelte5, they adapted bind:clientHeight and
* bind:clientWidth to use an internal resize observer.
*
* TODO: When svelte5 is ready, go back to bind:clientWidth and
* bind:clientHeight.
*/
export function resizeObserver(element: HTMLElement, onResize: OnResizeCallback) {
if (!observer) {
callbacks = new WeakMap();
observer = new ResizeObserver((entries) => {
for (const entry of entries) {
const onResize = callbacks.get(entry.target as HTMLElement);
if (onResize) {
onResize({
target: entry.target as HTMLElement,
width: entry.borderBoxSize[0].inlineSize,
height: entry.borderBoxSize[0].blockSize,
});
}
}
});
}
callbacks.set(element, onResize);
observer.observe(element);
return {
destroy: () => {
callbacks.delete(element);
observer.unobserve(element);
},
};
}

View file

@ -0,0 +1,85 @@
import { navigating } from '$app/stores';
import { SessionStorageKey } from '$lib/constants';
import { handlePromiseError } from '$lib/utils';
interface Options {
/**
* This must be kept the same in all subpages of this route for the scroll memory clearer to work.
*/
routeStartsWith: string;
/**
* Function to clear additional data/state before scrolling (ex infinite scroll).
*/
beforeClear?: () => void;
}
interface PageOptions extends Options {
/**
* Function to save additional data/state before scrolling (ex infinite scroll).
*/
beforeSave?: () => void;
/**
* Function to load additional data/state before scrolling (ex infinite scroll).
*/
beforeScroll?: () => Promise<void>;
}
/**
* @param node The scroll slot element, typically from {@link UserPageLayout}
*/
export function scrollMemory(
node: HTMLElement,
{ routeStartsWith, beforeSave, beforeClear, beforeScroll }: PageOptions,
) {
const unsubscribeNavigating = navigating.subscribe((navigation) => {
const existingScroll = sessionStorage.getItem(SessionStorageKey.SCROLL_POSITION);
if (navigation?.to && !existingScroll) {
// Save current scroll information when going into a subpage.
if (navigation.to.url.pathname.startsWith(routeStartsWith)) {
beforeSave?.();
sessionStorage.setItem(SessionStorageKey.SCROLL_POSITION, node.scrollTop.toString());
} else {
beforeClear?.();
sessionStorage.removeItem(SessionStorageKey.SCROLL_POSITION);
}
}
});
handlePromiseError(
(async () => {
await beforeScroll?.();
const newScroll = sessionStorage.getItem(SessionStorageKey.SCROLL_POSITION);
if (newScroll) {
node.scroll({
top: Number.parseFloat(newScroll),
behavior: 'instant',
});
}
beforeClear?.();
sessionStorage.removeItem(SessionStorageKey.SCROLL_POSITION);
})(),
);
return {
destroy() {
unsubscribeNavigating();
},
};
}
export function scrollMemoryClearer(_node: HTMLElement, { routeStartsWith, beforeClear }: Options) {
const unsubscribeNavigating = navigating.subscribe((navigation) => {
// Forget scroll position from main page if going somewhere else.
if (navigation?.to && !navigation?.to.url.pathname.startsWith(routeStartsWith)) {
beforeClear?.();
sessionStorage.removeItem(SessionStorageKey.SCROLL_POSITION);
}
});
return {
destroy() {
unsubscribeNavigating();
},
};
}

View file

@ -0,0 +1,112 @@
import type { ActionReturn } from 'svelte/action';
export type Shortcut = {
key: string;
alt?: boolean;
ctrl?: boolean;
shift?: boolean;
meta?: boolean;
};
export type ShortcutOptions<T = HTMLElement> = {
shortcut: Shortcut;
/** If true, the event handler will not execute if the event comes from an input field */
ignoreInputFields?: boolean;
onShortcut: (event: KeyboardEvent & { currentTarget: T }) => unknown;
preventDefault?: boolean;
};
export const shortcutLabel = (shortcut: Shortcut) => {
let label = '';
if (shortcut.ctrl) {
label += 'Ctrl ';
}
if (shortcut.alt) {
label += 'Alt ';
}
if (shortcut.meta) {
label += 'Cmd ';
}
if (shortcut.shift) {
label += '⇧';
}
label += shortcut.key.toUpperCase();
return label;
};
/** Determines whether an event should be ignored. The event will be ignored if:
* - The element dispatching the event is not the same as the element which the event listener is attached to
* - The element dispatching the event is an input field
* - The element dispatching the event is a map canvas
*/
export const shouldIgnoreEvent = (event: KeyboardEvent | ClipboardEvent): boolean => {
if (event.target === event.currentTarget) {
return false;
}
const type = (event.target as HTMLInputElement).type;
return (
['textarea', 'text', 'date', 'datetime-local', 'email', 'password'].includes(type) ||
(event.target instanceof HTMLCanvasElement && event.target.classList.contains('maplibregl-canvas'))
);
};
export const matchesShortcut = (event: KeyboardEvent, shortcut: Shortcut) => {
return (
shortcut.key.toLowerCase() === event.key.toLowerCase() &&
Boolean(shortcut.alt) === event.altKey &&
Boolean(shortcut.ctrl) === event.ctrlKey &&
Boolean(shortcut.shift) === event.shiftKey &&
Boolean(shortcut.meta) === event.metaKey
);
};
/** Bind a single keyboard shortcut to node. */
export const shortcut = <T extends HTMLElement>(
node: T,
option: ShortcutOptions<T>,
): ActionReturn<ShortcutOptions<T>> => {
const { update: shortcutsUpdate, destroy } = shortcuts(node, [option]);
return {
update(newOption) {
shortcutsUpdate?.([newOption]);
},
destroy,
};
};
/** Binds multiple keyboard shortcuts to node */
export const shortcuts = <T extends HTMLElement>(
node: T,
options: ShortcutOptions<T>[],
): ActionReturn<ShortcutOptions<T>[]> => {
function onKeydown(event: KeyboardEvent) {
const ignoreShortcut = shouldIgnoreEvent(event);
for (const { shortcut, onShortcut, ignoreInputFields = true, preventDefault = true } of options) {
if (ignoreInputFields && ignoreShortcut) {
continue;
}
if (matchesShortcut(event, shortcut)) {
if (preventDefault) {
event.preventDefault();
}
onShortcut(event as KeyboardEvent & { currentTarget: T });
return;
}
}
}
node.addEventListener('keydown', onKeydown);
return {
update(newOptions) {
options = newOptions;
},
destroy() {
node.removeEventListener('keydown', onKeydown);
},
};
};

View file

@ -0,0 +1,29 @@
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);
};

View file

@ -0,0 +1,67 @@
/**
* @license Apache-2.0
* https://github.com/hperrin/svelte-material-ui/blob/master/packages/common/src/internal/useActions.ts
*/
export type SvelteActionReturnType<P> = {
update?: (newParams?: P) => void;
destroy?: () => void;
} | void;
export type SvelteHTMLActionType<P> = (node: HTMLElement, params?: P) => SvelteActionReturnType<P>;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export type HTMLActionEntry<P = any> = SvelteHTMLActionType<P> | [SvelteHTMLActionType<P>, P];
export type HTMLActionArray = HTMLActionEntry[];
export type SvelteSVGActionType<P> = (node: SVGElement, params?: P) => SvelteActionReturnType<P>;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export type SVGActionEntry<P = any> = SvelteSVGActionType<P> | [SvelteSVGActionType<P>, P];
export type SVGActionArray = SVGActionEntry[];
export type ActionArray = HTMLActionArray | SVGActionArray;
export function useActions(node: HTMLElement | SVGElement, actions: ActionArray) {
const actionReturns: SvelteActionReturnType<unknown>[] = [];
if (actions) {
for (const actionEntry of actions) {
const action = Array.isArray(actionEntry) ? actionEntry[0] : actionEntry;
if (Array.isArray(actionEntry) && actionEntry.length > 1) {
actionReturns.push(action(node as HTMLElement & SVGElement, actionEntry[1]));
} else {
actionReturns.push(action(node as HTMLElement & SVGElement));
}
}
}
return {
update(actions: ActionArray) {
if ((actions?.length || 0) != actionReturns.length) {
throw new Error('You must not change the length of an actions array.');
}
if (actions) {
for (const [i, returnEntry] of actionReturns.entries()) {
if (returnEntry && returnEntry.update) {
const actionEntry = actions[i];
if (Array.isArray(actionEntry) && actionEntry.length > 1) {
returnEntry.update(actionEntry[1]);
} else {
returnEntry.update();
}
}
}
}
},
destroy() {
for (const returnEntry of actionReturns) {
returnEntry?.destroy?.();
}
},
};
}

View file

@ -0,0 +1,35 @@
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();
},
};
};

Binary file not shown.

After

Width:  |  Height:  |  Size: 147 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 156 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 157 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 163 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 167 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 173 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 173 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 73 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 212 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 228 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 241 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 242 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 74 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 202 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 311 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 216 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 137 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 224 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 220 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 106 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 112 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 114 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 122 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 296 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 130 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 130 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 73 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 83 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 98 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 5.3 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 6.6 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 9.4 KiB

View file

@ -0,0 +1 @@
<svg width="900" height="600" viewBox="0 0 900 600" fill="none" xmlns="http://www.w3.org/2000/svg"><path fill="transparent" d="M0 0h900v600H0z"/><path d="M214.359 475.389c16.42 16.712 47.124 13.189 47.124 13.189s4.064-30.62-12.372-47.322c-16.419-16.712-47.109-13.198-47.109-13.198s-4.063 30.619 12.357 47.331z" fill="url(#a)"/><path d="M639.439 125.517c-17.194 9.808-41.345-.121-41.345-.121s3.743-25.827 20.946-35.623c17.194-9.808 41.335.11 41.335.11s-3.743 25.827-20.936 35.634z" fill="url(#b)"/><path d="M324.812 156.133c-17.672 17.987-50.72 14.194-50.72 14.194s-4.373-32.955 13.316-50.931c17.673-17.987 50.704-14.206 50.704-14.206s4.373 32.956-13.3 50.943z" fill="url(#c)"/><ellipse rx="15.17" ry="15.928" transform="matrix(1 0 0 -1 228.07 341.957)" fill="#E1E4E5"/><circle r="8.5" transform="matrix(1 0 0 -1 478.5 509.5)" fill="#9d9ea3"/><circle r="17.518" transform="matrix(1 0 0 -1 693.518 420.518)" fill="#9d9ea3"/><circle cx="708.183" cy="266.183" r="14.183" fill="#4F4F51"/><circle cx="247.603" cy="225.621" r="12.136" fill="#F8AE9D"/><ellipse cx="316.324" cy="510.867" rx="7.324" ry="6.867" fill="#E1E4E5"/><ellipse cx="664.796" cy="371.388" rx="9.796" ry="9.388" fill="#E1E4E5"/><circle cx="625.378" cy="479.378" r="11.377" fill="#E1E4E5"/><ellipse cx="401.025" cy="114.39" rx="5.309" ry="6.068" fill="#E1E4E5"/><circle cx="661.834" cy="300.834" r="5.58" transform="rotate(105 661.834 300.834)" fill="#E1E4E5"/><circle cx="654.769" cy="226.082" r="7.585" fill="#E1E4E5"/><ellipse cx="254.159" cy="284.946" rx="5.309" ry="4.551" fill="#E1E4E5"/><circle cx="521.363" cy="106.27" r="11.613" transform="rotate(105 521.363 106.27)" fill="#E1E4E5"/><path d="M162.314 308.103h-.149C161.284 320.589 152 320.781 152 320.781s10.238.2 10.238 14.628c0-14.428 10.238-14.628 10.238-14.628s-9.281-.192-10.162-12.678zm531.83-158.512h-.256c-1.518 21.504-17.507 21.835-17.507 21.835s17.632.345 17.632 25.192c0-24.847 17.632-25.192 17.632-25.192s-15.983-.331-17.501-21.835z" fill="#E1E4E5"/><path fill-rule="evenodd" clip-rule="evenodd" d="M553.714 397.505v56.123c0 20.672-16.743 37.416-37.415 37.416H329.22c-20.672 0-37.415-16.744-37.415-37.416V266.55c0-20.672 16.743-37.416 37.415-37.416h56.124" fill="url(#d)"/><path fill-rule="evenodd" clip-rule="evenodd" d="M363.07 155.431h214.049c26.28 0 47.566 21.286 47.566 47.566v214.049c0 26.28-21.286 47.566-47.566 47.566H363.07c-26.28 0-47.566-21.286-47.566-47.566V202.997c0-26.28 21.286-47.566 47.566-47.566z" fill="#9d9ea3"/><path d="m425.113 307.765 33.925 33.924 74.038-74.059" stroke="#fff" stroke-width="32.125" stroke-linecap="round" stroke-linejoin="round"/><defs><linearGradient id="a" x1="279.871" y1="532.474" x2="161.165" y2="346.391" gradientUnits="userSpaceOnUse"><stop stop-color="#fff"/><stop offset="1" stop-color="#EEE"/></linearGradient><linearGradient id="b" x1="573.046" y1="156.85" x2="712.364" y2="32.889" gradientUnits="userSpaceOnUse"><stop stop-color="#fff"/><stop offset="1" stop-color="#EEE"/></linearGradient><linearGradient id="c" x1="254.302" y1="217.573" x2="382.065" y2="17.293" gradientUnits="userSpaceOnUse"><stop stop-color="#fff"/><stop offset="1" stop-color="#EEE"/></linearGradient><linearGradient id="d" x1="417.175" y1="82.293" x2="425.251" y2="775.957" gradientUnits="userSpaceOnUse"><stop stop-color="#fff"/><stop offset="1" stop-color="#EEE"/></linearGradient></defs></svg>

After

Width:  |  Height:  |  Size: 3.3 KiB

View file

@ -0,0 +1 @@
<svg width="900" height="600" viewBox="0 0 900 600" fill="none" xmlns="http://www.w3.org/2000/svg"><path fill="transparent" d="M0 0h900v600H0z"/><path fill-rule="evenodd" clip-rule="evenodd" d="M249.841 115.734v250.041c0 13.572 10.867 24.563 24.287 24.563h147.186l64.25-91.581c3.063-4.369 10.722-4.369 13.786 0l22.494 32.07.175.25.152-.221 48.243-70.046c3.336-4.85 11.695-4.85 15.031 0l63.892 92.779v12.215-250.07c0-13.572-10.897-24.562-24.288-24.562H274.128c-13.42 0-24.287 10.99-24.287 24.562z" fill="#9d9ea3"/><path d="M362.501 281.935c-34.737 0-62.896-28.16-62.896-62.897 0-34.736 28.159-62.896 62.896-62.896s62.897 28.16 62.897 62.896c0 34.737-28.16 62.897-62.897 62.897z" fill="#fff"/><path d="M449.176 445.963H259.725c-7.79 0-14.188-6.399-14.188-14.188 0-7.882 6.398-14.281 14.188-14.281h189.451c7.882 0 14.28 6.399 14.28 14.281 0 7.789-6.398 14.188-14.28 14.188zm189.543.002H501.662c-7.882 0-14.281-6.399-14.281-14.281 0-7.882 6.399-14.281 14.281-14.281h137.057c7.883 0 14.281 6.399 14.281 14.281 0 7.882-6.398 14.281-14.281 14.281zm-298.503 62.592h-80.491c-7.79 0-14.188-6.398-14.188-14.188 0-7.882 6.398-14.281 14.188-14.281h80.491c7.882 0 14.281 6.399 14.281 14.281 0 7.79-6.399 14.188-14.281 14.188zm298.503.002H388.065c-7.882 0-14.28-6.398-14.28-14.28s6.398-14.281 14.28-14.281h250.654c7.883 0 14.281 6.399 14.281 14.281 0 7.882-6.398 14.28-14.281 14.28z" fill="#E1E4E5"/></svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

View file

@ -0,0 +1 @@
<svg width="900" height="600" viewBox="0 0 900 600" fill="none" xmlns="http://www.w3.org/2000/svg"><path fill="transparent" d="M0 0h900v600H0z"/><path d="M718.697 359.789c2.347 69.208-149.828 213.346-331.607 165.169-84.544-22.409-76.298-62.83-139.698-114.488-37.789-30.789-92.638-53.5-106.885-99.138-12.309-39.393-3.044-82.222 20.77-110.466 53.556-63.52 159.542-108.522 260.374-12.465 100.832 96.056 290.968-7.105 297.046 171.388z" fill="url(#a)"/><path fill-rule="evenodd" clip-rule="evenodd" d="M629.602 207.307v-51.154c0-28.251-22.902-51.153-51.154-51.153H322.681c-28.251 0-51.153 22.902-51.153 51.153v127.884" fill="#fff"/><path d="M629.602 207.307v-51.154c0-28.251-22.902-51.153-51.154-51.153H322.681c-28.251 0-51.153 22.902-51.153 51.153v127.884" stroke="#E1E4E5" stroke-width="4.13"/><path fill-rule="evenodd" clip-rule="evenodd" d="M271.528 216.252h165.353a25.578 25.578 0 0 0 21.28-11.382l35.884-53.941a25.575 25.575 0 0 1 21.357-11.407h114.2c28.251 0 51.154 22.902 51.154 51.153v255.767c0 28.252-22.903 51.154-51.154 51.154H271.528c-28.251 0-51.154-22.902-51.154-51.154V267.405c0-28.251 22.903-51.153 51.154-51.153z" fill="#fff" stroke="#E1E4E5" stroke-width="4.13"/><path fill-rule="evenodd" clip-rule="evenodd" d="M320.022 432.016v3.968h3.964A3.028 3.028 0 0 1 327 439a3.028 3.028 0 0 1-3.014 3.016h-3.964v3.968a3.028 3.028 0 0 1-3.014 3.016 3.029 3.029 0 0 1-3.014-3.016v-3.951h-3.98a3.029 3.029 0 0 1-3.014-3.017 3.029 3.029 0 0 1 3.014-3.016h3.964v-3.984a3.031 3.031 0 0 1 3.03-3.016 3.028 3.028 0 0 1 3.014 3.016zm-33.14-27.793v5.554h5.748c2.399 0 4.37 1.905 4.37 4.223 0 2.318-1.971 4.223-4.37 4.223h-5.748v5.554c0 2.318-1.971 4.223-4.37 4.223s-4.37-1.905-4.37-4.223v-5.531h-5.772c-2.399 0-4.37-1.905-4.37-4.223 0-2.318 1.971-4.223 4.37-4.223h5.748v-5.577c0-2.318 1.971-4.223 4.394-4.223 2.399 0 4.37 1.905 4.37 4.223z" fill="#E1E4E5"/><circle cx="451.101" cy="358.294" r="98.899" fill="#aaa"/><rect x="444.142" y="322.427" width="13.918" height="71.734" rx="6.959" fill="#fff"/><rect x="486.968" y="351.335" width="13.918" height="71.734" rx="6.959" transform="rotate(90 486.968 351.335)" fill="#fff"/><ellipse rx="13.917" ry="13.254" transform="matrix(-1 0 0 1 718.227 479.149)" fill="#E1E4E5"/><circle r="4.639" transform="matrix(-1 0 0 1 292.465 519.783)" fill="#E1E4E5"/><circle r="6.627" transform="matrix(-1 0 0 1 566.399 205.929)" fill="#E1E4E5"/><circle r="6.476" transform="scale(1 -1) rotate(-75 -180.786 -314.12)" fill="#E1E4E5"/><circle r="8.615" transform="matrix(-1 0 0 1 217.158 114.719)" fill="#E1E4E5"/><ellipse rx="6.627" ry="5.302" transform="matrix(-1 0 0 1 704.513 233.511)" fill="#E1E4E5"/><path d="M186.177 456.259h.174c1.026 14.545 11.844 14.769 11.844 14.769s-11.929.233-11.929 17.04c0-16.807-11.929-17.04-11.929-17.04s10.814-.224 11.84-14.769zm574.334-165.951h.18c1.067 15.36 12.309 15.596 12.309 15.596s-12.397.246-12.397 17.994c0-17.748-12.396-17.994-12.396-17.994s11.237-.236 12.304-15.596z" fill="#E1E4E5"/><defs><linearGradient id="a" x1="530.485" y1="779.032" x2="277.414" y2="-357.319" gradientUnits="userSpaceOnUse"><stop stop-color="#fff"/><stop offset="1" stop-color="#EEE"/></linearGradient></defs></svg>

After

Width:  |  Height:  |  Size: 3.1 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

View file

@ -0,0 +1 @@
<svg fill="#2443c2" version="1.1" id="Capa_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="101px" height="101px" viewBox="0 0 425.963 425.963" xml:space="preserve" stroke="#2443c2"><g id="SVGRepo_bgCarrier" stroke-width="0"></g><g id="SVGRepo_tracerCarrier" stroke-linecap="round" stroke-linejoin="round"></g><g id="SVGRepo_iconCarrier"> <g> <path d="M213.285,0h-0.608C139.114,0,79.268,59.826,79.268,133.361c0,48.202,21.952,111.817,65.246,189.081 c32.098,57.281,64.646,101.152,64.972,101.588c0.906,1.217,2.334,1.934,3.847,1.934c0.043,0,0.087,0,0.13-0.002 c1.561-0.043,3.002-0.842,3.868-2.143c0.321-0.486,32.637-49.287,64.517-108.976c43.03-80.563,64.848-141.624,64.848-181.482 C346.693,59.825,286.846,0,213.285,0z M274.865,136.62c0,34.124-27.761,61.884-61.885,61.884 c-34.123,0-61.884-27.761-61.884-61.884s27.761-61.884,61.884-61.884C247.104,74.736,274.865,102.497,274.865,136.62z"></path> </g> </g></svg>

After

Width:  |  Height:  |  Size: 944 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 584 B

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 5.7 KiB

View file

@ -0,0 +1,6 @@
export const moonPath = 'M17.293 13.293A8 8 0 016.707 2.707a8.001 8.001 0 1010.586 10.586z';
export const sunPath =
'M10 2a1 1 0 011 1v1a1 1 0 11-2 0V3a1 1 0 011-1zm4 8a4 4 0 11-8 0 4 4 0 018 0zm-.464 4.95l.707.707a1 1 0 001.414-1.414l-.707-.707a1 1 0 00-1.414 1.414zm2.12-10.607a1 1 0 010 1.414l-.706.707a1 1 0 11-1.414-1.414l.707-.707a1 1 0 011.414 0zM17 11a1 1 0 100-2h-1a1 1 0 100 2h1zm-7 4a1 1 0 011 1v1a1 1 0 11-2 0v-1a1 1 0 011-1zM5.05 6.464A1 1 0 106.465 5.05l-.708-.707a1 1 0 00-1.414 1.414l.707.707zm1.414 8.486l-.707.707a1 1 0 01-1.414-1.414l.707-.707a1 1 0 011.414 1.414zM4 11a1 1 0 100-2H3a1 1 0 000 2h1z';
export const moonViewBox = '0 0 20 20';
export const sunViewBox = '0 0 20 20';

View file

@ -0,0 +1,105 @@
import type { Attachment } from 'svelte/attachments';
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(options: DragAndDropOptions): Attachment {
return (node: Element) => {
const element = node as HTMLElement;
const { index, onDragStart, onDragEnter, onDrop, onDragEnd, isDragging, isDragOver } = options;
const isFormElement = (el: HTMLElement) => {
return el.tagName === 'INPUT' || el.tagName === 'TEXTAREA' || el.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)) {
element.setAttribute('draggable', 'false');
}
};
const handleFocusOut = (e: FocusEvent) => {
const target = e.target as HTMLElement;
if (isFormElement(target)) {
element.setAttribute('draggable', 'true');
}
};
// Update classes based on drag state
const updateClasses = (dragging: boolean, dragOver: boolean) => {
// Remove all drag-related classes first
element.classList.remove('opacity-50', 'border-light-500', 'border-solid');
// Add back only the active ones
if (dragging) {
element.classList.add('opacity-50');
}
if (dragOver) {
element.classList.add('border-light-500', 'border-solid');
element.classList.remove('border-transparent');
} else {
element.classList.add('border-transparent');
}
};
element.setAttribute('draggable', 'true');
element.setAttribute('role', 'button');
element.setAttribute('tabindex', '0');
element.addEventListener('dragstart', handleDragStart);
element.addEventListener('dragenter', handleDragEnter);
element.addEventListener('dragover', handleDragOver);
element.addEventListener('drop', handleDrop);
element.addEventListener('dragend', handleDragEnd);
element.addEventListener('focusin', handleFocusIn);
element.addEventListener('focusout', handleFocusOut);
updateClasses(isDragging || false, isDragOver || false);
return () => {
element.removeEventListener('dragstart', handleDragStart);
element.removeEventListener('dragenter', handleDragEnter);
element.removeEventListener('dragover', handleDragOver);
element.removeEventListener('drop', handleDrop);
element.removeEventListener('dragend', handleDragEnd);
element.removeEventListener('focusin', handleFocusIn);
element.removeEventListener('focusout', handleFocusOut);
};
};
}

View file

@ -0,0 +1,15 @@
<script lang="ts">
import { isEnabled } from '$lib/utils';
import { IconButton, type ActionItem } from '@immich/ui';
type Props = {
action: ActionItem;
};
const { action }: Props = $props();
const { title, icon, color = 'secondary', onAction } = $derived(action);
</script>
{#if icon && isEnabled(action)}
<IconButton variant="ghost" shape="round" {color} {icon} aria-label={title} onclick={() => onAction(action)} />
{/if}

View file

@ -0,0 +1,16 @@
<script lang="ts">
import MenuOption from '$lib/components/shared-components/context-menu/menu-option.svelte';
import { isEnabled } from '$lib/utils';
import { type ActionItem } from '@immich/ui';
type Props = {
action: ActionItem;
};
const { action }: Props = $props();
const { title, icon, onAction } = $derived(action);
</script>
{#if icon && isEnabled(action)}
<MenuOption {icon} text={title} onClick={() => onAction(action)} />
{/if}

View file

@ -0,0 +1,33 @@
<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>

View file

@ -0,0 +1,78 @@
<script lang="ts">
import ApiKeyGrid from '$lib/components/user-settings-page/user-api-key-grid.svelte';
import { Permission } from '@immich/sdk';
import { Checkbox, IconButton, Input, Label } from '@immich/ui';
import { mdiClose } from '@mdi/js';
import { t } from 'svelte-i18n';
type Props = {
selectedPermissions: Permission[];
};
let { selectedPermissions = $bindable([]) }: Props = $props();
const permissions: Record<string, Permission[]> = {};
for (const permission of Object.values(Permission)) {
if (permission === Permission.All) {
continue;
}
const [group] = permission.split('.');
if (!permissions[group]) {
permissions[group] = [];
}
permissions[group].push(permission);
}
let searchValue = $state('');
let allItemsSelected = $derived(selectedPermissions.length === Object.keys(Permission).length - 1);
const matchFilter = (search: string) => {
search = search.toLowerCase();
return ([title, items]: [string, Permission[]]) =>
title.toLowerCase().includes(search) || items.some((item) => item.toLowerCase().includes(search));
};
const onCheckedAllChange = (checked: boolean) => {
selectedPermissions = checked
? Object.values(Permission).filter((permission) => permission !== Permission.All)
: [];
};
const filteredResults = $derived(Object.entries(permissions).filter(matchFilter(searchValue)));
const handleSelectItems = (items: Permission[]) =>
(selectedPermissions = Array.from(new Set([...selectedPermissions, ...items])));
const handleDeselectItems = (items: Permission[]) =>
(selectedPermissions = selectedPermissions.filter((item) => !items.includes(item)));
</script>
<Label label={$t('permission')} for="permission-container" />
<div class="flex items-center gap-2 m-4" id="permission-container">
<Checkbox id="input-select-all" size="tiny" checked={allItemsSelected} onCheckedChange={onCheckedAllChange} />
<Label label={$t('select_all')} for="input-select-all" />
</div>
<div class="ms-4 flex flex-col gap-2">
<Input bind:value={searchValue} placeholder={$t('search')}>
{#snippet trailingIcon()}
{#if searchValue}
<IconButton
icon={mdiClose}
size="small"
variant="ghost"
shape="round"
color="secondary"
class="me-1"
onclick={() => (searchValue = '')}
aria-label={$t('clear')}
/>
{/if}
{/snippet}
</Input>
{#each filteredResults as [title, subItems] (title)}
<ApiKeyGrid {title} {subItems} selectedItems={selectedPermissions} {handleSelectItems} {handleDeselectItems} />
{/each}
</div>

View file

@ -0,0 +1,31 @@
<script lang="ts">
import { assetViewerManager, type Events } from '$lib/managers/asset-viewer-manager.svelte';
import type { EventCallback } 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 unsubscribes: Array<() => void> = [];
for (const name of Object.keys(props)) {
const event = name.slice(2) as keyof Events;
const listener = props[name as keyof Props] as EventCallback<Events, typeof event> | undefined;
if (!listener) {
continue;
}
unsubscribes.push(assetViewerManager.on(event, listener));
}
return () => {
for (const unsubscribe of unsubscribes) {
unsubscribe();
}
};
});
</script>

View file

@ -0,0 +1,61 @@
<script lang="ts">
import type { HeaderButtonActionItem } from '$lib/types';
import {
Breadcrumbs,
Button,
Container,
ContextMenuButton,
HStack,
MenuItemType,
Scrollable,
isMenuItemType,
type BreadcrumbItem,
} from '@immich/ui';
import { mdiSlashForward } from '@mdi/js';
import type { Snippet } from 'svelte';
import { t } from 'svelte-i18n';
type Props = {
breadcrumbs?: BreadcrumbItem[];
actions?: Array<HeaderButtonActionItem | MenuItemType>;
children?: Snippet;
};
let { breadcrumbs = [], actions = [], children }: Props = $props();
const enabledActions = $derived(
actions
.filter((action): action is HeaderButtonActionItem => !isMenuItemType(action))
.filter((action) => action.$if?.() ?? true),
);
</script>
<div class="h-full flex flex-col">
<div class="flex h-16 w-full justify-between items-center border-b py-2 px-4 md:px-2">
<Breadcrumbs items={breadcrumbs} separator={mdiSlashForward} />
{#if enabledActions.length > 0}
<div class="hidden md:block">
<HStack gap={0}>
{#each enabledActions as action, i (i)}
<Button
variant="ghost"
size="small"
color={action.color ?? 'secondary'}
leadingIcon={action.icon}
onclick={() => action.onAction(action)}
title={action.data?.title}
>
{action.title}
</Button>
{/each}
</HStack>
</div>
<ContextMenuButton aria-label={$t('open')} items={actions} class="md:hidden" />
{/if}
</div>
<Scrollable class="grow">
<Container class="p-2 pb-16" {children} />
</Scrollable>
</div>

View file

@ -0,0 +1,24 @@
<script lang="ts">
import type { HeaderButtonActionItem } from '$lib/types';
import { Button } from '@immich/ui';
type Props = {
action: HeaderButtonActionItem;
};
const { action }: Props = $props();
const { title, icon, color = 'secondary', onAction } = $derived(action);
</script>
{#if action.$if?.() ?? true}
<Button
variant="ghost"
size="small"
{color}
leadingIcon={icon}
onclick={() => onAction(action)}
title={action.data?.title}
>
{title}
</Button>
{/if}

View file

@ -0,0 +1,33 @@
<script lang="ts">
import { eventManager, type Events } from '$lib/managers/event-manager.svelte';
import { onMount } from 'svelte';
type Props = {
[K in keyof Events as `on${K}`]?: (...args: Events[K]) => void;
};
const props: Props = $props();
onMount(() => {
const unsubscribes: Array<() => void> = [];
for (const name of Object.keys(props)) {
const event = name.slice(2) as keyof Events;
const listener = props[name as keyof Props];
if (!listener) {
continue;
}
const args = [event, listener as (...args: Events[typeof event]) => void] as const;
unsubscribes.push(eventManager.on(...args));
}
return () => {
for (const unsubscribe of unsubscribes) {
unsubscribe();
}
};
});
</script>

View file

@ -0,0 +1,187 @@
<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>

View file

@ -0,0 +1,23 @@
<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>

View file

@ -0,0 +1,37 @@
<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>

View file

@ -0,0 +1,165 @@
<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>

View file

@ -0,0 +1,132 @@
<script lang="ts">
import QueueCard from '$lib/components/QueueCard.svelte';
import QueueStorageMigrationDescription from '$lib/components/QueueStorageMigrationDescription.svelte';
import { featureFlagsManager } from '$lib/managers/feature-flags-manager.svelte';
import { queueManager } from '$lib/managers/queue-manager.svelte';
import { asQueueItem } from '$lib/services/queue.service';
import { handleError } from '$lib/utils/handle-error';
import {
QueueCommand,
type QueueCommandDto,
QueueName,
type QueueResponseDto,
runQueueCommandLegacy,
} from '@immich/sdk';
import { modalManager, toastManager } from '@immich/ui';
import type { Component } from 'svelte';
import { t } from 'svelte-i18n';
type Props = {
queues: QueueResponseDto[];
};
let { queues }: Props = $props();
const featureFlags = featureFlagsManager.value;
type QueueDetails = {
description?: Component;
allText?: string;
refreshText?: string;
missingText: string;
disabled?: boolean;
handleCommand?: (jobId: QueueName, jobCommand: QueueCommandDto) => Promise<void>;
};
const queueDetails: Partial<Record<QueueName, QueueDetails>> = {
[QueueName.ThumbnailGeneration]: {
allText: $t('all'),
missingText: $t('missing'),
},
[QueueName.MetadataExtraction]: {
allText: $t('all'),
missingText: $t('missing'),
},
[QueueName.Library]: {
missingText: $t('rescan'),
},
[QueueName.Sidecar]: {
allText: $t('sync'),
missingText: $t('discover'),
disabled: !featureFlags.sidecar,
},
[QueueName.SmartSearch]: {
allText: $t('all'),
missingText: $t('missing'),
disabled: !featureFlags.smartSearch,
},
[QueueName.DuplicateDetection]: {
allText: $t('all'),
missingText: $t('missing'),
disabled: !featureFlags.duplicateDetection,
},
[QueueName.FaceDetection]: {
allText: $t('reset'),
refreshText: $t('refresh'),
missingText: $t('missing'),
disabled: !featureFlags.facialRecognition,
},
[QueueName.FacialRecognition]: {
allText: $t('reset'),
missingText: $t('missing'),
disabled: !featureFlags.facialRecognition,
},
[QueueName.Ocr]: {
allText: $t('all'),
missingText: $t('missing'),
disabled: !featureFlags.ocr,
},
[QueueName.VideoConversion]: {
allText: $t('all'),
missingText: $t('missing'),
},
[QueueName.StorageTemplateMigration]: {
missingText: $t('start'),
description: QueueStorageMigrationDescription,
},
[QueueName.Migration]: {
missingText: $t('start'),
},
};
let queueList = Object.entries(queueDetails) as [QueueName, QueueDetails][];
const handleCommand = async (name: QueueName, dto: QueueCommandDto) => {
const item = asQueueItem($t, { name });
switch (name) {
case QueueName.FaceDetection:
case QueueName.FacialRecognition: {
if (dto.force) {
const confirmed = await modalManager.showDialog({ prompt: $t('admin.confirm_reprocess_all_faces') });
if (!confirmed) {
return;
}
break;
}
}
}
try {
await runQueueCommandLegacy({ name, queueCommandDto: dto });
await queueManager.refresh();
switch (dto.command) {
case QueueCommand.Empty: {
toastManager.success($t('admin.cleared_jobs', { values: { job: item.title } }));
break;
}
}
} catch (error) {
handleError(error, $t('admin.failed_job_command', { values: { command: dto.command, job: item.title } }));
}
};
</script>
<div class="flex flex-col gap-7 mt-10">
{#each queueList as [queueName, props] (queueName)}
{@const queue = queues.find(({ name }) => name === queueName)}
{#if queue}
<QueueCard {queue} onCommand={(command) => handleCommand(queueName, command)} {...props} />
{/if}
{/each}
</div>

View file

@ -0,0 +1,17 @@
<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>

View file

@ -0,0 +1,24 @@
<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>

View file

@ -0,0 +1,75 @@
<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>

View file

@ -0,0 +1,15 @@
<script lang="ts">
import { IconButton, type ActionItem, type Size } from '@immich/ui';
type Props = {
action: ActionItem;
size?: Size;
};
const { action, size }: Props = $props();
const { title, icon, onAction } = $derived(action);
</script>
{#if icon && (action.$if?.() ?? true)}
<IconButton {size} shape="round" color="primary" {icon} aria-label={title} onclick={() => onAction(action)} />
{/if}

View file

@ -0,0 +1,15 @@
<script lang="ts" generics="T extends Record<string, unknown>">
import { TooltipProvider } from '@immich/ui';
import type { Component } from 'svelte';
type Props = {
component: Component<T>;
componentProps: T;
};
const { component: Test, componentProps }: Props = $props();
</script>
<TooltipProvider>
<Test {...componentProps} />
</TooltipProvider>

View file

@ -0,0 +1,33 @@
<script lang="ts">
import { Button, ToastContainer, ToastContent, type Color, type IconLike } from '@immich/ui';
type Props = {
onClose?: () => void;
color?: Color;
title: string;
icon?: IconLike | false;
description: string;
button?: {
text: string;
color?: Color;
onClick: () => void;
};
};
const { onClose, title, description, color, icon, button }: Props = $props();
const onClick = () => {
button?.onClick();
onClose?.();
};
</script>
<ToastContainer {color}>
<ToastContent {color} {title} {description} {onClose} {icon}>
{#if button}
<div class="flex justify-end gap-2 px-2 pb-2 me-3 mt-2">
<Button color={button.color ?? 'secondary'} size="small" onclick={onClick}>{button.text}</Button>
</div>
{/if}
</ToastContent>
</ToastContainer>

View file

@ -0,0 +1,291 @@
<script lang="ts">
import SettingAccordion from '$lib/components/shared-components/settings/setting-accordion.svelte';
import SettingInputField from '$lib/components/shared-components/settings/setting-input-field.svelte';
import SettingSelect from '$lib/components/shared-components/settings/setting-select.svelte';
import SettingSwitch from '$lib/components/shared-components/settings/setting-switch.svelte';
import SettingButtonsRow from '$lib/components/shared-components/settings/SystemConfigButtonRow.svelte';
import { SettingInputFieldType } from '$lib/constants';
import FormatMessage from '$lib/elements/FormatMessage.svelte';
import { featureFlagsManager } from '$lib/managers/feature-flags-manager.svelte';
import { systemConfigManager } from '$lib/managers/system-config-manager.svelte';
import AuthDisableLoginConfirmModal from '$lib/modals/AuthDisableLoginConfirmModal.svelte';
import { handleError } from '$lib/utils/handle-error';
import { OAuthTokenEndpointAuthMethod, unlinkAllOAuthAccountsAdmin } from '@immich/sdk';
import { Button, modalManager, Text, toastManager } from '@immich/ui';
import { mdiRestart } from '@mdi/js';
import { t } from 'svelte-i18n';
import { fade } from 'svelte/transition';
const disabled = $derived(featureFlagsManager.value.configFile);
const config = $derived(systemConfigManager.value);
let configToEdit = $state(systemConfigManager.cloneValue());
const handleToggleOverride = () => {
// click runs before bind
const previouslyEnabled = configToEdit.oauth.mobileOverrideEnabled;
if (!previouslyEnabled && !configToEdit.oauth.mobileRedirectUri) {
configToEdit.oauth.mobileRedirectUri = globalThis.location.origin + '/api/oauth/mobile-redirect';
}
};
const onBeforeSave = async () => {
const allMethodsDisabled = !configToEdit.oauth.enabled && !configToEdit.passwordLogin.enabled;
if (allMethodsDisabled) {
const confirmed = await modalManager.show(AuthDisableLoginConfirmModal);
if (!confirmed) {
return false;
}
}
return true;
};
const handleUnlinkAllOAuthAccounts = async () => {
const confirmed = await modalManager.showDialog({
icon: mdiRestart,
title: $t('admin.unlink_all_oauth_accounts'),
prompt: $t('admin.unlink_all_oauth_accounts_prompt'),
confirmColor: 'danger',
});
if (!confirmed) {
return;
}
try {
await unlinkAllOAuthAccountsAdmin();
toastManager.success();
} catch (error) {
handleError(error, $t('errors.something_went_wrong'));
}
};
</script>
<div>
<div in:fade={{ duration: 500 }}>
<form autocomplete="off" onsubmit={(e) => e.preventDefault()}>
<div class="ms-4 mt-4 flex flex-col">
<SettingAccordion
key="oauth"
title={$t('admin.oauth_settings')}
subtitle={$t('admin.oauth_settings_description')}
>
<div class="ms-4 mt-4 flex flex-col gap-4">
<Text size="small">
<FormatMessage key="admin.oauth_settings_more_details">
{#snippet children({ message })}
<a
href="https://docs.immich.app/administration/oauth"
class="underline"
target="_blank"
rel="noreferrer"
>
{message}
</a>
{/snippet}
</FormatMessage>
</Text>
<SettingSwitch
{disabled}
title={$t('admin.oauth_enable_description')}
bind:checked={configToEdit.oauth.enabled}
/>
{#if configToEdit.oauth.enabled}
<hr />
<div class="flex items-center gap-2 justify-between">
<Text size="small">{$t('admin.unlink_all_oauth_accounts_description')}</Text>
<Button size="small" onclick={handleUnlinkAllOAuthAccounts}
>{$t('admin.unlink_all_oauth_accounts')}</Button
>
</div>
<SettingInputField
inputType={SettingInputFieldType.TEXT}
label="ISSUER_URL"
bind:value={configToEdit.oauth.issuerUrl}
required={true}
disabled={disabled || !configToEdit.oauth.enabled}
isEdited={!(configToEdit.oauth.issuerUrl === config.oauth.issuerUrl)}
/>
<SettingInputField
inputType={SettingInputFieldType.TEXT}
label="CLIENT_ID"
bind:value={configToEdit.oauth.clientId}
required={true}
disabled={disabled || !configToEdit.oauth.enabled}
isEdited={!(configToEdit.oauth.clientId === config.oauth.clientId)}
/>
<SettingInputField
inputType={SettingInputFieldType.TEXT}
label="CLIENT_SECRET"
description={$t('admin.oauth_client_secret_description')}
bind:value={configToEdit.oauth.clientSecret}
disabled={disabled || !configToEdit.oauth.enabled}
isEdited={!(configToEdit.oauth.clientSecret === config.oauth.clientSecret)}
/>
{#if configToEdit.oauth.clientSecret}
<SettingSelect
label="TOKEN_ENDPOINT_AUTH_METHOD"
bind:value={configToEdit.oauth.tokenEndpointAuthMethod}
disabled={disabled || !configToEdit.oauth.enabled || !configToEdit.oauth.clientSecret}
isEdited={!(configToEdit.oauth.tokenEndpointAuthMethod === config.oauth.tokenEndpointAuthMethod)}
options={[
{ value: OAuthTokenEndpointAuthMethod.ClientSecretPost, text: 'client_secret_post' },
{ value: OAuthTokenEndpointAuthMethod.ClientSecretBasic, text: 'client_secret_basic' },
]}
name="tokenEndpointAuthMethod"
/>
{/if}
<SettingInputField
inputType={SettingInputFieldType.TEXT}
label="SCOPE"
bind:value={configToEdit.oauth.scope}
required={true}
disabled={disabled || !configToEdit.oauth.enabled}
isEdited={!(configToEdit.oauth.scope === config.oauth.scope)}
/>
<SettingInputField
inputType={SettingInputFieldType.TEXT}
label="ID_TOKEN_SIGNED_RESPONSE_ALG"
bind:value={configToEdit.oauth.signingAlgorithm}
required={true}
disabled={disabled || !configToEdit.oauth.enabled}
isEdited={!(configToEdit.oauth.signingAlgorithm === config.oauth.signingAlgorithm)}
/>
<SettingInputField
inputType={SettingInputFieldType.TEXT}
label="USERINFO_SIGNED_RESPONSE_ALG"
bind:value={configToEdit.oauth.profileSigningAlgorithm}
required={true}
disabled={disabled || !configToEdit.oauth.enabled}
isEdited={!(configToEdit.oauth.profileSigningAlgorithm === config.oauth.profileSigningAlgorithm)}
/>
<SettingInputField
inputType={SettingInputFieldType.NUMBER}
label={$t('admin.oauth_timeout')}
description={$t('admin.oauth_timeout_description')}
required={true}
bind:value={configToEdit.oauth.timeout}
disabled={disabled || !configToEdit.oauth.enabled}
isEdited={!(configToEdit.oauth.timeout === config.oauth.timeout)}
/>
<SettingInputField
inputType={SettingInputFieldType.TEXT}
label={$t('admin.oauth_storage_label_claim')}
description={$t('admin.oauth_storage_label_claim_description')}
bind:value={configToEdit.oauth.storageLabelClaim}
required={true}
disabled={disabled || !configToEdit.oauth.enabled}
isEdited={!(configToEdit.oauth.storageLabelClaim === config.oauth.storageLabelClaim)}
/>
<SettingInputField
inputType={SettingInputFieldType.TEXT}
label={$t('admin.oauth_role_claim')}
description={$t('admin.oauth_role_claim_description')}
bind:value={configToEdit.oauth.roleClaim}
required={true}
disabled={disabled || !configToEdit.oauth.enabled}
isEdited={!(configToEdit.oauth.roleClaim === config.oauth.roleClaim)}
/>
<SettingInputField
inputType={SettingInputFieldType.TEXT}
label={$t('admin.oauth_storage_quota_claim')}
description={$t('admin.oauth_storage_quota_claim_description')}
bind:value={configToEdit.oauth.storageQuotaClaim}
required={true}
disabled={disabled || !configToEdit.oauth.enabled}
isEdited={!(configToEdit.oauth.storageQuotaClaim === config.oauth.storageQuotaClaim)}
/>
<SettingInputField
inputType={SettingInputFieldType.NUMBER}
label={$t('admin.oauth_storage_quota_default')}
description={$t('admin.oauth_storage_quota_default_description')}
bind:value={configToEdit.oauth.defaultStorageQuota}
required={false}
disabled={disabled || !configToEdit.oauth.enabled}
isEdited={!(configToEdit.oauth.defaultStorageQuota === config.oauth.defaultStorageQuota)}
/>
<SettingInputField
inputType={SettingInputFieldType.TEXT}
label={$t('admin.oauth_button_text')}
bind:value={configToEdit.oauth.buttonText}
required={false}
disabled={disabled || !configToEdit.oauth.enabled}
isEdited={!(configToEdit.oauth.buttonText === config.oauth.buttonText)}
/>
<SettingSwitch
title={$t('admin.oauth_auto_register')}
subtitle={$t('admin.oauth_auto_register_description')}
bind:checked={configToEdit.oauth.autoRegister}
disabled={disabled || !configToEdit.oauth.enabled}
/>
<SettingSwitch
title={$t('admin.oauth_auto_launch')}
subtitle={$t('admin.oauth_auto_launch_description')}
disabled={disabled || !configToEdit.oauth.enabled}
bind:checked={configToEdit.oauth.autoLaunch}
/>
<SettingSwitch
title={$t('admin.oauth_mobile_redirect_uri_override')}
subtitle={$t('admin.oauth_mobile_redirect_uri_override_description', {
values: { callback: 'app.immich:///oauth-callback' },
})}
disabled={disabled || !configToEdit.oauth.enabled}
onToggle={() => handleToggleOverride()}
bind:checked={configToEdit.oauth.mobileOverrideEnabled}
/>
{#if configToEdit.oauth.mobileOverrideEnabled}
<SettingInputField
inputType={SettingInputFieldType.TEXT}
label={$t('admin.oauth_mobile_redirect_uri')}
bind:value={configToEdit.oauth.mobileRedirectUri}
required={true}
disabled={disabled || !configToEdit.oauth.enabled}
isEdited={!(configToEdit.oauth.mobileRedirectUri === config.oauth.mobileRedirectUri)}
/>
{/if}
{/if}
</div>
</SettingAccordion>
<SettingAccordion
key="password"
title={$t('admin.password_settings')}
subtitle={$t('admin.password_settings_description')}
>
<div class="ms-4 mt-4 flex flex-col gap-4">
<div class="ms-4 mt-4 flex flex-col">
<SettingSwitch
title={$t('admin.password_enable_description')}
{disabled}
bind:checked={configToEdit.passwordLogin.enabled}
/>
</div>
</div>
</SettingAccordion>
<SettingButtonsRow bind:configToEdit keys={['passwordLogin', 'oauth']} {onBeforeSave} {disabled} />
</div>
</form>
</div>
</div>

View file

@ -0,0 +1,83 @@
<script lang="ts">
import SettingButtonsRow from '$lib/components/shared-components/settings/SystemConfigButtonRow.svelte';
import SettingInputField from '$lib/components/shared-components/settings/setting-input-field.svelte';
import SettingSelect from '$lib/components/shared-components/settings/setting-select.svelte';
import SettingSwitch from '$lib/components/shared-components/settings/setting-switch.svelte';
import { SettingInputFieldType } from '$lib/constants';
import FormatMessage from '$lib/elements/FormatMessage.svelte';
import { featureFlagsManager } from '$lib/managers/feature-flags-manager.svelte';
import { systemConfigManager } from '$lib/managers/system-config-manager.svelte';
import { t } from 'svelte-i18n';
import { fade } from 'svelte/transition';
const disabled = $derived(featureFlagsManager.value.configFile);
const config = $derived(systemConfigManager.value);
let configToEdit = $state(systemConfigManager.cloneValue());
let cronExpressionOptions = $derived([
{ text: $t('interval.night_at_midnight'), value: '0 0 * * *' },
{ text: $t('interval.night_at_twoam'), value: '0 02 * * *' },
{ text: $t('interval.day_at_onepm'), value: '0 13 * * *' },
{ text: $t('interval.hours', { values: { hours: 6 } }), value: '0 */6 * * *' },
]);
</script>
<div>
<div in:fade={{ duration: 500 }}>
<form autocomplete="off" onsubmit={(event) => event.preventDefault()}>
<div class="ms-4 mt-4 flex flex-col gap-4">
<SettingSwitch
title={$t('admin.backup_database_enable_description')}
{disabled}
bind:checked={configToEdit.backup.database.enabled}
/>
<SettingSelect
options={cronExpressionOptions}
disabled={disabled || !configToEdit.backup.database.enabled}
name="expression"
label={$t('admin.cron_expression_presets')}
bind:value={configToEdit.backup.database.cronExpression}
/>
<SettingInputField
inputType={SettingInputFieldType.TEXT}
required={true}
disabled={disabled || !configToEdit.backup.database.enabled}
label={$t('admin.cron_expression')}
bind:value={configToEdit.backup.database.cronExpression}
isEdited={configToEdit.backup.database.cronExpression !== config.backup.database.cronExpression}
>
{#snippet descriptionSnippet()}
<p class="text-sm dark:text-immich-dark-fg">
<FormatMessage key="admin.cron_expression_description">
{#snippet children({ message })}
<a
href="https://crontab.guru/#{configToEdit.backup.database.cronExpression.replaceAll(' ', '_')}"
class="underline"
target="_blank"
rel="noreferrer"
>
{message}
<br />
</a>
{/snippet}
</FormatMessage>
</p>
{/snippet}
</SettingInputField>
<SettingInputField
inputType={SettingInputFieldType.NUMBER}
required={true}
label={$t('admin.backup_keep_last_amount')}
disabled={disabled || !configToEdit.backup.database.enabled}
bind:value={configToEdit.backup.database.keepLastAmount}
isEdited={configToEdit.backup.database.keepLastAmount !== config.backup.database.keepLastAmount}
/>
<SettingButtonsRow {disabled} bind:configToEdit keys={['backup']} />
</div>
</form>
</div>
</div>

View file

@ -0,0 +1,404 @@
<script lang="ts">
import SettingAccordion from '$lib/components/shared-components/settings/setting-accordion.svelte';
import SettingCheckboxes from '$lib/components/shared-components/settings/setting-checkboxes.svelte';
import SettingInputField from '$lib/components/shared-components/settings/setting-input-field.svelte';
import SettingSelect from '$lib/components/shared-components/settings/setting-select.svelte';
import SettingSwitch from '$lib/components/shared-components/settings/setting-switch.svelte';
import SettingButtonsRow from '$lib/components/shared-components/settings/SystemConfigButtonRow.svelte';
import { SettingInputFieldType } from '$lib/constants';
import FormatMessage from '$lib/elements/FormatMessage.svelte';
import { featureFlagsManager } from '$lib/managers/feature-flags-manager.svelte';
import { systemConfigManager } from '$lib/managers/system-config-manager.svelte';
import {
AudioCodec,
CQMode,
ToneMapping,
TranscodeHWAccel,
TranscodePolicy,
VideoCodec,
VideoContainer,
} from '@immich/sdk';
import { Icon } from '@immich/ui';
import { mdiHelpCircleOutline } from '@mdi/js';
import { isEqual, sortBy } from 'lodash-es';
import { t } from 'svelte-i18n';
import { fade } from 'svelte/transition';
const disabled = $derived(featureFlagsManager.value.configFile);
const config = $derived(systemConfigManager.value);
let configToEdit = $state(systemConfigManager.cloneValue());
</script>
<div>
<div in:fade={{ duration: 500 }}>
<form autocomplete="off" onsubmit={(event) => event.preventDefault()}>
<div class="ms-4 mt-4 flex flex-col gap-4">
<p class="text-sm dark:text-immich-dark-fg">
<Icon icon={mdiHelpCircleOutline} class="inline" size="15" />
<FormatMessage key="admin.transcoding_codecs_learn_more">
{#snippet children({ tag, message })}
{#if tag === 'h264-link'}
<a href="https://trac.ffmpeg.org/wiki/Encode/H.264" class="underline" target="_blank" rel="noreferrer">
{message}
</a>
{:else if tag === 'hevc-link'}
<a href="https://trac.ffmpeg.org/wiki/Encode/H.265" class="underline" target="_blank" rel="noreferrer">
{message}
</a>
{:else if tag === 'vp9-link'}
<a href="https://trac.ffmpeg.org/wiki/Encode/VP9" class="underline" target="_blank" rel="noreferrer">
{message}
</a>
{/if}
{/snippet}
</FormatMessage>
</p>
<SettingAccordion
key="transcoding-policy"
title={$t('admin.transcoding_policy')}
subtitle={$t('admin.transcoding_policy_description')}
>
<div class="ms-4 mt-4 flex flex-col gap-4">
<SettingSelect
label={$t('admin.transcoding_transcode_policy')}
{disabled}
desc={$t('admin.transcoding_transcode_policy_description')}
bind:value={configToEdit.ffmpeg.transcode}
name="transcode"
options={[
{ value: TranscodePolicy.All, text: $t('all_videos') },
{
value: TranscodePolicy.Optimal,
text: $t('admin.transcoding_optimal_description'),
},
{
value: TranscodePolicy.Bitrate,
text: $t('admin.transcoding_bitrate_description'),
},
{
value: TranscodePolicy.Required,
text: $t('admin.transcoding_required_description'),
},
{
value: TranscodePolicy.Disabled,
text: $t('admin.transcoding_disabled_description'),
},
]}
isEdited={configToEdit.ffmpeg.transcode !== config.ffmpeg.transcode}
/>
<SettingCheckboxes
label={$t('admin.transcoding_accepted_video_codecs')}
{disabled}
desc={$t('admin.transcoding_accepted_video_codecs_description')}
bind:value={configToEdit.ffmpeg.acceptedVideoCodecs}
name="videoCodecs"
options={[
{ value: VideoCodec.H264, text: 'H.264' },
{ value: VideoCodec.Hevc, text: 'HEVC' },
{ value: VideoCodec.Vp9, text: 'VP9' },
{ value: VideoCodec.Av1, text: 'AV1' },
]}
isEdited={!isEqual(
sortBy(configToEdit.ffmpeg.acceptedVideoCodecs),
sortBy(config.ffmpeg.acceptedVideoCodecs),
)}
/>
<SettingCheckboxes
label={$t('admin.transcoding_accepted_audio_codecs')}
{disabled}
desc={$t('admin.transcoding_accepted_audio_codecs_description')}
bind:value={configToEdit.ffmpeg.acceptedAudioCodecs}
name="audioCodecs"
options={[
{ value: AudioCodec.Aac, text: 'AAC' },
{ value: AudioCodec.Mp3, text: 'MP3' },
{ value: AudioCodec.Libopus, text: 'Opus' },
{ value: AudioCodec.PcmS16Le, text: 'PCM (16 bit)' },
]}
isEdited={!isEqual(
sortBy(configToEdit.ffmpeg.acceptedAudioCodecs),
sortBy(config.ffmpeg.acceptedAudioCodecs),
)}
/>
<SettingCheckboxes
label={$t('admin.transcoding_accepted_containers')}
{disabled}
desc={$t('admin.transcoding_accepted_containers_description')}
bind:value={configToEdit.ffmpeg.acceptedContainers}
name="videoContainers"
options={[
{ value: VideoContainer.Mov, text: 'MOV' },
{ value: VideoContainer.Ogg, text: 'Ogg' },
{ value: VideoContainer.Webm, text: 'WebM' },
]}
isEdited={!isEqual(
sortBy(configToEdit.ffmpeg.acceptedContainers),
sortBy(config.ffmpeg.acceptedContainers),
)}
/>
</div>
</SettingAccordion>
<SettingAccordion
key="encoding-options"
title={$t('admin.transcoding_encoding_options')}
subtitle={$t('admin.transcoding_encoding_options_description')}
>
<div class="ms-4 mt-4 flex flex-col gap-4">
<SettingSelect
label={$t('admin.transcoding_video_codec')}
{disabled}
desc={$t('admin.transcoding_video_codec_description')}
bind:value={configToEdit.ffmpeg.targetVideoCodec}
options={[
{ value: VideoCodec.H264, text: 'h264' },
{ value: VideoCodec.Hevc, text: 'hevc' },
{ value: VideoCodec.Vp9, text: 'vp9' },
{ value: VideoCodec.Av1, text: 'av1' },
]}
name="vcodec"
isEdited={configToEdit.ffmpeg.targetVideoCodec !== config.ffmpeg.targetVideoCodec}
onSelect={() => (configToEdit.ffmpeg.acceptedVideoCodecs = [configToEdit.ffmpeg.targetVideoCodec])}
/>
<!-- PCM is excluded here since it's a bad choice for users storage-wise -->
<SettingSelect
label={$t('admin.transcoding_audio_codec')}
{disabled}
desc={$t('admin.transcoding_audio_codec_description')}
bind:value={configToEdit.ffmpeg.targetAudioCodec}
options={[
{ value: AudioCodec.Aac, text: 'aac' },
{ value: AudioCodec.Mp3, text: 'mp3' },
{ value: AudioCodec.Libopus, text: 'opus' },
]}
name="acodec"
isEdited={configToEdit.ffmpeg.targetAudioCodec !== config.ffmpeg.targetAudioCodec}
onSelect={() =>
configToEdit.ffmpeg.acceptedAudioCodecs.includes(configToEdit.ffmpeg.targetAudioCodec)
? null
: configToEdit.ffmpeg.acceptedAudioCodecs.push(configToEdit.ffmpeg.targetAudioCodec)}
/>
<SettingSelect
label={$t('admin.transcoding_target_resolution')}
{disabled}
desc={$t('admin.transcoding_target_resolution_description')}
bind:value={configToEdit.ffmpeg.targetResolution}
options={[
{ value: '2160', text: '4k' },
{ value: '1440', text: '1440p' },
{ value: '1080', text: '1080p' },
{ value: '720', text: '720p' },
{ value: '480', text: '480p' },
{ value: 'original', text: $t('original') },
]}
name="resolution"
isEdited={configToEdit.ffmpeg.targetResolution !== config.ffmpeg.targetResolution}
/>
<SettingInputField
inputType={SettingInputFieldType.NUMBER}
{disabled}
label={$t('admin.transcoding_constant_rate_factor')}
description={$t('admin.transcoding_constant_rate_factor_description')}
bind:value={configToEdit.ffmpeg.crf}
required={true}
isEdited={configToEdit.ffmpeg.crf !== config.ffmpeg.crf}
/>
<SettingSelect
label={$t('admin.transcoding_preset_preset')}
{disabled}
desc={$t('admin.transcoding_preset_preset_description')}
bind:value={configToEdit.ffmpeg.preset}
name="preset"
options={[
{ value: 'ultrafast', text: 'ultrafast' },
{ value: 'superfast', text: 'superfast' },
{ value: 'veryfast', text: 'veryfast' },
{ value: 'faster', text: 'faster' },
{ value: 'fast', text: 'fast' },
{ value: 'medium', text: 'medium' },
{ value: 'slow', text: 'slow' },
{ value: 'slower', text: 'slower' },
{ value: 'veryslow', text: 'veryslow' },
]}
isEdited={configToEdit.ffmpeg.preset !== config.ffmpeg.preset}
/>
<SettingInputField
inputType={SettingInputFieldType.TEXT}
{disabled}
label={$t('admin.transcoding_max_bitrate')}
description={$t('admin.transcoding_max_bitrate_description')}
bind:value={configToEdit.ffmpeg.maxBitrate}
isEdited={configToEdit.ffmpeg.maxBitrate !== config.ffmpeg.maxBitrate}
/>
<SettingInputField
inputType={SettingInputFieldType.NUMBER}
{disabled}
label={$t('admin.transcoding_threads')}
description={$t('admin.transcoding_threads_description')}
bind:value={configToEdit.ffmpeg.threads}
isEdited={configToEdit.ffmpeg.threads !== config.ffmpeg.threads}
/>
<SettingSelect
label={$t('admin.transcoding_tone_mapping')}
{disabled}
desc={$t('admin.transcoding_tone_mapping_description')}
bind:value={configToEdit.ffmpeg.tonemap}
name="tonemap"
options={[
{
value: ToneMapping.Hable,
text: 'Hable',
},
{
value: ToneMapping.Mobius,
text: 'Mobius',
},
{
value: ToneMapping.Reinhard,
text: 'Reinhard',
},
{
value: ToneMapping.Disabled,
text: $t('disabled'),
},
]}
isEdited={configToEdit.ffmpeg.tonemap !== config.ffmpeg.tonemap}
/>
<SettingSwitch
title={$t('admin.transcoding_two_pass_encoding')}
{disabled}
subtitle={$t('admin.transcoding_two_pass_encoding_setting_description')}
bind:checked={configToEdit.ffmpeg.twoPass}
isEdited={configToEdit.ffmpeg.twoPass !== config.ffmpeg.twoPass}
/>
</div>
</SettingAccordion>
<SettingAccordion
key="hardware-acceleration"
title={$t('admin.transcoding_hardware_acceleration')}
subtitle={$t('admin.transcoding_hardware_acceleration_description')}
>
<div class="ms-4 mt-4 flex flex-col gap-4">
<SettingSelect
label={$t('admin.transcoding_acceleration_api')}
{disabled}
desc={$t('admin.transcoding_acceleration_api_description')}
bind:value={configToEdit.ffmpeg.accel}
name="accel"
options={[
{ value: TranscodeHWAccel.Nvenc, text: $t('admin.transcoding_acceleration_nvenc') },
{
value: TranscodeHWAccel.Qsv,
text: $t('admin.transcoding_acceleration_qsv'),
},
{
value: TranscodeHWAccel.Vaapi,
text: $t('admin.transcoding_acceleration_vaapi'),
},
{
value: TranscodeHWAccel.Rkmpp,
text: $t('admin.transcoding_acceleration_rkmpp'),
},
{
value: TranscodeHWAccel.Disabled,
text: $t('disabled'),
},
]}
isEdited={configToEdit.ffmpeg.accel !== config.ffmpeg.accel}
/>
<SettingSwitch
title={$t('admin.transcoding_hardware_decoding')}
{disabled}
subtitle={$t('admin.transcoding_hardware_decoding_setting_description')}
bind:checked={configToEdit.ffmpeg.accelDecode}
isEdited={configToEdit.ffmpeg.accelDecode !== config.ffmpeg.accelDecode}
/>
<SettingSelect
label={$t('admin.transcoding_constant_quality_mode')}
desc={$t('admin.transcoding_constant_quality_mode_description')}
bind:value={configToEdit.ffmpeg.cqMode}
options={[
{ value: CQMode.Auto, text: 'Auto' },
{ value: CQMode.Icq, text: 'ICQ' },
{ value: CQMode.Cqp, text: 'CQP' },
]}
isEdited={configToEdit.ffmpeg.cqMode !== config.ffmpeg.cqMode}
{disabled}
/>
<SettingSwitch
title={$t('admin.transcoding_temporal_aq')}
{disabled}
subtitle={$t('admin.transcoding_temporal_aq_description')}
bind:checked={configToEdit.ffmpeg.temporalAQ}
isEdited={configToEdit.ffmpeg.temporalAQ !== config.ffmpeg.temporalAQ}
/>
<SettingInputField
inputType={SettingInputFieldType.TEXT}
label={$t('admin.transcoding_preferred_hardware_device')}
description={$t('admin.transcoding_preferred_hardware_device_description')}
bind:value={configToEdit.ffmpeg.preferredHwDevice}
isEdited={configToEdit.ffmpeg.preferredHwDevice !== config.ffmpeg.preferredHwDevice}
{disabled}
/>
</div>
</SettingAccordion>
<SettingAccordion
key="advanced-options"
title={$t('advanced')}
subtitle={$t('admin.transcoding_advanced_options_description')}
>
<div class="ms-4 mt-4 flex flex-col gap-4">
<SettingInputField
inputType={SettingInputFieldType.NUMBER}
label={$t('admin.transcoding_max_b_frames')}
description={$t('admin.transcoding_max_b_frames_description')}
bind:value={configToEdit.ffmpeg.bframes}
isEdited={configToEdit.ffmpeg.bframes !== config.ffmpeg.bframes}
{disabled}
/>
<SettingInputField
inputType={SettingInputFieldType.NUMBER}
label={$t('admin.transcoding_reference_frames')}
description={$t('admin.transcoding_reference_frames_description')}
bind:value={configToEdit.ffmpeg.refs}
isEdited={configToEdit.ffmpeg.refs !== config.ffmpeg.refs}
{disabled}
/>
<SettingInputField
inputType={SettingInputFieldType.NUMBER}
label={$t('admin.transcoding_max_keyframe_interval')}
description={$t('admin.transcoding_max_keyframe_interval_description')}
bind:value={configToEdit.ffmpeg.gopSize}
isEdited={configToEdit.ffmpeg.gopSize !== config.ffmpeg.gopSize}
{disabled}
/>
</div>
</SettingAccordion>
</div>
<div class="ms-4">
<SettingButtonsRow bind:configToEdit keys={['ffmpeg']} {disabled} />
</div>
</form>
</div>
</div>

View file

@ -0,0 +1,224 @@
<script lang="ts">
import SettingSelect from '$lib/components/shared-components/settings/setting-select.svelte';
import { Colorspace, ImageFormat } from '@immich/sdk';
import { fade } from 'svelte/transition';
import SettingAccordion from '$lib/components/shared-components/settings/setting-accordion.svelte';
import SettingInputField from '$lib/components/shared-components/settings/setting-input-field.svelte';
import SettingSwitch from '$lib/components/shared-components/settings/setting-switch.svelte';
import SettingButtonsRow from '$lib/components/shared-components/settings/SystemConfigButtonRow.svelte';
import { SettingInputFieldType } from '$lib/constants';
import { featureFlagsManager } from '$lib/managers/feature-flags-manager.svelte';
import { systemConfigManager } from '$lib/managers/system-config-manager.svelte';
import { t } from 'svelte-i18n';
const disabled = $derived(featureFlagsManager.value.configFile);
const config = $derived(systemConfigManager.value);
let configToEdit = $state(systemConfigManager.cloneValue());
</script>
<div>
<div in:fade={{ duration: 500 }}>
<form autocomplete="off" onsubmit={(event) => event.preventDefault()}>
<div class="ms-4 mt-4">
<SettingAccordion
key="thumbnail-settings"
title={$t('admin.image_thumbnail_title')}
subtitle={$t('admin.image_thumbnail_description')}
>
<SettingSelect
label={$t('admin.image_format')}
desc={$t('admin.image_format_description')}
bind:value={configToEdit.image.thumbnail.format}
options={[
{ value: ImageFormat.Jpeg, text: 'JPEG' },
{ value: ImageFormat.Webp, text: 'WebP' },
]}
name="format"
isEdited={configToEdit.image.thumbnail.format !== config.image.thumbnail.format}
{disabled}
onSelect={(value) => {
if (value === ImageFormat.Webp) {
configToEdit.image.thumbnail.progressive = false;
}
}}
/>
<SettingSelect
label={$t('admin.image_resolution')}
desc={$t('admin.image_resolution_description')}
number
bind:value={configToEdit.image.thumbnail.size}
options={[
{ value: 1080, text: '1080p' },
{ value: 720, text: '720p' },
{ value: 480, text: '480p' },
{ value: 250, text: '250p' },
{ value: 200, text: '200p' },
]}
name="resolution"
isEdited={configToEdit.image.thumbnail.size !== config.image.thumbnail.size}
{disabled}
/>
<SettingInputField
inputType={SettingInputFieldType.NUMBER}
label={$t('admin.image_quality')}
description={$t('admin.image_thumbnail_quality_description')}
bind:value={configToEdit.image.thumbnail.quality}
isEdited={configToEdit.image.thumbnail.quality !== config.image.thumbnail.quality}
{disabled}
/>
<SettingSwitch
title={$t('admin.image_progressive')}
subtitle={$t('admin.image_progressive_description')}
checked={configToEdit.image.thumbnail.progressive}
onToggle={(isChecked) => (configToEdit.image.thumbnail.progressive = isChecked)}
isEdited={configToEdit.image.thumbnail.progressive !== config.image.thumbnail.progressive}
disabled={disabled || configToEdit.image.thumbnail.format === ImageFormat.Webp}
/>
</SettingAccordion>
<SettingAccordion
key="preview-settings"
title={$t('admin.image_preview_title')}
subtitle={$t('admin.image_preview_description')}
>
<SettingSelect
label={$t('admin.image_format')}
desc={$t('admin.image_format_description')}
bind:value={configToEdit.image.preview.format}
options={[
{ value: ImageFormat.Jpeg, text: 'JPEG' },
{ value: ImageFormat.Webp, text: 'WebP' },
]}
name="format"
isEdited={configToEdit.image.preview.format !== config.image.preview.format}
{disabled}
onSelect={(value) => {
if (value === ImageFormat.Webp) {
configToEdit.image.preview.progressive = false;
}
}}
/>
<SettingSelect
label={$t('admin.image_resolution')}
desc={$t('admin.image_resolution_description')}
number
bind:value={configToEdit.image.preview.size}
options={[
{ value: 2160, text: '4K' },
{ value: 1440, text: '1440p' },
{ value: 1080, text: '1080p' },
{ value: 720, text: '720p' },
]}
name="resolution"
isEdited={configToEdit.image.preview.size !== config.image.preview.size}
{disabled}
/>
<SettingInputField
inputType={SettingInputFieldType.NUMBER}
label={$t('admin.image_quality')}
description={$t('admin.image_preview_quality_description')}
bind:value={configToEdit.image.preview.quality}
isEdited={configToEdit.image.preview.quality !== config.image.preview.quality}
{disabled}
/>
<SettingSwitch
title={$t('admin.image_progressive')}
subtitle={$t('admin.image_progressive_description')}
checked={configToEdit.image.preview.progressive}
onToggle={(isChecked) => (configToEdit.image.preview.progressive = isChecked)}
isEdited={configToEdit.image.preview.progressive !== config.image.preview.progressive}
disabled={disabled || configToEdit.image.preview.format === ImageFormat.Webp}
/>
</SettingAccordion>
<SettingAccordion
key="fullsize-settings"
title={$t('admin.image_fullsize_title')}
subtitle={$t('admin.image_fullsize_description')}
>
<SettingSwitch
title={$t('admin.image_fullsize_enabled')}
subtitle={$t('admin.image_fullsize_enabled_description')}
checked={configToEdit.image.fullsize.enabled}
onToggle={(isChecked) => (configToEdit.image.fullsize.enabled = isChecked)}
isEdited={configToEdit.image.fullsize.enabled !== config.image.fullsize.enabled}
{disabled}
/>
<hr class="my-4" />
<SettingSelect
label={$t('admin.image_format')}
desc={$t('admin.image_format_description')}
bind:value={configToEdit.image.fullsize.format}
options={[
{ value: ImageFormat.Jpeg, text: 'JPEG' },
{ value: ImageFormat.Webp, text: 'WebP' },
]}
name="format"
isEdited={configToEdit.image.fullsize.format !== config.image.fullsize.format}
disabled={disabled || !configToEdit.image.fullsize.enabled}
onSelect={(value) => {
if (value === ImageFormat.Webp) {
configToEdit.image.fullsize.progressive = false;
}
}}
/>
<SettingInputField
inputType={SettingInputFieldType.NUMBER}
label={$t('admin.image_quality')}
description={$t('admin.image_fullsize_quality_description')}
bind:value={configToEdit.image.fullsize.quality}
isEdited={configToEdit.image.fullsize.quality !== config.image.fullsize.quality}
disabled={disabled || !configToEdit.image.fullsize.enabled}
/>
<SettingSwitch
title={$t('admin.image_progressive')}
subtitle={$t('admin.image_progressive_description')}
checked={configToEdit.image.fullsize.progressive}
onToggle={(isChecked) => (configToEdit.image.fullsize.progressive = isChecked)}
isEdited={configToEdit.image.fullsize.progressive !== config.image.fullsize.progressive}
disabled={disabled ||
!configToEdit.image.fullsize.enabled ||
configToEdit.image.fullsize.format === ImageFormat.Webp}
/>
</SettingAccordion>
<div class="mt-4">
<SettingSwitch
title={$t('admin.image_prefer_wide_gamut')}
subtitle={$t('admin.image_prefer_wide_gamut_setting_description')}
checked={configToEdit.image.colorspace === Colorspace.P3}
onToggle={(isChecked) => (configToEdit.image.colorspace = isChecked ? Colorspace.P3 : Colorspace.Srgb)}
isEdited={configToEdit.image.colorspace !== config.image.colorspace}
{disabled}
/>
</div>
<div class="mt-4">
<SettingSwitch
title={$t('admin.image_prefer_embedded_preview')}
subtitle={$t('admin.image_prefer_embedded_preview_setting_description')}
checked={configToEdit.image.extractEmbedded}
onToggle={() => (configToEdit.image.extractEmbedded = !configToEdit.image.extractEmbedded)}
isEdited={configToEdit.image.extractEmbedded !== config.image.extractEmbedded}
{disabled}
/>
</div>
</div>
<div class="ms-4 mt-4">
<SettingButtonsRow bind:configToEdit keys={['image']} {disabled} />
</div>
</form>
</div>
</div>

View file

@ -0,0 +1,68 @@
<script lang="ts">
import SettingButtonsRow from '$lib/components/shared-components/settings/SystemConfigButtonRow.svelte';
import SettingInputField from '$lib/components/shared-components/settings/setting-input-field.svelte';
import { SettingInputFieldType } from '$lib/constants';
import { featureFlagsManager } from '$lib/managers/feature-flags-manager.svelte';
import { systemConfigManager } from '$lib/managers/system-config-manager.svelte';
import { getQueueName } from '$lib/utils';
import { QueueName, type SystemConfigJobDto } from '@immich/sdk';
import { t } from 'svelte-i18n';
import { fade } from 'svelte/transition';
const disabled = $derived(featureFlagsManager.value.configFile);
const config = $derived(systemConfigManager.value);
let configToEdit = $state(systemConfigManager.cloneValue());
const queueNames = [
QueueName.ThumbnailGeneration,
QueueName.MetadataExtraction,
QueueName.Library,
QueueName.Sidecar,
QueueName.SmartSearch,
QueueName.FaceDetection,
QueueName.FacialRecognition,
QueueName.VideoConversion,
QueueName.StorageTemplateMigration,
QueueName.Migration,
QueueName.Ocr,
];
function isSystemConfigJobDto(jobName: string): jobName is keyof SystemConfigJobDto {
return jobName in configToEdit.job;
}
</script>
<div>
<div in:fade={{ duration: 500 }}>
<form autocomplete="off" onsubmit={(event) => event.preventDefault()}>
{#each queueNames as queueName (queueName)}
<div class="ms-4 mt-4 flex flex-col gap-4">
{#if isSystemConfigJobDto(queueName)}
<SettingInputField
inputType={SettingInputFieldType.NUMBER}
{disabled}
label={$t('admin.job_concurrency', { values: { job: $getQueueName(queueName) } })}
description=""
bind:value={configToEdit.job[queueName].concurrency}
required={true}
isEdited={!(configToEdit.job[queueName].concurrency == config.job[queueName].concurrency)}
/>
{:else}
<SettingInputField
inputType={SettingInputFieldType.NUMBER}
label={$t('admin.job_concurrency', { values: { job: $getQueueName(queueName) } })}
description=""
value={1}
disabled={true}
title={$t('admin.job_not_concurrency_safe')}
/>
{/if}
</div>
{/each}
<div class="ms-4">
<SettingButtonsRow bind:configToEdit keys={['job']} {disabled} />
</div>
</form>
</div>
</div>

View file

@ -0,0 +1,96 @@
<script lang="ts">
import SettingAccordion from '$lib/components/shared-components/settings/setting-accordion.svelte';
import SettingInputField from '$lib/components/shared-components/settings/setting-input-field.svelte';
import SettingSelect from '$lib/components/shared-components/settings/setting-select.svelte';
import SettingSwitch from '$lib/components/shared-components/settings/setting-switch.svelte';
import SettingButtonsRow from '$lib/components/shared-components/settings/SystemConfigButtonRow.svelte';
import { SettingInputFieldType } from '$lib/constants';
import FormatMessage from '$lib/elements/FormatMessage.svelte';
import { featureFlagsManager } from '$lib/managers/feature-flags-manager.svelte';
import { systemConfigManager } from '$lib/managers/system-config-manager.svelte';
import { t } from 'svelte-i18n';
import { fade } from 'svelte/transition';
const disabled = $derived(featureFlagsManager.value.configFile);
const config = $derived(systemConfigManager.value);
let configToEdit = $state(systemConfigManager.cloneValue());
let cronExpressionOptions = $derived([
{ text: $t('interval.night_at_midnight'), value: '0 0 * * *' },
{ text: $t('interval.night_at_twoam'), value: '0 2 * * *' },
{ text: $t('interval.day_at_onepm'), value: '0 13 * * *' },
{ text: $t('interval.hours', { values: { hours: 6 } }), value: '0 */6 * * *' },
]);
</script>
<div>
<div in:fade={{ duration: 500 }}>
<form autocomplete="off" onsubmit={(event) => event.preventDefault()}>
<div class="ms-4 mt-4 flex flex-col gap-4">
<SettingAccordion
key="library-watching"
title={$t('admin.library_watching_settings')}
subtitle={$t('admin.library_watching_settings_description')}
>
<div class="ms-4 mt-4 flex flex-col gap-4">
<SettingSwitch
title={$t('admin.library_watching_enable_description')}
{disabled}
bind:checked={configToEdit.library.watch.enabled}
/>
</div>
</SettingAccordion>
<SettingAccordion
key="library-scanning"
title={$t('admin.library_scanning')}
subtitle={$t('admin.library_scanning_description')}
>
<div class="ms-4 mt-4 flex flex-col gap-4">
<SettingSwitch
title={$t('admin.library_scanning_enable_description')}
{disabled}
bind:checked={configToEdit.library.scan.enabled}
/>
<SettingSelect
options={cronExpressionOptions}
disabled={disabled || !configToEdit.library.scan.enabled}
name="expression"
label={$t('admin.cron_expression_presets')}
bind:value={configToEdit.library.scan.cronExpression}
/>
<SettingInputField
inputType={SettingInputFieldType.TEXT}
required={true}
disabled={disabled || !configToEdit.library.scan.enabled}
label={$t('admin.cron_expression')}
bind:value={configToEdit.library.scan.cronExpression}
isEdited={configToEdit.library.scan.cronExpression !== config.library.scan.cronExpression}
>
{#snippet descriptionSnippet()}
<p class="text-sm dark:text-immich-dark-fg">
<FormatMessage key="admin.cron_expression_description">
{#snippet children({ message })}
<a
href="https://crontab.guru/#{configToEdit.library.scan.cronExpression.replaceAll(' ', '_')}"
class="underline"
target="_blank"
rel="noreferrer"
>
{message}
</a>
{/snippet}
</FormatMessage>
</p>
{/snippet}
</SettingInputField>
</div>
</SettingAccordion>
<SettingButtonsRow bind:configToEdit keys={['library']} {disabled} />
</div>
</form>
</div>
</div>

View file

@ -0,0 +1,46 @@
<script lang="ts">
import SettingButtonsRow from '$lib/components/shared-components/settings/SystemConfigButtonRow.svelte';
import SettingSelect from '$lib/components/shared-components/settings/setting-select.svelte';
import SettingSwitch from '$lib/components/shared-components/settings/setting-switch.svelte';
import { featureFlagsManager } from '$lib/managers/feature-flags-manager.svelte';
import { systemConfigManager } from '$lib/managers/system-config-manager.svelte';
import { LogLevel } from '@immich/sdk';
import { t } from 'svelte-i18n';
import { fade } from 'svelte/transition';
const disabled = $derived(featureFlagsManager.value.configFile);
const config = $derived(systemConfigManager.value);
let configToEdit = $state(systemConfigManager.cloneValue());
</script>
<div>
<div in:fade={{ duration: 500 }}>
<form autocomplete="off" onsubmit={(event) => event.preventDefault()}>
<div class="ms-4 mt-4 flex flex-col gap-4">
<SettingSwitch
title={$t('admin.logging_enable_description')}
{disabled}
bind:checked={configToEdit.logging.enabled}
/>
<SettingSelect
label={$t('level')}
desc={$t('admin.logging_level_description')}
bind:value={configToEdit.logging.level}
options={[
{ value: LogLevel.Fatal, text: 'Fatal' },
{ value: LogLevel.Error, text: 'Error' },
{ value: LogLevel.Warn, text: 'Warn' },
{ value: LogLevel.Log, text: 'Log' },
{ value: LogLevel.Debug, text: 'Debug' },
{ value: LogLevel.Verbose, text: 'Verbose' },
]}
name="level"
isEdited={configToEdit.logging.level !== config.logging.level}
disabled={disabled || !configToEdit.logging.enabled}
/>
<SettingButtonsRow bind:configToEdit keys={['logging']} {disabled} />
</div>
</form>
</div>
</div>

View file

@ -0,0 +1,331 @@
<script lang="ts">
import SettingAccordion from '$lib/components/shared-components/settings/setting-accordion.svelte';
import SettingInputField from '$lib/components/shared-components/settings/setting-input-field.svelte';
import SettingSelect from '$lib/components/shared-components/settings/setting-select.svelte';
import SettingSwitch from '$lib/components/shared-components/settings/setting-switch.svelte';
import SettingButtonsRow from '$lib/components/shared-components/settings/SystemConfigButtonRow.svelte';
import { SettingInputFieldType } from '$lib/constants';
import FormatMessage from '$lib/elements/FormatMessage.svelte';
import { featureFlagsManager } from '$lib/managers/feature-flags-manager.svelte';
import { systemConfigManager } from '$lib/managers/system-config-manager.svelte';
import { Button, IconButton } from '@immich/ui';
import { mdiPlus, mdiTrashCanOutline } from '@mdi/js';
import { isEqual } from 'lodash-es';
import { t } from 'svelte-i18n';
import { fade } from 'svelte/transition';
const disabled = $derived(featureFlagsManager.value.configFile);
const config = $derived(systemConfigManager.value);
let configToEdit = $state(systemConfigManager.cloneValue());
</script>
<div class="mt-2">
<div in:fade={{ duration: 500 }}>
<form autocomplete="off" class="mx-4 mt-4" onsubmit={(event) => event.preventDefault()}>
<div class="flex flex-col gap-4">
<SettingSwitch
title={$t('admin.machine_learning_enabled')}
subtitle={$t('admin.machine_learning_enabled_description')}
{disabled}
bind:checked={configToEdit.machineLearning.enabled}
/>
<hr />
<div>
{#each configToEdit.machineLearning.urls as _, i (i)}
<SettingInputField
inputType={SettingInputFieldType.TEXT}
label={i === 0 ? $t('url') : undefined}
description={i === 0 ? $t('admin.machine_learning_url_description') : undefined}
bind:value={configToEdit.machineLearning.urls[i]}
required={i === 0}
disabled={disabled || !configToEdit.machineLearning.enabled}
isEdited={i === 0 && !isEqual(configToEdit.machineLearning.urls, config.machineLearning.urls)}
>
{#snippet trailingSnippet()}
{#if configToEdit.machineLearning.urls.length > 1}
<IconButton
aria-label=""
onclick={() => configToEdit.machineLearning.urls.splice(i, 1)}
icon={mdiTrashCanOutline}
color="danger"
/>
{/if}
{/snippet}
</SettingInputField>
{/each}
</div>
<div class="flex justify-end">
<Button
class="mb-2"
size="small"
shape="round"
leadingIcon={mdiPlus}
onclick={() => configToEdit.machineLearning.urls.push('')}
disabled={disabled || !configToEdit.machineLearning.enabled}>{$t('add_url')}</Button
>
</div>
</div>
<SettingAccordion
key="availability-checks"
title={$t('admin.machine_learning_availability_checks')}
subtitle={$t('admin.machine_learning_availability_checks_description')}
>
<div class="ms-4 mt-4 flex flex-col gap-4">
<SettingSwitch
title={$t('admin.machine_learning_availability_checks_enabled')}
bind:checked={configToEdit.machineLearning.availabilityChecks.enabled}
disabled={disabled || !configToEdit.machineLearning.enabled}
/>
<hr />
<SettingInputField
inputType={SettingInputFieldType.NUMBER}
label={$t('admin.machine_learning_availability_checks_interval')}
bind:value={configToEdit.machineLearning.availabilityChecks.interval}
description={$t('admin.machine_learning_availability_checks_interval_description')}
disabled={disabled ||
!configToEdit.machineLearning.enabled ||
!configToEdit.machineLearning.availabilityChecks.enabled}
isEdited={configToEdit.machineLearning.availabilityChecks.interval !==
config.machineLearning.availabilityChecks.interval}
/>
<SettingInputField
inputType={SettingInputFieldType.NUMBER}
label={$t('admin.machine_learning_availability_checks_timeout')}
bind:value={configToEdit.machineLearning.availabilityChecks.timeout}
description={$t('admin.machine_learning_availability_checks_timeout_description')}
disabled={disabled ||
!configToEdit.machineLearning.enabled ||
!configToEdit.machineLearning.availabilityChecks.enabled}
isEdited={configToEdit.machineLearning.availabilityChecks.timeout !==
config.machineLearning.availabilityChecks.timeout}
/>
</div>
</SettingAccordion>
<SettingAccordion
key="smart-search"
title={$t('admin.machine_learning_smart_search')}
subtitle={$t('admin.machine_learning_smart_search_description')}
>
<div class="ms-4 mt-4 flex flex-col gap-4">
<SettingSwitch
title={$t('admin.machine_learning_smart_search_enabled')}
subtitle={$t('admin.machine_learning_smart_search_enabled_description')}
bind:checked={configToEdit.machineLearning.clip.enabled}
disabled={disabled || !configToEdit.machineLearning.enabled}
/>
<hr />
<SettingInputField
inputType={SettingInputFieldType.TEXT}
label={$t('admin.machine_learning_clip_model')}
bind:value={configToEdit.machineLearning.clip.modelName}
required={true}
disabled={disabled || !configToEdit.machineLearning.enabled || !configToEdit.machineLearning.clip.enabled}
isEdited={configToEdit.machineLearning.clip.modelName !== config.machineLearning.clip.modelName}
>
{#snippet descriptionSnippet()}
<p class="immich-form-label pb-2 text-sm">
<FormatMessage key="admin.machine_learning_clip_model_description">
{#snippet children({ message })}
<a target="_blank" href="https://huggingface.co/immich-app"><u>{message}</u></a>
{/snippet}
</FormatMessage>
</p>
{/snippet}
</SettingInputField>
</div>
</SettingAccordion>
<SettingAccordion
key="duplicate-detection"
title={$t('admin.machine_learning_duplicate_detection')}
subtitle={$t('admin.machine_learning_duplicate_detection_setting_description')}
>
<div class="ms-4 mt-4 flex flex-col gap-4">
<SettingSwitch
title={$t('admin.machine_learning_duplicate_detection_enabled')}
subtitle={$t('admin.machine_learning_duplicate_detection_enabled_description')}
bind:checked={configToEdit.machineLearning.duplicateDetection.enabled}
disabled={disabled || !configToEdit.machineLearning.enabled || !configToEdit.machineLearning.clip.enabled}
/>
<hr />
<SettingInputField
inputType={SettingInputFieldType.NUMBER}
label={$t('admin.machine_learning_max_detection_distance')}
bind:value={configToEdit.machineLearning.duplicateDetection.maxDistance}
step="0.0005"
min={0.001}
max={0.1}
description={$t('admin.machine_learning_max_detection_distance_description')}
disabled={disabled || !featureFlagsManager.value.duplicateDetection}
isEdited={configToEdit.machineLearning.duplicateDetection.maxDistance !==
config.machineLearning.duplicateDetection.maxDistance}
/>
</div>
</SettingAccordion>
<SettingAccordion
key="facial-recognition"
title={$t('admin.machine_learning_facial_recognition')}
subtitle={$t('admin.machine_learning_facial_recognition_description')}
>
<div class="ms-4 mt-4 flex flex-col gap-4">
<SettingSwitch
title={$t('admin.machine_learning_facial_recognition_setting')}
subtitle={$t('admin.machine_learning_facial_recognition_setting_description')}
bind:checked={configToEdit.machineLearning.facialRecognition.enabled}
disabled={disabled || !configToEdit.machineLearning.enabled}
/>
<hr />
<SettingSelect
label={$t('admin.machine_learning_facial_recognition_model')}
desc={$t('admin.machine_learning_facial_recognition_model_description')}
name="facial-recognition-model"
bind:value={configToEdit.machineLearning.facialRecognition.modelName}
options={[
{ value: 'antelopev2', text: 'antelopev2' },
{ value: 'buffalo_l', text: 'buffalo_l' },
{ value: 'buffalo_m', text: 'buffalo_m' },
{ value: 'buffalo_s', text: 'buffalo_s' },
]}
disabled={disabled ||
!configToEdit.machineLearning.enabled ||
!configToEdit.machineLearning.facialRecognition.enabled}
isEdited={configToEdit.machineLearning.facialRecognition.modelName !==
config.machineLearning.facialRecognition.modelName}
/>
<SettingInputField
inputType={SettingInputFieldType.NUMBER}
label={$t('admin.machine_learning_min_detection_score')}
description={$t('admin.machine_learning_min_detection_score_description')}
bind:value={configToEdit.machineLearning.facialRecognition.minScore}
step="0.01"
min={0.1}
max={1}
disabled={disabled ||
!configToEdit.machineLearning.enabled ||
!configToEdit.machineLearning.facialRecognition.enabled}
isEdited={configToEdit.machineLearning.facialRecognition.minScore !==
config.machineLearning.facialRecognition.minScore}
/>
<SettingInputField
inputType={SettingInputFieldType.NUMBER}
label={$t('admin.machine_learning_max_recognition_distance')}
description={$t('admin.machine_learning_max_recognition_distance_description')}
bind:value={configToEdit.machineLearning.facialRecognition.maxDistance}
step="0.01"
min={0.1}
max={2}
disabled={disabled ||
!configToEdit.machineLearning.enabled ||
!configToEdit.machineLearning.facialRecognition.enabled}
isEdited={configToEdit.machineLearning.facialRecognition.maxDistance !==
config.machineLearning.facialRecognition.maxDistance}
/>
<SettingInputField
inputType={SettingInputFieldType.NUMBER}
label={$t('admin.machine_learning_min_recognized_faces')}
description={$t('admin.machine_learning_min_recognized_faces_description')}
bind:value={configToEdit.machineLearning.facialRecognition.minFaces}
step="1"
min={1}
disabled={disabled ||
!configToEdit.machineLearning.enabled ||
!configToEdit.machineLearning.facialRecognition.enabled}
isEdited={configToEdit.machineLearning.facialRecognition.minFaces !==
config.machineLearning.facialRecognition.minFaces}
/>
</div>
</SettingAccordion>
<SettingAccordion
key="ocr"
title={$t('admin.machine_learning_ocr')}
subtitle={$t('admin.machine_learning_ocr_description')}
>
<div class="ml-4 mt-4 flex flex-col gap-4">
<SettingSwitch
title={$t('admin.machine_learning_ocr_enabled')}
subtitle={$t('admin.machine_learning_ocr_enabled_description')}
bind:checked={configToEdit.machineLearning.ocr.enabled}
disabled={disabled || !configToEdit.machineLearning.enabled}
/>
<hr />
<SettingSelect
label={$t('admin.machine_learning_ocr_model')}
desc={$t('admin.machine_learning_ocr_model_description')}
name="ocr-model"
bind:value={configToEdit.machineLearning.ocr.modelName}
options={[
{ text: 'PP-OCRv5_server (Chinese, Japanese and English)', value: 'PP-OCRv5_server' },
{ text: 'PP-OCRv5_mobile (Chinese, Japanese and English)', value: 'PP-OCRv5_mobile' },
{ text: 'PP-OCRv5_mobile (English-only)', value: 'EN__PP-OCRv5_mobile' },
{ text: 'PP-OCRv5_mobile (Greek and English)', value: 'EL__PP-OCRv5_mobile' },
{ text: 'PP-OCRv5_mobile (Korean and English)', value: 'KOREAN__PP-OCRv5_mobile' },
{ text: 'PP-OCRv5_mobile (Latin script languages)', value: 'LATIN__PP-OCRv5_mobile' },
{ text: 'PP-OCRv5_mobile (Russian, Belarusian, Ukrainian and English)', value: 'ESLAV__PP-OCRv5_mobile' },
{ text: 'PP-OCRv5_mobile (Thai and English)', value: 'TH__PP-OCRv5_mobile' },
]}
disabled={disabled || !configToEdit.machineLearning.enabled || !configToEdit.machineLearning.ocr.enabled}
isEdited={configToEdit.machineLearning.ocr.modelName !== config.machineLearning.ocr.modelName}
/>
<SettingInputField
inputType={SettingInputFieldType.NUMBER}
label={$t('admin.machine_learning_ocr_min_detection_score')}
description={$t('admin.machine_learning_ocr_min_detection_score_description')}
bind:value={configToEdit.machineLearning.ocr.minDetectionScore}
step="0.1"
min={0.1}
max={1}
disabled={disabled || !configToEdit.machineLearning.enabled || !configToEdit.machineLearning.ocr.enabled}
isEdited={configToEdit.machineLearning.ocr.minDetectionScore !==
config.machineLearning.ocr.minDetectionScore}
/>
<SettingInputField
inputType={SettingInputFieldType.NUMBER}
label={$t('admin.machine_learning_ocr_min_recognition_score')}
description={$t('admin.machine_learning_ocr_min_score_recognition_description')}
bind:value={configToEdit.machineLearning.ocr.minRecognitionScore}
step="0.1"
min={0.1}
max={1}
disabled={disabled || !configToEdit.machineLearning.enabled || !configToEdit.machineLearning.ocr.enabled}
isEdited={configToEdit.machineLearning.ocr.minRecognitionScore !==
config.machineLearning.ocr.minRecognitionScore}
/>
<SettingInputField
inputType={SettingInputFieldType.NUMBER}
label={$t('admin.machine_learning_ocr_max_resolution')}
description={$t('admin.machine_learning_ocr_max_resolution_description')}
bind:value={configToEdit.machineLearning.ocr.maxResolution}
min={1}
disabled={disabled || !configToEdit.machineLearning.enabled || !configToEdit.machineLearning.ocr.enabled}
isEdited={configToEdit.machineLearning.ocr.maxResolution !== config.machineLearning.ocr.maxResolution}
/>
</div>
</SettingAccordion>
<SettingButtonsRow bind:configToEdit keys={['machineLearning']} {disabled} />
</form>
</div>
</div>

View file

@ -0,0 +1,82 @@
<script lang="ts">
import SettingAccordion from '$lib/components/shared-components/settings/setting-accordion.svelte';
import SettingInputField from '$lib/components/shared-components/settings/setting-input-field.svelte';
import SettingSwitch from '$lib/components/shared-components/settings/setting-switch.svelte';
import SettingButtonsRow from '$lib/components/shared-components/settings/SystemConfigButtonRow.svelte';
import { SettingInputFieldType } from '$lib/constants';
import FormatMessage from '$lib/elements/FormatMessage.svelte';
import { featureFlagsManager } from '$lib/managers/feature-flags-manager.svelte';
import { systemConfigManager } from '$lib/managers/system-config-manager.svelte';
import { t } from 'svelte-i18n';
import { fade } from 'svelte/transition';
const disabled = $derived(featureFlagsManager.value.configFile);
const config = $derived(systemConfigManager.value);
let configToEdit = $state(systemConfigManager.cloneValue());
</script>
<div class="mt-2">
<div in:fade={{ duration: 500 }}>
<form autocomplete="off" onsubmit={(event) => event.preventDefault()}>
<div class="flex flex-col gap-4">
<SettingAccordion key="map" title={$t('admin.map_settings')} subtitle={$t('admin.map_settings_description')}>
<div class="ms-4 mt-4 flex flex-col gap-4">
<SettingSwitch
title={$t('admin.map_enable_description')}
subtitle={$t('admin.map_implications')}
{disabled}
bind:checked={configToEdit.map.enabled}
/>
<hr />
<SettingInputField
inputType={SettingInputFieldType.TEXT}
label={$t('admin.map_light_style')}
description={$t('admin.map_style_description')}
bind:value={configToEdit.map.lightStyle}
disabled={disabled || !configToEdit.map.enabled}
isEdited={configToEdit.map.lightStyle !== config.map.lightStyle}
/>
<SettingInputField
inputType={SettingInputFieldType.TEXT}
label={$t('admin.map_dark_style')}
description={$t('admin.map_style_description')}
bind:value={configToEdit.map.darkStyle}
disabled={disabled || !configToEdit.map.enabled}
isEdited={configToEdit.map.darkStyle !== config.map.darkStyle}
/>
</div></SettingAccordion
>
<SettingAccordion key="reverse-geocoding" title={$t('admin.map_reverse_geocoding_settings')}>
{#snippet subtitleSnippet()}
<p class="text-sm dark:text-immich-dark-fg">
<FormatMessage key="admin.map_manage_reverse_geocoding_settings">
{#snippet children({ message })}
<a
href="https://docs.immich.app/features/reverse-geocoding"
class="underline"
target="_blank"
rel="noreferrer"
>
{message}
</a>
{/snippet}
</FormatMessage>
</p>
{/snippet}
<div class="ms-4 mt-4 flex flex-col gap-4">
<SettingSwitch
title={$t('admin.map_reverse_geocoding_enable_description')}
{disabled}
bind:checked={configToEdit.reverseGeocoding.enabled}
/>
</div></SettingAccordion
>
<SettingButtonsRow bind:configToEdit keys={['map', 'reverseGeocoding']} {disabled} />
</div>
</form>
</div>
</div>

View file

@ -0,0 +1,28 @@
<script lang="ts">
import SettingButtonsRow from '$lib/components/shared-components/settings/SystemConfigButtonRow.svelte';
import SettingSwitch from '$lib/components/shared-components/settings/setting-switch.svelte';
import { featureFlagsManager } from '$lib/managers/feature-flags-manager.svelte';
import { systemConfigManager } from '$lib/managers/system-config-manager.svelte';
import { t } from 'svelte-i18n';
import { fade } from 'svelte/transition';
const disabled = $derived(featureFlagsManager.value.configFile);
let configToEdit = $state(systemConfigManager.cloneValue());
</script>
<div class="mt-2">
<div in:fade={{ duration: 500 }}>
<form autocomplete="off" class="mx-4 mt-4" onsubmit={(event) => event.preventDefault()}>
<div class="ms-4 mt-4 flex flex-col gap-4">
<SettingSwitch
title={$t('admin.metadata_faces_import_setting')}
subtitle={$t('admin.metadata_faces_import_setting_description')}
bind:checked={configToEdit.metadata.faces.import}
{disabled}
/>
</div>
<SettingButtonsRow bind:configToEdit keys={['metadata']} {disabled} />
</form>
</div>
</div>

View file

@ -0,0 +1,27 @@
<script lang="ts">
import SettingButtonsRow from '$lib/components/shared-components/settings/SystemConfigButtonRow.svelte';
import SettingSwitch from '$lib/components/shared-components/settings/setting-switch.svelte';
import { featureFlagsManager } from '$lib/managers/feature-flags-manager.svelte';
import { systemConfigManager } from '$lib/managers/system-config-manager.svelte';
import { t } from 'svelte-i18n';
import { fade } from 'svelte/transition';
const disabled = $derived(featureFlagsManager.value.configFile);
let configToEdit = $state(systemConfigManager.cloneValue());
</script>
<div>
<div in:fade={{ duration: 500 }}>
<form autocomplete="off" onsubmit={(event) => event.preventDefault()}>
<div class="ms-4 mt-4">
<SettingSwitch
title={$t('admin.version_check_enabled_description')}
subtitle={$t('admin.version_check_implications')}
bind:checked={configToEdit.newVersionCheck.enabled}
{disabled}
/>
<SettingButtonsRow bind:configToEdit keys={['newVersionCheck']} {disabled} />
</div>
</form>
</div>
</div>

View file

@ -0,0 +1,64 @@
<script lang="ts">
import SettingButtonsRow from '$lib/components/shared-components/settings/SystemConfigButtonRow.svelte';
import SettingInputField from '$lib/components/shared-components/settings/setting-input-field.svelte';
import SettingSwitch from '$lib/components/shared-components/settings/setting-switch.svelte';
import { SettingInputFieldType } from '$lib/constants';
import { featureFlagsManager } from '$lib/managers/feature-flags-manager.svelte';
import { systemConfigManager } from '$lib/managers/system-config-manager.svelte';
import { t } from 'svelte-i18n';
import { fade } from 'svelte/transition';
const disabled = $derived(featureFlagsManager.value.configFile);
const config = $derived(systemConfigManager.value);
let configToEdit = $state(systemConfigManager.cloneValue());
</script>
<div class="mt-2">
<div in:fade={{ duration: 500 }}>
<form autocomplete="off" class="mx-4 mt-4" onsubmit={(event) => event.preventDefault()}>
<div class="ms-4 mt-4 flex flex-col gap-4">
<SettingInputField
inputType={SettingInputFieldType.TEXT}
label={$t('admin.nightly_tasks_start_time_setting')}
description={$t('admin.nightly_tasks_start_time_setting_description')}
bind:value={configToEdit.nightlyTasks.startTime}
required={true}
{disabled}
isEdited={!(configToEdit.nightlyTasks.startTime === config.nightlyTasks.startTime)}
/>
<SettingSwitch
title={$t('admin.nightly_tasks_database_cleanup_setting')}
subtitle={$t('admin.nightly_tasks_database_cleanup_setting_description')}
bind:checked={configToEdit.nightlyTasks.databaseCleanup}
{disabled}
/>
<SettingSwitch
title={$t('admin.nightly_tasks_missing_thumbnails_setting')}
subtitle={$t('admin.nightly_tasks_missing_thumbnails_setting_description')}
bind:checked={configToEdit.nightlyTasks.missingThumbnails}
{disabled}
/>
<SettingSwitch
title={$t('admin.nightly_tasks_cluster_new_faces_setting')}
subtitle={$t('admin.nightly_tasks_cluster_faces_setting_description')}
bind:checked={configToEdit.nightlyTasks.clusterNewFaces}
{disabled}
/>
<SettingSwitch
title={$t('admin.nightly_tasks_generate_memories_setting')}
subtitle={$t('admin.nightly_tasks_generate_memories_setting_description')}
bind:checked={configToEdit.nightlyTasks.generateMemories}
{disabled}
/>
<SettingSwitch
title={$t('admin.nightly_tasks_sync_quota_usage_setting')}
subtitle={$t('admin.nightly_tasks_sync_quota_usage_setting_description')}
bind:checked={configToEdit.nightlyTasks.syncQuotaUsage}
{disabled}
/>
</div>
<SettingButtonsRow bind:configToEdit keys={['nightlyTasks']} {disabled} />
</form>
</div>
</div>

View file

@ -0,0 +1,166 @@
<script lang="ts">
import TemplateSettings from '$lib/components/admin-settings/TemplateSettings.svelte';
import SettingAccordion from '$lib/components/shared-components/settings/setting-accordion.svelte';
import SettingInputField from '$lib/components/shared-components/settings/setting-input-field.svelte';
import SettingSwitch from '$lib/components/shared-components/settings/setting-switch.svelte';
import SettingButtonsRow from '$lib/components/shared-components/settings/SystemConfigButtonRow.svelte';
import { SettingInputFieldType } from '$lib/constants';
import { featureFlagsManager } from '$lib/managers/feature-flags-manager.svelte';
import { systemConfigManager } from '$lib/managers/system-config-manager.svelte';
import { handleSystemConfigSave } from '$lib/services/system-config.service';
import { user } from '$lib/stores/user.store';
import { handleError } from '$lib/utils/handle-error';
import { sendTestEmailAdmin } from '@immich/sdk';
import { Button, LoadingSpinner, toastManager } from '@immich/ui';
import { t } from 'svelte-i18n';
import { fade } from 'svelte/transition';
const disabled = $derived(featureFlagsManager.value.configFile);
const config = $derived(systemConfigManager.value);
let configToEdit = $state(systemConfigManager.cloneValue());
let isSending = $state(false);
const handleSendTestEmail = async () => {
if (isSending) {
return;
}
isSending = true;
try {
await sendTestEmailAdmin({
systemConfigSmtpDto: {
enabled: configToEdit.notifications.smtp.enabled,
transport: {
host: configToEdit.notifications.smtp.transport.host,
port: configToEdit.notifications.smtp.transport.port,
secure: configToEdit.notifications.smtp.transport.secure,
username: configToEdit.notifications.smtp.transport.username,
password: configToEdit.notifications.smtp.transport.password,
ignoreCert: configToEdit.notifications.smtp.transport.ignoreCert,
},
from: configToEdit.notifications.smtp.from,
replyTo: configToEdit.notifications.smtp.from,
},
});
toastManager.success($t('admin.notification_email_test_email_sent', { values: { email: $user.email } }));
if (!disabled) {
await handleSystemConfigSave({ notifications: configToEdit.notifications });
}
} catch (error) {
handleError(error, $t('admin.notification_email_test_email_failed'));
} finally {
isSending = false;
}
};
</script>
<div>
<div in:fade={{ duration: 500 }}>
<form autocomplete="off" class="mt-4" onsubmit={(event) => event.preventDefault()}>
<div class="flex flex-col gap-4">
<SettingAccordion key="email" title={$t('email')} subtitle={$t('admin.notification_email_setting_description')}>
<div class="ms-4 mt-4 flex flex-col gap-4">
<SettingSwitch
title={$t('admin.notification_enable_email_notifications')}
{disabled}
bind:checked={configToEdit.notifications.smtp.enabled}
/>
<hr />
<SettingInputField
inputType={SettingInputFieldType.TEXT}
required
label={$t('host')}
description={$t('admin.notification_email_host_description')}
disabled={disabled || !configToEdit.notifications.smtp.enabled}
bind:value={configToEdit.notifications.smtp.transport.host}
isEdited={configToEdit.notifications.smtp.transport.host !== config.notifications.smtp.transport.host}
/>
<SettingInputField
inputType={SettingInputFieldType.NUMBER}
required
label={$t('port')}
description={$t('admin.notification_email_port_description')}
disabled={disabled || !configToEdit.notifications.smtp.enabled}
bind:value={configToEdit.notifications.smtp.transport.port}
isEdited={configToEdit.notifications.smtp.transport.port !== config.notifications.smtp.transport.port}
/>
<SettingInputField
inputType={SettingInputFieldType.TEXT}
label={$t('username')}
description={$t('admin.notification_email_username_description')}
disabled={disabled || !configToEdit.notifications.smtp.enabled}
bind:value={configToEdit.notifications.smtp.transport.username}
isEdited={configToEdit.notifications.smtp.transport.username !==
config.notifications.smtp.transport.username}
/>
<SettingInputField
inputType={SettingInputFieldType.PASSWORD}
label={$t('password')}
description={$t('admin.notification_email_password_description')}
disabled={disabled || !configToEdit.notifications.smtp.enabled}
bind:value={configToEdit.notifications.smtp.transport.password}
isEdited={configToEdit.notifications.smtp.transport.password !==
config.notifications.smtp.transport.password}
/>
<SettingSwitch
title={$t('admin.notification_email_secure')}
subtitle={$t('admin.notification_email_secure_description')}
disabled={disabled || !configToEdit.notifications.smtp.enabled}
bind:checked={configToEdit.notifications.smtp.transport.secure}
/>
<SettingSwitch
title={$t('admin.notification_email_ignore_certificate_errors')}
subtitle={$t('admin.notification_email_ignore_certificate_errors_description')}
disabled={disabled || !configToEdit.notifications.smtp.enabled}
bind:checked={configToEdit.notifications.smtp.transport.ignoreCert}
/>
<hr />
<SettingInputField
inputType={SettingInputFieldType.TEXT}
required
label={$t('admin.notification_email_from_address')}
description={$t('admin.notification_email_from_address_description')}
disabled={disabled || !configToEdit.notifications.smtp.enabled}
bind:value={configToEdit.notifications.smtp.from}
isEdited={configToEdit.notifications.smtp.from !== config.notifications.smtp.from}
/>
<div class="flex gap-2 place-items-center">
<Button
size="small"
shape="round"
disabled={!configToEdit.notifications.smtp.enabled}
onclick={handleSendTestEmail}
>
{#if disabled}
{$t('admin.notification_email_test_email')}
{:else}
{$t('admin.notification_email_sent_test_email_button')}
{/if}
</Button>
{#if isSending}
<LoadingSpinner />
{/if}
</div>
</div>
</SettingAccordion>
</div>
</form>
</div>
<TemplateSettings bind:config={configToEdit} />
<SettingButtonsRow bind:configToEdit keys={['notifications', 'templates']} {disabled} />
</div>

View file

@ -0,0 +1,49 @@
<script lang="ts">
import SettingButtonsRow from '$lib/components/shared-components/settings/SystemConfigButtonRow.svelte';
import SettingInputField from '$lib/components/shared-components/settings/setting-input-field.svelte';
import SettingSwitch from '$lib/components/shared-components/settings/setting-switch.svelte';
import { SettingInputFieldType } from '$lib/constants';
import { featureFlagsManager } from '$lib/managers/feature-flags-manager.svelte';
import { systemConfigManager } from '$lib/managers/system-config-manager.svelte';
import { t } from 'svelte-i18n';
import { fade } from 'svelte/transition';
const disabled = $derived(featureFlagsManager.value.configFile);
const config = $derived(systemConfigManager.value);
let configToEdit = $state(systemConfigManager.cloneValue());
</script>
<div>
<div in:fade={{ duration: 500 }}>
<form autocomplete="off" onsubmit={(event) => event.preventDefault()}>
<div class="mt-4 ms-4">
<SettingInputField
inputType={SettingInputFieldType.TEXT}
label={$t('admin.server_external_domain_settings')}
description={$t('admin.server_external_domain_settings_description')}
bind:value={configToEdit.server.externalDomain}
isEdited={configToEdit.server.externalDomain !== config.server.externalDomain}
/>
<SettingInputField
inputType={SettingInputFieldType.TEXT}
label={$t('admin.server_welcome_message')}
description={$t('admin.server_welcome_message_description')}
bind:value={configToEdit.server.loginPageMessage}
isEdited={configToEdit.server.loginPageMessage !== config.server.loginPageMessage}
/>
<SettingSwitch
title={$t('admin.server_public_users')}
subtitle={$t('admin.server_public_users_description')}
{disabled}
bind:checked={configToEdit.server.publicUsers}
/>
<div class="ms-4">
<SettingButtonsRow bind:configToEdit keys={['server']} {disabled} />
</div>
</div>
</form>
</div>
</div>

View file

@ -0,0 +1,283 @@
<script lang="ts">
import SupportedDatetimePanel from '$lib/components/admin-settings/SupportedDatetimePanel.svelte';
import SupportedVariablesPanel from '$lib/components/admin-settings/SupportedVariablesPanel.svelte';
import SettingButtonsRow from '$lib/components/shared-components/settings/SystemConfigButtonRow.svelte';
import SettingInputField from '$lib/components/shared-components/settings/setting-input-field.svelte';
import SettingSwitch from '$lib/components/shared-components/settings/setting-switch.svelte';
import { SettingInputFieldType } from '$lib/constants';
import FormatMessage from '$lib/elements/FormatMessage.svelte';
import { featureFlagsManager } from '$lib/managers/feature-flags-manager.svelte';
import { systemConfigManager } from '$lib/managers/system-config-manager.svelte';
import { Route } from '$lib/route';
import { handleSystemConfigSave } from '$lib/services/system-config.service';
import { user } from '$lib/stores/user.store';
import { getStorageTemplateOptions, type SystemConfigTemplateStorageOptionDto } from '@immich/sdk';
import { Heading, LoadingSpinner, Text } from '@immich/ui';
import handlebar from 'handlebars';
import * as luxon from 'luxon';
import { onDestroy } from 'svelte';
import { t } from 'svelte-i18n';
import { createBubbler, preventDefault } from 'svelte/legacy';
import { fade } from 'svelte/transition';
type Props = {
minified?: boolean;
duration?: number;
saveOnClose?: boolean;
};
const { minified = false, duration = 500, saveOnClose = false }: Props = $props();
const disabled = $derived(featureFlagsManager.value.configFile);
const config = $derived(systemConfigManager.value);
let configToEdit = $state(systemConfigManager.cloneValue());
const bubble = createBubbler();
let templateOptions: SystemConfigTemplateStorageOptionDto | undefined = $state();
let selectedPreset = $state('');
const getTemplateOptions = async () => {
templateOptions = await getStorageTemplateOptions();
selectedPreset = config.storageTemplate.template;
};
const getSupportDateTimeFormat = () => getStorageTemplateOptions();
const renderTemplate = (templateString: string) => {
if (!templateOptions) {
return '';
}
const template = handlebar.compile(templateString, {
knownHelpers: undefined,
});
const substitutions: Record<string, string> = {
filename: 'IMAGE_56437',
ext: 'jpg',
filetype: 'IMG',
filetypefull: 'IMAGE',
assetId: 'a8312960-e277-447d-b4ea-56717ccba856',
assetIdShort: '56717ccba856',
album: $t('album_name'),
make: 'FUJIFILM',
model: 'X-T50',
lensModel: 'XF27mm F2.8 R WR',
};
const dt = luxon.DateTime.fromISO(new Date('2022-02-03T04:56:05.250').toISOString());
const albumStartTime = luxon.DateTime.fromISO(new Date('2021-12-31T05:32:41.750').toISOString());
const albumEndTime = luxon.DateTime.fromISO(new Date('2023-05-06T09:15:17.100').toISOString());
const dateTokens = [
...templateOptions.yearOptions,
...templateOptions.monthOptions,
...templateOptions.weekOptions,
...templateOptions.dayOptions,
...templateOptions.hourOptions,
...templateOptions.minuteOptions,
...templateOptions.secondOptions,
];
for (const token of dateTokens) {
substitutions[token] = dt.toFormat(token);
substitutions['album-startDate-' + token] = albumStartTime.toFormat(token);
substitutions['album-endDate-' + token] = albumEndTime.toFormat(token);
}
return template(substitutions);
};
const handlePresetSelection = () => {
configToEdit.storageTemplate.template = selectedPreset;
};
let parsedTemplate = $derived(() => {
try {
return renderTemplate(configToEdit.storageTemplate.template);
} catch {
return 'error';
}
});
onDestroy(async () => {
if (saveOnClose) {
await handleSystemConfigSave({ storageTemplate: configToEdit.storageTemplate });
}
});
</script>
<section class="dark:text-immich-dark-fg mt-2">
<div in:fade={{ duration }} class="mx-4 flex flex-col gap-4 py-4">
<p class="text-sm dark:text-immich-dark-fg">
<FormatMessage key="admin.storage_template_more_details">
{#snippet children({ tag, message })}
{#if tag === 'template-link'}
<a
href="https://docs.immich.app/administration/storage-template"
class="underline"
target="_blank"
rel="noreferrer"
>
{message}
</a>
{:else if tag === 'implications-link'}
<a
href="https://docs.immich.app/administration/backup-and-restore#asset-types-and-storage-locations"
class="underline"
target="_blank"
rel="noreferrer"
>
{message}
</a>
{/if}
{/snippet}
</FormatMessage>
</p>
</div>
{#await getTemplateOptions() then}
<div id="directory-path-builder" class="flex flex-col gap-4 {minified ? '' : 'ms-4 mt-4'}">
<SettingSwitch
title={$t('admin.storage_template_enable_description')}
{disabled}
bind:checked={configToEdit.storageTemplate.enabled}
isEdited={!(configToEdit.storageTemplate.enabled === config.storageTemplate.enabled)}
/>
{#if !minified}
<SettingSwitch
title={$t('admin.storage_template_hash_verification_enabled')}
{disabled}
subtitle={$t('admin.storage_template_hash_verification_enabled_description')}
bind:checked={configToEdit.storageTemplate.hashVerificationEnabled}
isEdited={!(
configToEdit.storageTemplate.hashVerificationEnabled === config.storageTemplate.hashVerificationEnabled
)}
/>
{/if}
{#if configToEdit.storageTemplate.enabled}
<hr />
<Heading size="tiny" color="primary">
{$t('variables')}
</Heading>
<section class="support-date">
{#await getSupportDateTimeFormat()}
<LoadingSpinner />
{:then options}
<div transition:fade={{ duration: 200 }}>
<SupportedDatetimePanel {options} />
</div>
{/await}
</section>
<section class="support-date">
<SupportedVariablesPanel />
</section>
<div class="flex flex-col mt-2">
<!-- <h3 class="text-base font-medium text-primary">{$t('template')}</h3> -->
<Heading size="tiny" color="primary">
{$t('template')}
</Heading>
<div class="my-2">
<Text size="small">{$t('preview')}</Text>
</div>
<p class="text-sm">
<FormatMessage
key="admin.storage_template_path_length"
values={{ length: parsedTemplate().length + $user.id.length + 'UPLOAD_LOCATION'.length, limit: 260 }}
>
{#snippet children({ message })}
<span class="font-semibold text-primary">{message}</span>
{/snippet}
</FormatMessage>
</p>
<p class="text-sm">
<FormatMessage key="admin.storage_template_user_label" values={{ label: $user.storageLabel || $user.id }}>
{#snippet children({ message })}
<code class="text-primary">{message}</code>
{/snippet}
</FormatMessage>
</p>
<p class="p-4 py-2 mt-2 text-xs bg-gray-200 rounded-lg dark:bg-gray-700 dark:text-immich-dark-fg">
<span class="text-immich-fg/25 dark:text-immich-dark-fg/50"
>UPLOAD_LOCATION/library/{$user.storageLabel || $user.id}</span
>/{parsedTemplate()}.jpg
</p>
<form autocomplete="off" class="flex flex-col" onsubmit={preventDefault(bubble('submit'))}>
<div class="flex flex-col my-2">
{#if templateOptions}
<label class="font-medium text-primary text-sm" for="preset-select">
{$t('preset')}
</label>
<select
class="immich-form-input p-2 mt-2 text-sm rounded-lg bg-slate-200 hover:cursor-pointer dark:bg-gray-600"
disabled={disabled || !configToEdit.storageTemplate.enabled}
name="presets"
id="preset-select"
bind:value={selectedPreset}
onchange={handlePresetSelection}
>
{#each templateOptions.presetOptions as preset (preset)}
<option value={preset}>{renderTemplate(preset)}</option>
{/each}
</select>
{/if}
</div>
<div class="flex gap-2 align-bottom">
<SettingInputField
label={$t('template')}
disabled={disabled || !configToEdit.storageTemplate.enabled}
required
inputType={SettingInputFieldType.TEXT}
bind:value={configToEdit.storageTemplate.template}
isEdited={!(configToEdit.storageTemplate.template === config.storageTemplate.template)}
/>
<div class="flex-0">
<SettingInputField
label={$t('extension')}
inputType={SettingInputFieldType.TEXT}
value=".jpg"
disabled
/>
</div>
</div>
{#if !minified}
<div id="migration-info" class="mt-2 text-sm">
<Heading size="tiny" color="primary">
{$t('notes')}
</Heading>
<section class="flex flex-col gap-2">
<p>
<FormatMessage
key="admin.storage_template_migration_info"
values={{ job: $t('admin.storage_template_migration_job') }}
>
{#snippet children({ message })}
<a href={Route.queues()} class="text-primary">{message}</a>
{/snippet}
</FormatMessage>
</p>
</section>
</div>
{/if}
</form>
</div>
{/if}
{#if !minified}
<SettingButtonsRow bind:configToEdit keys={['storageTemplate']} {disabled} />
{/if}
</div>
{/await}
</section>

View file

@ -0,0 +1,95 @@
<script lang="ts">
import { locale } from '$lib/stores/preferences.store';
import type { SystemConfigTemplateStorageOptionDto } from '@immich/sdk';
import { Card, CardBody, CardHeader, Text } from '@immich/ui';
import { DateTime } from 'luxon';
import { t } from 'svelte-i18n';
interface Props {
options: SystemConfigTemplateStorageOptionDto;
}
let { options }: Props = $props();
const getLuxonExample = (format: string) => {
return DateTime.fromISO('2022-02-15T20:03:05.250Z', { locale: $locale }).toFormat(format);
};
</script>
<Text size="small">{$t('date_and_time')}</Text>
<!-- eslint-disable svelte/no-useless-mustaches -->
<Card class="mt-2 text-sm bg-light-50 shadow-none">
<CardHeader>
<Text class="mb-1">{$t('admin.storage_template_date_time_description')}</Text>
<Text color="primary"
>{$t('admin.storage_template_date_time_sample', { values: { date: '2022-02-03T20:03:05.250' } })}</Text
>
</CardHeader>
<CardBody>
<div class="grid grid-cols-1 gap-6 sm:grid-cols-3 md:grid-cols-4">
<div>
<Text fontWeight="medium" size="tiny" color="primary" class="mb-1">{$t('year')}</Text>
<ul>
{#each options.yearOptions as yearFormat, index (index)}
<li>{'{{'}{yearFormat}{'}}'} - {getLuxonExample(yearFormat)}</li>
{/each}
</ul>
</div>
<div>
<Text fontWeight="medium" size="tiny" color="primary" class="mb-1">{$t('month')}</Text>
<ul>
{#each options.monthOptions as monthFormat, index (index)}
<li>{'{{'}{monthFormat}{'}}'} - {getLuxonExample(monthFormat)}</li>
{/each}
</ul>
</div>
<div>
<Text fontWeight="medium" size="tiny" color="primary" class="mb-1">{$t('week')}</Text>
<ul>
{#each options.weekOptions as weekFormat, index (index)}
<li>{'{{'}{weekFormat}{'}}'} - {getLuxonExample(weekFormat)}</li>
{/each}
</ul>
</div>
<div>
<Text fontWeight="medium" size="tiny" color="primary" class="mb-1">{$t('day')}</Text>
<ul>
{#each options.dayOptions as dayFormat, index (index)}
<li>{'{{'}{dayFormat}{'}}'} - {getLuxonExample(dayFormat)}</li>
{/each}
</ul>
</div>
<div>
<Text fontWeight="medium" size="tiny" color="primary" class="mb-1">{$t('hour')}</Text>
<ul>
{#each options.hourOptions as dayFormat, index (index)}
<li>{'{{'}{dayFormat}{'}}'} - {getLuxonExample(dayFormat)}</li>
{/each}
</ul>
</div>
<div>
<Text fontWeight="medium" size="tiny" color="primary" class="mb-1">{$t('minute')}</Text>
<ul>
{#each options.minuteOptions as dayFormat, index (index)}
<li>{'{{'}{dayFormat}{'}}'} - {getLuxonExample(dayFormat)}</li>
{/each}
</ul>
</div>
<div>
<Text fontWeight="medium" size="tiny" color="primary" class="mb-1">{$t('second')}</Text>
<ul>
{#each options.secondOptions as dayFormat, index (index)}
<li>{'{{'}{dayFormat}{'}}'} - {getLuxonExample(dayFormat)}</li>
{/each}
</ul>
</div>
</div>
</CardBody>
</Card>

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