Merge branch 'immich-main' into feat/shared-album-owner-labels

This commit is contained in:
CJPeckover
2025-11-23 21:01:07 -05:00
1615 changed files with 124744 additions and 35454 deletions
@@ -0,0 +1,16 @@
<script lang="ts">
import type { ActionItem } from '$lib/types';
import { IconButton, type IconButtonProps } from '@immich/ui';
type Props = {
action: ActionItem;
};
const { action }: Props = $props();
const { title, icon, color = 'secondary', props: other = {}, onSelect } = $derived(action);
const onclick = (event: Event) => onSelect?.({ event, item: action });
</script>
{#if action.$if?.() ?? true}
<IconButton variant="ghost" {color} shape="round" {...other as IconButtonProps} {icon} aria-label={title} {onclick} />
{/if}
@@ -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>
@@ -0,0 +1,18 @@
<script lang="ts">
import type { ActionItem } from '$lib/types';
import { Button, type ButtonProps, Text } from '@immich/ui';
type Props = {
action: ActionItem;
};
const { action }: Props = $props();
const { title, icon, color = 'secondary', props: other = {}, onSelect } = $derived(action);
const onclick = (event: Event) => onSelect?.({ event, item: action });
</script>
{#if action.$if?.() ?? true}
<Button variant="ghost" size="small" {color} {...other as ButtonProps} leadingIcon={icon} {onclick}>
<Text class="hidden md:block">{title}</Text>
</Button>
{/if}
+33
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 = Partial<{
[K in keyof Events as `on${K}`]: (...args: Events[K]) => void;
}>;
const props: Props = $props();
const unsubscribes: Array<() => void> = [];
onMount(() => {
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;
eventManager.on(...args);
unsubscribes.push(() => eventManager.off(...args));
}
return () => {
for (const unsubscribe of unsubscribes) {
unsubscribe();
}
};
});
</script>
@@ -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>
@@ -0,0 +1,76 @@
<script lang="ts">
import SettingSelect from '$lib/components/shared-components/settings/setting-select.svelte';
import { locale } from '$lib/stores/preferences.store';
import { minBy } 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={[...new Set([...expiredDateOptions, getExpirationOption(createdAt, expiresAt)])]}
label={$t('expire_after')}
disabled={expiresAt !== null && DateTime.fromISO(expiresAt) < DateTime.now()}
number={true}
/>
</div>
+16
View File
@@ -0,0 +1,16 @@
<script lang="ts">
import type { ActionItem } from '$lib/types';
import { IconButton, type IconButtonProps } from '@immich/ui';
type Props = {
action: ActionItem;
};
const { action }: Props = $props();
const { title, icon, props: other = {}, onSelect } = $derived(action);
const onclick = (event: Event) => onSelect?.({ event, item: action });
</script>
{#if action.$if?.() ?? true}
<IconButton shape="round" color="primary" {...other as IconButtonProps} {icon} aria-label={title} {onclick} />
{/if}
+33
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>
@@ -1,87 +0,0 @@
<script lang="ts">
import {
NotificationType,
notificationController,
} from '$lib/components/shared-components/notification/notification';
import { handleError } from '$lib/utils/handle-error';
import { getConfig, getConfigDefaults, updateConfig, type SystemConfigDto } from '@immich/sdk';
import { retrieveServerConfig } from '$lib/stores/server-config.store';
import { cloneDeep, isEqual } from 'lodash-es';
import { onMount } from 'svelte';
import type { SettingsResetOptions } from './admin-settings';
import { t } from 'svelte-i18n';
interface Props {
config: SystemConfigDto;
children: import('svelte').Snippet<[{ savedConfig: SystemConfigDto; defaultConfig: SystemConfigDto }]>;
}
let { config = $bindable(), children }: Props = $props();
let savedConfig: SystemConfigDto | undefined = $state();
let defaultConfig: SystemConfigDto | undefined = $state();
export const handleReset = async (options: SettingsResetOptions) => {
await (options.default ? resetToDefault(options.configKeys) : reset(options.configKeys));
};
export const handleSave = async (update: Partial<SystemConfigDto>) => {
let systemConfigDto = {
...savedConfig,
...update,
} as SystemConfigDto;
if (isEqual(systemConfigDto, savedConfig)) {
return;
}
try {
const newConfig = await updateConfig({
systemConfigDto,
});
config = cloneDeep(newConfig);
savedConfig = cloneDeep(newConfig);
notificationController.show({ message: $t('settings_saved'), type: NotificationType.Info });
await retrieveServerConfig();
} catch (error) {
handleError(error, $t('errors.unable_to_save_settings'));
}
};
const reset = async (configKeys: Array<keyof SystemConfigDto>) => {
const resetConfig = await getConfig();
for (const key of configKeys) {
config = { ...config, [key]: resetConfig[key] };
}
notificationController.show({
message: $t('admin.reset_settings_to_recent_saved'),
type: NotificationType.Info,
});
};
const resetToDefault = (configKeys: Array<keyof SystemConfigDto>) => {
if (!defaultConfig) {
return;
}
for (const key of configKeys) {
config = { ...config, [key]: defaultConfig[key] };
}
notificationController.show({
message: $t('admin.reset_settings_to_default'),
type: NotificationType.Info,
});
};
onMount(async () => {
[savedConfig, defaultConfig] = await Promise.all([getConfig(), getConfigDefaults()]);
});
</script>
{#if savedConfig && defaultConfig}
{@render children({ savedConfig, defaultConfig })}
{/if}
@@ -1,15 +0,0 @@
import type { ResetOptions } from '$lib/utils/dipatch';
import type { SystemConfigDto } from '@immich/sdk';
export type SettingsResetOptions = ResetOptions & { configKeys: Array<keyof SystemConfigDto> };
export type SettingsResetEvent = (options: SettingsResetOptions) => void;
export type SettingsSaveEvent = (config: Partial<SystemConfigDto>) => void;
export type SettingsComponentProps = {
disabled?: boolean;
defaultConfig: SystemConfigDto;
config: SystemConfigDto;
savedConfig: SystemConfigDto;
onReset: SettingsResetEvent;
onSave: SettingsSaveEvent;
};
@@ -1,84 +0,0 @@
<script lang="ts">
import { getJobName } from '$lib/utils';
import { JobName, type SystemConfigDto, type SystemConfigJobDto } from '@immich/sdk';
import { isEqual } from 'lodash-es';
import { fade } from 'svelte/transition';
import type { SettingsResetEvent, SettingsSaveEvent } from '../admin-settings';
import SettingButtonsRow from '$lib/components/shared-components/settings/setting-buttons-row.svelte';
import SettingInputField from '$lib/components/shared-components/settings/setting-input-field.svelte';
import { t } from 'svelte-i18n';
import { SettingInputFieldType } from '$lib/constants';
interface Props {
savedConfig: SystemConfigDto;
defaultConfig: SystemConfigDto;
config: SystemConfigDto;
disabled?: boolean;
onReset: SettingsResetEvent;
onSave: SettingsSaveEvent;
}
let { savedConfig, defaultConfig, config = $bindable(), disabled = false, onReset, onSave }: Props = $props();
const jobNames = [
JobName.ThumbnailGeneration,
JobName.MetadataExtraction,
JobName.Library,
JobName.Sidecar,
JobName.SmartSearch,
JobName.FaceDetection,
JobName.FacialRecognition,
JobName.VideoConversion,
JobName.StorageTemplateMigration,
JobName.Migration,
];
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function isSystemConfigJobDto(jobName: any): jobName is keyof SystemConfigJobDto {
return jobName in config.job;
}
const onsubmit = (event: Event) => {
event.preventDefault();
};
</script>
<div>
<div in:fade={{ duration: 500 }}>
<form autocomplete="off" {onsubmit}>
{#each jobNames as jobName (jobName)}
<div class="ms-4 mt-4 flex flex-col gap-4">
{#if isSystemConfigJobDto(jobName)}
<SettingInputField
inputType={SettingInputFieldType.NUMBER}
{disabled}
label={$t('admin.job_concurrency', { values: { job: $getJobName(jobName) } })}
description=""
bind:value={config.job[jobName].concurrency}
required={true}
isEdited={!(config.job[jobName].concurrency == savedConfig.job[jobName].concurrency)}
/>
{:else}
<SettingInputField
inputType={SettingInputFieldType.NUMBER}
label={$t('admin.job_concurrency', { values: { job: $getJobName(jobName) } })}
description=""
value="1"
disabled={true}
title={$t('admin.job_not_concurrency_safe')}
/>
{/if}
</div>
{/each}
<div class="ms-4">
<SettingButtonsRow
onReset={(options) => onReset({ ...options, configKeys: ['job'] })}
onSave={() => onSave({ job: config.job })}
showResetToDefault={!isEqual(savedConfig.job, defaultConfig.job)}
{disabled}
/>
</div>
</form>
</div>
</div>
@@ -1,62 +0,0 @@
<script lang="ts">
import { LogLevel, type SystemConfigDto } from '@immich/sdk';
import { isEqual } from 'lodash-es';
import { fade } from 'svelte/transition';
import type { SettingsResetEvent, SettingsSaveEvent } from '../admin-settings';
import SettingButtonsRow from '$lib/components/shared-components/settings/setting-buttons-row.svelte';
import SettingSwitch from '$lib/components/shared-components/settings/setting-switch.svelte';
import SettingSelect from '$lib/components/shared-components/settings/setting-select.svelte';
import { t } from 'svelte-i18n';
interface Props {
savedConfig: SystemConfigDto;
defaultConfig: SystemConfigDto;
config: SystemConfigDto;
disabled?: boolean;
onReset: SettingsResetEvent;
onSave: SettingsSaveEvent;
}
let { savedConfig, defaultConfig, config = $bindable(), disabled = false, onReset, onSave }: Props = $props();
const onsubmit = (event: Event) => {
event.preventDefault();
};
</script>
<div>
<div in:fade={{ duration: 500 }}>
<form autocomplete="off" {onsubmit}>
<div class="ms-4 mt-4 flex flex-col gap-4">
<SettingSwitch
title={$t('admin.logging_enable_description')}
{disabled}
bind:checked={config.logging.enabled}
/>
<SettingSelect
label={$t('level')}
desc={$t('admin.logging_level_description')}
bind:value={config.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={config.logging.level !== savedConfig.logging.level}
disabled={disabled || !config.logging.enabled}
/>
<SettingButtonsRow
onReset={(options) => onReset({ ...options, configKeys: ['logging'] })}
onSave={() => onSave({ logging: config.logging })}
showResetToDefault={!isEqual(savedConfig.logging, defaultConfig.logging)}
{disabled}
/>
</div>
</form>
</div>
</div>
@@ -1,229 +0,0 @@
<script lang="ts">
import FormatMessage from '$lib/components/i18n/format-message.svelte';
import SettingAccordion from '$lib/components/shared-components/settings/setting-accordion.svelte';
import SettingButtonsRow from '$lib/components/shared-components/settings/setting-buttons-row.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 { featureFlags } from '$lib/stores/server-config.store';
import type { SystemConfigDto } from '@immich/sdk';
import { Button, IconButton } from '@immich/ui';
import { mdiMinusCircle } from '@mdi/js';
import { isEqual } from 'lodash-es';
import { t } from 'svelte-i18n';
import { fade } from 'svelte/transition';
import type { SettingsResetEvent, SettingsSaveEvent } from '../admin-settings';
interface Props {
savedConfig: SystemConfigDto;
defaultConfig: SystemConfigDto;
config: SystemConfigDto;
disabled?: boolean;
onReset: SettingsResetEvent;
onSave: SettingsSaveEvent;
}
let { savedConfig, defaultConfig, config = $bindable(), disabled = false, onReset, onSave }: Props = $props();
const onsubmit = (event: Event) => {
event.preventDefault();
};
</script>
<div class="mt-2">
<div in:fade={{ duration: 500 }}>
<form autocomplete="off" {onsubmit} class="mx-4 mt-4">
<div class="flex flex-col gap-4">
<SettingSwitch
title={$t('admin.machine_learning_enabled')}
subtitle={$t('admin.machine_learning_enabled_description')}
{disabled}
bind:checked={config.machineLearning.enabled}
/>
<hr />
<div>
{#each config.machineLearning.urls as _, i (i)}
{#snippet removeButton()}
{#if config.machineLearning.urls.length > 1}
<IconButton
size="large"
shape="round"
color="danger"
aria-label=""
onclick={() => config.machineLearning.urls.splice(i, 1)}
icon={mdiMinusCircle}
/>
{/if}
{/snippet}
<SettingInputField
inputType={SettingInputFieldType.TEXT}
label={i === 0 ? $t('url') : undefined}
description={i === 0 ? $t('admin.machine_learning_url_description') : undefined}
bind:value={config.machineLearning.urls[i]}
required={i === 0}
disabled={disabled || !config.machineLearning.enabled}
isEdited={i === 0 && !isEqual(config.machineLearning.urls, savedConfig.machineLearning.urls)}
trailingSnippet={removeButton}
/>
{/each}
</div>
<Button
class="mb-2"
size="small"
shape="round"
onclick={() => config.machineLearning.urls.splice(0, 0, '')}
disabled={disabled || !config.machineLearning.enabled}>{$t('add_url')}</Button
>
</div>
<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={config.machineLearning.clip.enabled}
disabled={disabled || !config.machineLearning.enabled}
/>
<hr />
<SettingInputField
inputType={SettingInputFieldType.TEXT}
label={$t('admin.machine_learning_clip_model')}
bind:value={config.machineLearning.clip.modelName}
required={true}
disabled={disabled || !config.machineLearning.enabled || !config.machineLearning.clip.enabled}
isEdited={config.machineLearning.clip.modelName !== savedConfig.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={config.machineLearning.duplicateDetection.enabled}
disabled={disabled || !config.machineLearning.enabled || !config.machineLearning.clip.enabled}
/>
<hr />
<SettingInputField
inputType={SettingInputFieldType.NUMBER}
label={$t('admin.machine_learning_max_detection_distance')}
bind:value={config.machineLearning.duplicateDetection.maxDistance}
step="0.0005"
min={0.001}
max={0.1}
description={$t('admin.machine_learning_max_detection_distance_description')}
disabled={disabled || !$featureFlags.duplicateDetection}
isEdited={config.machineLearning.duplicateDetection.maxDistance !==
savedConfig.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={config.machineLearning.facialRecognition.enabled}
disabled={disabled || !config.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={config.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 || !config.machineLearning.enabled || !config.machineLearning.facialRecognition.enabled}
isEdited={config.machineLearning.facialRecognition.modelName !==
savedConfig.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={config.machineLearning.facialRecognition.minScore}
step="0.01"
min={0.1}
max={1}
disabled={disabled || !config.machineLearning.enabled || !config.machineLearning.facialRecognition.enabled}
isEdited={config.machineLearning.facialRecognition.minScore !==
savedConfig.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={config.machineLearning.facialRecognition.maxDistance}
step="0.01"
min={0.1}
max={2}
disabled={disabled || !config.machineLearning.enabled || !config.machineLearning.facialRecognition.enabled}
isEdited={config.machineLearning.facialRecognition.maxDistance !==
savedConfig.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={config.machineLearning.facialRecognition.minFaces}
step="1"
min={1}
disabled={disabled || !config.machineLearning.enabled || !config.machineLearning.facialRecognition.enabled}
isEdited={config.machineLearning.facialRecognition.minFaces !==
savedConfig.machineLearning.facialRecognition.minFaces}
/>
</div>
</SettingAccordion>
<SettingButtonsRow
onReset={(options) => onReset({ ...options, configKeys: ['machineLearning'] })}
onSave={() => onSave({ machineLearning: config.machineLearning })}
showResetToDefault={!isEqual(savedConfig.machineLearning, defaultConfig.machineLearning)}
{disabled}
/>
</form>
</div>
</div>
@@ -1,46 +0,0 @@
<script lang="ts">
import type { SystemConfigDto } from '@immich/sdk';
import { isEqual } from 'lodash-es';
import { fade } from 'svelte/transition';
import type { SettingsResetEvent, SettingsSaveEvent } from '../admin-settings';
import SettingButtonsRow from '$lib/components/shared-components/settings/setting-buttons-row.svelte';
import SettingSwitch from '$lib/components/shared-components/settings/setting-switch.svelte';
import { t } from 'svelte-i18n';
interface Props {
savedConfig: SystemConfigDto;
defaultConfig: SystemConfigDto;
config: SystemConfigDto;
disabled?: boolean;
onReset: SettingsResetEvent;
onSave: SettingsSaveEvent;
}
let { savedConfig, defaultConfig, config = $bindable(), disabled = false, onReset, onSave }: Props = $props();
const onsubmit = (event: Event) => {
event.preventDefault();
};
</script>
<div class="mt-2">
<div in:fade={{ duration: 500 }}>
<form autocomplete="off" {onsubmit} class="mx-4 mt-4">
<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={config.metadata.faces.import}
{disabled}
/>
</div>
<SettingButtonsRow
onReset={(options) => onReset({ ...options, configKeys: ['metadata'] })}
onSave={() => onSave({ metadata: config.metadata })}
showResetToDefault={!isEqual(savedConfig.metadata.faces.import, defaultConfig.metadata.faces.import)}
{disabled}
/>
</form>
</div>
</div>
@@ -1,45 +0,0 @@
<script lang="ts">
import type { SystemConfigDto } from '@immich/sdk';
import { isEqual } from 'lodash-es';
import { fade } from 'svelte/transition';
import type { SettingsResetEvent, SettingsSaveEvent } from '../admin-settings';
import SettingButtonsRow from '$lib/components/shared-components/settings/setting-buttons-row.svelte';
import SettingSwitch from '$lib/components/shared-components/settings/setting-switch.svelte';
import { t } from 'svelte-i18n';
interface Props {
savedConfig: SystemConfigDto;
defaultConfig: SystemConfigDto;
config: SystemConfigDto;
disabled?: boolean;
onReset: SettingsResetEvent;
onSave: SettingsSaveEvent;
}
let { savedConfig, defaultConfig, config = $bindable(), disabled = false, onReset, onSave }: Props = $props();
const onsubmit = (event: Event) => {
event.preventDefault();
};
</script>
<div>
<div in:fade={{ duration: 500 }}>
<form autocomplete="off" {onsubmit}>
<div class="ms-4 mt-4">
<SettingSwitch
title={$t('admin.version_check_enabled_description')}
subtitle={$t('admin.version_check_implications')}
bind:checked={config.newVersionCheck.enabled}
{disabled}
/>
<SettingButtonsRow
onReset={(options) => onReset({ ...options, configKeys: ['newVersionCheck'] })}
onSave={() => onSave({ newVersionCheck: config.newVersionCheck })}
showResetToDefault={!isEqual(savedConfig.newVersionCheck, defaultConfig.newVersionCheck)}
{disabled}
/>
</div>
</form>
</div>
</div>
@@ -1,181 +0,0 @@
<script lang="ts">
import TemplateSettings from '$lib/components/admin-page/settings/template-settings/template-settings.svelte';
import LoadingSpinner from '$lib/components/shared-components/loading-spinner.svelte';
import {
NotificationType,
notificationController,
} from '$lib/components/shared-components/notification/notification';
import SettingAccordion from '$lib/components/shared-components/settings/setting-accordion.svelte';
import SettingButtonsRow from '$lib/components/shared-components/settings/setting-buttons-row.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 { user } from '$lib/stores/user.store';
import { handleError } from '$lib/utils/handle-error';
import { sendTestEmailAdmin, type SystemConfigDto } from '@immich/sdk';
import { Button } from '@immich/ui';
import { isEqual } from 'lodash-es';
import { t } from 'svelte-i18n';
import { fade } from 'svelte/transition';
import type { SettingsResetEvent, SettingsSaveEvent } from '../admin-settings';
interface Props {
savedConfig: SystemConfigDto;
defaultConfig: SystemConfigDto;
config: SystemConfigDto;
disabled?: boolean;
onReset: SettingsResetEvent;
onSave: SettingsSaveEvent;
}
let { savedConfig, defaultConfig, config = $bindable(), disabled = false, onReset, onSave }: Props = $props();
let isSending = $state(false);
const handleSendTestEmail = async () => {
if (isSending) {
return;
}
isSending = true;
try {
await sendTestEmailAdmin({
systemConfigSmtpDto: {
enabled: config.notifications.smtp.enabled,
transport: {
host: config.notifications.smtp.transport.host,
port: config.notifications.smtp.transport.port,
username: config.notifications.smtp.transport.username,
password: config.notifications.smtp.transport.password,
ignoreCert: config.notifications.smtp.transport.ignoreCert,
},
from: config.notifications.smtp.from,
replyTo: config.notifications.smtp.from,
},
});
notificationController.show({
type: NotificationType.Info,
message: $t('admin.notification_email_test_email_sent', { values: { email: $user.email } }),
});
if (!disabled) {
onSave({ notifications: config.notifications });
}
} catch (error) {
handleError(error, $t('admin.notification_email_test_email_failed'));
} finally {
isSending = false;
}
};
const onsubmit = (event: Event) => {
event.preventDefault();
};
</script>
<div>
<div in:fade={{ duration: 500 }}>
<form autocomplete="off" {onsubmit} class="mt-4">
<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={config.notifications.smtp.enabled}
/>
<hr />
<SettingInputField
inputType={SettingInputFieldType.TEXT}
required
label={$t('host')}
description={$t('admin.notification_email_host_description')}
disabled={disabled || !config.notifications.smtp.enabled}
bind:value={config.notifications.smtp.transport.host}
isEdited={config.notifications.smtp.transport.host !== savedConfig.notifications.smtp.transport.host}
/>
<SettingInputField
inputType={SettingInputFieldType.NUMBER}
required
label={$t('port')}
description={$t('admin.notification_email_port_description')}
disabled={disabled || !config.notifications.smtp.enabled}
bind:value={config.notifications.smtp.transport.port}
isEdited={config.notifications.smtp.transport.port !== savedConfig.notifications.smtp.transport.port}
/>
<SettingInputField
inputType={SettingInputFieldType.TEXT}
label={$t('username')}
description={$t('admin.notification_email_username_description')}
disabled={disabled || !config.notifications.smtp.enabled}
bind:value={config.notifications.smtp.transport.username}
isEdited={config.notifications.smtp.transport.username !==
savedConfig.notifications.smtp.transport.username}
/>
<SettingInputField
inputType={SettingInputFieldType.PASSWORD}
label={$t('password')}
description={$t('admin.notification_email_password_description')}
disabled={disabled || !config.notifications.smtp.enabled}
bind:value={config.notifications.smtp.transport.password}
isEdited={config.notifications.smtp.transport.password !==
savedConfig.notifications.smtp.transport.password}
/>
<SettingSwitch
title={$t('admin.notification_email_ignore_certificate_errors')}
subtitle={$t('admin.notification_email_ignore_certificate_errors_description')}
disabled={disabled || !config.notifications.smtp.enabled}
bind:checked={config.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 || !config.notifications.smtp.enabled}
bind:value={config.notifications.smtp.from}
isEdited={config.notifications.smtp.from !== savedConfig.notifications.smtp.from}
/>
<div class="flex gap-2 place-items-center">
<Button
size="small"
shape="round"
disabled={!config.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 {config} {savedConfig} />
<SettingButtonsRow
onReset={(options) => onReset({ ...options, configKeys: ['notifications', 'templates'] })}
onSave={() => onSave({ notifications: config.notifications, templates: config.templates })}
showResetToDefault={!isEqual(savedConfig, defaultConfig)}
{disabled}
/>
</div>
@@ -1,66 +0,0 @@
<script lang="ts">
import type { SystemConfigDto } from '@immich/sdk';
import { isEqual } from 'lodash-es';
import { fade } from 'svelte/transition';
import type { SettingsResetEvent, SettingsSaveEvent } from '../admin-settings';
import SettingInputField from '$lib/components/shared-components/settings/setting-input-field.svelte';
import SettingButtonsRow from '$lib/components/shared-components/settings/setting-buttons-row.svelte';
import SettingSwitch from '$lib/components/shared-components/settings/setting-switch.svelte';
import { t } from 'svelte-i18n';
import { SettingInputFieldType } from '$lib/constants';
interface Props {
savedConfig: SystemConfigDto;
defaultConfig: SystemConfigDto;
config: SystemConfigDto;
disabled?: boolean;
onReset: SettingsResetEvent;
onSave: SettingsSaveEvent;
}
let { savedConfig, defaultConfig, config = $bindable(), disabled = false, onReset, onSave }: Props = $props();
const onsubmit = (event: Event) => {
event.preventDefault();
};
</script>
<div>
<div in:fade={{ duration: 500 }}>
<form autocomplete="off" {onsubmit}>
<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={config.server.externalDomain}
isEdited={config.server.externalDomain !== savedConfig.server.externalDomain}
/>
<SettingInputField
inputType={SettingInputFieldType.TEXT}
label={$t('admin.server_welcome_message')}
description={$t('admin.server_welcome_message_description')}
bind:value={config.server.loginPageMessage}
isEdited={config.server.loginPageMessage !== savedConfig.server.loginPageMessage}
/>
<SettingSwitch
title={$t('admin.server_public_users')}
subtitle={$t('admin.server_public_users_description')}
{disabled}
bind:checked={config.server.publicUsers}
/>
<div class="ms-4">
<SettingButtonsRow
onReset={(options) => onReset({ ...options, configKeys: ['server'] })}
onSave={() => onSave({ server: config.server })}
showResetToDefault={!isEqual(savedConfig.server, defaultConfig.server)}
{disabled}
/>
</div>
</div>
</form>
</div>
</div>
@@ -1,47 +0,0 @@
<script lang="ts">
import type { SystemConfigDto } from '@immich/sdk';
import { isEqual } from 'lodash-es';
import { fade } from 'svelte/transition';
import type { SettingsResetEvent, SettingsSaveEvent } from '../admin-settings';
import SettingTextarea from '$lib/components/shared-components/settings/setting-textarea.svelte';
import SettingButtonsRow from '$lib/components/shared-components/settings/setting-buttons-row.svelte';
import { t } from 'svelte-i18n';
interface Props {
savedConfig: SystemConfigDto;
defaultConfig: SystemConfigDto;
config: SystemConfigDto;
disabled?: boolean;
onReset: SettingsResetEvent;
onSave: SettingsSaveEvent;
}
let { savedConfig, defaultConfig, config = $bindable(), disabled = false, onReset, onSave }: Props = $props();
const onsubmit = (event: Event) => {
event.preventDefault();
};
</script>
<div>
<div in:fade={{ duration: 500 }}>
<form autocomplete="off" {onsubmit}>
<div class="ms-4 mt-4 flex flex-col gap-4">
<SettingTextarea
{disabled}
label={$t('admin.theme_custom_css_settings')}
description={$t('admin.theme_custom_css_settings_description')}
bind:value={config.theme.customCss}
isEdited={config.theme.customCss !== savedConfig.theme.customCss}
/>
<SettingButtonsRow
onReset={(options) => onReset({ ...options, configKeys: ['theme'] })}
onSave={() => onSave({ theme: config.theme })}
showResetToDefault={!isEqual(savedConfig, defaultConfig)}
{disabled}
/>
</div>
</form>
</div>
</div>
@@ -1,55 +0,0 @@
<script lang="ts">
import type { SystemConfigDto } from '@immich/sdk';
import { isEqual } from 'lodash-es';
import { fade } from 'svelte/transition';
import type { SettingsResetEvent, SettingsSaveEvent } from '../admin-settings';
import SettingSwitch from '$lib/components/shared-components/settings/setting-switch.svelte';
import SettingInputField from '$lib/components/shared-components/settings/setting-input-field.svelte';
import SettingButtonsRow from '$lib/components/shared-components/settings/setting-buttons-row.svelte';
import { t } from 'svelte-i18n';
import { SettingInputFieldType } from '$lib/constants';
interface Props {
savedConfig: SystemConfigDto;
defaultConfig: SystemConfigDto;
config: SystemConfigDto;
disabled?: boolean;
onReset: SettingsResetEvent;
onSave: SettingsSaveEvent;
}
let { savedConfig, defaultConfig, config = $bindable(), disabled = false, onReset, onSave }: Props = $props();
const onsubmit = (event: Event) => {
event.preventDefault();
};
</script>
<div>
<div in:fade={{ duration: 500 }}>
<form autocomplete="off" {onsubmit}>
<div class="ms-4 mt-4 flex flex-col gap-4">
<SettingSwitch title={$t('admin.trash_enabled_description')} {disabled} bind:checked={config.trash.enabled} />
<hr />
<SettingInputField
inputType={SettingInputFieldType.NUMBER}
label={$t('admin.trash_number_of_days')}
description={$t('admin.trash_number_of_days_description')}
bind:value={config.trash.days}
required={true}
disabled={disabled || !config.trash.enabled}
isEdited={config.trash.days !== savedConfig.trash.days}
/>
<SettingButtonsRow
onReset={(options) => onReset({ ...options, configKeys: ['trash'] })}
onSave={() => onSave({ trash: config.trash })}
showResetToDefault={!isEqual(savedConfig.trash, defaultConfig.trash)}
{disabled}
/>
</div>
</form>
</div>
</div>
@@ -1,48 +0,0 @@
<script lang="ts">
import { type SystemConfigDto } from '@immich/sdk';
import { isEqual } from 'lodash-es';
import { fade } from 'svelte/transition';
import type { SettingsResetEvent, SettingsSaveEvent } from '../admin-settings';
import SettingButtonsRow from '$lib/components/shared-components/settings/setting-buttons-row.svelte';
import SettingInputField from '$lib/components/shared-components/settings/setting-input-field.svelte';
import { t } from 'svelte-i18n';
import { SettingInputFieldType } from '$lib/constants';
interface Props {
savedConfig: SystemConfigDto;
defaultConfig: SystemConfigDto;
config: SystemConfigDto;
disabled?: boolean;
onReset: SettingsResetEvent;
onSave: SettingsSaveEvent;
}
let { savedConfig, defaultConfig, config = $bindable(), disabled = false, onReset, onSave }: Props = $props();
</script>
<div>
<div in:fade={{ duration: 500 }}>
<form autocomplete="off" onsubmit={(e) => e.preventDefault()}>
<div class="ms-4 mt-4 flex flex-col gap-4">
<SettingInputField
inputType={SettingInputFieldType.NUMBER}
min={1}
label={$t('admin.user_delete_delay_settings')}
description={$t('admin.user_delete_delay_settings_description')}
bind:value={config.user.deleteDelay}
isEdited={config.user.deleteDelay !== savedConfig.user.deleteDelay}
/>
</div>
<div class="ms-4">
<SettingButtonsRow
onReset={(options) => onReset({ ...options, configKeys: ['user'] })}
onSave={() => onSave({ user: config.user })}
showResetToDefault={!isEqual(savedConfig.user, defaultConfig.user)}
{disabled}
/>
</div>
</form>
</div>
</div>
@@ -1,54 +1,44 @@
<script lang="ts">
import FormatMessage from '$lib/components/i18n/format-message.svelte';
import {
notificationController,
NotificationType,
} from '$lib/components/shared-components/notification/notification';
import SettingAccordion from '$lib/components/shared-components/settings/setting-accordion.svelte';
import SettingButtonsRow from '$lib/components/shared-components/settings/setting-buttons-row.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, type SystemConfigDto } from '@immich/sdk';
import { Button, modalManager, Text } from '@immich/ui';
import { OAuthTokenEndpointAuthMethod, unlinkAllOAuthAccountsAdmin } from '@immich/sdk';
import { Button, modalManager, Text, toastManager } from '@immich/ui';
import { mdiRestart } from '@mdi/js';
import { isEqual } from 'lodash-es';
import { t } from 'svelte-i18n';
import { fade } from 'svelte/transition';
import type { SettingsResetEvent, SettingsSaveEvent } from '../admin-settings';
interface Props {
savedConfig: SystemConfigDto;
defaultConfig: SystemConfigDto;
config: SystemConfigDto;
disabled?: boolean;
onReset: SettingsResetEvent;
onSave: SettingsSaveEvent;
}
let { savedConfig, defaultConfig, config = $bindable(), disabled = false, onReset, onSave }: Props = $props();
const disabled = $derived(featureFlagsManager.value.configFile);
const config = $derived(systemConfigManager.value);
let configToEdit = $state(systemConfigManager.cloneValue());
const handleToggleOverride = () => {
// click runs before bind
const previouslyEnabled = config.oauth.mobileOverrideEnabled;
if (!previouslyEnabled && !config.oauth.mobileRedirectUri) {
config.oauth.mobileRedirectUri = globalThis.location.origin + '/api/oauth/mobile-redirect';
const previouslyEnabled = configToEdit.oauth.mobileOverrideEnabled;
if (!previouslyEnabled && !configToEdit.oauth.mobileRedirectUri) {
configToEdit.oauth.mobileRedirectUri = globalThis.location.origin + '/api/oauth/mobile-redirect';
}
};
const handleSave = async (skipConfirm: boolean) => {
const allMethodsDisabled = !config.oauth.enabled && !config.passwordLogin.enabled;
if (allMethodsDisabled && !skipConfirm) {
const onBeforeSave = async () => {
const allMethodsDisabled = !configToEdit.oauth.enabled && !configToEdit.passwordLogin.enabled;
if (allMethodsDisabled) {
const isConfirmed = await modalManager.show(AuthDisableLoginConfirmModal);
if (!isConfirmed) {
return;
return false;
}
}
onSave({ passwordLogin: config.passwordLogin, oauth: config.oauth });
return true;
};
const handleUnlinkAllOAuthAccounts = async () => {
@@ -64,8 +54,8 @@
}
try {
await unlinkAllOAuthAccountsAdmin({});
notificationController.show({ message: $t('success'), type: NotificationType.Info });
await unlinkAllOAuthAccountsAdmin();
toastManager.success();
} catch (error) {
handleError(error, $t('errors.something_went_wrong'));
}
@@ -86,7 +76,7 @@
<FormatMessage key="admin.oauth_settings_more_details">
{#snippet children({ message })}
<a
href="https://immich.app/docs/administration/oauth"
href="https://docs.immich.app/administration/oauth"
class="underline"
target="_blank"
rel="noreferrer"
@@ -100,10 +90,10 @@
<SettingSwitch
{disabled}
title={$t('admin.oauth_enable_description')}
bind:checked={config.oauth.enabled}
bind:checked={configToEdit.oauth.enabled}
/>
{#if config.oauth.enabled}
{#if configToEdit.oauth.enabled}
<hr />
<div class="flex items-center gap-2 justify-between">
@@ -116,36 +106,36 @@
<SettingInputField
inputType={SettingInputFieldType.TEXT}
label="ISSUER_URL"
bind:value={config.oauth.issuerUrl}
bind:value={configToEdit.oauth.issuerUrl}
required={true}
disabled={disabled || !config.oauth.enabled}
isEdited={!(config.oauth.issuerUrl == savedConfig.oauth.issuerUrl)}
disabled={disabled || !configToEdit.oauth.enabled}
isEdited={!(configToEdit.oauth.issuerUrl === config.oauth.issuerUrl)}
/>
<SettingInputField
inputType={SettingInputFieldType.TEXT}
label="CLIENT_ID"
bind:value={config.oauth.clientId}
bind:value={configToEdit.oauth.clientId}
required={true}
disabled={disabled || !config.oauth.enabled}
isEdited={!(config.oauth.clientId == savedConfig.oauth.clientId)}
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={config.oauth.clientSecret}
disabled={disabled || !config.oauth.enabled}
isEdited={!(config.oauth.clientSecret == savedConfig.oauth.clientSecret)}
bind:value={configToEdit.oauth.clientSecret}
disabled={disabled || !configToEdit.oauth.enabled}
isEdited={!(configToEdit.oauth.clientSecret === config.oauth.clientSecret)}
/>
{#if config.oauth.clientSecret}
{#if configToEdit.oauth.clientSecret}
<SettingSelect
label="TOKEN_ENDPOINT_AUTH_METHOD"
bind:value={config.oauth.tokenEndpointAuthMethod}
disabled={disabled || !config.oauth.enabled || !config.oauth.clientSecret}
isEdited={!(config.oauth.tokenEndpointAuthMethod == savedConfig.oauth.tokenEndpointAuthMethod)}
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' },
@@ -157,121 +147,121 @@
<SettingInputField
inputType={SettingInputFieldType.TEXT}
label="SCOPE"
bind:value={config.oauth.scope}
bind:value={configToEdit.oauth.scope}
required={true}
disabled={disabled || !config.oauth.enabled}
isEdited={!(config.oauth.scope == savedConfig.oauth.scope)}
disabled={disabled || !configToEdit.oauth.enabled}
isEdited={!(configToEdit.oauth.scope === config.oauth.scope)}
/>
<SettingInputField
inputType={SettingInputFieldType.TEXT}
label="ID_TOKEN_SIGNED_RESPONSE_ALG"
bind:value={config.oauth.signingAlgorithm}
bind:value={configToEdit.oauth.signingAlgorithm}
required={true}
disabled={disabled || !config.oauth.enabled}
isEdited={!(config.oauth.signingAlgorithm == savedConfig.oauth.signingAlgorithm)}
disabled={disabled || !configToEdit.oauth.enabled}
isEdited={!(configToEdit.oauth.signingAlgorithm === config.oauth.signingAlgorithm)}
/>
<SettingInputField
inputType={SettingInputFieldType.TEXT}
label="USERINFO_SIGNED_RESPONSE_ALG"
bind:value={config.oauth.profileSigningAlgorithm}
bind:value={configToEdit.oauth.profileSigningAlgorithm}
required={true}
disabled={disabled || !config.oauth.enabled}
isEdited={!(config.oauth.profileSigningAlgorithm == savedConfig.oauth.profileSigningAlgorithm)}
/>
<SettingInputField
inputType={SettingInputFieldType.TEXT}
label={$t('admin.oauth_timeout').toUpperCase()}
description={$t('admin.oauth_timeout_description')}
required={true}
bind:value={config.oauth.timeout}
disabled={disabled || !config.oauth.enabled}
isEdited={!(config.oauth.timeout == savedConfig.oauth.timeout)}
/>
<SettingInputField
inputType={SettingInputFieldType.TEXT}
label={$t('admin.oauth_storage_label_claim').toUpperCase()}
description={$t('admin.oauth_storage_label_claim_description')}
bind:value={config.oauth.storageLabelClaim}
required={true}
disabled={disabled || !config.oauth.enabled}
isEdited={!(config.oauth.storageLabelClaim == savedConfig.oauth.storageLabelClaim)}
/>
<SettingInputField
inputType={SettingInputFieldType.TEXT}
label={$t('admin.oauth_role_claim').toUpperCase()}
description={$t('admin.oauth_role_claim_description')}
bind:value={config.oauth.roleClaim}
required={true}
disabled={disabled || !config.oauth.enabled}
isEdited={!(config.oauth.roleClaim == savedConfig.oauth.roleClaim)}
/>
<SettingInputField
inputType={SettingInputFieldType.TEXT}
label={$t('admin.oauth_storage_quota_claim').toUpperCase()}
description={$t('admin.oauth_storage_quota_claim_description')}
bind:value={config.oauth.storageQuotaClaim}
required={true}
disabled={disabled || !config.oauth.enabled}
isEdited={!(config.oauth.storageQuotaClaim == savedConfig.oauth.storageQuotaClaim)}
disabled={disabled || !configToEdit.oauth.enabled}
isEdited={!(configToEdit.oauth.profileSigningAlgorithm === config.oauth.profileSigningAlgorithm)}
/>
<SettingInputField
inputType={SettingInputFieldType.NUMBER}
label={$t('admin.oauth_storage_quota_default').toUpperCase()}
description={$t('admin.oauth_storage_quota_default_description')}
bind:value={config.oauth.defaultStorageQuota}
required={false}
disabled={disabled || !config.oauth.enabled}
isEdited={!(config.oauth.defaultStorageQuota == savedConfig.oauth.defaultStorageQuota)}
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_button_text').toUpperCase()}
bind:value={config.oauth.buttonText}
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 || !config.oauth.enabled}
isEdited={!(config.oauth.buttonText == savedConfig.oauth.buttonText)}
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').toUpperCase()}
title={$t('admin.oauth_auto_register')}
subtitle={$t('admin.oauth_auto_register_description')}
bind:checked={config.oauth.autoRegister}
disabled={disabled || !config.oauth.enabled}
bind:checked={configToEdit.oauth.autoRegister}
disabled={disabled || !configToEdit.oauth.enabled}
/>
<SettingSwitch
title={$t('admin.oauth_auto_launch').toUpperCase()}
title={$t('admin.oauth_auto_launch')}
subtitle={$t('admin.oauth_auto_launch_description')}
disabled={disabled || !config.oauth.enabled}
bind:checked={config.oauth.autoLaunch}
disabled={disabled || !configToEdit.oauth.enabled}
bind:checked={configToEdit.oauth.autoLaunch}
/>
<SettingSwitch
title={$t('admin.oauth_mobile_redirect_uri_override').toUpperCase()}
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 || !config.oauth.enabled}
disabled={disabled || !configToEdit.oauth.enabled}
onToggle={() => handleToggleOverride()}
bind:checked={config.oauth.mobileOverrideEnabled}
bind:checked={configToEdit.oauth.mobileOverrideEnabled}
/>
{#if config.oauth.mobileOverrideEnabled}
{#if configToEdit.oauth.mobileOverrideEnabled}
<SettingInputField
inputType={SettingInputFieldType.TEXT}
label={$t('admin.oauth_mobile_redirect_uri').toUpperCase()}
bind:value={config.oauth.mobileRedirectUri}
label={$t('admin.oauth_mobile_redirect_uri')}
bind:value={configToEdit.oauth.mobileRedirectUri}
required={true}
disabled={disabled || !config.oauth.enabled}
isEdited={!(config.oauth.mobileRedirectUri == savedConfig.oauth.mobileRedirectUri)}
disabled={disabled || !configToEdit.oauth.enabled}
isEdited={!(configToEdit.oauth.mobileRedirectUri === config.oauth.mobileRedirectUri)}
/>
{/if}
{/if}
@@ -288,19 +278,13 @@
<SettingSwitch
title={$t('admin.password_enable_description')}
{disabled}
bind:checked={config.passwordLogin.enabled}
bind:checked={configToEdit.passwordLogin.enabled}
/>
</div>
</div>
</SettingAccordion>
<SettingButtonsRow
showResetToDefault={!isEqual(savedConfig.passwordLogin, defaultConfig.passwordLogin) ||
!isEqual(savedConfig.oauth, defaultConfig.oauth)}
{disabled}
onReset={(options) => onReset({ ...options, configKeys: ['passwordLogin', 'oauth'] })}
onSave={() => handleSave(false)}
/>
<SettingButtonsRow bind:configToEdit keys={['passwordLogin', 'oauth']} {onBeforeSave} {disabled} />
</div>
</form>
</div>
@@ -1,26 +1,18 @@
<script lang="ts">
import type { SystemConfigDto } from '@immich/sdk';
import { isEqual } from 'lodash-es';
import { fade } from 'svelte/transition';
import type { SettingsResetEvent, SettingsSaveEvent } from '../admin-settings';
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 SettingButtonsRow from '$lib/components/shared-components/settings/setting-buttons-row.svelte';
import SettingSelect from '$lib/components/shared-components/settings/setting-select.svelte';
import { t } from 'svelte-i18n';
import FormatMessage from '$lib/components/i18n/format-message.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';
interface Props {
savedConfig: SystemConfigDto;
defaultConfig: SystemConfigDto;
config: SystemConfigDto;
disabled?: boolean;
onReset: SettingsResetEvent;
onSave: SettingsSaveEvent;
}
let { savedConfig, defaultConfig, config = $bindable(), disabled = false, onReset, onSave }: Props = $props();
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 * * *' },
@@ -28,44 +20,40 @@
{ text: $t('interval.day_at_onepm'), value: '0 13 * * *' },
{ text: $t('interval.hours', { values: { hours: 6 } }), value: '0 */6 * * *' },
]);
const onsubmit = (event: Event) => {
event.preventDefault();
};
</script>
<div>
<div in:fade={{ duration: 500 }}>
<form autocomplete="off" {onsubmit}>
<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={config.backup.database.enabled}
bind:checked={configToEdit.backup.database.enabled}
/>
<SettingSelect
options={cronExpressionOptions}
disabled={disabled || !config.backup.database.enabled}
disabled={disabled || !configToEdit.backup.database.enabled}
name="expression"
label={$t('admin.cron_expression_presets')}
bind:value={config.backup.database.cronExpression}
bind:value={configToEdit.backup.database.cronExpression}
/>
<SettingInputField
inputType={SettingInputFieldType.TEXT}
required={true}
disabled={disabled || !config.backup.database.enabled}
disabled={disabled || !configToEdit.backup.database.enabled}
label={$t('admin.cron_expression')}
bind:value={config.backup.database.cronExpression}
isEdited={config.backup.database.cronExpression !== savedConfig.backup.database.cronExpression}
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/#{config.backup.database.cronExpression.replaceAll(' ', '_')}"
href="https://crontab.guru/#{configToEdit.backup.database.cronExpression.replaceAll(' ', '_')}"
class="underline"
target="_blank"
rel="noreferrer"
@@ -83,17 +71,12 @@
inputType={SettingInputFieldType.NUMBER}
required={true}
label={$t('admin.backup_keep_last_amount')}
disabled={disabled || !config.backup.database.enabled}
bind:value={config.backup.database.keepLastAmount}
isEdited={config.backup.database.keepLastAmount !== savedConfig.backup.database.keepLastAmount}
disabled={disabled || !configToEdit.backup.database.enabled}
bind:value={configToEdit.backup.database.keepLastAmount}
isEdited={configToEdit.backup.database.keepLastAmount !== config.backup.database.keepLastAmount}
/>
<SettingButtonsRow
onReset={(options) => onReset({ ...options, configKeys: ['backup'] })}
onSave={() => onSave({ backup: config.backup })}
showResetToDefault={!isEqual(savedConfig.backup, defaultConfig.backup)}
{disabled}
/>
<SettingButtonsRow {disabled} bind:configToEdit keys={['backup']} />
</div>
</form>
</div>
@@ -1,5 +1,14 @@
<script lang="ts">
import Icon from '$lib/components/elements/icon.svelte';
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,
@@ -8,44 +17,24 @@
TranscodePolicy,
VideoCodec,
VideoContainer,
type SystemConfigDto,
} from '@immich/sdk';
import { Icon } from '@immich/ui';
import { mdiHelpCircleOutline } from '@mdi/js';
import { isEqual, sortBy } from 'lodash-es';
import { fade } from 'svelte/transition';
import type { SettingsResetEvent, SettingsSaveEvent } from '../admin-settings';
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 SettingCheckboxes from '$lib/components/shared-components/settings/setting-checkboxes.svelte';
import SettingButtonsRow from '$lib/components/shared-components/settings/setting-buttons-row.svelte';
import { t } from 'svelte-i18n';
import FormatMessage from '$lib/components/i18n/format-message.svelte';
import { SettingInputFieldType } from '$lib/constants';
import { fade } from 'svelte/transition';
interface Props {
savedConfig: SystemConfigDto;
defaultConfig: SystemConfigDto;
config: SystemConfigDto;
disabled?: boolean;
onReset: SettingsResetEvent;
onSave: SettingsSaveEvent;
}
let { savedConfig, defaultConfig, config = $bindable(), disabled = false, onReset, onSave }: Props = $props();
const onsubmit = (event: Event) => {
event.preventDefault();
};
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}>
<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 path={mdiHelpCircleOutline} class="inline" size="15" />
<Icon icon={mdiHelpCircleOutline} class="inline" size="15" />
<FormatMessage key="admin.transcoding_codecs_learn_more">
{#snippet children({ tag, message })}
{#if tag === 'h264-link'}
@@ -75,7 +64,7 @@
label={$t('admin.transcoding_transcode_policy')}
{disabled}
desc={$t('admin.transcoding_transcode_policy_description')}
bind:value={config.ffmpeg.transcode}
bind:value={configToEdit.ffmpeg.transcode}
name="transcode"
options={[
{ value: TranscodePolicy.All, text: $t('all_videos') },
@@ -96,14 +85,14 @@
text: $t('admin.transcoding_disabled_description'),
},
]}
isEdited={config.ffmpeg.transcode !== savedConfig.ffmpeg.transcode}
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={config.ffmpeg.acceptedVideoCodecs}
bind:value={configToEdit.ffmpeg.acceptedVideoCodecs}
name="videoCodecs"
options={[
{ value: VideoCodec.H264, text: 'H.264' },
@@ -112,8 +101,8 @@
{ value: VideoCodec.Av1, text: 'AV1' },
]}
isEdited={!isEqual(
sortBy(configToEdit.ffmpeg.acceptedVideoCodecs),
sortBy(config.ffmpeg.acceptedVideoCodecs),
sortBy(savedConfig.ffmpeg.acceptedVideoCodecs),
)}
/>
@@ -121,7 +110,7 @@
label={$t('admin.transcoding_accepted_audio_codecs')}
{disabled}
desc={$t('admin.transcoding_accepted_audio_codecs_description')}
bind:value={config.ffmpeg.acceptedAudioCodecs}
bind:value={configToEdit.ffmpeg.acceptedAudioCodecs}
name="audioCodecs"
options={[
{ value: AudioCodec.Aac, text: 'AAC' },
@@ -130,8 +119,8 @@
{ value: AudioCodec.PcmS16Le, text: 'PCM (16 bit)' },
]}
isEdited={!isEqual(
sortBy(configToEdit.ffmpeg.acceptedAudioCodecs),
sortBy(config.ffmpeg.acceptedAudioCodecs),
sortBy(savedConfig.ffmpeg.acceptedAudioCodecs),
)}
/>
@@ -139,7 +128,7 @@
label={$t('admin.transcoding_accepted_containers')}
{disabled}
desc={$t('admin.transcoding_accepted_containers_description')}
bind:value={config.ffmpeg.acceptedContainers}
bind:value={configToEdit.ffmpeg.acceptedContainers}
name="videoContainers"
options={[
{ value: VideoContainer.Mov, text: 'MOV' },
@@ -147,8 +136,8 @@
{ value: VideoContainer.Webm, text: 'WebM' },
]}
isEdited={!isEqual(
sortBy(configToEdit.ffmpeg.acceptedContainers),
sortBy(config.ffmpeg.acceptedContainers),
sortBy(savedConfig.ffmpeg.acceptedContainers),
)}
/>
</div>
@@ -164,7 +153,7 @@
label={$t('admin.transcoding_video_codec')}
{disabled}
desc={$t('admin.transcoding_video_codec_description')}
bind:value={config.ffmpeg.targetVideoCodec}
bind:value={configToEdit.ffmpeg.targetVideoCodec}
options={[
{ value: VideoCodec.H264, text: 'h264' },
{ value: VideoCodec.Hevc, text: 'hevc' },
@@ -172,8 +161,8 @@
{ value: VideoCodec.Av1, text: 'av1' },
]}
name="vcodec"
isEdited={config.ffmpeg.targetVideoCodec !== savedConfig.ffmpeg.targetVideoCodec}
onSelect={() => (config.ffmpeg.acceptedVideoCodecs = [config.ffmpeg.targetVideoCodec])}
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 -->
@@ -181,25 +170,25 @@
label={$t('admin.transcoding_audio_codec')}
{disabled}
desc={$t('admin.transcoding_audio_codec_description')}
bind:value={config.ffmpeg.targetAudioCodec}
bind:value={configToEdit.ffmpeg.targetAudioCodec}
options={[
{ value: AudioCodec.Aac, text: 'aac' },
{ value: AudioCodec.Mp3, text: 'mp3' },
{ value: AudioCodec.Libopus, text: 'opus' },
]}
name="acodec"
isEdited={config.ffmpeg.targetAudioCodec !== savedConfig.ffmpeg.targetAudioCodec}
isEdited={configToEdit.ffmpeg.targetAudioCodec !== config.ffmpeg.targetAudioCodec}
onSelect={() =>
config.ffmpeg.acceptedAudioCodecs.includes(config.ffmpeg.targetAudioCodec)
configToEdit.ffmpeg.acceptedAudioCodecs.includes(configToEdit.ffmpeg.targetAudioCodec)
? null
: config.ffmpeg.acceptedAudioCodecs.push(config.ffmpeg.targetAudioCodec)}
: configToEdit.ffmpeg.acceptedAudioCodecs.push(configToEdit.ffmpeg.targetAudioCodec)}
/>
<SettingSelect
label={$t('admin.transcoding_target_resolution')}
{disabled}
desc={$t('admin.transcoding_target_resolution_description')}
bind:value={config.ffmpeg.targetResolution}
bind:value={configToEdit.ffmpeg.targetResolution}
options={[
{ value: '2160', text: '4k' },
{ value: '1440', text: '1440p' },
@@ -209,7 +198,7 @@
{ value: 'original', text: $t('original') },
]}
name="resolution"
isEdited={config.ffmpeg.targetResolution !== savedConfig.ffmpeg.targetResolution}
isEdited={configToEdit.ffmpeg.targetResolution !== config.ffmpeg.targetResolution}
/>
<SettingInputField
@@ -217,16 +206,16 @@
{disabled}
label={$t('admin.transcoding_constant_rate_factor')}
description={$t('admin.transcoding_constant_rate_factor_description')}
bind:value={config.ffmpeg.crf}
bind:value={configToEdit.ffmpeg.crf}
required={true}
isEdited={config.ffmpeg.crf !== savedConfig.ffmpeg.crf}
isEdited={configToEdit.ffmpeg.crf !== config.ffmpeg.crf}
/>
<SettingSelect
label={$t('admin.transcoding_preset_preset')}
{disabled}
desc={$t('admin.transcoding_preset_preset_description')}
bind:value={config.ffmpeg.preset}
bind:value={configToEdit.ffmpeg.preset}
name="preset"
options={[
{ value: 'ultrafast', text: 'ultrafast' },
@@ -239,7 +228,7 @@
{ value: 'slower', text: 'slower' },
{ value: 'veryslow', text: 'veryslow' },
]}
isEdited={config.ffmpeg.preset !== savedConfig.ffmpeg.preset}
isEdited={configToEdit.ffmpeg.preset !== config.ffmpeg.preset}
/>
<SettingInputField
@@ -247,8 +236,8 @@
{disabled}
label={$t('admin.transcoding_max_bitrate')}
description={$t('admin.transcoding_max_bitrate_description')}
bind:value={config.ffmpeg.maxBitrate}
isEdited={config.ffmpeg.maxBitrate !== savedConfig.ffmpeg.maxBitrate}
bind:value={configToEdit.ffmpeg.maxBitrate}
isEdited={configToEdit.ffmpeg.maxBitrate !== config.ffmpeg.maxBitrate}
/>
<SettingInputField
@@ -256,15 +245,15 @@
{disabled}
label={$t('admin.transcoding_threads')}
description={$t('admin.transcoding_threads_description')}
bind:value={config.ffmpeg.threads}
isEdited={config.ffmpeg.threads !== savedConfig.ffmpeg.threads}
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={config.ffmpeg.tonemap}
bind:value={configToEdit.ffmpeg.tonemap}
name="tonemap"
options={[
{
@@ -284,15 +273,15 @@
text: $t('disabled'),
},
]}
isEdited={config.ffmpeg.tonemap !== savedConfig.ffmpeg.tonemap}
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={config.ffmpeg.twoPass}
isEdited={config.ffmpeg.twoPass !== savedConfig.ffmpeg.twoPass}
bind:checked={configToEdit.ffmpeg.twoPass}
isEdited={configToEdit.ffmpeg.twoPass !== config.ffmpeg.twoPass}
/>
</div>
</SettingAccordion>
@@ -307,7 +296,7 @@
label={$t('admin.transcoding_acceleration_api')}
{disabled}
desc={$t('admin.transcoding_acceleration_api_description')}
bind:value={config.ffmpeg.accel}
bind:value={configToEdit.ffmpeg.accel}
name="accel"
options={[
{ value: TranscodeHWAccel.Nvenc, text: $t('admin.transcoding_acceleration_nvenc') },
@@ -328,27 +317,27 @@
text: $t('disabled'),
},
]}
isEdited={config.ffmpeg.accel !== savedConfig.ffmpeg.accel}
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={config.ffmpeg.accelDecode}
isEdited={config.ffmpeg.accelDecode !== savedConfig.ffmpeg.accelDecode}
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={config.ffmpeg.cqMode}
bind:value={configToEdit.ffmpeg.cqMode}
options={[
{ value: CQMode.Auto, text: 'Auto' },
{ value: CQMode.Icq, text: 'ICQ' },
{ value: CQMode.Cqp, text: 'CQP' },
]}
isEdited={config.ffmpeg.cqMode !== savedConfig.ffmpeg.cqMode}
isEdited={configToEdit.ffmpeg.cqMode !== config.ffmpeg.cqMode}
{disabled}
/>
@@ -356,16 +345,16 @@
title={$t('admin.transcoding_temporal_aq')}
{disabled}
subtitle={$t('admin.transcoding_temporal_aq_description')}
bind:checked={config.ffmpeg.temporalAQ}
isEdited={config.ffmpeg.temporalAQ !== savedConfig.ffmpeg.temporalAQ}
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={config.ffmpeg.preferredHwDevice}
isEdited={config.ffmpeg.preferredHwDevice !== savedConfig.ffmpeg.preferredHwDevice}
bind:value={configToEdit.ffmpeg.preferredHwDevice}
isEdited={configToEdit.ffmpeg.preferredHwDevice !== config.ffmpeg.preferredHwDevice}
{disabled}
/>
</div>
@@ -381,8 +370,8 @@
inputType={SettingInputFieldType.NUMBER}
label={$t('admin.transcoding_max_b_frames')}
description={$t('admin.transcoding_max_b_frames_description')}
bind:value={config.ffmpeg.bframes}
isEdited={config.ffmpeg.bframes !== savedConfig.ffmpeg.bframes}
bind:value={configToEdit.ffmpeg.bframes}
isEdited={configToEdit.ffmpeg.bframes !== config.ffmpeg.bframes}
{disabled}
/>
@@ -390,8 +379,8 @@
inputType={SettingInputFieldType.NUMBER}
label={$t('admin.transcoding_reference_frames')}
description={$t('admin.transcoding_reference_frames_description')}
bind:value={config.ffmpeg.refs}
isEdited={config.ffmpeg.refs !== savedConfig.ffmpeg.refs}
bind:value={configToEdit.ffmpeg.refs}
isEdited={configToEdit.ffmpeg.refs !== config.ffmpeg.refs}
{disabled}
/>
@@ -399,8 +388,8 @@
inputType={SettingInputFieldType.NUMBER}
label={$t('admin.transcoding_max_keyframe_interval')}
description={$t('admin.transcoding_max_keyframe_interval_description')}
bind:value={config.ffmpeg.gopSize}
isEdited={config.ffmpeg.gopSize !== savedConfig.ffmpeg.gopSize}
bind:value={configToEdit.ffmpeg.gopSize}
isEdited={configToEdit.ffmpeg.gopSize !== config.ffmpeg.gopSize}
{disabled}
/>
</div>
@@ -408,12 +397,7 @@
</div>
<div class="ms-4">
<SettingButtonsRow
onReset={(options) => onReset({ ...options, configKeys: ['ffmpeg'] })}
onSave={() => onSave({ ffmpeg: config.ffmpeg })}
showResetToDefault={!isEqual(savedConfig.ffmpeg, defaultConfig.ffmpeg)}
{disabled}
/>
<SettingButtonsRow bind:configToEdit keys={['ffmpeg']} {disabled} />
</div>
</form>
</div>
@@ -1,62 +1,41 @@
<script lang="ts">
import { Colorspace, ImageFormat, type SystemConfigDto } from '@immich/sdk';
import { isEqual } from 'lodash-es';
import { fade } from 'svelte/transition';
import type { SettingsResetEvent, SettingsSaveEvent } from '../admin-settings';
import SettingSelect from '$lib/components/shared-components/settings/setting-select.svelte';
import { Colorspace, ImageFormat } from '@immich/sdk';
import { fade } from 'svelte/transition';
import SettingSwitch from '$lib/components/shared-components/settings/setting-switch.svelte';
import SettingButtonsRow from '$lib/components/shared-components/settings/setting-buttons-row.svelte';
import SettingInputField from '$lib/components/shared-components/settings/setting-input-field.svelte';
import { t } from 'svelte-i18n';
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';
interface Props {
savedConfig: SystemConfigDto;
defaultConfig: SystemConfigDto;
config: SystemConfigDto;
disabled?: boolean;
onReset: SettingsResetEvent;
onSave: SettingsSaveEvent;
openByDefault?: boolean;
}
let {
savedConfig,
defaultConfig,
config = $bindable(),
disabled = false,
onReset,
onSave,
openByDefault = false,
}: Props = $props();
const onsubmit = (event: Event) => {
event.preventDefault();
};
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}>
<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')}
isOpen={openByDefault}
>
<SettingSelect
label={$t('admin.image_format')}
desc={$t('admin.image_format_description')}
bind:value={config.image.thumbnail.format}
bind:value={configToEdit.image.thumbnail.format}
options={[
{ value: ImageFormat.Jpeg, text: 'JPEG' },
{ value: ImageFormat.Webp, text: 'WebP' },
]}
name="format"
isEdited={config.image.thumbnail.format !== savedConfig.image.thumbnail.format}
isEdited={configToEdit.image.thumbnail.format !== config.image.thumbnail.format}
{disabled}
/>
@@ -64,7 +43,7 @@
label={$t('admin.image_resolution')}
desc={$t('admin.image_resolution_description')}
number
bind:value={config.image.thumbnail.size}
bind:value={configToEdit.image.thumbnail.size}
options={[
{ value: 1080, text: '1080p' },
{ value: 720, text: '720p' },
@@ -73,7 +52,7 @@
{ value: 200, text: '200p' },
]}
name="resolution"
isEdited={config.image.thumbnail.size !== savedConfig.image.thumbnail.size}
isEdited={configToEdit.image.thumbnail.size !== config.image.thumbnail.size}
{disabled}
/>
@@ -81,8 +60,8 @@
inputType={SettingInputFieldType.NUMBER}
label={$t('admin.image_quality')}
description={$t('admin.image_thumbnail_quality_description')}
bind:value={config.image.thumbnail.quality}
isEdited={config.image.thumbnail.quality !== savedConfig.image.thumbnail.quality}
bind:value={configToEdit.image.thumbnail.quality}
isEdited={configToEdit.image.thumbnail.quality !== config.image.thumbnail.quality}
{disabled}
/>
</SettingAccordion>
@@ -91,18 +70,17 @@
key="preview-settings"
title={$t('admin.image_preview_title')}
subtitle={$t('admin.image_preview_description')}
isOpen={openByDefault}
>
<SettingSelect
label={$t('admin.image_format')}
desc={$t('admin.image_format_description')}
bind:value={config.image.preview.format}
bind:value={configToEdit.image.preview.format}
options={[
{ value: ImageFormat.Jpeg, text: 'JPEG' },
{ value: ImageFormat.Webp, text: 'WebP' },
]}
name="format"
isEdited={config.image.preview.format !== savedConfig.image.preview.format}
isEdited={configToEdit.image.preview.format !== config.image.preview.format}
{disabled}
/>
@@ -110,7 +88,7 @@
label={$t('admin.image_resolution')}
desc={$t('admin.image_resolution_description')}
number
bind:value={config.image.preview.size}
bind:value={configToEdit.image.preview.size}
options={[
{ value: 2160, text: '4K' },
{ value: 1440, text: '1440p' },
@@ -118,7 +96,7 @@
{ value: 720, text: '720p' },
]}
name="resolution"
isEdited={config.image.preview.size !== savedConfig.image.preview.size}
isEdited={configToEdit.image.preview.size !== config.image.preview.size}
{disabled}
/>
@@ -126,8 +104,8 @@
inputType={SettingInputFieldType.NUMBER}
label={$t('admin.image_quality')}
description={$t('admin.image_preview_quality_description')}
bind:value={config.image.preview.quality}
isEdited={config.image.preview.quality !== savedConfig.image.preview.quality}
bind:value={configToEdit.image.preview.quality}
isEdited={configToEdit.image.preview.quality !== config.image.preview.quality}
{disabled}
/>
</SettingAccordion>
@@ -136,14 +114,13 @@
key="fullsize-settings"
title={$t('admin.image_fullsize_title')}
subtitle={$t('admin.image_fullsize_description')}
isOpen={openByDefault}
>
<SettingSwitch
title={$t('admin.image_fullsize_enabled')}
subtitle={$t('admin.image_fullsize_enabled_description')}
checked={config.image.fullsize.enabled}
onToggle={(isChecked) => (config.image.fullsize.enabled = isChecked)}
isEdited={config.image.fullsize.enabled !== savedConfig.image.fullsize.enabled}
checked={configToEdit.image.fullsize.enabled}
onToggle={(isChecked) => (configToEdit.image.fullsize.enabled = isChecked)}
isEdited={configToEdit.image.fullsize.enabled !== config.image.fullsize.enabled}
{disabled}
/>
@@ -152,23 +129,23 @@
<SettingSelect
label={$t('admin.image_format')}
desc={$t('admin.image_format_description')}
bind:value={config.image.fullsize.format}
bind:value={configToEdit.image.fullsize.format}
options={[
{ value: ImageFormat.Jpeg, text: 'JPEG' },
{ value: ImageFormat.Webp, text: 'WebP' },
]}
name="format"
isEdited={config.image.fullsize.format !== savedConfig.image.fullsize.format}
disabled={disabled || !config.image.fullsize.enabled}
isEdited={configToEdit.image.fullsize.format !== config.image.fullsize.format}
disabled={disabled || !configToEdit.image.fullsize.enabled}
/>
<SettingInputField
inputType={SettingInputFieldType.NUMBER}
label={$t('admin.image_quality')}
description={$t('admin.image_fullsize_quality_description')}
bind:value={config.image.fullsize.quality}
isEdited={config.image.fullsize.quality !== savedConfig.image.fullsize.quality}
disabled={disabled || !config.image.fullsize.enabled}
bind:value={configToEdit.image.fullsize.quality}
isEdited={configToEdit.image.fullsize.quality !== config.image.fullsize.quality}
disabled={disabled || !configToEdit.image.fullsize.enabled}
/>
</SettingAccordion>
@@ -176,9 +153,9 @@
<SettingSwitch
title={$t('admin.image_prefer_wide_gamut')}
subtitle={$t('admin.image_prefer_wide_gamut_setting_description')}
checked={config.image.colorspace === Colorspace.P3}
onToggle={(isChecked) => (config.image.colorspace = isChecked ? Colorspace.P3 : Colorspace.Srgb)}
isEdited={config.image.colorspace !== savedConfig.image.colorspace}
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>
@@ -187,21 +164,16 @@
<SettingSwitch
title={$t('admin.image_prefer_embedded_preview')}
subtitle={$t('admin.image_prefer_embedded_preview_setting_description')}
checked={config.image.extractEmbedded}
onToggle={() => (config.image.extractEmbedded = !config.image.extractEmbedded)}
isEdited={config.image.extractEmbedded !== savedConfig.image.extractEmbedded}
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
onReset={(options) => onReset({ ...options, configKeys: ['image'] })}
onSave={() => onSave({ image: config.image })}
showResetToDefault={!isEqual(savedConfig.image, defaultConfig.image)}
{disabled}
/>
<SettingButtonsRow bind:configToEdit keys={['image']} {disabled} />
</div>
</form>
</div>
@@ -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>
@@ -1,36 +1,19 @@
<script lang="ts">
import type { SystemConfigDto } from '@immich/sdk';
import { isEqual } from 'lodash-es';
import { fade } from 'svelte/transition';
import type { SettingsResetEvent, SettingsSaveEvent } from '../admin-settings';
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/setting-buttons-row.svelte';
import { t } from 'svelte-i18n';
import FormatMessage from '$lib/components/i18n/format-message.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';
interface Props {
savedConfig: SystemConfigDto;
defaultConfig: SystemConfigDto;
config: SystemConfigDto;
disabled?: boolean;
onReset: SettingsResetEvent;
onSave: SettingsSaveEvent;
openByDefault?: boolean;
}
let {
savedConfig,
defaultConfig,
config = $bindable(),
disabled = false,
onReset,
onSave,
openByDefault = false,
}: Props = $props();
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 * * *' },
@@ -38,27 +21,22 @@
{ text: $t('interval.day_at_onepm'), value: '0 13 * * *' },
{ text: $t('interval.hours', { values: { hours: 6 } }), value: '0 */6 * * *' },
]);
const onsubmit = (event: Event) => {
event.preventDefault();
};
</script>
<div>
<div in:fade={{ duration: 500 }}>
<form autocomplete="off" {onsubmit}>
<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')}
isOpen={openByDefault}
>
<div class="ms-4 mt-4 flex flex-col gap-4">
<SettingSwitch
title={$t('admin.library_watching_enable_description')}
{disabled}
bind:checked={config.library.watch.enabled}
bind:checked={configToEdit.library.watch.enabled}
/>
</div>
</SettingAccordion>
@@ -67,37 +45,36 @@
key="library-scanning"
title={$t('admin.library_scanning')}
subtitle={$t('admin.library_scanning_description')}
isOpen={openByDefault}
>
<div class="ms-4 mt-4 flex flex-col gap-4">
<SettingSwitch
title={$t('admin.library_scanning_enable_description')}
{disabled}
bind:checked={config.library.scan.enabled}
bind:checked={configToEdit.library.scan.enabled}
/>
<SettingSelect
options={cronExpressionOptions}
disabled={disabled || !config.library.scan.enabled}
disabled={disabled || !configToEdit.library.scan.enabled}
name="expression"
label={$t('admin.cron_expression_presets')}
bind:value={config.library.scan.cronExpression}
bind:value={configToEdit.library.scan.cronExpression}
/>
<SettingInputField
inputType={SettingInputFieldType.TEXT}
required={true}
disabled={disabled || !config.library.scan.enabled}
disabled={disabled || !configToEdit.library.scan.enabled}
label={$t('admin.cron_expression')}
bind:value={config.library.scan.cronExpression}
isEdited={config.library.scan.cronExpression !== savedConfig.library.scan.cronExpression}
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/#{config.library.scan.cronExpression.replaceAll(' ', '_')}"
href="https://crontab.guru/#{configToEdit.library.scan.cronExpression.replaceAll(' ', '_')}"
class="underline"
target="_blank"
rel="noreferrer"
@@ -112,12 +89,7 @@
</div>
</SettingAccordion>
<SettingButtonsRow
onReset={(options) => onReset({ ...options, configKeys: ['library'] })}
onSave={() => onSave({ library: config.library })}
showResetToDefault={!isEqual(savedConfig.library, defaultConfig.library)}
{disabled}
/>
<SettingButtonsRow bind:configToEdit keys={['library']} {disabled} />
</div>
</form>
</div>
@@ -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>
@@ -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>
@@ -0,0 +1,35 @@
<script lang="ts">
import { handleError } from '$lib/utils/handle-error';
import { MaintenanceAction, setMaintenanceMode } from '@immich/sdk';
import { Button } from '@immich/ui';
import { t } from 'svelte-i18n';
import { fade } from 'svelte/transition';
interface Props {
disabled?: boolean;
}
let { disabled = false }: Props = $props();
async function start() {
try {
await setMaintenanceMode({
setMaintenanceModeDto: {
action: MaintenanceAction.Start,
},
});
} catch (error) {
handleError(error, $t('admin.maintenance_start_error'));
}
}
</script>
<div>
<div in:fade={{ duration: 500 }}>
<div class="ms-4 mt-4 flex items-end gap-4">
<Button shape="round" type="submit" {disabled} size="small" onclick={start}
>{$t('admin.maintenance_start')}</Button
>
</div>
</div>
</div>
@@ -1,35 +1,23 @@
<script lang="ts">
import type { SystemConfigDto } from '@immich/sdk';
import { isEqual } from 'lodash-es';
import { fade } from 'svelte/transition';
import type { SettingsResetEvent, SettingsSaveEvent } from '../admin-settings';
import SettingAccordion from '$lib/components/shared-components/settings/setting-accordion.svelte';
import SettingButtonsRow from '$lib/components/shared-components/settings/setting-buttons-row.svelte';
import SettingSwitch from '$lib/components/shared-components/settings/setting-switch.svelte';
import SettingInputField from '$lib/components/shared-components/settings/setting-input-field.svelte';
import { t } from 'svelte-i18n';
import FormatMessage from '$lib/components/i18n/format-message.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';
interface Props {
savedConfig: SystemConfigDto;
defaultConfig: SystemConfigDto;
config: SystemConfigDto;
disabled?: boolean;
onReset: SettingsResetEvent;
onSave: SettingsSaveEvent;
}
let { savedConfig, defaultConfig, config = $bindable(), disabled = false, onReset, onSave }: Props = $props();
const onsubmit = (event: Event) => {
event.preventDefault();
};
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}>
<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">
@@ -37,7 +25,7 @@
title={$t('admin.map_enable_description')}
subtitle={$t('admin.map_implications')}
{disabled}
bind:checked={config.map.enabled}
bind:checked={configToEdit.map.enabled}
/>
<hr />
@@ -46,17 +34,17 @@
inputType={SettingInputFieldType.TEXT}
label={$t('admin.map_light_style')}
description={$t('admin.map_style_description')}
bind:value={config.map.lightStyle}
disabled={disabled || !config.map.enabled}
isEdited={config.map.lightStyle !== savedConfig.map.lightStyle}
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={config.map.darkStyle}
disabled={disabled || !config.map.enabled}
isEdited={config.map.darkStyle !== savedConfig.map.darkStyle}
bind:value={configToEdit.map.darkStyle}
disabled={disabled || !configToEdit.map.enabled}
isEdited={configToEdit.map.darkStyle !== config.map.darkStyle}
/>
</div></SettingAccordion
>
@@ -67,7 +55,7 @@
<FormatMessage key="admin.map_manage_reverse_geocoding_settings">
{#snippet children({ message })}
<a
href="https://immich.app/docs/features/reverse-geocoding"
href="https://docs.immich.app/features/reverse-geocoding"
class="underline"
target="_blank"
rel="noreferrer"
@@ -82,20 +70,12 @@
<SettingSwitch
title={$t('admin.map_reverse_geocoding_enable_description')}
{disabled}
bind:checked={config.reverseGeocoding.enabled}
bind:checked={configToEdit.reverseGeocoding.enabled}
/>
</div></SettingAccordion
>
<SettingButtonsRow
onReset={(options) => onReset({ ...options, configKeys: ['map', 'reverseGeocoding'] })}
onSave={() => onSave({ map: config.map, reverseGeocoding: config.reverseGeocoding })}
showResetToDefault={!isEqual(
{ map: savedConfig.map, reverseGeocoding: savedConfig.reverseGeocoding },
{ map: defaultConfig.map, reverseGeocoding: defaultConfig.reverseGeocoding },
)}
{disabled}
/>
<SettingButtonsRow bind:configToEdit keys={['map', 'reverseGeocoding']} {disabled} />
</div>
</form>
</div>
@@ -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>
@@ -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>
@@ -1,81 +1,64 @@
<script lang="ts">
import SettingButtonsRow from '$lib/components/shared-components/settings/setting-buttons-row.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 type { SystemConfigDto } from '@immich/sdk';
import { isEqual } from 'lodash-es';
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';
import type { SettingsResetEvent, SettingsSaveEvent } from '../admin-settings';
interface Props {
savedConfig: SystemConfigDto;
defaultConfig: SystemConfigDto;
config: SystemConfigDto;
disabled?: boolean;
onReset: SettingsResetEvent;
onSave: SettingsSaveEvent;
}
let { savedConfig, defaultConfig, config = $bindable(), disabled = false, onReset, onSave }: Props = $props();
const onsubmit = (event: Event) => {
event.preventDefault();
};
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} class="mx-4 mt-4">
<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={config.nightlyTasks.startTime}
bind:value={configToEdit.nightlyTasks.startTime}
required={true}
{disabled}
isEdited={!(config.nightlyTasks.startTime === savedConfig.nightlyTasks.startTime)}
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={config.nightlyTasks.databaseCleanup}
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={config.nightlyTasks.missingThumbnails}
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={config.nightlyTasks.clusterNewFaces}
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={config.nightlyTasks.generateMemories}
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={config.nightlyTasks.syncQuotaUsage}
bind:checked={configToEdit.nightlyTasks.syncQuotaUsage}
{disabled}
/>
</div>
<SettingButtonsRow
onReset={(options) => onReset({ ...options, configKeys: ['nightlyTasks'] })}
onSave={() => onSave({ nightlyTasks: config.nightlyTasks })}
showResetToDefault={!isEqual(savedConfig.nightlyTasks, defaultConfig.nightlyTasks)}
{disabled}
/>
<SettingButtonsRow bind:configToEdit keys={['nightlyTasks']} {disabled} />
</form>
</div>
</div>
@@ -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>
@@ -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>
@@ -1,59 +1,44 @@
<script lang="ts">
import { createBubbler, preventDefault } from 'svelte/legacy';
const bubble = createBubbler();
import LoadingSpinner from '$lib/components/shared-components/loading-spinner.svelte';
import { AppRoute, SettingInputFieldType } from '$lib/constants';
import { user } from '$lib/stores/user.store';
import {
getStorageTemplateOptions,
type SystemConfigDto,
type SystemConfigTemplateStorageOptionDto,
} from '@immich/sdk';
import handlebar from 'handlebars';
import { isEqual } from 'lodash-es';
import * as luxon from 'luxon';
import { fade } from 'svelte/transition';
import type { SettingsResetEvent, SettingsSaveEvent } from '../admin-settings';
import SupportedDatetimePanel from './supported-datetime-panel.svelte';
import SupportedVariablesPanel from './supported-variables-panel.svelte';
import SettingButtonsRow from '$lib/components/shared-components/settings/setting-buttons-row.svelte';
import { resolve } from '$app/paths';
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 { AppRoute, 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 { handleSystemConfigSave } from '$lib/services/system-config.service';
import { user } from '$lib/stores/user.store';
import { getStorageTemplateOptions, type SystemConfigTemplateStorageOptionDto } from '@immich/sdk';
import { LoadingSpinner } from '@immich/ui';
import handlebar from 'handlebars';
import * as luxon from 'luxon';
import { onDestroy } from 'svelte';
import { t } from 'svelte-i18n';
import FormatMessage from '$lib/components/i18n/format-message.svelte';
import type { Snippet } from 'svelte';
import { createBubbler, preventDefault } from 'svelte/legacy';
import { fade } from 'svelte/transition';
interface Props {
savedConfig: SystemConfigDto;
defaultConfig: SystemConfigDto;
config: SystemConfigDto;
disabled?: boolean;
type Props = {
minified?: boolean;
onReset: SettingsResetEvent;
onSave: SettingsSaveEvent;
duration?: number;
children?: Snippet;
}
saveOnClose?: boolean;
};
let {
savedConfig,
defaultConfig,
config = $bindable(),
disabled = false,
minified = false,
onReset,
onSave,
duration = 500,
children,
}: Props = $props();
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 = savedConfig.storageTemplate.template;
selectedPreset = config.storageTemplate.template;
};
const getSupportDateTimeFormat = () => getStorageTemplateOptions();
@@ -101,15 +86,21 @@
};
const handlePresetSelection = () => {
config.storageTemplate.template = selectedPreset;
configToEdit.storageTemplate.template = selectedPreset;
};
let parsedTemplate = $derived(() => {
try {
return renderTemplate(config.storageTemplate.template);
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">
@@ -119,7 +110,7 @@
{#snippet children({ tag, message })}
{#if tag === 'template-link'}
<a
href="https://immich.app/docs/administration/storage-template"
href="https://docs.immich.app/administration/storage-template"
class="underline"
target="_blank"
rel="noreferrer"
@@ -128,7 +119,7 @@
</a>
{:else if tag === 'implications-link'}
<a
href="https://immich.app/docs/administration/backup-and-restore#asset-types-and-storage-locations"
href="https://docs.immich.app/administration/backup-and-restore#asset-types-and-storage-locations"
class="underline"
target="_blank"
rel="noreferrer"
@@ -145,8 +136,8 @@
<SettingSwitch
title={$t('admin.storage_template_enable_description')}
{disabled}
bind:checked={config.storageTemplate.enabled}
isEdited={!(config.storageTemplate.enabled === savedConfig.storageTemplate.enabled)}
bind:checked={configToEdit.storageTemplate.enabled}
isEdited={!(configToEdit.storageTemplate.enabled === config.storageTemplate.enabled)}
/>
{#if !minified}
@@ -154,17 +145,17 @@
title={$t('admin.storage_template_hash_verification_enabled')}
{disabled}
subtitle={$t('admin.storage_template_hash_verification_enabled_description')}
bind:checked={config.storageTemplate.hashVerificationEnabled}
bind:checked={configToEdit.storageTemplate.hashVerificationEnabled}
isEdited={!(
config.storageTemplate.hashVerificationEnabled === savedConfig.storageTemplate.hashVerificationEnabled
configToEdit.storageTemplate.hashVerificationEnabled === config.storageTemplate.hashVerificationEnabled
)}
/>
{/if}
{#if config.storageTemplate.enabled}
{#if configToEdit.storageTemplate.enabled}
<hr />
<h3 class="text-base font-medium text-immich-primary dark:text-immich-dark-primary">{$t('variables')}</h3>
<h3 class="text-base font-medium text-primary">{$t('variables')}</h3>
<section class="support-date">
{#await getSupportDateTimeFormat()}
@@ -181,10 +172,10 @@
</section>
<div class="flex flex-col mt-4">
<h3 class="text-base font-medium text-immich-primary dark:text-immich-dark-primary">{$t('template')}</h3>
<h3 class="text-base font-medium text-primary">{$t('template')}</h3>
<div class="my-2 text-sm">
<h4>{$t('preview').toUpperCase()}</h4>
<h4 class="uppercase">{$t('preview')}</h4>
</div>
<p class="text-sm">
@@ -193,7 +184,7 @@
values={{ length: parsedTemplate().length + $user.id.length + 'UPLOAD_LOCATION'.length, limit: 260 }}
>
{#snippet children({ message })}
<span class="font-semibold text-immich-primary dark:text-immich-dark-primary">{message}</span>
<span class="font-semibold text-primary">{message}</span>
{/snippet}
</FormatMessage>
</p>
@@ -201,7 +192,7 @@
<p class="text-sm">
<FormatMessage key="admin.storage_template_user_label" values={{ label: $user.storageLabel || $user.id }}>
{#snippet children({ message })}
<code class="text-immich-primary dark:text-immich-dark-primary">{message}</code>
<code class="text-primary">{message}</code>
{/snippet}
</FormatMessage>
</p>
@@ -215,15 +206,12 @@
<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-immich-primary dark:text-immich-dark-primary text-sm"
for="preset-select"
>
<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 || !config.storageTemplate.enabled}
disabled={disabled || !configToEdit.storageTemplate.enabled}
name="presets"
id="preset-select"
bind:value={selectedPreset}
@@ -239,11 +227,11 @@
<div class="flex gap-2 align-bottom">
<SettingInputField
label={$t('template')}
disabled={disabled || !config.storageTemplate.enabled}
disabled={disabled || !configToEdit.storageTemplate.enabled}
required
inputType={SettingInputFieldType.TEXT}
bind:value={config.storageTemplate.template}
isEdited={!(config.storageTemplate.template === savedConfig.storageTemplate.template)}
bind:value={configToEdit.storageTemplate.template}
isEdited={!(configToEdit.storageTemplate.template === config.storageTemplate.template)}
/>
<div class="flex-0">
@@ -258,7 +246,7 @@
{#if !minified}
<div id="migration-info" class="mt-2 text-sm">
<h3 class="text-base font-medium text-immich-primary dark:text-immich-dark-primary">{$t('notes')}</h3>
<h3 class="text-base font-medium text-primary">{$t('notes')}</h3>
<section class="flex flex-col gap-2">
<p>
<FormatMessage
@@ -266,7 +254,7 @@
values={{ job: $t('admin.storage_template_migration_job') }}
>
{#snippet children({ message })}
<a href={AppRoute.ADMIN_JOBS} class="text-immich-primary dark:text-immich-dark-primary">
<a href={resolve(AppRoute.ADMIN_JOBS)} class="text-primary">
{message}
</a>
{/snippet}
@@ -279,15 +267,8 @@
</div>
{/if}
{#if minified}
{@render children?.()}
{:else}
<SettingButtonsRow
onReset={(options) => onReset({ ...options, configKeys: ['storageTemplate'] })}
onSave={() => onSave({ storageTemplate: config.storageTemplate })}
showResetToDefault={!isEqual(savedConfig.storageTemplate, defaultConfig.storageTemplate) && !minified}
{disabled}
/>
{#if !minified}
<SettingButtonsRow bind:configToEdit keys={['storageTemplate']} {disabled} />
{/if}
</div>
{/await}
@@ -11,23 +11,23 @@
let { options }: Props = $props();
const getLuxonExample = (format: string) => {
return DateTime.fromISO('2022-09-04T20:03:05.250Z', { locale: $locale }).toFormat(format);
return DateTime.fromISO('2022-02-15T20:03:05.250Z', { locale: $locale }).toFormat(format);
};
</script>
<div class="mt-2 text-sm">
<h4>{$t('date_and_time').toUpperCase()}</h4>
<h4 class="uppercase">{$t('date_and_time')}</h4>
</div>
<!-- eslint-disable svelte/no-useless-mustaches -->
<div class="mt-2 rounded-lg bg-gray-200 p-4 text-xs dark:bg-gray-700 dark:text-immich-dark-fg">
<div class="mb-2 text-gray-600 dark:text-immich-dark-fg">
<p>{$t('admin.storage_template_date_time_description')}</p>
<p>{$t('admin.storage_template_date_time_sample', { values: { date: '2022-09-04T20:03:05.250' } })}</p>
<p>{$t('admin.storage_template_date_time_sample', { values: { date: '2022-02-03T20:03:05.250' } })}</p>
</div>
<div class="flex gap-[40px]">
<div class="flex gap-10">
<div>
<p class="font-medium text-immich-primary dark:text-immich-dark-primary">{$t('year').toUpperCase()}</p>
<p class="uppercase font-medium text-primary">{$t('year')}</p>
<ul>
{#each options.yearOptions as yearFormat, index (index)}
<li>{'{{'}{yearFormat}{'}}'} - {getLuxonExample(yearFormat)}</li>
@@ -36,7 +36,7 @@
</div>
<div>
<p class="font-medium text-immich-primary dark:text-immich-dark-primary">{$t('month').toUpperCase()}</p>
<p class="uppercase font-medium text-primary">{$t('month')}</p>
<ul>
{#each options.monthOptions as monthFormat, index (index)}
<li>{'{{'}{monthFormat}{'}}'} - {getLuxonExample(monthFormat)}</li>
@@ -45,7 +45,7 @@
</div>
<div>
<p class="font-medium text-immich-primary dark:text-immich-dark-primary">{$t('week').toUpperCase()}</p>
<p class="uppercase font-medium text-primary">{$t('week')}</p>
<ul>
{#each options.weekOptions as weekFormat, index (index)}
<li>{'{{'}{weekFormat}{'}}'} - {getLuxonExample(weekFormat)}</li>
@@ -54,7 +54,7 @@
</div>
<div>
<p class="font-medium text-immich-primary dark:text-immich-dark-primary">{$t('day').toUpperCase()}</p>
<p class="uppercase font-medium text-primary">{$t('day')}</p>
<ul>
{#each options.dayOptions as dayFormat, index (index)}
<li>{'{{'}{dayFormat}{'}}'} - {getLuxonExample(dayFormat)}</li>
@@ -63,7 +63,7 @@
</div>
<div>
<p class="font-medium text-immich-primary dark:text-immich-dark-primary">{$t('hour').toUpperCase()}</p>
<p class="uppercase font-medium text-primary">{$t('hour')}</p>
<ul>
{#each options.hourOptions as dayFormat, index (index)}
<li>{'{{'}{dayFormat}{'}}'} - {getLuxonExample(dayFormat)}</li>
@@ -72,7 +72,7 @@
</div>
<div>
<p class="font-medium text-immich-primary dark:text-immich-dark-primary">{$t('minute').toUpperCase()}</p>
<p class="uppercase font-medium text-primary">{$t('minute')}</p>
<ul>
{#each options.minuteOptions as dayFormat, index (index)}
<li>{'{{'}{dayFormat}{'}}'} - {getLuxonExample(dayFormat)}</li>
@@ -81,7 +81,7 @@
</div>
<div>
<p class="font-medium text-immich-primary dark:text-immich-dark-primary">{$t('second').toUpperCase()}</p>
<p class="uppercase font-medium text-primary">{$t('second')}</p>
<ul>
{#each options.secondOptions as dayFormat, index (index)}
<li>{'{{'}{dayFormat}{'}}'} - {getLuxonExample(dayFormat)}</li>
@@ -3,13 +3,13 @@
</script>
<div class="mt-4 text-sm">
<h4>{$t('other_variables').toUpperCase()}</h4>
<h4 class="uppercase">{$t('other_variables')}</h4>
</div>
<div class="p-4 mt-2 text-xs bg-gray-200 rounded-lg dark:bg-gray-700 dark:text-immich-dark-fg">
<div class="flex gap-[50px]">
<div class="flex gap-12">
<div>
<p class="font-medium text-immich-primary dark:text-immich-dark-primary">{$t('filename').toUpperCase()}</p>
<p class="uppercase font-medium text-primary">{$t('filename')}</p>
<ul>
<li>{`{{filename}}`} - IMG_123</li>
<li>{`{{ext}}`} - jpg</li>
@@ -17,14 +17,14 @@
</div>
<div>
<p class="font-medium text-immich-primary dark:text-immich-dark-primary">{$t('filetype').toUpperCase()}</p>
<p class="uppercase font-medium text-primary">{$t('filetype')}</p>
<ul>
<li>{`{{filetype}}`} - VID or IMG</li>
<li>{`{{filetypefull}}`} - VIDEO or IMAGE</li>
</ul>
</div>
<div>
<p class="font-medium text-immich-primary dark:text-immich-dark-primary uppercase">{$t('other').toUpperCase()}</p>
<p class="uppercase font-medium text-primary">{$t('other')}</p>
<ul>
<li>{`{{assetId}}`} - Asset ID</li>
<li>{`{{assetIdShort}}`} - Asset ID (last 12 characters)</li>
@@ -1,23 +1,21 @@
<script lang="ts">
import Icon from '$lib/components/elements/icon.svelte';
import FormatMessage from '$lib/components/i18n/format-message.svelte';
import LoadingSpinner from '$lib/components/shared-components/loading-spinner.svelte';
import SettingAccordion from '$lib/components/shared-components/settings/setting-accordion.svelte';
import SettingTextarea from '$lib/components/shared-components/settings/setting-textarea.svelte';
import FormatMessage from '$lib/elements/FormatMessage.svelte';
import { systemConfigManager } from '$lib/managers/system-config-manager.svelte';
import EmailTemplatePreviewModal from '$lib/modals/EmailTemplatePreviewModal.svelte';
import { handleError } from '$lib/utils/handle-error';
import { type SystemConfigDto, type SystemConfigTemplateEmailsDto, getNotificationTemplateAdmin } from '@immich/sdk';
import { Button, modalManager } from '@immich/ui';
import { Button, Icon, LoadingSpinner, modalManager } from '@immich/ui';
import { mdiEyeOutline } from '@mdi/js';
import { t } from 'svelte-i18n';
import { fade } from 'svelte/transition';
interface Props {
savedConfig: SystemConfigDto;
config: SystemConfigDto;
}
let { savedConfig, config = $bindable() }: Props = $props();
let { config = $bindable() }: Props = $props();
let loadingPreview = $state(false);
@@ -55,7 +53,7 @@
];
const isEdited = (templateKey: keyof SystemConfigTemplateEmailsDto) =>
config.templates.email[templateKey] !== savedConfig.templates.email[templateKey];
config.templates.email[templateKey] !== systemConfigManager.value.templates.email[templateKey];
const onsubmit = (event: Event) => {
event.preventDefault();
@@ -96,7 +94,7 @@
onclick={() => getTemplate(templateName, config.templates.email[templateKey])}
title={$t('admin.template_email_preview')}
>
<Icon path={mdiEyeOutline} class="me-1" />
<Icon icon={mdiEyeOutline} class="me-1" />
{$t('admin.template_email_preview')}
</Button>
</div>
@@ -0,0 +1,30 @@
<script lang="ts">
import SettingButtonsRow from '$lib/components/shared-components/settings/SystemConfigButtonRow.svelte';
import SettingTextarea from '$lib/components/shared-components/settings/setting-textarea.svelte';
import { featureFlagsManager } from '$lib/managers/feature-flags-manager.svelte';
import { systemConfigManager } from '$lib/managers/system-config-manager.svelte';
import { t } from 'svelte-i18n';
import { fade } from 'svelte/transition';
const disabled = $derived(featureFlagsManager.value.configFile);
const config = $derived(systemConfigManager.value);
let configToEdit = $state(systemConfigManager.cloneValue());
</script>
<div>
<div in:fade={{ duration: 500 }}>
<form autocomplete="off" onsubmit={(event) => event.preventDefault()}>
<div class="ms-4 mt-4 flex flex-col gap-4">
<SettingTextarea
{disabled}
label={$t('admin.theme_custom_css_settings')}
description={$t('admin.theme_custom_css_settings_description')}
bind:value={configToEdit.theme.customCss}
isEdited={configToEdit.theme.customCss !== config.theme.customCss}
/>
<SettingButtonsRow bind:configToEdit keys={['theme']} {disabled} />
</div>
</form>
</div>
</div>
@@ -0,0 +1,42 @@
<script lang="ts">
import SettingButtonsRow from '$lib/components/shared-components/settings/SystemConfigButtonRow.svelte';
import SettingInputField from '$lib/components/shared-components/settings/setting-input-field.svelte';
import SettingSwitch from '$lib/components/shared-components/settings/setting-switch.svelte';
import { SettingInputFieldType } from '$lib/constants';
import { featureFlagsManager } from '$lib/managers/feature-flags-manager.svelte';
import { systemConfigManager } from '$lib/managers/system-config-manager.svelte';
import { t } from 'svelte-i18n';
import { fade } from 'svelte/transition';
const disabled = $derived(featureFlagsManager.value.configFile);
const config = $derived(systemConfigManager.value);
let configToEdit = $state(systemConfigManager.cloneValue());
</script>
<div>
<div in:fade={{ duration: 500 }}>
<form autocomplete="off" onsubmit={(event) => event.preventDefault()}>
<div class="ms-4 mt-4 flex flex-col gap-4">
<SettingSwitch
title={$t('admin.trash_enabled_description')}
{disabled}
bind:checked={configToEdit.trash.enabled}
/>
<hr />
<SettingInputField
inputType={SettingInputFieldType.NUMBER}
label={$t('admin.trash_number_of_days')}
description={$t('admin.trash_number_of_days_description')}
bind:value={configToEdit.trash.days}
required={true}
disabled={disabled || !configToEdit.trash.enabled}
isEdited={configToEdit.trash.days !== config.trash.days}
/>
<SettingButtonsRow bind:configToEdit keys={['trash']} {disabled} />
</div>
</form>
</div>
</div>
@@ -0,0 +1,35 @@
<script lang="ts">
import { fade } from 'svelte/transition';
import SettingButtonsRow from '$lib/components/shared-components/settings/SystemConfigButtonRow.svelte';
import SettingInputField from '$lib/components/shared-components/settings/setting-input-field.svelte';
import { SettingInputFieldType } from '$lib/constants';
import { featureFlagsManager } from '$lib/managers/feature-flags-manager.svelte';
import { systemConfigManager } from '$lib/managers/system-config-manager.svelte';
import { t } from 'svelte-i18n';
const disabled = $derived(featureFlagsManager.value.configFile);
const config = $derived(systemConfigManager.value);
let configToEdit = $state(systemConfigManager.cloneValue());
</script>
<div>
<div in:fade={{ duration: 500 }}>
<form autocomplete="off" onsubmit={(e) => e.preventDefault()}>
<div class="ms-4 mt-4 flex flex-col gap-4">
<SettingInputField
inputType={SettingInputFieldType.NUMBER}
min={1}
label={$t('admin.user_delete_delay_settings')}
description={$t('admin.user_delete_delay_settings_description')}
bind:value={configToEdit.user.deleteDelay}
isEdited={configToEdit.user.deleteDelay !== config.user.deleteDelay}
/>
</div>
<div class="ms-4">
<SettingButtonsRow bind:configToEdit keys={['user']} {disabled} />
</div>
</form>
</div>
</div>
@@ -1,11 +1,12 @@
<script lang="ts">
import { resolve } from '$app/paths';
import AlbumCard from '$lib/components/album-page/album-card.svelte';
import Icon from '$lib/components/elements/icon.svelte';
import { AppRoute } from '$lib/constants';
import { albumViewSettings } from '$lib/stores/preferences.store';
import { type AlbumGroup, isAlbumGroupCollapsed, toggleAlbumGroupCollapsing } from '$lib/utils/album-utils';
import type { ContextMenuPosition } from '$lib/utils/context-menu';
import type { AlbumResponseDto } from '@immich/sdk';
import { Icon } from '@immich/ui';
import { mdiChevronRight } from '@mdi/js';
import { t } from 'svelte-i18n';
import { flip } from 'svelte/animate';
@@ -48,10 +49,10 @@
<button
type="button"
onclick={() => toggleAlbumGroupCollapsing(group.id)}
class="w-full text-start mt-2 pt-2 pe-2 pb-2 rounded-md transition-colors cursor-pointer dark:text-immich-dark-fg hover:text-immich-primary dark:hover:text-immich-dark-primary hover:bg-subtle dark:hover:bg-immich-dark-gray"
class="w-full text-start mt-2 pt-2 pe-2 pb-2 rounded-md transition-colors cursor-pointer dark:text-immich-dark-fg hover:text-primary hover:bg-subtle dark:hover:bg-immich-dark-gray"
aria-expanded={!isCollapsed}
>
<Icon path={mdiChevronRight} size="24" class="inline-block -mt-2.5 transition-all duration-250 {iconRotation}" />
<Icon icon={mdiChevronRight} size="24" class="inline-block -mt-2.5 transition-all duration-250 {iconRotation}" />
<span class="font-bold text-3xl text-black dark:text-white">{group.name}</span>
<span class="ms-1.5">({$t('albums_count', { values: { count: albums.length } })})</span>
</button>
@@ -65,7 +66,7 @@
{#each albums as album, index (album.id)}
<a
data-sveltekit-preload-data="hover"
href="{AppRoute.ALBUMS}/{album.id}"
href={resolve(`${AppRoute.ALBUMS}/${album.id}`)}
animate:flip={{ duration: 400 }}
oncontextmenu={(event) => oncontextmenu(event, album)}
>
@@ -48,7 +48,7 @@
aria-label={$t('show_album_options')}
icon={mdiDotsVertical}
shape="round"
variant="ghost"
variant="filled"
size="medium"
class="icon-white-drop-shadow"
onclick={showAlbumContextMenu}
@@ -60,7 +60,7 @@
<div class="mt-4">
<p
class="w-full leading-6 text-lg line-clamp-2 font-semibold text-black dark:text-white group-hover:text-immich-primary dark:group-hover:text-immich-dark-primary"
class="w-full leading-6 text-lg line-clamp-2 font-semibold text-black dark:text-white group-hover:text-primary"
data-testid="album-name"
title={album.albumName}
>
@@ -1,4 +1,5 @@
<script lang="ts">
import { authManager } from '$lib/managers/auth-manager.svelte';
import MapModal from '$lib/modals/MapModal.svelte';
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
import { getAlbumInfo, type AlbumResponseDto, type MapMarkerResponseDto } from '@immich/sdk';
@@ -32,7 +33,7 @@
}
abortController = new AbortController();
let albumInfo: AlbumResponseDto = await getAlbumInfo({ id: album.id, withoutAssets: false });
let albumInfo: AlbumResponseDto = await getAlbumInfo({ id: album.id, withoutAssets: false, ...authManager.params });
let markers: MapMarkerResponseDto[] = [];
for (const asset of albumInfo.assets) {
@@ -1,19 +1,18 @@
<script lang="ts">
import SharedLinkCopy from '$lib/components/sharedlinks-page/actions/shared-link-copy.svelte';
import ActionButton from '$lib/components/ActionButton.svelte';
import { getSharedLinkActions } from '$lib/services/shared-link.service';
import { locale } from '$lib/stores/preferences.store';
import type { AlbumResponseDto, SharedLinkResponseDto } from '@immich/sdk';
import { IconButton, Text } from '@immich/ui';
import { mdiQrcode } from '@mdi/js';
import { Text } from '@immich/ui';
import { DateTime } from 'luxon';
import { t } from 'svelte-i18n';
type Props = {
album: AlbumResponseDto;
sharedLink: SharedLinkResponseDto;
onViewQrCode: () => void;
};
const { album, sharedLink, onViewQrCode }: Props = $props();
const { album, sharedLink }: Props = $props();
const getShareProperties = () =>
[
@@ -32,6 +31,8 @@
]
.filter(Boolean)
.join(' • ');
const SharedLinkActions = $derived(getSharedLinkActions($t, sharedLink));
</script>
<div class="flex justify-between items-center">
@@ -40,14 +41,7 @@
<Text size="tiny" color="muted">{getShareProperties()}</Text>
</div>
<div class="flex">
<IconButton
aria-label={$t('view_qr_code')}
shape="round"
color="secondary"
variant="ghost"
icon={mdiQrcode}
onclick={onViewQrCode}
/>
<SharedLinkCopy link={sharedLink} />
<ActionButton action={SharedLinkActions.ViewQrCode} />
<ActionButton action={SharedLinkActions.Copy} />
</div>
</div>
@@ -38,7 +38,7 @@
<input
use:shortcut={{ shortcut: { key: 'Enter' }, onShortcut: (e) => e.currentTarget.blur() }}
onblur={handleUpdateName}
class="w-[99%] mb-2 border-b-2 border-transparent text-2xl md:text-4xl lg:text-6xl text-immich-primary outline-none transition-all dark:text-immich-dark-primary {isOwned
class="w-[99%] mb-2 border-b-2 border-transparent text-2xl md:text-4xl lg:text-6xl text-primary outline-none transition-all {isOwned
? 'hover:border-gray-400'
: 'hover:border-transparent'} focus:border-b-2 focus:border-immich-primary focus:outline-none bg-light dark:focus:border-immich-dark-primary dark:focus:bg-immich-dark-gray placeholder:text-primary/90"
type="text"
@@ -2,25 +2,24 @@
import { shortcut } from '$lib/actions/shortcut';
import CastButton from '$lib/cast/cast-button.svelte';
import AlbumMap from '$lib/components/album-page/album-map.svelte';
import SelectAllAssets from '$lib/components/photos-page/actions/select-all-assets.svelte';
import AssetSelectControlBar from '$lib/components/photos-page/asset-select-control-bar.svelte';
import DownloadAction from '$lib/components/timeline/actions/DownloadAction.svelte';
import SelectAllAssets from '$lib/components/timeline/actions/SelectAllAction.svelte';
import AssetSelectControlBar from '$lib/components/timeline/AssetSelectControlBar.svelte';
import Timeline from '$lib/components/timeline/Timeline.svelte';
import { featureFlagsManager } from '$lib/managers/feature-flags-manager.svelte';
import { TimelineManager } from '$lib/managers/timeline-manager/timeline-manager.svelte';
import { handleDownloadAlbum } from '$lib/services/album.service';
import { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
import { dragAndDropFilesStore } from '$lib/stores/drag-and-drop-files.store';
import { featureFlags } from '$lib/stores/server-config.store';
import { handlePromiseError } from '$lib/utils';
import { cancelMultiselect, downloadAlbum } from '$lib/utils/asset-utils';
import { cancelMultiselect } from '$lib/utils/asset-utils';
import { fileUploadHandler, openFileUploadDialog } from '$lib/utils/file-uploader';
import type { AlbumResponseDto, SharedLinkResponseDto, UserResponseDto } from '@immich/sdk';
import { IconButton } from '@immich/ui';
import { IconButton, Logo } from '@immich/ui';
import { mdiDownload, mdiFileImagePlusOutline } from '@mdi/js';
import { onDestroy } from 'svelte';
import { t } from 'svelte-i18n';
import DownloadAction from '../photos-page/actions/download-action.svelte';
import AssetGrid from '../photos-page/asset-grid.svelte';
import ControlAppBar from '../shared-components/control-app-bar.svelte';
import ImmichLogoSmallLink from '../shared-components/immich-logo-small-link.svelte';
import ThemeButton from '../shared-components/theme-button.svelte';
import AlbumSummary from './album-summary.svelte';
@@ -35,9 +34,8 @@
let { isViewing: showAssetViewer } = assetViewingStore;
const timelineManager = new TimelineManager();
$effect(() => void timelineManager.updateOptions({ albumId: album.id, order: album.order }));
onDestroy(() => timelineManager.destroy());
const options = $derived({ albumId: album.id, order: album.order });
let timelineManager = $state<TimelineManager>() as TimelineManager;
const assetInteraction = new AssetInteraction();
@@ -61,12 +59,10 @@
/>
<main class="relative h-dvh overflow-hidden px-2 md:px-6 max-md:pt-(--navbar-height-md) pt-(--navbar-height)">
<AssetGrid enableRouting={true} {album} {timelineManager} {assetInteraction}>
<Timeline enableRouting={true} {album} bind:timelineManager {options} {assetInteraction}>
<section class="pt-8 md:pt-24 px-2 md:px-0">
<!-- ALBUM TITLE -->
<h1
class="text-2xl md:text-4xl lg:text-6xl text-immich-primary outline-none transition-all dark:text-immich-dark-primary"
>
<h1 class="text-2xl md:text-4xl lg:text-6xl text-primary outline-none transition-all">
{album.albumName}
</h1>
@@ -83,7 +79,7 @@
</p>
{/if}
</section>
</AssetGrid>
</Timeline>
</main>
<header>
@@ -101,7 +97,9 @@
{:else}
<ControlAppBar showBackButton={false}>
{#snippet leading()}
<ImmichLogoSmallLink />
<a data-sveltekit-preload-data="hover" class="ms-4" href="/">
<Logo variant="inline" />
</a>
{/snippet}
{#snippet trailing()}
@@ -124,11 +122,11 @@
color="secondary"
variant="ghost"
aria-label={$t('download')}
onclick={() => downloadAlbum(album)}
onclick={() => handleDownloadAlbum(album)}
icon={mdiDownload}
/>
{/if}
{#if sharedLink.showMetadata && $featureFlags.loaded && $featureFlags.map}
{#if sharedLink.showMetadata && featureFlagsManager.value.map}
<AlbumMap {album} />
{/if}
<ThemeButton />
@@ -1,7 +1,7 @@
<script lang="ts">
import Dropdown from '$lib/components/elements/dropdown.svelte';
import GroupTab from '$lib/components/elements/group-tab.svelte';
import SearchBar from '$lib/components/elements/search-bar.svelte';
import Dropdown from '$lib/elements/Dropdown.svelte';
import GroupTab from '$lib/elements/GroupTab.svelte';
import SearchBar from '$lib/elements/SearchBar.svelte';
import {
AlbumFilter,
AlbumGroupBy,
@@ -1,18 +1,17 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { resolve } from '$app/paths';
import AlbumCardGroup from '$lib/components/album-page/album-card-group.svelte';
import AlbumsTable from '$lib/components/album-page/albums-table.svelte';
import OnEvents from '$lib/components/OnEvents.svelte';
import MenuOption from '$lib/components/shared-components/context-menu/menu-option.svelte';
import RightClickContextMenu from '$lib/components/shared-components/context-menu/right-click-context-menu.svelte';
import {
NotificationType,
notificationController,
} from '$lib/components/shared-components/notification/notification';
import ToastAction from '$lib/components/ToastAction.svelte';
import { AppRoute } from '$lib/constants';
import AlbumEditModal from '$lib/modals/AlbumEditModal.svelte';
import AlbumShareModal from '$lib/modals/AlbumShareModal.svelte';
import QrCodeModal from '$lib/modals/QrCodeModal.svelte';
import SharedLinkCreateModal from '$lib/modals/SharedLinkCreateModal.svelte';
import { handleDeleteAlbum, handleDownloadAlbum } from '$lib/services/album.service';
import {
AlbumFilter,
AlbumGroupBy,
@@ -24,20 +23,12 @@
} from '$lib/stores/preferences.store';
import { user } from '$lib/stores/user.store';
import { userInteraction } from '$lib/stores/user.svelte';
import { makeSharedLinkUrl } from '$lib/utils';
import {
confirmAlbumDelete,
getSelectedAlbumGroupOption,
sortAlbums,
stringToSortOrder,
type AlbumGroup,
} from '$lib/utils/album-utils';
import { downloadAlbum } from '$lib/utils/asset-utils';
import { getSelectedAlbumGroupOption, sortAlbums, stringToSortOrder, type AlbumGroup } from '$lib/utils/album-utils';
import type { ContextMenuPosition } from '$lib/utils/context-menu';
import { handleError } from '$lib/utils/handle-error';
import { normalizeSearchString } from '$lib/utils/string-utils';
import { addUsersToAlbum, deleteAlbum, isHttpError, type AlbumResponseDto, type AlbumUserAddDto } from '@immich/sdk';
import { modalManager } from '@immich/ui';
import { addUsersToAlbum, type AlbumResponseDto, type AlbumUserAddDto } from '@immich/sdk';
import { modalManager, toastManager } from '@immich/ui';
import { mdiDeleteOutline, mdiDownload, mdiRenameOutline, mdiShareVariantOutline } from '@mdi/js';
import { groupBy } from 'lodash-es';
import { onMount, type Snippet } from 'svelte';
@@ -144,10 +135,9 @@
let albumGroupOption: string = $state(AlbumGroupBy.None);
let albumToShare: AlbumResponseDto | null = $state(null);
let albumToDelete: AlbumResponseDto | null = null;
let contextMenuPosition: ContextMenuPosition = $state({ x: 0, y: 0 });
let contextMenuTargetAlbum: AlbumResponseDto | undefined = $state();
let selectedAlbum: AlbumResponseDto | undefined = $state();
let isOpen = $state(false);
// Step 1: Filter between Owned and Shared albums, or both.
@@ -200,9 +190,7 @@
albumGroupIds = groupedAlbums.map(({ id }) => id);
});
let showFullContextMenu = $derived(
allowEdit && contextMenuTargetAlbum && contextMenuTargetAlbum.ownerId === $user.id,
);
let showFullContextMenu = $derived(allowEdit && selectedAlbum && selectedAlbum.ownerId === $user.id);
onMount(async () => {
if (allowEdit) {
@@ -211,7 +199,7 @@
});
const showAlbumContextMenu = (contextMenuDetail: ContextMenuPosition, album: AlbumResponseDto) => {
contextMenuTargetAlbum = album;
selectedAlbum = album;
contextMenuPosition = {
x: contextMenuDetail.x,
y: contextMenuDetail.y,
@@ -223,75 +211,58 @@
isOpen = false;
};
const handleDownloadAlbum = async () => {
if (contextMenuTargetAlbum) {
const album = contextMenuTargetAlbum;
closeAlbumContextMenu();
await downloadAlbum(album);
}
};
const handleSelect = async (action: 'edit' | 'share' | 'download' | 'delete') => {
closeAlbumContextMenu();
const handleDeleteAlbum = async (albumToDelete: AlbumResponseDto) => {
try {
await deleteAlbum({
id: albumToDelete.id,
});
} catch (error) {
// In rare cases deleting an album completes after the list of albums has been requested,
// leading to a bad request error.
// Since the album is already deleted, the error is ignored.
const isBadRequest = isHttpError(error) && error.status === 400;
if (!isBadRequest) {
throw error;
if (!selectedAlbum) {
return;
}
switch (action) {
case 'edit': {
const editedAlbum = await modalManager.show(AlbumEditModal, { album: selectedAlbum });
if (editedAlbum) {
successEditAlbumInfo(editedAlbum);
}
break;
}
}
ownedAlbums = ownedAlbums.filter(({ id }) => id !== albumToDelete.id);
sharedAlbums = sharedAlbums.filter(({ id }) => id !== albumToDelete.id);
};
case 'share': {
const result = await modalManager.show(AlbumShareModal, { album: selectedAlbum });
switch (result?.action) {
case 'sharedUsers': {
await handleAddUsers(result.data);
break;
}
const setAlbumToDelete = async () => {
albumToDelete = contextMenuTargetAlbum ?? null;
closeAlbumContextMenu();
await deleteSelectedAlbum();
};
case 'sharedLink': {
const success = await modalManager.show(SharedLinkCreateModal, { albumId: selectedAlbum.id });
if (success) {
selectedAlbum.shared = true;
selectedAlbum.hasSharedLink = true;
updateAlbumInfo(selectedAlbum);
}
break;
}
}
break;
}
const handleEdit = async (album: AlbumResponseDto) => {
closeAlbumContextMenu();
const editedAlbum = await modalManager.show(AlbumEditModal, {
album,
});
if (editedAlbum) {
successEditAlbumInfo(editedAlbum);
}
};
case 'download': {
await handleDownloadAlbum(selectedAlbum);
break;
}
const deleteSelectedAlbum = async () => {
if (!albumToDelete) {
return;
}
const isConfirmed = await confirmAlbumDelete(albumToDelete);
if (!isConfirmed) {
return;
}
try {
await handleDeleteAlbum(albumToDelete);
} catch {
notificationController.show({
message: $t('errors.unable_to_delete_album'),
type: NotificationType.Error,
});
} finally {
albumToDelete = null;
case 'delete': {
await handleDeleteAlbum(selectedAlbum);
break;
}
}
};
const removeAlbumsIfEmpty = async () => {
const albumsToRemove = ownedAlbums.filter((album) => album.assetCount === 0 && !album.albumName);
await Promise.allSettled(albumsToRemove.map((album) => handleDeleteAlbum(album)));
await Promise.allSettled(albumsToRemove.map((album) => handleDeleteAlbum(album, { prompt: false, notify: false })));
};
const updateAlbumInfo = (album: AlbumResponseDto) => {
@@ -309,13 +280,18 @@
};
const successEditAlbumInfo = (album: AlbumResponseDto) => {
notificationController.show({
message: $t('album_info_updated'),
type: NotificationType.Info,
button: {
text: $t('view_album'),
onClick() {
return goto(`${AppRoute.ALBUMS}/${album.id}`);
toastManager.custom({
component: ToastAction,
props: {
color: 'primary',
title: $t('success'),
description: $t('album_info_updated'),
button: {
text: $t('view_album'),
color: 'primary',
onClick() {
return goto(resolve(`${AppRoute.ALBUMS}/${album.id}`));
},
},
},
});
@@ -343,40 +319,14 @@
}
};
const handleSharedLinkCreated = (album: AlbumResponseDto) => {
album.shared = true;
album.hasSharedLink = true;
updateAlbumInfo(album);
};
const openShareModal = async () => {
if (!contextMenuTargetAlbum) {
return;
}
albumToShare = contextMenuTargetAlbum;
closeAlbumContextMenu();
const result = await modalManager.show(AlbumShareModal, { album: albumToShare });
switch (result?.action) {
case 'sharedUsers': {
await handleAddUsers(result.data);
return;
}
case 'sharedLink': {
const sharedLink = await modalManager.show(SharedLinkCreateModal, { albumId: albumToShare.id });
if (sharedLink) {
handleSharedLinkCreated(albumToShare);
await modalManager.show(QrCodeModal, { title: $t('view_link'), value: makeSharedLinkUrl(sharedLink) });
}
return;
}
}
const onAlbumDelete = (album: AlbumResponseDto) => {
ownedAlbums = ownedAlbums.filter(({ id }) => id !== album.id);
sharedAlbums = sharedAlbums.filter(({ id }) => id !== album.id);
};
</script>
<OnEvents {onAlbumDelete} />
{#if albums.length > 0}
{#if userSettings.view === AlbumViewMode.Cover}
<!-- Album Cards -->
@@ -412,15 +362,11 @@
<!-- Context Menu -->
<RightClickContextMenu title={$t('album_options')} {...contextMenuPosition} {isOpen} onClose={closeAlbumContextMenu}>
{#if showFullContextMenu}
<MenuOption
icon={mdiRenameOutline}
text={$t('edit_album')}
onClick={() => contextMenuTargetAlbum && handleEdit(contextMenuTargetAlbum)}
/>
<MenuOption icon={mdiShareVariantOutline} text={$t('share')} onClick={() => openShareModal()} />
<MenuOption icon={mdiRenameOutline} text={$t('edit_album')} onClick={() => handleSelect('edit')} />
<MenuOption icon={mdiShareVariantOutline} text={$t('share')} onClick={() => handleSelect('share')} />
{/if}
<MenuOption icon={mdiDownload} text={$t('download')} onClick={() => handleDownloadAlbum()} />
<MenuOption icon={mdiDownload} text={$t('download')} onClick={() => handleSelect('download')} />
{#if showFullContextMenu}
<MenuOption icon={mdiDeleteOutline} text={$t('delete')} onClick={() => setAlbumToDelete()} />
<MenuOption icon={mdiDeleteOutline} text={$t('delete')} onClick={() => handleSelect('delete')} />
{/if}
</RightClickContextMenu>
@@ -1,11 +1,12 @@
<script lang="ts">
import { goto } from '$app/navigation';
import Icon from '$lib/components/elements/icon.svelte';
import { resolve } from '$app/paths';
import { AppRoute, dateFormats } from '$lib/constants';
import { locale } from '$lib/stores/preferences.store';
import { user } from '$lib/stores/user.store';
import type { ContextMenuPosition } from '$lib/utils/context-menu';
import type { AlbumResponseDto } from '@immich/sdk';
import { Icon } from '@immich/ui';
import { mdiShareVariantOutline } from '@mdi/js';
import { t } from 'svelte-i18n';
@@ -31,15 +32,15 @@
</script>
<tr
class="flex h-[50px] w-full place-items-center border-[3px] border-transparent p-2 text-center even:bg-subtle/20 odd:bg-subtle/80 hover:cursor-pointer hover:border-immich-primary/75 odd:dark:bg-immich-dark-gray/75 even:dark:bg-immich-dark-gray/50 dark:hover:border-immich-dark-primary/75 md:p-5"
onclick={() => goto(`${AppRoute.ALBUMS}/${album.id}`)}
class="flex h-12 w-full place-items-center border-3 border-transparent p-2 text-center even:bg-subtle/20 odd:bg-subtle/80 hover:cursor-pointer hover:border-immich-primary/75 odd:dark:bg-immich-dark-gray/75 even:dark:bg-immich-dark-gray/50 dark:hover:border-immich-dark-primary/75 md:p-5"
onclick={() => goto(resolve(`${AppRoute.ALBUMS}/${album.id}`))}
{oncontextmenu}
>
<td class="text-md text-ellipsis text-start w-8/12 sm:w-4/12 md:w-4/12 xl:w-[30%] 2xl:w-[40%] items-center">
{album.albumName}
{#if album.shared}
<Icon
path={mdiShareVariantOutline}
icon={mdiShareVariantOutline}
size="16"
class="inline ms-1 opacity-70"
title={album.ownerId === $user.id
@@ -1,19 +1,19 @@
<script lang="ts">
import { slide } from 'svelte/transition';
import type { AlbumResponseDto } from '@immich/sdk';
import { AlbumGroupBy, albumViewSettings } from '$lib/stores/preferences.store';
import type { ContextMenuPosition } from '$lib/utils/context-menu';
import { mdiChevronRight } from '@mdi/js';
import AlbumTableHeader from '$lib/components/album-page/albums-table-header.svelte';
import AlbumTableRow from '$lib/components/album-page/albums-table-row.svelte';
import Icon from '$lib/components/elements/icon.svelte';
import { AlbumGroupBy, albumViewSettings } from '$lib/stores/preferences.store';
import {
isAlbumGroupCollapsed,
toggleAlbumGroupCollapsing,
sortOptionsMetadata,
toggleAlbumGroupCollapsing,
type AlbumGroup,
} from '$lib/utils/album-utils';
import type { ContextMenuPosition } from '$lib/utils/context-menu';
import type { AlbumResponseDto } from '@immich/sdk';
import { Icon } from '@immich/ui';
import { mdiChevronRight } from '@mdi/js';
import { t } from 'svelte-i18n';
import { slide } from 'svelte/transition';
interface Props {
groupedAlbums: AlbumGroup[];
@@ -26,7 +26,7 @@
<table class="mt-2 w-full text-start">
<thead
class="mb-4 flex h-12 w-full rounded-md border bg-gray-50 text-immich-primary dark:border-immich-dark-gray dark:bg-immich-dark-gray dark:text-immich-dark-primary"
class="mb-4 flex h-12 w-full rounded-md border bg-gray-50 text-primary dark:border-immich-dark-gray dark:bg-immich-dark-gray"
>
<tr class="flex w-full place-items-center p-2 md:p-5">
{#each sortOptionsMetadata as option, index (index)}
@@ -54,7 +54,7 @@
>
<td class="text-md text-start -mb-1">
<Icon
path={mdiChevronRight}
icon={mdiChevronRight}
size="20"
class="inline-block -mt-2 transition-all duration-250 {iconRotation}"
/>
@@ -1,6 +1,6 @@
import type { AssetAction } from '$lib/constants';
import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
import type { AlbumResponseDto, AssetResponseDto, StackResponseDto } from '@immich/sdk';
import type { AlbumResponseDto, AssetResponseDto, PersonResponseDto, StackResponseDto } from '@immich/sdk';
type ActionMap = {
[AssetAction.ARCHIVE]: { asset: TimelineAsset };
@@ -12,12 +12,14 @@ type ActionMap = {
[AssetAction.RESTORE]: { asset: TimelineAsset };
[AssetAction.ADD]: { asset: TimelineAsset };
[AssetAction.ADD_TO_ALBUM]: { asset: TimelineAsset; album: AlbumResponseDto };
[AssetAction.STACK]: { stack: StackResponseDto };
[AssetAction.UNSTACK]: { assets: TimelineAsset[] };
[AssetAction.KEEP_THIS_DELETE_OTHERS]: { asset: TimelineAsset };
[AssetAction.SET_STACK_PRIMARY_ASSET]: { stack: StackResponseDto };
[AssetAction.REMOVE_ASSET_FROM_STACK]: { stack: StackResponseDto | null; asset: AssetResponseDto };
[AssetAction.SET_VISIBILITY_LOCKED]: { asset: TimelineAsset };
[AssetAction.SET_VISIBILITY_TIMELINE]: { asset: TimelineAsset };
[AssetAction.SET_PERSON_FEATURED_PHOTO]: { asset: AssetResponseDto; person: PersonResponseDto };
};
export type Action = {
@@ -0,0 +1,37 @@
<script lang="ts">
import MenuOption from '$lib/components/shared-components/context-menu/menu-option.svelte';
import { AssetAction } from '$lib/constants';
import { openFileUploadDialog } from '$lib/utils/file-uploader';
import { createStack, type AssetResponseDto, type StackResponseDto } from '@immich/sdk';
import { mdiUploadMultiple } from '@mdi/js';
import { t } from 'svelte-i18n';
import type { OnAction } from './action';
interface Props {
asset: AssetResponseDto;
stack: StackResponseDto | null;
onAction: OnAction;
}
let { asset, stack, onAction }: Props = $props();
const handleAddUploadToStack = async () => {
const newAssetIds = await openFileUploadDialog({ multiple: true });
// Including the old stacks primary asset ID ensures that all assets of the
// old stack are automatically included in the new stack.
const primaryAssetId = stack?.primaryAssetId ?? asset.id;
// First asset in the list will become the new primary asset.
const assetIds = [primaryAssetId, ...newAssetIds];
const newStack = await createStack({
stackCreateDto: {
assetIds,
},
});
onAction({ type: AssetAction.STACK, stack: newStack });
};
</script>
<MenuOption icon={mdiUploadMultiple} onClick={handleAddUploadToStack} text={$t('add_upload_to_stack')} />
@@ -1,21 +1,17 @@
<script lang="ts">
import { shortcuts } from '$lib/actions/shortcut';
import DeleteAssetDialog from '$lib/components/photos-page/delete-asset-dialog.svelte';
import {
NotificationType,
notificationController,
} from '$lib/components/shared-components/notification/notification';
import Portal from '$lib/components/shared-components/portal/portal.svelte';
import { AssetAction } from '$lib/constants';
import Portal from '$lib/elements/Portal.svelte';
import { featureFlagsManager } from '$lib/managers/feature-flags-manager.svelte';
import { showDeleteModal } from '$lib/stores/preferences.store';
import { featureFlags } from '$lib/stores/server-config.store';
import { handleError } from '$lib/utils/handle-error';
import { toTimelineAsset } from '$lib/utils/timeline-util';
import { deleteAssets, type AssetResponseDto } from '@immich/sdk';
import { IconButton, toastManager } from '@immich/ui';
import { mdiDeleteForeverOutline, mdiDeleteOutline } from '@mdi/js';
import { t } from 'svelte-i18n';
import type { OnAction, PreAction } from './action';
import { IconButton } from '@immich/ui';
interface Props {
asset: AssetResponseDto;
@@ -28,7 +24,7 @@
let showConfirmModal = $state(false);
const trashOrDelete = async (force = false) => {
if (force || !$featureFlags.trash) {
if (force || !featureFlagsManager.value.trash) {
if ($showDeleteModal) {
showConfirmModal = true;
return;
@@ -46,11 +42,7 @@
preAction({ type: AssetAction.TRASH, asset: toTimelineAsset(asset) });
await deleteAssets({ assetBulkDeleteDto: { ids: [asset.id] } });
onAction({ type: AssetAction.TRASH, asset: toTimelineAsset(asset) });
notificationController.show({
message: $t('moved_to_trash'),
type: NotificationType.Info,
});
toastManager.success($t('moved_to_trash'));
} catch (error) {
handleError(error, $t('errors.unable_to_trash_asset'));
}
@@ -61,11 +53,7 @@
preAction({ type: AssetAction.DELETE, asset: toTimelineAsset(asset) });
await deleteAssets({ assetBulkDeleteDto: { ids: [asset.id], force: true } });
onAction({ type: AssetAction.DELETE, asset: toTimelineAsset(asset) });
notificationController.show({
message: $t('permanently_deleted_asset'),
type: NotificationType.Info,
});
toastManager.success($t('permanently_deleted_asset'));
} catch (error) {
handleError(error, $t('errors.unable_to_delete_asset'));
} finally {
@@ -1,17 +1,13 @@
<script lang="ts">
import { shortcut } from '$lib/actions/shortcut';
import {
NotificationType,
notificationController,
} from '$lib/components/shared-components/notification/notification';
import { AssetAction } from '$lib/constants';
import { handleError } from '$lib/utils/handle-error';
import { toTimelineAsset } from '$lib/utils/timeline-util';
import { updateAsset, type AssetResponseDto } from '@immich/sdk';
import { IconButton, toastManager } from '@immich/ui';
import { mdiHeart, mdiHeartOutline } from '@mdi/js';
import { t } from 'svelte-i18n';
import type { OnAction } from './action';
import { IconButton } from '@immich/ui';
interface Props {
asset: AssetResponseDto;
@@ -36,10 +32,7 @@
asset: toTimelineAsset(asset),
});
notificationController.show({
type: NotificationType.Info,
message: asset.isFavorite ? $t('added_to_favorites') : $t('removed_from_favorites'),
});
toastManager.success(asset.isFavorite ? $t('added_to_favorites') : $t('removed_from_favorites'));
} catch (error) {
handleError(error, $t('errors.unable_to_add_remove_favorites', { values: { favorite: asset.isFavorite } }));
}
@@ -1,6 +1,6 @@
<script lang="ts">
import { IconButton } from '@immich/ui';
import { mdiMotionPauseOutline, mdiPlaySpeed } from '@mdi/js';
import { mdiMotionPauseOutline, mdiMotionPlayOutline } from '@mdi/js';
import { t } from 'svelte-i18n';
interface Props {
@@ -15,7 +15,7 @@
color="secondary"
variant="ghost"
shape="round"
icon={isPlaying ? mdiMotionPauseOutline : mdiPlaySpeed}
icon={isPlaying ? mdiMotionPauseOutline : mdiMotionPlayOutline}
aria-label={isPlaying ? $t('stop_motion_photo') : $t('play_motion_photo')}
onclick={() => onClick(!isPlaying)}
/>
@@ -1,6 +1,6 @@
<script lang="ts">
import { shortcuts } from '$lib/actions/shortcut';
import Icon from '$lib/components/elements/icon.svelte';
import { Icon } from '@immich/ui';
import { mdiChevronRight } from '@mdi/js';
import { t } from 'svelte-i18n';
import NavigationArea from '../navigation-area.svelte';
@@ -20,5 +20,5 @@
/>
<NavigationArea onClick={onNextAsset} label={$t('view_next_asset')}>
<Icon path={mdiChevronRight} size="36" ariaHidden />
<Icon icon={mdiChevronRight} size="36" aria-hidden />
</NavigationArea>
@@ -1,6 +1,6 @@
<script lang="ts">
import { shortcuts } from '$lib/actions/shortcut';
import Icon from '$lib/components/elements/icon.svelte';
import { Icon } from '@immich/ui';
import { mdiChevronLeft } from '@mdi/js';
import { t } from 'svelte-i18n';
import NavigationArea from '../navigation-area.svelte';
@@ -20,5 +20,5 @@
/>
<NavigationArea onClick={onPreviousAsset} label={$t('view_previous_asset')}>
<Icon path={mdiChevronLeft} size="36" ariaHidden />
<Icon icon={mdiChevronLeft} size="36" aria-hidden />
</NavigationArea>
@@ -1,13 +1,10 @@
<script lang="ts">
import MenuOption from '$lib/components/shared-components/context-menu/menu-option.svelte';
import {
notificationController,
NotificationType,
} from '$lib/components/shared-components/notification/notification';
import { AssetAction } from '$lib/constants';
import { handleError } from '$lib/utils/handle-error';
import { toTimelineAsset } from '$lib/utils/timeline-util';
import { restoreAssets, type AssetResponseDto } from '@immich/sdk';
import { toastManager } from '@immich/ui';
import { mdiHistory } from '@mdi/js';
import { t } from 'svelte-i18n';
import type { OnAction } from './action';
@@ -23,13 +20,8 @@
try {
await restoreAssets({ bulkIdsDto: { ids: [asset.id] } });
asset.isTrashed = false;
onAction({ type: AssetAction.RESTORE, asset: toTimelineAsset(asset) });
notificationController.show({
type: NotificationType.Info,
message: $t('restored_asset'),
});
toastManager.success($t('restored_asset'));
} catch (error) {
handleError(error, $t('errors.unable_to_restore_assets'));
}
@@ -1,11 +1,8 @@
<script lang="ts">
import MenuOption from '$lib/components/shared-components/context-menu/menu-option.svelte';
import {
notificationController,
NotificationType,
} from '$lib/components/shared-components/notification/notification';
import { handleError } from '$lib/utils/handle-error';
import { updateAlbumInfo, type AlbumResponseDto, type AssetResponseDto } from '@immich/sdk';
import { toastManager } from '@immich/ui';
import { mdiImageOutline } from '@mdi/js';
import { t } from 'svelte-i18n';
@@ -24,11 +21,7 @@
albumThumbnailAssetId: asset.id,
},
});
notificationController.show({
type: NotificationType.Info,
message: $t('album_cover_updated'),
timeout: 1500,
});
toastManager.success($t('album_cover_updated'));
} catch (error) {
handleError(error, $t('errors.unable_to_update_album_cover'));
}
@@ -1,25 +1,37 @@
<script lang="ts">
import MenuOption from '$lib/components/shared-components/context-menu/menu-option.svelte';
import {
notificationController,
NotificationType,
} from '$lib/components/shared-components/notification/notification';
import { AssetAction } from '$lib/constants';
import { handleError } from '$lib/utils/handle-error';
import { updatePerson, type AssetResponseDto, type PersonResponseDto } from '@immich/sdk';
import { toastManager } from '@immich/ui';
import { mdiFaceManProfile } from '@mdi/js';
import { t } from 'svelte-i18n';
import type { OnAction } from './action';
interface Props {
asset: AssetResponseDto;
person: PersonResponseDto;
onAction?: OnAction;
}
let { asset, person }: Props = $props();
let { asset, person, onAction }: Props = $props();
const handleSelectFeaturePhoto = async () => {
try {
await updatePerson({ id: person.id, personUpdateDto: { featureFaceAssetId: asset.id } });
notificationController.show({ message: $t('feature_photo_updated'), type: NotificationType.Info });
const updatedPerson = await updatePerson({
id: person.id,
personUpdateDto: { featureFaceAssetId: asset.id },
});
person = { ...person, ...updatedPerson };
onAction?.({
type: AssetAction.SET_PERSON_FEATURED_PHOTO,
asset,
person,
});
toastManager.success($t('feature_photo_updated'));
} catch (error) {
handleError(error, $t('errors.unable_to_set_feature_photo'));
}
@@ -1,7 +1,5 @@
<script lang="ts">
import QrCodeModal from '$lib/modals/QrCodeModal.svelte';
import SharedLinkCreateModal from '$lib/modals/SharedLinkCreateModal.svelte';
import { makeSharedLinkUrl } from '$lib/utils';
import type { AssetResponseDto } from '@immich/sdk';
import { IconButton, modalManager } from '@immich/ui';
import { mdiShareVariantOutline } from '@mdi/js';
@@ -14,11 +12,7 @@
let { asset }: Props = $props();
const handleClick = async () => {
const sharedLink = await modalManager.show(SharedLinkCreateModal, { assetIds: [asset.id] });
if (sharedLink) {
await modalManager.show(QrCodeModal, { title: $t('view_link'), value: makeSharedLinkUrl(sharedLink) });
}
await modalManager.show(SharedLinkCreateModal, { assetIds: [asset.id] });
};
</script>
@@ -1,8 +1,8 @@
<script lang="ts">
import { locale } from '$lib/stores/preferences.store';
import type { ActivityResponseDto } from '@immich/sdk';
import { Icon } from '@immich/ui';
import { mdiCommentOutline, mdiHeart, mdiHeartOutline } from '@mdi/js';
import Icon from '../elements/icon.svelte';
interface Props {
isLiked: ActivityResponseDto | null;
@@ -19,7 +19,7 @@
<div class="w-full flex p-4 items-center justify-center rounded-full gap-5 bg-subtle border bg-opacity-60">
<button type="button" class={disabled ? 'cursor-not-allowed' : ''} onclick={onFavorite} {disabled}>
<div class="flex gap-2 items-center justify-center">
<Icon path={isLiked ? mdiHeart : mdiHeartOutline} size={24} class={isLiked ? 'text-red-400' : 'text-fg'} />
<Icon icon={isLiked ? mdiHeart : mdiHeartOutline} size="24" class={isLiked ? 'text-red-400' : 'text-fg'} />
{#if numberOfLikes}
<div class="text-l">{numberOfLikes.toLocaleString($locale)}</div>
{/if}
@@ -27,7 +27,7 @@
</button>
<button type="button" onclick={onOpenActivityTab}>
<div class="flex gap-2 items-center justify-center">
<Icon path={mdiCommentOutline} class="scale-x-[-1]" size={24} />
<Icon icon={mdiCommentOutline} class="scale-x-[-1]" size="24" />
{#if numberOfComments}
<div class="text-l">{numberOfComments.toLocaleString($locale)}</div>
{/if}
@@ -1,7 +1,7 @@
<script lang="ts">
import { resolve } from '$app/paths';
import { autoGrowHeight } from '$lib/actions/autogrow';
import { shortcut } from '$lib/actions/shortcut';
import Icon from '$lib/components/elements/icon.svelte';
import ButtonContextMenu from '$lib/components/shared-components/context-menu/button-context-menu.svelte';
import MenuOption from '$lib/components/shared-components/context-menu/menu-option.svelte';
import { AppRoute, timeBeforeShowLoadingSpinner } from '$lib/constants';
@@ -12,12 +12,10 @@
import { handleError } from '$lib/utils/handle-error';
import { isTenMinutesApart } from '$lib/utils/timesince';
import { ReactionType, type ActivityResponseDto, type AssetTypeEnum, type UserResponseDto } from '@immich/sdk';
import { IconButton } from '@immich/ui';
import { Icon, IconButton, LoadingSpinner, toastManager } from '@immich/ui';
import { mdiClose, mdiDeleteOutline, mdiDotsVertical, mdiHeart, mdiSend } from '@mdi/js';
import * as luxon from 'luxon';
import { t } from 'svelte-i18n';
import LoadingSpinner from '../shared-components/loading-spinner.svelte';
import { NotificationType, notificationController } from '../shared-components/notification/notification';
import UserAvatar from '../shared-components/user-avatar.svelte';
const units: Intl.RelativeTimeFormatUnit[] = ['year', 'month', 'week', 'day', 'hour', 'minute', 'second'];
@@ -76,10 +74,7 @@
[ReactionType.Comment]: $t('comment_deleted'),
[ReactionType.Like]: $t('like_deleted'),
};
notificationController.show({
message: deleteMessages[reaction.type],
type: NotificationType.Info,
});
toastManager.success(deleteMessages[reaction.type]);
} catch (error) {
handleError(error, $t('errors.unable_to_remove_reaction'));
}
@@ -146,11 +141,14 @@
<UserAvatar user={reaction.user} size="sm" />
</div>
<div class="w-full leading-4 overflow-hidden self-center break-words text-sm">{reaction.comment}</div>
<div class="w-full leading-4 overflow-hidden self-center wrap-break-word text-sm">{reaction.comment}</div>
{#if assetId === undefined && reaction.assetId}
<a class="aspect-square w-[75px] h-[75px]" href="{AppRoute.ALBUMS}/{albumId}/photos/{reaction.assetId}">
<a
class="aspect-square w-19 h-19"
href={resolve(`${AppRoute.ALBUMS}/${albumId}/photos/${reaction.assetId}`)}
>
<img
class="rounded-lg w-[75px] h-[75px] object-cover"
class="rounded-lg w-19 h-19 object-cover"
src={getAssetThumbnailUrl(reaction.assetId)}
alt="Profile picture of {reaction.user.name}, who commented on this asset"
/>
@@ -187,7 +185,7 @@
{:else if reaction.type === ReactionType.Like}
<div class="relative">
<div class="flex py-3 ps-3 mt-3 gap-4 items-center text-sm">
<div class="text-red-600"><Icon path={mdiHeart} size={20} /></div>
<div class="text-red-600"><Icon icon={mdiHeart} size="20" /></div>
<div class="w-full" title={`${reaction.user.name} (${reaction.user.email})`}>
{$t('user_liked', {
@@ -199,11 +197,11 @@
</div>
{#if assetId === undefined && reaction.assetId}
<a
class="aspect-square w-[75px] h-[75px]"
href="{AppRoute.ALBUMS}/{albumId}/photos/{reaction.assetId}"
class="aspect-square w-19 h-19"
href={resolve(`${AppRoute.ALBUMS}/${albumId}/photos/${reaction.assetId}`)}
>
<img
class="rounded-lg w-[75px] h-[75px] object-cover"
class="rounded-lg w-19 h-19 object-cover"
src={getAssetThumbnailUrl(reaction.assetId)}
alt="Profile picture of {reaction.user.name}, who liked this asset"
/>
@@ -1,10 +1,10 @@
<script lang="ts">
import Icon from '$lib/components/elements/icon.svelte';
import { SCROLL_PROPERTIES } from '$lib/components/shared-components/album-selection/album-selection-utils';
import { mobileDevice } from '$lib/stores/mobile-device.svelte';
import { getAssetThumbnailUrl } from '$lib/utils';
import { normalizeSearchString } from '$lib/utils/string-utils.js';
import { type AlbumResponseDto } from '@immich/sdk';
import { Icon } from '@immich/ui';
import { mdiCheckCircle } from '@mdi/js';
import type { Action } from 'svelte/action';
import AlbumListItemDetails from './album-list-item-details.svelte';
@@ -165,10 +165,10 @@
>
{#if multiSelected}
<div class="rounded-full">
<Icon path={mdiCheckCircle} size="24" class="text-primary" />
<Icon icon={mdiCheckCircle} size="24" class="text-primary" />
</div>
{:else}
<Icon path={mdiCheckCircle} size="24" class="text-gray-300 hover:text-primary/75" />
<Icon icon={mdiCheckCircle} size="24" class="text-gray-300 hover:text-primary/75" />
{/if}
</button>
{/if}
@@ -32,6 +32,10 @@ describe('AssetViewerNavBar component', () => {
'ResizeObserver',
vi.fn(() => ({ observe: vi.fn(), unobserve: vi.fn(), disconnect: vi.fn() })),
);
vi.mock(import('$lib/managers/feature-flags-manager.svelte'), () => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return { featureFlagsManager: { init: vi.fn(), loadFeatureFlags: vi.fn(), value: { smartSearch: true } } as any };
});
});
afterEach(() => {
@@ -1,8 +1,10 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { resolve } from '$app/paths';
import CastButton from '$lib/cast/cast-button.svelte';
import type { OnAction, PreAction } from '$lib/components/asset-viewer/actions/action';
import AddToAlbumAction from '$lib/components/asset-viewer/actions/add-to-album-action.svelte';
import AddToStackAction from '$lib/components/asset-viewer/actions/add-to-stack-action.svelte';
import ArchiveAction from '$lib/components/asset-viewer/actions/archive-action.svelte';
import CloseAction from '$lib/components/asset-viewer/actions/close-action.svelte';
import DeleteAction from '$lib/components/asset-viewer/actions/delete-action.svelte';
@@ -22,11 +24,13 @@
import ButtonContextMenu from '$lib/components/shared-components/context-menu/button-context-menu.svelte';
import MenuOption from '$lib/components/shared-components/context-menu/menu-option.svelte';
import { AppRoute } from '$lib/constants';
import { featureFlagsManager } from '$lib/managers/feature-flags-manager.svelte';
import { handleReplaceAsset } from '$lib/services/asset.service';
import { photoViewerImgElement } from '$lib/stores/assets-store.svelte';
import { user } from '$lib/stores/user.store';
import { photoZoomState } from '$lib/stores/zoom-image.store';
import { getAssetJobName, getSharedLink } from '$lib/utils';
import { canCopyImageToClipboard } from '$lib/utils/asset-utils';
import { openFileUploadDialog } from '$lib/utils/file-uploader';
import { toTimelineAsset } from '$lib/utils/timeline-util';
import {
AssetJobName,
@@ -41,6 +45,7 @@
import {
mdiAlertOutline,
mdiCogRefreshOutline,
mdiCompare,
mdiContentCopy,
mdiDatabaseRefreshOutline,
mdiDotsVertical,
@@ -51,6 +56,7 @@
mdiMagnifyPlusOutline,
mdiPresentationPlay,
mdiUpload,
mdiVideoOutline,
} from '@mdi/js';
import type { Snippet } from 'svelte';
import { t } from 'svelte-i18n';
@@ -73,6 +79,8 @@
// export let showEditorHandler: () => void;
onClose: () => void;
motionPhoto?: Snippet;
playOriginalVideo: boolean;
setPlayOriginalVideo: (value: boolean) => void;
}
let {
@@ -92,12 +100,15 @@
onShowDetail,
onClose,
motionPhoto,
playOriginalVideo = false,
setPlayOriginalVideo,
}: Props = $props();
const sharedLink = getSharedLink();
let isOwner = $derived($user && asset.ownerId === $user?.id);
let showDownloadButton = $derived(sharedLink ? sharedLink.allowDownload : !asset.isOffline);
let isLocked = $derived(asset.visibility === AssetVisibility.Locked);
let smartSearchEnabled = $derived(featureFlagsManager.value.smartSearch);
// $: showEditorButton =
// isOwner &&
@@ -147,7 +158,7 @@
onclick={onZoomImage}
/>
{/if}
{#if canCopyImageToClipboard() && asset.type === AssetTypeEnum.Image}
{#if canCopyImageToClipboard() && asset.type === AssetTypeEnum.Image && $photoViewerImgElement}
<IconButton
color="secondary"
variant="ghost"
@@ -191,6 +202,7 @@
{/if}
{#if isOwner}
<AddToStackAction {asset} {stack} {onAction} />
{#if stack}
<UnstackAction {stack} {onAction} />
<KeepThisDeleteOthersAction {stack} {asset} {onAction} />
@@ -205,7 +217,7 @@
<SetAlbumCoverAction {asset} {album} />
{/if}
{#if person}
<SetFeaturedPhotoAction {asset} {person} />
<SetFeaturedPhotoAction {asset} {person} {onAction} />
{/if}
{#if asset.type === AssetTypeEnum.Image && !isLocked}
<SetProfilePictureAction {asset} />
@@ -215,21 +227,38 @@
<ArchiveAction {asset} {onAction} {preAction} />
<MenuOption
icon={mdiUpload}
onClick={() => openFileUploadDialog({ multiple: false, assetId: asset.id })}
onClick={() => handleReplaceAsset(asset.id)}
text={$t('replace_with_upload')}
/>
{#if !asset.isArchived && !asset.isTrashed}
<MenuOption
icon={mdiImageSearch}
onClick={() => goto(`${AppRoute.PHOTOS}?at=${stack?.primaryAssetId ?? asset.id}`)}
onClick={() => goto(resolve(`${AppRoute.PHOTOS}?at=${stack?.primaryAssetId ?? asset.id}`))}
text={$t('view_in_timeline')}
/>
{/if}
{#if !asset.isArchived && !asset.isTrashed && smartSearchEnabled}
<MenuOption
icon={mdiCompare}
onClick={() =>
goto(resolve(`${AppRoute.SEARCH}?query={"queryAssetId":"${stack?.primaryAssetId ?? asset.id}"}`))}
text={$t('view_similar_photos')}
/>
{/if}
{/if}
{#if !asset.isTrashed}
<SetVisibilityAction asset={toTimelineAsset(asset)} {onAction} {preAction} />
{/if}
{#if asset.type === AssetTypeEnum.Video}
<MenuOption
icon={mdiVideoOutline}
onClick={() => setPlayOriginalVideo(!playOriginalVideo)}
text={playOriginalVideo ? $t('play_transcoded_video') : $t('play_original_video')}
/>
{/if}
<hr />
<MenuOption
icon={mdiHeadSyncOutline}
@@ -1,17 +1,20 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { focusTrap } from '$lib/actions/focus-trap';
import type { Action, OnAction, PreAction } from '$lib/components/asset-viewer/actions/action';
import MotionPhotoAction from '$lib/components/asset-viewer/actions/motion-photo-action.svelte';
import NextAssetAction from '$lib/components/asset-viewer/actions/next-asset-action.svelte';
import PreviousAssetAction from '$lib/components/asset-viewer/actions/previous-asset-action.svelte';
import AssetViewerNavBar from '$lib/components/asset-viewer/asset-viewer-nav-bar.svelte';
import { AssetAction, ProjectionType } from '$lib/constants';
import OnEvents from '$lib/components/OnEvents.svelte';
import { AppRoute, AssetAction, ProjectionType } from '$lib/constants';
import { activityManager } from '$lib/managers/activity-manager.svelte';
import { authManager } from '$lib/managers/auth-manager.svelte';
import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
import { closeEditorCofirm } from '$lib/stores/asset-editor.store';
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
import { isShowDetail } from '$lib/stores/preferences.store';
import { ocrManager } from '$lib/stores/ocr.svelte';
import { alwaysLoadOriginalVideo, isShowDetail } from '$lib/stores/preferences.store';
import { SlideshowNavigation, SlideshowState, slideshowStore } from '$lib/stores/slideshow.store';
import { user } from '$lib/stores/user.store';
import { websocketEvents } from '$lib/stores/websocket';
@@ -23,6 +26,7 @@
AssetJobName,
AssetTypeEnum,
getAllAlbums,
getAssetInfo,
getStack,
runAssetJobs,
type AlbumResponseDto,
@@ -30,17 +34,18 @@
type PersonResponseDto,
type StackResponseDto,
} from '@immich/sdk';
import { toastManager } from '@immich/ui';
import { onDestroy, onMount, untrack } from 'svelte';
import { t } from 'svelte-i18n';
import { fly } from 'svelte/transition';
import Thumbnail from '../assets/thumbnail/thumbnail.svelte';
import { NotificationType, notificationController } from '../shared-components/notification/notification';
import ActivityStatus from './activity-status.svelte';
import ActivityViewer from './activity-viewer.svelte';
import DetailPanel from './detail-panel.svelte';
import CropArea from './editor/crop-tool/crop-area.svelte';
import EditorPanel from './editor/editor-panel.svelte';
import ImagePanoramaViewer from './image-panorama-viewer.svelte';
import OcrButton from './ocr-button.svelte';
import PhotoViewer from './photo-viewer.svelte';
import SlideshowBar from './slideshow-bar.svelte';
import VideoViewer from './video-wrapper-viewer.svelte';
@@ -109,6 +114,11 @@
let stack: StackResponseDto | null = $state(null);
let zoomToggle = $state(() => void 0);
let playOriginalVideo = $state($alwaysLoadOriginalVideo);
const setPlayOriginalVideo = (value: boolean) => {
playOriginalVideo = value;
};
const refreshStack = async () => {
if (authManager.isSharedLink) {
@@ -269,7 +279,7 @@
const handleRunJob = async (name: AssetJobName) => {
try {
await runAssetJobs({ assetJobsDto: { assetIds: [asset.id], name } });
notificationController.show({ type: NotificationType.Info, message: $getAssetJobMessage(name) });
toastManager.success($getAssetJobMessage(name));
} catch (error) {
handleError(error, $t('errors.unable_to_submit_job'));
}
@@ -302,10 +312,8 @@
const handleStopSlideshow = async () => {
try {
// eslint-disable-next-line tscompat/tscompat
if (document.fullscreenElement) {
document.body.style.cursor = '';
// eslint-disable-next-line tscompat/tscompat
await document.exitFullscreen();
}
} catch (error) {
@@ -335,10 +343,16 @@
}
break;
}
case AssetAction.STACK:
case AssetAction.SET_STACK_PRIMARY_ASSET: {
stack = action.stack;
break;
}
case AssetAction.SET_PERSON_FEATURED_PHOTO: {
const assetInfo = await getAssetInfo({ id: asset.id });
asset = { ...asset, people: assetInfo.people };
break;
}
case AssetAction.KEEP_THIS_DELETE_OTHERS:
case AssetAction.UNSTACK: {
closeViewer();
@@ -353,6 +367,15 @@
selectedEditType = type;
};
const handleAssetReplace = async ({ oldAssetId, newAssetId }: { oldAssetId: string; newAssetId: string }) => {
if (oldAssetId !== asset.id) {
return;
}
await new Promise((promise) => setTimeout(promise, 500));
await goto(`${AppRoute.PHOTOS}/${newAssetId}`);
};
let isFullScreen = $derived(fullscreenElement !== null);
$effect(() => {
@@ -371,13 +394,19 @@
handlePromiseError(activityManager.init(album.id, asset.id));
}
});
let currentAssetId = $derived(asset.id);
$effect(() => {
if (asset.id) {
handlePromiseError(handleGetAllAlbums());
if (currentAssetId) {
untrack(() => handlePromiseError(handleGetAllAlbums()));
ocrManager.clear();
handlePromiseError(ocrManager.getAssetOcr(currentAssetId));
}
});
</script>
<OnEvents onAssetReplace={handleAssetReplace} />
<svelte:document bind:fullscreenElement />
<section
@@ -405,6 +434,8 @@
onPlaySlideshow={() => ($slideshowState = SlideshowState.PlaySlideshow)}
onShowDetail={toggleDetailPanel}
onClose={closeViewer}
{playOriginalVideo}
{setPlayOriginalVideo}
>
{#snippet motionPhoto()}
<MotionPhotoAction
@@ -460,6 +491,7 @@
onClose={closeViewer}
onVideoEnded={() => navigateAsset()}
onVideoStarted={handleVideoStarted}
{playOriginalVideo}
/>
{/if}
{/key}
@@ -475,6 +507,7 @@
onPreviousAsset={() => navigateAsset('previous')}
onNextAsset={() => navigateAsset('next')}
onVideoEnded={() => (shouldPlayMotionPhoto = false)}
{playOriginalVideo}
/>
{:else if asset.exifInfo?.projectionType === ProjectionType.EQUIRECTANGULAR || (asset.originalPath && asset.originalPath
.toLowerCase()
@@ -491,7 +524,7 @@
onPreviousAsset={() => navigateAsset('previous')}
onNextAsset={() => navigateAsset('next')}
{sharedLink}
haveFadeTransition={$slideshowState === SlideshowState.None || $slideshowTransition}
haveFadeTransition={$slideshowState !== SlideshowState.None && $slideshowTransition}
/>
{/if}
{:else}
@@ -505,8 +538,10 @@
onClose={closeViewer}
onVideoEnded={() => navigateAsset()}
onVideoStarted={handleVideoStarted}
{playOriginalVideo}
/>
{/if}
{#if $slideshowState === SlideshowState.None && isShared && ((album && album.isActivityEnabled) || activityManager.commentCount > 0) && !activityManager.isLoading}
<div class="absolute bottom-0 end-0 mb-20 me-8">
<ActivityStatus
@@ -519,6 +554,12 @@
/>
</div>
{/if}
{#if $slideshowState === SlideshowState.None && asset.type === AssetTypeEnum.Image && !isShowEditor && ocrManager.hasOcrData}
<div class="absolute bottom-0 end-0 mb-6 me-6">
<OcrButton />
</div>
{/if}
{/key}
{/if}
</div>
@@ -578,7 +619,7 @@
{#if stackedAsset.id === asset.id}
<div class="w-full flex place-items-center place-content-center">
<div class="w-2 h-2 bg-white rounded-full flex mt-[2px]"></div>
<div class="w-2 h-2 bg-white rounded-full flex mt-0.5"></div>
</div>
{/if}
</div>
@@ -1,11 +1,8 @@
<script lang="ts">
import {
NotificationType,
notificationController,
} from '$lib/components/shared-components/notification/notification';
import AutogrowTextarea from '$lib/components/shared-components/autogrow-textarea.svelte';
import { handleError } from '$lib/utils/handle-error';
import { updateAsset, type AssetResponseDto } from '@immich/sdk';
import AutogrowTextarea from '$lib/components/shared-components/autogrow-textarea.svelte';
import { toastManager } from '@immich/ui';
import { t } from 'svelte-i18n';
interface Props {
@@ -23,10 +20,7 @@
asset.exifInfo = { ...asset.exifInfo, description: newDescription };
notificationController.show({
type: NotificationType.Info,
message: $t('asset_description_updated'),
});
toastManager.success($t('asset_description_updated'));
} catch (error) {
handleError(error, $t('cannot_update_the_description'));
}
@@ -37,13 +31,13 @@
<section class="px-4 mt-10">
<AutogrowTextarea
content={description}
class="max-h-[500px] w-full border-b border-gray-500 bg-transparent text-base text-black outline-none transition-all focus:border-b-2 focus:border-immich-primary disabled:border-none dark:text-white dark:focus:border-immich-dark-primary immich-scrollbar"
class="max-h-125 w-full border-b border-gray-500 bg-transparent text-base text-black outline-none transition-all focus:border-b-2 focus:border-immich-primary disabled:border-none dark:text-white dark:focus:border-immich-dark-primary immich-scrollbar"
onContentUpdate={handleFocusOut}
placeholder={$t('add_a_description')}
/>
</section>
{:else if description}
<section class="px-4 mt-6">
<p class="break-words whitespace-pre-line w-full text-black dark:text-white text-base">{description}</p>
<p class="wrap-break-word whitespace-pre-line w-full text-black dark:text-white text-base">{description}</p>
</section>
{/if}
@@ -1,9 +1,9 @@
<script lang="ts">
import Icon from '$lib/components/elements/icon.svelte';
import ChangeLocation from '$lib/components/shared-components/change-location.svelte';
import Portal from '$lib/components/shared-components/portal/portal.svelte';
import Portal from '$lib/elements/Portal.svelte';
import { handleError } from '$lib/utils/handle-error';
import { updateAsset, type AssetResponseDto } from '@immich/sdk';
import { Icon } from '@immich/ui';
import { mdiMapMarkerOutline, mdiPencil } from '@mdi/js';
import { t } from 'svelte-i18n';
@@ -16,18 +16,22 @@
let isShowChangeLocation = $state(false);
async function handleConfirmChangeLocation(gps: { lng: number; lat: number }) {
const onClose = async (point?: { lng: number; lat: number }) => {
isShowChangeLocation = false;
if (!point) {
return;
}
try {
asset = await updateAsset({
id: asset.id,
updateAssetDto: { latitude: gps.lat, longitude: gps.lng },
updateAssetDto: { latitude: point.lat, longitude: point.lng },
});
} catch (error) {
handleError(error, $t('errors.unable_to_change_location'));
}
}
};
</script>
{#if asset.exifInfo?.country}
@@ -36,11 +40,10 @@
class="flex w-full text-start justify-between place-items-start gap-4 py-4"
onclick={() => (isOwner ? (isShowChangeLocation = true) : null)}
title={isOwner ? $t('edit_location') : ''}
class:hover:dark:text-immich-dark-primary={isOwner}
class:hover:text-immich-primary={isOwner}
class:hover:text-primary={isOwner}
>
<div class="flex gap-4">
<div><Icon path={mdiMapMarkerOutline} size="24" /></div>
<div><Icon icon={mdiMapMarkerOutline} size="24" /></div>
<div>
{#if asset.exifInfo?.city}
@@ -61,30 +64,30 @@
{#if isOwner}
<div>
<Icon path={mdiPencil} size="20" />
<Icon icon={mdiPencil} size="20" />
</div>
{/if}
</button>
{:else if !asset.exifInfo?.city && isOwner}
<button
type="button"
class="flex w-full text-start justify-between place-items-start gap-4 py-4 rounded-lg hover:dark:text-immich-dark-primary hover:text-immich-primary"
class="flex w-full text-start justify-between place-items-start gap-4 py-4 rounded-lg hover:text-primary"
onclick={() => (isShowChangeLocation = true)}
title={$t('add_location')}
>
<div class="flex gap-4">
<div><Icon path={mdiMapMarkerOutline} size="24" /></div>
<div><Icon icon={mdiMapMarkerOutline} size="24" /></div>
<p>{$t('add_a_location')}</p>
</div>
<div class="focus:outline-none p-1">
<Icon path={mdiPencil} size="20" />
<Icon icon={mdiPencil} size="20" />
</div>
</button>
{/if}
{#if isShowChangeLocation}
<Portal>
<ChangeLocation {asset} onConfirm={handleConfirmChangeLocation} onCancel={() => (isShowChangeLocation = false)} />
<ChangeLocation {asset} {onClose} />
</Portal>
{/if}
@@ -1,5 +1,5 @@
<script lang="ts">
import StarRating from '$lib/components/shared-components/star-rating.svelte';
import StarRating from '$lib/elements/StarRating.svelte';
import { authManager } from '$lib/managers/auth-manager.svelte';
import { preferences } from '$lib/stores/user.store';
import { handlePromiseError } from '$lib/utils';
@@ -1,12 +1,12 @@
<script lang="ts">
import { resolve } from '$app/paths';
import { shortcut } from '$lib/actions/shortcut';
import Icon from '$lib/components/elements/icon.svelte';
import { AppRoute } from '$lib/constants';
import { authManager } from '$lib/managers/auth-manager.svelte';
import AssetTagModal from '$lib/modals/AssetTagModal.svelte';
import { removeTag } from '$lib/utils/asset-utils';
import { getAssetInfo, type AssetResponseDto } from '@immich/sdk';
import { modalManager } from '@immich/ui';
import { Icon, modalManager } from '@immich/ui';
import { mdiClose, mdiPlus } from '@mdi/js';
import { t } from 'svelte-i18n';
@@ -21,7 +21,6 @@
const handleAddTag = async () => {
const success = await modalManager.show(AssetTagModal, { assetIds: [asset.id] });
if (success) {
asset = await getAssetInfo({ id: asset.id });
}
@@ -40,14 +39,14 @@
{#if isOwner && !authManager.isSharedLink}
<section class="px-4 mt-4">
<div class="flex h-10 w-full items-center justify-between text-sm">
<h2>{$t('tags').toUpperCase()}</h2>
<h2 class="uppercase">{$t('tags')}</h2>
</div>
<section class="flex flex-wrap pt-2 gap-1" data-testid="detail-panel-tags">
{#each tags as tag (tag.id)}
<div class="flex group transition-all">
<a
class="inline-block h-min whitespace-nowrap ps-3 pe-1 group-hover:ps-3 py-1 text-center align-baseline leading-none text-gray-100 dark:text-immich-dark-gray bg-primary rounded-s-full hover:bg-immich-primary/80 dark:hover:bg-immich-dark-primary/80 transition-all"
href={encodeURI(`${AppRoute.TAGS}/?path=${tag.value}`)}
href={resolve(`${AppRoute.TAGS}/?path=${encodeURI(tag.value)}`)}
>
<p class="text-sm">
{tag.value}
@@ -60,7 +59,7 @@
title="Remove tag"
onclick={() => handleRemove(tag.id)}
>
<Icon path={mdiClose} />
<Icon icon={mdiClose} />
</button>
</div>
{/each}
@@ -71,7 +70,7 @@
onclick={handleAddTag}
>
<span class="text-sm px-1 flex place-items-center place-content-center gap-1"
><Icon path={mdiPlus} />{$t('add')}</span
><Icon icon={mdiPlus} />{$t('add')}</span
>
</button>
</section>
@@ -1,37 +1,26 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { resolve } from '$app/paths';
import DetailPanelDescription from '$lib/components/asset-viewer/detail-panel-description.svelte';
import DetailPanelLocation from '$lib/components/asset-viewer/detail-panel-location.svelte';
import DetailPanelRating from '$lib/components/asset-viewer/detail-panel-star-rating.svelte';
import DetailPanelTags from '$lib/components/asset-viewer/detail-panel-tags.svelte';
import Icon from '$lib/components/elements/icon.svelte';
import ChangeDate, {
type AbsoluteResult,
type RelativeResult,
} from '$lib/components/shared-components/change-date.svelte';
import { AppRoute, QueryParameter, timeToLoadTheMap } from '$lib/constants';
import { authManager } from '$lib/managers/auth-manager.svelte';
import { featureFlagsManager } from '$lib/managers/feature-flags-manager.svelte';
import AssetChangeDateModal from '$lib/modals/AssetChangeDateModal.svelte';
import { isFaceEditMode } from '$lib/stores/face-edit.svelte';
import { boundingBoxesArray } from '$lib/stores/people.store';
import { locale } from '$lib/stores/preferences.store';
import { featureFlags } from '$lib/stores/server-config.store';
import { preferences, user } from '$lib/stores/user.store';
import { getAssetThumbnailUrl, getPeopleThumbnailUrl, handlePromiseError } from '$lib/utils';
import { delay, isFlipped } from '$lib/utils/asset-utils';
import { getAssetThumbnailUrl, getPeopleThumbnailUrl } from '$lib/utils';
import { delay, getDimensions } from '$lib/utils/asset-utils';
import { getByteUnitString } from '$lib/utils/byte-units';
import { handleError } from '$lib/utils/handle-error';
import { getMetadataSearchQuery } from '$lib/utils/metadata-search';
import { fromISODateTime, fromISODateTimeUTC } from '$lib/utils/timeline-util';
import { fromISODateTime, fromISODateTimeUTC, toTimelineAsset } from '$lib/utils/timeline-util';
import { getParentPath } from '$lib/utils/tree-utils';
import {
AssetMediaSize,
getAssetInfo,
updateAsset,
type AlbumResponseDto,
type AssetResponseDto,
type ExifResponseDto,
} from '@immich/sdk';
import { IconButton } from '@immich/ui';
import { AssetMediaSize, getAssetInfo, type AlbumResponseDto, type AssetResponseDto } from '@immich/sdk';
import { Icon, IconButton, LoadingSpinner, modalManager } from '@immich/ui';
import {
mdiCalendar,
mdiCameraIris,
@@ -48,7 +37,6 @@
import { slide } from 'svelte/transition';
import ImageThumbnail from '../assets/thumbnail/image-thumbnail.svelte';
import PersonSidePanel from '../faces-page/person-side-panel.svelte';
import LoadingSpinner from '../shared-components/loading-spinner.svelte';
import UserAvatar from '../shared-components/user-avatar.svelte';
import AlbumListItemDetails from './album-list-item-details.svelte';
@@ -61,17 +49,28 @@
let { asset, albums = [], currentAlbum = null, onClose }: Props = $props();
const getDimensions = (exifInfo: ExifResponseDto) => {
const { exifImageWidth: width, exifImageHeight: height } = exifInfo;
if (isFlipped(exifInfo.orientation)) {
return { width: height, height: width };
}
return { width, height };
};
let showAssetPath = $state(false);
let showEditFaces = $state(false);
let isOwner = $derived($user?.id === asset.ownerId);
let people = $derived(asset.people || []);
let unassignedFaces = $derived(asset.unassignedFaces || []);
let showingHiddenPeople = $state(false);
let timeZone = $derived(asset.exifInfo?.timeZone ?? undefined);
let dateTime = $derived(
timeZone && asset.exifInfo?.dateTimeOriginal
? fromISODateTime(asset.exifInfo.dateTimeOriginal, timeZone)
: fromISODateTimeUTC(asset.localDateTime),
);
let latlng = $derived(
(() => {
const lat = asset.exifInfo?.latitude;
const lng = asset.exifInfo?.longitude;
if (lat && lng) {
return { lat: Number(lat.toFixed(7)), lng: Number(lng.toFixed(7)) };
}
})(),
);
let previousId: string | undefined = $state();
$effect(() => {
@@ -84,42 +83,6 @@
}
});
let isOwner = $derived($user?.id === asset.ownerId);
const handleNewAsset = async (newAsset: AssetResponseDto) => {
// TODO: check if reloading asset data is necessary
if (newAsset.id && !authManager.isSharedLink) {
const data = await getAssetInfo({ id: asset.id });
people = data?.people || [];
unassignedFaces = data?.unassignedFaces || [];
}
};
$effect(() => {
handlePromiseError(handleNewAsset(asset));
});
let latlng = $derived(
(() => {
const lat = asset.exifInfo?.latitude;
const lng = asset.exifInfo?.longitude;
if (lat && lng) {
return { lat: Number(lat.toFixed(7)), lng: Number(lng.toFixed(7)) };
}
})(),
);
let people = $state(asset.people || []);
let unassignedFaces = $state(asset.unassignedFaces || []);
let showingHiddenPeople = $state(false);
let timeZone = $derived(asset.exifInfo?.timeZone);
let dateTime = $derived(
timeZone && asset.exifInfo?.dateTimeOriginal
? fromISODateTime(asset.exifInfo.dateTimeOriginal, timeZone)
: fromISODateTimeUTC(asset.localDateTime),
);
const getMegapixel = (width: number, height: number): number | undefined => {
const megapixel = Math.round((height * width) / 1_000_000);
@@ -131,10 +94,7 @@
};
const handleRefreshPeople = async () => {
await getAssetInfo({ id: asset.id }).then((data) => {
people = data?.people || [];
unassignedFaces = data?.unassignedFaces || [];
});
asset = await getAssetInfo({ id: asset.id });
showEditFaces = false;
};
@@ -148,18 +108,13 @@
const toggleAssetPath = () => (showAssetPath = !showAssetPath);
let isShowChangeDate = $state(false);
async function handleConfirmChangeDate(result: AbsoluteResult | RelativeResult) {
isShowChangeDate = false;
try {
if (result.mode === 'absolute') {
await updateAsset({ id: asset.id, updateAssetDto: { dateTimeOriginal: result.date } });
}
} catch (error) {
handleError(error, $t('errors.unable_to_change_date'));
const handleChangeDate = async () => {
if (!isOwner) {
return;
}
}
await modalManager.show(AssetChangeDateModal, { asset: toTimelineAsset(asset), initialDate: dateTime });
};
</script>
<section class="relative p-2">
@@ -203,7 +158,7 @@
{#if !authManager.isSharedLink && isOwner}
<section class="px-4 pt-4 text-sm">
<div class="flex h-10 w-full items-center justify-between">
<h2>{$t('people').toUpperCase()}</h2>
<h2 class="uppercase">{$t('people')}</h2>
<div class="flex gap-2 items-center">
{#if people.some((person) => person.isHidden)}
<IconButton
@@ -244,10 +199,12 @@
{#each people as person, index (person.id)}
{#if showingHiddenPeople || !person.isHidden}
<a
class="w-[90px]"
href="{AppRoute.PEOPLE}/{person.id}?{QueryParameter.PREVIOUS_ROUTE}={currentAlbum?.id
? `${AppRoute.ALBUMS}/${currentAlbum?.id}`
: AppRoute.PHOTOS}"
class="w-22"
href={resolve(
`${AppRoute.PEOPLE}/${person.id}?${QueryParameter.PREVIOUS_ROUTE}=${
currentAlbum?.id ? `${AppRoute.ALBUMS}/${currentAlbum?.id}` : AppRoute.PHOTOS
}`,
)}
onfocus={() => ($boundingBoxesArray = people[index].faces)}
onblur={() => ($boundingBoxesArray = [])}
onmouseover={() => ($boundingBoxesArray = people[index].faces)}
@@ -304,24 +261,23 @@
<div class="px-4 py-4">
{#if asset.exifInfo}
<div class="flex h-10 w-full items-center justify-between text-sm">
<h2>{$t('details').toUpperCase()}</h2>
<h2 class="uppercase">{$t('details')}</h2>
</div>
{:else}
<p class="text-sm">{$t('no_exif_info_available').toUpperCase()}</p>
<p class="uppercase text-sm">{$t('no_exif_info_available')}</p>
{/if}
{#if dateTime}
<button
type="button"
class="flex w-full text-start justify-between place-items-start gap-4 py-4"
onclick={() => (isOwner ? (isShowChangeDate = true) : null)}
onclick={handleChangeDate}
title={isOwner ? $t('edit_date') : ''}
class:hover:dark:text-immich-dark-primary={isOwner}
class:hover:text-immich-primary={isOwner}
class:hover:text-primary={isOwner}
>
<div class="flex gap-4">
<div>
<Icon path={mdiCalendar} size="24" />
<Icon icon={mdiCalendar} size="24" />
</div>
<div>
@@ -342,6 +298,7 @@
weekday: 'short',
hour: 'numeric',
minute: '2-digit',
second: '2-digit',
timeZoneName: timeZone ? 'longOffset' : undefined,
},
{ locale: $locale },
@@ -353,7 +310,7 @@
{#if isOwner}
<div class="p-1">
<Icon path={mdiPencil} size="20" />
<Icon icon={mdiPencil} size="20" />
</div>
{/if}
</button>
@@ -361,27 +318,17 @@
<div class="flex justify-between place-items-start gap-4 py-4">
<div class="flex gap-4">
<div>
<Icon path={mdiCalendar} size="24" />
<Icon icon={mdiCalendar} size="24" />
</div>
</div>
<div class="p-1">
<Icon path={mdiPencil} size="20" />
<Icon icon={mdiPencil} size="20" />
</div>
</div>
{/if}
{#if isShowChangeDate}
<ChangeDate
initialDate={dateTime}
initialTimeZone={timeZone ?? ''}
withDuration={false}
onConfirm={handleConfirmChangeDate}
onCancel={() => (isShowChangeDate = false)}
/>
{/if}
<div class="flex gap-4 py-4">
<div><Icon path={mdiImageOutline} size="24" /></div>
<div><Icon icon={mdiImageOutline} size="24" /></div>
<div>
<p class="break-all flex place-items-center gap-2 whitespace-pre-wrap">
@@ -399,10 +346,8 @@
{/if}
</p>
{#if showAssetPath}
<p
class="text-xs opacity-50 break-all pb-2 hover:dark:text-immich-dark-primary hover:text-immich-primary"
transition:slide={{ duration: 250 }}
>
<p class="text-xs opacity-50 break-all pb-2 hover:text-primary" transition:slide={{ duration: 250 }}>
<!-- eslint-disable-next-line svelte/no-navigation-without-resolve this is supposed to be treated as an absolute/external link -->
<a href={getAssetFolderHref(asset)} title={$t('go_to_folder')} class="whitespace-pre-wrap">
{asset.originalPath}
</a>
@@ -429,18 +374,20 @@
{#if asset.exifInfo?.make || asset.exifInfo?.model || asset.exifInfo?.fNumber}
<div class="flex gap-4 py-4">
<div><Icon path={mdiCameraIris} size="24" /></div>
<div><Icon icon={mdiCameraIris} size="24" /></div>
<div>
{#if asset.exifInfo?.make || asset.exifInfo?.model}
<p>
<a
href="{AppRoute.SEARCH}?{getMetadataSearchQuery({
...(asset.exifInfo?.make ? { make: asset.exifInfo.make } : {}),
...(asset.exifInfo?.model ? { model: asset.exifInfo.model } : {}),
})}"
href={resolve(
`${AppRoute.SEARCH}?${getMetadataSearchQuery({
...(asset.exifInfo?.make ? { make: asset.exifInfo.make } : {}),
...(asset.exifInfo?.model ? { model: asset.exifInfo.model } : {}),
})}`,
)}
title="{$t('search_for')} {asset.exifInfo.make || ''} {asset.exifInfo.model || ''}"
class="hover:dark:text-immich-dark-primary hover:text-immich-primary"
class="hover:text-primary"
>
{asset.exifInfo.make || ''}
{asset.exifInfo.model || ''}
@@ -452,9 +399,11 @@
<div class="flex gap-2 text-sm">
<p>
<a
href="{AppRoute.SEARCH}?{getMetadataSearchQuery({ lensModel: asset.exifInfo.lensModel })}"
href={resolve(
`${AppRoute.SEARCH}?${getMetadataSearchQuery({ lensModel: asset.exifInfo.lensModel })}`,
)}
title="{$t('search_for')} {asset.exifInfo.lensModel}"
class="hover:dark:text-immich-dark-primary hover:text-immich-primary line-clamp-1"
class="hover:text-primary line-clamp-1"
>
{asset.exifInfo.lensModel}
</a>
@@ -489,17 +438,17 @@
</div>
</section>
{#if latlng && $featureFlags.loaded && $featureFlags.map}
<div class="h-[360px]">
{#await import('../shared-components/map/map.svelte')}
{#if latlng && featureFlagsManager.value.map}
<div class="h-90">
{#await import('$lib/components/shared-components/map/map.svelte')}
{#await delay(timeToLoadTheMap) then}
<!-- show the loading spinner only if loading the map takes too much time -->
<div class="flex items-center justify-center h-full w-full">
<LoadingSpinner />
</div>
{/await}
{:then component}
<component.default
{:then { default: Map }}
<Map
mapMarkers={[
{
lat: latlng.lat,
@@ -516,7 +465,7 @@
simplified
useLocationPin
showSimpleControls={!showEditFaces}
onOpenInMapView={() => goto(`${AppRoute.MAP}#12.5/${latlng.lat}/${latlng.lng}`)}
onOpenInMapView={() => goto(resolve(`${AppRoute.MAP}#12.5/${latlng.lat}/${latlng.lng}`))}
>
{#snippet popup({ marker })}
{@const { lat, lon } = marker}
@@ -525,20 +474,20 @@
<a
href="https://www.openstreetmap.org/?mlat={lat}&mlon={lon}&zoom=13#map=15/{lat}/{lon}"
target="_blank"
class="font-medium text-immich-primary"
class="font-medium text-primary underline focus:outline-none"
>
{$t('open_in_openstreetmap')}
</a>
</div>
{/snippet}
</component.default>
</Map>
{/await}
</div>
{/if}
{#if currentAlbum && currentAlbum.albumUsers.length > 0 && asset.owner}
<section class="px-6 dark:text-immich-dark-fg mt-4">
<p class="text-sm">{$t('shared_by').toUpperCase()}</p>
<p class="uppercase text-sm">{$t('shared_by')}</p>
<div class="flex gap-4 pt-4">
<div>
<UserAvatar user={asset.owner} size="md" />
@@ -554,15 +503,15 @@
{/if}
{#if albums.length > 0}
<section class="px-6 pt-6 dark:text-immich-dark-fg">
<p class="pb-4 text-sm">{$t('appears_in').toUpperCase()}</p>
<section class="px-6 py-6 dark:text-immich-dark-fg">
<p class="uppercase pb-4 text-sm">{$t('appears_in')}</p>
{#each albums as album (album.id)}
<a href="{AppRoute.ALBUMS}/{album.id}">
<a href={resolve(`${AppRoute.ALBUMS}/${album.id}`)}>
<div class="flex gap-4 pt-2 hover:cursor-pointer items-center">
<div>
<img
alt={album.albumName}
class="h-[50px] w-[50px] rounded object-cover"
class="h-12.5 w-12.5 rounded object-cover"
src={album.albumThumbnailAssetId &&
getAssetThumbnailUrl({ id: album.albumThumbnailAssetId, size: AssetMediaSize.Preview })}
draggable="false"
@@ -1,11 +1,11 @@
<script lang="ts">
import { type DownloadProgress, downloadManager } from '$lib/managers/download-manager.svelte';
import { locale } from '$lib/stores/preferences.store';
import { Heading, IconButton } from '@immich/ui';
import { mdiClose } from '@mdi/js';
import { t } from 'svelte-i18n';
import { fly, slide } from 'svelte/transition';
import { getByteUnitString } from '../../utils/byte-units';
import { IconButton } from '@immich/ui';
const abort = (downloadKey: string, download: DownloadProgress) => {
download.abort?.abort();
@@ -16,41 +16,40 @@
{#if downloadManager.isDownloading}
<div
transition:fly={{ x: -100, duration: 350 }}
class="fixed bottom-10 start-2 max-h-[270px] w-[315px] rounded-2xl border p-4 text-sm shadow-sm bg-light"
class="fixed bottom-10 start-2 max-h-67.5 w-79 rounded-2xl border dark:border-white/10 p-4 shadow-lg bg-subtle"
>
<p class="mb-2 text-xs text-gray-500">{$t('downloading').toUpperCase()}</p>
<div class="my-2 mb-2 flex max-h-[200px] flex-col overflow-y-auto text-sm">
<Heading size="tiny">{$t('downloading')}</Heading>
<div class="my-2 mb-2 flex max-h-50 flex-col overflow-y-auto text-sm">
{#each Object.keys(downloadManager.assets) as downloadKey (downloadKey)}
{@const download = downloadManager.assets[downloadKey]}
<div class="mb-2 flex place-items-center" transition:slide>
<div class="w-full pe-10">
<div class="flex place-items-center justify-between gap-2 text-xs font-medium">
<p class="truncate">{downloadKey}</p>
<p class="truncate">{downloadKey}</p>
{#if download.total}
<p class="whitespace-nowrap">{getByteUnitString(download.total, $locale)}</p>
{/if}
</div>
<div class="flex place-items-center gap-2">
<div class="h-[7px] w-full rounded-full bg-gray-200">
<div class="h-[7px] rounded-full bg-immich-primary" style={`width: ${download.percentage}%`}></div>
<div class="h-2.5 w-full rounded-full bg-neutral-200 dark:bg-neutral-600">
<div class="h-2.5 rounded-full bg-primary" style={`width: ${download.percentage}%`}></div>
</div>
<p class="min-w-[4em] whitespace-nowrap text-right">
<span class="text-immich-primary">
<p class="min-w-16 whitespace-nowrap text-right">
<span class="text-primary">
{(download.percentage / 100).toLocaleString($locale, { style: 'percent' })}
</span>
</p>
</div>
</div>
<div class="absolute end-2">
<div class="absolute end-4">
<IconButton
variant="ghost"
shape="round"
color="secondary"
variant="outline"
shape="round"
aria-label={$t('close')}
onclick={() => abort(downloadKey, download)}
size="large"
size="tiny"
icon={mdiClose}
class="dark:text-immich-dark-gray"
/>
</div>
</div>
@@ -1,7 +1,6 @@
<script lang="ts">
import Icon from '$lib/components/elements/icon.svelte';
import type { CropAspectRatio } from '$lib/stores/asset-editor.store';
import { Button, type Color } from '@immich/ui';
import { Button, Icon, type Color } from '@immich/ui';
interface Props {
size: {
@@ -38,7 +37,7 @@
<li>
<Button shape="round" color={buttonColor} class="flex-col gap-1" size="small" onclick={() => selectType(size.name)}>
<Icon size="1.75em" path={size.icon} viewBox={size.viewBox} class={toRotate(size.rotate) ? 'rotate-90' : ''} />
<Icon size="1.75em" icon={size.icon} viewBox={size.viewBox} class={toRotate(size.rotate) ? 'rotate-90' : ''} />
<span>{rotatedTitle(size.name, rotateHorizontal)}</span>
</Button>
</li>
@@ -135,7 +135,7 @@
<div class="mt-3 px-4 py-4">
<div class="flex h-10 w-full items-center justify-between text-sm">
<h2>{$t('editor_crop_tool_h2_aspect_ratios').toUpperCase()}</h2>
<h2 class="uppercase">{$t('editor_crop_tool_h2_aspect_ratios')}</h2>
</div>
{#each sizesRows as sizesRow, index (index)}
<ul class="flex-wrap flex-row flex gap-x-6 py-2 justify-evenly">
@@ -145,7 +145,7 @@
</ul>
{/each}
<div class="flex h-10 w-full items-center justify-between text-sm">
<h2>{$t('editor_crop_tool_h2_rotation').toUpperCase()}</h2>
<h2 class="uppercase">{$t('editor_crop_tool_h2_rotation')}</h2>
</div>
<ul class="flex-wrap flex-row flex gap-x-6 gap-y-4 justify-center">
<li>
@@ -1,12 +1,11 @@
<script lang="ts">
import ImageThumbnail from '$lib/components/assets/thumbnail/image-thumbnail.svelte';
import { notificationController } from '$lib/components/shared-components/notification/notification';
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
import { isFaceEditMode } from '$lib/stores/face-edit.svelte';
import { getPeopleThumbnailUrl } from '$lib/utils';
import { handleError } from '$lib/utils/handle-error';
import { createFace, getAllPeople, type PersonResponseDto } from '@immich/sdk';
import { Button, Input, modalManager } from '@immich/ui';
import { Button, Input, modalManager, toastManager } from '@immich/ui';
import { Canvas, InteractiveFabricObject, Rect } from 'fabric';
import { onMount } from 'svelte';
import { t } from 'svelte-i18n';
@@ -278,9 +277,7 @@
try {
const data = getFaceCroppedCoordinates();
if (!data) {
notificationController.show({
message: $t('error_tag_face_bounding_box'),
});
toastManager.warning($t('error_tag_face_bounding_box'));
return;
}
@@ -325,7 +322,7 @@
<Input placeholder={$t('search_people')} bind:value={searchTerm} size="tiny" />
</div>
<div class="h-[250px] overflow-y-auto mt-2">
<div class="h-62.5 overflow-y-auto mt-2">
{#if filteredCandidates.length > 0}
<div class="mt-2 rounded-lg">
{#each filteredCandidates as person (person.id)}
@@ -1,11 +1,11 @@
<script lang="ts">
import { authManager } from '$lib/managers/auth-manager.svelte';
import { getAssetOriginalUrl } from '$lib/utils';
import { getAssetOriginalUrl, getAssetThumbnailUrl } from '$lib/utils';
import { isWebCompatibleImage } from '$lib/utils/asset-utils';
import { AssetMediaSize, viewAsset, type AssetResponseDto } from '@immich/sdk';
import { LoadingSpinner } from '@immich/ui';
import { t } from 'svelte-i18n';
import { fade } from 'svelte/transition';
import LoadingSpinner from '../shared-components/loading-spinner.svelte';
interface Props {
asset: AssetResponseDto;
@@ -25,7 +25,9 @@
{:then [data, { default: PhotoSphereViewer }]}
<PhotoSphereViewer
panorama={data}
originalPanorama={isWebCompatibleImage(asset) ? getAssetOriginalUrl(asset.id) : undefined}
originalPanorama={isWebCompatibleImage(asset)
? getAssetOriginalUrl(asset.id)
: getAssetThumbnailUrl({ id: asset.id, size: AssetMediaSize.Fullsize, cacheKey: asset.thumbhash })}
/>
{:catch}
{$t('errors.failed_to_load_asset')}
@@ -0,0 +1,36 @@
<script lang="ts">
import type { OcrBox } from '$lib/utils/ocr-utils';
import { calculateBoundingBoxDimensions } from '$lib/utils/ocr-utils';
type Props = {
ocrBox: OcrBox;
};
let { ocrBox }: Props = $props();
const dimensions = $derived(calculateBoundingBoxDimensions(ocrBox.points));
const transform = $derived(
`translate(${dimensions.minX}px, ${dimensions.minY}px) rotate(${dimensions.rotation}deg) skew(${dimensions.skewX}deg, ${dimensions.skewY}deg)`,
);
const transformOrigin = $derived(
`${dimensions.centerX - dimensions.minX}px ${dimensions.centerY - dimensions.minY}px`,
);
</script>
<div class="absolute group left-0 top-0 pointer-events-none">
<!-- Bounding box with CSS transforms -->
<div
class="absolute border-2 border-blue-500 bg-blue-500/10 cursor-pointer pointer-events-auto transition-all group-hover:bg-blue-500/30 group-hover:border-blue-600 group-hover:border-[3px]"
style="width: {dimensions.width}px; height: {dimensions.height}px; transform: {transform}; transform-origin: {transformOrigin};"
></div>
<!-- Text overlay - always rendered but invisible, allows text selection and copy -->
<div
class="absolute flex items-center justify-center text-transparent text-sm px-2 py-1 pointer-events-auto cursor-text whitespace-pre-wrap wrap-break-word select-text group-hover:text-white group-hover:bg-black/75 group-hover:z-10"
style="width: {dimensions.width}px; height: {dimensions.height}px; transform: {transform}; transform-origin: {transformOrigin};"
>
{ocrBox.text}
</div>
</div>
@@ -0,0 +1,17 @@
<script lang="ts">
import { ocrManager } from '$lib/stores/ocr.svelte';
import { IconButton } from '@immich/ui';
import { mdiTextRecognition } from '@mdi/js';
import { t } from 'svelte-i18n';
</script>
<IconButton
title={ocrManager.showOverlay ? $t('hide_text_recognition') : $t('show_text_recognition')}
icon={mdiTextRecognition}
class={"dark {ocrStore.showOverlay ? 'bg-immich-primary text-white dark' : 'dark'}"}
color="secondary"
variant="ghost"
shape="round"
aria-label={$t('text_recognition')}
onclick={() => ocrManager.toggleOcrBoundingBox()}
/>
@@ -1,4 +1,5 @@
<script lang="ts">
import { boundingBoxesArray, type Faces } from '$lib/stores/people.store';
import { alwaysLoadOriginalFile } from '$lib/stores/preferences.store';
import {
EquirectangularAdapter,
@@ -8,11 +9,21 @@
type PluginConstructor,
} from '@photo-sphere-viewer/core';
import '@photo-sphere-viewer/core/index.css';
import { MarkersPlugin } from '@photo-sphere-viewer/markers-plugin';
import '@photo-sphere-viewer/markers-plugin/index.css';
import { ResolutionPlugin } from '@photo-sphere-viewer/resolution-plugin';
import { SettingsPlugin } from '@photo-sphere-viewer/settings-plugin';
import '@photo-sphere-viewer/settings-plugin/index.css';
import { onDestroy, onMount } from 'svelte';
// Adapted as well as possible from classlist 'border-solid border-white border-3 rounded-lg'
const FACE_BOX_SVG_STYLE = {
fill: 'rgba(0, 0, 0, 0)',
stroke: '#ffffff',
strokeWidth: '3px',
strokeLinejoin: 'round',
};
interface Props {
panorama: string | { source: string };
originalPanorama?: string | { source: string };
@@ -26,6 +37,62 @@
let container: HTMLDivElement | undefined = $state();
let viewer: Viewer;
let animationInProgress: { cancel: () => void } | undefined;
let previousFaces: Faces[] = [];
const boundingBoxesUnsubscribe = boundingBoxesArray.subscribe((faces: Faces[]) => {
// Debounce; don't do anything when the data didn't actually change.
if (faces === previousFaces) {
return;
}
previousFaces = faces;
if (animationInProgress) {
animationInProgress.cancel();
animationInProgress = undefined;
}
if (!viewer || !viewer.state.textureData || !viewer.getPlugin(MarkersPlugin)) {
return;
}
const markersPlugin = viewer.getPlugin<MarkersPlugin>(MarkersPlugin);
// croppedWidth is the size of the texture, which might be cropped to be less than 360/180 degrees.
// This is what we want because the facial recognition is done on the image, not the sphere.
const currentTextureWidth = viewer.state.textureData.panoData.croppedWidth;
markersPlugin.clearMarkers();
for (const [index, face] of faces.entries()) {
const { boundingBoxX1: x1, boundingBoxY1: y1, boundingBoxX2: x2, boundingBoxY2: y2 } = face;
const ratio = currentTextureWidth / face.imageWidth;
// Pixel values are translated to spherical coordinates and only then added to the panorama;
// no need to recalculate when the texture image changes to the original size.
markersPlugin.addMarker({
id: `face_${index}`,
polygonPixels: [
[x1 * ratio, y1 * ratio],
[x2 * ratio, y1 * ratio],
[x2 * ratio, y2 * ratio],
[x1 * ratio, y2 * ratio],
],
svgStyle: FACE_BOX_SVG_STYLE,
});
}
// Smoothly pan to the highlighted (hovered-over) face.
if (faces.length === 1) {
const { boundingBoxX1: x1, boundingBoxY1: y1, boundingBoxX2: x2, boundingBoxY2: y2, imageWidth: w } = faces[0];
const ratio = currentTextureWidth / w;
const x = ((x1 + x2) * ratio) / 2;
const y = ((y1 + y2) * ratio) / 2;
animationInProgress = viewer.animate({
textureX: x,
textureY: y,
zoom: Math.min(viewer.getZoomLevel(), 75),
speed: 500, // duration in ms
});
}
});
onMount(() => {
if (!container) {
return;
@@ -34,6 +101,7 @@
viewer = new Viewer({
adapter,
plugins: [
MarkersPlugin,
SettingsPlugin,
[
ResolutionPlugin,
@@ -68,7 +136,7 @@
zoomSpeed: 0.5,
fisheye: false,
});
const resolutionPlugin = viewer.getPlugin(ResolutionPlugin) as ResolutionPlugin;
const resolutionPlugin = viewer.getPlugin<ResolutionPlugin>(ResolutionPlugin);
const zoomHandler = ({ zoomLevel }: events.ZoomUpdatedEvent) => {
// zoomLevel range: [0, 100]
if (Math.round(zoomLevel) >= 75) {
@@ -89,6 +157,7 @@
if (viewer) {
viewer.destroy();
}
boundingBoxesUnsubscribe();
});
</script>
@@ -1,7 +1,7 @@
import { getAnimateMock } from '$lib/__mocks__/animate.mock';
import PhotoViewer from '$lib/components/asset-viewer/photo-viewer.svelte';
import * as utils from '$lib/utils';
import { AssetMediaSize } from '@immich/sdk';
import { AssetMediaSize, AssetTypeEnum } from '@immich/sdk';
import { assetFactory } from '@test-data/factories/asset-factory';
import { sharedLinkFactory } from '@test-data/factories/shared-link-factory';
import { render } from '@testing-library/svelte';
@@ -65,7 +65,11 @@ describe('PhotoViewer component', () => {
});
it('loads the thumbnail', () => {
const asset = assetFactory.build({ originalPath: 'image.jpg', originalMimeType: 'image/jpeg' });
const asset = assetFactory.build({
originalPath: 'image.jpg',
originalMimeType: 'image/jpeg',
type: AssetTypeEnum.Image,
});
render(PhotoViewer, { asset });
expect(getAssetThumbnailUrlSpy).toBeCalledWith({
@@ -76,16 +80,89 @@ describe('PhotoViewer component', () => {
expect(getAssetOriginalUrlSpy).not.toBeCalled();
});
it('loads the original image for gifs', () => {
const asset = assetFactory.build({ originalPath: 'image.gif', originalMimeType: 'image/gif' });
it('loads the thumbnail image for static gifs', () => {
const asset = assetFactory.build({
originalPath: 'image.gif',
originalMimeType: 'image/gif',
type: AssetTypeEnum.Image,
});
render(PhotoViewer, { asset });
expect(getAssetThumbnailUrlSpy).toBeCalledWith({
id: asset.id,
size: AssetMediaSize.Preview,
cacheKey: asset.thumbhash,
});
expect(getAssetOriginalUrlSpy).not.toBeCalled();
});
it('loads the thumbnail image for static webp images', () => {
const asset = assetFactory.build({
originalPath: 'image.webp',
originalMimeType: 'image/webp',
type: AssetTypeEnum.Image,
});
render(PhotoViewer, { asset });
expect(getAssetThumbnailUrlSpy).toBeCalledWith({
id: asset.id,
size: AssetMediaSize.Preview,
cacheKey: asset.thumbhash,
});
expect(getAssetOriginalUrlSpy).not.toBeCalled();
});
it('loads the original image for animated gifs', () => {
const asset = assetFactory.build({
originalPath: 'image.gif',
originalMimeType: 'image/gif',
type: AssetTypeEnum.Image,
duration: '2.0',
});
render(PhotoViewer, { asset });
expect(getAssetThumbnailUrlSpy).not.toBeCalled();
expect(getAssetOriginalUrlSpy).toBeCalledWith({ id: asset.id, cacheKey: asset.thumbhash });
});
it('loads original for shared link when download permission is true and showMetadata permission is true', () => {
const asset = assetFactory.build({ originalPath: 'image.gif', originalMimeType: 'image/gif' });
it('loads the original image for animated webp images', () => {
const asset = assetFactory.build({
originalPath: 'image.webp',
originalMimeType: 'image/webp',
type: AssetTypeEnum.Image,
duration: '2.0',
});
render(PhotoViewer, { asset });
expect(getAssetThumbnailUrlSpy).not.toBeCalled();
expect(getAssetOriginalUrlSpy).toBeCalledWith({ id: asset.id, cacheKey: asset.thumbhash });
});
it('not loads original static image in shared link even when download permission is true and showMetadata permission is true', () => {
const asset = assetFactory.build({
originalPath: 'image.gif',
originalMimeType: 'image/gif',
type: AssetTypeEnum.Image,
});
const sharedLink = sharedLinkFactory.build({ allowDownload: true, showMetadata: true, assets: [asset] });
render(PhotoViewer, { asset, sharedLink });
expect(getAssetThumbnailUrlSpy).toBeCalledWith({
id: asset.id,
size: AssetMediaSize.Preview,
cacheKey: asset.thumbhash,
});
expect(getAssetOriginalUrlSpy).not.toBeCalled();
});
it('loads original animated image in shared link when download permission is true and showMetadata permission is true', () => {
const asset = assetFactory.build({
originalPath: 'image.gif',
originalMimeType: 'image/gif',
type: AssetTypeEnum.Image,
duration: '2.0',
});
const sharedLink = sharedLinkFactory.build({ allowDownload: true, showMetadata: true, assets: [asset] });
render(PhotoViewer, { asset, sharedLink });
@@ -93,8 +170,13 @@ describe('PhotoViewer component', () => {
expect(getAssetOriginalUrlSpy).toBeCalledWith({ id: asset.id, cacheKey: asset.thumbhash });
});
it('not loads original image when shared link download permission is false', () => {
const asset = assetFactory.build({ originalPath: 'image.gif', originalMimeType: 'image/gif' });
it('not loads original animated image when shared link download permission is false', () => {
const asset = assetFactory.build({
originalPath: 'image.gif',
originalMimeType: 'image/gif',
type: AssetTypeEnum.Image,
duration: '2.0',
});
const sharedLink = sharedLinkFactory.build({ allowDownload: false, assets: [asset] });
render(PhotoViewer, { asset, sharedLink });
@@ -107,8 +189,13 @@ describe('PhotoViewer component', () => {
expect(getAssetOriginalUrlSpy).not.toBeCalled();
});
it('not loads original image when shared link showMetadata permission is false', () => {
const asset = assetFactory.build({ originalPath: 'image.gif', originalMimeType: 'image/gif' });
it('not loads original animated image when shared link showMetadata permission is false', () => {
const asset = assetFactory.build({
originalPath: 'image.gif',
originalMimeType: 'image/gif',
type: AssetTypeEnum.Image,
duration: '2.0',
});
const sharedLink = sharedLinkFactory.build({ showMetadata: false, assets: [asset] });
render(PhotoViewer, { asset, sharedLink });
@@ -2,12 +2,14 @@
import { shortcuts } from '$lib/actions/shortcut';
import { zoomImageAction } from '$lib/actions/zoom-image';
import FaceEditor from '$lib/components/asset-viewer/face-editor/face-editor.svelte';
import OcrBoundingBox from '$lib/components/asset-viewer/ocr-bounding-box.svelte';
import BrokenAsset from '$lib/components/assets/broken-asset.svelte';
import { assetViewerFadeDuration } from '$lib/constants';
import { castManager } from '$lib/managers/cast-manager.svelte';
import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
import { photoViewerImgElement } from '$lib/stores/assets-store.svelte';
import { isFaceEditMode } from '$lib/stores/face-edit.svelte';
import { ocrManager } from '$lib/stores/ocr.svelte';
import { boundingBoxesArray } from '$lib/stores/people.store';
import { alwaysLoadOriginalFile } from '$lib/stores/preferences.store';
import { SlideshowLook, SlideshowState, slideshowLookCssMapping, slideshowStore } from '$lib/stores/slideshow.store';
@@ -15,17 +17,17 @@
import { getAssetOriginalUrl, getAssetThumbnailUrl, handlePromiseError } from '$lib/utils';
import { canCopyImageToClipboard, copyImageToClipboard, isWebCompatibleImage } from '$lib/utils/asset-utils';
import { handleError } from '$lib/utils/handle-error';
import { getOcrBoundingBoxes } from '$lib/utils/ocr-utils';
import { getBoundingBox } from '$lib/utils/people-utils';
import { cancelImageUrl } from '$lib/utils/sw-messaging';
import { getAltText } from '$lib/utils/thumbnail-util';
import { toTimelineAsset } from '$lib/utils/timeline-util';
import { AssetMediaSize, type AssetResponseDto, type SharedLinkResponseDto } from '@immich/sdk';
import { AssetMediaSize, AssetTypeEnum, type AssetResponseDto, type SharedLinkResponseDto } from '@immich/sdk';
import { LoadingSpinner, toastManager } from '@immich/ui';
import { onDestroy, onMount } from 'svelte';
import { swipe, type SwipeCustomEvent } from 'svelte-gestures';
import { useSwipe, type SwipeCustomEvent } from 'svelte-gestures';
import { t } from 'svelte-i18n';
import { fade } from 'svelte/transition';
import LoadingSpinner from '../shared-components/loading-spinner.svelte';
import { NotificationType, notificationController } from '../shared-components/notification/notification';
interface Props {
asset: AssetResponseDto;
@@ -72,6 +74,14 @@
$boundingBoxesArray = [];
});
let ocrBoxes = $derived(
ocrManager.showOverlay && $photoViewerImgElement
? getOcrBoundingBoxes(ocrManager.data, $photoZoomState, $photoViewerImgElement)
: [],
);
let isOcrActive = $derived(ocrManager.showOverlay);
const preload = (targetSize: AssetMediaSize | 'original', preloadAssets?: TimelineAsset[]) => {
for (const preloadAsset of preloadAssets || []) {
if (preloadAsset.isImage) {
@@ -92,17 +102,13 @@
};
copyImage = async () => {
if (!canCopyImageToClipboard()) {
if (!canCopyImageToClipboard() || !$photoViewerImgElement) {
return;
}
try {
await copyImageToClipboard($photoViewerImgElement ?? assetFileUrl);
notificationController.show({
type: NotificationType.Info,
message: $t('copied_image_to_clipboard'),
timeout: 3000,
});
await copyImageToClipboard($photoViewerImgElement);
toastManager.info($t('copied_image_to_clipboard'));
} catch (error) {
handleError(error, $t('copy_error'));
}
@@ -135,16 +141,25 @@
if ($photoZoomState.currentZoom > 1) {
return;
}
if (ocrManager.showOverlay) {
return;
}
if (onNextAsset && event.detail.direction === 'left') {
onNextAsset();
}
if (onPreviousAsset && event.detail.direction === 'right') {
onPreviousAsset();
}
};
// when true, will force loading of the original image
let forceUseOriginal: boolean = $derived(asset.originalMimeType === 'image/gif' || $photoZoomState.currentZoom > 1);
let forceUseOriginal: boolean = $derived(
(asset.type === AssetTypeEnum.Image && asset.duration && !asset.duration.includes('0:00:00.000')) ||
$photoZoomState.currentZoom > 1,
);
const targetImageSize = $derived.by(() => {
if ($alwaysLoadOriginalFile || forceUseOriginal || originalImageLoaded) {
@@ -237,9 +252,8 @@
</div>
{:else if !imageError}
<div
use:zoomImageAction
use:swipe={() => ({})}
onswipe={onSwipe}
use:zoomImageAction={{ disabled: isOcrActive }}
{...useSwipe(onSwipe)}
class="h-full w-full"
transition:fade={{ duration: haveFadeTransition ? assetViewerFadeDuration : 0 }}
>
@@ -263,10 +277,14 @@
<!-- eslint-disable-next-line svelte/require-each-key -->
{#each getBoundingBox($boundingBoxesArray, $photoZoomState, $photoViewerImgElement) as boundingbox}
<div
class="absolute border-solid border-white border-[3px] rounded-lg"
class="absolute border-solid border-white border-3 rounded-lg"
style="top: {boundingbox.top}px; left: {boundingbox.left}px; height: {boundingbox.height}px; width: {boundingbox.width}px;"
></div>
{/each}
{#each ocrBoxes as ocrBox (ocrBox.id)}
<OcrBoundingBox {ocrBox} />
{/each}
</div>
{#if isFaceEditMode.value}
@@ -7,7 +7,7 @@
import { IconButton, modalManager } from '@immich/ui';
import { mdiChevronLeft, mdiChevronRight, mdiClose, mdiCog, mdiFullscreen, mdiPause, mdiPlay } from '@mdi/js';
import { onDestroy, onMount } from 'svelte';
import { swipe } from 'svelte-gestures';
import { useSwipe } from 'svelte-gestures';
import { t } from 'svelte-i18n';
import { fly } from 'svelte/transition';
@@ -100,9 +100,7 @@
};
const onShowSettings = async () => {
// eslint-disable-next-line tscompat/tscompat
if (document.fullscreenElement) {
// eslint-disable-next-line tscompat/tscompat
await document.exitFullscreen();
}
await modalManager.show(SlideshowSettingsModal);
@@ -114,11 +112,7 @@
webkitIsFullScreen?: boolean;
};
if (
// eslint-disable-next-line tscompat/tscompat
!document.fullscreenElement &&
!doc.webkitIsFullScreen
) {
if (!document.fullscreenElement && !doc.webkitIsFullScreen) {
onClose();
}
}
@@ -131,6 +125,13 @@
document.removeEventListener('webkitfullscreenchange', exitFullscreenHandler);
};
});
const { swipe, onswipe, onswipedown } = useSwipe(
() => {},
() => ({ touchAction: 'pan-x' }),
{ onswipedown: showControlBar },
true,
);
</script>
<svelte:document
@@ -153,7 +154,8 @@
]}
/>
<svelte:body use:swipe={() => ({ touchAction: 'pan-x' })} onswipedown={showControlBar} />
{/* @ts-expect-error https://github.com/Rezi/svelte-gestures/issues/38#issuecomment-3315953573 */ null}
<svelte:body {@attach swipe} {onswipe} {onswipedown} />
{#if showControls}
<div
@@ -1,24 +1,27 @@
<script lang="ts">
import FaceEditor from '$lib/components/asset-viewer/face-editor/face-editor.svelte';
import VideoRemoteViewer from '$lib/components/asset-viewer/video-remote-viewer.svelte';
import LoadingSpinner from '$lib/components/shared-components/loading-spinner.svelte';
import { assetViewerFadeDuration } from '$lib/constants';
import { castManager } from '$lib/managers/cast-manager.svelte';
import { isFaceEditMode } from '$lib/stores/face-edit.svelte';
import { loopVideo as loopVideoPreference, videoViewerMuted, videoViewerVolume } from '$lib/stores/preferences.store';
import { getAssetPlaybackUrl, getAssetThumbnailUrl } from '$lib/utils';
import { handleError } from '$lib/utils/handle-error';
import {
autoPlayVideo,
loopVideo as loopVideoPreference,
videoViewerMuted,
videoViewerVolume,
} from '$lib/stores/preferences.store';
import { getAssetOriginalUrl, getAssetPlaybackUrl, getAssetThumbnailUrl } from '$lib/utils';
import { AssetMediaSize } from '@immich/sdk';
import { LoadingSpinner } from '@immich/ui';
import { onDestroy, onMount } from 'svelte';
import type { SwipeCustomEvent } from 'svelte-gestures';
import { swipe } from 'svelte-gestures';
import { t } from 'svelte-i18n';
import { useSwipe, type SwipeCustomEvent } from 'svelte-gestures';
import { fade } from 'svelte/transition';
interface Props {
assetId: string;
loopVideo: boolean;
cacheKey: string | null;
playOriginalVideo: boolean;
onPreviousAsset?: () => void;
onNextAsset?: () => void;
onVideoEnded?: () => void;
@@ -30,6 +33,7 @@
assetId,
loopVideo,
cacheKey,
playOriginalVideo,
onPreviousAsset = () => {},
onNextAsset = () => {},
onVideoEnded = () => {},
@@ -40,16 +44,19 @@
let videoPlayer: HTMLVideoElement | undefined = $state();
let isLoading = $state(true);
let assetFileUrl = $state('');
let forceMuted = $state(false);
let isScrubbing = $state(false);
let showVideo = $state(false);
onMount(() => {
// Show video after mount to ensure fading in.
showVideo = true;
assetFileUrl = getAssetPlaybackUrl({ id: assetId, cacheKey });
});
$effect(() => {
assetFileUrl = playOriginalVideo
? getAssetOriginalUrl({ id: assetId, cacheKey })
: getAssetPlaybackUrl({ id: assetId, cacheKey });
if (videoPlayer) {
forceMuted = false;
videoPlayer.load();
}
});
@@ -67,23 +74,27 @@
onVideoStarted();
}
} catch (error) {
if (error instanceof DOMException && error.name === 'NotAllowedError' && !forceMuted) {
if (error instanceof DOMException && error.name === 'NotAllowedError') {
await tryForceMutedPlay(video);
return;
}
handleError(error, $t('errors.unable_to_play_video'));
// auto-play failed
} finally {
isLoading = false;
}
};
const tryForceMutedPlay = async (video: HTMLVideoElement) => {
if (video.muted) {
return;
}
try {
video.muted = true;
await handleCanPlay(video);
} catch (error) {
handleError(error, $t('errors.unable_to_play_video'));
} catch {
// muted auto-play failed
}
};
@@ -126,26 +137,22 @@
<video
bind:this={videoPlayer}
loop={$loopVideoPreference && loopVideo}
autoplay
autoplay={$autoPlayVideo}
playsinline
controls
disablePictureInPicture
class="h-full object-contain"
use:swipe={() => ({})}
onswipe={onSwipe}
{...useSwipe(onSwipe)}
oncanplay={(e) => handleCanPlay(e.currentTarget)}
onended={onVideoEnded}
onvolumechange={(e) => {
if (!forceMuted) {
$videoViewerMuted = e.currentTarget.muted;
}
}}
onvolumechange={(e) => ($videoViewerMuted = e.currentTarget.muted)}
onseeking={() => (isScrubbing = true)}
onseeked={() => (isScrubbing = false)}
onplaying={(e) => {
e.currentTarget.focus();
}}
onclose={() => onClose()}
muted={forceMuted || $videoViewerMuted}
muted={$videoViewerMuted}
bind:volume={$videoViewerVolume}
poster={getAssetThumbnailUrl({ id: assetId, size: AssetMediaSize.Preview, cacheKey })}
src={assetFileUrl}
@@ -1,8 +1,8 @@
<script lang="ts">
import { getAssetOriginalUrl, getAssetPlaybackUrl } from '$lib/utils';
import { LoadingSpinner } from '@immich/ui';
import { t } from 'svelte-i18n';
import { fade } from 'svelte/transition';
import LoadingSpinner from '../shared-components/loading-spinner.svelte';
interface Props {
assetId: string;
@@ -1,9 +1,7 @@
<script lang="ts">
import Icon from '$lib/components/elements/icon.svelte';
import LoadingSpinner from '$lib/components/shared-components/loading-spinner.svelte';
import { castManager, CastState } from '$lib/managers/cast-manager.svelte';
import { handleError } from '$lib/utils/handle-error';
import { IconButton } from '@immich/ui';
import { Icon, IconButton, LoadingSpinner } from '@immich/ui';
import { mdiCastConnected, mdiPause, mdiPlay } from '@mdi/js';
import { t } from 'svelte-i18n';
@@ -72,7 +70,7 @@
</script>
<span class="flex items-center space-x-2 text-gray-200 text-2xl font-bold">
<Icon path={mdiCastConnected} class="text-primary" size="36" />
<Icon icon={mdiCastConnected} class="text-primary" size="36" />
<span>{$t('connected_to')} {castManager.receiverName}</span>
</span>
@@ -1,13 +1,14 @@
<script lang="ts">
import { ProjectionType } from '$lib/constants';
import VideoNativeViewer from '$lib/components/asset-viewer/video-native-viewer.svelte';
import VideoPanoramaViewer from '$lib/components/asset-viewer/video-panorama-viewer.svelte';
import { ProjectionType } from '$lib/constants';
interface Props {
assetId: string;
projectionType: string | null | undefined;
cacheKey: string | null;
loopVideo: boolean;
playOriginalVideo: boolean;
onClose?: () => void;
onPreviousAsset?: () => void;
onNextAsset?: () => void;
@@ -20,6 +21,7 @@
projectionType,
cacheKey,
loopVideo,
playOriginalVideo,
onPreviousAsset,
onClose,
onNextAsset,
@@ -35,6 +37,7 @@
{loopVideo}
{cacheKey}
{assetId}
{playOriginalVideo}
{onPreviousAsset}
{onNextAsset}
{onVideoEnded}
@@ -1,5 +1,5 @@
<script lang="ts">
import Icon from '$lib/components/elements/icon.svelte';
import { Icon } from '@immich/ui';
import { mdiImageBrokenVariant } from '@mdi/js';
import { t } from 'svelte-i18n';
@@ -18,7 +18,7 @@
style:width
style:height
>
<Icon path={mdiImageBrokenVariant} size="7em" class="max-w-full" />
<Icon icon={mdiImageBrokenVariant} size="7em" class="max-w-full" />
{#if !hideMessage}
<span class="text-center">{$t('error_loading_image')}</span>
{/if}
@@ -1,7 +1,7 @@
<script lang="ts">
import BrokenAsset from '$lib/components/assets/broken-asset.svelte';
import Icon from '$lib/components/elements/icon.svelte';
import { cancelImageUrl } from '$lib/utils/sw-messaging';
import { Icon } from '@immich/ui';
import { mdiEyeOffOutline } from '@mdi/js';
import type { ActionReturn } from 'svelte/action';
import type { ClassValue } from 'svelte/elements';
@@ -20,6 +20,7 @@
hiddenIconClass?: string;
class?: ClassValue;
brokenAssetClass?: ClassValue;
preload?: boolean;
onComplete?: ((errored: boolean) => void) | undefined;
}
@@ -38,6 +39,7 @@
onComplete = undefined,
class: imageClass = '',
brokenAssetClass = '',
preload = true,
}: Props = $props();
let loaded = $state(false);
@@ -68,7 +70,7 @@
circle && 'rounded-full',
shadow && 'shadow-lg',
(circle || !heightStyle) && 'aspect-square',
border && 'border-[3px] border-immich-dark-primary/80 hover:border-immich-primary',
border && 'border-3 border-immich-dark-primary/80 hover:border-immich-primary',
brokenAssetClass,
]
.filter(Boolean)
@@ -92,11 +94,13 @@
{title}
class={['object-cover', optionalClasses, imageClass]}
draggable="false"
loading={preload ? 'eager' : 'lazy'}
/>
{/if}
{#if hidden}
<div class="absolute start-1/2 top-1/2 translate-x-[-50%] translate-y-[-50%] transform">
<Icon {title} path={mdiEyeOffOutline} size="2em" class={hiddenIconClass} />
<!-- TODO fix `title` type -->
<Icon title={title ?? undefined} icon={mdiEyeOffOutline} size="2em" class={hiddenIconClass} />
</div>
{/if}
@@ -1,8 +1,7 @@
<script lang="ts">
import Icon from '$lib/components/elements/icon.svelte';
import { ProjectionType } from '$lib/constants';
import { locale, playVideoThumbnailOnHover } from '$lib/stores/preferences.store';
import { getAssetPlaybackUrl, getAssetThumbnailUrl } from '$lib/utils';
import { getAssetOriginalUrl, getAssetPlaybackUrl, getAssetThumbnailUrl } from '$lib/utils';
import { timeToSeconds } from '$lib/utils/date-time';
import { getAltText } from '$lib/utils/thumbnail-util';
import { AssetMediaSize, AssetVisibility, type UserResponseDto } from '@immich/sdk';
@@ -10,6 +9,7 @@
mdiArchiveArrowDownOutline,
mdiCameraBurst,
mdiCheckCircle,
mdiFileGifBox,
mdiHeart,
mdiMotionPauseOutline,
mdiMotionPlayOutline,
@@ -23,6 +23,7 @@
import { moveFocus } from '$lib/utils/focus-util';
import { currentUrlReplaceAssetId } from '$lib/utils/navigation';
import { TUNABLES } from '$lib/utils/tunables';
import { Icon } from '@immich/ui';
import { onMount } from 'svelte';
import type { ClassValue } from 'svelte/elements';
import { fade } from 'svelte/transition';
@@ -201,7 +202,7 @@
<div
class={[
'focus-visible:outline-none flex overflow-hidden',
disabled ? 'bg-gray-300' : 'bg-immich-primary/20 dark:bg-immich-dark-primary/20',
disabled ? 'bg-gray-300' : 'dark:bg-neutral-700 bg-neutral-200',
]}
style:width="{width}px"
style:height="{height}px"
@@ -236,7 +237,7 @@
></div>
<div
class={['group absolute -top-[0px] -bottom-[0px]', { 'cursor-not-allowed': disabled, 'cursor-pointer': !disabled }]}
class={['group absolute top-0 bottom-0', { 'cursor-not-allowed': disabled, 'cursor-pointer': !disabled }]}
style:width="inherit"
style:height="inherit"
>
@@ -267,7 +268,7 @@
<!-- Favorite asset star -->
{#if !authManager.isSharedLink && asset.isFavorite}
<div class="absolute bottom-2 start-2">
<Icon path={mdiHeart} size="24" class="text-white" />
<Icon icon={mdiHeart} size="24" class="text-white" />
</div>
{/if}
@@ -279,14 +280,22 @@
{#if !authManager.isSharedLink && showArchiveIcon && asset.visibility === AssetVisibility.Archive}
<div class={['absolute start-2', asset.isFavorite ? 'bottom-10' : 'bottom-2']}>
<Icon path={mdiArchiveArrowDownOutline} size="24" class="text-white" />
<Icon icon={mdiArchiveArrowDownOutline} size="24" class="text-white" />
</div>
{/if}
{#if asset.isImage && asset.projectionType === ProjectionType.EQUIRECTANGULAR}
<div class="absolute end-0 top-0 flex place-items-center gap-1 text-xs font-medium text-white">
<span class="pe-2 pt-2">
<Icon path={mdiRotate360} size="24" />
<Icon icon={mdiRotate360} size="24" />
</span>
</div>
{/if}
{#if asset.isImage && asset.duration && !asset.duration.includes('0:00:00.000')}
<div class="absolute end-0 top-0 flex place-items-center gap-1 text-xs font-medium text-white">
<span class="pe-2 pt-2">
<Icon icon={mdiFileGifBox} size="24" />
</span>
</div>
{/if}
@@ -301,7 +310,7 @@
>
<span class="pe-2 pt-2 flex place-items-center gap-1">
<p>{asset.stack.assetCount.toLocaleString($locale)}</p>
<Icon path={mdiCameraBurst} size="24" />
<Icon icon={mdiCameraBurst} size="24" />
</span>
</div>
{/if}
@@ -331,7 +340,7 @@
onComplete={(errored) => ((loaded = true), (thumbError = errored))}
/>
{#if asset.isVideo}
<div class="absolute top-0 h-full w-full">
<div class="absolute top-0 h-full w-full pointer-events-none">
<VideoThumbnail
url={getAssetPlaybackUrl({ id: asset.id, cacheKey: asset.thumbhash })}
enablePlayback={mouseOver && $playVideoThumbnailOnHover}
@@ -341,7 +350,7 @@
/>
</div>
{:else if asset.isImage && asset.livePhotoVideoId}
<div class="absolute top-0 h-full w-full">
<div class="absolute top-0 h-full w-full pointer-events-none">
<VideoThumbnail
url={getAssetPlaybackUrl({ id: asset.livePhotoVideoId, cacheKey: asset.thumbhash })}
enablePlayback={mouseOver && $playVideoThumbnailOnHover}
@@ -352,6 +361,25 @@
playbackOnIconHover={!$playVideoThumbnailOnHover}
/>
</div>
{:else if asset.isImage && asset.duration && !asset.duration.includes('0:00:00.000') && mouseOver}
<!-- GIF -->
<div class="absolute top-0 h-full w-full pointer-events-none">
<div class="absolute h-full w-full bg-linear-to-b from-black/25 via-[transparent_25%]"></div>
<ImageThumbnail
class={imageClass}
{brokenAssetClass}
url={getAssetOriginalUrl({ id: asset.id, cacheKey: asset.thumbhash })}
altText={$getAltText(asset)}
widthStyle="{width}px"
heightStyle="{height}px"
curve={selected}
/>
<div class="absolute end-0 top-0 flex place-items-center gap-1 text-xs font-medium text-white">
<span class="pe-2 pt-2">
<Icon icon={mdiMotionPauseOutline} size="24" />
</span>
</div>
</div>
{/if}
{#if (!loaded || thumbError) && asset.thumbhash}
@@ -388,13 +416,13 @@
{disabled}
>
{#if disabled}
<Icon path={mdiCheckCircle} size="24" class="text-zinc-800" />
<Icon icon={mdiCheckCircle} size="24" class="text-zinc-800" />
{:else if selected}
<div class="rounded-full bg-[#D9DCEF] dark:bg-[#232932]">
<Icon path={mdiCheckCircle} size="24" class="text-primary" />
<Icon icon={mdiCheckCircle} size="24" class="text-primary" />
</div>
{:else}
<Icon path={mdiCheckCircle} size="24" class="text-white/80 hover:text-white" />
<Icon icon={mdiCheckCircle} size="24" class="text-white/80 hover:text-white" />
{/if}
</button>
{/if}
@@ -1,6 +1,5 @@
<script lang="ts">
import Icon from '$lib/components/elements/icon.svelte';
import LoadingSpinner from '$lib/components/shared-components/loading-spinner.svelte';
import { Icon, LoadingSpinner } from '@immich/ui';
import { mdiAlertCircleOutline, mdiPauseCircleOutline, mdiPlayCircleOutline } from '@mdi/js';
import { Duration } from 'luxon';
@@ -105,12 +104,12 @@
{#if loading}
<LoadingSpinner />
{:else if error}
<Icon path={mdiAlertCircleOutline} size="24" class="text-red-600" />
<Icon icon={mdiAlertCircleOutline} size="24" class="text-red-600" />
{:else}
<Icon path={pauseIcon} size="24" />
<Icon icon={pauseIcon} size="24" />
{/if}
{:else}
<Icon path={playIcon} size="24" />
<Icon icon={playIcon} size="24" />
{/if}
</span>
</div>
@@ -1,18 +0,0 @@
<script lang="ts">
import type { Snippet } from 'svelte';
interface Props {
rounded?: boolean | 'full';
children?: Snippet;
}
let { rounded = true, children }: Props = $props();
</script>
<span
class="bg-primary text-subtle inline-block h-min whitespace-nowrap px-3 py-1 text-center align-baseline text-xs leading-none"
class:rounded-md={rounded === true}
class:rounded-full={rounded === 'full'}
>
{@render children?.()}
</span>
@@ -1,69 +0,0 @@
<script lang="ts">
import { getTabbable } from '$lib/utils/focus-util';
import { Button } from '@immich/ui';
import { t } from 'svelte-i18n';
interface Props {
/**
* Target for the skip link to move focus to.
*/
target?: string;
/**
* Text for the skip link button.
*/
text?: string;
/**
* Breakpoint at which the skip link is visible. Defaults to always being visible.
*/
breakpoint?: 'sm' | 'md' | 'lg' | 'xl' | '2xl';
}
let { target = 'main', text = $t('skip_to_content'), breakpoint }: Props = $props();
let isFocused = $state(false);
const moveFocus = () => {
const targetEl = document.querySelector<HTMLElement>(target);
if (targetEl) {
const element = getTabbable(targetEl)[0];
if (element) {
element.focus();
}
}
};
const getBreakpoint = () => {
if (!breakpoint) {
return '';
}
switch (breakpoint) {
case 'sm': {
return 'hidden sm:block';
}
case 'md': {
return 'hidden md:block';
}
case 'lg': {
return 'hidden lg:block';
}
case 'xl': {
return 'hidden xl:block';
}
case '2xl': {
return 'hidden 2xl:block';
}
}
};
</script>
<div class="absolute top-2 start-2 transition-transform {isFocused ? 'translate-y-0' : '-translate-y-10 sr-only'}">
<Button
size="small"
onclick={moveFocus}
class={getBreakpoint()}
onfocus={() => (isFocused = true)}
onblur={() => (isFocused = false)}
>
{text}
</Button>
</div>
@@ -1,37 +0,0 @@
<script lang="ts">
interface Props {
type: 'date' | 'datetime-local';
value?: string;
min?: string;
max?: string;
class?: string;
id?: string;
name?: string;
placeholder?: string;
autofocus?: boolean;
onkeydown?: (e: KeyboardEvent) => void;
}
let { type, value = $bindable(), max = undefined, onkeydown, ...rest }: Props = $props();
let fallbackMax = $derived(type === 'date' ? '9999-12-31' : '9999-12-31T23:59');
// Updating `value` directly causes the date input to reset itself or
// interfere with user changes.
let updatedValue = $derived(value);
</script>
<input
{...rest}
{type}
bind:value
max={max || fallbackMax}
oninput={(e) => (updatedValue = e.currentTarget.value)}
onblur={() => (value = updatedValue)}
onkeydown={(e) => {
if (e.key === 'Enter') {
value = updatedValue;
}
onkeydown?.(e);
}}
/>
@@ -1,141 +0,0 @@
<script lang="ts" module>
// Necessary for eslint
/* eslint-disable @typescript-eslint/no-explicit-any */
type T = any;
export type RenderedOption = {
title: string;
icon?: string;
disabled?: boolean;
};
</script>
<script lang="ts" generics="T">
import { clickOutside } from '$lib/actions/click-outside';
import { Button, Text } from '@immich/ui';
import { mdiCheck } from '@mdi/js';
import { isEqual } from 'lodash-es';
import { fly } from 'svelte/transition';
import Icon from './icon.svelte';
interface Props {
class?: string;
options: T[];
selectedOption?: any;
showMenu?: boolean;
controlable?: boolean;
hideTextOnSmallScreen?: boolean;
title?: string | undefined;
position?: 'bottom-left' | 'bottom-right';
onSelect: (option: T) => void;
onClickOutside?: () => void;
render?: (item: T) => string | RenderedOption;
}
let {
position = 'bottom-left',
class: className = '',
options,
selectedOption = $bindable(options[0]),
showMenu = $bindable(false),
controlable = false,
hideTextOnSmallScreen = true,
title = undefined,
onSelect,
onClickOutside = () => {},
render = String,
}: Props = $props();
const handleClickOutside = () => {
if (!controlable) {
showMenu = false;
}
onClickOutside();
};
const handleSelectOption = (option: T) => {
onSelect(option);
selectedOption = option;
showMenu = false;
};
const renderOption = (option: T): RenderedOption => {
const renderedOption = render(option);
switch (typeof renderedOption) {
case 'string': {
return { title: renderedOption };
}
default: {
return {
title: renderedOption.title,
icon: renderedOption.icon,
disabled: renderedOption.disabled,
};
}
}
};
let renderedSelectedOption = $derived(renderOption(selectedOption));
const getAlignClass = (position: 'bottom-left' | 'bottom-right') => {
switch (position) {
case 'bottom-left': {
return 'start-0';
}
case 'bottom-right': {
return 'end-0';
}
default: {
return '';
}
}
};
</script>
<div use:clickOutside={{ onOutclick: handleClickOutside, onEscape: handleClickOutside }} class="relative">
<!-- BUTTON TITLE -->
<Button onclick={() => (showMenu = true)} fullWidth {title} variant="ghost" color="secondary" size="small">
{#if renderedSelectedOption?.icon}
<Icon path={renderedSelectedOption.icon} />
{/if}
<Text class={hideTextOnSmallScreen ? 'hidden sm:block' : ''}>{renderedSelectedOption.title}</Text>
</Button>
<!-- DROP DOWN MENU -->
{#if showMenu}
<div
transition:fly={{ y: -30, duration: 250 }}
class="text-sm font-medium z-1 absolute flex min-w-[250px] max-h-[70vh] overflow-y-auto immich-scrollbar flex-col rounded-2xl bg-gray-100 py-2 text-black shadow-lg dark:bg-gray-700 dark:text-white {className} {getAlignClass(
position,
)}"
>
{#each options as option (option)}
{@const renderedOption = renderOption(option)}
{@const buttonStyle = renderedOption.disabled ? '' : 'transition-all hover:bg-gray-300 dark:hover:bg-gray-800'}
<button
type="button"
class="grid grid-cols-[36px_1fr] place-items-center p-2 disabled:opacity-40 {buttonStyle}"
disabled={renderedOption.disabled}
onclick={() => !renderedOption.disabled && handleSelectOption(option)}
>
{#if isEqual(selectedOption, option)}
<div class="text-immich-primary dark:text-immich-dark-primary">
<Icon path={mdiCheck} />
</div>
<p class="justify-self-start text-immich-primary dark:text-immich-dark-primary">
{renderedOption.title}
</p>
{:else}
<div></div>
<p class="justify-self-start">
{renderedOption.title}
</p>
{/if}
</button>
{/each}
</div>
{/if}
</div>
@@ -1,52 +0,0 @@
<script lang="ts">
import { t } from 'svelte-i18n';
import { Duration } from 'luxon';
interface Props {
value: number;
class?: string;
id?: string;
}
let { value = $bindable(), class: className = '', ...rest }: Props = $props();
function minToParts(minutes: number) {
const duration = Duration.fromObject({ minutes: Math.abs(minutes) }).shiftTo('days', 'hours', 'minutes');
return {
sign: minutes < 0 ? -1 : 1,
days: duration.days === 0 ? null : duration.days,
hours: duration.hours === 0 ? null : duration.hours,
minutes: duration.minutes === 0 ? null : duration.minutes,
};
}
function partsToMin(sign: number, days: number | null, hours: number | null, minutes: number | null) {
return (
sign *
Duration.fromObject({ days: days ?? 0, hours: hours ?? 0, minutes: minutes ?? 0 }).shiftTo('minutes').minutes
);
}
const initial = minToParts(value);
let sign = $state(initial.sign);
let days = $state(initial.days);
let hours = $state(initial.hours);
let minutes = $state(initial.minutes);
$effect(() => {
value = partsToMin(sign, days, hours, minutes);
});
function toggleSign() {
sign = -sign;
}
</script>
<div class={`flex gap-2 ${className}`} {...rest}>
<button type="button" class="w-8 text-xl font-bold leading-none" onclick={toggleSign} title="Toggle sign">
{sign >= 0 ? '+' : '-'}
</button>
<input type="number" min="0" placeholder={$t('days')} class="w-1/3" bind:value={days} />
<input type="number" min="0" max="23" placeholder={$t('hours')} class="w-1/3" bind:value={hours} />
<input type="number" min="0" max="59" placeholder={$t('minutes')} class="w-1/3" bind:value={minutes} />
</div>
@@ -1,40 +0,0 @@
<script lang="ts">
import { generateId } from '$lib/utils/generate-id';
interface Props {
filters: string[];
labels?: string[];
selected: string;
label: string;
onSelect: (selected: string) => void;
}
let { filters, selected, label, labels, onSelect }: Props = $props();
const id = `group-tab-${generateId()}`;
</script>
<fieldset
class="dark:bg-immich-dark-gray flex h-full rounded-2xl bg-gray-200 ring-gray-400 has-focus-visible:ring dark:ring-gray-600"
>
<legend class="sr-only">{label}</legend>
{#each filters as filter, index (`${id}-${index}`)}
<div class="group">
<input
type="radio"
name={id}
id="{id}-{index}"
class="peer sr-only"
value={filter}
checked={filter === selected}
onchange={() => onSelect(filter)}
/>
<label
for="{id}-{index}"
class="flex h-full cursor-pointer items-center px-4 text-sm hover:bg-gray-300 group-first-of-type:rounded-s-2xl group-last-of-type:rounded-e-2xl peer-checked:bg-gray-300 dark:hover:bg-gray-800 peer-checked:dark:bg-gray-700"
>
{labels?.[index] ?? filter}
</label>
</div>
{/each}
</fieldset>
@@ -1,60 +0,0 @@
<script lang="ts">
import type { AriaRole } from 'svelte/elements';
interface Props {
size?: string | number;
color?: string;
path: string;
title?: string | null;
desc?: string;
flipped?: boolean;
class?: string;
viewBox?: string;
role?: AriaRole;
ariaHidden?: boolean | undefined;
ariaLabel?: string | undefined;
ariaLabelledby?: string | undefined;
strokeWidth?: number;
strokeColor?: string;
spin?: boolean;
}
let {
size = '1em',
color = 'currentColor',
path,
title = null,
desc = '',
flipped = false,
class: className = '',
viewBox = '0 0 24 24',
role = 'img',
ariaHidden = undefined,
ariaLabel = undefined,
ariaLabelledby = undefined,
strokeWidth = 0,
strokeColor = 'currentColor',
spin = false,
}: Props = $props();
</script>
<svg
width={size}
height={size}
{viewBox}
class="{className} {flipped ? '-scale-x-100' : ''} {spin ? 'animate-spin' : ''}"
{role}
stroke={strokeColor}
stroke-width={strokeWidth}
aria-label={ariaLabel}
aria-hidden={ariaHidden}
aria-labelledby={ariaLabelledby}
>
{#if title}
<title>{title}</title>
{/if}
{#if desc}
<desc>{desc}</desc>
{/if}
<path d={path} fill={color} />
</svg>

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