1
0
Fork 0
mirror of https://github.com/immich-app/immich.git synced 2024-12-29 15:11:58 +00:00

chore(web): migration svelte 5 syntax (#13883)

This commit is contained in:
Alex 2024-11-14 08:43:25 -06:00 committed by GitHub
parent 9203a61709
commit 0b3742cf13
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
310 changed files with 6435 additions and 4176 deletions

6
web/package-lock.json generated
View file

@ -36,7 +36,7 @@
"@faker-js/faker": "^9.0.0", "@faker-js/faker": "^9.0.0",
"@socket.io/component-emitter": "^3.1.0", "@socket.io/component-emitter": "^3.1.0",
"@sveltejs/adapter-static": "^3.0.5", "@sveltejs/adapter-static": "^3.0.5",
"@sveltejs/enhanced-img": "^0.3.0", "@sveltejs/enhanced-img": "^0.3.9",
"@sveltejs/kit": "^2.7.2", "@sveltejs/kit": "^2.7.2",
"@sveltejs/vite-plugin-svelte": "^4.0.0", "@sveltejs/vite-plugin-svelte": "^4.0.0",
"@testing-library/jest-dom": "^6.4.2", "@testing-library/jest-dom": "^6.4.2",
@ -53,7 +53,7 @@
"dotenv": "^16.4.5", "dotenv": "^16.4.5",
"eslint": "^9.0.0", "eslint": "^9.0.0",
"eslint-config-prettier": "^9.1.0", "eslint-config-prettier": "^9.1.0",
"eslint-plugin-svelte": "^2.43.0", "eslint-plugin-svelte": "^2.45.1",
"eslint-plugin-unicorn": "^55.0.0", "eslint-plugin-unicorn": "^55.0.0",
"factory.ts": "^1.4.1", "factory.ts": "^1.4.1",
"globals": "^15.9.0", "globals": "^15.9.0",
@ -68,7 +68,7 @@
"tailwindcss": "^3.4.1", "tailwindcss": "^3.4.1",
"tslib": "^2.6.2", "tslib": "^2.6.2",
"typescript": "^5.5.0", "typescript": "^5.5.0",
"vite": "^5.1.4", "vite": "^5.4.4",
"vitest": "^2.0.5" "vitest": "^2.0.5"
} }
}, },

View file

@ -28,7 +28,7 @@
"@faker-js/faker": "^9.0.0", "@faker-js/faker": "^9.0.0",
"@socket.io/component-emitter": "^3.1.0", "@socket.io/component-emitter": "^3.1.0",
"@sveltejs/adapter-static": "^3.0.5", "@sveltejs/adapter-static": "^3.0.5",
"@sveltejs/enhanced-img": "^0.3.0", "@sveltejs/enhanced-img": "^0.3.9",
"@sveltejs/kit": "^2.7.2", "@sveltejs/kit": "^2.7.2",
"@sveltejs/vite-plugin-svelte": "^4.0.0", "@sveltejs/vite-plugin-svelte": "^4.0.0",
"@testing-library/jest-dom": "^6.4.2", "@testing-library/jest-dom": "^6.4.2",
@ -45,7 +45,7 @@
"dotenv": "^16.4.5", "dotenv": "^16.4.5",
"eslint": "^9.0.0", "eslint": "^9.0.0",
"eslint-config-prettier": "^9.1.0", "eslint-config-prettier": "^9.1.0",
"eslint-plugin-svelte": "^2.43.0", "eslint-plugin-svelte": "^2.45.1",
"eslint-plugin-unicorn": "^55.0.0", "eslint-plugin-unicorn": "^55.0.0",
"factory.ts": "^1.4.1", "factory.ts": "^1.4.1",
"globals": "^15.9.0", "globals": "^15.9.0",
@ -60,7 +60,7 @@
"tailwindcss": "^3.4.1", "tailwindcss": "^3.4.1",
"tslib": "^2.6.2", "tslib": "^2.6.2",
"typescript": "^5.5.0", "typescript": "^5.5.0",
"vite": "^5.1.4", "vite": "^5.4.4",
"vitest": "^2.0.5" "vitest": "^2.0.5"
}, },
"type": "module", "type": "module",

View file

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

View file

