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:
parent
9203a61709
commit
0b3742cf13
310 changed files with 6435 additions and 4176 deletions
6
web/package-lock.json
generated
6
web/package-lock.json
generated
|
@ -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"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
|
@ -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",
|
||||||
|
|
|
@ -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" />
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
export const autoGrowHeight = (textarea: HTMLTextAreaElement, height = 'auto') => {
|
export const autoGrowHeight = (textarea?: HTMLTextAreaElement, height = 'auto') => {
|
||||||
if (!textarea) {
|
if (!textarea) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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">
|
||||||
|
|
|
@ -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">
|
||||||
|
|
|
@ -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}
|
||||||
|
|
|
@ -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}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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}
|
||||||
|
|
|
@ -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}
|
||||||
|
|
|
@ -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')}
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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')}
|
||||||
|
|
|
@ -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}
|
||||||
|
|
|
@ -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')}
|
||||||
|
|
|
@ -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')}
|
||||||
|
|
|
@ -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')}
|
||||||
|
|
|
@ -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}
|
||||||
|
|
|
@ -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}
|
||||||
/>
|
/>
|
||||||
|
|
|
@ -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'] })}
|
||||||
|
|
|
@ -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);
|
||||||
|
|
|
@ -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}
|
||||||
|
|
|
@ -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}
|
||||||
|
|
|
@ -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}
|
||||||
/>
|
/>
|
||||||
|
|
|
@ -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 }));
|
||||||
});
|
});
|
||||||
|
|
|
@ -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}
|
||||||
|
|
|
@ -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}
|
||||||
|
|
|
@ -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}
|
||||||
|
|
|
@ -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 {
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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">
|
||||||
|
|
|
@ -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"
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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" />
|
||||||
|
|
|
@ -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 -->
|
||||||
|
|
|
@ -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}
|
||||||
|
|
|
@ -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}
|
||||||
|
|
|
@ -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">
|
||||||
|
|
|
@ -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
|
||||||
>
|
>
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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);
|
||||||
|
|
|
@ -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} />
|
||||||
|
|
|
@ -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}
|
||||||
|
|
|
@ -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}
|
||||||
|
|
|
@ -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}
|
||||||
/>
|
/>
|
||||||
|
|
|
@ -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)}
|
||||||
/>
|
/>
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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 {
|
||||||
|
|
|
@ -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 {
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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">
|
||||||
|
|
|
@ -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')} />
|
||||||
|
|
|
@ -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]);
|
||||||
|
|
|
@ -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}
|
||||||
|
|
|
@ -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}
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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">
|
||||||
|
|
|
@ -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} -->
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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>
|
||||||
|
|
||||||
|
|
|
@ -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">
|
||||||
|
|
|
@ -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 {
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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}
|
||||||
|
|
|
@ -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"
|
||||||
|
|
|
@ -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"
|
||||||
>
|
>
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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>
|
||||||
|
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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}
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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 }}
|
||||||
>
|
>
|
||||||
|
|
|
@ -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}
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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}
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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}
|
||||||
|
|
|
@ -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"
|
||||||
|
|
|
@ -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),
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
@ -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
Loading…
Reference in a new issue