@ -1,4 +1,4 @@
export const autoGrowHeight = (textarea: HTMLTextAreaElement, height = 'auto') => { export const autoGrowHeight = (textarea?: HTMLTextAreaElement, height = 'auto') => {
if (!textarea) { if (!textarea) {
return; return;
} }

View file

@ -10,7 +10,7 @@ interface Options {
/** /**
* The container element that with direct children that should be navigated. * The container element that with direct children that should be navigated.
*/ */
container: HTMLElement; container?: HTMLElement;
/** /**
* Indicates if the dropdown is open. * Indicates if the dropdown is open.
*/ */
@ -52,7 +52,11 @@ export const contextMenuNavigation: Action<HTMLElement, Options> = (node, option
await tick(); await tick();
} }
const children = Array.from(container?.children).filter((child) => child.tagName !== 'HR') as HTMLElement[]; if (!container) {
return;
}
const children = Array.from(container.children).filter((child) => child.tagName !== 'HR') as HTMLElement[];
if (children.length === 0) { if (children.length === 0) {
return; return;
} }

View file

@ -6,8 +6,15 @@ import type { Action } from 'svelte/action';
* @param node Element which listens for keyboard events * @param node Element which listens for keyboard events
* @param container Element containing the list of elements * @param container Element containing the list of elements
*/ */
export const listNavigation: Action<HTMLElement, HTMLElement> = (node, container: HTMLElement) => { export const listNavigation: Action<HTMLElement, HTMLElement | undefined> = (
node: HTMLElement,
container?: HTMLElement,
) => {
const moveFocus = (direction: 'up' | 'down') => { const moveFocus = (direction: 'up' | 'down') => {
if (!container) {
return;
}
const children = Array.from(container?.children); const children = Array.from(container?.children);
if (children.length === 0) { if (children.length === 0) {
return; return;

View file

@ -7,13 +7,17 @@
import { deleteUserAdmin, type UserResponseDto } from '@immich/sdk'; import { deleteUserAdmin, type UserResponseDto } from '@immich/sdk';
import { t } from 'svelte-i18n'; import { t } from 'svelte-i18n';
export let user: UserResponseDto; interface Props {
export let onSuccess: () => void; user: UserResponseDto;
export let onFail: () => void; onSuccess: () => void;
export let onCancel: () => void; onFail: () => void;
onCancel: () => void;
}
let forceDelete = false; let { user, onSuccess, onFail, onCancel }: Props = $props();
let deleteButtonDisabled = false;
let forceDelete = $state(false);
let deleteButtonDisabled = $state(false);
let userIdInput: string = ''; let userIdInput: string = '';
const handleDeleteUser = async () => { const handleDeleteUser = async () => {
@ -47,12 +51,14 @@
{onCancel} {onCancel}
disabled={deleteButtonDisabled} disabled={deleteButtonDisabled}
> >
<svelte:fragment slot="prompt"> {#snippet promptSnippet()}
<div class="flex flex-col gap-4"> <div class="flex flex-col gap-4">
{#if forceDelete} {#if forceDelete}
<p> <p>
<FormatMessage key="admin.user_delete_immediately" values={{ user: user.name }} let:message> <FormatMessage key="admin.user_delete_immediately" values={{ user: user.name }}>
<b>{message}</b> {#snippet children({ message })}
<b>{message}</b>
{/snippet}
</FormatMessage> </FormatMessage>
</p> </p>
{:else} {:else}
@ -60,9 +66,10 @@
<FormatMessage <FormatMessage
key="admin.user_delete_delay" key="admin.user_delete_delay"
values={{ user: user.name, delay: $serverConfig.userDeleteDelay }} values={{ user: user.name, delay: $serverConfig.userDeleteDelay }}
let:message
> >
<b>{message}</b> {#snippet children({ message })}
<b>{message}</b>
{/snippet}
</FormatMessage> </FormatMessage>
</p> </p>
{/if} {/if}
@ -73,7 +80,7 @@
label={$t('admin.user_delete_immediately_checkbox')} label={$t('admin.user_delete_immediately_checkbox')}
labelClass="text-sm dark:text-immich-dark-fg" labelClass="text-sm dark:text-immich-dark-fg"
bind:checked={forceDelete} bind:checked={forceDelete}
on:change={() => { onchange={() => {
deleteButtonDisabled = forceDelete; deleteButtonDisabled = forceDelete;
}} }}
/> />
@ -92,9 +99,9 @@
aria-describedby="confirm-user-desc" aria-describedby="confirm-user-desc"
name="confirm-user-id" name="confirm-user-id"
type="text" type="text"
on:input={handleConfirm} oninput={handleConfirm}
/> />
{/if} {/if}
</div> </div>
</svelte:fragment> {/snippet}
</ConfirmDialog> </ConfirmDialog>

View file

@ -1,10 +1,18 @@
<script lang="ts" context="module"> <script lang="ts" module>
export type Colors = 'light-gray' | 'gray' | 'dark-gray'; export type Colors = 'light-gray' | 'gray' | 'dark-gray';
</script> </script>
<script lang="ts"> <script lang="ts">
export let color: Colors; import type { Snippet } from 'svelte';
export let disabled = false;
interface Props {
color: Colors;
disabled?: boolean;
children?: Snippet;
onClick?: () => void;
}
let { color, disabled = false, onClick = () => {}, children }: Props = $props();
const colorClasses: Record<Colors, string> = { const colorClasses: Record<Colors, string> = {
'light-gray': 'bg-gray-300/80 dark:bg-gray-700', 'light-gray': 'bg-gray-300/80 dark:bg-gray-700',
@ -23,7 +31,7 @@
class="flex h-full w-full flex-col place-content-center place-items-center gap-2 px-8 py-2 text-xs text-gray-600 transition-colors dark:text-gray-200 {colorClasses[ class="flex h-full w-full flex-col place-content-center place-items-center gap-2 px-8 py-2 text-xs text-gray-600 transition-colors dark:text-gray-200 {colorClasses[
color color
]} {hoverClasses}" ]} {hoverClasses}"
on:click onclick={onClick}
> >
<slot /> {@render children?.()}
</button> </button>

View file

@ -1,9 +1,16 @@
<script lang="ts" context="module"> <script lang="ts" module>
export type Color = 'success' | 'warning'; export type Color = 'success' | 'warning';
</script> </script>
<script lang="ts"> <script lang="ts">
export let color: Color; import type { Snippet } from 'svelte';
interface Props {
color: Color;
children?: Snippet;
}
let { color, children }: Props = $props();
const colorClasses: Record<Color, string> = { const colorClasses: Record<Color, string> = {
success: 'bg-green-500/70 text-gray-900 dark:bg-green-700/90 dark:text-gray-100', success: 'bg-green-500/70 text-gray-900 dark:bg-green-700/90 dark:text-gray-100',
@ -12,5 +19,5 @@
</script> </script>
<div class="w-full p-2 text-center text-sm {colorClasses[color]}"> <div class="w-full p-2 text-center text-sm {colorClasses[color]}">
<slot /> {@render children?.()}
</div> </div>

View file

@ -19,22 +19,37 @@
import JobTileButton from './job-tile-button.svelte'; import JobTileButton from './job-tile-button.svelte';
import JobTileStatus from './job-tile-status.svelte'; import JobTileStatus from './job-tile-status.svelte';
export let title: string; interface Props {
export let subtitle: string | undefined; title: string;
export let description: Component | undefined; subtitle: string | undefined;
export let jobCounts: JobCountsDto; description: Component | undefined;
export let queueStatus: QueueStatusDto; jobCounts: JobCountsDto;
export let icon: string; queueStatus: QueueStatusDto;
export let disabled = false; icon: string;
disabled?: boolean;
allText: string | undefined;
refreshText: string | undefined;
missingText: string;
onCommand: (command: JobCommandDto) => void;
}
export let allText: string | undefined; let {
export let refreshText: string | undefined; title,
export let missingText: string; subtitle,
export let onCommand: (command: JobCommandDto) => void; description,
jobCounts,
queueStatus,
icon,
disabled = false,
allText,
refreshText,
missingText,
onCommand,
}: Props = $props();
$: waitingCount = jobCounts.waiting + jobCounts.paused + jobCounts.delayed; let waitingCount = $derived(jobCounts.waiting + jobCounts.paused + jobCounts.delayed);
$: isIdle = !queueStatus.isActive && !queueStatus.isPaused; let isIdle = $derived(!queueStatus.isActive && !queueStatus.isPaused);
$: multipleButtons = allText || refreshText; let multipleButtons = $derived(allText || refreshText);
const commonClasses = 'flex place-items-center justify-between w-full py-2 sm:py-4 pr-4 pl-6'; const commonClasses = 'flex place-items-center justify-between w-full py-2 sm:py-4 pr-4 pl-6';
</script> </script>
@ -67,7 +82,7 @@
title={$t('clear_message')} title={$t('clear_message')}
size="12" size="12"
padding="1" padding="1"
on:click={() => onCommand({ command: JobCommand.ClearFailed, force: false })} onclick={() => onCommand({ command: JobCommand.ClearFailed, force: false })}
/> />
</div> </div>
</Badge> </Badge>
@ -87,8 +102,9 @@
{/if} {/if}
{#if description} {#if description}
{@const SvelteComponent = description}
<div class="text-sm dark:text-white"> <div class="text-sm dark:text-white">
<svelte:component this={description} /> <SvelteComponent />
</div> </div>
{/if} {/if}
@ -118,7 +134,7 @@
<JobTileButton <JobTileButton
disabled={true} disabled={true}
color="light-gray" color="light-gray"
on:click={() => onCommand({ command: JobCommand.Start, force: false })} onClick={() => onCommand({ command: JobCommand.Start, force: false })}
> >
<Icon path={mdiAlertCircle} size="36" /> <Icon path={mdiAlertCircle} size="36" />
{$t('disabled').toUpperCase()} {$t('disabled').toUpperCase()}
@ -127,20 +143,20 @@
{#if !disabled && !isIdle} {#if !disabled && !isIdle}
{#if waitingCount > 0} {#if waitingCount > 0}
<JobTileButton color="gray" on:click={() => onCommand({ command: JobCommand.Empty, force: false })}> <JobTileButton color="gray" onClick={() => onCommand({ command: JobCommand.Empty, force: false })}>
<Icon path={mdiClose} size="24" /> <Icon path={mdiClose} size="24" />
{$t('clear').toUpperCase()} {$t('clear').toUpperCase()}
</JobTileButton> </JobTileButton>
{/if} {/if}
{#if queueStatus.isPaused} {#if queueStatus.isPaused}
{@const size = waitingCount > 0 ? '24' : '48'} {@const size = waitingCount > 0 ? '24' : '48'}
<JobTileButton color="light-gray" on:click={() => onCommand({ command: JobCommand.Resume, force: false })}> <JobTileButton color="light-gray" onClick={() => onCommand({ command: JobCommand.Resume, force: false })}>
<!-- size property is not reactive, so have to use width and height --> <!-- size property is not reactive, so have to use width and height -->
<Icon path={mdiFastForward} {size} /> <Icon path={mdiFastForward} {size} />
{$t('resume').toUpperCase()} {$t('resume').toUpperCase()}
</JobTileButton> </JobTileButton>
{:else} {:else}
<JobTileButton color="light-gray" on:click={() => onCommand({ command: JobCommand.Pause, force: false })}> <JobTileButton color="light-gray" onClick={() => onCommand({ command: JobCommand.Pause, force: false })}>
<Icon path={mdiPause} size="24" /> <Icon path={mdiPause} size="24" />
{$t('pause').toUpperCase()} {$t('pause').toUpperCase()}
</JobTileButton> </JobTileButton>
@ -149,25 +165,25 @@
{#if !disabled && multipleButtons && isIdle} {#if !disabled && multipleButtons && isIdle}
{#if allText} {#if allText}
<JobTileButton color="dark-gray" on:click={() => onCommand({ command: JobCommand.Start, force: true })}> <JobTileButton color="dark-gray" onClick={() => onCommand({ command: JobCommand.Start, force: true })}>
<Icon path={mdiAllInclusive} size="24" /> <Icon path={mdiAllInclusive} size="24" />
{allText} {allText}
</JobTileButton> </JobTileButton>
{/if} {/if}
{#if refreshText} {#if refreshText}
<JobTileButton color="gray" on:click={() => onCommand({ command: JobCommand.Start, force: undefined })}> <JobTileButton color="gray" onClick={() => onCommand({ command: JobCommand.Start, force: undefined })}>
<Icon path={mdiImageRefreshOutline} size="24" /> <Icon path={mdiImageRefreshOutline} size="24" />
{refreshText} {refreshText}
</JobTileButton> </JobTileButton>
{/if} {/if}
<JobTileButton color="light-gray" on:click={() => onCommand({ command: JobCommand.Start, force: false })}> <JobTileButton color="light-gray" onClick={() => onCommand({ command: JobCommand.Start, force: false })}>
<Icon path={mdiSelectionSearch} size="24" /> <Icon path={mdiSelectionSearch} size="24" />
{missingText} {missingText}
</JobTileButton> </JobTileButton>
{/if} {/if}
{#if !disabled && !multipleButtons && isIdle} {#if !disabled && !multipleButtons && isIdle}
<JobTileButton color="light-gray" on:click={() => onCommand({ command: JobCommand.Start, force: false })}> <JobTileButton color="light-gray" onClick={() => onCommand({ command: JobCommand.Start, force: false })}>
<Icon path={mdiPlay} size="48" /> <Icon path={mdiPlay} size="48" />
{$t('start').toUpperCase()} {$t('start').toUpperCase()}
</JobTileButton> </JobTileButton>

View file

@ -25,7 +25,11 @@
import { dialogController } from '$lib/components/shared-components/dialog/dialog'; import { dialogController } from '$lib/components/shared-components/dialog/dialog';
import { t } from 'svelte-i18n'; import { t } from 'svelte-i18n';
export let jobs: AllJobStatusResponseDto; interface Props {
jobs: AllJobStatusResponseDto;
}
let { jobs = $bindable() }: Props = $props();
interface JobDetails { interface JobDetails {
title: string; title: string;
@ -56,8 +60,7 @@
await handleCommand(jobId, dto); await handleCommand(jobId, dto);
}; };
// svelte-ignore reactive_declaration_non_reactive_property let jobDetails: Partial<Record<JobName, JobDetails>> = {
$: jobDetails = <Partial<Record<JobName, JobDetails>>>{
[JobName.ThumbnailGeneration]: { [JobName.ThumbnailGeneration]: {
icon: mdiFileJpgBox, icon: mdiFileJpgBox,
title: $getJobName(JobName.ThumbnailGeneration), title: $getJobName(JobName.ThumbnailGeneration),
@ -142,7 +145,8 @@
missingText: $t('missing'), missingText: $t('missing'),
}, },
}; };
$: jobList = Object.entries(jobDetails) as [JobName, JobDetails][];
let jobList = Object.entries(jobDetails) as [JobName, JobDetails][];
async function handleCommand(jobId: JobName, jobCommand: JobCommandDto) { async function handleCommand(jobId: JobName, jobCommand: JobCommandDto) {
const title = jobDetails[jobId]?.title; const title = jobDetails[jobId]?.title;

View file

@ -7,12 +7,13 @@
<FormatMessage <FormatMessage
key="admin.storage_template_migration_description" key="admin.storage_template_migration_description"
values={{ template: $t('admin.storage_template_settings') }} values={{ template: $t('admin.storage_template_settings') }}
let:message
> >
<a {#snippet children({ message })}
href="{AppRoute.ADMIN_SETTINGS}?{QueryParameter.IS_OPEN}={OpenSettingQueryParameterValue.STORAGE_TEMPLATE}" <a
class="text-immich-primary dark:text-immich-dark-primary" href="{AppRoute.ADMIN_SETTINGS}?{QueryParameter.IS_OPEN}={OpenSettingQueryParameterValue.STORAGE_TEMPLATE}"
> class="text-immich-primary dark:text-immich-dark-primary"
{message} >
</a> {message}
</a>
{/snippet}
</FormatMessage> </FormatMessage>

View file

@ -5,10 +5,14 @@
import { restoreUserAdmin, type UserResponseDto } from '@immich/sdk'; import { restoreUserAdmin, type UserResponseDto } from '@immich/sdk';
import { t } from 'svelte-i18n'; import { t } from 'svelte-i18n';
export let user: UserResponseDto; interface Props {
export let onSuccess: () => void; user: UserResponseDto;
export let onFail: () => void; onSuccess: () => void;
export let onCancel: () => void; onFail: () => void;
onCancel: () => void;
}
let { user, onSuccess, onFail, onCancel }: Props = $props();
const handleRestoreUser = async () => { const handleRestoreUser = async () => {
try { try {
@ -32,11 +36,13 @@
onConfirm={handleRestoreUser} onConfirm={handleRestoreUser}
{onCancel} {onCancel}
> >
<svelte:fragment slot="prompt"> {#snippet promptSnippet()}
<p> <p>
<FormatMessage key="admin.user_restore_description" values={{ user: user.name }} let:message> <FormatMessage key="admin.user_restore_description" values={{ user: user.name }}>
<b>{message}</b> {#snippet children({ message })}
<b>{message}</b>
{/snippet}
</FormatMessage> </FormatMessage>
</p> </p>
</svelte:fragment> {/snippet}
</ConfirmDialog> </ConfirmDialog>

View file

@ -7,14 +7,20 @@
import StatsCard from './stats-card.svelte'; import StatsCard from './stats-card.svelte';
import { t } from 'svelte-i18n'; import { t } from 'svelte-i18n';
export let stats: ServerStatsResponseDto = { interface Props {
photos: 0, stats?: ServerStatsResponseDto;
videos: 0, }
usage: 0,
usageByUser: [],
};
$: zeros = (value: number) => { let {
stats = {
photos: 0,
videos: 0,
usage: 0,
usageByUser: [],
},
}: Props = $props();
const zeros = (value: number) => {
const maxLength = 13; const maxLength = 13;
const valueLength = value.toString().length; const valueLength = value.toString().length;
const zeroLength = maxLength - valueLength; const zeroLength = maxLength - valueLength;
@ -23,7 +29,7 @@
}; };
const TiB = 1024 ** 4; const TiB = 1024 ** 4;
$: [statsUsage, statsUsageUnit] = getBytesWithUnit(stats.usage, stats.usage > TiB ? 2 : 0); let [statsUsage, statsUsageUnit] = $derived(getBytesWithUnit(stats.usage, stats.usage > TiB ? 2 : 0));
</script> </script>
<div class="flex flex-col gap-5"> <div class="flex flex-col gap-5">

View file

@ -2,18 +2,22 @@
import Icon from '$lib/components/elements/icon.svelte'; import Icon from '$lib/components/elements/icon.svelte';
import { ByteUnit } from '$lib/utils/byte-units'; import { ByteUnit } from '$lib/utils/byte-units';
export let icon: string; interface Props {
export let title: string; icon: string;
export let value: number; title: string;
export let unit: ByteUnit | undefined = undefined; value: number;
unit?: ByteUnit | undefined;
}
$: zeros = () => { let { icon, title, value, unit = undefined }: Props = $props();
const zeros = $derived(() => {
const maxLength = 13; const maxLength = 13;
const valueLength = value.toString().length; const valueLength = value.toString().length;
const zeroLength = maxLength - valueLength; const zeroLength = maxLength - valueLength;
return '0'.repeat(zeroLength); return '0'.repeat(zeroLength);
}; });
</script> </script>
<div class="flex h-[140px] w-[250px] flex-col justify-between rounded-3xl bg-immich-gray p-5 dark:bg-immich-dark-gray"> <div class="flex h-[140px] w-[250px] flex-col justify-between rounded-3xl bg-immich-gray p-5 dark:bg-immich-dark-gray">

View file

@ -1,5 +1,3 @@
<svelte:options accessors />
<script lang="ts"> <script lang="ts">
import { import {
NotificationType, NotificationType,
@ -13,12 +11,17 @@
import type { SettingsResetOptions } from './admin-settings'; import type { SettingsResetOptions } from './admin-settings';
import { t } from 'svelte-i18n'; import { t } from 'svelte-i18n';
export let config: SystemConfigDto; interface Props {
config: SystemConfigDto;
children: import('svelte').Snippet<[{ savedConfig: SystemConfigDto; defaultConfig: SystemConfigDto }]>;
}
let savedConfig: SystemConfigDto; let { config = $bindable(), children }: Props = $props();
let defaultConfig: SystemConfigDto;
const handleReset = async (options: SettingsResetOptions) => { 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)); await (options.default ? resetToDefault(options.configKeys) : reset(options.configKeys));
}; };
@ -26,7 +29,8 @@
let systemConfigDto = { let systemConfigDto = {
...savedConfig, ...savedConfig,
...update, ...update,
}; } as SystemConfigDto;
if (isEqual(systemConfigDto, savedConfig)) { if (isEqual(systemConfigDto, savedConfig)) {
return; return;
} }
@ -59,6 +63,10 @@
}; };
const resetToDefault = (configKeys: Array<keyof SystemConfigDto>) => { const resetToDefault = (configKeys: Array<keyof SystemConfigDto>) => {
if (!defaultConfig) {
return;
}
for (const key of configKeys) { for (const key of configKeys) {
config = { ...config, [key]: defaultConfig[key] }; config = { ...config, [key]: defaultConfig[key] };
} }
@ -75,5 +83,5 @@
</script> </script>
{#if savedConfig && defaultConfig} {#if savedConfig && defaultConfig}
<slot {handleReset} {handleSave} {savedConfig} {defaultConfig} /> {@render children({ savedConfig, defaultConfig })}
{/if} {/if}

View file

@ -2,9 +2,7 @@
import ConfirmDialog from '$lib/components/shared-components/dialog/confirm-dialog.svelte'; import ConfirmDialog from '$lib/components/shared-components/dialog/confirm-dialog.svelte';
import SettingAccordion from '$lib/components/shared-components/settings/setting-accordion.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 SettingButtonsRow from '$lib/components/shared-components/settings/setting-buttons-row.svelte';
import SettingInputField, { import SettingInputField from '$lib/components/shared-components/settings/setting-input-field.svelte';
SettingInputFieldType,
} from '$lib/components/shared-components/settings/setting-input-field.svelte';
import SettingSwitch from '$lib/components/shared-components/settings/setting-switch.svelte'; import SettingSwitch from '$lib/components/shared-components/settings/setting-switch.svelte';
import { type SystemConfigDto } from '@immich/sdk'; import { type SystemConfigDto } from '@immich/sdk';
import { isEqual } from 'lodash-es'; import { isEqual } from 'lodash-es';
@ -12,15 +10,20 @@
import type { SettingsResetEvent, SettingsSaveEvent } from '../admin-settings'; import type { SettingsResetEvent, SettingsSaveEvent } from '../admin-settings';
import { t } from 'svelte-i18n'; import { t } from 'svelte-i18n';
import FormatMessage from '$lib/components/i18n/format-message.svelte'; import FormatMessage from '$lib/components/i18n/format-message.svelte';
import { SettingInputFieldType } from '$lib/constants';
export let savedConfig: SystemConfigDto; interface Props {
export let defaultConfig: SystemConfigDto; savedConfig: SystemConfigDto;
export let config: SystemConfigDto; // this is the config that is being edited defaultConfig: SystemConfigDto;
export let disabled = false; config: SystemConfigDto;
export let onReset: SettingsResetEvent; disabled?: boolean;
export let onSave: SettingsSaveEvent; onReset: SettingsResetEvent;
onSave: SettingsSaveEvent;
}
let isConfirmOpen = false; let { savedConfig, defaultConfig, config = $bindable(), disabled = false, onReset, onSave }: Props = $props();
let isConfirmOpen = $state(false);
const handleToggleOverride = () => { const handleToggleOverride = () => {
// click runs before bind // click runs before bind
@ -48,29 +51,31 @@
onCancel={() => (isConfirmOpen = false)} onCancel={() => (isConfirmOpen = false)}
onConfirm={() => handleSave(true)} onConfirm={() => handleSave(true)}
> >
<svelte:fragment slot="prompt"> {#snippet promptSnippet()}
<div class="flex flex-col gap-4"> <div class="flex flex-col gap-4">
<p>{$t('admin.authentication_settings_disable_all')}</p> <p>{$t('admin.authentication_settings_disable_all')}</p>
<p> <p>
<FormatMessage key="admin.authentication_settings_reenable" let:message> <FormatMessage key="admin.authentication_settings_reenable">
<a {#snippet children({ message })}
href="https://immich.app/docs/administration/server-commands" <a
rel="noreferrer" href="https://immich.app/docs/administration/server-commands"
target="_blank" rel="noreferrer"
class="underline" target="_blank"
> class="underline"
{message} >
</a> {message}
</a>
{/snippet}
</FormatMessage> </FormatMessage>
</p> </p>
</div> </div>
</svelte:fragment> {/snippet}
</ConfirmDialog> </ConfirmDialog>
{/if} {/if}
<div> <div>
<div in:fade={{ duration: 500 }}> <div in:fade={{ duration: 500 }}>
<form autocomplete="off" on:submit|preventDefault> <form autocomplete="off" onsubmit={(e) => e.preventDefault()}>
<div class="ml-4 mt-4 flex flex-col"> <div class="ml-4 mt-4 flex flex-col">
<SettingAccordion <SettingAccordion
key="oauth" key="oauth"
@ -79,15 +84,17 @@
> >
<div class="ml-4 mt-4 flex flex-col gap-4"> <div class="ml-4 mt-4 flex flex-col gap-4">
<p class="text-sm dark:text-immich-dark-fg"> <p class="text-sm dark:text-immich-dark-fg">
<FormatMessage key="admin.oauth_settings_more_details" let:message> <FormatMessage key="admin.oauth_settings_more_details">
<a {#snippet children({ message })}
href="https://immich.app/docs/administration/oauth" <a
class="underline" href="https://immich.app/docs/administration/oauth"
target="_blank" class="underline"
rel="noreferrer" target="_blank"
> rel="noreferrer"
{message} >
</a> {message}
</a>
{/snippet}
</FormatMessage> </FormatMessage>
</p> </p>
@ -147,7 +154,7 @@
<SettingInputField <SettingInputField
inputType={SettingInputFieldType.TEXT} inputType={SettingInputFieldType.TEXT}
label={$t('admin.oauth_profile_signing_algorithm').toUpperCase()} label={$t('admin.oauth_profile_signing_algorithm').toUpperCase()}
desc={$t('admin.oauth_profile_signing_algorithm_description')} description={$t('admin.oauth_profile_signing_algorithm_description')}
bind:value={config.oauth.profileSigningAlgorithm} bind:value={config.oauth.profileSigningAlgorithm}
required={true} required={true}
disabled={disabled || !config.oauth.enabled} disabled={disabled || !config.oauth.enabled}
@ -157,7 +164,7 @@
<SettingInputField <SettingInputField
inputType={SettingInputFieldType.TEXT} inputType={SettingInputFieldType.TEXT}
label={$t('admin.oauth_storage_label_claim').toUpperCase()} label={$t('admin.oauth_storage_label_claim').toUpperCase()}
desc={$t('admin.oauth_storage_label_claim_description')} description={$t('admin.oauth_storage_label_claim_description')}
bind:value={config.oauth.storageLabelClaim} bind:value={config.oauth.storageLabelClaim}
required={true} required={true}
disabled={disabled || !config.oauth.enabled} disabled={disabled || !config.oauth.enabled}
@ -167,7 +174,7 @@
<SettingInputField <SettingInputField
inputType={SettingInputFieldType.TEXT} inputType={SettingInputFieldType.TEXT}
label={$t('admin.oauth_storage_quota_claim').toUpperCase()} label={$t('admin.oauth_storage_quota_claim').toUpperCase()}
desc={$t('admin.oauth_storage_quota_claim_description')} description={$t('admin.oauth_storage_quota_claim_description')}
bind:value={config.oauth.storageQuotaClaim} bind:value={config.oauth.storageQuotaClaim}
required={true} required={true}
disabled={disabled || !config.oauth.enabled} disabled={disabled || !config.oauth.enabled}
@ -177,7 +184,7 @@
<SettingInputField <SettingInputField
inputType={SettingInputFieldType.NUMBER} inputType={SettingInputFieldType.NUMBER}
label={$t('admin.oauth_storage_quota_default').toUpperCase()} label={$t('admin.oauth_storage_quota_default').toUpperCase()}
desc={$t('admin.oauth_storage_quota_default_description')} description={$t('admin.oauth_storage_quota_default_description')}
bind:value={config.oauth.defaultStorageQuota} bind:value={config.oauth.defaultStorageQuota}
required={true} required={true}
disabled={disabled || !config.oauth.enabled} disabled={disabled || !config.oauth.enabled}
@ -213,7 +220,7 @@
values: { callback: 'app.immich:///oauth-callback' }, values: { callback: 'app.immich:///oauth-callback' },
})} })}
disabled={disabled || !config.oauth.enabled} disabled={disabled || !config.oauth.enabled}
on:click={() => handleToggleOverride()} onToggle={() => handleToggleOverride()}
bind:checked={config.oauth.mobileOverrideEnabled} bind:checked={config.oauth.mobileOverrideEnabled}
/> />

View file

@ -3,33 +3,40 @@
import { isEqual } from 'lodash-es'; import { isEqual } from 'lodash-es';
import { fade } from 'svelte/transition'; import { fade } from 'svelte/transition';
import type { SettingsResetEvent, SettingsSaveEvent } from '../admin-settings'; import type { SettingsResetEvent, SettingsSaveEvent } from '../admin-settings';
import SettingInputField, { import SettingInputField from '$lib/components/shared-components/settings/setting-input-field.svelte';
SettingInputFieldType,
} from '$lib/components/shared-components/settings/setting-input-field.svelte';
import SettingSwitch from '$lib/components/shared-components/settings/setting-switch.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 SettingButtonsRow from '$lib/components/shared-components/settings/setting-buttons-row.svelte';
import SettingSelect from '$lib/components/shared-components/settings/setting-select.svelte'; import SettingSelect from '$lib/components/shared-components/settings/setting-select.svelte';
import { t } from 'svelte-i18n'; import { t } from 'svelte-i18n';
import FormatMessage from '$lib/components/i18n/format-message.svelte'; import FormatMessage from '$lib/components/i18n/format-message.svelte';
import { SettingInputFieldType } from '$lib/constants';
export let savedConfig: SystemConfigDto; interface Props {
export let defaultConfig: SystemConfigDto; savedConfig: SystemConfigDto;
export let config: SystemConfigDto; // this is the config that is being edited defaultConfig: SystemConfigDto;
export let disabled = false; config: SystemConfigDto;
export let onReset: SettingsResetEvent; disabled?: boolean;
export let onSave: SettingsSaveEvent; onReset: SettingsResetEvent;
onSave: SettingsSaveEvent;
}
$: cronExpressionOptions = [ let { savedConfig, defaultConfig, config = $bindable(), disabled = false, onReset, onSave }: Props = $props();
let cronExpressionOptions = $derived([
{ text: $t('interval.night_at_midnight'), value: '0 0 * * *' }, { text: $t('interval.night_at_midnight'), value: '0 0 * * *' },
{ text: $t('interval.night_at_twoam'), value: '0 02 * * *' }, { text: $t('interval.night_at_twoam'), value: '0 02 * * *' },
{ text: $t('interval.day_at_onepm'), value: '0 13 * * *' }, { text: $t('interval.day_at_onepm'), value: '0 13 * * *' },
{ text: $t('interval.hours', { values: { hours: 6 } }), value: '0 */6 * * *' }, { text: $t('interval.hours', { values: { hours: 6 } }), value: '0 */6 * * *' },
]; ]);
const onsubmit = (event: Event) => {
event.preventDefault();
};
</script> </script>
<div> <div>
<div in:fade={{ duration: 500 }}> <div in:fade={{ duration: 500 }}>
<form autocomplete="off" on:submit|preventDefault> <form autocomplete="off" {onsubmit}>
<div class="ml-4 mt-4 flex flex-col gap-4"> <div class="ml-4 mt-4 flex flex-col gap-4">
<SettingSwitch <SettingSwitch
title={$t('admin.backup_database_enable_description')} title={$t('admin.backup_database_enable_description')}
@ -53,21 +60,23 @@
bind:value={config.backup.database.cronExpression} bind:value={config.backup.database.cronExpression}
isEdited={config.backup.database.cronExpression !== savedConfig.backup.database.cronExpression} isEdited={config.backup.database.cronExpression !== savedConfig.backup.database.cronExpression}
> >
<svelte:fragment slot="desc"> {#snippet descriptionSnippet()}
<p class="text-sm dark:text-immich-dark-fg"> <p class="text-sm dark:text-immich-dark-fg">
<FormatMessage key="admin.cron_expression_description" let:message> <FormatMessage key="admin.cron_expression_description">
<a {#snippet children({ message })}
href="https://crontab.guru/#{config.backup.database.cronExpression.replaceAll(' ', '_')}" <a
class="underline" href="https://crontab.guru/#{config.backup.database.cronExpression.replaceAll(' ', '_')}"
target="_blank" class="underline"
rel="noreferrer" target="_blank"
> rel="noreferrer"
{message} >
<br /> {message}
</a> <br />
</a>
{/snippet}
</FormatMessage> </FormatMessage>
</p> </p>
</svelte:fragment> {/snippet}
</SettingInputField> </SettingInputField>
<SettingInputField <SettingInputField

View file

@ -15,44 +15,53 @@
import { fade } from 'svelte/transition'; import { fade } from 'svelte/transition';
import type { SettingsResetEvent, SettingsSaveEvent } from '../admin-settings'; import type { SettingsResetEvent, SettingsSaveEvent } from '../admin-settings';
import SettingAccordion from '$lib/components/shared-components/settings/setting-accordion.svelte'; import SettingAccordion from '$lib/components/shared-components/settings/setting-accordion.svelte';
import SettingInputField, { import SettingInputField from '$lib/components/shared-components/settings/setting-input-field.svelte';
SettingInputFieldType,
} from '$lib/components/shared-components/settings/setting-input-field.svelte';
import SettingSelect from '$lib/components/shared-components/settings/setting-select.svelte'; import SettingSelect from '$lib/components/shared-components/settings/setting-select.svelte';
import SettingSwitch from '$lib/components/shared-components/settings/setting-switch.svelte'; import SettingSwitch from '$lib/components/shared-components/settings/setting-switch.svelte';
import SettingCheckboxes from '$lib/components/shared-components/settings/setting-checkboxes.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 SettingButtonsRow from '$lib/components/shared-components/settings/setting-buttons-row.svelte';
import { t } from 'svelte-i18n'; import { t } from 'svelte-i18n';
import FormatMessage from '$lib/components/i18n/format-message.svelte'; import FormatMessage from '$lib/components/i18n/format-message.svelte';
import { SettingInputFieldType } from '$lib/constants';
export let savedConfig: SystemConfigDto; interface Props {
export let defaultConfig: SystemConfigDto; savedConfig: SystemConfigDto;
export let config: SystemConfigDto; // this is the config that is being edited defaultConfig: SystemConfigDto;
export let disabled = false; config: SystemConfigDto;
export let onReset: SettingsResetEvent; disabled?: boolean;
export let onSave: SettingsSaveEvent; onReset: SettingsResetEvent;
onSave: SettingsSaveEvent;
}
let { savedConfig, defaultConfig, config = $bindable(), disabled = false, onReset, onSave }: Props = $props();
const onsubmit = (event: Event) => {
event.preventDefault();
};
</script> </script>
<div> <div>
<div in:fade={{ duration: 500 }}> <div in:fade={{ duration: 500 }}>
<form autocomplete="off" on:submit|preventDefault> <form autocomplete="off" {onsubmit}>
<div class="ml-4 mt-4 flex flex-col gap-4"> <div class="ml-4 mt-4 flex flex-col gap-4">
<p class="text-sm dark:text-immich-dark-fg"> <p class="text-sm dark:text-immich-dark-fg">
<Icon path={mdiHelpCircleOutline} class="inline" size="15" /> <Icon path={mdiHelpCircleOutline} class="inline" size="15" />
<FormatMessage key="admin.transcoding_codecs_learn_more" let:tag let:message> <FormatMessage key="admin.transcoding_codecs_learn_more">
{#if tag === 'h264-link'} {#snippet children({ tag, message })}
<a href="https://trac.ffmpeg.org/wiki/Encode/H.264" class="underline" target="_blank" rel="noreferrer"> {#if tag === 'h264-link'}
{message} <a href="https://trac.ffmpeg.org/wiki/Encode/H.264" class="underline" target="_blank" rel="noreferrer">
</a> {message}
{:else if tag === 'hevc-link'} </a>
<a href="https://trac.ffmpeg.org/wiki/Encode/H.265" class="underline" target="_blank" rel="noreferrer"> {:else if tag === 'hevc-link'}
{message} <a href="https://trac.ffmpeg.org/wiki/Encode/H.265" class="underline" target="_blank" rel="noreferrer">
</a> {message}
{:else if tag === 'vp9-link'} </a>
<a href="https://trac.ffmpeg.org/wiki/Encode/VP9" class="underline" target="_blank" rel="noreferrer"> {:else if tag === 'vp9-link'}
{message} <a href="https://trac.ffmpeg.org/wiki/Encode/VP9" class="underline" target="_blank" rel="noreferrer">
</a> {message}
{/if} </a>
{/if}
{/snippet}
</FormatMessage> </FormatMessage>
</p> </p>
@ -60,7 +69,7 @@
inputType={SettingInputFieldType.NUMBER} inputType={SettingInputFieldType.NUMBER}
{disabled} {disabled}
label={$t('admin.transcoding_constant_rate_factor')} label={$t('admin.transcoding_constant_rate_factor')}
desc={$t('admin.transcoding_constant_rate_factor_description')} description={$t('admin.transcoding_constant_rate_factor_description')}
bind:value={config.ffmpeg.crf} bind:value={config.ffmpeg.crf}
required={true} required={true}
isEdited={config.ffmpeg.crf !== savedConfig.ffmpeg.crf} isEdited={config.ffmpeg.crf !== savedConfig.ffmpeg.crf}
@ -186,7 +195,7 @@
inputType={SettingInputFieldType.TEXT} inputType={SettingInputFieldType.TEXT}
{disabled} {disabled}
label={$t('admin.transcoding_max_bitrate')} label={$t('admin.transcoding_max_bitrate')}
desc={$t('admin.transcoding_max_bitrate_description')} description={$t('admin.transcoding_max_bitrate_description')}
bind:value={config.ffmpeg.maxBitrate} bind:value={config.ffmpeg.maxBitrate}
isEdited={config.ffmpeg.maxBitrate !== savedConfig.ffmpeg.maxBitrate} isEdited={config.ffmpeg.maxBitrate !== savedConfig.ffmpeg.maxBitrate}
/> />
@ -195,7 +204,7 @@
inputType={SettingInputFieldType.NUMBER} inputType={SettingInputFieldType.NUMBER}
{disabled} {disabled}
label={$t('admin.transcoding_threads')} label={$t('admin.transcoding_threads')}
desc={$t('admin.transcoding_threads_description')} description={$t('admin.transcoding_threads_description')}
bind:value={config.ffmpeg.threads} bind:value={config.ffmpeg.threads}
isEdited={config.ffmpeg.threads !== savedConfig.ffmpeg.threads} isEdited={config.ffmpeg.threads !== savedConfig.ffmpeg.threads}
/> />
@ -329,7 +338,7 @@
<SettingInputField <SettingInputField
inputType={SettingInputFieldType.TEXT} inputType={SettingInputFieldType.TEXT}
label={$t('admin.transcoding_preferred_hardware_device')} label={$t('admin.transcoding_preferred_hardware_device')}
desc={$t('admin.transcoding_preferred_hardware_device_description')} description={$t('admin.transcoding_preferred_hardware_device_description')}
bind:value={config.ffmpeg.preferredHwDevice} bind:value={config.ffmpeg.preferredHwDevice}
isEdited={config.ffmpeg.preferredHwDevice !== savedConfig.ffmpeg.preferredHwDevice} isEdited={config.ffmpeg.preferredHwDevice !== savedConfig.ffmpeg.preferredHwDevice}
{disabled} {disabled}
@ -346,7 +355,7 @@
<SettingInputField <SettingInputField
inputType={SettingInputFieldType.NUMBER} inputType={SettingInputFieldType.NUMBER}
label={$t('admin.transcoding_max_b_frames')} label={$t('admin.transcoding_max_b_frames')}
desc={$t('admin.transcoding_max_b_frames_description')} description={$t('admin.transcoding_max_b_frames_description')}
bind:value={config.ffmpeg.bframes} bind:value={config.ffmpeg.bframes}
isEdited={config.ffmpeg.bframes !== savedConfig.ffmpeg.bframes} isEdited={config.ffmpeg.bframes !== savedConfig.ffmpeg.bframes}
{disabled} {disabled}
@ -355,7 +364,7 @@
<SettingInputField <SettingInputField
inputType={SettingInputFieldType.NUMBER} inputType={SettingInputFieldType.NUMBER}
label={$t('admin.transcoding_reference_frames')} label={$t('admin.transcoding_reference_frames')}
desc={$t('admin.transcoding_reference_frames_description')} description={$t('admin.transcoding_reference_frames_description')}
bind:value={config.ffmpeg.refs} bind:value={config.ffmpeg.refs}
isEdited={config.ffmpeg.refs !== savedConfig.ffmpeg.refs} isEdited={config.ffmpeg.refs !== savedConfig.ffmpeg.refs}
{disabled} {disabled}
@ -364,7 +373,7 @@
<SettingInputField <SettingInputField
inputType={SettingInputFieldType.NUMBER} inputType={SettingInputFieldType.NUMBER}
label={$t('admin.transcoding_max_keyframe_interval')} label={$t('admin.transcoding_max_keyframe_interval')}
desc={$t('admin.transcoding_max_keyframe_interval_description')} description={$t('admin.transcoding_max_keyframe_interval_description')}
bind:value={config.ffmpeg.gopSize} bind:value={config.ffmpeg.gopSize}
isEdited={config.ffmpeg.gopSize !== savedConfig.ffmpeg.gopSize} isEdited={config.ffmpeg.gopSize !== savedConfig.ffmpeg.gopSize}
{disabled} {disabled}

View file

@ -7,24 +7,39 @@
import SettingSwitch from '$lib/components/shared-components/settings/setting-switch.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 SettingButtonsRow from '$lib/components/shared-components/settings/setting-buttons-row.svelte';
import SettingInputField, { import SettingInputField from '$lib/components/shared-components/settings/setting-input-field.svelte';
SettingInputFieldType,
} from '$lib/components/shared-components/settings/setting-input-field.svelte';
import { t } from 'svelte-i18n'; import { t } from 'svelte-i18n';
import SettingAccordion from '$lib/components/shared-components/settings/setting-accordion.svelte'; import SettingAccordion from '$lib/components/shared-components/settings/setting-accordion.svelte';
import { SettingInputFieldType } from '$lib/constants';
export let savedConfig: SystemConfigDto; interface Props {
export let defaultConfig: SystemConfigDto; savedConfig: SystemConfigDto;
export let config: SystemConfigDto; // this is the config that is being edited defaultConfig: SystemConfigDto;
export let disabled = false; config: SystemConfigDto;
export let onReset: SettingsResetEvent; disabled?: boolean;
export let onSave: SettingsSaveEvent; onReset: SettingsResetEvent;
export let openByDefault = false; onSave: SettingsSaveEvent;
openByDefault?: boolean;
}
let {
savedConfig,
defaultConfig,
config = $bindable(),
disabled = false,
onReset,
onSave,
openByDefault = false,
}: Props = $props();
const onsubmit = (event: Event) => {
event.preventDefault();
};
</script> </script>
<div> <div>
<div in:fade={{ duration: 500 }}> <div in:fade={{ duration: 500 }}>
<form autocomplete="off" on:submit|preventDefault> <form autocomplete="off" {onsubmit}>
<div class="ml-4 mt-4 flex flex-col gap-4"> <div class="ml-4 mt-4 flex flex-col gap-4">
<SettingAccordion <SettingAccordion
key="thumbnail-settings" key="thumbnail-settings"
@ -65,7 +80,7 @@
<SettingInputField <SettingInputField
inputType={SettingInputFieldType.NUMBER} inputType={SettingInputFieldType.NUMBER}
label={$t('admin.image_quality')} label={$t('admin.image_quality')}
desc={$t('admin.image_thumbnail_quality_description')} description={$t('admin.image_thumbnail_quality_description')}
bind:value={config.image.thumbnail.quality} bind:value={config.image.thumbnail.quality}
isEdited={config.image.thumbnail.quality !== savedConfig.image.thumbnail.quality} isEdited={config.image.thumbnail.quality !== savedConfig.image.thumbnail.quality}
{disabled} {disabled}
@ -110,7 +125,7 @@
<SettingInputField <SettingInputField
inputType={SettingInputFieldType.NUMBER} inputType={SettingInputFieldType.NUMBER}
label={$t('admin.image_quality')} label={$t('admin.image_quality')}
desc={$t('admin.image_preview_quality_description')} description={$t('admin.image_preview_quality_description')}
bind:value={config.image.preview.quality} bind:value={config.image.preview.quality}
isEdited={config.image.preview.quality !== savedConfig.image.preview.quality} isEdited={config.image.preview.quality !== savedConfig.image.preview.quality}
{disabled} {disabled}

View file

@ -5,17 +5,20 @@
import { fade } from 'svelte/transition'; import { fade } from 'svelte/transition';
import type { SettingsResetEvent, SettingsSaveEvent } from '../admin-settings'; import type { SettingsResetEvent, SettingsSaveEvent } from '../admin-settings';
import SettingButtonsRow from '$lib/components/shared-components/settings/setting-buttons-row.svelte'; import SettingButtonsRow from '$lib/components/shared-components/settings/setting-buttons-row.svelte';
import SettingInputField, { import SettingInputField from '$lib/components/shared-components/settings/setting-input-field.svelte';
SettingInputFieldType,
} from '$lib/components/shared-components/settings/setting-input-field.svelte';
import { t } from 'svelte-i18n'; import { t } from 'svelte-i18n';
import { SettingInputFieldType } from '$lib/constants';
export let savedConfig: SystemConfigDto; interface Props {
export let defaultConfig: SystemConfigDto; savedConfig: SystemConfigDto;
export let config: SystemConfigDto; // this is the config that is being edited defaultConfig: SystemConfigDto;
export let disabled = false; config: SystemConfigDto;
export let onReset: SettingsResetEvent; disabled?: boolean;
export let onSave: SettingsSaveEvent; onReset: SettingsResetEvent;
onSave: SettingsSaveEvent;
}
let { savedConfig, defaultConfig, config = $bindable(), disabled = false, onReset, onSave }: Props = $props();
const jobNames = [ const jobNames = [
JobName.ThumbnailGeneration, JobName.ThumbnailGeneration,
@ -34,11 +37,15 @@
function isSystemConfigJobDto(jobName: any): jobName is keyof SystemConfigJobDto { function isSystemConfigJobDto(jobName: any): jobName is keyof SystemConfigJobDto {
return jobName in config.job; return jobName in config.job;
} }
const onsubmit = (event: Event) => {
event.preventDefault();
};
</script> </script>
<div> <div>
<div in:fade={{ duration: 500 }}> <div in:fade={{ duration: 500 }}>
<form autocomplete="off" on:submit|preventDefault> <form autocomplete="off" {onsubmit}>
{#each jobNames as jobName} {#each jobNames as jobName}
<div class="ml-4 mt-4 flex flex-col gap-4"> <div class="ml-4 mt-4 flex flex-col gap-4">
{#if isSystemConfigJobDto(jobName)} {#if isSystemConfigJobDto(jobName)}
@ -46,7 +53,7 @@
inputType={SettingInputFieldType.NUMBER} inputType={SettingInputFieldType.NUMBER}
{disabled} {disabled}
label={$t('admin.job_concurrency', { values: { job: $getJobName(jobName) } })} label={$t('admin.job_concurrency', { values: { job: $getJobName(jobName) } })}
desc="" description=""
bind:value={config.job[jobName].concurrency} bind:value={config.job[jobName].concurrency}
required={true} required={true}
isEdited={!(config.job[jobName].concurrency == savedConfig.job[jobName].concurrency)} isEdited={!(config.job[jobName].concurrency == savedConfig.job[jobName].concurrency)}
@ -55,7 +62,7 @@
<SettingInputField <SettingInputField
inputType={SettingInputFieldType.NUMBER} inputType={SettingInputFieldType.NUMBER}
label={$t('admin.job_concurrency', { values: { job: $getJobName(jobName) } })} label={$t('admin.job_concurrency', { values: { job: $getJobName(jobName) } })}
desc="" description=""
value="1" value="1"
disabled={true} disabled={true}
title={$t('admin.job_not_concurrency_safe')} title={$t('admin.job_not_concurrency_safe')}

View file

@ -4,34 +4,49 @@
import { fade } from 'svelte/transition'; import { fade } from 'svelte/transition';
import type { SettingsResetEvent, SettingsSaveEvent } from '../admin-settings'; import type { SettingsResetEvent, SettingsSaveEvent } from '../admin-settings';
import SettingAccordion from '$lib/components/shared-components/settings/setting-accordion.svelte'; import SettingAccordion from '$lib/components/shared-components/settings/setting-accordion.svelte';
import SettingInputField, { import SettingInputField from '$lib/components/shared-components/settings/setting-input-field.svelte';
SettingInputFieldType,
} from '$lib/components/shared-components/settings/setting-input-field.svelte';
import SettingSwitch from '$lib/components/shared-components/settings/setting-switch.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 SettingButtonsRow from '$lib/components/shared-components/settings/setting-buttons-row.svelte';
import { t } from 'svelte-i18n'; import { t } from 'svelte-i18n';
import FormatMessage from '$lib/components/i18n/format-message.svelte'; import FormatMessage from '$lib/components/i18n/format-message.svelte';
import SettingSelect from '$lib/components/shared-components/settings/setting-select.svelte'; import SettingSelect from '$lib/components/shared-components/settings/setting-select.svelte';
import { SettingInputFieldType } from '$lib/constants';
export let savedConfig: SystemConfigDto; interface Props {
export let defaultConfig: SystemConfigDto; savedConfig: SystemConfigDto;
export let config: SystemConfigDto; // this is the config that is being edited defaultConfig: SystemConfigDto;
export let disabled = false; config: SystemConfigDto;
export let onReset: SettingsResetEvent; disabled?: boolean;
export let onSave: SettingsSaveEvent; onReset: SettingsResetEvent;
export let openByDefault = false; onSave: SettingsSaveEvent;
openByDefault?: boolean;
}
$: cronExpressionOptions = [ let {
savedConfig,
defaultConfig,
config = $bindable(),
disabled = false,
onReset,
onSave,
openByDefault = false,
}: Props = $props();
let cronExpressionOptions = $derived([
{ text: $t('interval.night_at_midnight'), value: '0 0 * * *' }, { text: $t('interval.night_at_midnight'), value: '0 0 * * *' },
{ text: $t('interval.night_at_twoam'), value: '0 2 * * *' }, { text: $t('interval.night_at_twoam'), value: '0 2 * * *' },
{ text: $t('interval.day_at_onepm'), value: '0 13 * * *' }, { text: $t('interval.day_at_onepm'), value: '0 13 * * *' },
{ text: $t('interval.hours', { values: { hours: 6 } }), value: '0 */6 * * *' }, { text: $t('interval.hours', { values: { hours: 6 } }), value: '0 */6 * * *' },
]; ]);
const onsubmit = (event: Event) => {
event.preventDefault();
};
</script> </script>
<div> <div>
<div in:fade={{ duration: 500 }}> <div in:fade={{ duration: 500 }}>
<form autocomplete="off" on:submit|preventDefault> <form autocomplete="off" {onsubmit}>
<div class="ml-4 mt-4 flex flex-col gap-4"> <div class="ml-4 mt-4 flex flex-col gap-4">
<SettingAccordion <SettingAccordion
key="library-watching" key="library-watching"
@ -77,20 +92,22 @@
bind:value={config.library.scan.cronExpression} bind:value={config.library.scan.cronExpression}
isEdited={config.library.scan.cronExpression !== savedConfig.library.scan.cronExpression} isEdited={config.library.scan.cronExpression !== savedConfig.library.scan.cronExpression}
> >
<svelte:fragment slot="desc"> {#snippet descriptionSnippet()}
<p class="text-sm dark:text-immich-dark-fg"> <p class="text-sm dark:text-immich-dark-fg">
<FormatMessage key="admin.cron_expression_description" let:message> <FormatMessage key="admin.cron_expression_description">
<a {#snippet children({ message })}
href="https://crontab.guru/#{config.library.scan.cronExpression.replaceAll(' ', '_')}" <a
class="underline" href="https://crontab.guru/#{config.library.scan.cronExpression.replaceAll(' ', '_')}"
target="_blank" class="underline"
rel="noreferrer" target="_blank"
> rel="noreferrer"
{message} >
</a> {message}
</a>
{/snippet}
</FormatMessage> </FormatMessage>
</p> </p>
</svelte:fragment> {/snippet}
</SettingInputField> </SettingInputField>
</div> </div>
</SettingAccordion> </SettingAccordion>

View file

@ -8,17 +8,25 @@
import SettingSelect from '$lib/components/shared-components/settings/setting-select.svelte'; import SettingSelect from '$lib/components/shared-components/settings/setting-select.svelte';
import { t } from 'svelte-i18n'; import { t } from 'svelte-i18n';
export let savedConfig: SystemConfigDto; interface Props {
export let defaultConfig: SystemConfigDto; savedConfig: SystemConfigDto;
export let config: SystemConfigDto; // this is the config that is being edited defaultConfig: SystemConfigDto;
export let disabled = false; config: SystemConfigDto;
export let onReset: SettingsResetEvent; disabled?: boolean;
export let onSave: SettingsSaveEvent; onReset: SettingsResetEvent;
onSave: SettingsSaveEvent;
}
let { savedConfig, defaultConfig, config = $bindable(), disabled = false, onReset, onSave }: Props = $props();
const onsubmit = (event: Event) => {
event.preventDefault();
};
</script> </script>
<div> <div>
<div in:fade={{ duration: 500 }}> <div in:fade={{ duration: 500 }}>
<form autocomplete="off" on:submit|preventDefault> <form autocomplete="off" {onsubmit}>
<div class="ml-4 mt-4 flex flex-col gap-4"> <div class="ml-4 mt-4 flex flex-col gap-4">
<SettingSwitch <SettingSwitch
title={$t('admin.logging_enable_description')} title={$t('admin.logging_enable_description')}

View file

@ -5,26 +5,33 @@
import type { SettingsResetEvent, SettingsSaveEvent } from '../admin-settings'; import type { SettingsResetEvent, SettingsSaveEvent } from '../admin-settings';
import SettingAccordion from '$lib/components/shared-components/settings/setting-accordion.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 SettingButtonsRow from '$lib/components/shared-components/settings/setting-buttons-row.svelte';
import SettingInputField, { import SettingInputField from '$lib/components/shared-components/settings/setting-input-field.svelte';
SettingInputFieldType,
} from '$lib/components/shared-components/settings/setting-input-field.svelte';
import SettingSelect from '$lib/components/shared-components/settings/setting-select.svelte'; import SettingSelect from '$lib/components/shared-components/settings/setting-select.svelte';
import SettingSwitch from '$lib/components/shared-components/settings/setting-switch.svelte'; import SettingSwitch from '$lib/components/shared-components/settings/setting-switch.svelte';
import { featureFlags } from '$lib/stores/server-config.store'; import { featureFlags } from '$lib/stores/server-config.store';
import { t } from 'svelte-i18n'; import { t } from 'svelte-i18n';
import FormatMessage from '$lib/components/i18n/format-message.svelte'; import FormatMessage from '$lib/components/i18n/format-message.svelte';
import { SettingInputFieldType } from '$lib/constants';
export let savedConfig: SystemConfigDto; interface Props {
export let defaultConfig: SystemConfigDto; savedConfig: SystemConfigDto;
export let config: SystemConfigDto; // this is the config that is being edited defaultConfig: SystemConfigDto;
export let disabled = false; config: SystemConfigDto;
export let onReset: SettingsResetEvent; disabled?: boolean;
export let onSave: SettingsSaveEvent; onReset: SettingsResetEvent;
onSave: SettingsSaveEvent;
}
let { savedConfig, defaultConfig, config = $bindable(), disabled = false, onReset, onSave }: Props = $props();
const onsubmit = (event: Event) => {
event.preventDefault();
};
</script> </script>
<div class="mt-2"> <div class="mt-2">
<div in:fade={{ duration: 500 }}> <div in:fade={{ duration: 500 }}>
<form autocomplete="off" on:submit|preventDefault class="mx-4 mt-4"> <form autocomplete="off" {onsubmit} class="mx-4 mt-4">
<div class="flex flex-col gap-4"> <div class="flex flex-col gap-4">
<SettingSwitch <SettingSwitch
title={$t('admin.machine_learning_enabled')} title={$t('admin.machine_learning_enabled')}
@ -38,7 +45,7 @@
<SettingInputField <SettingInputField
inputType={SettingInputFieldType.TEXT} inputType={SettingInputFieldType.TEXT}
label={$t('url')} label={$t('url')}
desc={$t('admin.machine_learning_url_description')} description={$t('admin.machine_learning_url_description')}
bind:value={config.machineLearning.url} bind:value={config.machineLearning.url}
required={true} required={true}
disabled={disabled || !config.machineLearning.enabled} disabled={disabled || !config.machineLearning.enabled}
@ -69,11 +76,15 @@
disabled={disabled || !config.machineLearning.enabled || !config.machineLearning.clip.enabled} disabled={disabled || !config.machineLearning.enabled || !config.machineLearning.clip.enabled}
isEdited={config.machineLearning.clip.modelName !== savedConfig.machineLearning.clip.modelName} isEdited={config.machineLearning.clip.modelName !== savedConfig.machineLearning.clip.modelName}
> >
<p slot="desc" class="immich-form-label pb-2 text-sm"> {#snippet descriptionSnippet()}
<FormatMessage key="admin.machine_learning_clip_model_description" let:message> <p class="immich-form-label pb-2 text-sm">
<a href="https://huggingface.co/immich-app"><u>{message}</u></a> <FormatMessage key="admin.machine_learning_clip_model_description">
</FormatMessage> {#snippet children({ message })}
</p> <a href="https://huggingface.co/immich-app"><u>{message}</u></a>
{/snippet}
</FormatMessage>
</p>
{/snippet}
</SettingInputField> </SettingInputField>
</div> </div>
</SettingAccordion> </SettingAccordion>
@ -100,7 +111,7 @@
step="0.0005" step="0.0005"
min={0.001} min={0.001}
max={0.1} max={0.1}
desc={$t('admin.machine_learning_max_detection_distance_description')} description={$t('admin.machine_learning_max_detection_distance_description')}
disabled={disabled || !$featureFlags.duplicateDetection} disabled={disabled || !$featureFlags.duplicateDetection}
isEdited={config.machineLearning.duplicateDetection.maxDistance !== isEdited={config.machineLearning.duplicateDetection.maxDistance !==
savedConfig.machineLearning.duplicateDetection.maxDistance} savedConfig.machineLearning.duplicateDetection.maxDistance}
@ -142,7 +153,7 @@
<SettingInputField <SettingInputField
inputType={SettingInputFieldType.NUMBER} inputType={SettingInputFieldType.NUMBER}
label={$t('admin.machine_learning_min_detection_score')} label={$t('admin.machine_learning_min_detection_score')}
desc={$t('admin.machine_learning_min_detection_score_description')} description={$t('admin.machine_learning_min_detection_score_description')}
bind:value={config.machineLearning.facialRecognition.minScore} bind:value={config.machineLearning.facialRecognition.minScore}
step="0.1" step="0.1"
min={0.1} min={0.1}
@ -155,7 +166,7 @@
<SettingInputField <SettingInputField
inputType={SettingInputFieldType.NUMBER} inputType={SettingInputFieldType.NUMBER}
label={$t('admin.machine_learning_max_recognition_distance')} label={$t('admin.machine_learning_max_recognition_distance')}
desc={$t('admin.machine_learning_max_recognition_distance_description')} description={$t('admin.machine_learning_max_recognition_distance_description')}
bind:value={config.machineLearning.facialRecognition.maxDistance} bind:value={config.machineLearning.facialRecognition.maxDistance}
step="0.1" step="0.1"
min={0.1} min={0.1}
@ -168,7 +179,7 @@
<SettingInputField <SettingInputField
inputType={SettingInputFieldType.NUMBER} inputType={SettingInputFieldType.NUMBER}
label={$t('admin.machine_learning_min_recognized_faces')} label={$t('admin.machine_learning_min_recognized_faces')}
desc={$t('admin.machine_learning_min_recognized_faces_description')} description={$t('admin.machine_learning_min_recognized_faces_description')}
bind:value={config.machineLearning.facialRecognition.minFaces} bind:value={config.machineLearning.facialRecognition.minFaces}
step="1" step="1"
min={1} min={1}

View file

@ -6,23 +6,30 @@
import SettingAccordion from '$lib/components/shared-components/settings/setting-accordion.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 SettingButtonsRow from '$lib/components/shared-components/settings/setting-buttons-row.svelte';
import SettingSwitch from '$lib/components/shared-components/settings/setting-switch.svelte'; import SettingSwitch from '$lib/components/shared-components/settings/setting-switch.svelte';
import SettingInputField, { import SettingInputField from '$lib/components/shared-components/settings/setting-input-field.svelte';
SettingInputFieldType,
} from '$lib/components/shared-components/settings/setting-input-field.svelte';
import { t } from 'svelte-i18n'; import { t } from 'svelte-i18n';
import FormatMessage from '$lib/components/i18n/format-message.svelte'; import FormatMessage from '$lib/components/i18n/format-message.svelte';
import { SettingInputFieldType } from '$lib/constants';
export let savedConfig: SystemConfigDto; interface Props {
export let defaultConfig: SystemConfigDto; savedConfig: SystemConfigDto;
export let config: SystemConfigDto; // this is the config that is being edited defaultConfig: SystemConfigDto;
export let disabled = false; config: SystemConfigDto;
export let onReset: SettingsResetEvent; disabled?: boolean;
export let onSave: SettingsSaveEvent; onReset: SettingsResetEvent;
onSave: SettingsSaveEvent;
}
let { savedConfig, defaultConfig, config = $bindable(), disabled = false, onReset, onSave }: Props = $props();
const onsubmit = (event: Event) => {
event.preventDefault();
};
</script> </script>
<div class="mt-2"> <div class="mt-2">
<div in:fade={{ duration: 500 }}> <div in:fade={{ duration: 500 }}>
<form autocomplete="off" on:submit|preventDefault> <form autocomplete="off" {onsubmit}>
<div class="flex flex-col gap-4"> <div class="flex flex-col gap-4">
<SettingAccordion key="map" title={$t('admin.map_settings')} subtitle={$t('admin.map_settings_description')}> <SettingAccordion key="map" title={$t('admin.map_settings')} subtitle={$t('admin.map_settings_description')}>
<div class="ml-4 mt-4 flex flex-col gap-4"> <div class="ml-4 mt-4 flex flex-col gap-4">
@ -38,7 +45,7 @@
<SettingInputField <SettingInputField
inputType={SettingInputFieldType.TEXT} inputType={SettingInputFieldType.TEXT}
label={$t('admin.map_light_style')} label={$t('admin.map_light_style')}
desc={$t('admin.map_style_description')} description={$t('admin.map_style_description')}
bind:value={config.map.lightStyle} bind:value={config.map.lightStyle}
disabled={disabled || !config.map.enabled} disabled={disabled || !config.map.enabled}
isEdited={config.map.lightStyle !== savedConfig.map.lightStyle} isEdited={config.map.lightStyle !== savedConfig.map.lightStyle}
@ -46,7 +53,7 @@
<SettingInputField <SettingInputField
inputType={SettingInputFieldType.TEXT} inputType={SettingInputFieldType.TEXT}
label={$t('admin.map_dark_style')} label={$t('admin.map_dark_style')}
desc={$t('admin.map_style_description')} description={$t('admin.map_style_description')}
bind:value={config.map.darkStyle} bind:value={config.map.darkStyle}
disabled={disabled || !config.map.enabled} disabled={disabled || !config.map.enabled}
isEdited={config.map.darkStyle !== savedConfig.map.darkStyle} isEdited={config.map.darkStyle !== savedConfig.map.darkStyle}
@ -55,20 +62,22 @@
> >
<SettingAccordion key="reverse-geocoding" title={$t('admin.map_reverse_geocoding_settings')}> <SettingAccordion key="reverse-geocoding" title={$t('admin.map_reverse_geocoding_settings')}>
<svelte:fragment slot="subtitle"> {#snippet subtitleSnippet()}
<p class="text-sm dark:text-immich-dark-fg"> <p class="text-sm dark:text-immich-dark-fg">
<FormatMessage key="admin.map_manage_reverse_geocoding_settings" let:message> <FormatMessage key="admin.map_manage_reverse_geocoding_settings">
<a {#snippet children({ message })}
href="https://immich.app/docs/features/reverse-geocoding" <a
class="underline" href="https://immich.app/docs/features/reverse-geocoding"
target="_blank" class="underline"
rel="noreferrer" target="_blank"
> rel="noreferrer"
{message} >
</a> {message}
</a>
{/snippet}
</FormatMessage> </FormatMessage>
</p> </p>
</svelte:fragment> {/snippet}
<div class="ml-4 mt-4 flex flex-col gap-4"> <div class="ml-4 mt-4 flex flex-col gap-4">
<SettingSwitch <SettingSwitch
title={$t('admin.map_reverse_geocoding_enable_description')} title={$t('admin.map_reverse_geocoding_enable_description')}

View file

@ -7,17 +7,25 @@
import SettingSwitch from '$lib/components/shared-components/settings/setting-switch.svelte'; import SettingSwitch from '$lib/components/shared-components/settings/setting-switch.svelte';
import { t } from 'svelte-i18n'; import { t } from 'svelte-i18n';
export let savedConfig: SystemConfigDto; interface Props {
export let defaultConfig: SystemConfigDto; savedConfig: SystemConfigDto;
export let config: SystemConfigDto; // this is the config that is being edited defaultConfig: SystemConfigDto;
export let disabled = false; config: SystemConfigDto;
export let onReset: SettingsResetEvent; disabled?: boolean;
export let onSave: SettingsSaveEvent; onReset: SettingsResetEvent;
onSave: SettingsSaveEvent;
}
let { savedConfig, defaultConfig, config = $bindable(), disabled = false, onReset, onSave }: Props = $props();
const onsubmit = (event: Event) => {
event.preventDefault();
};
</script> </script>
<div class="mt-2"> <div class="mt-2">
<div in:fade={{ duration: 500 }}> <div in:fade={{ duration: 500 }}>
<form autocomplete="off" on:submit|preventDefault class="mx-4 mt-4"> <form autocomplete="off" {onsubmit} class="mx-4 mt-4">
<div class="ml-4 mt-4 flex flex-col gap-4"> <div class="ml-4 mt-4 flex flex-col gap-4">
<SettingSwitch <SettingSwitch
title={$t('admin.metadata_faces_import_setting')} title={$t('admin.metadata_faces_import_setting')}

View file

@ -7,17 +7,25 @@
import SettingSwitch from '$lib/components/shared-components/settings/setting-switch.svelte'; import SettingSwitch from '$lib/components/shared-components/settings/setting-switch.svelte';
import { t } from 'svelte-i18n'; import { t } from 'svelte-i18n';
export let savedConfig: SystemConfigDto; interface Props {
export let defaultConfig: SystemConfigDto; savedConfig: SystemConfigDto;
export let config: SystemConfigDto; // this is the config that is being edited defaultConfig: SystemConfigDto;
export let disabled = false; config: SystemConfigDto;
export let onReset: SettingsResetEvent; disabled?: boolean;
export let onSave: SettingsSaveEvent; onReset: SettingsResetEvent;
onSave: SettingsSaveEvent;
}
let { savedConfig, defaultConfig, config = $bindable(), disabled = false, onReset, onSave }: Props = $props();
const onsubmit = (event: Event) => {
event.preventDefault();
};
</script> </script>
<div> <div>
<div in:fade={{ duration: 500 }}> <div in:fade={{ duration: 500 }}>
<form autocomplete="off" on:submit|preventDefault> <form autocomplete="off" {onsubmit}>
<div class="ml-4 mt-4"> <div class="ml-4 mt-4">
<SettingSwitch <SettingSwitch
title={$t('admin.version_check_enabled_description')} title={$t('admin.version_check_enabled_description')}

View file

@ -3,9 +3,7 @@
import { isEqual } from 'lodash-es'; import { isEqual } from 'lodash-es';
import { fade } from 'svelte/transition'; import { fade } from 'svelte/transition';
import type { SettingsResetEvent, SettingsSaveEvent } from '../admin-settings'; import type { SettingsResetEvent, SettingsSaveEvent } from '../admin-settings';
import SettingInputField, { import SettingInputField from '$lib/components/shared-components/settings/setting-input-field.svelte';
SettingInputFieldType,
} from '$lib/components/shared-components/settings/setting-input-field.svelte';
import SettingButtonsRow from '$lib/components/shared-components/settings/setting-buttons-row.svelte'; import SettingButtonsRow from '$lib/components/shared-components/settings/setting-buttons-row.svelte';
import SettingAccordion from '$lib/components/shared-components/settings/setting-accordion.svelte'; import SettingAccordion from '$lib/components/shared-components/settings/setting-accordion.svelte';
import SettingSwitch from '$lib/components/shared-components/settings/setting-switch.svelte'; import SettingSwitch from '$lib/components/shared-components/settings/setting-switch.svelte';
@ -18,15 +16,20 @@
import { user } from '$lib/stores/user.store'; import { user } from '$lib/stores/user.store';
import LoadingSpinner from '$lib/components/shared-components/loading-spinner.svelte'; import LoadingSpinner from '$lib/components/shared-components/loading-spinner.svelte';
import { handleError } from '$lib/utils/handle-error'; import { handleError } from '$lib/utils/handle-error';
import { SettingInputFieldType } from '$lib/constants';
export let savedConfig: SystemConfigDto; interface Props {
export let defaultConfig: SystemConfigDto; savedConfig: SystemConfigDto;
export let config: SystemConfigDto; // this is the config that is being edited defaultConfig: SystemConfigDto;
export let disabled = false; config: SystemConfigDto;
export let onReset: SettingsResetEvent; disabled?: boolean;
export let onSave: SettingsSaveEvent; onReset: SettingsResetEvent;
onSave: SettingsSaveEvent;
}
let isSending = false; let { savedConfig, defaultConfig, config = $bindable(), disabled = false, onReset, onSave }: Props = $props();
let isSending = $state(false);
const handleSendTestEmail = async () => { const handleSendTestEmail = async () => {
if (isSending) { if (isSending) {
@ -65,11 +68,15 @@
isSending = false; isSending = false;
} }
}; };
const onsubmit = (event: Event) => {
event.preventDefault();
};
</script> </script>
<div> <div>
<div in:fade={{ duration: 500 }}> <div in:fade={{ duration: 500 }}>
<form autocomplete="off" on:submit|preventDefault class="mt-4"> <form autocomplete="off" {onsubmit} class="mt-4">
<div class="flex flex-col gap-4"> <div class="flex flex-col gap-4">
<SettingAccordion key="email" title={$t('email')} subtitle={$t('admin.notification_email_setting_description')}> <SettingAccordion key="email" title={$t('email')} subtitle={$t('admin.notification_email_setting_description')}>
<div class="ml-4 mt-4 flex flex-col gap-4"> <div class="ml-4 mt-4 flex flex-col gap-4">
@ -85,7 +92,7 @@
inputType={SettingInputFieldType.TEXT} inputType={SettingInputFieldType.TEXT}
required required
label={$t('host')} label={$t('host')}
desc={$t('admin.notification_email_host_description')} description={$t('admin.notification_email_host_description')}
disabled={disabled || !config.notifications.smtp.enabled} disabled={disabled || !config.notifications.smtp.enabled}
bind:value={config.notifications.smtp.transport.host} bind:value={config.notifications.smtp.transport.host}
isEdited={config.notifications.smtp.transport.host !== savedConfig.notifications.smtp.transport.host} isEdited={config.notifications.smtp.transport.host !== savedConfig.notifications.smtp.transport.host}
@ -95,7 +102,7 @@
inputType={SettingInputFieldType.NUMBER} inputType={SettingInputFieldType.NUMBER}
required required
label={$t('port')} label={$t('port')}
desc={$t('admin.notification_email_port_description')} description={$t('admin.notification_email_port_description')}
disabled={disabled || !config.notifications.smtp.enabled} disabled={disabled || !config.notifications.smtp.enabled}
bind:value={config.notifications.smtp.transport.port} bind:value={config.notifications.smtp.transport.port}
isEdited={config.notifications.smtp.transport.port !== savedConfig.notifications.smtp.transport.port} isEdited={config.notifications.smtp.transport.port !== savedConfig.notifications.smtp.transport.port}
@ -104,7 +111,7 @@
<SettingInputField <SettingInputField
inputType={SettingInputFieldType.TEXT} inputType={SettingInputFieldType.TEXT}
label={$t('username')} label={$t('username')}
desc={$t('admin.notification_email_username_description')} description={$t('admin.notification_email_username_description')}
disabled={disabled || !config.notifications.smtp.enabled} disabled={disabled || !config.notifications.smtp.enabled}
bind:value={config.notifications.smtp.transport.username} bind:value={config.notifications.smtp.transport.username}
isEdited={config.notifications.smtp.transport.username !== isEdited={config.notifications.smtp.transport.username !==
@ -114,7 +121,7 @@
<SettingInputField <SettingInputField
inputType={SettingInputFieldType.PASSWORD} inputType={SettingInputFieldType.PASSWORD}
label={$t('password')} label={$t('password')}
desc={$t('admin.notification_email_password_description')} description={$t('admin.notification_email_password_description')}
disabled={disabled || !config.notifications.smtp.enabled} disabled={disabled || !config.notifications.smtp.enabled}
bind:value={config.notifications.smtp.transport.password} bind:value={config.notifications.smtp.transport.password}
isEdited={config.notifications.smtp.transport.password !== isEdited={config.notifications.smtp.transport.password !==
@ -134,14 +141,14 @@
inputType={SettingInputFieldType.TEXT} inputType={SettingInputFieldType.TEXT}
required required
label={$t('admin.notification_email_from_address')} label={$t('admin.notification_email_from_address')}
desc={$t('admin.notification_email_from_address_description')} description={$t('admin.notification_email_from_address_description')}
disabled={disabled || !config.notifications.smtp.enabled} disabled={disabled || !config.notifications.smtp.enabled}
bind:value={config.notifications.smtp.from} bind:value={config.notifications.smtp.from}
isEdited={config.notifications.smtp.from !== savedConfig.notifications.smtp.from} isEdited={config.notifications.smtp.from !== savedConfig.notifications.smtp.from}
/> />
<div class="flex gap-2 place-items-center"> <div class="flex gap-2 place-items-center">
<Button size="sm" disabled={!config.notifications.smtp.enabled} on:click={handleSendTestEmail}> <Button size="sm" disabled={!config.notifications.smtp.enabled} onclick={handleSendTestEmail}>
{#if disabled} {#if disabled}
{$t('admin.notification_email_test_email')} {$t('admin.notification_email_test_email')}
{:else} {:else}

View file

@ -3,28 +3,35 @@
import { isEqual } from 'lodash-es'; import { isEqual } from 'lodash-es';
import { fade } from 'svelte/transition'; import { fade } from 'svelte/transition';
import type { SettingsResetEvent, SettingsSaveEvent } from '../admin-settings'; import type { SettingsResetEvent, SettingsSaveEvent } from '../admin-settings';
import SettingInputField, { import SettingInputField from '$lib/components/shared-components/settings/setting-input-field.svelte';
SettingInputFieldType,
} from '$lib/components/shared-components/settings/setting-input-field.svelte';
import SettingButtonsRow from '$lib/components/shared-components/settings/setting-buttons-row.svelte'; import SettingButtonsRow from '$lib/components/shared-components/settings/setting-buttons-row.svelte';
import { t } from 'svelte-i18n'; import { t } from 'svelte-i18n';
import { SettingInputFieldType } from '$lib/constants';
export let savedConfig: SystemConfigDto; interface Props {
export let defaultConfig: SystemConfigDto; savedConfig: SystemConfigDto;
export let config: SystemConfigDto; // this is the config that is being edited defaultConfig: SystemConfigDto;
export let disabled = false; config: SystemConfigDto;
export let onReset: SettingsResetEvent; disabled?: boolean;
export let onSave: SettingsSaveEvent; onReset: SettingsResetEvent;
onSave: SettingsSaveEvent;
}
let { savedConfig, defaultConfig, config = $bindable(), disabled = false, onReset, onSave }: Props = $props();
const onsubmit = (event: Event) => {
event.preventDefault();
};
</script> </script>
<div> <div>
<div in:fade={{ duration: 500 }}> <div in:fade={{ duration: 500 }}>
<form autocomplete="off" on:submit|preventDefault> <form autocomplete="off" {onsubmit}>
<div class="mt-4 ml-4"> <div class="mt-4 ml-4">
<SettingInputField <SettingInputField
inputType={SettingInputFieldType.TEXT} inputType={SettingInputFieldType.TEXT}
label={$t('admin.server_external_domain_settings')} label={$t('admin.server_external_domain_settings')}
desc={$t('admin.server_external_domain_settings_description')} description={$t('admin.server_external_domain_settings_description')}
bind:value={config.server.externalDomain} bind:value={config.server.externalDomain}
isEdited={config.server.externalDomain !== savedConfig.server.externalDomain} isEdited={config.server.externalDomain !== savedConfig.server.externalDomain}
/> />
@ -32,7 +39,7 @@
<SettingInputField <SettingInputField
inputType={SettingInputFieldType.TEXT} inputType={SettingInputFieldType.TEXT}
label={$t('admin.server_welcome_message')} label={$t('admin.server_welcome_message')}
desc={$t('admin.server_welcome_message_description')} description={$t('admin.server_welcome_message_description')}
bind:value={config.server.loginPageMessage} bind:value={config.server.loginPageMessage}
isEdited={config.server.loginPageMessage !== savedConfig.server.loginPageMessage} isEdited={config.server.loginPageMessage !== savedConfig.server.loginPageMessage}
/> />

View file

@ -1,6 +1,9 @@
<script lang="ts"> <script lang="ts">
import { createBubbler, preventDefault } from 'svelte/legacy';
const bubble = createBubbler();
import LoadingSpinner from '$lib/components/shared-components/loading-spinner.svelte'; import LoadingSpinner from '$lib/components/shared-components/loading-spinner.svelte';
import { AppRoute } from '$lib/constants'; import { AppRoute, SettingInputFieldType } from '$lib/constants';
import { user } from '$lib/stores/user.store'; import { user } from '$lib/stores/user.store';
import { import {
getStorageTemplateOptions, getStorageTemplateOptions,
@ -15,24 +18,38 @@
import SupportedDatetimePanel from './supported-datetime-panel.svelte'; import SupportedDatetimePanel from './supported-datetime-panel.svelte';
import SupportedVariablesPanel from './supported-variables-panel.svelte'; import SupportedVariablesPanel from './supported-variables-panel.svelte';
import SettingButtonsRow from '$lib/components/shared-components/settings/setting-buttons-row.svelte'; import SettingButtonsRow from '$lib/components/shared-components/settings/setting-buttons-row.svelte';
import SettingInputField, { import SettingInputField from '$lib/components/shared-components/settings/setting-input-field.svelte';
SettingInputFieldType,
} from '$lib/components/shared-components/settings/setting-input-field.svelte';
import SettingSwitch from '$lib/components/shared-components/settings/setting-switch.svelte'; import SettingSwitch from '$lib/components/shared-components/settings/setting-switch.svelte';
import { t } from 'svelte-i18n'; import { t } from 'svelte-i18n';
import FormatMessage from '$lib/components/i18n/format-message.svelte'; import FormatMessage from '$lib/components/i18n/format-message.svelte';
import type { Snippet } from 'svelte';
export let savedConfig: SystemConfigDto; interface Props {
export let defaultConfig: SystemConfigDto; savedConfig: SystemConfigDto;
export let config: SystemConfigDto; // this is the config that is being edited defaultConfig: SystemConfigDto;
export let disabled = false; config: SystemConfigDto;
export let minified = false; disabled?: boolean;
export let onReset: SettingsResetEvent; minified?: boolean;
export let onSave: SettingsSaveEvent; onReset: SettingsResetEvent;
export let duration: number = 500; onSave: SettingsSaveEvent;
duration?: number;
children?: Snippet;
}
let templateOptions: SystemConfigTemplateStorageOptionDto; let {
let selectedPreset = ''; savedConfig,
defaultConfig,
config = $bindable(),
disabled = false,
minified = false,
onReset,
onSave,
duration = 500,
children,
}: Props = $props();
let templateOptions: SystemConfigTemplateStorageOptionDto | undefined = $state();
let selectedPreset = $state('');
const getTemplateOptions = async () => { const getTemplateOptions = async () => {
templateOptions = await getStorageTemplateOptions(); templateOptions = await getStorageTemplateOptions();
@ -41,15 +58,11 @@
const getSupportDateTimeFormat = () => getStorageTemplateOptions(); const getSupportDateTimeFormat = () => getStorageTemplateOptions();
$: parsedTemplate = () => {
try {
return renderTemplate(config.storageTemplate.template);
} catch {
return 'error';
}
};
const renderTemplate = (templateString: string) => { const renderTemplate = (templateString: string) => {
if (!templateOptions) {
return '';
}
const template = handlebar.compile(templateString, { const template = handlebar.compile(templateString, {
knownHelpers: undefined, knownHelpers: undefined,
}); });
@ -85,31 +98,40 @@
const handlePresetSelection = () => { const handlePresetSelection = () => {
config.storageTemplate.template = selectedPreset; config.storageTemplate.template = selectedPreset;
}; };
let parsedTemplate = $derived(() => {
try {
return renderTemplate(config.storageTemplate.template);
} catch {
return 'error';
}
});
</script> </script>
<section class="dark:text-immich-dark-fg mt-2"> <section class="dark:text-immich-dark-fg mt-2">
<div in:fade={{ duration }} class="mx-4 flex flex-col gap-4 py-4"> <div in:fade={{ duration }} class="mx-4 flex flex-col gap-4 py-4">
<p class="text-sm dark:text-immich-dark-fg"> <p class="text-sm dark:text-immich-dark-fg">
<FormatMessage key="admin.storage_template_more_details" let:tag let:message> <FormatMessage key="admin.storage_template_more_details">
{#if tag === 'template-link'} {#snippet children({ tag, message })}
<a {#if tag === 'template-link'}
href="https://immich.app/docs/administration/storage-template" <a
class="underline" href="https://immich.app/docs/administration/storage-template"
target="_blank" class="underline"
rel="noreferrer" target="_blank"
> rel="noreferrer"
{message} >
</a> {message}
{:else if tag === 'implications-link'} </a>
<a {:else if tag === 'implications-link'}
href="https://immich.app/docs/administration/backup-and-restore#asset-types-and-storage-locations" <a
class="underline" href="https://immich.app/docs/administration/backup-and-restore#asset-types-and-storage-locations"
target="_blank" class="underline"
rel="noreferrer" target="_blank"
> rel="noreferrer"
{message} >
</a> {message}
{/if} </a>
{/if}
{/snippet}
</FormatMessage> </FormatMessage>
</p> </p>
</div> </div>
@ -164,19 +186,18 @@
<FormatMessage <FormatMessage
key="admin.storage_template_path_length" key="admin.storage_template_path_length"
values={{ length: parsedTemplate().length + $user.id.length + 'UPLOAD_LOCATION'.length, limit: 260 }} values={{ length: parsedTemplate().length + $user.id.length + 'UPLOAD_LOCATION'.length, limit: 260 }}
let:message
> >
<span class="font-semibold text-immich-primary dark:text-immich-dark-primary">{message}</span> {#snippet children({ message })}
<span class="font-semibold text-immich-primary dark:text-immich-dark-primary">{message}</span>
{/snippet}
</FormatMessage> </FormatMessage>
</p> </p>
<p class="text-sm"> <p class="text-sm">
<FormatMessage <FormatMessage key="admin.storage_template_user_label" values={{ label: $user.storageLabel || $user.id }}>
key="admin.storage_template_user_label" {#snippet children({ message })}
values={{ label: $user.storageLabel || $user.id }} <code class="text-immich-primary dark:text-immich-dark-primary">{message}</code>
let:message {/snippet}
>
<code class="text-immich-primary dark:text-immich-dark-primary">{message}</code>
</FormatMessage> </FormatMessage>
</p> </p>
@ -186,24 +207,30 @@
>/{parsedTemplate()}.jpg >/{parsedTemplate()}.jpg
</p> </p>
<form autocomplete="off" class="flex flex-col" on:submit|preventDefault> <form autocomplete="off" class="flex flex-col" onsubmit={preventDefault(bubble('submit'))}>
<div class="flex flex-col my-2"> <div class="flex flex-col my-2">
<label class="font-medium text-immich-primary dark:text-immich-dark-primary text-sm" for="preset-select"> {#if templateOptions}
{$t('preset')} <label
</label> class="font-medium text-immich-primary dark:text-immich-dark-primary text-sm"
<select for="preset-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} {$t('preset')}
name="presets" </label>
id="preset-select" <select
bind:value={selectedPreset} class="immich-form-input p-2 mt-2 text-sm rounded-lg bg-slate-200 hover:cursor-pointer dark:bg-gray-600"
on:change={handlePresetSelection} disabled={disabled || !config.storageTemplate.enabled}
> name="presets"
{#each templateOptions.presetOptions as preset} id="preset-select"
<option value={preset}>{renderTemplate(preset)}</option> bind:value={selectedPreset}
{/each} onchange={handlePresetSelection}
</select> >
{#each templateOptions.presetOptions as preset}
<option value={preset}>{renderTemplate(preset)}</option>
{/each}
</select>
{/if}
</div> </div>
<div class="flex gap-2 align-bottom"> <div class="flex gap-2 align-bottom">
<SettingInputField <SettingInputField
label={$t('template')} label={$t('template')}
@ -232,11 +259,12 @@
<FormatMessage <FormatMessage
key="admin.storage_template_migration_info" key="admin.storage_template_migration_info"
values={{ job: $t('admin.storage_template_migration_job') }} values={{ job: $t('admin.storage_template_migration_job') }}
let:message
> >
<a href={AppRoute.ADMIN_JOBS} class="text-immich-primary dark:text-immich-dark-primary"> {#snippet children({ message })}
{message} <a href={AppRoute.ADMIN_JOBS} class="text-immich-primary dark:text-immich-dark-primary">
</a> {message}
</a>
{/snippet}
</FormatMessage> </FormatMessage>
</p> </p>
</section> </section>
@ -247,7 +275,7 @@
{/if} {/if}
{#if minified} {#if minified}
<slot /> {@render children?.()}
{:else} {:else}
<SettingButtonsRow <SettingButtonsRow
onReset={(options) => onReset({ ...options, configKeys: ['storageTemplate'] })} onReset={(options) => onReset({ ...options, configKeys: ['storageTemplate'] })}

View file

@ -4,7 +4,11 @@
import { DateTime } from 'luxon'; import { DateTime } from 'luxon';
import { t } from 'svelte-i18n'; import { t } from 'svelte-i18n';
export let options: SystemConfigTemplateStorageOptionDto; interface Props {
options: SystemConfigTemplateStorageOptionDto;
}
let { options }: Props = $props();
const getLuxonExample = (format: string) => { const getLuxonExample = (format: string) => {
return DateTime.fromISO('2022-09-04T20:03:05.250Z', { locale: $locale }).toFormat(format); return DateTime.fromISO('2022-09-04T20:03:05.250Z', { locale: $locale }).toFormat(format);

View file

@ -7,22 +7,30 @@
import SettingButtonsRow from '$lib/components/shared-components/settings/setting-buttons-row.svelte'; import SettingButtonsRow from '$lib/components/shared-components/settings/setting-buttons-row.svelte';
import { t } from 'svelte-i18n'; import { t } from 'svelte-i18n';
export let savedConfig: SystemConfigDto; interface Props {
export let defaultConfig: SystemConfigDto; savedConfig: SystemConfigDto;
export let config: SystemConfigDto; // this is the config that is being edited defaultConfig: SystemConfigDto;
export let disabled = false; config: SystemConfigDto;
export let onReset: SettingsResetEvent; disabled?: boolean;
export let onSave: SettingsSaveEvent; onReset: SettingsResetEvent;
onSave: SettingsSaveEvent;
}
let { savedConfig, defaultConfig, config = $bindable(), disabled = false, onReset, onSave }: Props = $props();
const onsubmit = (event: Event) => {
event.preventDefault();
};
</script> </script>
<div> <div>
<div in:fade={{ duration: 500 }}> <div in:fade={{ duration: 500 }}>
<form autocomplete="off" on:submit|preventDefault> <form autocomplete="off" {onsubmit}>
<div class="ml-4 mt-4 flex flex-col gap-4"> <div class="ml-4 mt-4 flex flex-col gap-4">
<SettingTextarea <SettingTextarea
{disabled} {disabled}
label={$t('admin.theme_custom_css_settings')} label={$t('admin.theme_custom_css_settings')}
desc={$t('admin.theme_custom_css_settings_description')} description={$t('admin.theme_custom_css_settings_description')}
bind:value={config.theme.customCss} bind:value={config.theme.customCss}
required={true} required={true}
isEdited={config.theme.customCss !== savedConfig.theme.customCss} isEdited={config.theme.customCss !== savedConfig.theme.customCss}

View file

@ -4,23 +4,30 @@
import { fade } from 'svelte/transition'; import { fade } from 'svelte/transition';
import type { SettingsResetEvent, SettingsSaveEvent } from '../admin-settings'; import type { SettingsResetEvent, SettingsSaveEvent } from '../admin-settings';
import SettingSwitch from '$lib/components/shared-components/settings/setting-switch.svelte'; import SettingSwitch from '$lib/components/shared-components/settings/setting-switch.svelte';
import SettingInputField, { import SettingInputField from '$lib/components/shared-components/settings/setting-input-field.svelte';
SettingInputFieldType,
} from '$lib/components/shared-components/settings/setting-input-field.svelte';
import SettingButtonsRow from '$lib/components/shared-components/settings/setting-buttons-row.svelte'; import SettingButtonsRow from '$lib/components/shared-components/settings/setting-buttons-row.svelte';
import { t } from 'svelte-i18n'; import { t } from 'svelte-i18n';
import { SettingInputFieldType } from '$lib/constants';
export let savedConfig: SystemConfigDto; interface Props {
export let defaultConfig: SystemConfigDto; savedConfig: SystemConfigDto;
export let config: SystemConfigDto; // this is the config that is being edited defaultConfig: SystemConfigDto;
export let disabled = false; config: SystemConfigDto;
export let onReset: SettingsResetEvent; disabled?: boolean;
export let onSave: SettingsSaveEvent; onReset: SettingsResetEvent;
onSave: SettingsSaveEvent;
}
let { savedConfig, defaultConfig, config = $bindable(), disabled = false, onReset, onSave }: Props = $props();
const onsubmit = (event: Event) => {
event.preventDefault();
};
</script> </script>
<div> <div>
<div in:fade={{ duration: 500 }}> <div in:fade={{ duration: 500 }}>
<form autocomplete="off" on:submit|preventDefault> <form autocomplete="off" {onsubmit}>
<div class="ml-4 mt-4 flex flex-col gap-4"> <div class="ml-4 mt-4 flex flex-col gap-4">
<SettingSwitch title={$t('admin.trash_enabled_description')} {disabled} bind:checked={config.trash.enabled} /> <SettingSwitch title={$t('admin.trash_enabled_description')} {disabled} bind:checked={config.trash.enabled} />
@ -29,7 +36,7 @@
<SettingInputField <SettingInputField
inputType={SettingInputFieldType.NUMBER} inputType={SettingInputFieldType.NUMBER}
label={$t('admin.trash_number_of_days')} label={$t('admin.trash_number_of_days')}
desc={$t('admin.trash_number_of_days_description')} description={$t('admin.trash_number_of_days_description')}
bind:value={config.trash.days} bind:value={config.trash.days}
required={true} required={true}
disabled={disabled || !config.trash.enabled} disabled={disabled || !config.trash.enabled}

View file

@ -5,28 +5,31 @@
import type { SettingsResetEvent, SettingsSaveEvent } from '../admin-settings'; import type { SettingsResetEvent, SettingsSaveEvent } from '../admin-settings';
import SettingButtonsRow from '$lib/components/shared-components/settings/setting-buttons-row.svelte'; import SettingButtonsRow from '$lib/components/shared-components/settings/setting-buttons-row.svelte';
import SettingInputField, { import SettingInputField from '$lib/components/shared-components/settings/setting-input-field.svelte';
SettingInputFieldType,
} from '$lib/components/shared-components/settings/setting-input-field.svelte';
import { t } from 'svelte-i18n'; import { t } from 'svelte-i18n';
import { SettingInputFieldType } from '$lib/constants';
export let savedConfig: SystemConfigDto; interface Props {
export let defaultConfig: SystemConfigDto; savedConfig: SystemConfigDto;
export let config: SystemConfigDto; // this is the config that is being edited defaultConfig: SystemConfigDto;
export let disabled = false; config: SystemConfigDto;
export let onReset: SettingsResetEvent; disabled?: boolean;
export let onSave: SettingsSaveEvent; onReset: SettingsResetEvent;
onSave: SettingsSaveEvent;
}
let { savedConfig, defaultConfig, config = $bindable(), disabled = false, onReset, onSave }: Props = $props();
</script> </script>
<div> <div>
<div in:fade={{ duration: 500 }}> <div in:fade={{ duration: 500 }}>
<form autocomplete="off" on:submit|preventDefault> <form autocomplete="off" onsubmit={(e) => e.preventDefault()}>
<div class="ml-4 mt-4 flex flex-col gap-4"> <div class="ml-4 mt-4 flex flex-col gap-4">
<SettingInputField <SettingInputField
inputType={SettingInputFieldType.NUMBER} inputType={SettingInputFieldType.NUMBER}
min={1} min={1}
label={$t('admin.user_delete_delay_settings')} label={$t('admin.user_delete_delay_settings')}
desc={$t('admin.user_delete_delay_settings_description')} description={$t('admin.user_delete_delay_settings_description')}
bind:value={config.user.deleteDelay} bind:value={config.user.deleteDelay}
isEdited={config.user.deleteDelay !== savedConfig.user.deleteDelay} isEdited={config.user.deleteDelay !== savedConfig.user.deleteDelay}
/> />

View file

@ -1,14 +1,15 @@
import { sdkMock } from '$lib/__mocks__/sdk.mock'; import { sdkMock } from '$lib/__mocks__/sdk.mock';
import { albumFactory } from '@test-data/factories/album-factory'; import { albumFactory } from '@test-data/factories/album-factory';
import '@testing-library/jest-dom'; import '@testing-library/jest-dom';
import { fireEvent, render, waitFor, type RenderResult } from '@testing-library/svelte'; import { render, waitFor, type RenderResult } from '@testing-library/svelte';
import userEvent from '@testing-library/user-event';
import { init, register, waitLocale } from 'svelte-i18n'; import { init, register, waitLocale } from 'svelte-i18n';
import AlbumCard from '../album-card.svelte'; import AlbumCard from '../album-card.svelte';
const onShowContextMenu = vi.fn(); const onShowContextMenu = vi.fn();
describe('AlbumCard component', () => { describe('AlbumCard component', () => {
let sut: RenderResult<AlbumCard>; let sut: RenderResult<typeof AlbumCard>;
beforeAll(async () => { beforeAll(async () => {
await init({ fallbackLocale: 'en-US' }); await init({ fallbackLocale: 'en-US' });
@ -110,13 +111,9 @@ describe('AlbumCard component', () => {
toJSON: () => ({}), toJSON: () => ({}),
}); });
await fireEvent( const user = userEvent.setup();
contextMenuButton, await user.click(contextMenuButton);
new MouseEvent('click', {
clientX: 123,
clientY: 456,
}),
);
expect(onShowContextMenu).toHaveBeenCalledTimes(1); expect(onShowContextMenu).toHaveBeenCalledTimes(1);
expect(onShowContextMenu).toHaveBeenCalledWith(expect.objectContaining({ x: 123, y: 456 })); expect(onShowContextMenu).toHaveBeenCalledWith(expect.objectContaining({ x: 123, y: 456 }));
}); });

View file

@ -11,28 +11,43 @@
import Icon from '$lib/components/elements/icon.svelte'; import Icon from '$lib/components/elements/icon.svelte';
import { t } from 'svelte-i18n'; import { t } from 'svelte-i18n';
export let albums: AlbumResponseDto[]; interface Props {
export let group: AlbumGroup | undefined = undefined; albums: AlbumResponseDto[];
export let showOwner = false; group?: AlbumGroup | undefined;
export let showDateRange = false; showOwner?: boolean;
export let showItemCount = false; showDateRange?: boolean;
export let onShowContextMenu: ((position: ContextMenuPosition, album: AlbumResponseDto) => unknown) | undefined = showItemCount?: boolean;
undefined; onShowContextMenu?: ((position: ContextMenuPosition, album: AlbumResponseDto) => unknown) | undefined;
}
$: isCollapsed = !!group && isAlbumGroupCollapsed($albumViewSettings, group.id); let {
albums,
group = undefined,
showOwner = false,
showDateRange = false,
showItemCount = false,
onShowContextMenu = undefined,
}: Props = $props();
let isCollapsed = $derived(!!group && isAlbumGroupCollapsed($albumViewSettings, group.id));
const showContextMenu = (position: ContextMenuPosition, album: AlbumResponseDto) => { const showContextMenu = (position: ContextMenuPosition, album: AlbumResponseDto) => {
onShowContextMenu?.(position, album); onShowContextMenu?.(position, album);
}; };
$: iconRotation = isCollapsed ? 'rotate-0' : 'rotate-90'; let iconRotation = $derived(isCollapsed ? 'rotate-0' : 'rotate-90');
const oncontextmenu = (event: MouseEvent, album: AlbumResponseDto) => {
event.preventDefault();
showContextMenu({ x: event.x, y: event.y }, album);
};
</script> </script>
{#if group} {#if group}
<div class="grid"> <div class="grid">
<button <button
type="button" type="button"
on:click={() => toggleAlbumGroupCollapsing(group.id)} onclick={() => toggleAlbumGroupCollapsing(group.id)}
class="w-fit mt-2 pt-2 pr-2 mb-2 dark:text-immich-dark-fg" class="w-fit mt-2 pt-2 pr-2 mb-2 dark:text-immich-dark-fg"
aria-expanded={!isCollapsed} aria-expanded={!isCollapsed}
> >
@ -56,7 +71,7 @@
data-sveltekit-preload-data="hover" data-sveltekit-preload-data="hover"
href="{AppRoute.ALBUMS}/{album.id}" href="{AppRoute.ALBUMS}/{album.id}"
animate:flip={{ duration: 400 }} animate:flip={{ duration: 400 }}
on:contextmenu|preventDefault={(e) => showContextMenu({ x: e.x, y: e.y }, album)} oncontextmenu={(event) => oncontextmenu(event, album)}
> >
<AlbumCard <AlbumCard
{album} {album}

View file

@ -8,12 +8,23 @@
import CircleIconButton from '$lib/components/elements/buttons/circle-icon-button.svelte'; import CircleIconButton from '$lib/components/elements/buttons/circle-icon-button.svelte';
import { t } from 'svelte-i18n'; import { t } from 'svelte-i18n';
export let album: AlbumResponseDto; interface Props {
export let showOwner = false; album: AlbumResponseDto;
export let showDateRange = false; showOwner?: boolean;
export let showItemCount = false; showDateRange?: boolean;
export let preload = false; showItemCount?: boolean;
export let onShowContextMenu: ((position: ContextMenuPosition) => unknown) | undefined = undefined; preload?: boolean;
onShowContextMenu?: ((position: ContextMenuPosition) => unknown) | undefined;
}
let {
album,
showOwner = false,
showDateRange = false,
showItemCount = false,
preload = false,
onShowContextMenu = undefined,
}: Props = $props();
const showAlbumContextMenu = (e: MouseEvent) => { const showAlbumContextMenu = (e: MouseEvent) => {
e.stopPropagation(); e.stopPropagation();
@ -39,7 +50,7 @@
size="20" size="20"
padding="2" padding="2"
class="icon-white-drop-shadow" class="icon-white-drop-shadow"
on:click={showAlbumContextMenu} onclick={showAlbumContextMenu}
/> />
</div> </div>
{/if} {/if}

View file

@ -5,13 +5,18 @@
import AssetCover from '$lib/components/sharedlinks-page/covers/asset-cover.svelte'; import AssetCover from '$lib/components/sharedlinks-page/covers/asset-cover.svelte';
import { t } from 'svelte-i18n'; import { t } from 'svelte-i18n';
export let album: AlbumResponseDto; interface Props {
export let preload = false; album: AlbumResponseDto;
let className = ''; preload?: boolean;
export { className as class }; class?: string;
}
$: alt = album.albumName || $t('unnamed_album'); let { album, preload = false, class: className = '' }: Props = $props();
$: thumbnailUrl = album.albumThumbnailAssetId ? getAssetThumbnailUrl({ id: album.albumThumbnailAssetId }) : null;
let alt = $derived(album.albumName || $t('unnamed_album'));
let thumbnailUrl = $derived(
album.albumThumbnailAssetId ? getAssetThumbnailUrl({ id: album.albumThumbnailAssetId }) : null,
);
</script> </script>
{#if thumbnailUrl} {#if thumbnailUrl}

View file

@ -4,9 +4,13 @@
import AutogrowTextarea from '$lib/components/shared-components/autogrow-textarea.svelte'; import AutogrowTextarea from '$lib/components/shared-components/autogrow-textarea.svelte';
import { t } from 'svelte-i18n'; import { t } from 'svelte-i18n';
export let id: string; interface Props {
export let description: string; id: string;
export let isOwned: boolean; description: string;
isOwned: boolean;
}
let { id, description = $bindable(), isOwned }: Props = $props();
const handleUpdateDescription = async (newDescription: string) => { const handleUpdateDescription = async (newDescription: string) => {
try { try {

View file

@ -23,24 +23,38 @@
import { notificationController, NotificationType } from '../shared-components/notification/notification'; import { notificationController, NotificationType } from '../shared-components/notification/notification';
import MenuOption from '$lib/components/shared-components/context-menu/menu-option.svelte'; import MenuOption from '$lib/components/shared-components/context-menu/menu-option.svelte';
export let album: AlbumResponseDto; interface Props {
export let order: AssetOrder | undefined; album: AlbumResponseDto;
export let user: UserResponseDto; // Declare user as a prop order: AssetOrder | undefined;
export let onChangeOrder: (order: AssetOrder) => void; user: UserResponseDto;
export let onClose: () => void; onChangeOrder: (order: AssetOrder) => void;
export let onToggleEnabledActivity: () => void; onClose: () => void;
export let onShowSelectSharedUser: () => void; onToggleEnabledActivity: () => void;
export let onRemove: (userId: string) => void; onShowSelectSharedUser: () => void;
export let onRefreshAlbum: () => void; onRemove: (userId: string) => void;
onRefreshAlbum: () => void;
}
let selectedRemoveUser: UserResponseDto | null = null; let {
album,
order,
user,
onChangeOrder,
onClose,
onToggleEnabledActivity,
onShowSelectSharedUser,
onRemove,
onRefreshAlbum,
}: Props = $props();
let selectedRemoveUser: UserResponseDto | null = $state(null);
const options: Record<AssetOrder, RenderedOption> = { const options: Record<AssetOrder, RenderedOption> = {
[AssetOrder.Asc]: { icon: mdiArrowUpThin, title: $t('oldest_first') }, [AssetOrder.Asc]: { icon: mdiArrowUpThin, title: $t('oldest_first') },
[AssetOrder.Desc]: { icon: mdiArrowDownThin, title: $t('newest_first') }, [AssetOrder.Desc]: { icon: mdiArrowDownThin, title: $t('newest_first') },
}; };
$: selectedOption = order ? options[order] : options[AssetOrder.Desc]; let selectedOption = $derived(order ? options[order] : options[AssetOrder.Desc]);
const handleToggle = async (returnedOption: RenderedOption): Promise<void> => { const handleToggle = async (returnedOption: RenderedOption): Promise<void> => {
if (selectedOption === returnedOption) { if (selectedOption === returnedOption) {
@ -125,7 +139,7 @@
<div class="py-2"> <div class="py-2">
<div class="text-gray text-sm mb-3">{$t('people').toUpperCase()}</div> <div class="text-gray text-sm mb-3">{$t('people').toUpperCase()}</div>
<div class="p-2"> <div class="p-2">
<button type="button" class="flex items-center gap-2" on:click={onShowSelectSharedUser}> <button type="button" class="flex items-center gap-2" onclick={onShowSelectSharedUser}>
<div class="rounded-full w-10 h-10 border border-gray-500 flex items-center justify-center"> <div class="rounded-full w-10 h-10 border border-gray-500 flex items-center justify-center">
<div><Icon path={mdiPlus} size="25" /></div> <div><Icon path={mdiPlus} size="25" /></div>
</div> </div>

View file

@ -4,10 +4,11 @@
import type { AlbumResponseDto } from '@immich/sdk'; import type { AlbumResponseDto } from '@immich/sdk';
import { t } from 'svelte-i18n'; import { t } from 'svelte-i18n';
export let album: AlbumResponseDto; interface Props {
album: AlbumResponseDto;
}
$: startDate = formatDate(album.startDate); let { album }: Props = $props();
$: endDate = formatDate(album.endDate);
const formatDate = (date?: string) => { const formatDate = (date?: string) => {
return date ? new Date(date).toLocaleDateString($locale, dateFormats.album) : undefined; return date ? new Date(date).toLocaleDateString($locale, dateFormats.album) : undefined;
@ -24,6 +25,8 @@
return ''; return '';
}; };
let startDate = $derived(formatDate(album.startDate));
let endDate = $derived(formatDate(album.endDate));
</script> </script>
<span class="my-2 flex gap-2 text-sm font-medium text-gray-500" data-testid="album-details"> <span class="my-2 flex gap-2 text-sm font-medium text-gray-500" data-testid="album-details">

View file

@ -4,12 +4,20 @@
import { shortcut } from '$lib/actions/shortcut'; import { shortcut } from '$lib/actions/shortcut';
import { t } from 'svelte-i18n'; import { t } from 'svelte-i18n';
export let id: string; interface Props {
export let albumName: string; id: string;
export let isOwned: boolean; albumName: string;
export let onUpdate: (albumName: string) => void; isOwned: boolean;
onUpdate: (albumName: string) => void;
}
$: newAlbumName = albumName; let { id, albumName = $bindable(), isOwned, onUpdate }: Props = $props();
let newAlbumName = $state(albumName);
$effect(() => {
newAlbumName = albumName;
});
const handleUpdateName = async () => { const handleUpdateName = async () => {
if (newAlbumName === albumName) { if (newAlbumName === albumName) {
@ -33,7 +41,7 @@
<input <input
use:shortcut={{ shortcut: { key: 'Enter' }, onShortcut: (e) => e.currentTarget.blur() }} use:shortcut={{ shortcut: { key: 'Enter' }, onShortcut: (e) => e.currentTarget.blur() }}
on:blur={handleUpdateName} 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-immich-primary outline-none transition-all dark:text-immich-dark-primary {isOwned
? 'hover:border-gray-400' ? 'hover:border-gray-400'
: 'hover:border-transparent'} bg-immich-bg focus:border-b-2 focus:border-immich-primary focus:outline-none dark:bg-immich-dark-bg dark:focus:border-immich-dark-primary dark:focus:bg-immich-dark-gray" : 'hover:border-transparent'} bg-immich-bg focus:border-b-2 focus:border-immich-primary focus:outline-none dark:bg-immich-dark-bg dark:focus:border-immich-dark-primary dark:focus:bg-immich-dark-gray"

View file

@ -21,11 +21,15 @@
import { t } from 'svelte-i18n'; import { t } from 'svelte-i18n';
import { onDestroy } from 'svelte'; import { onDestroy } from 'svelte';
export let sharedLink: SharedLinkResponseDto; interface Props {
export let user: UserResponseDto | undefined = undefined; sharedLink: SharedLinkResponseDto;
user?: UserResponseDto | undefined;
}
let { sharedLink, user = undefined }: Props = $props();
const album = sharedLink.album as AlbumResponseDto; const album = sharedLink.album as AlbumResponseDto;
let innerWidth: number; let innerWidth: number = $state(0);
let { isViewing: showAssetViewer } = assetViewingStore; let { isViewing: showAssetViewer } = assetViewingStore;
@ -70,15 +74,15 @@
</AssetSelectControlBar> </AssetSelectControlBar>
{:else} {:else}
<ControlAppBar showBackButton={false}> <ControlAppBar showBackButton={false}>
<svelte:fragment slot="leading"> {#snippet leading()}
<ImmichLogoSmallLink width={innerWidth} /> <ImmichLogoSmallLink width={innerWidth} />
</svelte:fragment> {/snippet}
<svelte:fragment slot="trailing"> {#snippet trailing()}
{#if sharedLink.allowUpload} {#if sharedLink.allowUpload}
<CircleIconButton <CircleIconButton
title={$t('add_photos')} title={$t('add_photos')}
on:click={() => openFileUploadDialog({ albumId: album.id })} onclick={() => openFileUploadDialog({ albumId: album.id })}
icon={mdiFileImagePlusOutline} icon={mdiFileImagePlusOutline}
/> />
{/if} {/if}
@ -86,13 +90,13 @@
{#if album.assetCount > 0 && sharedLink.allowDownload} {#if album.assetCount > 0 && sharedLink.allowDownload}
<CircleIconButton <CircleIconButton
title={$t('download')} title={$t('download')}
on:click={() => downloadAlbum(album)} onclick={() => downloadAlbum(album)}
icon={mdiFolderDownloadOutline} icon={mdiFolderDownloadOutline}
/> />
{/if} {/if}
<ThemeButton /> <ThemeButton />
</svelte:fragment> {/snippet}
</ControlAppBar> </ControlAppBar>
{/if} {/if}
</header> </header>

View file

@ -38,8 +38,12 @@
import { fly } from 'svelte/transition'; import { fly } from 'svelte/transition';
import { t } from 'svelte-i18n'; import { t } from 'svelte-i18n';
export let albumGroups: string[]; interface Props {
export let searchQuery: string; albumGroups: string[];
searchQuery: string;
}
let { albumGroups, searchQuery = $bindable() }: Props = $props();
const flipOrdering = (ordering: string) => { const flipOrdering = (ordering: string) => {
return ordering === SortOrder.Asc ? SortOrder.Desc : SortOrder.Asc; return ordering === SortOrder.Asc ? SortOrder.Desc : SortOrder.Asc;
@ -73,62 +77,38 @@
$albumViewSettings.view === AlbumViewMode.Cover ? AlbumViewMode.List : AlbumViewMode.Cover; $albumViewSettings.view === AlbumViewMode.Cover ? AlbumViewMode.List : AlbumViewMode.Cover;
}; };
let selectedGroupOption: AlbumGroupOptionMetadata; let groupIcon = $derived.by(() => {
let groupIcon: string; if (selectedGroupOption?.id === AlbumGroupBy.None) {
return mdiFolderRemoveOutline;
$: selectedFilterOption = albumFilterNames[findFilterOption($albumViewSettings.filter)];
$: selectedSortOption = findSortOptionMetadata($albumViewSettings.sortBy);
$: {
selectedGroupOption = findGroupOptionMetadata($albumViewSettings.groupBy);
if (selectedGroupOption.isDisabled()) {
selectedGroupOption = findGroupOptionMetadata(AlbumGroupBy.None);
} }
} return $albumViewSettings.groupOrder === SortOrder.Desc ? mdiFolderArrowDownOutline : mdiFolderArrowUpOutline;
});
// svelte-ignore reactive_declaration_non_reactive_property let albumFilterNames: Record<AlbumFilter, string> = $derived({
$: { [AlbumFilter.All]: $t('all'),
if (selectedGroupOption.id === AlbumGroupBy.None) { [AlbumFilter.Owned]: $t('owned'),
groupIcon = mdiFolderRemoveOutline; [AlbumFilter.Shared]: $t('shared'),
} else { });
groupIcon =
$albumViewSettings.groupOrder === SortOrder.Desc ? mdiFolderArrowDownOutline : mdiFolderArrowUpOutline;
}
}
// svelte-ignore reactive_declaration_non_reactive_property let selectedFilterOption = $derived(albumFilterNames[findFilterOption($albumViewSettings.filter)]);
$: sortIcon = $albumViewSettings.sortOrder === SortOrder.Desc ? mdiArrowDownThin : mdiArrowUpThin; let selectedSortOption = $derived(findSortOptionMetadata($albumViewSettings.sortBy));
let selectedGroupOption = $derived(findGroupOptionMetadata($albumViewSettings.groupBy));
let sortIcon = $derived($albumViewSettings.sortOrder === SortOrder.Desc ? mdiArrowDownThin : mdiArrowUpThin);
// svelte-ignore reactive_declaration_non_reactive_property let albumSortByNames: Record<AlbumSortBy, string> = $derived({
$: albumFilterNames = ((): Record<AlbumFilter, string> => { [AlbumSortBy.Title]: $t('sort_title'),
return { [AlbumSortBy.ItemCount]: $t('sort_items'),
[AlbumFilter.All]: $t('all'), [AlbumSortBy.DateModified]: $t('sort_modified'),
[AlbumFilter.Owned]: $t('owned'), [AlbumSortBy.DateCreated]: $t('sort_created'),
[AlbumFilter.Shared]: $t('shared'), [AlbumSortBy.MostRecentPhoto]: $t('sort_recent'),
}; [AlbumSortBy.OldestPhoto]: $t('sort_oldest'),
})(); });
// svelte-ignore reactive_declaration_non_reactive_property let albumGroupByNames: Record<AlbumGroupBy, string> = $derived({
$: albumSortByNames = ((): Record<AlbumSortBy, string> => { [AlbumGroupBy.None]: $t('group_no'),
return { [AlbumGroupBy.Owner]: $t('group_owner'),
[AlbumSortBy.Title]: $t('sort_title'), [AlbumGroupBy.Year]: $t('group_year'),
[AlbumSortBy.ItemCount]: $t('sort_items'), });
[AlbumSortBy.DateModified]: $t('sort_modified'),
[AlbumSortBy.DateCreated]: $t('sort_created'),
[AlbumSortBy.MostRecentPhoto]: $t('sort_recent'),
[AlbumSortBy.OldestPhoto]: $t('sort_oldest'),
};
})();
// svelte-ignore reactive_declaration_non_reactive_property
$: albumGroupByNames = ((): Record<AlbumGroupBy, string> => {
return {
[AlbumGroupBy.None]: $t('group_no'),
[AlbumGroupBy.Owner]: $t('group_owner'),
[AlbumGroupBy.Year]: $t('group_year'),
};
})();
</script> </script>
<!-- Filter Albums by Sharing Status (All, Owned, Shared) --> <!-- Filter Albums by Sharing Status (All, Owned, Shared) -->
@ -147,7 +127,7 @@
</div> </div>
<!-- Create Album --> <!-- Create Album -->
<LinkButton on:click={() => createAlbumAndRedirect()}> <LinkButton onclick={() => createAlbumAndRedirect()}>
<div class="flex place-items-center gap-2 text-sm"> <div class="flex place-items-center gap-2 text-sm">
<Icon path={mdiPlusBoxOutline} size="18" /> <Icon path={mdiPlusBoxOutline} size="18" />
<p class="hidden md:block">{$t('create_album')}</p> <p class="hidden md:block">{$t('create_album')}</p>
@ -184,7 +164,7 @@
<!-- Expand Album Groups --> <!-- Expand Album Groups -->
<div class="hidden xl:flex gap-0"> <div class="hidden xl:flex gap-0">
<div class="block"> <div class="block">
<LinkButton title={$t('expand_all')} on:click={() => expandAllAlbumGroups()}> <LinkButton title={$t('expand_all')} onclick={() => expandAllAlbumGroups()}>
<div class="flex place-items-center gap-2 text-sm"> <div class="flex place-items-center gap-2 text-sm">
<Icon path={mdiUnfoldMoreHorizontal} size="18" /> <Icon path={mdiUnfoldMoreHorizontal} size="18" />
</div> </div>
@ -193,7 +173,7 @@
<!-- Collapse Album Groups --> <!-- Collapse Album Groups -->
<div class="block"> <div class="block">
<LinkButton title={$t('collapse_all')} on:click={() => collapseAllAlbumGroups(albumGroups)}> <LinkButton title={$t('collapse_all')} onclick={() => collapseAllAlbumGroups(albumGroups)}>
<div class="flex place-items-center gap-2 text-sm"> <div class="flex place-items-center gap-2 text-sm">
<Icon path={mdiUnfoldLessHorizontal} size="18" /> <Icon path={mdiUnfoldLessHorizontal} size="18" />
</div> </div>
@ -204,7 +184,7 @@
{/if} {/if}
<!-- Cover/List Display Toggle --> <!-- Cover/List Display Toggle -->
<LinkButton on:click={() => handleChangeListMode()}> <LinkButton onclick={() => handleChangeListMode()}>
<div class="flex place-items-center gap-2 text-sm"> <div class="flex place-items-center gap-2 text-sm">
{#if $albumViewSettings.view === AlbumViewMode.List} {#if $albumViewSettings.view === AlbumViewMode.List}
<Icon path={mdiViewGridOutline} size="18" /> <Icon path={mdiViewGridOutline} size="18" />

View file

@ -1,5 +1,5 @@
<script lang="ts"> <script lang="ts">
import { onMount } from 'svelte'; import { onMount, type Snippet } from 'svelte';
import { groupBy } from 'lodash-es'; import { groupBy } from 'lodash-es';
import { addUsersToAlbum, deleteAlbum, type AlbumUserAddDto, type AlbumResponseDto, isHttpError } from '@immich/sdk'; import { addUsersToAlbum, deleteAlbum, type AlbumUserAddDto, type AlbumResponseDto, isHttpError } from '@immich/sdk';
import { mdiDeleteOutline, mdiShareVariantOutline, mdiFolderDownloadOutline, mdiRenameOutline } from '@mdi/js'; import { mdiDeleteOutline, mdiShareVariantOutline, mdiFolderDownloadOutline, mdiRenameOutline } from '@mdi/js';
@ -38,14 +38,29 @@
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
import { AppRoute } from '$lib/constants'; import { AppRoute } from '$lib/constants';
import { t } from 'svelte-i18n'; import { t } from 'svelte-i18n';
import { run } from 'svelte/legacy';
export let ownedAlbums: AlbumResponseDto[] = []; interface Props {
export let sharedAlbums: AlbumResponseDto[] = []; ownedAlbums?: AlbumResponseDto[];
export let searchQuery: string = ''; sharedAlbums?: AlbumResponseDto[];
export let userSettings: AlbumViewSettings; searchQuery?: string;
export let allowEdit = false; userSettings: AlbumViewSettings;
export let showOwner = false; allowEdit?: boolean;
export let albumGroupIds: string[] = []; showOwner?: boolean;
albumGroupIds?: string[];
empty?: Snippet;
}
let {
ownedAlbums = $bindable([]),
sharedAlbums = $bindable([]),
searchQuery = '',
userSettings,
allowEdit = false,
showOwner = false,
albumGroupIds = $bindable([]),
empty,
}: Props = $props();
interface AlbumGroupOption { interface AlbumGroupOption {
[option: string]: (order: SortOrder, albums: AlbumResponseDto[]) => AlbumGroup[]; [option: string]: (order: SortOrder, albums: AlbumResponseDto[]) => AlbumGroup[];
@ -118,25 +133,24 @@
}, },
}; };
let albums: AlbumResponseDto[] = []; let albums: AlbumResponseDto[] = $state([]);
let filteredAlbums: AlbumResponseDto[] = []; let filteredAlbums: AlbumResponseDto[] = $state([]);
let groupedAlbums: AlbumGroup[] = []; let groupedAlbums: AlbumGroup[] = $state([]);
let albumGroupOption: string = AlbumGroupBy.None; let albumGroupOption: string = $state(AlbumGroupBy.None);
let showShareByURLModal = false; let showShareByURLModal = $state(false);
let albumToEdit: AlbumResponseDto | null = null; let albumToEdit: AlbumResponseDto | null = $state(null);
let albumToShare: AlbumResponseDto | null = null; let albumToShare: AlbumResponseDto | null = $state(null);
let albumToDelete: AlbumResponseDto | null = null; let albumToDelete: AlbumResponseDto | null = null;
let contextMenuPosition: ContextMenuPosition = { x: 0, y: 0 }; let contextMenuPosition: ContextMenuPosition = $state({ x: 0, y: 0 });
let contextMenuTargetAlbum: AlbumResponseDto | null = null; let contextMenuTargetAlbum: AlbumResponseDto | undefined = $state();
let isOpen = false; let isOpen = $state(false);
// Step 1: Filter between Owned and Shared albums, or both. // Step 1: Filter between Owned and Shared albums, or both.
// svelte-ignore reactive_declaration_non_reactive_property run(() => {
$: {
switch (userSettings.filter) { switch (userSettings.filter) {
case AlbumFilter.Owned: { case AlbumFilter.Owned: {
albums = ownedAlbums; albums = ownedAlbums;
@ -152,10 +166,10 @@
albums = nonOwnedAlbums.length > 0 ? ownedAlbums.concat(nonOwnedAlbums) : ownedAlbums; albums = nonOwnedAlbums.length > 0 ? ownedAlbums.concat(nonOwnedAlbums) : ownedAlbums;
} }
} }
} });
// Step 2: Filter using the given search query. // Step 2: Filter using the given search query.
$: { run(() => {
if (searchQuery) { if (searchQuery) {
const searchAlbumNormalized = normalizeSearchString(searchQuery); const searchAlbumNormalized = normalizeSearchString(searchQuery);
@ -165,17 +179,17 @@
} else { } else {
filteredAlbums = albums; filteredAlbums = albums;
} }
} });
// Step 3: Group albums. // Step 3: Group albums.
$: { run(() => {
albumGroupOption = getSelectedAlbumGroupOption(userSettings); albumGroupOption = getSelectedAlbumGroupOption(userSettings);
const groupFunc = groupOptions[albumGroupOption] ?? groupOptions[AlbumGroupBy.None]; const groupFunc = groupOptions[albumGroupOption] ?? groupOptions[AlbumGroupBy.None];
groupedAlbums = groupFunc(stringToSortOrder(userSettings.groupOrder), filteredAlbums); groupedAlbums = groupFunc(stringToSortOrder(userSettings.groupOrder), filteredAlbums);
} });
// Step 4: Sort albums amongst each group. // Step 4: Sort albums amongst each group.
$: { run(() => {
groupedAlbums = groupedAlbums.map((group) => ({ groupedAlbums = groupedAlbums.map((group) => ({
id: group.id, id: group.id,
name: group.name, name: group.name,
@ -183,9 +197,11 @@
})); }));
albumGroupIds = groupedAlbums.map(({ id }) => id); albumGroupIds = groupedAlbums.map(({ id }) => id);
} });
$: showFullContextMenu = allowEdit && contextMenuTargetAlbum && contextMenuTargetAlbum.ownerId === $user.id; let showFullContextMenu = $derived(
allowEdit && contextMenuTargetAlbum && contextMenuTargetAlbum.ownerId === $user.id,
);
onMount(async () => { onMount(async () => {
if (allowEdit) { if (allowEdit) {
@ -320,6 +336,10 @@
}; };
const openShareModal = () => { const openShareModal = () => {
if (!contextMenuTargetAlbum) {
return;
}
albumToShare = contextMenuTargetAlbum; albumToShare = contextMenuTargetAlbum;
closeAlbumContextMenu(); closeAlbumContextMenu();
}; };
@ -359,7 +379,7 @@
{/if} {/if}
{:else} {:else}
<!-- Empty Message --> <!-- Empty Message -->
<slot name="empty" /> {@render empty?.()}
{/if} {/if}
<!-- Context Menu --> <!-- Context Menu -->

View file

@ -3,7 +3,11 @@
import type { AlbumSortOptionMetadata } from '$lib/utils/album-utils'; import type { AlbumSortOptionMetadata } from '$lib/utils/album-utils';
import { t } from 'svelte-i18n'; import { t } from 'svelte-i18n';
export let option: AlbumSortOptionMetadata; interface Props {
option: AlbumSortOptionMetadata;
}
let { option }: Props = $props();
const handleSort = () => { const handleSort = () => {
if ($albumViewSettings.sortBy === option.id) { if ($albumViewSettings.sortBy === option.id) {
@ -13,24 +17,22 @@
$albumViewSettings.sortOrder = option.defaultOrder; $albumViewSettings.sortOrder = option.defaultOrder;
} }
}; };
// svelte-ignore reactive_declaration_non_reactive_property
$: albumSortByNames = ((): Record<AlbumSortBy, string> => { let albumSortByNames: Record<AlbumSortBy, string> = $derived({
return { [AlbumSortBy.Title]: $t('sort_title'),
[AlbumSortBy.Title]: $t('sort_title'), [AlbumSortBy.ItemCount]: $t('sort_items'),
[AlbumSortBy.ItemCount]: $t('sort_items'), [AlbumSortBy.DateModified]: $t('sort_modified'),
[AlbumSortBy.DateModified]: $t('sort_modified'), [AlbumSortBy.DateCreated]: $t('sort_created'),
[AlbumSortBy.DateCreated]: $t('sort_created'), [AlbumSortBy.MostRecentPhoto]: $t('sort_recent'),
[AlbumSortBy.MostRecentPhoto]: $t('sort_recent'), [AlbumSortBy.OldestPhoto]: $t('sort_oldest'),
[AlbumSortBy.OldestPhoto]: $t('sort_oldest'), });
};
})();
</script> </script>
<th class="text-sm font-medium {option.columnStyle}"> <th class="text-sm font-medium {option.columnStyle}">
<button <button
type="button" type="button"
class="rounded-lg p-2 hover:bg-immich-dark-primary hover:dark:bg-immich-dark-primary/50" class="rounded-lg p-2 hover:bg-immich-dark-primary hover:dark:bg-immich-dark-primary/50"
on:click={handleSort} onclick={handleSort}
> >
{#if $albumViewSettings.sortBy === option.id} {#if $albumViewSettings.sortBy === option.id}
{#if $albumViewSettings.sortOrder === SortOrder.Desc} {#if $albumViewSettings.sortOrder === SortOrder.Desc}

View file

@ -9,9 +9,12 @@
import Icon from '$lib/components/elements/icon.svelte'; import Icon from '$lib/components/elements/icon.svelte';
import { t } from 'svelte-i18n'; import { t } from 'svelte-i18n';
export let album: AlbumResponseDto; interface Props {
export let onShowContextMenu: ((position: ContextMenuPosition, album: AlbumResponseDto) => unknown) | undefined = album: AlbumResponseDto;
undefined; onShowContextMenu?: ((position: ContextMenuPosition, album: AlbumResponseDto) => unknown) | undefined;
}
let { album, onShowContextMenu = undefined }: Props = $props();
const showContextMenu = (position: ContextMenuPosition) => { const showContextMenu = (position: ContextMenuPosition) => {
onShowContextMenu?.(position, album); onShowContextMenu?.(position, album);
@ -20,12 +23,17 @@
const dateLocaleString = (dateString: string) => { const dateLocaleString = (dateString: string) => {
return new Date(dateString).toLocaleDateString($locale, dateFormats.album); return new Date(dateString).toLocaleDateString($locale, dateFormats.album);
}; };
const oncontextmenu = (event: MouseEvent) => {
event.preventDefault();
showContextMenu({ x: event.x, y: event.y });
};
</script> </script>
<tr <tr
class="flex h-[50px] w-full place-items-center border-[3px] border-transparent p-2 text-center odd:bg-immich-gray even:bg-immich-bg 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" class="flex h-[50px] w-full place-items-center border-[3px] border-transparent p-2 text-center odd:bg-immich-gray even:bg-immich-bg 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"
on:click={() => goto(`${AppRoute.ALBUMS}/${album.id}`)} onclick={() => goto(`${AppRoute.ALBUMS}/${album.id}`)}
on:contextmenu|preventDefault={(e) => showContextMenu({ x: e.x, y: e.y })} {oncontextmenu}
> >
<td class="text-md text-ellipsis text-left w-8/12 sm:w-4/12 md:w-4/12 xl:w-[30%] 2xl:w-[40%] items-center"> <td class="text-md text-ellipsis text-left w-8/12 sm:w-4/12 md:w-4/12 xl:w-[30%] 2xl:w-[40%] items-center">
{album.albumName} {album.albumName}

View file

@ -15,10 +15,13 @@
} from '$lib/utils/album-utils'; } from '$lib/utils/album-utils';
import { t } from 'svelte-i18n'; import { t } from 'svelte-i18n';
export let groupedAlbums: AlbumGroup[]; interface Props {
export let albumGroupOption: string = AlbumGroupBy.None; groupedAlbums: AlbumGroup[];
export let onShowContextMenu: ((position: ContextMenuPosition, album: AlbumResponseDto) => unknown) | undefined = albumGroupOption?: string;
undefined; onShowContextMenu?: ((position: ContextMenuPosition, album: AlbumResponseDto) => unknown) | undefined;
}
let { groupedAlbums, albumGroupOption = AlbumGroupBy.None, onShowContextMenu }: Props = $props();
</script> </script>
<table class="mt-2 w-full text-left"> <table class="mt-2 w-full text-left">
@ -46,7 +49,7 @@
> >
<tr <tr
class="flex w-full place-items-center p-2 md:pl-5 md:pr-5 md:pt-3 md:pb-3" class="flex w-full place-items-center p-2 md:pl-5 md:pr-5 md:pt-3 md:pb-3"
on:click={() => toggleAlbumGroupCollapsing(albumGroup.id)} onclick={() => toggleAlbumGroupCollapsing(albumGroup.id)}
aria-expanded={!isCollapsed} aria-expanded={!isCollapsed}
> >
<td class="text-md text-left -mb-1"> <td class="text-md text-left -mb-1">

View file

@ -18,15 +18,19 @@
import { t } from 'svelte-i18n'; import { t } from 'svelte-i18n';
import ButtonContextMenu from '$lib/components/shared-components/context-menu/button-context-menu.svelte'; import ButtonContextMenu from '$lib/components/shared-components/context-menu/button-context-menu.svelte';
export let album: AlbumResponseDto; interface Props {
export let onClose: () => void; album: AlbumResponseDto;
export let onRemove: (userId: string) => void; onClose: () => void;
export let onRefreshAlbum: () => void; onRemove: (userId: string) => void;
onRefreshAlbum: () => void;
}
let currentUser: UserResponseDto; let { album, onClose, onRemove, onRefreshAlbum }: Props = $props();
let selectedRemoveUser: UserResponseDto | null = null;
$: isOwned = currentUser?.id == album.ownerId; let currentUser: UserResponseDto | undefined = $state();
let selectedRemoveUser: UserResponseDto | null = $state(null);
let isOwned = $derived(currentUser?.id == album.ownerId);
onMount(async () => { onMount(async () => {
try { try {
@ -123,7 +127,7 @@
{:else if user.id == currentUser?.id} {:else if user.id == currentUser?.id}
<button <button
type="button" type="button"
on:click={() => (selectedRemoveUser = user)} onclick={() => (selectedRemoveUser = user)}
class="text-sm font-medium text-immich-primary transition-colors hover:text-immich-primary/75 dark:text-immich-dark-primary" class="text-sm font-medium text-immich-primary transition-colors hover:text-immich-primary/75 dark:text-immich-dark-primary"
>{$t('leave')}</button >{$t('leave')}</button
> >

View file

@ -18,13 +18,17 @@
import UserAvatar from '../shared-components/user-avatar.svelte'; import UserAvatar from '../shared-components/user-avatar.svelte';
import { t } from 'svelte-i18n'; import { t } from 'svelte-i18n';
export let album: AlbumResponseDto; interface Props {
export let onClose: () => void; album: AlbumResponseDto;
export let onSelect: (selectedUsers: AlbumUserAddDto[]) => void; onClose: () => void;
export let onShare: () => void; onSelect: (selectedUsers: AlbumUserAddDto[]) => void;
onShare: () => void;
}
let users: UserResponseDto[] = []; let { album, onClose, onSelect, onShare }: Props = $props();
let selectedUsers: Record<string, { user: UserResponseDto; role: AlbumUserRole }> = {};
let users: UserResponseDto[] = $state([]);
let selectedUsers: Record<string, { user: UserResponseDto; role: AlbumUserRole }> = $state({});
const roleOptions: Array<{ title: string; value: AlbumUserRole | 'none'; icon?: string }> = [ const roleOptions: Array<{ title: string; value: AlbumUserRole | 'none'; icon?: string }> = [
{ title: $t('role_editor'), value: AlbumUserRole.Editor, icon: mdiPencil }, { title: $t('role_editor'), value: AlbumUserRole.Editor, icon: mdiPencil },
@ -32,7 +36,7 @@
{ title: $t('remove_user'), value: 'none' }, { title: $t('remove_user'), value: 'none' },
]; ];
let sharedLinks: SharedLinkResponseDto[] = []; let sharedLinks: SharedLinkResponseDto[] = $state([]);
onMount(async () => { onMount(async () => {
await getSharedLinks(); await getSharedLinks();
const data = await searchUsers(); const data = await searchUsers();
@ -121,11 +125,7 @@
{#each users as user} {#each users as user}
{#if !Object.keys(selectedUsers).includes(user.id)} {#if !Object.keys(selectedUsers).includes(user.id)}
<div class="flex place-items-center transition-all hover:bg-gray-200 dark:hover:bg-gray-700 rounded-xl"> <div class="flex place-items-center transition-all hover:bg-gray-200 dark:hover:bg-gray-700 rounded-xl">
<button <button type="button" onclick={() => handleToggle(user)} class="flex w-full place-items-center gap-4 p-4">
type="button"
on:click={() => handleToggle(user)}
class="flex w-full place-items-center gap-4 p-4"
>
<UserAvatar {user} size="md" /> <UserAvatar {user} size="md" />
<div class="text-left flex-grow"> <div class="text-left flex-grow">
<p class="text-immich-fg dark:text-immich-dark-fg"> <p class="text-immich-fg dark:text-immich-dark-fg">
@ -150,7 +150,7 @@
fullwidth fullwidth
rounded="full" rounded="full"
disabled={Object.keys(selectedUsers).length === 0} disabled={Object.keys(selectedUsers).length === 0}
on:click={() => onclick={() =>
onSelect(Object.values(selectedUsers).map(({ user, ...rest }) => ({ userId: user.id, ...rest })))} onSelect(Object.values(selectedUsers).map(({ user, ...rest }) => ({ userId: user.id, ...rest })))}
>{$t('add')}</Button >{$t('add')}</Button
> >
@ -163,7 +163,7 @@
<button <button
type="button" type="button"
class="flex flex-col place-content-center place-items-center gap-2 hover:cursor-pointer" class="flex flex-col place-content-center place-items-center gap-2 hover:cursor-pointer"
on:click={onShare} onclick={onShare}
> >
<Icon path={mdiLink} size={24} /> <Icon path={mdiLink} size={24} />
<p class="text-sm">{$t('create_link')}</p> <p class="text-sm">{$t('create_link')}</p>

View file

@ -9,11 +9,15 @@
import { mdiImageAlbum, mdiShareVariantOutline } from '@mdi/js'; import { mdiImageAlbum, mdiShareVariantOutline } from '@mdi/js';
import { t } from 'svelte-i18n'; import { t } from 'svelte-i18n';
export let asset: AssetResponseDto; interface Props {
export let onAction: OnAction; asset: AssetResponseDto;
export let shared = false; onAction: OnAction;
shared?: boolean;
}
let showSelectionModal = false; let { asset, onAction, shared = false }: Props = $props();
let showSelectionModal = $state(false);
const handleAddToNewAlbum = async (albumName: string) => { const handleAddToNewAlbum = async (albumName: string) => {
showSelectionModal = false; showSelectionModal = false;

View file

@ -8,8 +8,12 @@
import { mdiArchiveArrowDownOutline, mdiArchiveArrowUpOutline } from '@mdi/js'; import { mdiArchiveArrowDownOutline, mdiArchiveArrowUpOutline } from '@mdi/js';
import { t } from 'svelte-i18n'; import { t } from 'svelte-i18n';
export let asset: AssetResponseDto; interface Props {
export let onAction: OnAction; asset: AssetResponseDto;
onAction: OnAction;
}
let { asset, onAction }: Props = $props();
const onArchive = async () => { const onArchive = async () => {
const updatedAsset = await toggleArchive(asset); const updatedAsset = await toggleArchive(asset);

View file

@ -4,9 +4,13 @@
import { mdiArrowLeft } from '@mdi/js'; import { mdiArrowLeft } from '@mdi/js';
import { t } from 'svelte-i18n'; import { t } from 'svelte-i18n';
export let onClose: () => void; interface Props {
onClose: () => void;
}
let { onClose }: Props = $props();
</script> </script>
<svelte:window use:shortcut={{ shortcut: { key: 'Escape' }, onShortcut: onClose }} /> <svelte:window use:shortcut={{ shortcut: { key: 'Escape' }, onShortcut: onClose }} />
<CircleIconButton color="opaque" icon={mdiArrowLeft} title={$t('go_back')} on:click={onClose} /> <CircleIconButton color="opaque" icon={mdiArrowLeft} title={$t('go_back')} onclick={onClose} />

View file

@ -16,10 +16,14 @@
import { t } from 'svelte-i18n'; import { t } from 'svelte-i18n';
import type { OnAction } from './action'; import type { OnAction } from './action';
export let asset: AssetResponseDto; interface Props {
export let onAction: OnAction; asset: AssetResponseDto;
onAction: OnAction;
}
let showConfirmModal = false; let { asset, onAction }: Props = $props();
let showConfirmModal = $state(false);
const trashOrDelete = async (force = false) => { const trashOrDelete = async (force = false) => {
if (force || !$featureFlags.trash) { if (force || !$featureFlags.trash) {
@ -77,7 +81,7 @@
color="opaque" color="opaque"
icon={asset.isTrashed ? mdiDeleteForeverOutline : mdiDeleteOutline} icon={asset.isTrashed ? mdiDeleteForeverOutline : mdiDeleteOutline}
title={asset.isTrashed ? $t('permanently_delete') : $t('delete')} title={asset.isTrashed ? $t('permanently_delete') : $t('delete')}
on:click={() => trashOrDelete(asset.isTrashed)} onclick={() => trashOrDelete(asset.isTrashed)}
/> />
{#if showConfirmModal} {#if showConfirmModal}

View file

@ -7,8 +7,12 @@
import { mdiFolderDownloadOutline } from '@mdi/js'; import { mdiFolderDownloadOutline } from '@mdi/js';
import { t } from 'svelte-i18n'; import { t } from 'svelte-i18n';
export let asset: AssetResponseDto; interface Props {
export let menuItem = false; asset: AssetResponseDto;
menuItem?: boolean;
}
let { asset, menuItem = false }: Props = $props();
const onDownloadFile = () => downloadFile(asset); const onDownloadFile = () => downloadFile(asset);
</script> </script>
@ -16,7 +20,7 @@
<svelte:window use:shortcut={{ shortcut: { key: 'd', shift: true }, onShortcut: onDownloadFile }} /> <svelte:window use:shortcut={{ shortcut: { key: 'd', shift: true }, onShortcut: onDownloadFile }} />
{#if !menuItem} {#if !menuItem}
<CircleIconButton color="opaque" icon={mdiFolderDownloadOutline} title={$t('download')} on:click={onDownloadFile} /> <CircleIconButton color="opaque" icon={mdiFolderDownloadOutline} title={$t('download')} onclick={onDownloadFile} />
{:else} {:else}
<MenuOption icon={mdiFolderDownloadOutline} text={$t('download')} onClick={onDownloadFile} /> <MenuOption icon={mdiFolderDownloadOutline} text={$t('download')} onClick={onDownloadFile} />
{/if} {/if}

View file

@ -12,8 +12,12 @@
import { t } from 'svelte-i18n'; import { t } from 'svelte-i18n';
import type { OnAction } from './action'; import type { OnAction } from './action';
export let asset: AssetResponseDto; interface Props {
export let onAction: OnAction; asset: AssetResponseDto;
onAction: OnAction;
}
let { asset, onAction }: Props = $props();
const toggleFavorite = async () => { const toggleFavorite = async () => {
try { try {
@ -24,7 +28,8 @@
}, },
}); });
asset.isFavorite = data.isFavorite; asset = { ...asset, isFavorite: data.isFavorite };
onAction({ type: asset.isFavorite ? AssetAction.FAVORITE : AssetAction.UNFAVORITE, asset }); onAction({ type: asset.isFavorite ? AssetAction.FAVORITE : AssetAction.UNFAVORITE, asset });
notificationController.show({ notificationController.show({
@ -43,5 +48,5 @@
color="opaque" color="opaque"
icon={asset.isFavorite ? mdiHeart : mdiHeartOutline} icon={asset.isFavorite ? mdiHeart : mdiHeartOutline}
title={asset.isFavorite ? $t('unfavorite') : $t('to_favorite')} title={asset.isFavorite ? $t('unfavorite') : $t('to_favorite')}
on:click={toggleFavorite} onclick={toggleFavorite}
/> />

View file

@ -3,13 +3,17 @@
import { mdiMotionPauseOutline, mdiPlaySpeed } from '@mdi/js'; import { mdiMotionPauseOutline, mdiPlaySpeed } from '@mdi/js';
import { t } from 'svelte-i18n'; import { t } from 'svelte-i18n';
export let isPlaying: boolean; interface Props {
export let onClick: (shouldPlay: boolean) => void; isPlaying: boolean;
onClick: (shouldPlay: boolean) => void;
}
let { isPlaying, onClick }: Props = $props();
</script> </script>
<CircleIconButton <CircleIconButton
color="opaque" color="opaque"
icon={isPlaying ? mdiMotionPauseOutline : mdiPlaySpeed} icon={isPlaying ? mdiMotionPauseOutline : mdiPlaySpeed}
title={isPlaying ? $t('stop_motion_photo') : $t('play_motion_photo')} title={isPlaying ? $t('stop_motion_photo') : $t('play_motion_photo')}
on:click={() => onClick(!isPlaying)} onclick={() => onClick(!isPlaying)}
/> />

View file

@ -5,7 +5,11 @@
import { t } from 'svelte-i18n'; import { t } from 'svelte-i18n';
import NavigationArea from '../navigation-area.svelte'; import NavigationArea from '../navigation-area.svelte';
export let onNextAsset: () => void; interface Props {
onNextAsset: () => void;
}
let { onNextAsset }: Props = $props();
</script> </script>
<svelte:window <svelte:window

View file

@ -5,7 +5,11 @@
import { t } from 'svelte-i18n'; import { t } from 'svelte-i18n';
import NavigationArea from '../navigation-area.svelte'; import NavigationArea from '../navigation-area.svelte';
export let onPreviousAsset: () => void; interface Props {
onPreviousAsset: () => void;
}
let { onPreviousAsset }: Props = $props();
</script> </script>
<svelte:window <svelte:window

View file

@ -11,8 +11,12 @@
import { t } from 'svelte-i18n'; import { t } from 'svelte-i18n';
import type { OnAction } from './action'; import type { OnAction } from './action';
export let asset: AssetResponseDto; interface Props {
export let onAction: OnAction; asset: AssetResponseDto;
onAction: OnAction;
}
let { asset = $bindable(), onAction }: Props = $props();
const handleRestoreAsset = async () => { const handleRestoreAsset = async () => {
try { try {

View file

@ -9,8 +9,12 @@
import { mdiImageOutline } from '@mdi/js'; import { mdiImageOutline } from '@mdi/js';
import { t } from 'svelte-i18n'; import { t } from 'svelte-i18n';
export let asset: AssetResponseDto; interface Props {
export let album: AlbumResponseDto; asset: AssetResponseDto;
album: AlbumResponseDto;
}
let { asset, album }: Props = $props();
const handleUpdateThumbnail = async () => { const handleUpdateThumbnail = async () => {
try { try {

View file

@ -6,9 +6,13 @@
import { mdiAccountCircleOutline } from '@mdi/js'; import { mdiAccountCircleOutline } from '@mdi/js';
import { t } from 'svelte-i18n'; import { t } from 'svelte-i18n';
export let asset: AssetResponseDto; interface Props {
asset: AssetResponseDto;
}
let showProfileImageCrop = false; let { asset }: Props = $props();
let showProfileImageCrop = $state(false);
</script> </script>
<MenuOption <MenuOption

View file

@ -6,17 +6,16 @@
import { mdiShareVariantOutline } from '@mdi/js'; import { mdiShareVariantOutline } from '@mdi/js';
import { t } from 'svelte-i18n'; import { t } from 'svelte-i18n';
export let asset: AssetResponseDto; interface Props {
asset: AssetResponseDto;
}
let showModal = false; let { asset }: Props = $props();
let showModal = $state(false);
</script> </script>
<CircleIconButton <CircleIconButton color="opaque" icon={mdiShareVariantOutline} onclick={() => (showModal = true)} title={$t('share')} />
color="opaque"
icon={mdiShareVariantOutline}
on:click={() => (showModal = true)}
title={$t('share')}
/>
{#if showModal} {#if showModal}
<Portal target="body"> <Portal target="body">

View file

@ -4,9 +4,13 @@
import { mdiInformationOutline } from '@mdi/js'; import { mdiInformationOutline } from '@mdi/js';
import { t } from 'svelte-i18n'; import { t } from 'svelte-i18n';
export let onShowDetail: () => void; interface Props {
onShowDetail: () => void;
}
let { onShowDetail }: Props = $props();
</script> </script>
<svelte:window use:shortcut={{ shortcut: { key: 'i' }, onShortcut: onShowDetail }} /> <svelte:window use:shortcut={{ shortcut: { key: 'i' }, onShortcut: onShowDetail }} />
<CircleIconButton color="opaque" icon={mdiInformationOutline} on:click={onShowDetail} title={$t('info')} /> <CircleIconButton color="opaque" icon={mdiInformationOutline} onclick={onShowDetail} title={$t('info')} />

View file

@ -7,8 +7,12 @@
import { t } from 'svelte-i18n'; import { t } from 'svelte-i18n';
import type { OnAction } from './action'; import type { OnAction } from './action';
export let stack: StackResponseDto; interface Props {
export let onAction: OnAction; stack: StackResponseDto;
onAction: OnAction;
}
let { stack, onAction }: Props = $props();
const handleUnstack = async () => { const handleUnstack = async () => {
const unstackedAssets = await deleteStack([stack.id]); const unstackedAssets = await deleteStack([stack.id]);

View file

@ -4,20 +4,24 @@
import { mdiCommentOutline, mdiHeart, mdiHeartOutline } from '@mdi/js'; import { mdiCommentOutline, mdiHeart, mdiHeartOutline } from '@mdi/js';
import Icon from '../elements/icon.svelte'; import Icon from '../elements/icon.svelte';
export let isLiked: ActivityResponseDto | null; interface Props {
export let numberOfComments: number | undefined; isLiked: ActivityResponseDto | null;
export let disabled: boolean; numberOfComments: number | undefined;
export let onOpenActivityTab: () => void; disabled: boolean;
export let onFavorite: () => void; onOpenActivityTab: () => void;
onFavorite: () => void;
}
let { isLiked, numberOfComments, disabled, onOpenActivityTab, onFavorite }: Props = $props();
</script> </script>
<div class="w-full flex p-4 text-white items-center justify-center rounded-full gap-5 bg-immich-dark-bg bg-opacity-60"> <div class="w-full flex p-4 text-white items-center justify-center rounded-full gap-5 bg-immich-dark-bg bg-opacity-60">
<button type="button" class={disabled ? 'cursor-not-allowed' : ''} on:click={onFavorite} {disabled}> <button type="button" class={disabled ? 'cursor-not-allowed' : ''} onclick={onFavorite} {disabled}>
<div class="items-center justify-center"> <div class="items-center justify-center">
<Icon path={isLiked ? mdiHeart : mdiHeartOutline} size={24} /> <Icon path={isLiked ? mdiHeart : mdiHeartOutline} size={24} />
</div> </div>
</button> </button>
<button type="button" on:click={onOpenActivityTab}> <button type="button" onclick={onOpenActivityTab}>
<div class="flex gap-2 items-center justify-center"> <div class="flex gap-2 items-center justify-center">
<Icon path={mdiCommentOutline} class="scale-x-[-1]" size={24} /> <Icon path={mdiCommentOutline} class="scale-x-[-1]" size={24} />
{#if numberOfComments} {#if numberOfComments}

View file

@ -47,40 +47,45 @@
return relativeFormatter.format(Math.trunc(diff.as(unit)), unit); return relativeFormatter.format(Math.trunc(diff.as(unit)), unit);
}; };
export let reactions: ActivityResponseDto[]; interface Props {
export let user: UserResponseDto; reactions: ActivityResponseDto[];
export let assetId: string | undefined = undefined; user: UserResponseDto;
export let albumId: string; assetId?: string | undefined;
export let assetType: AssetTypeEnum | undefined = undefined; albumId: string;
export let albumOwnerId: string; assetType?: AssetTypeEnum | undefined;
export let disabled: boolean; albumOwnerId: string;
export let isLiked: ActivityResponseDto | null; disabled: boolean;
export let onDeleteComment: () => void; isLiked: ActivityResponseDto | null;
export let onDeleteLike: () => void; onDeleteComment: () => void;
export let onAddComment: () => void; onDeleteLike: () => void;
export let onClose: () => void; onAddComment: () => void;
onClose: () => void;
let textArea: HTMLTextAreaElement;
let innerHeight: number;
let activityHeight: number;
let chatHeight: number;
let divHeight: number;
let previousAssetId: string | undefined = assetId;
let message = '';
let isSendingMessage = false;
$: {
if (innerHeight && activityHeight) {
divHeight = innerHeight - activityHeight;
}
} }
$: { let {
if (assetId && previousAssetId != assetId) { reactions = $bindable(),
handlePromiseError(getReactions()); user,
previousAssetId = assetId; assetId = undefined,
} albumId,
} assetType = undefined,
albumOwnerId,
disabled,
isLiked,
onDeleteComment,
onDeleteLike,
onAddComment,
onClose,
}: Props = $props();
let textArea: HTMLTextAreaElement | undefined = $state();
let innerHeight: number = $state(0);
let activityHeight: number = $state(0);
let chatHeight: number = $state(0);
let divHeight: number = $state(0);
let previousAssetId: string | undefined = $state(assetId);
let message = $state('');
let isSendingMessage = $state(false);
onMount(async () => { onMount(async () => {
await getReactions(); await getReactions();
}); });
@ -136,7 +141,11 @@
activityCreateDto: { albumId, assetId, type: ReactionType.Comment, comment: message }, activityCreateDto: { albumId, assetId, type: ReactionType.Comment, comment: message },
}); });
reactions.push(data); reactions.push(data);
textArea.style.height = '18px';
if (textArea) {
textArea.style.height = '18px';
}
message = ''; message = '';
onAddComment(); onAddComment();
// Re-render the activity feed // Re-render the activity feed
@ -148,6 +157,22 @@
} }
isSendingMessage = false; isSendingMessage = false;
}; };
$effect(() => {
if (innerHeight && activityHeight) {
divHeight = innerHeight - activityHeight;
}
});
$effect(() => {
if (assetId && previousAssetId != assetId) {
handlePromiseError(getReactions());
previousAssetId = assetId;
}
});
const onsubmit = async (event: Event) => {
event.preventDefault();
await handleSendComment();
};
</script> </script>
<div class="overflow-y-hidden relative h-full" bind:offsetHeight={innerHeight}> <div class="overflow-y-hidden relative h-full" bind:offsetHeight={innerHeight}>
@ -157,7 +182,7 @@
bind:clientHeight={activityHeight} bind:clientHeight={activityHeight}
> >
<div class="flex place-items-center gap-2"> <div class="flex place-items-center gap-2">
<CircleIconButton on:click={onClose} icon={mdiClose} title={$t('close')} /> <CircleIconButton onclick={onClose} icon={mdiClose} title={$t('close')} />
<p class="text-lg text-immich-fg dark:text-immich-dark-fg">{$t('activity')}</p> <p class="text-lg text-immich-fg dark:text-immich-dark-fg">{$t('activity')}</p>
</div> </div>
@ -277,7 +302,7 @@
<div> <div>
<UserAvatar {user} size="md" showTitle={false} /> <UserAvatar {user} size="md" showTitle={false} />
</div> </div>
<form class="flex w-full max-h-56 gap-1" on:submit|preventDefault={() => handleSendComment()}> <form class="flex w-full max-h-56 gap-1" {onsubmit}>
<div class="flex w-full items-center gap-4"> <div class="flex w-full items-center gap-4">
<textarea <textarea
{disabled} {disabled}
@ -285,7 +310,7 @@
bind:value={message} bind:value={message}
use:autoGrowHeight={'5px'} use:autoGrowHeight={'5px'}
placeholder={disabled ? $t('comments_are_disabled') : $t('say_something')} placeholder={disabled ? $t('comments_are_disabled') : $t('say_something')}
on:input={() => autoGrowHeight(textArea, '5px')} oninput={() => autoGrowHeight(textArea, '5px')}
use:shortcut={{ use:shortcut={{
shortcut: { key: 'Enter' }, shortcut: { key: 'Enter' },
onShortcut: () => handleSendComment(), onShortcut: () => handleSendComment(),
@ -308,7 +333,7 @@
size="15" size="15"
icon={mdiSend} icon={mdiSend}
class="dark:text-immich-dark-gray" class="dark:text-immich-dark-gray"
on:click={() => handleSendComment()} onclick={() => handleSendComment()}
/> />
</div> </div>
{/if} {/if}

View file

@ -2,7 +2,11 @@
import type { AlbumResponseDto } from '@immich/sdk'; import type { AlbumResponseDto } from '@immich/sdk';
import { t } from 'svelte-i18n'; import { t } from 'svelte-i18n';
export let album: AlbumResponseDto; interface Props {
album: AlbumResponseDto;
}
let { album }: Props = $props();
</script> </script>
<span>{$t('items_count', { values: { count: album.assetCount } })}</span> <span>{$t('items_count', { values: { count: album.assetCount } })}</span>

View file

@ -4,15 +4,19 @@
import { normalizeSearchString } from '$lib/utils/string-utils.js'; import { normalizeSearchString } from '$lib/utils/string-utils.js';
import AlbumListItemDetails from './album-list-item-details.svelte'; import AlbumListItemDetails from './album-list-item-details.svelte';
export let album: AlbumResponseDto; interface Props {
export let searchQuery = ''; album: AlbumResponseDto;
export let onAlbumClick: () => void; searchQuery?: string;
onAlbumClick: () => void;
}
let albumNameArray: string[] = ['', '', '']; let { album, searchQuery = '', onAlbumClick }: Props = $props();
let albumNameArray: string[] = $state(['', '', '']);
// This part of the code is responsible for splitting album name into 3 parts where part 2 is the search query // This part of the code is responsible for splitting album name into 3 parts where part 2 is the search query
// It is used to highlight the search query in the album name // It is used to highlight the search query in the album name
$: { $effect(() => {
let { albumName } = album; let { albumName } = album;
let findIndex = normalizeSearchString(albumName).indexOf(normalizeSearchString(searchQuery)); let findIndex = normalizeSearchString(albumName).indexOf(normalizeSearchString(searchQuery));
let findLength = searchQuery.length; let findLength = searchQuery.length;
@ -21,12 +25,12 @@
albumName.slice(findIndex, findIndex + findLength), albumName.slice(findIndex, findIndex + findLength),
albumName.slice(findIndex + findLength), albumName.slice(findIndex + findLength),
]; ];
} });
</script> </script>
<button <button
type="button" type="button"
on:click={onAlbumClick} onclick={onAlbumClick}
class="flex w-full gap-4 px-6 py-2 text-left transition-colors hover:bg-gray-200 dark:hover:bg-gray-700 rounded-xl" class="flex w-full gap-4 px-6 py-2 text-left transition-colors hover:bg-gray-200 dark:hover:bg-gray-700 rounded-xl"
> >
<span class="h-12 w-12 shrink-0 rounded-xl bg-slate-300"> <span class="h-12 w-12 shrink-0 rounded-xl bg-slate-300">

View file

@ -44,25 +44,44 @@
} from '@mdi/js'; } from '@mdi/js';
import { canCopyImageToClipboard } from '$lib/utils/asset-utils'; import { canCopyImageToClipboard } from '$lib/utils/asset-utils';
import { t } from 'svelte-i18n'; import { t } from 'svelte-i18n';
import type { Snippet } from 'svelte';
export let asset: AssetResponseDto; interface Props {
export let album: AlbumResponseDto | null = null; asset: AssetResponseDto;
export let stack: StackResponseDto | null = null; album?: AlbumResponseDto | null;
export let showDetailButton: boolean; stack?: StackResponseDto | null;
export let showSlideshow = false; showDetailButton: boolean;
export let onZoomImage: () => void; showSlideshow?: boolean;
export let onCopyImage: () => void; onZoomImage: () => void;
export let onAction: OnAction; onCopyImage?: () => Promise<void>;
export let onRunJob: (name: AssetJobName) => void; onAction: OnAction;
export let onPlaySlideshow: () => void; onRunJob: (name: AssetJobName) => void;
export let onShowDetail: () => void; onPlaySlideshow: () => void;
// export let showEditorHandler: () => void; onShowDetail: () => void;
export let onClose: () => void; // export let showEditorHandler: () => void;
onClose: () => void;
motionPhoto?: Snippet;
}
let {
asset,
album = null,
stack = null,
showDetailButton,
showSlideshow = false,
onZoomImage,
onCopyImage,
onAction,
onRunJob,
onPlaySlideshow,
onShowDetail,
onClose,
motionPhoto,
}: Props = $props();
const sharedLink = getSharedLink(); const sharedLink = getSharedLink();
$: isOwner = $user && asset.ownerId === $user?.id; let isOwner = $derived($user && asset.ownerId === $user?.id);
// svelte-ignore reactive_declaration_non_reactive_property let showDownloadButton = $derived(sharedLink ? sharedLink.allowDownload : !asset.isOffline);
$: showDownloadButton = sharedLink ? sharedLink.allowDownload : !asset.isOffline;
// $: showEditorButton = // $: showEditorButton =
// isOwner && // isOwner &&
// asset.type === AssetTypeEnum.Image && // asset.type === AssetTypeEnum.Image &&
@ -88,10 +107,10 @@
<ShareAction {asset} /> <ShareAction {asset} />
{/if} {/if}
{#if asset.isOffline} {#if asset.isOffline}
<CircleIconButton color="alert" icon={mdiAlertOutline} on:click={onShowDetail} title={$t('asset_offline')} /> <CircleIconButton color="alert" icon={mdiAlertOutline} onclick={onShowDetail} title={$t('asset_offline')} />
{/if} {/if}
{#if asset.livePhotoVideoId} {#if asset.livePhotoVideoId}
<slot name="motion-photo" /> {@render motionPhoto?.()}
{/if} {/if}
{#if asset.type === AssetTypeEnum.Image} {#if asset.type === AssetTypeEnum.Image}
<CircleIconButton <CircleIconButton
@ -99,11 +118,11 @@
hideMobile={true} hideMobile={true}
icon={$photoZoomState && $photoZoomState.currentZoom > 1 ? mdiMagnifyMinusOutline : mdiMagnifyPlusOutline} icon={$photoZoomState && $photoZoomState.currentZoom > 1 ? mdiMagnifyMinusOutline : mdiMagnifyPlusOutline}
title={$t('zoom_image')} title={$t('zoom_image')}
on:click={onZoomImage} onclick={onZoomImage}
/> />
{/if} {/if}
{#if canCopyImageToClipboard() && asset.type === AssetTypeEnum.Image} {#if canCopyImageToClipboard() && asset.type === AssetTypeEnum.Image}
<CircleIconButton color="opaque" icon={mdiContentCopy} title={$t('copy_image')} on:click={onCopyImage} /> <CircleIconButton color="opaque" icon={mdiContentCopy} title={$t('copy_image')} onclick={() => onCopyImage?.()} />
{/if} {/if}
{#if !isOwner && showDownloadButton} {#if !isOwner && showDownloadButton}
@ -122,7 +141,7 @@
color="opaque" color="opaque"
hideMobile={true} hideMobile={true}
icon={mdiImageEditOutline} icon={mdiImageEditOutline}
on:click={showEditorHandler} onclick={showEditorHandler}
title={$t('editor')} title={$t('editor')}
/> />
{/if} --> {/if} -->

View file

@ -48,18 +48,37 @@
import SlideshowBar from './slideshow-bar.svelte'; import SlideshowBar from './slideshow-bar.svelte';
import VideoViewer from './video-wrapper-viewer.svelte'; import VideoViewer from './video-wrapper-viewer.svelte';
export let assetStore: AssetStore | null = null; interface Props {
export let asset: AssetResponseDto; assetStore?: AssetStore | null;
export let preloadAssets: AssetResponseDto[] = []; asset: AssetResponseDto;
export let showNavigation = true; preloadAssets?: AssetResponseDto[];
export let withStacked = false; showNavigation?: boolean;
export let isShared = false; withStacked?: boolean;
export let album: AlbumResponseDto | null = null; isShared?: boolean;
export let onAction: OnAction | undefined = undefined; album?: AlbumResponseDto | null;
export let reactions: ActivityResponseDto[] = []; onAction?: OnAction | undefined;
export let onClose: (dto: { asset: AssetResponseDto }) => void; reactions?: ActivityResponseDto[];
export let onNext: () => void; onClose: (dto: { asset: AssetResponseDto }) => void;
export let onPrevious: () => void; onNext: () => void;
onPrevious: () => void;
copyImage?: () => Promise<void>;
}
let {
assetStore = null,
asset = $bindable(),
preloadAssets = $bindable([]),
showNavigation = true,
withStacked = false,
isShared = false,
album = null,
onAction = undefined,
reactions = $bindable([]),
onClose,
onNext,
onPrevious,
copyImage = $bindable(),
}: Props = $props();
const { setAsset } = assetViewingStore; const { setAsset } = assetViewingStore;
const { const {
@ -70,26 +89,23 @@
slideshowTransition, slideshowTransition,
} = slideshowStore; } = slideshowStore;
let appearsInAlbums: AlbumResponseDto[] = []; let appearsInAlbums: AlbumResponseDto[] = $state([]);
let shouldPlayMotionPhoto = false; let shouldPlayMotionPhoto = $state(false);
let sharedLink = getSharedLink(); let sharedLink = getSharedLink();
let enableDetailPanel = asset.hasMetadata; let enableDetailPanel = asset.hasMetadata;
let slideshowStateUnsubscribe: () => void; let slideshowStateUnsubscribe: () => void;
let shuffleSlideshowUnsubscribe: () => void; let shuffleSlideshowUnsubscribe: () => void;
let previewStackedAsset: AssetResponseDto | undefined; let previewStackedAsset: AssetResponseDto | undefined = $state();
let isShowActivity = false; let isShowActivity = $state(false);
let isShowEditor = false; let isShowEditor = $state(false);
let isLiked: ActivityResponseDto | null = null; let isLiked: ActivityResponseDto | null = $state(null);
let numberOfComments: number; let numberOfComments = $state(0);
let fullscreenElement: Element; let fullscreenElement = $state<Element>();
let unsubscribes: (() => void)[] = []; let unsubscribes: (() => void)[] = [];
let selectedEditType: string = ''; let selectedEditType: string = $state('');
let stack: StackResponseDto | null = null; let stack: StackResponseDto | null = $state(null);
let zoomToggle = () => void 0; let zoomToggle = $state(() => void 0);
let copyImage: () => Promise<void>;
$: isFullScreen = fullscreenElement !== null;
const refreshStack = async () => { const refreshStack = async () => {
if (isSharedLink()) { if (isSharedLink()) {
@ -109,16 +125,6 @@
} }
}; };
$: if (asset) {
handlePromiseError(refreshStack());
}
$: {
if (album && !album.isActivityEnabled && numberOfComments === 0) {
isShowActivity = false;
}
}
const handleAddComment = () => { const handleAddComment = () => {
numberOfComments++; numberOfComments++;
updateNumberOfComments(1); updateNumberOfComments(1);
@ -184,13 +190,6 @@
} }
}; };
$: {
if (isShared && asset.id) {
handlePromiseError(getFavorite());
handlePromiseError(getNumberOfComments());
}
}
onMount(async () => { onMount(async () => {
unsubscribes.push( unsubscribes.push(
websocketEvents.on('on_upload_success', onAssetUpdate), websocketEvents.on('on_upload_success', onAssetUpdate),
@ -233,12 +232,6 @@
} }
}); });
$: {
if (asset.id && !sharedLink) {
handlePromiseError(handleGetAllAlbums());
}
}
const handleGetAllAlbums = async () => { const handleGetAllAlbums = async () => {
if (isSharedLink()) { if (isSharedLink()) {
return; return;
@ -337,7 +330,7 @@
* Slide show mode * Slide show mode
*/ */
let assetViewerHtmlElement: HTMLElement; let assetViewerHtmlElement = $state<HTMLElement>();
const slideshowHistory = new SlideshowHistory((asset) => { const slideshowHistory = new SlideshowHistory((asset) => {
setAsset(asset); setAsset(asset);
@ -352,7 +345,7 @@
const handlePlaySlideshow = async () => { const handlePlaySlideshow = async () => {
try { try {
await assetViewerHtmlElement.requestFullscreen?.(); await assetViewerHtmlElement?.requestFullscreen?.();
} catch (error) { } catch (error) {
handleError(error, $t('errors.unable_to_enter_fullscreen')); handleError(error, $t('errors.unable_to_enter_fullscreen'));
$slideshowState = SlideshowState.StopSlideshow; $slideshowState = SlideshowState.StopSlideshow;
@ -395,6 +388,28 @@
const handleUpdateSelectedEditType = (type: string) => { const handleUpdateSelectedEditType = (type: string) => {
selectedEditType = type; selectedEditType = type;
}; };
let isFullScreen = $derived(fullscreenElement !== null);
$effect(() => {
if (asset) {
handlePromiseError(refreshStack());
}
});
$effect(() => {
if (album && !album.isActivityEnabled && numberOfComments === 0) {
isShowActivity = false;
}
});
$effect(() => {
if (isShared && asset.id) {
handlePromiseError(getFavorite());
handlePromiseError(getNumberOfComments());
}
});
$effect(() => {
if (asset.id && !sharedLink) {
handlePromiseError(handleGetAllAlbums());
}
});
</script> </script>
<svelte:document bind:fullscreenElement /> <svelte:document bind:fullscreenElement />
@ -421,11 +436,12 @@
onShowDetail={toggleDetailPanel} onShowDetail={toggleDetailPanel}
onClose={closeViewer} onClose={closeViewer}
> >
<MotionPhotoAction {#snippet motionPhoto()}
slot="motion-photo" <MotionPhotoAction
isPlaying={shouldPlayMotionPhoto} isPlaying={shouldPlayMotionPhoto}
onClick={(shouldPlay) => (shouldPlayMotionPhoto = shouldPlay)} onClick={(shouldPlay) => (shouldPlayMotionPhoto = shouldPlay)}
/> />
{/snippet}
</AssetViewerNavBar> </AssetViewerNavBar>
</div> </div>
{/if} {/if}
@ -442,7 +458,7 @@
<div class="z-[1000] absolute w-full flex"> <div class="z-[1000] absolute w-full flex">
<SlideshowBar <SlideshowBar
{isFullScreen} {isFullScreen}
onSetToFullScreen={() => assetViewerHtmlElement.requestFullscreen?.()} onSetToFullScreen={() => assetViewerHtmlElement?.requestFullscreen?.()}
onPrevious={() => navigateAsset('previous')} onPrevious={() => navigateAsset('previous')}
onNext={() => navigateAsset('next')} onNext={() => navigateAsset('next')}
onClose={() => ($slideshowState = SlideshowState.StopSlideshow)} onClose={() => ($slideshowState = SlideshowState.StopSlideshow)}
@ -460,7 +476,7 @@
{preloadAssets} {preloadAssets}
onPreviousAsset={() => navigateAsset('previous')} onPreviousAsset={() => navigateAsset('previous')}
onNextAsset={() => navigateAsset('next')} onNextAsset={() => navigateAsset('next')}
on:close={closeViewer} onClose={closeViewer}
haveFadeTransition={false} haveFadeTransition={false}
{sharedLink} {sharedLink}
/> />
@ -472,9 +488,9 @@
loopVideo={true} loopVideo={true}
onPreviousAsset={() => navigateAsset('previous')} onPreviousAsset={() => navigateAsset('previous')}
onNextAsset={() => navigateAsset('next')} onNextAsset={() => navigateAsset('next')}
on:close={closeViewer} onClose={closeViewer}
on:onVideoEnded={() => navigateAsset()} onVideoEnded={() => navigateAsset()}
on:onVideoStarted={handleVideoStarted} onVideoStarted={handleVideoStarted}
/> />
{/if} {/if}
{/key} {/key}
@ -489,8 +505,7 @@
loopVideo={$slideshowState !== SlideshowState.PlaySlideshow} loopVideo={$slideshowState !== SlideshowState.PlaySlideshow}
onPreviousAsset={() => navigateAsset('previous')} onPreviousAsset={() => navigateAsset('previous')}
onNextAsset={() => navigateAsset('next')} onNextAsset={() => navigateAsset('next')}
on:close={closeViewer} onVideoEnded={() => (shouldPlayMotionPhoto = false)}
on:onVideoEnded={() => (shouldPlayMotionPhoto = false)}
/> />
{:else if asset.exifInfo?.projectionType === ProjectionType.EQUIRECTANGULAR || (asset.originalPath && asset.originalPath {:else if asset.exifInfo?.projectionType === ProjectionType.EQUIRECTANGULAR || (asset.originalPath && asset.originalPath
.toLowerCase() .toLowerCase()
@ -506,7 +521,7 @@
{preloadAssets} {preloadAssets}
onPreviousAsset={() => navigateAsset('previous')} onPreviousAsset={() => navigateAsset('previous')}
onNextAsset={() => navigateAsset('next')} onNextAsset={() => navigateAsset('next')}
on:close={closeViewer} onClose={closeViewer}
{sharedLink} {sharedLink}
haveFadeTransition={$slideshowState === SlideshowState.None || $slideshowTransition} haveFadeTransition={$slideshowState === SlideshowState.None || $slideshowTransition}
/> />
@ -519,9 +534,9 @@
loopVideo={$slideshowState !== SlideshowState.PlaySlideshow} loopVideo={$slideshowState !== SlideshowState.PlaySlideshow}
onPreviousAsset={() => navigateAsset('previous')} onPreviousAsset={() => navigateAsset('previous')}
onNextAsset={() => navigateAsset('next')} onNextAsset={() => navigateAsset('next')}
on:close={closeViewer} onClose={closeViewer}
on:onVideoEnded={() => navigateAsset()} onVideoEnded={() => navigateAsset()}
on:onVideoStarted={handleVideoStarted} onVideoStarted={handleVideoStarted}
/> />
{/if} {/if}
{#if $slideshowState === SlideshowState.None && isShared && ((album && album.isActivityEnabled) || numberOfComments > 0)} {#if $slideshowState === SlideshowState.None && isShared && ((album && album.isActivityEnabled) || numberOfComments > 0)}
@ -574,7 +589,7 @@
class="z-[1002] flex place-item-center place-content-center absolute bottom-0 w-full col-span-4 col-start-1 overflow-x-auto horizontal-scrollbar" class="z-[1002] flex place-item-center place-content-center absolute bottom-0 w-full col-span-4 col-start-1 overflow-x-auto horizontal-scrollbar"
> >
<div class="relative w-full whitespace-nowrap transition-all"> <div class="relative w-full whitespace-nowrap transition-all">
{#each stackedAssets as stackedAsset, index (stackedAsset.id)} {#each stackedAssets as stackedAsset (stackedAsset.id)}
<div <div
class="{stackedAsset.id == asset.id class="{stackedAsset.id == asset.id
? '-translate-y-[1px]' ? '-translate-y-[1px]'
@ -587,7 +602,6 @@
asset={stackedAsset} asset={stackedAsset}
onClick={(stackedAsset) => { onClick={(stackedAsset) => {
asset = stackedAsset; asset = stackedAsset;
preloadAssets = index + 1 >= stackedAssets.length ? [] : [stackedAssets[index + 1]];
}} }}
onMouseEvent={({ isMouseOver }) => handleStackedAssetMouseEvent(isMouseOver, stackedAsset)} onMouseEvent={({ isMouseOver }) => handleStackedAssetMouseEvent(isMouseOver, stackedAsset)}
disableMouseOver disableMouseOver

View file

@ -8,14 +8,21 @@
import AutogrowTextarea from '$lib/components/shared-components/autogrow-textarea.svelte'; import AutogrowTextarea from '$lib/components/shared-components/autogrow-textarea.svelte';
import { t } from 'svelte-i18n'; import { t } from 'svelte-i18n';
export let asset: AssetResponseDto; interface Props {
export let isOwner: boolean; asset: AssetResponseDto;
isOwner: boolean;
}
$: description = asset.exifInfo?.description || ''; let { asset, isOwner }: Props = $props();
let description = $derived(asset.exifInfo?.description || '');
const handleFocusOut = async (newDescription: string) => { const handleFocusOut = async (newDescription: string) => {
try { try {
await updateAsset({ id: asset.id, updateAssetDto: { description: newDescription } }); await updateAsset({ id: asset.id, updateAssetDto: { description: newDescription } });
asset.exifInfo = { ...asset.exifInfo, description: newDescription };
notificationController.show({ notificationController.show({
type: NotificationType.Info, type: NotificationType.Info,
message: $t('asset_description_updated'), message: $t('asset_description_updated'),
@ -23,7 +30,6 @@
} catch (error) { } catch (error) {
handleError(error, $t('cannot_update_the_description')); handleError(error, $t('cannot_update_the_description'));
} }
description = newDescription;
}; };
</script> </script>

View file

@ -7,10 +7,14 @@
import { mdiMapMarkerOutline, mdiPencil } from '@mdi/js'; import { mdiMapMarkerOutline, mdiPencil } from '@mdi/js';
import { t } from 'svelte-i18n'; import { t } from 'svelte-i18n';
export let isOwner: boolean; interface Props {
export let asset: AssetResponseDto; isOwner: boolean;
asset: AssetResponseDto;
}
let isShowChangeLocation = false; let { isOwner, asset = $bindable() }: Props = $props();
let isShowChangeLocation = $state(false);
async function handleConfirmChangeLocation(gps: { lng: number; lat: number }) { async function handleConfirmChangeLocation(gps: { lng: number; lat: number }) {
isShowChangeLocation = false; isShowChangeLocation = false;
@ -30,7 +34,7 @@
<button <button
type="button" type="button"
class="flex w-full text-left justify-between place-items-start gap-4 py-4" class="flex w-full text-left justify-between place-items-start gap-4 py-4"
on:click={() => (isOwner ? (isShowChangeLocation = true) : null)} onclick={() => (isOwner ? (isShowChangeLocation = true) : null)}
title={isOwner ? $t('edit_location') : ''} title={isOwner ? $t('edit_location') : ''}
class:hover:dark:text-immich-dark-primary={isOwner} class:hover:dark:text-immich-dark-primary={isOwner}
class:hover:text-immich-primary={isOwner} class:hover:text-immich-primary={isOwner}
@ -65,7 +69,7 @@
<button <button
type="button" type="button"
class="flex w-full text-left 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-left justify-between place-items-start gap-4 py-4 rounded-lg hover:dark:text-immich-dark-primary hover:text-immich-primary"
on:click={() => (isShowChangeLocation = true)} onclick={() => (isShowChangeLocation = true)}
title={$t('add_location')} title={$t('add_location')}
> >
<div class="flex gap-4"> <div class="flex gap-4">

View file

@ -6,10 +6,14 @@
import { handlePromiseError, isSharedLink } from '$lib/utils'; import { handlePromiseError, isSharedLink } from '$lib/utils';
import { preferences } from '$lib/stores/user.store'; import { preferences } from '$lib/stores/user.store';
export let asset: AssetResponseDto; interface Props {
export let isOwner: boolean; asset: AssetResponseDto;
isOwner: boolean;
}
$: rating = asset.exifInfo?.rating || 0; let { asset, isOwner }: Props = $props();
let rating = $derived(asset.exifInfo?.rating || 0);
const handleChangeRating = async (rating: number) => { const handleChangeRating = async (rating: number) => {
try { try {

View file

@ -9,12 +9,16 @@
import { mdiClose, mdiPlus } from '@mdi/js'; import { mdiClose, mdiPlus } from '@mdi/js';
import { t } from 'svelte-i18n'; import { t } from 'svelte-i18n';
export let asset: AssetResponseDto; interface Props {
export let isOwner: boolean; asset: AssetResponseDto;
isOwner: boolean;
}
$: tags = asset.tags || []; let { asset = $bindable(), isOwner }: Props = $props();
let isOpen = false; let tags = $derived(asset.tags || []);
let isOpen = $state(false);
const handleAdd = () => (isOpen = true); const handleAdd = () => (isOpen = true);
@ -58,7 +62,7 @@
type="button" type="button"
class="text-gray-100 dark:text-immich-dark-gray bg-immich-primary/95 dark:bg-immich-dark-primary/95 rounded-tr-full rounded-br-full place-items-center place-content-center pr-2 pl-1 py-1 hover:bg-immich-primary/80 dark:hover:bg-immich-dark-primary/80 transition-all" class="text-gray-100 dark:text-immich-dark-gray bg-immich-primary/95 dark:bg-immich-dark-primary/95 rounded-tr-full rounded-br-full place-items-center place-content-center pr-2 pl-1 py-1 hover:bg-immich-primary/80 dark:hover:bg-immich-dark-primary/80 transition-all"
title="Remove tag" title="Remove tag"
on:click={() => handleRemove(tag.id)} onclick={() => handleRemove(tag.id)}
> >
<Icon path={mdiClose} /> <Icon path={mdiClose} />
</button> </button>
@ -68,7 +72,7 @@
type="button" type="button"
class="rounded-full bg-gray-100 dark:bg-gray-800 text-gray-600 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-700 hover:text-gray-700 dark:hover:text-gray-200 flex place-items-center place-content-center gap-1 px-2 py-1" class="rounded-full bg-gray-100 dark:bg-gray-800 text-gray-600 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-700 hover:text-gray-700 dark:hover:text-gray-200 flex place-items-center place-content-center gap-1 px-2 py-1"
title="Add tag" title="Add tag"
on:click={handleAdd} onclick={handleAdd}
> >
<span class="text-sm px-1 flex place-items-center place-content-center gap-1"><Icon path={mdiPlus} />Add</span> <span class="text-sm px-1 flex place-items-center place-content-center gap-1"><Icon path={mdiPlus} />Add</span>
</button> </button>

View file

@ -46,10 +46,14 @@
import AlbumListItemDetails from './album-list-item-details.svelte'; import AlbumListItemDetails from './album-list-item-details.svelte';
import Portal from '$lib/components/shared-components/portal/portal.svelte'; import Portal from '$lib/components/shared-components/portal/portal.svelte';
export let asset: AssetResponseDto; interface Props {
export let albums: AlbumResponseDto[] = []; asset: AssetResponseDto;
export let currentAlbum: AlbumResponseDto | null = null; albums?: AlbumResponseDto[];
export let onClose: () => void; currentAlbum?: AlbumResponseDto | null;
onClose: () => void;
}
let { asset, albums = [], currentAlbum = null, onClose }: Props = $props();
const getDimensions = (exifInfo: ExifResponseDto) => { const getDimensions = (exifInfo: ExifResponseDto) => {
const { exifImageWidth: width, exifImageHeight: height } = exifInfo; const { exifImageWidth: width, exifImageHeight: height } = exifInfo;
@ -60,11 +64,11 @@
return { width, height }; return { width, height };
}; };
let showAssetPath = false; let showAssetPath = $state(false);
let showEditFaces = false; let showEditFaces = $state(false);
let previousId: string; let previousId: string | undefined = $state();
$: { $effect(() => {
if (!previousId) { if (!previousId) {
previousId = asset.id; previousId = asset.id;
} }
@ -72,9 +76,9 @@
showEditFaces = false; showEditFaces = false;
previousId = asset.id; previousId = asset.id;
} }
} });
$: isOwner = $user?.id === asset.ownerId; let isOwner = $derived($user?.id === asset.ownerId);
const handleNewAsset = async (newAsset: AssetResponseDto) => { const handleNewAsset = async (newAsset: AssetResponseDto) => {
// TODO: check if reloading asset data is necessary // TODO: check if reloading asset data is necessary
@ -85,27 +89,30 @@
} }
}; };
$: handlePromiseError(handleNewAsset(asset)); $effect(() => {
handlePromiseError(handleNewAsset(asset));
});
$: latlng = (() => { let latlng = $derived(
const lat = asset.exifInfo?.latitude; (() => {
const lng = asset.exifInfo?.longitude; const lat = asset.exifInfo?.latitude;
const lng = asset.exifInfo?.longitude;
if (lat && lng) { if (lat && lng) {
return { lat: Number(lat.toFixed(7)), lng: Number(lng.toFixed(7)) }; return { lat: Number(lat.toFixed(7)), lng: Number(lng.toFixed(7)) };
} }
})(); })(),
);
$: people = asset.people || []; let people = $state(asset.people || []);
$: showingHiddenPeople = false; let unassignedFaces = $state(asset.unassignedFaces || []);
let showingHiddenPeople = $state(false);
$: unassignedFaces = asset.unassignedFaces || []; let timeZone = $derived(asset.exifInfo?.timeZone);
let dateTime = $derived(
$: timeZone = asset.exifInfo?.timeZone;
$: dateTime =
timeZone && asset.exifInfo?.dateTimeOriginal timeZone && asset.exifInfo?.dateTimeOriginal
? fromDateTimeOriginal(asset.exifInfo.dateTimeOriginal, timeZone) ? fromDateTimeOriginal(asset.exifInfo.dateTimeOriginal, timeZone)
: fromLocalDateTime(asset.localDateTime); : fromLocalDateTime(asset.localDateTime),
);
const getMegapixel = (width: number, height: number): number | undefined => { const getMegapixel = (width: number, height: number): number | undefined => {
const megapixel = Math.round((height * width) / 1_000_000); const megapixel = Math.round((height * width) / 1_000_000);
@ -127,7 +134,7 @@
const toggleAssetPath = () => (showAssetPath = !showAssetPath); const toggleAssetPath = () => (showAssetPath = !showAssetPath);
let isShowChangeDate = false; let isShowChangeDate = $state(false);
async function handleConfirmChangeDate(dateTimeOriginal: string) { async function handleConfirmChangeDate(dateTimeOriginal: string) {
isShowChangeDate = false; isShowChangeDate = false;
@ -141,7 +148,7 @@
<section class="relative p-2 dark:bg-immich-dark-bg dark:text-immich-dark-fg"> <section class="relative p-2 dark:bg-immich-dark-bg dark:text-immich-dark-fg">
<div class="flex place-items-center gap-2"> <div class="flex place-items-center gap-2">
<CircleIconButton icon={mdiClose} title={$t('close')} on:click={onClose} /> <CircleIconButton icon={mdiClose} title={$t('close')} onclick={onClose} />
<p class="text-lg text-immich-fg dark:text-immich-dark-fg">{$t('info')}</p> <p class="text-lg text-immich-fg dark:text-immich-dark-fg">{$t('info')}</p>
</div> </div>
@ -190,7 +197,7 @@
icon={showingHiddenPeople ? mdiEyeOff : mdiEye} icon={showingHiddenPeople ? mdiEyeOff : mdiEye}
padding="1" padding="1"
buttonSize="32" buttonSize="32"
on:click={() => (showingHiddenPeople = !showingHiddenPeople)} onclick={() => (showingHiddenPeople = !showingHiddenPeople)}
/> />
{/if} {/if}
<CircleIconButton <CircleIconButton
@ -199,7 +206,7 @@
padding="1" padding="1"
size="20" size="20"
buttonSize="32" buttonSize="32"
on:click={() => (showEditFaces = true)} onclick={() => (showEditFaces = true)}
/> />
</div> </div>
</div> </div>
@ -212,10 +219,10 @@
href="{AppRoute.PEOPLE}/{person.id}?{QueryParameter.PREVIOUS_ROUTE}={currentAlbum?.id href="{AppRoute.PEOPLE}/{person.id}?{QueryParameter.PREVIOUS_ROUTE}={currentAlbum?.id
? `${AppRoute.ALBUMS}/${currentAlbum?.id}` ? `${AppRoute.ALBUMS}/${currentAlbum?.id}`
: AppRoute.PHOTOS}" : AppRoute.PHOTOS}"
on:focus={() => ($boundingBoxesArray = people[index].faces)} onfocus={() => ($boundingBoxesArray = people[index].faces)}
on:blur={() => ($boundingBoxesArray = [])} onblur={() => ($boundingBoxesArray = [])}
on:mouseover={() => ($boundingBoxesArray = people[index].faces)} onmouseover={() => ($boundingBoxesArray = people[index].faces)}
on:mouseleave={() => ($boundingBoxesArray = [])} onmouseleave={() => ($boundingBoxesArray = [])}
> >
<div class="relative"> <div class="relative">
<ImageThumbnail <ImageThumbnail
@ -278,7 +285,7 @@
<button <button
type="button" type="button"
class="flex w-full text-left justify-between place-items-start gap-4 py-4" class="flex w-full text-left justify-between place-items-start gap-4 py-4"
on:click={() => (isOwner ? (isShowChangeDate = true) : null)} onclick={() => (isOwner ? (isShowChangeDate = true) : null)}
title={isOwner ? $t('edit_date') : ''} title={isOwner ? $t('edit_date') : ''}
class:hover:dark:text-immich-dark-primary={isOwner} class:hover:dark:text-immich-dark-primary={isOwner}
class:hover:text-immich-primary={isOwner} class:hover:text-immich-primary={isOwner}
@ -357,7 +364,7 @@
title={$t('show_file_location')} title={$t('show_file_location')}
size="16" size="16"
padding="2" padding="2"
on:click={toggleAssetPath} onclick={toggleAssetPath}
/> />
{/if} {/if}
</p> </p>
@ -428,8 +435,7 @@
</div> </div>
{/await} {/await}
{:then component} {:then component}
<svelte:component <component.default
this={component.default}
mapMarkers={[ mapMarkers={[
{ {
lat: latlng.lat, lat: latlng.lat,
@ -446,7 +452,7 @@
useLocationPin useLocationPin
onOpenInMapView={() => goto(`${AppRoute.MAP}#12.5/${latlng.lat}/${latlng.lng}`)} onOpenInMapView={() => goto(`${AppRoute.MAP}#12.5/${latlng.lat}/${latlng.lng}`)}
> >
<svelte:fragment slot="popup" let:marker> {#snippet popup({ marker })}
{@const { lat, lon } = marker} {@const { lat, lon } = marker}
<div class="flex flex-col items-center gap-1"> <div class="flex flex-col items-center gap-1">
<p class="font-bold">{lat.toPrecision(6)}, {lon.toPrecision(6)}</p> <p class="font-bold">{lat.toPrecision(6)}, {lon.toPrecision(6)}</p>
@ -458,8 +464,8 @@
{$t('open_in_openstreetmap')} {$t('open_in_openstreetmap')}
</a> </a>
</div> </div>
</svelte:fragment> {/snippet}
</svelte:component> </component.default>
{/await} {/await}
</div> </div>
{/if} {/if}

View file

@ -44,7 +44,7 @@
<div class="absolute right-2"> <div class="absolute right-2">
<CircleIconButton <CircleIconButton
title={$t('close')} title={$t('close')}
on:click={() => abort(downloadKey, download)} onclick={() => abort(downloadKey, download)}
size="20" size="20"
icon={mdiClose} icon={mdiClose}
class="dark:text-immich-dark-gray" class="dark:text-immich-dark-gray"

View file

@ -1,5 +1,5 @@
<script lang="ts"> <script lang="ts">
import { onMount, afterUpdate, onDestroy, tick } from 'svelte'; import { onMount, onDestroy, tick } from 'svelte';
import { t } from 'svelte-i18n'; import { t } from 'svelte-i18n';
import { getAssetOriginalUrl } from '$lib/utils'; import { getAssetOriginalUrl } from '$lib/utils';
import { handleError } from '$lib/utils/handle-error'; import { handleError } from '$lib/utils/handle-error';
@ -17,11 +17,23 @@
resetGlobalCropStore, resetGlobalCropStore,
rotateDegrees, rotateDegrees,
} from '$lib/stores/asset-editor.store'; } from '$lib/stores/asset-editor.store';
import type { AssetResponseDto } from '@immich/sdk';
export let asset; interface Props {
let img: HTMLImageElement; asset: AssetResponseDto;
}
$: imgElement.set(img); let { asset }: Props = $props();
let img = $state<HTMLImageElement>();
$effect(() => {
if (!img) {
return;
}
imgElement.set(img);
});
cropAspectRatio.subscribe((value) => { cropAspectRatio.subscribe((value) => {
if (!img || !$cropAreaEl) { if (!img || !$cropAreaEl) {
@ -54,7 +66,7 @@
resetGlobalCropStore(); resetGlobalCropStore();
}); });
afterUpdate(() => { $effect(() => {
resizeCanvas(); resizeCanvas();
}); });
</script> </script>
@ -64,8 +76,8 @@
class={`crop-area ${$changedOriention ? 'changedOriention' : ''}`} class={`crop-area ${$changedOriention ? 'changedOriention' : ''}`}
style={`rotate:${$rotateDegrees}deg`} style={`rotate:${$rotateDegrees}deg`}
bind:this={$cropAreaEl} bind:this={$cropAreaEl}
on:mousedown={handleMouseDown} onmousedown={handleMouseDown}
on:mouseup={handleMouseUp} onmouseup={handleMouseUp}
aria-label="Crop area" aria-label="Crop area"
type="button" type="button"
> >

View file

@ -3,37 +3,41 @@
import Icon from '$lib/components/elements/icon.svelte'; import Icon from '$lib/components/elements/icon.svelte';
import type { CropAspectRatio } from '$lib/stores/asset-editor.store'; import type { CropAspectRatio } from '$lib/stores/asset-editor.store';
export let size: { interface Props {
icon: string; size: {
name: CropAspectRatio; icon: string;
viewBox: string; name: CropAspectRatio;
rotate?: boolean; viewBox: string;
}; rotate?: boolean;
export let selectedSize: CropAspectRatio; };
export let rotateHorizontal: boolean; selectedSize: CropAspectRatio;
export let selectType: (size: CropAspectRatio) => void; rotateHorizontal: boolean;
selectType: (size: CropAspectRatio) => void;
}
$: isSelected = selectedSize === size.name; let { size, selectedSize, rotateHorizontal, selectType }: Props = $props();
$: buttonColor = (isSelected ? 'primary' : 'transparent-gray') as Color;
$: rotatedTitle = (title: string, toRotate: boolean) => { let isSelected = $derived(selectedSize === size.name);
let buttonColor = $derived((isSelected ? 'primary' : 'transparent-gray') as Color);
let rotatedTitle = $derived((title: string, toRotate: boolean) => {
let sides = title.split(':'); let sides = title.split(':');
if (toRotate) { if (toRotate) {
sides.reverse(); sides.reverse();
} }
return sides.join(':'); return sides.join(':');
}; });
$: toRotate = (def: boolean | undefined) => { let toRotate = $derived((def: boolean | undefined) => {
if (def === false) { if (def === false) {
return false; return false;
} }
return (def && !rotateHorizontal) || (!def && rotateHorizontal); return (def && !rotateHorizontal) || (!def && rotateHorizontal);
}; });
</script> </script>
<li> <li>
<Button color={buttonColor} class="flex-col gap-1" size="sm" rounded="lg" on:click={() => selectType(size.name)}> <Button color={buttonColor} class="flex-col gap-1" size="sm" rounded="lg" onclick={() => selectType(size.name)}>
<Icon size="1.75em" path={size.icon} viewBox={size.viewBox} class={toRotate(size.rotate) ? 'rotate-90' : ''} /> <Icon size="1.75em" path={size.icon} viewBox={size.viewBox} class={toRotate(size.rotate) ? 'rotate-90' : ''} />
<span>{rotatedTitle(size.name, rotateHorizontal)}</span> <span>{rotatedTitle(size.name, rotateHorizontal)}</span>
</Button> </Button>

View file

@ -16,7 +16,7 @@
import { tick } from 'svelte'; import { tick } from 'svelte';
import CropPreset from './crop-preset.svelte'; import CropPreset from './crop-preset.svelte';
$: rotateHorizontal = [90, 270].includes($normaizedRorateDegrees); let rotateHorizontal = $derived([90, 270].includes($normaizedRorateDegrees));
const icon_16_9 = `M200-280q-33 0-56.5-23.5T120-360v-240q0-33 23.5-56.5T200-680h560q33 0 56.5 23.5T840-600v240q0 33-23.5 56.5T760-280H200Zm0-80h560v-240H200v240Zm0 0v-240 240Z`; const icon_16_9 = `M200-280q-33 0-56.5-23.5T120-360v-240q0-33 23.5-56.5T200-680h560q33 0 56.5 23.5T840-600v240q0 33-23.5 56.5T760-280H200Zm0-80h560v-240H200v240Zm0 0v-240 240Z`;
const icon_4_3 = `M19 5H5c-1.1 0-2 .9-2 2v10c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2V7c0-1.1-.9-2-2-2zm0 12H5V7h14v10z`; const icon_4_3 = `M19 5H5c-1.1 0-2 .9-2 2v10c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2V7c0-1.1-.9-2-2-2zm0 12H5V7h14v10z`;
const icon_3_2 = `M200-240q-33 0-56.5-23.5T120-320v-320q0-33 23.5-56.5T200-720h560q33 0 56.5 23.5T840-640v320q0 33-23.5 56.5T760-240H200Zm0-80h560v-320H200v320Zm0 0v-320 320Z`; const icon_3_2 = `M200-240q-33 0-56.5-23.5T120-320v-320q0-33 23.5-56.5T200-720h560q33 0 56.5 23.5T840-640v320q0 33-23.5 56.5T760-240H200Zm0-80h560v-320H200v320Zm0 0v-320 320Z`;
@ -92,14 +92,17 @@
}, },
]; ];
let selectedSize: CropAspectRatio = 'free'; let selectedSize: CropAspectRatio = $state('free');
$cropAspectRatio = selectedSize;
$: sizesRows = [ $effect(() => {
$cropAspectRatio = selectedSize;
});
let sizesRows = $derived([
sizes.filter((s) => s.rotate === false), sizes.filter((s) => s.rotate === false),
sizes.filter((s) => s.rotate === undefined), sizes.filter((s) => s.rotate === undefined),
sizes.filter((s) => s.rotate === true), sizes.filter((s) => s.rotate === true),
]; ]);
async function rotate(clock: boolean) { async function rotate(clock: boolean) {
rotateDegrees.update((v) => { rotateDegrees.update((v) => {
@ -145,7 +148,7 @@
<h2>{$t('editor_crop_tool_h2_rotation').toUpperCase()}</h2> <h2>{$t('editor_crop_tool_h2_rotation').toUpperCase()}</h2>
</div> </div>
<ul class="flex-wrap flex-row flex gap-x-6 gap-y-4 justify-center"> <ul class="flex-wrap flex-row flex gap-x-6 gap-y-4 justify-center">
<li><CircleIconButton title={$t('anti_clockwise')} on:click={() => rotate(false)} icon={mdiRotateLeft} /></li> <li><CircleIconButton title={$t('anti_clockwise')} onclick={() => rotate(false)} icon={mdiRotateLeft} /></li>
<li><CircleIconButton title={$t('clockwise')} on:click={() => rotate(true)} icon={mdiRotateRight} /></li> <li><CircleIconButton title={$t('clockwise')} onclick={() => rotate(true)} icon={mdiRotateRight} /></li>
</ul> </ul>
</div> </div>

View file

@ -9,8 +9,6 @@
import ConfirmDialog from '$lib/components/shared-components/dialog/confirm-dialog.svelte'; import ConfirmDialog from '$lib/components/shared-components/dialog/confirm-dialog.svelte';
import { shortcut } from '$lib/actions/shortcut'; import { shortcut } from '$lib/actions/shortcut';
export let asset: AssetResponseDto;
onMount(() => { onMount(() => {
return websocketEvents.on('on_asset_update', (assetUpdate) => { return websocketEvents.on('on_asset_update', (assetUpdate) => {
if (assetUpdate.id === asset.id) { if (assetUpdate.id === asset.id) {
@ -19,12 +17,16 @@
}); });
}); });
export let onUpdateSelectedType: (type: string) => void; interface Props {
export let onClose: () => void; asset: AssetResponseDto;
onUpdateSelectedType: (type: string) => void;
onClose: () => void;
}
let selectedType: string = editTypes[0].name; let { asset = $bindable(), onUpdateSelectedType, onClose }: Props = $props();
// svelte-ignore reactive_declaration_non_reactive_property
$: selectedTypeObj = editTypes.find((t) => t.name === selectedType) || editTypes[0]; let selectedType: string = $state(editTypes[0].name);
let selectedTypeObj = $derived(editTypes.find((t) => t.name === selectedType) || editTypes[0]);
setTimeout(() => { setTimeout(() => {
onUpdateSelectedType(selectedType); onUpdateSelectedType(selectedType);
@ -39,7 +41,7 @@
<section class="relative p-2 dark:bg-immich-dark-bg dark:text-immich-dark-fg"> <section class="relative p-2 dark:bg-immich-dark-bg dark:text-immich-dark-fg">
<div class="flex place-items-center gap-2"> <div class="flex place-items-center gap-2">
<CircleIconButton icon={mdiClose} title={$t('close')} on:click={onClose} /> <CircleIconButton icon={mdiClose} title={$t('close')} onclick={onClose} />
<p class="text-lg text-immich-fg dark:text-immich-dark-fg capitalize">{$t('editor')}</p> <p class="text-lg text-immich-fg dark:text-immich-dark-fg capitalize">{$t('editor')}</p>
</div> </div>
<section class="px-4 py-4"> <section class="px-4 py-4">
@ -50,14 +52,14 @@
color={etype.name === selectedType ? 'primary' : 'opaque'} color={etype.name === selectedType ? 'primary' : 'opaque'}
icon={etype.icon} icon={etype.icon}
title={etype.name} title={etype.name}
on:click={() => selectType(etype.name)} onclick={() => selectType(etype.name)}
/> />
</li> </li>
{/each} {/each}
</ul> </ul>
</section> </section>
<section> <section>
<svelte:component this={selectedTypeObj.component} /> <selectedTypeObj.component />
</section> </section>
</section> </section>

View file

@ -1,13 +1,20 @@
<script lang="ts"> <script lang="ts">
export let onClick: (e: MouseEvent) => void; import type { Snippet } from 'svelte';
export let label: string;
interface Props {
onClick: (e: MouseEvent) => void;
label: string;
children?: Snippet;
}
let { onClick, label, children }: Props = $props();
</script> </script>
<button <button
type="button" type="button"
class="my-auto mx-4 rounded-full p-3 text-gray-500 transition hover:bg-gray-500 hover:text-white" class="my-auto mx-4 rounded-full p-3 text-gray-500 transition hover:bg-gray-500 hover:text-white"
aria-label={label} aria-label={label}
on:click={onClick} onclick={onClick}
> >
<slot /> {@render children?.()}
</button> </button>

View file

@ -8,7 +8,11 @@
import { fade } from 'svelte/transition'; import { fade } from 'svelte/transition';
import LoadingSpinner from '../shared-components/loading-spinner.svelte'; import LoadingSpinner from '../shared-components/loading-spinner.svelte';
export let asset: { id: string; type: AssetTypeEnum.Video } | AssetResponseDto; interface Props {
asset: { id: string; type: AssetTypeEnum.Video } | AssetResponseDto;
}
let { asset }: Props = $props();
const photoSphereConfigs = const photoSphereConfigs =
asset.type === AssetTypeEnum.Video asset.type === AssetTypeEnum.Video
@ -43,14 +47,7 @@
{#await Promise.all([loadAssetData(), import('./photo-sphere-viewer-adapter.svelte'), ...photoSphereConfigs])} {#await Promise.all([loadAssetData(), import('./photo-sphere-viewer-adapter.svelte'), ...photoSphereConfigs])}
<LoadingSpinner /> <LoadingSpinner />
{:then [data, module, adapter, plugins, navbar]} {:then [data, module, adapter, plugins, navbar]}
<svelte:component <module.default panorama={data} plugins={plugins ?? undefined} {navbar} {adapter} {originalImageUrl} />
this={module.default}
panorama={data}
plugins={plugins ?? undefined}
{navbar}
{adapter}
{originalImageUrl}
/>
{:catch} {:catch}
{$t('errors.failed_to_load_asset')} {$t('errors.failed_to_load_asset')}
{/await} {/await}

View file

@ -10,16 +10,24 @@
import '@photo-sphere-viewer/core/index.css'; import '@photo-sphere-viewer/core/index.css';
import { onDestroy, onMount } from 'svelte'; import { onDestroy, onMount } from 'svelte';
export let panorama: string | { source: string }; interface Props {
export let originalImageUrl: string | null; panorama: string | { source: string };
export let adapter: AdapterConstructor | [AdapterConstructor, unknown] = EquirectangularAdapter; originalImageUrl: string | null;
export let plugins: (PluginConstructor | [PluginConstructor, unknown])[] = []; adapter?: AdapterConstructor | [AdapterConstructor, unknown];
export let navbar = false; plugins?: (PluginConstructor | [PluginConstructor, unknown])[];
navbar?: boolean;
}
let container: HTMLDivElement; let { panorama, originalImageUrl, adapter = EquirectangularAdapter, plugins = [], navbar = false }: Props = $props();
let container: HTMLDivElement | undefined = $state();
let viewer: Viewer; let viewer: Viewer;
onMount(() => { onMount(() => {
if (!container) {
return;
}
viewer = new Viewer({ viewer = new Viewer({
adapter, adapter,
plugins, plugins,

View file

@ -20,33 +20,38 @@
import { NotificationType, notificationController } from '../shared-components/notification/notification'; import { NotificationType, notificationController } from '../shared-components/notification/notification';
import { handleError } from '$lib/utils/handle-error'; import { handleError } from '$lib/utils/handle-error';
export let asset: AssetResponseDto; interface Props {
export let preloadAssets: AssetResponseDto[] | undefined = undefined; asset: AssetResponseDto;
export let element: HTMLDivElement | undefined = undefined; preloadAssets?: AssetResponseDto[] | undefined;
export let haveFadeTransition = true; element?: HTMLDivElement | undefined;
export let sharedLink: SharedLinkResponseDto | undefined = undefined; haveFadeTransition?: boolean;
export let onPreviousAsset: (() => void) | null = null; sharedLink?: SharedLinkResponseDto | undefined;
export let onNextAsset: (() => void) | null = null; onPreviousAsset?: (() => void) | null;
export let copyImage: (() => Promise<void>) | null = null; onNextAsset?: (() => void) | null;
export let zoomToggle: (() => void) | null = null; copyImage?: () => Promise<void>;
zoomToggle?: (() => void) | null;
onClose?: () => void;
}
let {
asset,
preloadAssets = undefined,
element = $bindable(),
haveFadeTransition = true,
sharedLink = undefined,
onPreviousAsset = null,
onNextAsset = null,
copyImage = $bindable(),
zoomToggle = $bindable(),
}: Props = $props();
const { slideshowState, slideshowLook } = slideshowStore; const { slideshowState, slideshowLook } = slideshowStore;
let assetFileUrl: string = ''; let assetFileUrl: string = $state('');
let imageLoaded: boolean = false; let imageLoaded: boolean = $state(false);
let imageError: boolean = false; let imageError: boolean = $state(false);
let forceUseOriginal: boolean = false;
let loader: HTMLImageElement;
$: isWebCompatible = isWebCompatibleImage(asset); let loader = $state<HTMLImageElement>();
$: useOriginalByDefault = isWebCompatible && $alwaysLoadOriginalFile;
$: useOriginalImage = useOriginalByDefault || forceUseOriginal;
// when true, will force loading of the original image
$: forceUseOriginal =
forceUseOriginal || asset.originalMimeType === 'image/gif' || ($photoZoomState.currentZoom > 1 && isWebCompatible);
$: preload(useOriginalImage, preloadAssets);
$: imageLoaderUrl = getAssetUrl(asset.id, useOriginalImage, asset.checksum);
photoZoomState.set({ photoZoomState.set({
currentRotation: 0, currentRotation: 0,
@ -129,16 +134,31 @@
const onerror = () => { const onerror = () => {
imageError = imageLoaded = true; imageError = imageLoaded = true;
}; };
if (loader.complete) { if (loader?.complete) {
onload(); onload();
} }
loader.addEventListener('load', onload); loader?.addEventListener('load', onload);
loader.addEventListener('error', onerror); loader?.addEventListener('error', onerror);
return () => { return () => {
loader?.removeEventListener('load', onload); loader?.removeEventListener('load', onload);
loader?.removeEventListener('error', onerror); loader?.removeEventListener('error', onerror);
}; };
}); });
let isWebCompatible = $derived(isWebCompatibleImage(asset));
let useOriginalByDefault = $derived(isWebCompatible && $alwaysLoadOriginalFile);
// when true, will force loading of the original image
let forceUseOriginal: boolean = $derived(
asset.originalMimeType === 'image/gif' || ($photoZoomState.currentZoom > 1 && isWebCompatible),
);
let useOriginalImage = $derived(useOriginalByDefault || forceUseOriginal);
$effect(() => {
preload(useOriginalImage, preloadAssets);
});
let imageLoaderUrl = $derived(getAssetUrl(asset.id, useOriginalImage, asset.checksum));
</script> </script>
<svelte:window <svelte:window
@ -150,15 +170,15 @@
{#if imageError} {#if imageError}
<BrokenAsset class="text-xl" /> <BrokenAsset class="text-xl" />
{/if} {/if}
<!-- svelte-ignore a11y-missing-attribute --> <!-- svelte-ignore a11y_missing_attribute -->
<img bind:this={loader} style="display:none" src={imageLoaderUrl} aria-hidden="true" /> <img bind:this={loader} style="display:none" src={imageLoaderUrl} aria-hidden="true" />
<div bind:this={element} class="relative h-full select-none"> <div bind:this={element} class="relative h-full select-none">
<img <img
style="display:none" style="display:none"
src={imageLoaderUrl} src={imageLoaderUrl}
alt={$getAltText(asset)} alt={$getAltText(asset)}
on:load={() => ((imageLoaded = true), (assetFileUrl = imageLoaderUrl))} onload={() => ((imageLoaded = true), (assetFileUrl = imageLoaderUrl))}
on:error={() => (imageError = imageLoaded = true)} onerror={() => (imageError = imageLoaded = true)}
/> />
{#if !imageLoaded} {#if !imageLoaded}
<div id="spinner" class="flex h-full items-center justify-center"> <div id="spinner" class="flex h-full items-center justify-center">
@ -168,7 +188,7 @@
<div <div
use:zoomImageAction use:zoomImageAction
use:swipe use:swipe
on:swipe={onSwipe} onswipe={onSwipe}
class="h-full w-full" class="h-full w-full"
transition:fade={{ duration: haveFadeTransition ? 150 : 0 }} transition:fade={{ duration: haveFadeTransition ? 150 : 0 }}
> >

View file

@ -9,20 +9,30 @@
import { t } from 'svelte-i18n'; import { t } from 'svelte-i18n';
import { fly } from 'svelte/transition'; import { fly } from 'svelte/transition';
export let isFullScreen: boolean; interface Props {
export let onNext = () => {}; isFullScreen: boolean;
export let onPrevious = () => {}; onNext?: () => void;
export let onClose = () => {}; onPrevious?: () => void;
export let onSetToFullScreen = () => {}; onClose?: () => void;
onSetToFullScreen?: () => void;
}
let {
isFullScreen,
onNext = () => {},
onPrevious = () => {},
onClose = () => {},
onSetToFullScreen = () => {},
}: Props = $props();
const { restartProgress, stopProgress, slideshowDelay, showProgressBar, slideshowNavigation } = slideshowStore; const { restartProgress, stopProgress, slideshowDelay, showProgressBar, slideshowNavigation } = slideshowStore;
let progressBarStatus: ProgressBarStatus; let progressBarStatus: ProgressBarStatus | undefined = $state();
let progressBar: ProgressBar; let progressBar = $state<ReturnType<typeof ProgressBar>>();
let showSettings = false; let showSettings = $state(false);
let showControls = true; let showControls = $state(true);
let timer: NodeJS.Timeout; let timer: NodeJS.Timeout;
let isOverControls = false; let isOverControls = $state(false);
let unsubscribeRestart: () => void; let unsubscribeRestart: () => void;
let unsubscribeStop: () => void; let unsubscribeStop: () => void;
@ -55,13 +65,13 @@
hideControlsAfterDelay(); hideControlsAfterDelay();
unsubscribeRestart = restartProgress.subscribe((value) => { unsubscribeRestart = restartProgress.subscribe((value) => {
if (value) { if (value) {
progressBar.restart(value); progressBar?.restart(value);
} }
}); });
unsubscribeStop = stopProgress.subscribe((value) => { unsubscribeStop = stopProgress.subscribe((value) => {
if (value) { if (value) {
progressBar.restart(false); progressBar?.restart(false);
stopControlsHideTimer(); stopControlsHideTimer();
} }
}); });
@ -77,7 +87,9 @@
} }
}); });
const handleDone = () => { const handleDone = async () => {
await progressBar?.reset();
if ($slideshowNavigation === SlideshowNavigation.AscendingOrder) { if ($slideshowNavigation === SlideshowNavigation.AscendingOrder) {
onPrevious(); onPrevious();
return; return;
@ -87,7 +99,7 @@
</script> </script>
<svelte:window <svelte:window
on:mousemove={showControlBar} onmousemove={showControlBar}
use:shortcuts={[ use:shortcuts={[
{ shortcut: { key: 'Escape' }, onShortcut: onClose }, { shortcut: { key: 'Escape' }, onShortcut: onClose },
{ shortcut: { key: 'ArrowLeft' }, onShortcut: onPrevious }, { shortcut: { key: 'ArrowLeft' }, onShortcut: onPrevious },
@ -98,32 +110,32 @@
{#if showControls} {#if showControls}
<div <div
class="m-4 flex gap-2" class="m-4 flex gap-2"
on:mouseenter={() => (isOverControls = true)} onmouseenter={() => (isOverControls = true)}
on:mouseleave={() => (isOverControls = false)} onmouseleave={() => (isOverControls = false)}
transition:fly={{ duration: 150 }} transition:fly={{ duration: 150 }}
role="navigation" role="navigation"
> >
<CircleIconButton buttonSize="50" icon={mdiClose} on:click={onClose} title={$t('exit_slideshow')} /> <CircleIconButton buttonSize="50" icon={mdiClose} onclick={onClose} title={$t('exit_slideshow')} />
<CircleIconButton <CircleIconButton
buttonSize="50" buttonSize="50"
icon={progressBarStatus === ProgressBarStatus.Paused ? mdiPlay : mdiPause} icon={progressBarStatus === ProgressBarStatus.Paused ? mdiPlay : mdiPause}
on:click={() => (progressBarStatus === ProgressBarStatus.Paused ? progressBar.play() : progressBar.pause())} onclick={() => (progressBarStatus === ProgressBarStatus.Paused ? progressBar?.play() : progressBar?.pause())}
title={progressBarStatus === ProgressBarStatus.Paused ? $t('play') : $t('pause')} title={progressBarStatus === ProgressBarStatus.Paused ? $t('play') : $t('pause')}
/> />
<CircleIconButton buttonSize="50" icon={mdiChevronLeft} on:click={onPrevious} title={$t('previous')} /> <CircleIconButton buttonSize="50" icon={mdiChevronLeft} onclick={onPrevious} title={$t('previous')} />
<CircleIconButton buttonSize="50" icon={mdiChevronRight} on:click={onNext} title={$t('next')} /> <CircleIconButton buttonSize="50" icon={mdiChevronRight} onclick={onNext} title={$t('next')} />
<CircleIconButton <CircleIconButton
buttonSize="50" buttonSize="50"
icon={mdiCog} icon={mdiCog}
on:click={() => (showSettings = !showSettings)} onclick={() => (showSettings = !showSettings)}
title={$t('slideshow_settings')} title={$t('slideshow_settings')}
/> />
{#if !isFullScreen} {#if !isFullScreen}
<CircleIconButton <CircleIconButton
buttonSize="50" buttonSize="50"
icon={mdiFullscreen} icon={mdiFullscreen}
on:click={onSetToFullScreen} onclick={onSetToFullScreen}
title={$t('set_slideshow_to_fullscreen')} title={$t('set_slideshow_to_fullscreen')}
/> />
{/if} {/if}

View file

@ -4,31 +4,53 @@
import { getAssetPlaybackUrl, getAssetThumbnailUrl } from '$lib/utils'; import { getAssetPlaybackUrl, getAssetThumbnailUrl } from '$lib/utils';
import { handleError } from '$lib/utils/handle-error'; import { handleError } from '$lib/utils/handle-error';
import { AssetMediaSize } from '@immich/sdk'; import { AssetMediaSize } from '@immich/sdk';
import { tick } from 'svelte'; import { onDestroy, onMount } from 'svelte';
import { swipe } from 'svelte-gestures'; import { swipe } from 'svelte-gestures';
import type { SwipeCustomEvent } from 'svelte-gestures'; import type { SwipeCustomEvent } from 'svelte-gestures';
import { fade } from 'svelte/transition'; import { fade } from 'svelte/transition';
import { t } from 'svelte-i18n'; import { t } from 'svelte-i18n';
export let assetId: string; interface Props {
export let loopVideo: boolean; assetId: string;
export let checksum: string; loopVideo: boolean;
export let onPreviousAsset: () => void = () => {}; checksum: string;
export let onNextAsset: () => void = () => {}; onPreviousAsset?: () => void;
export let onVideoEnded: () => void = () => {}; onNextAsset?: () => void;
export let onVideoStarted: () => void = () => {}; onVideoEnded?: () => void;
onVideoStarted?: () => void;
let element: HTMLVideoElement | undefined = undefined; onClose?: () => void;
let isVideoLoading = true;
let assetFileUrl: string;
let forceMuted = false;
$: if (element) {
assetFileUrl = getAssetPlaybackUrl({ id: assetId, checksum });
forceMuted = false;
element.load();
} }
let {
assetId,
loopVideo,
checksum,
onPreviousAsset = () => {},
onNextAsset = () => {},
onVideoEnded = () => {},
onVideoStarted = () => {},
onClose = () => {},
}: Props = $props();
let videoPlayer: HTMLVideoElement | undefined = $state();
let isLoading = $state(true);
let assetFileUrl = $state('');
let forceMuted = $state(false);
onMount(() => {
if (videoPlayer) {
assetFileUrl = getAssetPlaybackUrl({ id: assetId, checksum });
forceMuted = false;
videoPlayer.load();
}
});
onDestroy(() => {
if (videoPlayer) {
videoPlayer.src = '';
}
});
const handleCanPlay = async (video: HTMLVideoElement) => { const handleCanPlay = async (video: HTMLVideoElement) => {
try { try {
await video.play(); await video.play();
@ -38,16 +60,16 @@
await tryForceMutedPlay(video); await tryForceMutedPlay(video);
return; return;
} }
handleError(error, $t('errors.unable_to_play_video')); handleError(error, $t('errors.unable_to_play_video'));
} finally { } finally {
isVideoLoading = false; isLoading = false;
} }
}; };
const tryForceMutedPlay = async (video: HTMLVideoElement) => { const tryForceMutedPlay = async (video: HTMLVideoElement) => {
try { try {
forceMuted = true; video.muted = true;
await tick();
await handleCanPlay(video); await handleCanPlay(video);
} catch (error) { } catch (error) {
handleError(error, $t('errors.unable_to_play_video')); handleError(error, $t('errors.unable_to_play_video'));
@ -66,21 +88,22 @@
<div transition:fade={{ duration: 150 }} class="flex h-full select-none place-content-center place-items-center"> <div transition:fade={{ duration: 150 }} class="flex h-full select-none place-content-center place-items-center">
<video <video
bind:this={element} bind:this={videoPlayer}
loop={$loopVideoPreference && loopVideo} loop={$loopVideoPreference && loopVideo}
autoplay autoplay
playsinline playsinline
controls controls
class="h-full object-contain" class="h-full object-contain"
use:swipe use:swipe
on:swipe={onSwipe} onswipe={onSwipe}
on:canplay={(e) => handleCanPlay(e.currentTarget)} oncanplay={(e) => handleCanPlay(e.currentTarget)}
on:ended={onVideoEnded} onended={onVideoEnded}
on:volumechange={(e) => { onvolumechange={(e) => {
if (!forceMuted) { if (!forceMuted) {
$videoViewerMuted = e.currentTarget.muted; $videoViewerMuted = e.currentTarget.muted;
} }
}} }}
onclose={() => onClose()}
muted={forceMuted || $videoViewerMuted} muted={forceMuted || $videoViewerMuted}
bind:volume={$videoViewerVolume} bind:volume={$videoViewerVolume}
poster={getAssetThumbnailUrl({ id: assetId, size: AssetMediaSize.Preview, checksum })} poster={getAssetThumbnailUrl({ id: assetId, size: AssetMediaSize.Preview, checksum })}
@ -88,7 +111,7 @@
> >
</video> </video>
{#if isVideoLoading} {#if isLoading}
<div class="absolute flex place-content-center place-items-center"> <div class="absolute flex place-content-center place-items-center">
<LoadingSpinner /> <LoadingSpinner />
</div> </div>

View file

@ -4,12 +4,29 @@
import VideoNativeViewer from '$lib/components/asset-viewer/video-native-viewer.svelte'; import VideoNativeViewer from '$lib/components/asset-viewer/video-native-viewer.svelte';
import PanoramaViewer from '$lib/components/asset-viewer/panorama-viewer.svelte'; import PanoramaViewer from '$lib/components/asset-viewer/panorama-viewer.svelte';
export let assetId: string; interface Props {
export let projectionType: string | null | undefined; assetId: string;
export let checksum: string; projectionType: string | null | undefined;
export let loopVideo: boolean; checksum: string;
export let onPreviousAsset: () => void; loopVideo: boolean;
export let onNextAsset: () => void; onClose?: () => void;
onPreviousAsset?: () => void;
onNextAsset?: () => void;
onVideoEnded?: () => void;
onVideoStarted?: () => void;
}
let {
assetId,
projectionType,
checksum,
loopVideo,
onPreviousAsset,
onClose,
onNextAsset,
onVideoEnded,
onVideoStarted,
}: Props = $props();
</script> </script>
{#if projectionType === ProjectionType.EQUIRECTANGULAR} {#if projectionType === ProjectionType.EQUIRECTANGULAR}
@ -21,7 +38,8 @@
{assetId} {assetId}
{onPreviousAsset} {onPreviousAsset}
{onNextAsset} {onNextAsset}
on:onVideoEnded {onVideoEnded}
on:onVideoStarted {onVideoStarted}
{onClose}
/> />
{/if} {/if}

View file

@ -3,11 +3,14 @@
import { mdiImageBrokenVariant } from '@mdi/js'; import { mdiImageBrokenVariant } from '@mdi/js';
import { t } from 'svelte-i18n'; import { t } from 'svelte-i18n';
let className = ''; interface Props {
export { className as class }; class?: string;
export let hideMessage = false; hideMessage?: boolean;
export let width: string | undefined = undefined; width?: string | undefined;
export let height: string | undefined = undefined; height?: string | undefined;
}
let { class: className = '', hideMessage = false, width = undefined, height = undefined }: Props = $props();
</script> </script>
<div <div

View file

@ -7,29 +7,49 @@
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import { fade } from 'svelte/transition'; import { fade } from 'svelte/transition';
export let url: string; interface Props {
export let altText: string | undefined; url: string;
export let title: string | null = null; altText: string | undefined;
export let heightStyle: string | undefined = undefined; title?: string | null;
export let widthStyle: string; heightStyle?: string | undefined;
export let base64ThumbHash: string | null = null; widthStyle: string;
export let curve = false; base64ThumbHash?: string | null;
export let shadow = false; curve?: boolean;
export let circle = false; shadow?: boolean;
export let hidden = false; circle?: boolean;
export let border = false; hidden?: boolean;
export let preload = true; border?: boolean;
export let hiddenIconClass = 'text-white'; preload?: boolean;
export let onComplete: (() => void) | undefined = undefined; hiddenIconClass?: string;
onComplete?: (() => void) | undefined;
onClick?: (() => void) | undefined;
}
let {
url,
altText,
title = null,
heightStyle = undefined,
widthStyle,
base64ThumbHash = null,
curve = false,
shadow = false,
circle = false,
hidden = false,
border = false,
preload = true,
hiddenIconClass = 'text-white',
onComplete = undefined,
}: Props = $props();
let { let {
IMAGE_THUMBNAIL: { THUMBHASH_FADE_DURATION }, IMAGE_THUMBNAIL: { THUMBHASH_FADE_DURATION },
} = TUNABLES; } = TUNABLES;
let loaded = false; let loaded = $state(false);
let errored = false; let errored = $state(false);
let img: HTMLImageElement; let img = $state<HTMLImageElement>();
const setLoaded = () => { const setLoaded = () => {
loaded = true; loaded = true;
@ -40,20 +60,22 @@
onComplete?.(); onComplete?.();
}; };
onMount(() => { onMount(() => {
if (img.complete) { if (img?.complete) {
setLoaded(); setLoaded();
} }
}); });
$: optionalClasses = [ let optionalClasses = $derived(
curve && 'rounded-xl', [
circle && 'rounded-full', curve && 'rounded-xl',
shadow && 'shadow-lg', circle && 'rounded-full',
(circle || !heightStyle) && 'aspect-square', shadow && 'shadow-lg',
border && 'border-[3px] border-immich-dark-primary/80 hover:border-immich-primary', (circle || !heightStyle) && 'aspect-square',
] border && 'border-[3px] border-immich-dark-primary/80 hover:border-immich-primary',
.filter(Boolean) ]
.join(' '); .filter(Boolean)
.join(' '),
);
</script> </script>
{#if errored} {#if errored}
@ -61,8 +83,8 @@
{:else} {:else}
<img <img
bind:this={img} bind:this={img}
on:load={setLoaded} onload={setLoaded}
on:error={setErrored} onerror={setErrored}
loading={preload ? 'eager' : 'lazy'} loading={preload ? 'eager' : 'lazy'}
style:width={widthStyle} style:width={widthStyle}
style:height={heightStyle} style:height={heightStyle}

View file

@ -31,62 +31,89 @@
import { TUNABLES } from '$lib/utils/tunables'; import { TUNABLES } from '$lib/utils/tunables';
import { thumbhash } from '$lib/actions/thumbhash'; import { thumbhash } from '$lib/actions/thumbhash';
export let asset: AssetResponseDto; interface Props {
export let dateGroup: DateGroup | undefined = undefined; asset: AssetResponseDto;
export let assetStore: AssetStore | undefined = undefined; dateGroup?: DateGroup | undefined;
export let groupIndex = 0; assetStore?: AssetStore | undefined;
export let thumbnailSize: number | undefined = undefined; groupIndex?: number;
export let thumbnailWidth: number | undefined = undefined; thumbnailSize?: number | undefined;
export let thumbnailHeight: number | undefined = undefined; thumbnailWidth?: number | undefined;
export let selected = false; thumbnailHeight?: number | undefined;
export let selectionCandidate = false; selected?: boolean;
export let disabled = false; selectionCandidate?: boolean;
export let readonly = false;
export let showArchiveIcon = false;
export let showStackedIcon = true;
export let disableMouseOver = false;
export let intersectionConfig: {
root?: HTMLElement;
bottom?: string;
top?: string;
left?: string;
priority?: number;
disabled?: boolean; disabled?: boolean;
} = {}; readonly?: boolean;
showArchiveIcon?: boolean;
showStackedIcon?: boolean;
disableMouseOver?: boolean;
intersectionConfig?: {
root?: HTMLElement;
bottom?: string;
top?: string;
left?: string;
priority?: number;
disabled?: boolean;
};
retrieveElement?: boolean;
onIntersected?: (() => void) | undefined;
onClick?: ((asset: AssetResponseDto) => void) | undefined;
onRetrieveElement?: ((elment: HTMLElement) => void) | undefined;
onSelect?: ((asset: AssetResponseDto) => void) | undefined;
onMouseEvent?: ((event: { isMouseOver: boolean; selectedGroupIndex: number }) => void) | undefined;
class?: string;
}
export let retrieveElement: boolean = false; let {
export let onIntersected: (() => void) | undefined = undefined; asset,
export let onClick: ((asset: AssetResponseDto) => void) | undefined = undefined; dateGroup = undefined,
export let onRetrieveElement: ((elment: HTMLElement) => void) | undefined = undefined; assetStore = undefined,
export let onSelect: ((asset: AssetResponseDto) => void) | undefined = undefined; groupIndex = 0,
export let onMouseEvent: ((event: { isMouseOver: boolean; selectedGroupIndex: number }) => void) | undefined = thumbnailSize = undefined,
undefined; thumbnailWidth = undefined,
thumbnailHeight = undefined,
let className = ''; selected = false,
export { className as class }; selectionCandidate = false,
disabled = false,
readonly = false,
showArchiveIcon = false,
showStackedIcon = true,
disableMouseOver = false,
intersectionConfig = {},
retrieveElement = false,
onIntersected = undefined,
onClick = undefined,
onRetrieveElement = undefined,
onSelect = undefined,
onMouseEvent = undefined,
class: className = '',
}: Props = $props();
let { let {
IMAGE_THUMBNAIL: { THUMBHASH_FADE_DURATION }, IMAGE_THUMBNAIL: { THUMBHASH_FADE_DURATION },
} = TUNABLES; } = TUNABLES;
const componentId = generateId(); const componentId = generateId();
let element: HTMLElement | undefined; let element: HTMLElement | undefined = $state();
let mouseOver = false; let mouseOver = $state(false);
let intersecting = false; let intersecting = $state(false);
let lastRetrievedElement: HTMLElement | undefined; let lastRetrievedElement: HTMLElement | undefined = $state();
let loaded = false; let loaded = $state(false);
$: if (!retrieveElement) { $effect(() => {
lastRetrievedElement = undefined; if (!retrieveElement) {
} lastRetrievedElement = undefined;
$: if (retrieveElement && element && lastRetrievedElement !== element) { }
lastRetrievedElement = element; });
onRetrieveElement?.(element); $effect(() => {
} if (retrieveElement && element && lastRetrievedElement !== element) {
lastRetrievedElement = element;
onRetrieveElement?.(element);
}
});
$: width = thumbnailSize || thumbnailWidth || 235; let width = $derived(thumbnailSize || thumbnailWidth || 235);
$: height = thumbnailSize || thumbnailHeight || 235; let height = $derived(thumbnailSize || thumbnailHeight || 235);
$: display = intersecting; let display = $derived(intersecting);
const onIconClickedHandler = (e?: MouseEvent) => { const onIconClickedHandler = (e?: MouseEvent) => {
e?.stopPropagation(); e?.stopPropagation();
@ -197,15 +224,15 @@
class="group" class="group"
class:cursor-not-allowed={disabled} class:cursor-not-allowed={disabled}
class:cursor-pointer={!disabled} class:cursor-pointer={!disabled}
on:mouseenter={onMouseEnter} onmouseenter={onMouseEnter}
on:mouseleave={onMouseLeave} onmouseleave={onMouseLeave}
on:keypress={(evt) => { onkeypress={(evt) => {
if (evt.key === 'Enter') { if (evt.key === 'Enter') {
callClickHandlers(); callClickHandlers();
} }
}} }}
tabindex={0} tabindex={0}
on:click={handleClick} onclick={handleClick}
role="link" role="link"
> >
{#if mouseOver && !disableMouseOver} {#if mouseOver && !disableMouseOver}
@ -216,7 +243,7 @@
style:width="{width}px" style:width="{width}px"
style:height="{height}px" style:height="{height}px"
href={currentUrlReplaceAssetId(asset.id)} href={currentUrlReplaceAssetId(asset.id)}
on:click={(evt) => evt.preventDefault()} onclick={(evt) => evt.preventDefault()}
tabindex={0} tabindex={0}
aria-label="Thumbnail URL" aria-label="Thumbnail URL"
> >
@ -227,7 +254,7 @@
{#if !readonly && (mouseOver || selected || selectionCandidate)} {#if !readonly && (mouseOver || selected || selectionCandidate)}
<button <button
type="button" type="button"
on:click={onIconClickedHandler} onclick={onIconClickedHandler}
class="absolute p-2 focus:outline-none" class="absolute p-2 focus:outline-none"
class:cursor-not-allowed={disabled} class:cursor-not-allowed={disabled}
role="checkbox" role="checkbox"

View file

@ -7,31 +7,47 @@
import { generateId } from '$lib/utils/generate-id'; import { generateId } from '$lib/utils/generate-id';
import { onDestroy } from 'svelte'; import { onDestroy } from 'svelte';
export let assetStore: AssetStore | undefined = undefined; interface Props {
export let url: string; assetStore?: AssetStore | undefined;
export let durationInSeconds = 0; url: string;
export let enablePlayback = false; durationInSeconds?: number;
export let playbackOnIconHover = false; enablePlayback?: boolean;
export let showTime = true; playbackOnIconHover?: boolean;
export let curve = false; showTime?: boolean;
export let playIcon = mdiPlayCircleOutline; curve?: boolean;
export let pauseIcon = mdiPauseCircleOutline; playIcon?: string;
pauseIcon?: string;
}
let {
assetStore = undefined,
url,
durationInSeconds = 0,
enablePlayback = $bindable(false),
playbackOnIconHover = false,
showTime = true,
curve = false,
playIcon = mdiPlayCircleOutline,
pauseIcon = mdiPauseCircleOutline,
}: Props = $props();
const componentId = generateId(); const componentId = generateId();
let remainingSeconds = durationInSeconds; let remainingSeconds = $state(durationInSeconds);
let loading = true; let loading = $state(true);
let error = false; let error = $state(false);
let player: HTMLVideoElement; let player: HTMLVideoElement | undefined = $state();
$: if (!enablePlayback) { $effect(() => {
// Reset remaining time when playback is disabled. if (!enablePlayback) {
remainingSeconds = durationInSeconds; // Reset remaining time when playback is disabled.
remainingSeconds = durationInSeconds;
if (player) { if (player) {
// Cancel video buffering. // Cancel video buffering.
player.src = ''; player.src = '';
}
} }
} });
const onMouseEnter = () => { const onMouseEnter = () => {
if (assetStore) { if (assetStore) {
assetStore.taskManager.queueScrollSensitiveTask({ assetStore.taskManager.queueScrollSensitiveTask({
@ -78,8 +94,8 @@
</span> </span>
{/if} {/if}
<!-- svelte-ignore a11y-no-static-element-interactions --> <!-- svelte-ignore a11y_no_static_element_interactions -->
<span class="pr-2 pt-2" on:mouseenter={onMouseEnter} on:mouseleave={onMouseLeave}> <span class="pr-2 pt-2" onmouseenter={onMouseEnter} onmouseleave={onMouseLeave}>
{#if enablePlayback} {#if enablePlayback}
{#if loading} {#if loading}
<LoadingSpinner /> <LoadingSpinner />
@ -103,15 +119,19 @@
autoplay autoplay
loop loop
src={url} src={url}
on:play={() => { onplay={() => {
loading = false; loading = false;
error = false; error = false;
}} }}
on:error={() => { onerror={() => {
if (!player?.src) {
// Do not show error when the URL is empty.
return;
}
error = true; error = true;
loading = false; loading = false;
}} }}
on:timeupdate={({ currentTarget }) => { ontimeupdate={({ currentTarget }) => {
const remaining = currentTarget.duration - currentTarget.currentTime; const remaining = currentTarget.duration - currentTarget.currentTime;
remainingSeconds = Math.min( remainingSeconds = Math.min(
Math.ceil(Number.isNaN(remaining) ? Number.POSITIVE_INFINITY : remaining), Math.ceil(Number.isNaN(remaining) ? Number.POSITIVE_INFINITY : remaining),

View file

@ -1,11 +1,18 @@
<script lang="ts" context="module"> <script lang="ts" module>
export type Color = 'primary' | 'secondary'; export type Color = 'primary' | 'secondary';
export type Rounded = false | true | 'full'; export type Rounded = false | true | 'full';
</script> </script>
<script lang="ts"> <script lang="ts">
export let color: Color = 'primary'; import type { Snippet } from 'svelte';
export let rounded: Rounded = true;
interface Props {
color?: Color;
rounded?: Rounded;
children?: Snippet;
}
let { color = 'primary', rounded = true, children }: Props = $props();
const colorClasses: Record<Color, string> = { const colorClasses: Record<Color, string> = {
primary: 'text-gray-100 dark:text-immich-dark-gray bg-immich-primary dark:bg-immich-dark-primary', primary: 'text-gray-100 dark:text-immich-dark-gray bg-immich-primary dark:bg-immich-dark-primary',
@ -20,5 +27,5 @@
class:rounded-md={rounded === true} class:rounded-md={rounded === true}
class:rounded-full={rounded === 'full'} class:rounded-full={rounded === 'full'}
> >
<slot /> {@render children?.()}
</span> </span>

View file

@ -1,6 +1,4 @@
<script lang="ts" context="module"> <script lang="ts" module>
import type { HTMLButtonAttributes, HTMLLinkAttributes } from 'svelte/elements';
export type Color = export type Color =
| 'primary' | 'primary'
| 'primary-inversed' | 'primary-inversed'
@ -17,44 +15,47 @@
export type Size = 'tiny' | 'icon' | 'link' | 'sm' | 'base' | 'lg'; export type Size = 'tiny' | 'icon' | 'link' | 'sm' | 'base' | 'lg';
export type Rounded = 'lg' | '3xl' | 'full' | 'none'; export type Rounded = 'lg' | '3xl' | 'full' | 'none';
export type Shadow = 'md' | false; export type Shadow = 'md' | false;
</script>
type BaseProps = { <script lang="ts">
class?: string; import type { Snippet } from 'svelte';
interface Props {
type?: string;
href?: string;
color?: Color; color?: Color;
size?: Size; size?: Size;
rounded?: Rounded; rounded?: Rounded;
shadow?: Shadow; shadow?: Shadow;
fullwidth?: boolean; fullwidth?: boolean;
border?: boolean; border?: boolean;
}; class?: string;
children?: Snippet;
onclick?: (event: MouseEvent) => void;
onfocus?: () => void;
onblur?: () => void;
form?: string;
disabled?: boolean;
title?: string;
'aria-current'?: 'page' | 'step' | 'location' | 'date' | 'time' | undefined | null;
}
export type ButtonProps = HTMLButtonAttributes & let {
BaseProps & { type = 'button',
href?: never; href = undefined,
}; color = 'primary',
size = 'base',
export type LinkProps = HTMLLinkAttributes & rounded = '3xl',
BaseProps & { shadow = 'md',
type?: never; fullwidth = false,
}; border = false,
class: className = '',
export type Props = ButtonProps | LinkProps; children,
</script> onclick,
onfocus,
<script lang="ts"> onblur,
type $$Props = Props; ...rest
}: Props = $props();
export let type: $$Props['type'] = 'button';
export let href: $$Props['href'] = undefined;
export let color: Color = 'primary';
export let size: Size = 'base';
export let rounded: Rounded = '3xl';
export let shadow: Shadow = 'md';
export let fullwidth = false;
export let border = false;
let className = '';
export { className as class };
const colorClasses: Record<Color, string> = { const colorClasses: Record<Color, string> = {
primary: primary:
@ -93,29 +94,31 @@
full: 'rounded-full', full: 'rounded-full',
}; };
$: computedClass = [ let computedClass = $derived(
className, [
colorClasses[color], className,
sizeClasses[size], colorClasses[color],
roundedClasses[rounded], sizeClasses[size],
shadow === 'md' && 'shadow-md', roundedClasses[rounded],
fullwidth && 'w-full', shadow === 'md' && 'shadow-md',
border && 'border', fullwidth && 'w-full',
] border && 'border',
.filter(Boolean) ]
.join(' '); .filter(Boolean)
.join(' '),
);
</script> </script>
<!-- svelte-ignore a11y-no-static-element-interactions --> <!-- svelte-ignore a11y_no_static_element_interactions -->
<svelte:element <svelte:element
this={href ? 'a' : 'button'} this={href ? 'a' : 'button'}
type={href ? undefined : type} type={href ? undefined : type}
{href} {href}
on:click {onclick}
on:focus {onfocus}
on:blur {onblur}
class="inline-flex items-center justify-center transition-colors disabled:cursor-not-allowed disabled:opacity-60 disabled:pointer-events-none {computedClass}" class="inline-flex items-center justify-center transition-colors disabled:cursor-not-allowed disabled:opacity-60 disabled:pointer-events-none {computedClass}"
{...$$restProps} {...rest}
> >
<slot /> {@render children?.()}
</svelte:element> </svelte:element>

View file

@ -1,64 +1,64 @@
<script lang="ts" context="module"> <script lang="ts" module>
import type { HTMLButtonAttributes, HTMLLinkAttributes } from 'svelte/elements';
export type Color = 'transparent' | 'light' | 'dark' | 'gray' | 'primary' | 'opaque' | 'alert'; export type Color = 'transparent' | 'light' | 'dark' | 'gray' | 'primary' | 'opaque' | 'alert';
export type Padding = '1' | '2' | '3'; export type Padding = '1' | '2' | '3';
type BaseProps = {
icon: string;
title: string;
class?: string;
color?: Color;
padding?: Padding;
size?: string;
hideMobile?: true;
buttonSize?: string;
viewBox?: string;
};
export type ButtonProps = HTMLButtonAttributes &
BaseProps & {
href?: never;
};
export type LinkProps = HTMLLinkAttributes &
BaseProps & {
type?: never;
};
export type Props = ButtonProps | LinkProps;
</script> </script>
<script lang="ts"> <script lang="ts">
import Icon from '$lib/components/elements/icon.svelte'; import Icon from '$lib/components/elements/icon.svelte';
type $$Props = Props;
export let type: $$Props['type'] = 'button';
export let href: $$Props['href'] = undefined;
export let icon: string;
export let color: Color = 'transparent';
export let title: string;
/**
* The padding of the button, used by the `p-{padding}` Tailwind CSS class.
*/
export let padding: Padding = '3';
/**
* Size of the button, used for a CSS value.
*/
export let size = '24';
export let hideMobile = false;
export let buttonSize: string | undefined = undefined;
/**
* viewBox attribute for the SVG icon.
*/
export let viewBox: string | undefined = undefined;
/** /**
* Override the default styling of the button for specific use cases, such as the icon color. * Override the default styling of the button for specific use cases, such as the icon color.
*/ */
let className = ''; interface Props {
export { className as class }; id?: string;
type?: string;
href?: string;
icon: string;
color?: Color;
title: string;
/**
* The padding of the button, used by the `p-{padding}` Tailwind CSS class.
*/
padding?: Padding;
/**
* Size of the button, used for a CSS value.
*/
size?: string;
hideMobile?: boolean;
buttonSize?: string | undefined;
/**
* viewBox attribute for the SVG icon.
*/
viewBox?: string | undefined;
class?: string;
'aria-hidden'?: boolean | undefined | null;
'aria-checked'?: 'true' | 'false' | undefined | null;
'aria-current'?: 'page' | 'step' | 'location' | 'date' | 'time' | 'true' | 'false' | undefined | null;
'aria-controls'?: string | undefined | null;
'aria-expanded'?: boolean;
'aria-haspopup'?: boolean;
tabindex?: number | undefined | null;
role?: string | undefined | null;
onclick: (e: MouseEvent) => void;
disabled?: boolean;
}
let {
type = 'button',
href = undefined,
icon,
color = 'transparent',
title,
padding = '3',
size = '24',
hideMobile = false,
buttonSize = undefined,
viewBox = undefined,
class: className = '',
onclick,
...rest
}: Props = $props();
const colorClasses: Record<Color, string> = { const colorClasses: Record<Color, string> = {
transparent: 'bg-transparent hover:bg-[#d3d3d3] dark:text-immich-dark-fg', transparent: 'bg-transparent hover:bg-[#d3d3d3] dark:text-immich-dark-fg',
@ -77,12 +77,12 @@
'3': 'p-3', '3': 'p-3',
}; };
$: colorClass = colorClasses[color]; let colorClass = $derived(colorClasses[color]);
$: mobileClass = hideMobile ? 'hidden sm:flex' : ''; let mobileClass = $derived(hideMobile ? 'hidden sm:flex' : '');
$: paddingClass = paddingClasses[padding]; let paddingClass = $derived(paddingClasses[padding]);
</script> </script>
<!-- svelte-ignore a11y-no-static-element-interactions --> <!-- svelte-ignore a11y_no_static_element_interactions -->
<svelte:element <svelte:element
this={href ? 'a' : 'button'} this={href ? 'a' : 'button'}
type={href ? undefined : type} type={href ? undefined : type}
@ -91,8 +91,8 @@
style:width={buttonSize ? buttonSize + 'px' : ''} style:width={buttonSize ? buttonSize + 'px' : ''}
style:height={buttonSize ? buttonSize + 'px' : ''} style:height={buttonSize ? buttonSize + 'px' : ''}
class="flex place-content-center place-items-center rounded-full {colorClass} {paddingClass} transition-all disabled:cursor-default hover:dark:text-immich-dark-gray {className} {mobileClass}" class="flex place-content-center place-items-center rounded-full {colorClass} {paddingClass} transition-all disabled:cursor-default hover:dark:text-immich-dark-gray {className} {mobileClass}"
on:click {onclick}
{...$$restProps} {...rest}
> >
<Icon path={icon} {size} ariaLabel={title} {viewBox} color="currentColor" /> <Icon path={icon} {size} ariaLabel={title} {viewBox} color="currentColor" />
</svelte:element> </svelte:element>

View file

@ -1,22 +1,25 @@
<script lang="ts" context="module"> <script lang="ts" module>
export type Color = 'transparent-primary' | 'transparent-gray'; export type Color = 'transparent-primary' | 'transparent-gray';
type BaseProps = {
color?: Color;
};
export type Props = (LinkProps & BaseProps) | (ButtonProps & BaseProps);
</script> </script>
<script lang="ts"> <script lang="ts">
import Button, { type ButtonProps, type LinkProps } from '$lib/components/elements/buttons/button.svelte'; import Button from '$lib/components/elements/buttons/button.svelte';
import type { Snippet } from 'svelte';
// eslint-disable-next-line @typescript-eslint/no-unused-vars interface Props {
type $$Props = Props; href?: string;
color?: Color;
children?: Snippet;
onclick?: (e: MouseEvent) => void;
title?: string;
disabled?: boolean;
fullwidth?: boolean;
class?: string;
}
export let color: Color = 'transparent-gray'; let { color = 'transparent-gray', children, ...rest }: Props = $props();
</script> </script>
<Button size="link" {color} shadow={false} rounded="lg" on:click {...$$restProps}> <Button size="link" {color} shadow={false} rounded="lg" {...rest}>
<slot /> {@render children?.()}
</Button> </Button>

View file

@ -2,13 +2,17 @@
import { t } from 'svelte-i18n'; import { t } from 'svelte-i18n';
import Button from './button.svelte'; import Button from './button.svelte';
/** interface Props {
* Target for the skip link to move focus to. /**
*/ * Target for the skip link to move focus to.
export let target: string = 'main'; */
export let text: string = $t('skip_to_content'); target?: string;
text?: string;
}
let isFocused = false; let { target = 'main', text = $t('skip_to_content') }: Props = $props();
let isFocused = $state(false);
const moveFocus = () => { const moveFocus = () => {
const targetEl = document.querySelector<HTMLElement>(target); const targetEl = document.querySelector<HTMLElement>(target);
@ -20,9 +24,9 @@
<Button <Button
size={'sm'} size={'sm'}
rounded="none" rounded="none"
on:click={moveFocus} onclick={moveFocus}
on:focus={() => (isFocused = true)} onfocus={() => (isFocused = true)}
on:blur={() => (isFocused = false)} onblur={() => (isFocused = false)}
> >
{text} {text}
</Button> </Button>

View file

@ -1,11 +1,25 @@
<script lang="ts"> <script lang="ts">
export let id: string; interface Props {
export let label: string; id: string;
export let checked: boolean | undefined = undefined; label: string;
export let disabled: boolean = false; checked?: boolean | undefined;
export let labelClass: string | undefined = undefined; disabled?: boolean;
export let name: string | undefined = undefined; labelClass?: string | undefined;
export let value: string | undefined = undefined; name?: string | undefined;
value?: string | undefined;
onchange?: () => void;
}
let {
id,
label,
checked = $bindable(),
disabled = false,
labelClass = undefined,
name = undefined,
value = undefined,
onchange = () => {},
}: Props = $props();
</script> </script>
<div class="flex items-center space-x-2"> <div class="flex items-center space-x-2">
@ -17,7 +31,7 @@
{disabled} {disabled}
class="size-5 flex-shrink-0 focus-visible:ring" class="size-5 flex-shrink-0 focus-visible:ring"
bind:checked bind:checked
on:change {onchange}
/> />
<label class={labelClass} for={id}>{label}</label> <label class={labelClass} for={id}>{label}</label>
</div> </div>

View file

@ -1,29 +1,35 @@
<script lang="ts"> <script lang="ts">
import type { HTMLInputAttributes } from 'svelte/elements'; interface Props {
interface $$Props extends HTMLInputAttributes {
type: 'date' | 'datetime-local'; type: 'date' | 'datetime-local';
value?: string;
min?: string;
max?: string;
class?: string;
id?: string;
name?: string;
placeholder?: string;
} }
export let type: $$Props['type']; let { type, value = $bindable(), max = undefined, ...rest }: Props = $props();
export let value: $$Props['value'] = undefined;
export let max: $$Props['max'] = undefined;
$: fallbackMax = type === 'date' ? '9999-12-31' : '9999-12-31T23:59'; let fallbackMax = $derived(type === 'date' ? '9999-12-31' : '9999-12-31T23:59');
// Updating `value` directly causes the date input to reset itself or // Updating `value` directly causes the date input to reset itself or
// interfere with user changes. // interfere with user changes.
$: updatedValue = value; let updatedValue = $state<string>();
$effect(() => {
updatedValue = value;
});
</script> </script>
<input <input
{...$$restProps} {...rest}
{type} {type}
{value} {value}
max={max || fallbackMax} max={max || fallbackMax}
on:input={(e) => (updatedValue = e.currentTarget.value)} oninput={(e) => (updatedValue = e.currentTarget.value)}
on:blur={() => (value = updatedValue)} onblur={() => (value = updatedValue)}
on:keydown={(e) => { onkeydown={(e) => {
if (e.key === 'Enter') { if (e.key === 'Enter') {
value = updatedValue; value = updatedValue;
} }

View file

@ -1,4 +1,4 @@
<script lang="ts" context="module"> <script lang="ts" module>
// Necessary for eslint // Necessary for eslint
/* eslint-disable @typescript-eslint/no-explicit-any */ /* eslint-disable @typescript-eslint/no-explicit-any */
type T = any; type T = any;
@ -20,19 +20,31 @@
import { clickOutside } from '$lib/actions/click-outside'; import { clickOutside } from '$lib/actions/click-outside';
import { fly } from 'svelte/transition'; import { fly } from 'svelte/transition';
let className = ''; interface Props {
export { className as class }; class?: string;
options: T[];
selectedOption?: any;
showMenu?: boolean;
controlable?: boolean;
hideTextOnSmallScreen?: boolean;
title?: string | undefined;
onSelect: (option: T) => void;
onClickOutside?: () => void;
render?: (item: T) => string | RenderedOption;
}
export let options: T[]; let {
export let selectedOption = options[0]; class: className = '',
export let showMenu = false; options,
export let controlable = false; selectedOption = $bindable(options[0]),
export let hideTextOnSmallScreen = true; showMenu = $bindable(false),
export let title: string | undefined = undefined; controlable = false,
export let onSelect: (option: T) => void; hideTextOnSmallScreen = true,
export let onClickOutside: () => void = () => {}; title = undefined,
onSelect,
export let render: (item: T) => string | RenderedOption = String; onClickOutside = () => {},
render = String,
}: Props = $props();
const handleClickOutside = () => { const handleClickOutside = () => {
if (!controlable) { if (!controlable) {
@ -65,12 +77,12 @@
} }
}; };
$: renderedSelectedOption = renderOption(selectedOption); let renderedSelectedOption = $derived(renderOption(selectedOption));
</script> </script>
<div use:clickOutside={{ onOutclick: handleClickOutside, onEscape: handleClickOutside }}> <div use:clickOutside={{ onOutclick: handleClickOutside, onEscape: handleClickOutside }}>
<!-- BUTTON TITLE --> <!-- BUTTON TITLE -->
<LinkButton on:click={() => (showMenu = true)} fullwidth {title}> <LinkButton onclick={() => (showMenu = true)} fullwidth {title}>
<div class="flex place-items-center gap-2 text-sm"> <div class="flex place-items-center gap-2 text-sm">
{#if renderedSelectedOption?.icon} {#if renderedSelectedOption?.icon}
<Icon path={renderedSelectedOption.icon} size="18" /> <Icon path={renderedSelectedOption.icon} size="18" />
@ -92,7 +104,7 @@
type="button" type="button"
class="grid grid-cols-[36px,1fr] place-items-center p-2 disabled:opacity-40 {buttonStyle}" class="grid grid-cols-[36px,1fr] place-items-center p-2 disabled:opacity-40 {buttonStyle}"
disabled={renderedOption.disabled} disabled={renderedOption.disabled}
on:click={() => !renderedOption.disabled && handleSelectOption(option)} onclick={() => !renderedOption.disabled && handleSelectOption(option)}
> >
{#if isEqual(selectedOption, option)} {#if isEqual(selectedOption, option)}
<div class="text-immich-primary dark:text-immich-dark-primary"> <div class="text-immich-primary dark:text-immich-dark-primary">

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