mirror of
https://github.com/immich-app/immich.git
synced 2026-05-18 03:10:24 +03:00
strip down core
This commit is contained in:
@@ -1,105 +0,0 @@
|
||||
import type { Attachment } from 'svelte/attachments';
|
||||
|
||||
export interface DragAndDropOptions {
|
||||
index: number;
|
||||
onDragStart?: (index: number) => void;
|
||||
onDragEnter?: (index: number) => void;
|
||||
onDrop?: (e: DragEvent, index: number) => void;
|
||||
onDragEnd?: () => void;
|
||||
isDragging?: boolean;
|
||||
isDragOver?: boolean;
|
||||
}
|
||||
|
||||
export function dragAndDrop(options: DragAndDropOptions): Attachment {
|
||||
return (node: Element) => {
|
||||
const element = node as HTMLElement;
|
||||
const { index, onDragStart, onDragEnter, onDrop, onDragEnd, isDragging, isDragOver } = options;
|
||||
|
||||
const isFormElement = (el: HTMLElement) => {
|
||||
return el.tagName === 'INPUT' || el.tagName === 'TEXTAREA' || el.tagName === 'SELECT';
|
||||
};
|
||||
|
||||
const handleDragStart = (e: DragEvent) => {
|
||||
// Prevent drag if it originated from an input, textarea, or select element
|
||||
const target = e.target as HTMLElement;
|
||||
if (isFormElement(target)) {
|
||||
e.preventDefault();
|
||||
return;
|
||||
}
|
||||
onDragStart?.(index);
|
||||
};
|
||||
|
||||
const handleDragEnter = () => {
|
||||
onDragEnter?.(index);
|
||||
};
|
||||
|
||||
const handleDragOver = (e: DragEvent) => {
|
||||
e.preventDefault();
|
||||
};
|
||||
|
||||
const handleDrop = (e: DragEvent) => {
|
||||
onDrop?.(e, index);
|
||||
};
|
||||
|
||||
const handleDragEnd = () => {
|
||||
onDragEnd?.();
|
||||
};
|
||||
|
||||
// Disable draggable when focusing on form elements (fixes Firefox input interaction)
|
||||
const handleFocusIn = (e: FocusEvent) => {
|
||||
const target = e.target as HTMLElement;
|
||||
if (isFormElement(target)) {
|
||||
element.setAttribute('draggable', 'false');
|
||||
}
|
||||
};
|
||||
|
||||
const handleFocusOut = (e: FocusEvent) => {
|
||||
const target = e.target as HTMLElement;
|
||||
if (isFormElement(target)) {
|
||||
element.setAttribute('draggable', 'true');
|
||||
}
|
||||
};
|
||||
|
||||
// Update classes based on drag state
|
||||
const updateClasses = (dragging: boolean, dragOver: boolean) => {
|
||||
// Remove all drag-related classes first
|
||||
element.classList.remove('opacity-50', 'border-light-500', 'border-solid');
|
||||
|
||||
// Add back only the active ones
|
||||
if (dragging) {
|
||||
element.classList.add('opacity-50');
|
||||
}
|
||||
|
||||
if (dragOver) {
|
||||
element.classList.add('border-light-500', 'border-solid');
|
||||
element.classList.remove('border-transparent');
|
||||
} else {
|
||||
element.classList.add('border-transparent');
|
||||
}
|
||||
};
|
||||
|
||||
element.setAttribute('draggable', 'true');
|
||||
element.setAttribute('role', 'button');
|
||||
element.setAttribute('tabindex', '0');
|
||||
|
||||
element.addEventListener('dragstart', handleDragStart);
|
||||
element.addEventListener('dragenter', handleDragEnter);
|
||||
element.addEventListener('dragover', handleDragOver);
|
||||
element.addEventListener('drop', handleDrop);
|
||||
element.addEventListener('dragend', handleDragEnd);
|
||||
element.addEventListener('focusin', handleFocusIn);
|
||||
element.addEventListener('focusout', handleFocusOut);
|
||||
|
||||
updateClasses(isDragging || false, isDragOver || false);
|
||||
|
||||
return () => {
|
||||
element.removeEventListener('dragstart', handleDragStart);
|
||||
element.removeEventListener('dragenter', handleDragEnter);
|
||||
element.removeEventListener('dragover', handleDragOver);
|
||||
element.removeEventListener('drop', handleDrop);
|
||||
element.removeEventListener('dragend', handleDragEnd);
|
||||
element.removeEventListener('focusin', handleFocusIn);
|
||||
element.removeEventListener('focusout', handleFocusOut);
|
||||
};
|
||||
};
|
||||
}
|
||||
@@ -1,291 +0,0 @@
|
||||
<script lang="ts">
|
||||
import SettingAccordion from '$lib/components/shared-components/settings/setting-accordion.svelte';
|
||||
import SettingInputField from '$lib/components/shared-components/settings/setting-input-field.svelte';
|
||||
import SettingSelect from '$lib/components/shared-components/settings/setting-select.svelte';
|
||||
import SettingSwitch from '$lib/components/shared-components/settings/setting-switch.svelte';
|
||||
import SettingButtonsRow from '$lib/components/shared-components/settings/SystemConfigButtonRow.svelte';
|
||||
import { SettingInputFieldType } from '$lib/constants';
|
||||
import FormatMessage from '$lib/elements/FormatMessage.svelte';
|
||||
import { featureFlagsManager } from '$lib/managers/feature-flags-manager.svelte';
|
||||
import { systemConfigManager } from '$lib/managers/system-config-manager.svelte';
|
||||
import AuthDisableLoginConfirmModal from '$lib/modals/AuthDisableLoginConfirmModal.svelte';
|
||||
import { handleError } from '$lib/utils/handle-error';
|
||||
import { OAuthTokenEndpointAuthMethod, unlinkAllOAuthAccountsAdmin } from '@immich/sdk';
|
||||
import { Button, modalManager, Text, toastManager } from '@immich/ui';
|
||||
import { mdiRestart } from '@mdi/js';
|
||||
import { t } from 'svelte-i18n';
|
||||
import { fade } from 'svelte/transition';
|
||||
|
||||
const disabled = $derived(featureFlagsManager.value.configFile);
|
||||
const config = $derived(systemConfigManager.value);
|
||||
let configToEdit = $state(systemConfigManager.cloneValue());
|
||||
|
||||
const handleToggleOverride = () => {
|
||||
// click runs before bind
|
||||
const previouslyEnabled = configToEdit.oauth.mobileOverrideEnabled;
|
||||
if (!previouslyEnabled && !configToEdit.oauth.mobileRedirectUri) {
|
||||
configToEdit.oauth.mobileRedirectUri = globalThis.location.origin + '/api/oauth/mobile-redirect';
|
||||
}
|
||||
};
|
||||
|
||||
const onBeforeSave = async () => {
|
||||
const allMethodsDisabled = !configToEdit.oauth.enabled && !configToEdit.passwordLogin.enabled;
|
||||
|
||||
if (allMethodsDisabled) {
|
||||
const confirmed = await modalManager.show(AuthDisableLoginConfirmModal);
|
||||
if (!confirmed) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
const handleUnlinkAllOAuthAccounts = async () => {
|
||||
const confirmed = await modalManager.showDialog({
|
||||
icon: mdiRestart,
|
||||
title: $t('admin.unlink_all_oauth_accounts'),
|
||||
prompt: $t('admin.unlink_all_oauth_accounts_prompt'),
|
||||
confirmColor: 'danger',
|
||||
});
|
||||
|
||||
if (!confirmed) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await unlinkAllOAuthAccountsAdmin();
|
||||
toastManager.success();
|
||||
} catch (error) {
|
||||
handleError(error, $t('errors.something_went_wrong'));
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<div>
|
||||
<div in:fade={{ duration: 500 }}>
|
||||
<form autocomplete="off" onsubmit={(e) => e.preventDefault()}>
|
||||
<div class="ms-4 mt-4 flex flex-col">
|
||||
<SettingAccordion
|
||||
key="oauth"
|
||||
title={$t('admin.oauth_settings')}
|
||||
subtitle={$t('admin.oauth_settings_description')}
|
||||
>
|
||||
<div class="ms-4 mt-4 flex flex-col gap-4">
|
||||
<Text size="small">
|
||||
<FormatMessage key="admin.oauth_settings_more_details">
|
||||
{#snippet children({ message })}
|
||||
<a
|
||||
href="https://docs.immich.app/administration/oauth"
|
||||
class="underline"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
{message}
|
||||
</a>
|
||||
{/snippet}
|
||||
</FormatMessage>
|
||||
</Text>
|
||||
|
||||
<SettingSwitch
|
||||
{disabled}
|
||||
title={$t('admin.oauth_enable_description')}
|
||||
bind:checked={configToEdit.oauth.enabled}
|
||||
/>
|
||||
|
||||
{#if configToEdit.oauth.enabled}
|
||||
<hr />
|
||||
|
||||
<div class="flex items-center gap-2 justify-between">
|
||||
<Text size="small">{$t('admin.unlink_all_oauth_accounts_description')}</Text>
|
||||
<Button size="small" onclick={handleUnlinkAllOAuthAccounts}
|
||||
>{$t('admin.unlink_all_oauth_accounts')}</Button
|
||||
>
|
||||
</div>
|
||||
|
||||
<SettingInputField
|
||||
inputType={SettingInputFieldType.TEXT}
|
||||
label="issuer_url"
|
||||
bind:value={configToEdit.oauth.issuerUrl}
|
||||
required={true}
|
||||
disabled={disabled || !configToEdit.oauth.enabled}
|
||||
isEdited={!(configToEdit.oauth.issuerUrl === config.oauth.issuerUrl)}
|
||||
/>
|
||||
|
||||
<SettingInputField
|
||||
inputType={SettingInputFieldType.TEXT}
|
||||
label="client_id"
|
||||
bind:value={configToEdit.oauth.clientId}
|
||||
required={true}
|
||||
disabled={disabled || !configToEdit.oauth.enabled}
|
||||
isEdited={!(configToEdit.oauth.clientId === config.oauth.clientId)}
|
||||
/>
|
||||
|
||||
<SettingInputField
|
||||
inputType={SettingInputFieldType.TEXT}
|
||||
label="client_secret"
|
||||
description={$t('admin.oauth_client_secret_description')}
|
||||
bind:value={configToEdit.oauth.clientSecret}
|
||||
disabled={disabled || !configToEdit.oauth.enabled}
|
||||
isEdited={!(configToEdit.oauth.clientSecret === config.oauth.clientSecret)}
|
||||
/>
|
||||
|
||||
{#if configToEdit.oauth.clientSecret}
|
||||
<SettingSelect
|
||||
label="token_endpoint_auth_method"
|
||||
bind:value={configToEdit.oauth.tokenEndpointAuthMethod}
|
||||
disabled={disabled || !configToEdit.oauth.enabled || !configToEdit.oauth.clientSecret}
|
||||
isEdited={!(configToEdit.oauth.tokenEndpointAuthMethod === config.oauth.tokenEndpointAuthMethod)}
|
||||
options={[
|
||||
{ value: OAuthTokenEndpointAuthMethod.ClientSecretPost, text: 'client_secret_post' },
|
||||
{ value: OAuthTokenEndpointAuthMethod.ClientSecretBasic, text: 'client_secret_basic' },
|
||||
]}
|
||||
name="tokenEndpointAuthMethod"
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<SettingInputField
|
||||
inputType={SettingInputFieldType.TEXT}
|
||||
label="scope"
|
||||
bind:value={configToEdit.oauth.scope}
|
||||
required={true}
|
||||
disabled={disabled || !configToEdit.oauth.enabled}
|
||||
isEdited={!(configToEdit.oauth.scope === config.oauth.scope)}
|
||||
/>
|
||||
|
||||
<SettingInputField
|
||||
inputType={SettingInputFieldType.TEXT}
|
||||
label="id_token_signed_response_alg"
|
||||
bind:value={configToEdit.oauth.signingAlgorithm}
|
||||
required={true}
|
||||
disabled={disabled || !configToEdit.oauth.enabled}
|
||||
isEdited={!(configToEdit.oauth.signingAlgorithm === config.oauth.signingAlgorithm)}
|
||||
/>
|
||||
|
||||
<SettingInputField
|
||||
inputType={SettingInputFieldType.TEXT}
|
||||
label="userinfo_signed_response_alg"
|
||||
bind:value={configToEdit.oauth.profileSigningAlgorithm}
|
||||
required={true}
|
||||
disabled={disabled || !configToEdit.oauth.enabled}
|
||||
isEdited={!(configToEdit.oauth.profileSigningAlgorithm === config.oauth.profileSigningAlgorithm)}
|
||||
/>
|
||||
|
||||
<SettingInputField
|
||||
inputType={SettingInputFieldType.NUMBER}
|
||||
label={$t('admin.oauth_timeout')}
|
||||
description={$t('admin.oauth_timeout_description')}
|
||||
required={true}
|
||||
bind:value={configToEdit.oauth.timeout}
|
||||
disabled={disabled || !configToEdit.oauth.enabled}
|
||||
isEdited={!(configToEdit.oauth.timeout === config.oauth.timeout)}
|
||||
/>
|
||||
|
||||
<SettingInputField
|
||||
inputType={SettingInputFieldType.TEXT}
|
||||
label={$t('admin.oauth_storage_label_claim')}
|
||||
description={$t('admin.oauth_storage_label_claim_description')}
|
||||
bind:value={configToEdit.oauth.storageLabelClaim}
|
||||
required={true}
|
||||
disabled={disabled || !configToEdit.oauth.enabled}
|
||||
isEdited={!(configToEdit.oauth.storageLabelClaim === config.oauth.storageLabelClaim)}
|
||||
/>
|
||||
|
||||
<SettingInputField
|
||||
inputType={SettingInputFieldType.TEXT}
|
||||
label={$t('admin.oauth_role_claim')}
|
||||
description={$t('admin.oauth_role_claim_description')}
|
||||
bind:value={configToEdit.oauth.roleClaim}
|
||||
required={true}
|
||||
disabled={disabled || !configToEdit.oauth.enabled}
|
||||
isEdited={!(configToEdit.oauth.roleClaim === config.oauth.roleClaim)}
|
||||
/>
|
||||
|
||||
<SettingInputField
|
||||
inputType={SettingInputFieldType.TEXT}
|
||||
label={$t('admin.oauth_storage_quota_claim')}
|
||||
description={$t('admin.oauth_storage_quota_claim_description')}
|
||||
bind:value={configToEdit.oauth.storageQuotaClaim}
|
||||
required={true}
|
||||
disabled={disabled || !configToEdit.oauth.enabled}
|
||||
isEdited={!(configToEdit.oauth.storageQuotaClaim === config.oauth.storageQuotaClaim)}
|
||||
/>
|
||||
|
||||
<SettingInputField
|
||||
inputType={SettingInputFieldType.NUMBER}
|
||||
label={$t('admin.oauth_storage_quota_default')}
|
||||
description={$t('admin.oauth_storage_quota_default_description')}
|
||||
bind:value={configToEdit.oauth.defaultStorageQuota}
|
||||
required={false}
|
||||
disabled={disabled || !configToEdit.oauth.enabled}
|
||||
isEdited={!(configToEdit.oauth.defaultStorageQuota === config.oauth.defaultStorageQuota)}
|
||||
/>
|
||||
|
||||
<SettingInputField
|
||||
inputType={SettingInputFieldType.TEXT}
|
||||
label={$t('admin.oauth_button_text')}
|
||||
bind:value={configToEdit.oauth.buttonText}
|
||||
required={false}
|
||||
disabled={disabled || !configToEdit.oauth.enabled}
|
||||
isEdited={!(configToEdit.oauth.buttonText === config.oauth.buttonText)}
|
||||
/>
|
||||
|
||||
<SettingSwitch
|
||||
title={$t('admin.oauth_auto_register')}
|
||||
subtitle={$t('admin.oauth_auto_register_description')}
|
||||
bind:checked={configToEdit.oauth.autoRegister}
|
||||
disabled={disabled || !configToEdit.oauth.enabled}
|
||||
/>
|
||||
|
||||
<SettingSwitch
|
||||
title={$t('admin.oauth_auto_launch')}
|
||||
subtitle={$t('admin.oauth_auto_launch_description')}
|
||||
disabled={disabled || !configToEdit.oauth.enabled}
|
||||
bind:checked={configToEdit.oauth.autoLaunch}
|
||||
/>
|
||||
|
||||
<SettingSwitch
|
||||
title={$t('admin.oauth_mobile_redirect_uri_override')}
|
||||
subtitle={$t('admin.oauth_mobile_redirect_uri_override_description', {
|
||||
values: { callback: 'app.immich:///oauth-callback' },
|
||||
})}
|
||||
disabled={disabled || !configToEdit.oauth.enabled}
|
||||
onToggle={() => handleToggleOverride()}
|
||||
bind:checked={configToEdit.oauth.mobileOverrideEnabled}
|
||||
/>
|
||||
|
||||
{#if configToEdit.oauth.mobileOverrideEnabled}
|
||||
<SettingInputField
|
||||
inputType={SettingInputFieldType.TEXT}
|
||||
label={$t('admin.oauth_mobile_redirect_uri')}
|
||||
bind:value={configToEdit.oauth.mobileRedirectUri}
|
||||
required={true}
|
||||
disabled={disabled || !configToEdit.oauth.enabled}
|
||||
isEdited={!(configToEdit.oauth.mobileRedirectUri === config.oauth.mobileRedirectUri)}
|
||||
/>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
</SettingAccordion>
|
||||
|
||||
<SettingAccordion
|
||||
key="password"
|
||||
title={$t('admin.password_settings')}
|
||||
subtitle={$t('admin.password_settings_description')}
|
||||
>
|
||||
<div class="ms-4 mt-4 flex flex-col gap-4">
|
||||
<div class="ms-4 mt-4 flex flex-col">
|
||||
<SettingSwitch
|
||||
title={$t('admin.password_enable_description')}
|
||||
{disabled}
|
||||
bind:checked={configToEdit.passwordLogin.enabled}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</SettingAccordion>
|
||||
|
||||
<SettingButtonsRow bind:configToEdit keys={['passwordLogin', 'oauth']} {onBeforeSave} {disabled} />
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,83 +0,0 @@
|
||||
<script lang="ts">
|
||||
import SettingButtonsRow from '$lib/components/shared-components/settings/SystemConfigButtonRow.svelte';
|
||||
import SettingInputField from '$lib/components/shared-components/settings/setting-input-field.svelte';
|
||||
import SettingSelect from '$lib/components/shared-components/settings/setting-select.svelte';
|
||||
import SettingSwitch from '$lib/components/shared-components/settings/setting-switch.svelte';
|
||||
import { SettingInputFieldType } from '$lib/constants';
|
||||
import FormatMessage from '$lib/elements/FormatMessage.svelte';
|
||||
import { featureFlagsManager } from '$lib/managers/feature-flags-manager.svelte';
|
||||
import { systemConfigManager } from '$lib/managers/system-config-manager.svelte';
|
||||
import { t } from 'svelte-i18n';
|
||||
import { fade } from 'svelte/transition';
|
||||
|
||||
const disabled = $derived(featureFlagsManager.value.configFile);
|
||||
const config = $derived(systemConfigManager.value);
|
||||
let configToEdit = $state(systemConfigManager.cloneValue());
|
||||
|
||||
let cronExpressionOptions = $derived([
|
||||
{ text: $t('interval.night_at_midnight'), value: '0 0 * * *' },
|
||||
{ text: $t('interval.night_at_twoam'), value: '0 02 * * *' },
|
||||
{ text: $t('interval.day_at_onepm'), value: '0 13 * * *' },
|
||||
{ text: $t('interval.hours', { values: { hours: 6 } }), value: '0 */6 * * *' },
|
||||
]);
|
||||
</script>
|
||||
|
||||
<div>
|
||||
<div in:fade={{ duration: 500 }}>
|
||||
<form autocomplete="off" onsubmit={(event) => event.preventDefault()}>
|
||||
<div class="ms-4 mt-4 flex flex-col gap-4">
|
||||
<SettingSwitch
|
||||
title={$t('admin.backup_database_enable_description')}
|
||||
{disabled}
|
||||
bind:checked={configToEdit.backup.database.enabled}
|
||||
/>
|
||||
|
||||
<SettingSelect
|
||||
options={cronExpressionOptions}
|
||||
disabled={disabled || !configToEdit.backup.database.enabled}
|
||||
name="expression"
|
||||
label={$t('admin.cron_expression_presets')}
|
||||
bind:value={configToEdit.backup.database.cronExpression}
|
||||
/>
|
||||
|
||||
<SettingInputField
|
||||
inputType={SettingInputFieldType.TEXT}
|
||||
required={true}
|
||||
disabled={disabled || !configToEdit.backup.database.enabled}
|
||||
label={$t('admin.cron_expression')}
|
||||
bind:value={configToEdit.backup.database.cronExpression}
|
||||
isEdited={configToEdit.backup.database.cronExpression !== config.backup.database.cronExpression}
|
||||
>
|
||||
{#snippet descriptionSnippet()}
|
||||
<p class="text-sm dark:text-immich-dark-fg">
|
||||
<FormatMessage key="admin.cron_expression_description">
|
||||
{#snippet children({ message })}
|
||||
<a
|
||||
href="https://crontab.guru/#{configToEdit.backup.database.cronExpression.replaceAll(' ', '_')}"
|
||||
class="underline"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
{message}
|
||||
<br />
|
||||
</a>
|
||||
{/snippet}
|
||||
</FormatMessage>
|
||||
</p>
|
||||
{/snippet}
|
||||
</SettingInputField>
|
||||
|
||||
<SettingInputField
|
||||
inputType={SettingInputFieldType.NUMBER}
|
||||
required={true}
|
||||
label={$t('admin.backup_keep_last_amount')}
|
||||
disabled={disabled || !configToEdit.backup.database.enabled}
|
||||
bind:value={configToEdit.backup.database.keepLastAmount}
|
||||
isEdited={configToEdit.backup.database.keepLastAmount !== config.backup.database.keepLastAmount}
|
||||
/>
|
||||
|
||||
<SettingButtonsRow {disabled} bind:configToEdit keys={['backup']} />
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,404 +0,0 @@
|
||||
<script lang="ts">
|
||||
import SettingAccordion from '$lib/components/shared-components/settings/setting-accordion.svelte';
|
||||
import SettingCheckboxes from '$lib/components/shared-components/settings/setting-checkboxes.svelte';
|
||||
import SettingInputField from '$lib/components/shared-components/settings/setting-input-field.svelte';
|
||||
import SettingSelect from '$lib/components/shared-components/settings/setting-select.svelte';
|
||||
import SettingSwitch from '$lib/components/shared-components/settings/setting-switch.svelte';
|
||||
import SettingButtonsRow from '$lib/components/shared-components/settings/SystemConfigButtonRow.svelte';
|
||||
import { SettingInputFieldType } from '$lib/constants';
|
||||
import FormatMessage from '$lib/elements/FormatMessage.svelte';
|
||||
import { featureFlagsManager } from '$lib/managers/feature-flags-manager.svelte';
|
||||
import { systemConfigManager } from '$lib/managers/system-config-manager.svelte';
|
||||
import {
|
||||
AudioCodec,
|
||||
CQMode,
|
||||
ToneMapping,
|
||||
TranscodeHWAccel,
|
||||
TranscodePolicy,
|
||||
VideoCodec,
|
||||
VideoContainer,
|
||||
} from '@immich/sdk';
|
||||
import { Icon } from '@immich/ui';
|
||||
import { mdiHelpCircleOutline } from '@mdi/js';
|
||||
import { isEqual, sortBy } from 'lodash-es';
|
||||
import { t } from 'svelte-i18n';
|
||||
import { fade } from 'svelte/transition';
|
||||
|
||||
const disabled = $derived(featureFlagsManager.value.configFile);
|
||||
const config = $derived(systemConfigManager.value);
|
||||
let configToEdit = $state(systemConfigManager.cloneValue());
|
||||
</script>
|
||||
|
||||
<div>
|
||||
<div in:fade={{ duration: 500 }}>
|
||||
<form autocomplete="off" onsubmit={(event) => event.preventDefault()}>
|
||||
<div class="ms-4 mt-4 flex flex-col gap-4">
|
||||
<p class="text-sm dark:text-immich-dark-fg">
|
||||
<Icon icon={mdiHelpCircleOutline} class="inline" size="15" />
|
||||
<FormatMessage key="admin.transcoding_codecs_learn_more">
|
||||
{#snippet children({ tag, message })}
|
||||
{#if tag === 'h264-link'}
|
||||
<a href="https://trac.ffmpeg.org/wiki/Encode/H.264" class="underline" target="_blank" rel="noreferrer">
|
||||
{message}
|
||||
</a>
|
||||
{:else if tag === 'hevc-link'}
|
||||
<a href="https://trac.ffmpeg.org/wiki/Encode/H.265" class="underline" target="_blank" rel="noreferrer">
|
||||
{message}
|
||||
</a>
|
||||
{:else if tag === 'vp9-link'}
|
||||
<a href="https://trac.ffmpeg.org/wiki/Encode/VP9" class="underline" target="_blank" rel="noreferrer">
|
||||
{message}
|
||||
</a>
|
||||
{/if}
|
||||
{/snippet}
|
||||
</FormatMessage>
|
||||
</p>
|
||||
|
||||
<SettingAccordion
|
||||
key="transcoding-policy"
|
||||
title={$t('admin.transcoding_policy')}
|
||||
subtitle={$t('admin.transcoding_policy_description')}
|
||||
>
|
||||
<div class="ms-4 mt-4 flex flex-col gap-4">
|
||||
<SettingSelect
|
||||
label={$t('admin.transcoding_transcode_policy')}
|
||||
{disabled}
|
||||
desc={$t('admin.transcoding_transcode_policy_description')}
|
||||
bind:value={configToEdit.ffmpeg.transcode}
|
||||
name="transcode"
|
||||
options={[
|
||||
{ value: TranscodePolicy.All, text: $t('all_videos') },
|
||||
{
|
||||
value: TranscodePolicy.Optimal,
|
||||
text: $t('admin.transcoding_optimal_description'),
|
||||
},
|
||||
{
|
||||
value: TranscodePolicy.Bitrate,
|
||||
text: $t('admin.transcoding_bitrate_description'),
|
||||
},
|
||||
{
|
||||
value: TranscodePolicy.Required,
|
||||
text: $t('admin.transcoding_required_description'),
|
||||
},
|
||||
{
|
||||
value: TranscodePolicy.Disabled,
|
||||
text: $t('admin.transcoding_disabled_description'),
|
||||
},
|
||||
]}
|
||||
isEdited={configToEdit.ffmpeg.transcode !== config.ffmpeg.transcode}
|
||||
/>
|
||||
|
||||
<SettingCheckboxes
|
||||
label={$t('admin.transcoding_accepted_video_codecs')}
|
||||
{disabled}
|
||||
desc={$t('admin.transcoding_accepted_video_codecs_description')}
|
||||
bind:value={configToEdit.ffmpeg.acceptedVideoCodecs}
|
||||
name="videoCodecs"
|
||||
options={[
|
||||
{ value: VideoCodec.H264, text: 'H.264' },
|
||||
{ value: VideoCodec.Hevc, text: 'HEVC' },
|
||||
{ value: VideoCodec.Vp9, text: 'VP9' },
|
||||
{ value: VideoCodec.Av1, text: 'AV1' },
|
||||
]}
|
||||
isEdited={!isEqual(
|
||||
sortBy(configToEdit.ffmpeg.acceptedVideoCodecs),
|
||||
sortBy(config.ffmpeg.acceptedVideoCodecs),
|
||||
)}
|
||||
/>
|
||||
|
||||
<SettingCheckboxes
|
||||
label={$t('admin.transcoding_accepted_audio_codecs')}
|
||||
{disabled}
|
||||
desc={$t('admin.transcoding_accepted_audio_codecs_description')}
|
||||
bind:value={configToEdit.ffmpeg.acceptedAudioCodecs}
|
||||
name="audioCodecs"
|
||||
options={[
|
||||
{ value: AudioCodec.Aac, text: 'AAC' },
|
||||
{ value: AudioCodec.Mp3, text: 'MP3' },
|
||||
{ value: AudioCodec.Libopus, text: 'Opus' },
|
||||
{ value: AudioCodec.PcmS16Le, text: 'PCM (16 bit)' },
|
||||
]}
|
||||
isEdited={!isEqual(
|
||||
sortBy(configToEdit.ffmpeg.acceptedAudioCodecs),
|
||||
sortBy(config.ffmpeg.acceptedAudioCodecs),
|
||||
)}
|
||||
/>
|
||||
|
||||
<SettingCheckboxes
|
||||
label={$t('admin.transcoding_accepted_containers')}
|
||||
{disabled}
|
||||
desc={$t('admin.transcoding_accepted_containers_description')}
|
||||
bind:value={configToEdit.ffmpeg.acceptedContainers}
|
||||
name="videoContainers"
|
||||
options={[
|
||||
{ value: VideoContainer.Mov, text: 'MOV' },
|
||||
{ value: VideoContainer.Ogg, text: 'Ogg' },
|
||||
{ value: VideoContainer.Webm, text: 'WebM' },
|
||||
]}
|
||||
isEdited={!isEqual(
|
||||
sortBy(configToEdit.ffmpeg.acceptedContainers),
|
||||
sortBy(config.ffmpeg.acceptedContainers),
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</SettingAccordion>
|
||||
|
||||
<SettingAccordion
|
||||
key="encoding-options"
|
||||
title={$t('admin.transcoding_encoding_options')}
|
||||
subtitle={$t('admin.transcoding_encoding_options_description')}
|
||||
>
|
||||
<div class="ms-4 mt-4 flex flex-col gap-4">
|
||||
<SettingSelect
|
||||
label={$t('admin.transcoding_video_codec')}
|
||||
{disabled}
|
||||
desc={$t('admin.transcoding_video_codec_description')}
|
||||
bind:value={configToEdit.ffmpeg.targetVideoCodec}
|
||||
options={[
|
||||
{ value: VideoCodec.H264, text: 'h264' },
|
||||
{ value: VideoCodec.Hevc, text: 'hevc' },
|
||||
{ value: VideoCodec.Vp9, text: 'vp9' },
|
||||
{ value: VideoCodec.Av1, text: 'av1' },
|
||||
]}
|
||||
name="vcodec"
|
||||
isEdited={configToEdit.ffmpeg.targetVideoCodec !== config.ffmpeg.targetVideoCodec}
|
||||
onSelect={() => (configToEdit.ffmpeg.acceptedVideoCodecs = [configToEdit.ffmpeg.targetVideoCodec])}
|
||||
/>
|
||||
|
||||
<!-- PCM is excluded here since it's a bad choice for users storage-wise -->
|
||||
<SettingSelect
|
||||
label={$t('admin.transcoding_audio_codec')}
|
||||
{disabled}
|
||||
desc={$t('admin.transcoding_audio_codec_description')}
|
||||
bind:value={configToEdit.ffmpeg.targetAudioCodec}
|
||||
options={[
|
||||
{ value: AudioCodec.Aac, text: 'aac' },
|
||||
{ value: AudioCodec.Mp3, text: 'mp3' },
|
||||
{ value: AudioCodec.Libopus, text: 'opus' },
|
||||
]}
|
||||
name="acodec"
|
||||
isEdited={configToEdit.ffmpeg.targetAudioCodec !== config.ffmpeg.targetAudioCodec}
|
||||
onSelect={() =>
|
||||
configToEdit.ffmpeg.acceptedAudioCodecs.includes(configToEdit.ffmpeg.targetAudioCodec)
|
||||
? null
|
||||
: configToEdit.ffmpeg.acceptedAudioCodecs.push(configToEdit.ffmpeg.targetAudioCodec)}
|
||||
/>
|
||||
|
||||
<SettingSelect
|
||||
label={$t('admin.transcoding_target_resolution')}
|
||||
{disabled}
|
||||
desc={$t('admin.transcoding_target_resolution_description')}
|
||||
bind:value={configToEdit.ffmpeg.targetResolution}
|
||||
options={[
|
||||
{ value: '2160', text: '4k' },
|
||||
{ value: '1440', text: '1440p' },
|
||||
{ value: '1080', text: '1080p' },
|
||||
{ value: '720', text: '720p' },
|
||||
{ value: '480', text: '480p' },
|
||||
{ value: 'original', text: $t('original') },
|
||||
]}
|
||||
name="resolution"
|
||||
isEdited={configToEdit.ffmpeg.targetResolution !== config.ffmpeg.targetResolution}
|
||||
/>
|
||||
|
||||
<SettingInputField
|
||||
inputType={SettingInputFieldType.NUMBER}
|
||||
{disabled}
|
||||
label={$t('admin.transcoding_constant_rate_factor')}
|
||||
description={$t('admin.transcoding_constant_rate_factor_description')}
|
||||
bind:value={configToEdit.ffmpeg.crf}
|
||||
required={true}
|
||||
isEdited={configToEdit.ffmpeg.crf !== config.ffmpeg.crf}
|
||||
/>
|
||||
|
||||
<SettingSelect
|
||||
label={$t('admin.transcoding_preset_preset')}
|
||||
{disabled}
|
||||
desc={$t('admin.transcoding_preset_preset_description')}
|
||||
bind:value={configToEdit.ffmpeg.preset}
|
||||
name="preset"
|
||||
options={[
|
||||
{ value: 'ultrafast', text: 'ultrafast' },
|
||||
{ value: 'superfast', text: 'superfast' },
|
||||
{ value: 'veryfast', text: 'veryfast' },
|
||||
{ value: 'faster', text: 'faster' },
|
||||
{ value: 'fast', text: 'fast' },
|
||||
{ value: 'medium', text: 'medium' },
|
||||
{ value: 'slow', text: 'slow' },
|
||||
{ value: 'slower', text: 'slower' },
|
||||
{ value: 'veryslow', text: 'veryslow' },
|
||||
]}
|
||||
isEdited={configToEdit.ffmpeg.preset !== config.ffmpeg.preset}
|
||||
/>
|
||||
|
||||
<SettingInputField
|
||||
inputType={SettingInputFieldType.TEXT}
|
||||
{disabled}
|
||||
label={$t('admin.transcoding_max_bitrate')}
|
||||
description={$t('admin.transcoding_max_bitrate_description')}
|
||||
bind:value={configToEdit.ffmpeg.maxBitrate}
|
||||
isEdited={configToEdit.ffmpeg.maxBitrate !== config.ffmpeg.maxBitrate}
|
||||
/>
|
||||
|
||||
<SettingInputField
|
||||
inputType={SettingInputFieldType.NUMBER}
|
||||
{disabled}
|
||||
label={$t('admin.transcoding_threads')}
|
||||
description={$t('admin.transcoding_threads_description')}
|
||||
bind:value={configToEdit.ffmpeg.threads}
|
||||
isEdited={configToEdit.ffmpeg.threads !== config.ffmpeg.threads}
|
||||
/>
|
||||
|
||||
<SettingSelect
|
||||
label={$t('admin.transcoding_tone_mapping')}
|
||||
{disabled}
|
||||
desc={$t('admin.transcoding_tone_mapping_description')}
|
||||
bind:value={configToEdit.ffmpeg.tonemap}
|
||||
name="tonemap"
|
||||
options={[
|
||||
{
|
||||
value: ToneMapping.Hable,
|
||||
text: 'Hable',
|
||||
},
|
||||
{
|
||||
value: ToneMapping.Mobius,
|
||||
text: 'Mobius',
|
||||
},
|
||||
{
|
||||
value: ToneMapping.Reinhard,
|
||||
text: 'Reinhard',
|
||||
},
|
||||
{
|
||||
value: ToneMapping.Disabled,
|
||||
text: $t('disabled'),
|
||||
},
|
||||
]}
|
||||
isEdited={configToEdit.ffmpeg.tonemap !== config.ffmpeg.tonemap}
|
||||
/>
|
||||
|
||||
<SettingSwitch
|
||||
title={$t('admin.transcoding_two_pass_encoding')}
|
||||
{disabled}
|
||||
subtitle={$t('admin.transcoding_two_pass_encoding_setting_description')}
|
||||
bind:checked={configToEdit.ffmpeg.twoPass}
|
||||
isEdited={configToEdit.ffmpeg.twoPass !== config.ffmpeg.twoPass}
|
||||
/>
|
||||
</div>
|
||||
</SettingAccordion>
|
||||
|
||||
<SettingAccordion
|
||||
key="hardware-acceleration"
|
||||
title={$t('admin.transcoding_hardware_acceleration')}
|
||||
subtitle={$t('admin.transcoding_hardware_acceleration_description')}
|
||||
>
|
||||
<div class="ms-4 mt-4 flex flex-col gap-4">
|
||||
<SettingSelect
|
||||
label={$t('admin.transcoding_acceleration_api')}
|
||||
{disabled}
|
||||
desc={$t('admin.transcoding_acceleration_api_description')}
|
||||
bind:value={configToEdit.ffmpeg.accel}
|
||||
name="accel"
|
||||
options={[
|
||||
{ value: TranscodeHWAccel.Nvenc, text: $t('admin.transcoding_acceleration_nvenc') },
|
||||
{
|
||||
value: TranscodeHWAccel.Qsv,
|
||||
text: $t('admin.transcoding_acceleration_qsv'),
|
||||
},
|
||||
{
|
||||
value: TranscodeHWAccel.Vaapi,
|
||||
text: $t('admin.transcoding_acceleration_vaapi'),
|
||||
},
|
||||
{
|
||||
value: TranscodeHWAccel.Rkmpp,
|
||||
text: $t('admin.transcoding_acceleration_rkmpp'),
|
||||
},
|
||||
{
|
||||
value: TranscodeHWAccel.Disabled,
|
||||
text: $t('disabled'),
|
||||
},
|
||||
]}
|
||||
isEdited={configToEdit.ffmpeg.accel !== config.ffmpeg.accel}
|
||||
/>
|
||||
|
||||
<SettingSwitch
|
||||
title={$t('admin.transcoding_hardware_decoding')}
|
||||
{disabled}
|
||||
subtitle={$t('admin.transcoding_hardware_decoding_setting_description')}
|
||||
bind:checked={configToEdit.ffmpeg.accelDecode}
|
||||
isEdited={configToEdit.ffmpeg.accelDecode !== config.ffmpeg.accelDecode}
|
||||
/>
|
||||
|
||||
<SettingSelect
|
||||
label={$t('admin.transcoding_constant_quality_mode')}
|
||||
desc={$t('admin.transcoding_constant_quality_mode_description')}
|
||||
bind:value={configToEdit.ffmpeg.cqMode}
|
||||
options={[
|
||||
{ value: CQMode.Auto, text: 'Auto' },
|
||||
{ value: CQMode.Icq, text: 'ICQ' },
|
||||
{ value: CQMode.Cqp, text: 'CQP' },
|
||||
]}
|
||||
isEdited={configToEdit.ffmpeg.cqMode !== config.ffmpeg.cqMode}
|
||||
{disabled}
|
||||
/>
|
||||
|
||||
<SettingSwitch
|
||||
title={$t('admin.transcoding_temporal_aq')}
|
||||
{disabled}
|
||||
subtitle={$t('admin.transcoding_temporal_aq_description')}
|
||||
bind:checked={configToEdit.ffmpeg.temporalAQ}
|
||||
isEdited={configToEdit.ffmpeg.temporalAQ !== config.ffmpeg.temporalAQ}
|
||||
/>
|
||||
|
||||
<SettingInputField
|
||||
inputType={SettingInputFieldType.TEXT}
|
||||
label={$t('admin.transcoding_preferred_hardware_device')}
|
||||
description={$t('admin.transcoding_preferred_hardware_device_description')}
|
||||
bind:value={configToEdit.ffmpeg.preferredHwDevice}
|
||||
isEdited={configToEdit.ffmpeg.preferredHwDevice !== config.ffmpeg.preferredHwDevice}
|
||||
{disabled}
|
||||
/>
|
||||
</div>
|
||||
</SettingAccordion>
|
||||
|
||||
<SettingAccordion
|
||||
key="advanced-options"
|
||||
title={$t('advanced')}
|
||||
subtitle={$t('admin.transcoding_advanced_options_description')}
|
||||
>
|
||||
<div class="ms-4 mt-4 flex flex-col gap-4">
|
||||
<SettingInputField
|
||||
inputType={SettingInputFieldType.NUMBER}
|
||||
label={$t('admin.transcoding_max_b_frames')}
|
||||
description={$t('admin.transcoding_max_b_frames_description')}
|
||||
bind:value={configToEdit.ffmpeg.bframes}
|
||||
isEdited={configToEdit.ffmpeg.bframes !== config.ffmpeg.bframes}
|
||||
{disabled}
|
||||
/>
|
||||
|
||||
<SettingInputField
|
||||
inputType={SettingInputFieldType.NUMBER}
|
||||
label={$t('admin.transcoding_reference_frames')}
|
||||
description={$t('admin.transcoding_reference_frames_description')}
|
||||
bind:value={configToEdit.ffmpeg.refs}
|
||||
isEdited={configToEdit.ffmpeg.refs !== config.ffmpeg.refs}
|
||||
{disabled}
|
||||
/>
|
||||
|
||||
<SettingInputField
|
||||
inputType={SettingInputFieldType.NUMBER}
|
||||
label={$t('admin.transcoding_max_keyframe_interval')}
|
||||
description={$t('admin.transcoding_max_keyframe_interval_description')}
|
||||
bind:value={configToEdit.ffmpeg.gopSize}
|
||||
isEdited={configToEdit.ffmpeg.gopSize !== config.ffmpeg.gopSize}
|
||||
{disabled}
|
||||
/>
|
||||
</div>
|
||||
</SettingAccordion>
|
||||
</div>
|
||||
|
||||
<div class="ms-4">
|
||||
<SettingButtonsRow bind:configToEdit keys={['ffmpeg']} {disabled} />
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,224 +0,0 @@
|
||||
<script lang="ts">
|
||||
import SettingSelect from '$lib/components/shared-components/settings/setting-select.svelte';
|
||||
import { Colorspace, ImageFormat } from '@immich/sdk';
|
||||
import { fade } from 'svelte/transition';
|
||||
|
||||
import SettingAccordion from '$lib/components/shared-components/settings/setting-accordion.svelte';
|
||||
import SettingInputField from '$lib/components/shared-components/settings/setting-input-field.svelte';
|
||||
import SettingSwitch from '$lib/components/shared-components/settings/setting-switch.svelte';
|
||||
import SettingButtonsRow from '$lib/components/shared-components/settings/SystemConfigButtonRow.svelte';
|
||||
import { SettingInputFieldType } from '$lib/constants';
|
||||
import { featureFlagsManager } from '$lib/managers/feature-flags-manager.svelte';
|
||||
import { systemConfigManager } from '$lib/managers/system-config-manager.svelte';
|
||||
import { t } from 'svelte-i18n';
|
||||
|
||||
const disabled = $derived(featureFlagsManager.value.configFile);
|
||||
const config = $derived(systemConfigManager.value);
|
||||
let configToEdit = $state(systemConfigManager.cloneValue());
|
||||
</script>
|
||||
|
||||
<div>
|
||||
<div in:fade={{ duration: 500 }}>
|
||||
<form autocomplete="off" onsubmit={(event) => event.preventDefault()}>
|
||||
<div class="ms-4 mt-4">
|
||||
<SettingAccordion
|
||||
key="thumbnail-settings"
|
||||
title={$t('admin.image_thumbnail_title')}
|
||||
subtitle={$t('admin.image_thumbnail_description')}
|
||||
>
|
||||
<SettingSelect
|
||||
label={$t('admin.image_format')}
|
||||
desc={$t('admin.image_format_description')}
|
||||
bind:value={configToEdit.image.thumbnail.format}
|
||||
options={[
|
||||
{ value: ImageFormat.Jpeg, text: 'JPEG' },
|
||||
{ value: ImageFormat.Webp, text: 'WebP' },
|
||||
]}
|
||||
name="format"
|
||||
isEdited={configToEdit.image.thumbnail.format !== config.image.thumbnail.format}
|
||||
{disabled}
|
||||
onSelect={(value) => {
|
||||
if (value === ImageFormat.Webp) {
|
||||
configToEdit.image.thumbnail.progressive = false;
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
<SettingSelect
|
||||
label={$t('admin.image_resolution')}
|
||||
desc={$t('admin.image_resolution_description')}
|
||||
number
|
||||
bind:value={configToEdit.image.thumbnail.size}
|
||||
options={[
|
||||
{ value: 1080, text: '1080p' },
|
||||
{ value: 720, text: '720p' },
|
||||
{ value: 480, text: '480p' },
|
||||
{ value: 250, text: '250p' },
|
||||
{ value: 200, text: '200p' },
|
||||
]}
|
||||
name="resolution"
|
||||
isEdited={configToEdit.image.thumbnail.size !== config.image.thumbnail.size}
|
||||
{disabled}
|
||||
/>
|
||||
|
||||
<SettingInputField
|
||||
inputType={SettingInputFieldType.NUMBER}
|
||||
label={$t('admin.image_quality')}
|
||||
description={$t('admin.image_thumbnail_quality_description')}
|
||||
bind:value={configToEdit.image.thumbnail.quality}
|
||||
isEdited={configToEdit.image.thumbnail.quality !== config.image.thumbnail.quality}
|
||||
{disabled}
|
||||
/>
|
||||
|
||||
<SettingSwitch
|
||||
title={$t('admin.image_progressive')}
|
||||
subtitle={$t('admin.image_progressive_description')}
|
||||
checked={configToEdit.image.thumbnail.progressive}
|
||||
onToggle={(isChecked) => (configToEdit.image.thumbnail.progressive = isChecked)}
|
||||
isEdited={configToEdit.image.thumbnail.progressive !== config.image.thumbnail.progressive}
|
||||
disabled={disabled || configToEdit.image.thumbnail.format === ImageFormat.Webp}
|
||||
/>
|
||||
</SettingAccordion>
|
||||
|
||||
<SettingAccordion
|
||||
key="preview-settings"
|
||||
title={$t('admin.image_preview_title')}
|
||||
subtitle={$t('admin.image_preview_description')}
|
||||
>
|
||||
<SettingSelect
|
||||
label={$t('admin.image_format')}
|
||||
desc={$t('admin.image_format_description')}
|
||||
bind:value={configToEdit.image.preview.format}
|
||||
options={[
|
||||
{ value: ImageFormat.Jpeg, text: 'JPEG' },
|
||||
{ value: ImageFormat.Webp, text: 'WebP' },
|
||||
]}
|
||||
name="format"
|
||||
isEdited={configToEdit.image.preview.format !== config.image.preview.format}
|
||||
{disabled}
|
||||
onSelect={(value) => {
|
||||
if (value === ImageFormat.Webp) {
|
||||
configToEdit.image.preview.progressive = false;
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
<SettingSelect
|
||||
label={$t('admin.image_resolution')}
|
||||
desc={$t('admin.image_resolution_description')}
|
||||
number
|
||||
bind:value={configToEdit.image.preview.size}
|
||||
options={[
|
||||
{ value: 2160, text: '4K' },
|
||||
{ value: 1440, text: '1440p' },
|
||||
{ value: 1080, text: '1080p' },
|
||||
{ value: 720, text: '720p' },
|
||||
]}
|
||||
name="resolution"
|
||||
isEdited={configToEdit.image.preview.size !== config.image.preview.size}
|
||||
{disabled}
|
||||
/>
|
||||
|
||||
<SettingInputField
|
||||
inputType={SettingInputFieldType.NUMBER}
|
||||
label={$t('admin.image_quality')}
|
||||
description={$t('admin.image_preview_quality_description')}
|
||||
bind:value={configToEdit.image.preview.quality}
|
||||
isEdited={configToEdit.image.preview.quality !== config.image.preview.quality}
|
||||
{disabled}
|
||||
/>
|
||||
|
||||
<SettingSwitch
|
||||
title={$t('admin.image_progressive')}
|
||||
subtitle={$t('admin.image_progressive_description')}
|
||||
checked={configToEdit.image.preview.progressive}
|
||||
onToggle={(isChecked) => (configToEdit.image.preview.progressive = isChecked)}
|
||||
isEdited={configToEdit.image.preview.progressive !== config.image.preview.progressive}
|
||||
disabled={disabled || configToEdit.image.preview.format === ImageFormat.Webp}
|
||||
/>
|
||||
</SettingAccordion>
|
||||
|
||||
<SettingAccordion
|
||||
key="fullsize-settings"
|
||||
title={$t('admin.image_fullsize_title')}
|
||||
subtitle={$t('admin.image_fullsize_description')}
|
||||
>
|
||||
<SettingSwitch
|
||||
title={$t('admin.image_fullsize_enabled')}
|
||||
subtitle={$t('admin.image_fullsize_enabled_description')}
|
||||
checked={configToEdit.image.fullsize.enabled}
|
||||
onToggle={(isChecked) => (configToEdit.image.fullsize.enabled = isChecked)}
|
||||
isEdited={configToEdit.image.fullsize.enabled !== config.image.fullsize.enabled}
|
||||
{disabled}
|
||||
/>
|
||||
|
||||
<hr class="my-4" />
|
||||
|
||||
<SettingSelect
|
||||
label={$t('admin.image_format')}
|
||||
desc={$t('admin.image_format_description')}
|
||||
bind:value={configToEdit.image.fullsize.format}
|
||||
options={[
|
||||
{ value: ImageFormat.Jpeg, text: 'JPEG' },
|
||||
{ value: ImageFormat.Webp, text: 'WebP' },
|
||||
]}
|
||||
name="format"
|
||||
isEdited={configToEdit.image.fullsize.format !== config.image.fullsize.format}
|
||||
disabled={disabled || !configToEdit.image.fullsize.enabled}
|
||||
onSelect={(value) => {
|
||||
if (value === ImageFormat.Webp) {
|
||||
configToEdit.image.fullsize.progressive = false;
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
<SettingInputField
|
||||
inputType={SettingInputFieldType.NUMBER}
|
||||
label={$t('admin.image_quality')}
|
||||
description={$t('admin.image_fullsize_quality_description')}
|
||||
bind:value={configToEdit.image.fullsize.quality}
|
||||
isEdited={configToEdit.image.fullsize.quality !== config.image.fullsize.quality}
|
||||
disabled={disabled || !configToEdit.image.fullsize.enabled}
|
||||
/>
|
||||
|
||||
<SettingSwitch
|
||||
title={$t('admin.image_progressive')}
|
||||
subtitle={$t('admin.image_progressive_description')}
|
||||
checked={configToEdit.image.fullsize.progressive}
|
||||
onToggle={(isChecked) => (configToEdit.image.fullsize.progressive = isChecked)}
|
||||
isEdited={configToEdit.image.fullsize.progressive !== config.image.fullsize.progressive}
|
||||
disabled={disabled ||
|
||||
!configToEdit.image.fullsize.enabled ||
|
||||
configToEdit.image.fullsize.format === ImageFormat.Webp}
|
||||
/>
|
||||
</SettingAccordion>
|
||||
|
||||
<div class="mt-4">
|
||||
<SettingSwitch
|
||||
title={$t('admin.image_prefer_wide_gamut')}
|
||||
subtitle={$t('admin.image_prefer_wide_gamut_setting_description')}
|
||||
checked={configToEdit.image.colorspace === Colorspace.P3}
|
||||
onToggle={(isChecked) => (configToEdit.image.colorspace = isChecked ? Colorspace.P3 : Colorspace.Srgb)}
|
||||
isEdited={configToEdit.image.colorspace !== config.image.colorspace}
|
||||
{disabled}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="mt-4">
|
||||
<SettingSwitch
|
||||
title={$t('admin.image_prefer_embedded_preview')}
|
||||
subtitle={$t('admin.image_prefer_embedded_preview_setting_description')}
|
||||
checked={configToEdit.image.extractEmbedded}
|
||||
onToggle={() => (configToEdit.image.extractEmbedded = !configToEdit.image.extractEmbedded)}
|
||||
isEdited={configToEdit.image.extractEmbedded !== config.image.extractEmbedded}
|
||||
{disabled}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="ms-4 mt-4">
|
||||
<SettingButtonsRow bind:configToEdit keys={['image']} {disabled} />
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,68 +0,0 @@
|
||||
<script lang="ts">
|
||||
import SettingButtonsRow from '$lib/components/shared-components/settings/SystemConfigButtonRow.svelte';
|
||||
import SettingInputField from '$lib/components/shared-components/settings/setting-input-field.svelte';
|
||||
import { SettingInputFieldType } from '$lib/constants';
|
||||
import { featureFlagsManager } from '$lib/managers/feature-flags-manager.svelte';
|
||||
import { systemConfigManager } from '$lib/managers/system-config-manager.svelte';
|
||||
import { getQueueName } from '$lib/utils';
|
||||
import { QueueName, type SystemConfigJobDto } from '@immich/sdk';
|
||||
import { t } from 'svelte-i18n';
|
||||
import { fade } from 'svelte/transition';
|
||||
|
||||
const disabled = $derived(featureFlagsManager.value.configFile);
|
||||
const config = $derived(systemConfigManager.value);
|
||||
let configToEdit = $state(systemConfigManager.cloneValue());
|
||||
|
||||
const queueNames = [
|
||||
QueueName.ThumbnailGeneration,
|
||||
QueueName.MetadataExtraction,
|
||||
QueueName.Library,
|
||||
QueueName.Sidecar,
|
||||
QueueName.SmartSearch,
|
||||
QueueName.FaceDetection,
|
||||
QueueName.FacialRecognition,
|
||||
QueueName.VideoConversion,
|
||||
QueueName.StorageTemplateMigration,
|
||||
QueueName.Migration,
|
||||
QueueName.Ocr,
|
||||
];
|
||||
|
||||
function isSystemConfigJobDto(jobName: string): jobName is keyof SystemConfigJobDto {
|
||||
return jobName in configToEdit.job;
|
||||
}
|
||||
</script>
|
||||
|
||||
<div>
|
||||
<div in:fade={{ duration: 500 }}>
|
||||
<form autocomplete="off" onsubmit={(event) => event.preventDefault()}>
|
||||
{#each queueNames as queueName (queueName)}
|
||||
<div class="ms-4 mt-4 flex flex-col gap-4">
|
||||
{#if isSystemConfigJobDto(queueName)}
|
||||
<SettingInputField
|
||||
inputType={SettingInputFieldType.NUMBER}
|
||||
{disabled}
|
||||
label={$t('admin.job_concurrency', { values: { job: $getQueueName(queueName) } })}
|
||||
description=""
|
||||
bind:value={configToEdit.job[queueName].concurrency}
|
||||
required={true}
|
||||
isEdited={!(configToEdit.job[queueName].concurrency == config.job[queueName].concurrency)}
|
||||
/>
|
||||
{:else}
|
||||
<SettingInputField
|
||||
inputType={SettingInputFieldType.NUMBER}
|
||||
label={$t('admin.job_concurrency', { values: { job: $getQueueName(queueName) } })}
|
||||
description=""
|
||||
value={1}
|
||||
disabled={true}
|
||||
title={$t('admin.job_not_concurrency_safe')}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
|
||||
<div class="ms-4">
|
||||
<SettingButtonsRow bind:configToEdit keys={['job']} {disabled} />
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,96 +0,0 @@
|
||||
<script lang="ts">
|
||||
import SettingAccordion from '$lib/components/shared-components/settings/setting-accordion.svelte';
|
||||
import SettingInputField from '$lib/components/shared-components/settings/setting-input-field.svelte';
|
||||
import SettingSelect from '$lib/components/shared-components/settings/setting-select.svelte';
|
||||
import SettingSwitch from '$lib/components/shared-components/settings/setting-switch.svelte';
|
||||
import SettingButtonsRow from '$lib/components/shared-components/settings/SystemConfigButtonRow.svelte';
|
||||
import { SettingInputFieldType } from '$lib/constants';
|
||||
import FormatMessage from '$lib/elements/FormatMessage.svelte';
|
||||
import { featureFlagsManager } from '$lib/managers/feature-flags-manager.svelte';
|
||||
import { systemConfigManager } from '$lib/managers/system-config-manager.svelte';
|
||||
import { t } from 'svelte-i18n';
|
||||
import { fade } from 'svelte/transition';
|
||||
|
||||
const disabled = $derived(featureFlagsManager.value.configFile);
|
||||
const config = $derived(systemConfigManager.value);
|
||||
let configToEdit = $state(systemConfigManager.cloneValue());
|
||||
|
||||
let cronExpressionOptions = $derived([
|
||||
{ text: $t('interval.night_at_midnight'), value: '0 0 * * *' },
|
||||
{ text: $t('interval.night_at_twoam'), value: '0 2 * * *' },
|
||||
{ text: $t('interval.day_at_onepm'), value: '0 13 * * *' },
|
||||
{ text: $t('interval.hours', { values: { hours: 6 } }), value: '0 */6 * * *' },
|
||||
]);
|
||||
</script>
|
||||
|
||||
<div>
|
||||
<div in:fade={{ duration: 500 }}>
|
||||
<form autocomplete="off" onsubmit={(event) => event.preventDefault()}>
|
||||
<div class="ms-4 mt-4 flex flex-col gap-4">
|
||||
<SettingAccordion
|
||||
key="library-watching"
|
||||
title={$t('admin.library_watching_settings')}
|
||||
subtitle={$t('admin.library_watching_settings_description')}
|
||||
>
|
||||
<div class="ms-4 mt-4 flex flex-col gap-4">
|
||||
<SettingSwitch
|
||||
title={$t('admin.library_watching_enable_description')}
|
||||
{disabled}
|
||||
bind:checked={configToEdit.library.watch.enabled}
|
||||
/>
|
||||
</div>
|
||||
</SettingAccordion>
|
||||
|
||||
<SettingAccordion
|
||||
key="library-scanning"
|
||||
title={$t('admin.library_scanning')}
|
||||
subtitle={$t('admin.library_scanning_description')}
|
||||
>
|
||||
<div class="ms-4 mt-4 flex flex-col gap-4">
|
||||
<SettingSwitch
|
||||
title={$t('admin.library_scanning_enable_description')}
|
||||
{disabled}
|
||||
bind:checked={configToEdit.library.scan.enabled}
|
||||
/>
|
||||
|
||||
<SettingSelect
|
||||
options={cronExpressionOptions}
|
||||
disabled={disabled || !configToEdit.library.scan.enabled}
|
||||
name="expression"
|
||||
label={$t('admin.cron_expression_presets')}
|
||||
bind:value={configToEdit.library.scan.cronExpression}
|
||||
/>
|
||||
|
||||
<SettingInputField
|
||||
inputType={SettingInputFieldType.TEXT}
|
||||
required={true}
|
||||
disabled={disabled || !configToEdit.library.scan.enabled}
|
||||
label={$t('admin.cron_expression')}
|
||||
bind:value={configToEdit.library.scan.cronExpression}
|
||||
isEdited={configToEdit.library.scan.cronExpression !== config.library.scan.cronExpression}
|
||||
>
|
||||
{#snippet descriptionSnippet()}
|
||||
<p class="text-sm dark:text-immich-dark-fg">
|
||||
<FormatMessage key="admin.cron_expression_description">
|
||||
{#snippet children({ message })}
|
||||
<a
|
||||
href="https://crontab.guru/#{configToEdit.library.scan.cronExpression.replaceAll(' ', '_')}"
|
||||
class="underline"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
{message}
|
||||
</a>
|
||||
{/snippet}
|
||||
</FormatMessage>
|
||||
</p>
|
||||
{/snippet}
|
||||
</SettingInputField>
|
||||
</div>
|
||||
</SettingAccordion>
|
||||
|
||||
<SettingButtonsRow bind:configToEdit keys={['library']} {disabled} />
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,46 +0,0 @@
|
||||
<script lang="ts">
|
||||
import SettingButtonsRow from '$lib/components/shared-components/settings/SystemConfigButtonRow.svelte';
|
||||
import SettingSelect from '$lib/components/shared-components/settings/setting-select.svelte';
|
||||
import SettingSwitch from '$lib/components/shared-components/settings/setting-switch.svelte';
|
||||
import { featureFlagsManager } from '$lib/managers/feature-flags-manager.svelte';
|
||||
import { systemConfigManager } from '$lib/managers/system-config-manager.svelte';
|
||||
import { LogLevel } from '@immich/sdk';
|
||||
import { t } from 'svelte-i18n';
|
||||
import { fade } from 'svelte/transition';
|
||||
|
||||
const disabled = $derived(featureFlagsManager.value.configFile);
|
||||
const config = $derived(systemConfigManager.value);
|
||||
let configToEdit = $state(systemConfigManager.cloneValue());
|
||||
</script>
|
||||
|
||||
<div>
|
||||
<div in:fade={{ duration: 500 }}>
|
||||
<form autocomplete="off" onsubmit={(event) => event.preventDefault()}>
|
||||
<div class="ms-4 mt-4 flex flex-col gap-4">
|
||||
<SettingSwitch
|
||||
title={$t('admin.logging_enable_description')}
|
||||
{disabled}
|
||||
bind:checked={configToEdit.logging.enabled}
|
||||
/>
|
||||
<SettingSelect
|
||||
label={$t('level')}
|
||||
desc={$t('admin.logging_level_description')}
|
||||
bind:value={configToEdit.logging.level}
|
||||
options={[
|
||||
{ value: LogLevel.Fatal, text: 'Fatal' },
|
||||
{ value: LogLevel.Error, text: 'Error' },
|
||||
{ value: LogLevel.Warn, text: 'Warn' },
|
||||
{ value: LogLevel.Log, text: 'Log' },
|
||||
{ value: LogLevel.Debug, text: 'Debug' },
|
||||
{ value: LogLevel.Verbose, text: 'Verbose' },
|
||||
]}
|
||||
name="level"
|
||||
isEdited={configToEdit.logging.level !== config.logging.level}
|
||||
disabled={disabled || !configToEdit.logging.enabled}
|
||||
/>
|
||||
|
||||
<SettingButtonsRow bind:configToEdit keys={['logging']} {disabled} />
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,331 +0,0 @@
|
||||
<script lang="ts">
|
||||
import SettingAccordion from '$lib/components/shared-components/settings/setting-accordion.svelte';
|
||||
import SettingInputField from '$lib/components/shared-components/settings/setting-input-field.svelte';
|
||||
import SettingSelect from '$lib/components/shared-components/settings/setting-select.svelte';
|
||||
import SettingSwitch from '$lib/components/shared-components/settings/setting-switch.svelte';
|
||||
import SettingButtonsRow from '$lib/components/shared-components/settings/SystemConfigButtonRow.svelte';
|
||||
import { SettingInputFieldType } from '$lib/constants';
|
||||
import FormatMessage from '$lib/elements/FormatMessage.svelte';
|
||||
import { featureFlagsManager } from '$lib/managers/feature-flags-manager.svelte';
|
||||
import { systemConfigManager } from '$lib/managers/system-config-manager.svelte';
|
||||
import { Button, IconButton } from '@immich/ui';
|
||||
import { mdiPlus, mdiTrashCanOutline } from '@mdi/js';
|
||||
import { isEqual } from 'lodash-es';
|
||||
import { t } from 'svelte-i18n';
|
||||
import { fade } from 'svelte/transition';
|
||||
|
||||
const disabled = $derived(featureFlagsManager.value.configFile);
|
||||
const config = $derived(systemConfigManager.value);
|
||||
let configToEdit = $state(systemConfigManager.cloneValue());
|
||||
</script>
|
||||
|
||||
<div class="mt-2">
|
||||
<div in:fade={{ duration: 500 }}>
|
||||
<form autocomplete="off" class="mx-4 mt-4" onsubmit={(event) => event.preventDefault()}>
|
||||
<div class="flex flex-col gap-4">
|
||||
<SettingSwitch
|
||||
title={$t('admin.machine_learning_enabled')}
|
||||
subtitle={$t('admin.machine_learning_enabled_description')}
|
||||
{disabled}
|
||||
bind:checked={configToEdit.machineLearning.enabled}
|
||||
/>
|
||||
|
||||
<hr />
|
||||
|
||||
<div>
|
||||
{#each configToEdit.machineLearning.urls as _, i (i)}
|
||||
<SettingInputField
|
||||
inputType={SettingInputFieldType.TEXT}
|
||||
label={i === 0 ? $t('url') : undefined}
|
||||
description={i === 0 ? $t('admin.machine_learning_url_description') : undefined}
|
||||
bind:value={configToEdit.machineLearning.urls[i]}
|
||||
required={i === 0}
|
||||
disabled={disabled || !configToEdit.machineLearning.enabled}
|
||||
isEdited={i === 0 && !isEqual(configToEdit.machineLearning.urls, config.machineLearning.urls)}
|
||||
>
|
||||
{#snippet trailingSnippet()}
|
||||
{#if configToEdit.machineLearning.urls.length > 1}
|
||||
<IconButton
|
||||
aria-label=""
|
||||
onclick={() => configToEdit.machineLearning.urls.splice(i, 1)}
|
||||
icon={mdiTrashCanOutline}
|
||||
color="danger"
|
||||
/>
|
||||
{/if}
|
||||
{/snippet}
|
||||
</SettingInputField>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end">
|
||||
<Button
|
||||
class="mb-2"
|
||||
size="small"
|
||||
shape="round"
|
||||
leadingIcon={mdiPlus}
|
||||
onclick={() => configToEdit.machineLearning.urls.push('')}
|
||||
disabled={disabled || !configToEdit.machineLearning.enabled}>{$t('add_url')}</Button
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<SettingAccordion
|
||||
key="availability-checks"
|
||||
title={$t('admin.machine_learning_availability_checks')}
|
||||
subtitle={$t('admin.machine_learning_availability_checks_description')}
|
||||
>
|
||||
<div class="ms-4 mt-4 flex flex-col gap-4">
|
||||
<SettingSwitch
|
||||
title={$t('admin.machine_learning_availability_checks_enabled')}
|
||||
bind:checked={configToEdit.machineLearning.availabilityChecks.enabled}
|
||||
disabled={disabled || !configToEdit.machineLearning.enabled}
|
||||
/>
|
||||
|
||||
<hr />
|
||||
|
||||
<SettingInputField
|
||||
inputType={SettingInputFieldType.NUMBER}
|
||||
label={$t('admin.machine_learning_availability_checks_interval')}
|
||||
bind:value={configToEdit.machineLearning.availabilityChecks.interval}
|
||||
description={$t('admin.machine_learning_availability_checks_interval_description')}
|
||||
disabled={disabled ||
|
||||
!configToEdit.machineLearning.enabled ||
|
||||
!configToEdit.machineLearning.availabilityChecks.enabled}
|
||||
isEdited={configToEdit.machineLearning.availabilityChecks.interval !==
|
||||
config.machineLearning.availabilityChecks.interval}
|
||||
/>
|
||||
|
||||
<SettingInputField
|
||||
inputType={SettingInputFieldType.NUMBER}
|
||||
label={$t('admin.machine_learning_availability_checks_timeout')}
|
||||
bind:value={configToEdit.machineLearning.availabilityChecks.timeout}
|
||||
description={$t('admin.machine_learning_availability_checks_timeout_description')}
|
||||
disabled={disabled ||
|
||||
!configToEdit.machineLearning.enabled ||
|
||||
!configToEdit.machineLearning.availabilityChecks.enabled}
|
||||
isEdited={configToEdit.machineLearning.availabilityChecks.timeout !==
|
||||
config.machineLearning.availabilityChecks.timeout}
|
||||
/>
|
||||
</div>
|
||||
</SettingAccordion>
|
||||
|
||||
<SettingAccordion
|
||||
key="smart-search"
|
||||
title={$t('admin.machine_learning_smart_search')}
|
||||
subtitle={$t('admin.machine_learning_smart_search_description')}
|
||||
>
|
||||
<div class="ms-4 mt-4 flex flex-col gap-4">
|
||||
<SettingSwitch
|
||||
title={$t('admin.machine_learning_smart_search_enabled')}
|
||||
subtitle={$t('admin.machine_learning_smart_search_enabled_description')}
|
||||
bind:checked={configToEdit.machineLearning.clip.enabled}
|
||||
disabled={disabled || !configToEdit.machineLearning.enabled}
|
||||
/>
|
||||
|
||||
<hr />
|
||||
|
||||
<SettingInputField
|
||||
inputType={SettingInputFieldType.TEXT}
|
||||
label={$t('admin.machine_learning_clip_model')}
|
||||
bind:value={configToEdit.machineLearning.clip.modelName}
|
||||
required={true}
|
||||
disabled={disabled || !configToEdit.machineLearning.enabled || !configToEdit.machineLearning.clip.enabled}
|
||||
isEdited={configToEdit.machineLearning.clip.modelName !== config.machineLearning.clip.modelName}
|
||||
>
|
||||
{#snippet descriptionSnippet()}
|
||||
<p class="immich-form-label pb-2 text-sm">
|
||||
<FormatMessage key="admin.machine_learning_clip_model_description">
|
||||
{#snippet children({ message })}
|
||||
<a target="_blank" href="https://huggingface.co/immich-app"><u>{message}</u></a>
|
||||
{/snippet}
|
||||
</FormatMessage>
|
||||
</p>
|
||||
{/snippet}
|
||||
</SettingInputField>
|
||||
</div>
|
||||
</SettingAccordion>
|
||||
|
||||
<SettingAccordion
|
||||
key="duplicate-detection"
|
||||
title={$t('admin.machine_learning_duplicate_detection')}
|
||||
subtitle={$t('admin.machine_learning_duplicate_detection_setting_description')}
|
||||
>
|
||||
<div class="ms-4 mt-4 flex flex-col gap-4">
|
||||
<SettingSwitch
|
||||
title={$t('admin.machine_learning_duplicate_detection_enabled')}
|
||||
subtitle={$t('admin.machine_learning_duplicate_detection_enabled_description')}
|
||||
bind:checked={configToEdit.machineLearning.duplicateDetection.enabled}
|
||||
disabled={disabled || !configToEdit.machineLearning.enabled || !configToEdit.machineLearning.clip.enabled}
|
||||
/>
|
||||
|
||||
<hr />
|
||||
|
||||
<SettingInputField
|
||||
inputType={SettingInputFieldType.NUMBER}
|
||||
label={$t('admin.machine_learning_max_detection_distance')}
|
||||
bind:value={configToEdit.machineLearning.duplicateDetection.maxDistance}
|
||||
step="0.0005"
|
||||
min={0.001}
|
||||
max={0.1}
|
||||
description={$t('admin.machine_learning_max_detection_distance_description')}
|
||||
disabled={disabled || !featureFlagsManager.value.duplicateDetection}
|
||||
isEdited={configToEdit.machineLearning.duplicateDetection.maxDistance !==
|
||||
config.machineLearning.duplicateDetection.maxDistance}
|
||||
/>
|
||||
</div>
|
||||
</SettingAccordion>
|
||||
|
||||
<SettingAccordion
|
||||
key="facial-recognition"
|
||||
title={$t('admin.machine_learning_facial_recognition')}
|
||||
subtitle={$t('admin.machine_learning_facial_recognition_description')}
|
||||
>
|
||||
<div class="ms-4 mt-4 flex flex-col gap-4">
|
||||
<SettingSwitch
|
||||
title={$t('admin.machine_learning_facial_recognition_setting')}
|
||||
subtitle={$t('admin.machine_learning_facial_recognition_setting_description')}
|
||||
bind:checked={configToEdit.machineLearning.facialRecognition.enabled}
|
||||
disabled={disabled || !configToEdit.machineLearning.enabled}
|
||||
/>
|
||||
|
||||
<hr />
|
||||
|
||||
<SettingSelect
|
||||
label={$t('admin.machine_learning_facial_recognition_model')}
|
||||
desc={$t('admin.machine_learning_facial_recognition_model_description')}
|
||||
name="facial-recognition-model"
|
||||
bind:value={configToEdit.machineLearning.facialRecognition.modelName}
|
||||
options={[
|
||||
{ value: 'antelopev2', text: 'antelopev2' },
|
||||
{ value: 'buffalo_l', text: 'buffalo_l' },
|
||||
{ value: 'buffalo_m', text: 'buffalo_m' },
|
||||
{ value: 'buffalo_s', text: 'buffalo_s' },
|
||||
]}
|
||||
disabled={disabled ||
|
||||
!configToEdit.machineLearning.enabled ||
|
||||
!configToEdit.machineLearning.facialRecognition.enabled}
|
||||
isEdited={configToEdit.machineLearning.facialRecognition.modelName !==
|
||||
config.machineLearning.facialRecognition.modelName}
|
||||
/>
|
||||
|
||||
<SettingInputField
|
||||
inputType={SettingInputFieldType.NUMBER}
|
||||
label={$t('admin.machine_learning_min_detection_score')}
|
||||
description={$t('admin.machine_learning_min_detection_score_description')}
|
||||
bind:value={configToEdit.machineLearning.facialRecognition.minScore}
|
||||
step="0.01"
|
||||
min={0.1}
|
||||
max={1}
|
||||
disabled={disabled ||
|
||||
!configToEdit.machineLearning.enabled ||
|
||||
!configToEdit.machineLearning.facialRecognition.enabled}
|
||||
isEdited={configToEdit.machineLearning.facialRecognition.minScore !==
|
||||
config.machineLearning.facialRecognition.minScore}
|
||||
/>
|
||||
|
||||
<SettingInputField
|
||||
inputType={SettingInputFieldType.NUMBER}
|
||||
label={$t('admin.machine_learning_max_recognition_distance')}
|
||||
description={$t('admin.machine_learning_max_recognition_distance_description')}
|
||||
bind:value={configToEdit.machineLearning.facialRecognition.maxDistance}
|
||||
step="0.01"
|
||||
min={0.1}
|
||||
max={2}
|
||||
disabled={disabled ||
|
||||
!configToEdit.machineLearning.enabled ||
|
||||
!configToEdit.machineLearning.facialRecognition.enabled}
|
||||
isEdited={configToEdit.machineLearning.facialRecognition.maxDistance !==
|
||||
config.machineLearning.facialRecognition.maxDistance}
|
||||
/>
|
||||
|
||||
<SettingInputField
|
||||
inputType={SettingInputFieldType.NUMBER}
|
||||
label={$t('admin.machine_learning_min_recognized_faces')}
|
||||
description={$t('admin.machine_learning_min_recognized_faces_description')}
|
||||
bind:value={configToEdit.machineLearning.facialRecognition.minFaces}
|
||||
step="1"
|
||||
min={1}
|
||||
disabled={disabled ||
|
||||
!configToEdit.machineLearning.enabled ||
|
||||
!configToEdit.machineLearning.facialRecognition.enabled}
|
||||
isEdited={configToEdit.machineLearning.facialRecognition.minFaces !==
|
||||
config.machineLearning.facialRecognition.minFaces}
|
||||
/>
|
||||
</div>
|
||||
</SettingAccordion>
|
||||
|
||||
<SettingAccordion
|
||||
key="ocr"
|
||||
title={$t('admin.machine_learning_ocr')}
|
||||
subtitle={$t('admin.machine_learning_ocr_description')}
|
||||
>
|
||||
<div class="ml-4 mt-4 flex flex-col gap-4">
|
||||
<SettingSwitch
|
||||
title={$t('admin.machine_learning_ocr_enabled')}
|
||||
subtitle={$t('admin.machine_learning_ocr_enabled_description')}
|
||||
bind:checked={configToEdit.machineLearning.ocr.enabled}
|
||||
disabled={disabled || !configToEdit.machineLearning.enabled}
|
||||
/>
|
||||
|
||||
<hr />
|
||||
|
||||
<SettingSelect
|
||||
label={$t('admin.machine_learning_ocr_model')}
|
||||
desc={$t('admin.machine_learning_ocr_model_description')}
|
||||
name="ocr-model"
|
||||
bind:value={configToEdit.machineLearning.ocr.modelName}
|
||||
options={[
|
||||
{ text: 'PP-OCRv5_server (Chinese, Japanese and English)', value: 'PP-OCRv5_server' },
|
||||
{ text: 'PP-OCRv5_mobile (Chinese, Japanese and English)', value: 'PP-OCRv5_mobile' },
|
||||
{ text: 'PP-OCRv5_mobile (English-only)', value: 'EN__PP-OCRv5_mobile' },
|
||||
{ text: 'PP-OCRv5_mobile (Greek and English)', value: 'EL__PP-OCRv5_mobile' },
|
||||
{ text: 'PP-OCRv5_mobile (Korean and English)', value: 'KOREAN__PP-OCRv5_mobile' },
|
||||
{ text: 'PP-OCRv5_mobile (Latin script languages)', value: 'LATIN__PP-OCRv5_mobile' },
|
||||
{ text: 'PP-OCRv5_mobile (Russian, Belarusian, Ukrainian and English)', value: 'ESLAV__PP-OCRv5_mobile' },
|
||||
{ text: 'PP-OCRv5_mobile (Thai and English)', value: 'TH__PP-OCRv5_mobile' },
|
||||
]}
|
||||
disabled={disabled || !configToEdit.machineLearning.enabled || !configToEdit.machineLearning.ocr.enabled}
|
||||
isEdited={configToEdit.machineLearning.ocr.modelName !== config.machineLearning.ocr.modelName}
|
||||
/>
|
||||
|
||||
<SettingInputField
|
||||
inputType={SettingInputFieldType.NUMBER}
|
||||
label={$t('admin.machine_learning_ocr_min_detection_score')}
|
||||
description={$t('admin.machine_learning_ocr_min_detection_score_description')}
|
||||
bind:value={configToEdit.machineLearning.ocr.minDetectionScore}
|
||||
step="0.1"
|
||||
min={0.1}
|
||||
max={1}
|
||||
disabled={disabled || !configToEdit.machineLearning.enabled || !configToEdit.machineLearning.ocr.enabled}
|
||||
isEdited={configToEdit.machineLearning.ocr.minDetectionScore !==
|
||||
config.machineLearning.ocr.minDetectionScore}
|
||||
/>
|
||||
|
||||
<SettingInputField
|
||||
inputType={SettingInputFieldType.NUMBER}
|
||||
label={$t('admin.machine_learning_ocr_min_recognition_score')}
|
||||
description={$t('admin.machine_learning_ocr_min_score_recognition_description')}
|
||||
bind:value={configToEdit.machineLearning.ocr.minRecognitionScore}
|
||||
step="0.1"
|
||||
min={0.1}
|
||||
max={1}
|
||||
disabled={disabled || !configToEdit.machineLearning.enabled || !configToEdit.machineLearning.ocr.enabled}
|
||||
isEdited={configToEdit.machineLearning.ocr.minRecognitionScore !==
|
||||
config.machineLearning.ocr.minRecognitionScore}
|
||||
/>
|
||||
|
||||
<SettingInputField
|
||||
inputType={SettingInputFieldType.NUMBER}
|
||||
label={$t('admin.machine_learning_ocr_max_resolution')}
|
||||
description={$t('admin.machine_learning_ocr_max_resolution_description')}
|
||||
bind:value={configToEdit.machineLearning.ocr.maxResolution}
|
||||
min={1}
|
||||
disabled={disabled || !configToEdit.machineLearning.enabled || !configToEdit.machineLearning.ocr.enabled}
|
||||
isEdited={configToEdit.machineLearning.ocr.maxResolution !== config.machineLearning.ocr.maxResolution}
|
||||
/>
|
||||
</div>
|
||||
</SettingAccordion>
|
||||
<SettingButtonsRow bind:configToEdit keys={['machineLearning']} {disabled} />
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,82 +0,0 @@
|
||||
<script lang="ts">
|
||||
import SettingAccordion from '$lib/components/shared-components/settings/setting-accordion.svelte';
|
||||
import SettingInputField from '$lib/components/shared-components/settings/setting-input-field.svelte';
|
||||
import SettingSwitch from '$lib/components/shared-components/settings/setting-switch.svelte';
|
||||
import SettingButtonsRow from '$lib/components/shared-components/settings/SystemConfigButtonRow.svelte';
|
||||
import { SettingInputFieldType } from '$lib/constants';
|
||||
import FormatMessage from '$lib/elements/FormatMessage.svelte';
|
||||
import { featureFlagsManager } from '$lib/managers/feature-flags-manager.svelte';
|
||||
import { systemConfigManager } from '$lib/managers/system-config-manager.svelte';
|
||||
import { t } from 'svelte-i18n';
|
||||
import { fade } from 'svelte/transition';
|
||||
|
||||
const disabled = $derived(featureFlagsManager.value.configFile);
|
||||
const config = $derived(systemConfigManager.value);
|
||||
let configToEdit = $state(systemConfigManager.cloneValue());
|
||||
</script>
|
||||
|
||||
<div class="mt-2">
|
||||
<div in:fade={{ duration: 500 }}>
|
||||
<form autocomplete="off" onsubmit={(event) => event.preventDefault()}>
|
||||
<div class="flex flex-col gap-4">
|
||||
<SettingAccordion key="map" title={$t('admin.map_settings')} subtitle={$t('admin.map_settings_description')}>
|
||||
<div class="ms-4 mt-4 flex flex-col gap-4">
|
||||
<SettingSwitch
|
||||
title={$t('admin.map_enable_description')}
|
||||
subtitle={$t('admin.map_implications')}
|
||||
{disabled}
|
||||
bind:checked={configToEdit.map.enabled}
|
||||
/>
|
||||
|
||||
<hr />
|
||||
|
||||
<SettingInputField
|
||||
inputType={SettingInputFieldType.TEXT}
|
||||
label={$t('admin.map_light_style')}
|
||||
description={$t('admin.map_style_description')}
|
||||
bind:value={configToEdit.map.lightStyle}
|
||||
disabled={disabled || !configToEdit.map.enabled}
|
||||
isEdited={configToEdit.map.lightStyle !== config.map.lightStyle}
|
||||
/>
|
||||
<SettingInputField
|
||||
inputType={SettingInputFieldType.TEXT}
|
||||
label={$t('admin.map_dark_style')}
|
||||
description={$t('admin.map_style_description')}
|
||||
bind:value={configToEdit.map.darkStyle}
|
||||
disabled={disabled || !configToEdit.map.enabled}
|
||||
isEdited={configToEdit.map.darkStyle !== config.map.darkStyle}
|
||||
/>
|
||||
</div></SettingAccordion
|
||||
>
|
||||
|
||||
<SettingAccordion key="reverse-geocoding" title={$t('admin.map_reverse_geocoding_settings')}>
|
||||
{#snippet subtitleSnippet()}
|
||||
<p class="text-sm dark:text-immich-dark-fg">
|
||||
<FormatMessage key="admin.map_manage_reverse_geocoding_settings">
|
||||
{#snippet children({ message })}
|
||||
<a
|
||||
href="https://docs.immich.app/features/reverse-geocoding"
|
||||
class="underline"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
{message}
|
||||
</a>
|
||||
{/snippet}
|
||||
</FormatMessage>
|
||||
</p>
|
||||
{/snippet}
|
||||
<div class="ms-4 mt-4 flex flex-col gap-4">
|
||||
<SettingSwitch
|
||||
title={$t('admin.map_reverse_geocoding_enable_description')}
|
||||
{disabled}
|
||||
bind:checked={configToEdit.reverseGeocoding.enabled}
|
||||
/>
|
||||
</div></SettingAccordion
|
||||
>
|
||||
|
||||
<SettingButtonsRow bind:configToEdit keys={['map', 'reverseGeocoding']} {disabled} />
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,28 +0,0 @@
|
||||
<script lang="ts">
|
||||
import SettingButtonsRow from '$lib/components/shared-components/settings/SystemConfigButtonRow.svelte';
|
||||
import SettingSwitch from '$lib/components/shared-components/settings/setting-switch.svelte';
|
||||
import { featureFlagsManager } from '$lib/managers/feature-flags-manager.svelte';
|
||||
import { systemConfigManager } from '$lib/managers/system-config-manager.svelte';
|
||||
import { t } from 'svelte-i18n';
|
||||
import { fade } from 'svelte/transition';
|
||||
|
||||
const disabled = $derived(featureFlagsManager.value.configFile);
|
||||
let configToEdit = $state(systemConfigManager.cloneValue());
|
||||
</script>
|
||||
|
||||
<div class="mt-2">
|
||||
<div in:fade={{ duration: 500 }}>
|
||||
<form autocomplete="off" class="mx-4 mt-4" onsubmit={(event) => event.preventDefault()}>
|
||||
<div class="ms-4 mt-4 flex flex-col gap-4">
|
||||
<SettingSwitch
|
||||
title={$t('admin.metadata_faces_import_setting')}
|
||||
subtitle={$t('admin.metadata_faces_import_setting_description')}
|
||||
bind:checked={configToEdit.metadata.faces.import}
|
||||
{disabled}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<SettingButtonsRow bind:configToEdit keys={['metadata']} {disabled} />
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,27 +0,0 @@
|
||||
<script lang="ts">
|
||||
import SettingButtonsRow from '$lib/components/shared-components/settings/SystemConfigButtonRow.svelte';
|
||||
import SettingSwitch from '$lib/components/shared-components/settings/setting-switch.svelte';
|
||||
import { featureFlagsManager } from '$lib/managers/feature-flags-manager.svelte';
|
||||
import { systemConfigManager } from '$lib/managers/system-config-manager.svelte';
|
||||
import { t } from 'svelte-i18n';
|
||||
import { fade } from 'svelte/transition';
|
||||
|
||||
const disabled = $derived(featureFlagsManager.value.configFile);
|
||||
let configToEdit = $state(systemConfigManager.cloneValue());
|
||||
</script>
|
||||
|
||||
<div>
|
||||
<div in:fade={{ duration: 500 }}>
|
||||
<form autocomplete="off" onsubmit={(event) => event.preventDefault()}>
|
||||
<div class="ms-4 mt-4">
|
||||
<SettingSwitch
|
||||
title={$t('admin.version_check_enabled_description')}
|
||||
subtitle={$t('admin.version_check_implications')}
|
||||
bind:checked={configToEdit.newVersionCheck.enabled}
|
||||
{disabled}
|
||||
/>
|
||||
<SettingButtonsRow bind:configToEdit keys={['newVersionCheck']} {disabled} />
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,64 +0,0 @@
|
||||
<script lang="ts">
|
||||
import SettingButtonsRow from '$lib/components/shared-components/settings/SystemConfigButtonRow.svelte';
|
||||
import SettingInputField from '$lib/components/shared-components/settings/setting-input-field.svelte';
|
||||
import SettingSwitch from '$lib/components/shared-components/settings/setting-switch.svelte';
|
||||
import { SettingInputFieldType } from '$lib/constants';
|
||||
import { featureFlagsManager } from '$lib/managers/feature-flags-manager.svelte';
|
||||
import { systemConfigManager } from '$lib/managers/system-config-manager.svelte';
|
||||
import { t } from 'svelte-i18n';
|
||||
import { fade } from 'svelte/transition';
|
||||
|
||||
const disabled = $derived(featureFlagsManager.value.configFile);
|
||||
const config = $derived(systemConfigManager.value);
|
||||
let configToEdit = $state(systemConfigManager.cloneValue());
|
||||
</script>
|
||||
|
||||
<div class="mt-2">
|
||||
<div in:fade={{ duration: 500 }}>
|
||||
<form autocomplete="off" class="mx-4 mt-4" onsubmit={(event) => event.preventDefault()}>
|
||||
<div class="ms-4 mt-4 flex flex-col gap-4">
|
||||
<SettingInputField
|
||||
inputType={SettingInputFieldType.TEXT}
|
||||
label={$t('admin.nightly_tasks_start_time_setting')}
|
||||
description={$t('admin.nightly_tasks_start_time_setting_description')}
|
||||
bind:value={configToEdit.nightlyTasks.startTime}
|
||||
required={true}
|
||||
{disabled}
|
||||
isEdited={!(configToEdit.nightlyTasks.startTime === config.nightlyTasks.startTime)}
|
||||
/>
|
||||
<SettingSwitch
|
||||
title={$t('admin.nightly_tasks_database_cleanup_setting')}
|
||||
subtitle={$t('admin.nightly_tasks_database_cleanup_setting_description')}
|
||||
bind:checked={configToEdit.nightlyTasks.databaseCleanup}
|
||||
{disabled}
|
||||
/>
|
||||
<SettingSwitch
|
||||
title={$t('admin.nightly_tasks_missing_thumbnails_setting')}
|
||||
subtitle={$t('admin.nightly_tasks_missing_thumbnails_setting_description')}
|
||||
bind:checked={configToEdit.nightlyTasks.missingThumbnails}
|
||||
{disabled}
|
||||
/>
|
||||
<SettingSwitch
|
||||
title={$t('admin.nightly_tasks_cluster_new_faces_setting')}
|
||||
subtitle={$t('admin.nightly_tasks_cluster_faces_setting_description')}
|
||||
bind:checked={configToEdit.nightlyTasks.clusterNewFaces}
|
||||
{disabled}
|
||||
/>
|
||||
<SettingSwitch
|
||||
title={$t('admin.nightly_tasks_generate_memories_setting')}
|
||||
subtitle={$t('admin.nightly_tasks_generate_memories_setting_description')}
|
||||
bind:checked={configToEdit.nightlyTasks.generateMemories}
|
||||
{disabled}
|
||||
/>
|
||||
<SettingSwitch
|
||||
title={$t('admin.nightly_tasks_sync_quota_usage_setting')}
|
||||
subtitle={$t('admin.nightly_tasks_sync_quota_usage_setting_description')}
|
||||
bind:checked={configToEdit.nightlyTasks.syncQuotaUsage}
|
||||
{disabled}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<SettingButtonsRow bind:configToEdit keys={['nightlyTasks']} {disabled} />
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,166 +0,0 @@
|
||||
<script lang="ts">
|
||||
import TemplateSettings from '$lib/components/admin-settings/TemplateSettings.svelte';
|
||||
import SettingAccordion from '$lib/components/shared-components/settings/setting-accordion.svelte';
|
||||
import SettingInputField from '$lib/components/shared-components/settings/setting-input-field.svelte';
|
||||
import SettingSwitch from '$lib/components/shared-components/settings/setting-switch.svelte';
|
||||
import SettingButtonsRow from '$lib/components/shared-components/settings/SystemConfigButtonRow.svelte';
|
||||
import { SettingInputFieldType } from '$lib/constants';
|
||||
import { featureFlagsManager } from '$lib/managers/feature-flags-manager.svelte';
|
||||
import { systemConfigManager } from '$lib/managers/system-config-manager.svelte';
|
||||
import { handleSystemConfigSave } from '$lib/services/system-config.service';
|
||||
import { user } from '$lib/stores/user.store';
|
||||
import { handleError } from '$lib/utils/handle-error';
|
||||
import { sendTestEmailAdmin } from '@immich/sdk';
|
||||
import { Button, LoadingSpinner, toastManager } from '@immich/ui';
|
||||
import { t } from 'svelte-i18n';
|
||||
import { fade } from 'svelte/transition';
|
||||
|
||||
const disabled = $derived(featureFlagsManager.value.configFile);
|
||||
const config = $derived(systemConfigManager.value);
|
||||
let configToEdit = $state(systemConfigManager.cloneValue());
|
||||
|
||||
let isSending = $state(false);
|
||||
|
||||
const handleSendTestEmail = async () => {
|
||||
if (isSending) {
|
||||
return;
|
||||
}
|
||||
|
||||
isSending = true;
|
||||
|
||||
try {
|
||||
await sendTestEmailAdmin({
|
||||
systemConfigSmtpDto: {
|
||||
enabled: configToEdit.notifications.smtp.enabled,
|
||||
transport: {
|
||||
host: configToEdit.notifications.smtp.transport.host,
|
||||
port: configToEdit.notifications.smtp.transport.port,
|
||||
secure: configToEdit.notifications.smtp.transport.secure,
|
||||
username: configToEdit.notifications.smtp.transport.username,
|
||||
password: configToEdit.notifications.smtp.transport.password,
|
||||
ignoreCert: configToEdit.notifications.smtp.transport.ignoreCert,
|
||||
},
|
||||
from: configToEdit.notifications.smtp.from,
|
||||
replyTo: configToEdit.notifications.smtp.from,
|
||||
},
|
||||
});
|
||||
|
||||
toastManager.success($t('admin.notification_email_test_email_sent', { values: { email: $user.email } }));
|
||||
|
||||
if (!disabled) {
|
||||
await handleSystemConfigSave({ notifications: configToEdit.notifications });
|
||||
}
|
||||
} catch (error) {
|
||||
handleError(error, $t('admin.notification_email_test_email_failed'));
|
||||
} finally {
|
||||
isSending = false;
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<div>
|
||||
<div in:fade={{ duration: 500 }}>
|
||||
<form autocomplete="off" class="mt-4" onsubmit={(event) => event.preventDefault()}>
|
||||
<div class="flex flex-col gap-4">
|
||||
<SettingAccordion key="email" title={$t('email')} subtitle={$t('admin.notification_email_setting_description')}>
|
||||
<div class="ms-4 mt-4 flex flex-col gap-4">
|
||||
<SettingSwitch
|
||||
title={$t('admin.notification_enable_email_notifications')}
|
||||
{disabled}
|
||||
bind:checked={configToEdit.notifications.smtp.enabled}
|
||||
/>
|
||||
|
||||
<hr />
|
||||
|
||||
<SettingInputField
|
||||
inputType={SettingInputFieldType.TEXT}
|
||||
required
|
||||
label={$t('host')}
|
||||
description={$t('admin.notification_email_host_description')}
|
||||
disabled={disabled || !configToEdit.notifications.smtp.enabled}
|
||||
bind:value={configToEdit.notifications.smtp.transport.host}
|
||||
isEdited={configToEdit.notifications.smtp.transport.host !== config.notifications.smtp.transport.host}
|
||||
/>
|
||||
|
||||
<SettingInputField
|
||||
inputType={SettingInputFieldType.NUMBER}
|
||||
required
|
||||
label={$t('port')}
|
||||
description={$t('admin.notification_email_port_description')}
|
||||
disabled={disabled || !configToEdit.notifications.smtp.enabled}
|
||||
bind:value={configToEdit.notifications.smtp.transport.port}
|
||||
isEdited={configToEdit.notifications.smtp.transport.port !== config.notifications.smtp.transport.port}
|
||||
/>
|
||||
|
||||
<SettingInputField
|
||||
inputType={SettingInputFieldType.TEXT}
|
||||
label={$t('username')}
|
||||
description={$t('admin.notification_email_username_description')}
|
||||
disabled={disabled || !configToEdit.notifications.smtp.enabled}
|
||||
bind:value={configToEdit.notifications.smtp.transport.username}
|
||||
isEdited={configToEdit.notifications.smtp.transport.username !==
|
||||
config.notifications.smtp.transport.username}
|
||||
/>
|
||||
|
||||
<SettingInputField
|
||||
inputType={SettingInputFieldType.PASSWORD}
|
||||
label={$t('password')}
|
||||
description={$t('admin.notification_email_password_description')}
|
||||
disabled={disabled || !configToEdit.notifications.smtp.enabled}
|
||||
bind:value={configToEdit.notifications.smtp.transport.password}
|
||||
isEdited={configToEdit.notifications.smtp.transport.password !==
|
||||
config.notifications.smtp.transport.password}
|
||||
/>
|
||||
|
||||
<SettingSwitch
|
||||
title={$t('admin.notification_email_secure')}
|
||||
subtitle={$t('admin.notification_email_secure_description')}
|
||||
disabled={disabled || !configToEdit.notifications.smtp.enabled}
|
||||
bind:checked={configToEdit.notifications.smtp.transport.secure}
|
||||
/>
|
||||
|
||||
<SettingSwitch
|
||||
title={$t('admin.notification_email_ignore_certificate_errors')}
|
||||
subtitle={$t('admin.notification_email_ignore_certificate_errors_description')}
|
||||
disabled={disabled || !configToEdit.notifications.smtp.enabled}
|
||||
bind:checked={configToEdit.notifications.smtp.transport.ignoreCert}
|
||||
/>
|
||||
|
||||
<hr />
|
||||
|
||||
<SettingInputField
|
||||
inputType={SettingInputFieldType.TEXT}
|
||||
required
|
||||
label={$t('admin.notification_email_from_address')}
|
||||
description={$t('admin.notification_email_from_address_description')}
|
||||
disabled={disabled || !configToEdit.notifications.smtp.enabled}
|
||||
bind:value={configToEdit.notifications.smtp.from}
|
||||
isEdited={configToEdit.notifications.smtp.from !== config.notifications.smtp.from}
|
||||
/>
|
||||
|
||||
<div class="flex gap-2 place-items-center">
|
||||
<Button
|
||||
size="small"
|
||||
shape="round"
|
||||
disabled={!configToEdit.notifications.smtp.enabled}
|
||||
onclick={handleSendTestEmail}
|
||||
>
|
||||
{#if disabled}
|
||||
{$t('admin.notification_email_test_email')}
|
||||
{:else}
|
||||
{$t('admin.notification_email_sent_test_email_button')}
|
||||
{/if}
|
||||
</Button>
|
||||
{#if isSending}
|
||||
<LoadingSpinner />
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</SettingAccordion>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<TemplateSettings bind:config={configToEdit} />
|
||||
|
||||
<SettingButtonsRow bind:configToEdit keys={['notifications', 'templates']} {disabled} />
|
||||
</div>
|
||||
@@ -1,49 +0,0 @@
|
||||
<script lang="ts">
|
||||
import SettingButtonsRow from '$lib/components/shared-components/settings/SystemConfigButtonRow.svelte';
|
||||
import SettingInputField from '$lib/components/shared-components/settings/setting-input-field.svelte';
|
||||
import SettingSwitch from '$lib/components/shared-components/settings/setting-switch.svelte';
|
||||
import { SettingInputFieldType } from '$lib/constants';
|
||||
import { featureFlagsManager } from '$lib/managers/feature-flags-manager.svelte';
|
||||
import { systemConfigManager } from '$lib/managers/system-config-manager.svelte';
|
||||
import { t } from 'svelte-i18n';
|
||||
import { fade } from 'svelte/transition';
|
||||
|
||||
const disabled = $derived(featureFlagsManager.value.configFile);
|
||||
const config = $derived(systemConfigManager.value);
|
||||
let configToEdit = $state(systemConfigManager.cloneValue());
|
||||
</script>
|
||||
|
||||
<div>
|
||||
<div in:fade={{ duration: 500 }}>
|
||||
<form autocomplete="off" onsubmit={(event) => event.preventDefault()}>
|
||||
<div class="mt-4 ms-4">
|
||||
<SettingInputField
|
||||
inputType={SettingInputFieldType.TEXT}
|
||||
label={$t('admin.server_external_domain_settings')}
|
||||
description={$t('admin.server_external_domain_settings_description')}
|
||||
bind:value={configToEdit.server.externalDomain}
|
||||
isEdited={configToEdit.server.externalDomain !== config.server.externalDomain}
|
||||
/>
|
||||
|
||||
<SettingInputField
|
||||
inputType={SettingInputFieldType.TEXT}
|
||||
label={$t('admin.server_welcome_message')}
|
||||
description={$t('admin.server_welcome_message_description')}
|
||||
bind:value={configToEdit.server.loginPageMessage}
|
||||
isEdited={configToEdit.server.loginPageMessage !== config.server.loginPageMessage}
|
||||
/>
|
||||
|
||||
<SettingSwitch
|
||||
title={$t('admin.server_public_users')}
|
||||
subtitle={$t('admin.server_public_users_description')}
|
||||
{disabled}
|
||||
bind:checked={configToEdit.server.publicUsers}
|
||||
/>
|
||||
|
||||
<div class="ms-4">
|
||||
<SettingButtonsRow bind:configToEdit keys={['server']} {disabled} />
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,283 +0,0 @@
|
||||
<script lang="ts">
|
||||
import SupportedDatetimePanel from '$lib/components/admin-settings/SupportedDatetimePanel.svelte';
|
||||
import SupportedVariablesPanel from '$lib/components/admin-settings/SupportedVariablesPanel.svelte';
|
||||
import SettingButtonsRow from '$lib/components/shared-components/settings/SystemConfigButtonRow.svelte';
|
||||
import SettingInputField from '$lib/components/shared-components/settings/setting-input-field.svelte';
|
||||
import SettingSwitch from '$lib/components/shared-components/settings/setting-switch.svelte';
|
||||
import { SettingInputFieldType } from '$lib/constants';
|
||||
import FormatMessage from '$lib/elements/FormatMessage.svelte';
|
||||
import { featureFlagsManager } from '$lib/managers/feature-flags-manager.svelte';
|
||||
import { systemConfigManager } from '$lib/managers/system-config-manager.svelte';
|
||||
import { Route } from '$lib/route';
|
||||
import { handleSystemConfigSave } from '$lib/services/system-config.service';
|
||||
import { user } from '$lib/stores/user.store';
|
||||
import { getStorageTemplateOptions, type SystemConfigTemplateStorageOptionDto } from '@immich/sdk';
|
||||
import { Heading, LoadingSpinner, Text } from '@immich/ui';
|
||||
import handlebar from 'handlebars';
|
||||
import * as luxon from 'luxon';
|
||||
import { onDestroy } from 'svelte';
|
||||
import { t } from 'svelte-i18n';
|
||||
import { createBubbler, preventDefault } from 'svelte/legacy';
|
||||
import { fade } from 'svelte/transition';
|
||||
|
||||
type Props = {
|
||||
minified?: boolean;
|
||||
duration?: number;
|
||||
saveOnClose?: boolean;
|
||||
};
|
||||
|
||||
const { minified = false, duration = 500, saveOnClose = false }: Props = $props();
|
||||
|
||||
const disabled = $derived(featureFlagsManager.value.configFile);
|
||||
const config = $derived(systemConfigManager.value);
|
||||
let configToEdit = $state(systemConfigManager.cloneValue());
|
||||
|
||||
const bubble = createBubbler();
|
||||
let templateOptions: SystemConfigTemplateStorageOptionDto | undefined = $state();
|
||||
let selectedPreset = $state('');
|
||||
|
||||
const getTemplateOptions = async () => {
|
||||
templateOptions = await getStorageTemplateOptions();
|
||||
selectedPreset = config.storageTemplate.template;
|
||||
};
|
||||
|
||||
const getSupportDateTimeFormat = () => getStorageTemplateOptions();
|
||||
|
||||
const renderTemplate = (templateString: string) => {
|
||||
if (!templateOptions) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const template = handlebar.compile(templateString, {
|
||||
knownHelpers: undefined,
|
||||
});
|
||||
|
||||
const substitutions: Record<string, string> = {
|
||||
filename: 'IMAGE_56437',
|
||||
ext: 'jpg',
|
||||
filetype: 'IMG',
|
||||
filetypefull: 'IMAGE',
|
||||
assetId: 'a8312960-e277-447d-b4ea-56717ccba856',
|
||||
assetIdShort: '56717ccba856',
|
||||
album: $t('album_name'),
|
||||
make: 'FUJIFILM',
|
||||
model: 'X-T50',
|
||||
lensModel: 'XF27mm F2.8 R WR',
|
||||
};
|
||||
|
||||
const dt = luxon.DateTime.fromISO(new Date('2022-02-03T04:56:05.250').toISOString());
|
||||
const albumStartTime = luxon.DateTime.fromISO(new Date('2021-12-31T05:32:41.750').toISOString());
|
||||
const albumEndTime = luxon.DateTime.fromISO(new Date('2023-05-06T09:15:17.100').toISOString());
|
||||
|
||||
const dateTokens = [
|
||||
...templateOptions.yearOptions,
|
||||
...templateOptions.monthOptions,
|
||||
...templateOptions.weekOptions,
|
||||
...templateOptions.dayOptions,
|
||||
...templateOptions.hourOptions,
|
||||
...templateOptions.minuteOptions,
|
||||
...templateOptions.secondOptions,
|
||||
];
|
||||
|
||||
for (const token of dateTokens) {
|
||||
substitutions[token] = dt.toFormat(token);
|
||||
substitutions['album-startDate-' + token] = albumStartTime.toFormat(token);
|
||||
substitutions['album-endDate-' + token] = albumEndTime.toFormat(token);
|
||||
}
|
||||
|
||||
return template(substitutions);
|
||||
};
|
||||
|
||||
const handlePresetSelection = () => {
|
||||
configToEdit.storageTemplate.template = selectedPreset;
|
||||
};
|
||||
let parsedTemplate = $derived(() => {
|
||||
try {
|
||||
return renderTemplate(configToEdit.storageTemplate.template);
|
||||
} catch {
|
||||
return 'error';
|
||||
}
|
||||
});
|
||||
|
||||
onDestroy(async () => {
|
||||
if (saveOnClose) {
|
||||
await handleSystemConfigSave({ storageTemplate: configToEdit.storageTemplate });
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<section class="dark:text-immich-dark-fg mt-2">
|
||||
<div in:fade={{ duration }} class="mx-4 flex flex-col gap-4 py-4">
|
||||
<p class="text-sm dark:text-immich-dark-fg">
|
||||
<FormatMessage key="admin.storage_template_more_details">
|
||||
{#snippet children({ tag, message })}
|
||||
{#if tag === 'template-link'}
|
||||
<a
|
||||
href="https://docs.immich.app/administration/storage-template"
|
||||
class="underline"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
{message}
|
||||
</a>
|
||||
{:else if tag === 'implications-link'}
|
||||
<a
|
||||
href="https://docs.immich.app/administration/backup-and-restore#asset-types-and-storage-locations"
|
||||
class="underline"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
{message}
|
||||
</a>
|
||||
{/if}
|
||||
{/snippet}
|
||||
</FormatMessage>
|
||||
</p>
|
||||
</div>
|
||||
{#await getTemplateOptions() then}
|
||||
<div id="directory-path-builder" class="flex flex-col gap-4 {minified ? '' : 'ms-4 mt-4'}">
|
||||
<SettingSwitch
|
||||
title={$t('admin.storage_template_enable_description')}
|
||||
{disabled}
|
||||
bind:checked={configToEdit.storageTemplate.enabled}
|
||||
isEdited={!(configToEdit.storageTemplate.enabled === config.storageTemplate.enabled)}
|
||||
/>
|
||||
|
||||
{#if !minified}
|
||||
<SettingSwitch
|
||||
title={$t('admin.storage_template_hash_verification_enabled')}
|
||||
{disabled}
|
||||
subtitle={$t('admin.storage_template_hash_verification_enabled_description')}
|
||||
bind:checked={configToEdit.storageTemplate.hashVerificationEnabled}
|
||||
isEdited={!(
|
||||
configToEdit.storageTemplate.hashVerificationEnabled === config.storageTemplate.hashVerificationEnabled
|
||||
)}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
{#if configToEdit.storageTemplate.enabled}
|
||||
<hr />
|
||||
|
||||
<Heading size="tiny" color="primary">
|
||||
{$t('variables')}
|
||||
</Heading>
|
||||
|
||||
<section class="support-date">
|
||||
{#await getSupportDateTimeFormat()}
|
||||
<LoadingSpinner />
|
||||
{:then options}
|
||||
<div transition:fade={{ duration: 200 }}>
|
||||
<SupportedDatetimePanel {options} />
|
||||
</div>
|
||||
{/await}
|
||||
</section>
|
||||
|
||||
<section class="support-date">
|
||||
<SupportedVariablesPanel />
|
||||
</section>
|
||||
|
||||
<div class="flex flex-col mt-2">
|
||||
<!-- <h3 class="text-base font-medium text-primary">{$t('template')}</h3> -->
|
||||
<Heading size="tiny" color="primary">
|
||||
{$t('template')}
|
||||
</Heading>
|
||||
|
||||
<div class="my-2">
|
||||
<Text size="small">{$t('preview')}</Text>
|
||||
</div>
|
||||
|
||||
<p class="text-sm">
|
||||
<FormatMessage
|
||||
key="admin.storage_template_path_length"
|
||||
values={{ length: parsedTemplate().length + $user.id.length + 'UPLOAD_LOCATION'.length, limit: 260 }}
|
||||
>
|
||||
{#snippet children({ message })}
|
||||
<span class="font-semibold text-primary">{message}</span>
|
||||
{/snippet}
|
||||
</FormatMessage>
|
||||
</p>
|
||||
|
||||
<p class="text-sm">
|
||||
<FormatMessage key="admin.storage_template_user_label" values={{ label: $user.storageLabel || $user.id }}>
|
||||
{#snippet children({ message })}
|
||||
<code class="text-primary">{message}</code>
|
||||
{/snippet}
|
||||
</FormatMessage>
|
||||
</p>
|
||||
|
||||
<p class="p-4 py-2 mt-2 text-xs bg-gray-200 rounded-lg dark:bg-gray-700 dark:text-immich-dark-fg">
|
||||
<span class="text-immich-fg/25 dark:text-immich-dark-fg/50"
|
||||
>UPLOAD_LOCATION/library/{$user.storageLabel || $user.id}</span
|
||||
>/{parsedTemplate()}.jpg
|
||||
</p>
|
||||
|
||||
<form autocomplete="off" class="flex flex-col" onsubmit={preventDefault(bubble('submit'))}>
|
||||
<div class="flex flex-col my-2">
|
||||
{#if templateOptions}
|
||||
<label class="font-medium text-primary text-sm" for="preset-select">
|
||||
{$t('preset')}
|
||||
</label>
|
||||
<select
|
||||
class="immich-form-input p-2 mt-2 text-sm rounded-lg bg-slate-200 hover:cursor-pointer dark:bg-gray-600"
|
||||
disabled={disabled || !configToEdit.storageTemplate.enabled}
|
||||
name="presets"
|
||||
id="preset-select"
|
||||
bind:value={selectedPreset}
|
||||
onchange={handlePresetSelection}
|
||||
>
|
||||
{#each templateOptions.presetOptions as preset (preset)}
|
||||
<option value={preset}>{renderTemplate(preset)}</option>
|
||||
{/each}
|
||||
</select>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="flex gap-2 align-bottom">
|
||||
<SettingInputField
|
||||
label={$t('template')}
|
||||
disabled={disabled || !configToEdit.storageTemplate.enabled}
|
||||
required
|
||||
inputType={SettingInputFieldType.TEXT}
|
||||
bind:value={configToEdit.storageTemplate.template}
|
||||
isEdited={!(configToEdit.storageTemplate.template === config.storageTemplate.template)}
|
||||
/>
|
||||
|
||||
<div class="flex-0">
|
||||
<SettingInputField
|
||||
label={$t('extension')}
|
||||
inputType={SettingInputFieldType.TEXT}
|
||||
value=".jpg"
|
||||
disabled
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if !minified}
|
||||
<div id="migration-info" class="mt-2 text-sm">
|
||||
<Heading size="tiny" color="primary">
|
||||
{$t('notes')}
|
||||
</Heading>
|
||||
<section class="flex flex-col gap-2">
|
||||
<p>
|
||||
<FormatMessage
|
||||
key="admin.storage_template_migration_info"
|
||||
values={{ job: $t('admin.storage_template_migration_job') }}
|
||||
>
|
||||
{#snippet children({ message })}
|
||||
<a href={Route.queues()} class="text-primary">{message}</a>
|
||||
{/snippet}
|
||||
</FormatMessage>
|
||||
</p>
|
||||
</section>
|
||||
</div>
|
||||
{/if}
|
||||
</form>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if !minified}
|
||||
<SettingButtonsRow bind:configToEdit keys={['storageTemplate']} {disabled} />
|
||||
{/if}
|
||||
</div>
|
||||
{/await}
|
||||
</section>
|
||||
@@ -1,95 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { locale } from '$lib/stores/preferences.store';
|
||||
import type { SystemConfigTemplateStorageOptionDto } from '@immich/sdk';
|
||||
import { Card, CardBody, CardHeader, Text } from '@immich/ui';
|
||||
import { DateTime } from 'luxon';
|
||||
import { t } from 'svelte-i18n';
|
||||
|
||||
interface Props {
|
||||
options: SystemConfigTemplateStorageOptionDto;
|
||||
}
|
||||
|
||||
let { options }: Props = $props();
|
||||
|
||||
const getLuxonExample = (format: string) => {
|
||||
return DateTime.fromISO('2022-02-15T20:03:05.250Z', { locale: $locale }).toFormat(format);
|
||||
};
|
||||
</script>
|
||||
|
||||
<Text size="small">{$t('date_and_time')}</Text>
|
||||
|
||||
<!-- eslint-disable svelte/no-useless-mustaches -->
|
||||
<Card class="mt-2 text-sm bg-light-50 shadow-none">
|
||||
<CardHeader>
|
||||
<Text class="mb-1">{$t('admin.storage_template_date_time_description')}</Text>
|
||||
<Text color="primary"
|
||||
>{$t('admin.storage_template_date_time_sample', { values: { date: '2022-02-03T20:03:05.250' } })}</Text
|
||||
>
|
||||
</CardHeader>
|
||||
<CardBody>
|
||||
<div class="grid grid-cols-1 gap-6 sm:grid-cols-3 md:grid-cols-4">
|
||||
<div>
|
||||
<Text fontWeight="medium" size="tiny" color="primary" class="mb-1">{$t('year')}</Text>
|
||||
<ul>
|
||||
{#each options.yearOptions as yearFormat, index (index)}
|
||||
<li>{'{{'}{yearFormat}{'}}'} - {getLuxonExample(yearFormat)}</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Text fontWeight="medium" size="tiny" color="primary" class="mb-1">{$t('month')}</Text>
|
||||
<ul>
|
||||
{#each options.monthOptions as monthFormat, index (index)}
|
||||
<li>{'{{'}{monthFormat}{'}}'} - {getLuxonExample(monthFormat)}</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Text fontWeight="medium" size="tiny" color="primary" class="mb-1">{$t('week')}</Text>
|
||||
<ul>
|
||||
{#each options.weekOptions as weekFormat, index (index)}
|
||||
<li>{'{{'}{weekFormat}{'}}'} - {getLuxonExample(weekFormat)}</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Text fontWeight="medium" size="tiny" color="primary" class="mb-1">{$t('day')}</Text>
|
||||
<ul>
|
||||
{#each options.dayOptions as dayFormat, index (index)}
|
||||
<li>{'{{'}{dayFormat}{'}}'} - {getLuxonExample(dayFormat)}</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Text fontWeight="medium" size="tiny" color="primary" class="mb-1">{$t('hour')}</Text>
|
||||
<ul>
|
||||
{#each options.hourOptions as dayFormat, index (index)}
|
||||
<li>{'{{'}{dayFormat}{'}}'} - {getLuxonExample(dayFormat)}</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Text fontWeight="medium" size="tiny" color="primary" class="mb-1">{$t('minute')}</Text>
|
||||
<ul>
|
||||
{#each options.minuteOptions as dayFormat, index (index)}
|
||||
<li>{'{{'}{dayFormat}{'}}'} - {getLuxonExample(dayFormat)}</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Text fontWeight="medium" size="tiny" color="primary" class="mb-1">{$t('second')}</Text>
|
||||
<ul>
|
||||
{#each options.secondOptions as dayFormat, index (index)}
|
||||
<li>{'{{'}{dayFormat}{'}}'} - {getLuxonExample(dayFormat)}</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</CardBody>
|
||||
</Card>
|
||||
@@ -1,59 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { Card, CardBody, Text } from '@immich/ui';
|
||||
import { t } from 'svelte-i18n';
|
||||
</script>
|
||||
|
||||
<Text size="small">{$t('other_variables')}</Text>
|
||||
|
||||
<Card class="mt-2 text-sm bg-light-50 shadow-none">
|
||||
<CardBody>
|
||||
<div class="grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-3">
|
||||
<div>
|
||||
<Text fontWeight="medium" size="tiny" color="primary" class="mb-1">{$t('filename')}</Text>
|
||||
<ul>
|
||||
<li>{`{{filename}}`} - IMG_123</li>
|
||||
<li>{`{{ext}}`} - jpg</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Text fontWeight="medium" size="tiny" color="primary" class="mb-1">{$t('filetype')}</Text>
|
||||
<ul>
|
||||
<li>{`{{filetype}}`} - VID or IMG</li>
|
||||
<li>{`{{filetypefull}}`} - VIDEO or IMAGE</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Text fontWeight="medium" size="tiny" color="primary" class="mb-1">{$t('camera')}</Text>
|
||||
<ul>
|
||||
<li>{`{{make}}`} - FUJIFILM</li>
|
||||
<li>{`{{model}}`} - X-T50</li>
|
||||
<li>{`{{lensModel}}`} - XF27mm F2.8 R WR</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Text fontWeight="medium" size="tiny" color="primary" class="mb-1">{$t('album')}</Text>
|
||||
<ul>
|
||||
<li>{`{{album}}`} - Album Name</li>
|
||||
<li>
|
||||
{`{{album-startDate-x}}`} - Album Start Date and Time (e.g. album-startDate-yy).
|
||||
{$t('admin.storage_template_date_time_sample', { values: { date: '2021-12-31T05:32:41.750' } })}
|
||||
</li>
|
||||
<li>
|
||||
{`{{album-endDate-x}}`} - Album End Date and Time (e.g. album-endDate-MM).
|
||||
{$t('admin.storage_template_date_time_sample', { values: { date: '2023-05-06T09:15:17.100' } })}
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div>
|
||||
<Text fontWeight="medium" size="tiny" color="primary" class="mb-1">{$t('other')}</Text>
|
||||
<ul>
|
||||
<li>{`{{assetId}}`} - Asset ID</li>
|
||||
<li>{`{{assetIdShort}}`} - Asset ID (last 12 characters)</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</CardBody>
|
||||
</Card>
|
||||
@@ -1,106 +0,0 @@
|
||||
<script lang="ts">
|
||||
import SettingAccordion from '$lib/components/shared-components/settings/setting-accordion.svelte';
|
||||
import SettingTextarea from '$lib/components/shared-components/settings/setting-textarea.svelte';
|
||||
import FormatMessage from '$lib/elements/FormatMessage.svelte';
|
||||
import { systemConfigManager } from '$lib/managers/system-config-manager.svelte';
|
||||
import EmailTemplatePreviewModal from '$lib/modals/EmailTemplatePreviewModal.svelte';
|
||||
import { handleError } from '$lib/utils/handle-error';
|
||||
import { type SystemConfigDto, type SystemConfigTemplateEmailsDto, getNotificationTemplateAdmin } from '@immich/sdk';
|
||||
import { Button, Icon, LoadingSpinner, modalManager } from '@immich/ui';
|
||||
import { mdiEyeOutline } from '@mdi/js';
|
||||
import { t } from 'svelte-i18n';
|
||||
import { fade } from 'svelte/transition';
|
||||
|
||||
interface Props {
|
||||
config: SystemConfigDto;
|
||||
}
|
||||
|
||||
let { config = $bindable() }: Props = $props();
|
||||
|
||||
let loadingPreview = $state(false);
|
||||
|
||||
const getTemplate = async (name: string, template: string) => {
|
||||
try {
|
||||
loadingPreview = true;
|
||||
const { html } = await getNotificationTemplateAdmin({ name, templateDto: { template } });
|
||||
await modalManager.show(EmailTemplatePreviewModal, { html });
|
||||
} catch (error) {
|
||||
handleError(error, 'Could not load template.');
|
||||
} finally {
|
||||
loadingPreview = false;
|
||||
}
|
||||
};
|
||||
|
||||
const templateConfigs = [
|
||||
{
|
||||
label: $t('admin.template_email_welcome'),
|
||||
templateKey: 'welcomeTemplate' as const,
|
||||
descriptionTags: '{username}, {password}, {displayName}, {baseUrl}',
|
||||
templateName: 'welcome',
|
||||
},
|
||||
{
|
||||
label: $t('admin.template_email_invite_album'),
|
||||
templateKey: 'albumInviteTemplate' as const,
|
||||
descriptionTags: '{senderName}, {recipientName}, {albumId}, {albumName}, {baseUrl}',
|
||||
templateName: 'album-invite',
|
||||
},
|
||||
{
|
||||
label: $t('admin.template_email_update_album'),
|
||||
templateKey: 'albumUpdateTemplate' as const,
|
||||
descriptionTags: '{recipientName}, {albumId}, {albumName}, {baseUrl}',
|
||||
templateName: 'album-update',
|
||||
},
|
||||
];
|
||||
|
||||
const isEdited = (templateKey: keyof SystemConfigTemplateEmailsDto) =>
|
||||
config.templates.email[templateKey] !== systemConfigManager.value.templates.email[templateKey];
|
||||
|
||||
const onsubmit = (event: Event) => {
|
||||
event.preventDefault();
|
||||
};
|
||||
</script>
|
||||
|
||||
<div in:fade={{ duration: 500 }}>
|
||||
<form autocomplete="off" {onsubmit} class="mt-4">
|
||||
<div class="flex flex-col gap-4">
|
||||
<SettingAccordion
|
||||
key="templates"
|
||||
title={$t('admin.template_email_settings')}
|
||||
subtitle={$t('admin.template_settings_description')}
|
||||
>
|
||||
<div class="ms-4 mt-4 flex flex-col gap-4">
|
||||
<p class="text-sm dark:text-immich-dark-fg">
|
||||
<FormatMessage key="admin.template_email_if_empty">
|
||||
{$t('admin.template_email_if_empty')}
|
||||
</FormatMessage>
|
||||
</p>
|
||||
<hr />
|
||||
{#if loadingPreview}
|
||||
<LoadingSpinner />
|
||||
{/if}
|
||||
|
||||
{#each templateConfigs as { label, templateKey, descriptionTags, templateName } (templateKey)}
|
||||
<SettingTextarea
|
||||
{label}
|
||||
description={$t('admin.template_email_available_tags', { values: { tags: descriptionTags } })}
|
||||
bind:value={config.templates.email[templateKey]}
|
||||
isEdited={isEdited(templateKey)}
|
||||
disabled={!config.notifications.smtp.enabled}
|
||||
/>
|
||||
<div class="flex justify-between">
|
||||
<Button
|
||||
size="small"
|
||||
shape="round"
|
||||
onclick={() => getTemplate(templateName, config.templates.email[templateKey])}
|
||||
title={$t('admin.template_email_preview')}
|
||||
>
|
||||
<Icon icon={mdiEyeOutline} class="me-1" />
|
||||
{$t('admin.template_email_preview')}
|
||||
</Button>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</SettingAccordion>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
@@ -1,30 +0,0 @@
|
||||
<script lang="ts">
|
||||
import SettingButtonsRow from '$lib/components/shared-components/settings/SystemConfigButtonRow.svelte';
|
||||
import SettingTextarea from '$lib/components/shared-components/settings/setting-textarea.svelte';
|
||||
import { featureFlagsManager } from '$lib/managers/feature-flags-manager.svelte';
|
||||
import { systemConfigManager } from '$lib/managers/system-config-manager.svelte';
|
||||
import { t } from 'svelte-i18n';
|
||||
import { fade } from 'svelte/transition';
|
||||
|
||||
const disabled = $derived(featureFlagsManager.value.configFile);
|
||||
const config = $derived(systemConfigManager.value);
|
||||
let configToEdit = $state(systemConfigManager.cloneValue());
|
||||
</script>
|
||||
|
||||
<div>
|
||||
<div in:fade={{ duration: 500 }}>
|
||||
<form autocomplete="off" onsubmit={(event) => event.preventDefault()}>
|
||||
<div class="ms-4 mt-4 flex flex-col gap-4">
|
||||
<SettingTextarea
|
||||
{disabled}
|
||||
label={$t('admin.theme_custom_css_settings')}
|
||||
description={$t('admin.theme_custom_css_settings_description')}
|
||||
bind:value={configToEdit.theme.customCss}
|
||||
isEdited={configToEdit.theme.customCss !== config.theme.customCss}
|
||||
/>
|
||||
|
||||
<SettingButtonsRow bind:configToEdit keys={['theme']} {disabled} />
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,42 +0,0 @@
|
||||
<script lang="ts">
|
||||
import SettingButtonsRow from '$lib/components/shared-components/settings/SystemConfigButtonRow.svelte';
|
||||
import SettingInputField from '$lib/components/shared-components/settings/setting-input-field.svelte';
|
||||
import SettingSwitch from '$lib/components/shared-components/settings/setting-switch.svelte';
|
||||
import { SettingInputFieldType } from '$lib/constants';
|
||||
import { featureFlagsManager } from '$lib/managers/feature-flags-manager.svelte';
|
||||
import { systemConfigManager } from '$lib/managers/system-config-manager.svelte';
|
||||
import { t } from 'svelte-i18n';
|
||||
import { fade } from 'svelte/transition';
|
||||
|
||||
const disabled = $derived(featureFlagsManager.value.configFile);
|
||||
const config = $derived(systemConfigManager.value);
|
||||
let configToEdit = $state(systemConfigManager.cloneValue());
|
||||
</script>
|
||||
|
||||
<div>
|
||||
<div in:fade={{ duration: 500 }}>
|
||||
<form autocomplete="off" onsubmit={(event) => event.preventDefault()}>
|
||||
<div class="ms-4 mt-4 flex flex-col gap-4">
|
||||
<SettingSwitch
|
||||
title={$t('admin.trash_enabled_description')}
|
||||
{disabled}
|
||||
bind:checked={configToEdit.trash.enabled}
|
||||
/>
|
||||
|
||||
<hr />
|
||||
|
||||
<SettingInputField
|
||||
inputType={SettingInputFieldType.NUMBER}
|
||||
label={$t('admin.trash_number_of_days')}
|
||||
description={$t('admin.trash_number_of_days_description')}
|
||||
bind:value={configToEdit.trash.days}
|
||||
required={true}
|
||||
disabled={disabled || !configToEdit.trash.enabled}
|
||||
isEdited={configToEdit.trash.days !== config.trash.days}
|
||||
/>
|
||||
|
||||
<SettingButtonsRow bind:configToEdit keys={['trash']} {disabled} />
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,35 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { fade } from 'svelte/transition';
|
||||
|
||||
import SettingButtonsRow from '$lib/components/shared-components/settings/SystemConfigButtonRow.svelte';
|
||||
import SettingInputField from '$lib/components/shared-components/settings/setting-input-field.svelte';
|
||||
import { SettingInputFieldType } from '$lib/constants';
|
||||
import { featureFlagsManager } from '$lib/managers/feature-flags-manager.svelte';
|
||||
import { systemConfigManager } from '$lib/managers/system-config-manager.svelte';
|
||||
import { t } from 'svelte-i18n';
|
||||
|
||||
const disabled = $derived(featureFlagsManager.value.configFile);
|
||||
const config = $derived(systemConfigManager.value);
|
||||
let configToEdit = $state(systemConfigManager.cloneValue());
|
||||
</script>
|
||||
|
||||
<div>
|
||||
<div in:fade={{ duration: 500 }}>
|
||||
<form autocomplete="off" onsubmit={(e) => e.preventDefault()}>
|
||||
<div class="ms-4 mt-4 flex flex-col gap-4">
|
||||
<SettingInputField
|
||||
inputType={SettingInputFieldType.NUMBER}
|
||||
min={1}
|
||||
label={$t('admin.user_delete_delay_settings')}
|
||||
description={$t('admin.user_delete_delay_settings_description')}
|
||||
bind:value={configToEdit.user.deleteDelay}
|
||||
isEdited={configToEdit.user.deleteDelay !== config.user.deleteDelay}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="ms-4">
|
||||
<SettingButtonsRow bind:configToEdit keys={['user']} {disabled} />
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,122 +0,0 @@
|
||||
import { sdkMock } from '$lib/__mocks__/sdk.mock';
|
||||
import { renderWithTooltips } from '$tests/helpers';
|
||||
import { albumFactory } from '@test-data/factories/album-factory';
|
||||
import '@testing-library/jest-dom';
|
||||
import { render, waitFor, type RenderResult } from '@testing-library/svelte';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { init, register, waitLocale } from 'svelte-i18n';
|
||||
import AlbumCard from '../album-card.svelte';
|
||||
|
||||
const onShowContextMenu = vi.fn();
|
||||
|
||||
describe('AlbumCard component', () => {
|
||||
let sut: RenderResult<typeof AlbumCard>;
|
||||
|
||||
beforeAll(async () => {
|
||||
await init({ fallbackLocale: 'en-US' });
|
||||
register('en-US', () => import('$i18n/en.json'));
|
||||
await waitLocale('en-US');
|
||||
});
|
||||
|
||||
it.each([
|
||||
{
|
||||
album: albumFactory.build({ albumThumbnailAssetId: null, shared: false, assetCount: 0 }),
|
||||
count: 0,
|
||||
shared: false,
|
||||
},
|
||||
{
|
||||
album: albumFactory.build({ albumThumbnailAssetId: null, shared: true, assetCount: 0 }),
|
||||
count: 0,
|
||||
shared: true,
|
||||
},
|
||||
{
|
||||
album: albumFactory.build({ albumThumbnailAssetId: null, shared: false, assetCount: 5 }),
|
||||
count: 5,
|
||||
shared: false,
|
||||
},
|
||||
{
|
||||
album: albumFactory.build({ albumThumbnailAssetId: null, shared: true, assetCount: 2 }),
|
||||
count: 2,
|
||||
shared: true,
|
||||
},
|
||||
])('shows album data without thumbnail with count $count - shared: $shared', async ({ album, count, shared }) => {
|
||||
sut = render(AlbumCard, { album, showItemCount: true });
|
||||
|
||||
const albumImgElement = sut.getByTestId('album-image');
|
||||
const albumNameElement = sut.getByTestId('album-name');
|
||||
const albumDetailsElement = sut.getByTestId('album-details');
|
||||
const detailsText = `${count} items` + (shared ? ' . Shared' : '');
|
||||
|
||||
expect(albumImgElement).toHaveAttribute('src');
|
||||
expect(albumImgElement).toHaveAttribute('alt', album.albumName);
|
||||
|
||||
await waitFor(() => expect(albumImgElement).toHaveAttribute('src'));
|
||||
|
||||
expect(albumImgElement).toHaveAttribute('alt', album.albumName);
|
||||
expect(sdkMock.viewAsset).not.toHaveBeenCalled();
|
||||
|
||||
expect(albumNameElement).toHaveTextContent(album.albumName);
|
||||
expect(albumDetailsElement).toHaveTextContent(new RegExp(detailsText));
|
||||
});
|
||||
|
||||
it('shows album data', () => {
|
||||
const album = albumFactory.build({
|
||||
shared: false,
|
||||
albumName: 'some album name',
|
||||
});
|
||||
sut = render(AlbumCard, { album, showItemCount: true });
|
||||
|
||||
const albumImgElement = sut.getByTestId('album-image');
|
||||
const albumNameElement = sut.getByTestId('album-name');
|
||||
const albumDetailsElement = sut.getByTestId('album-details');
|
||||
|
||||
expect(albumImgElement).toHaveAttribute('alt', album.albumName);
|
||||
expect(albumImgElement).toHaveAttribute('src');
|
||||
|
||||
expect(albumNameElement).toHaveTextContent('some album name');
|
||||
expect(albumDetailsElement).toHaveTextContent('0 item');
|
||||
});
|
||||
|
||||
it('hides context menu when "onShowContextMenu" is undefined', () => {
|
||||
const album = Object.freeze(albumFactory.build({ albumThumbnailAssetId: null }));
|
||||
sut = render(AlbumCard, { album });
|
||||
|
||||
const contextButtonParent = sut.queryByTestId('context-button-parent');
|
||||
expect(contextButtonParent).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
describe('with rendered component - no thumbnail', () => {
|
||||
const album = Object.freeze(albumFactory.build({ albumThumbnailAssetId: null }));
|
||||
|
||||
beforeEach(async () => {
|
||||
sut = renderWithTooltips(AlbumCard, { album, onShowContextMenu });
|
||||
|
||||
const albumImgElement = sut.getByTestId('album-image');
|
||||
await waitFor(() => expect(albumImgElement).toHaveAttribute('src'));
|
||||
});
|
||||
|
||||
it('dispatches "onShowContextMenu" event on context menu click with mouse coordinates', async () => {
|
||||
const contextMenuButton = sut.getByTestId('context-button-parent').children[0];
|
||||
expect(contextMenuButton).toBeDefined();
|
||||
|
||||
// Mock getBoundingClientRect to return a bounding rectangle that will result in the expected position
|
||||
contextMenuButton.getBoundingClientRect = () => ({
|
||||
x: 123,
|
||||
y: 456,
|
||||
width: 0,
|
||||
height: 0,
|
||||
top: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
toJSON: () => ({}),
|
||||
});
|
||||
|
||||
const user = userEvent.setup();
|
||||
await user.click(contextMenuButton);
|
||||
|
||||
expect(onShowContextMenu).toHaveBeenCalledTimes(1);
|
||||
expect(onShowContextMenu).toHaveBeenCalledWith(expect.objectContaining({ x: 123, y: 456 }));
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,42 +0,0 @@
|
||||
import AlbumCover from '$lib/components/album-page/album-cover.svelte';
|
||||
import { getAssetMediaUrl } from '$lib/utils';
|
||||
import { albumFactory } from '@test-data/factories/album-factory';
|
||||
import { render } from '@testing-library/svelte';
|
||||
|
||||
vi.mock('$lib/utils');
|
||||
|
||||
describe('AlbumCover component', () => {
|
||||
it('renders an image when the album has a thumbnail', () => {
|
||||
vi.mocked(getAssetMediaUrl).mockReturnValue('/asdf');
|
||||
const component = render(AlbumCover, {
|
||||
album: albumFactory.build({
|
||||
albumName: 'someName',
|
||||
albumThumbnailAssetId: '123',
|
||||
}),
|
||||
preload: false,
|
||||
class: 'text',
|
||||
});
|
||||
const img = component.getByTestId('album-image') as HTMLImageElement;
|
||||
expect(img.alt).toBe('someName');
|
||||
expect(img.getAttribute('loading')).toBe('lazy');
|
||||
expect(img.className).toBe('size-full rounded-xl object-cover aspect-square text');
|
||||
expect(img.getAttribute('src')).toBe('/asdf');
|
||||
expect(getAssetMediaUrl).toHaveBeenCalledWith({ id: '123' });
|
||||
});
|
||||
|
||||
it('renders an image when the album has no thumbnail', () => {
|
||||
const component = render(AlbumCover, {
|
||||
album: albumFactory.build({
|
||||
albumName: '',
|
||||
albumThumbnailAssetId: null,
|
||||
}),
|
||||
preload: true,
|
||||
class: 'asdf',
|
||||
});
|
||||
const img = component.getByTestId('album-image') as HTMLImageElement;
|
||||
expect(img.alt).toBe('unnamed_album');
|
||||
expect(img.getAttribute('loading')).toBe('eager');
|
||||
expect(img.className).toBe('size-full rounded-xl object-cover aspect-square asdf');
|
||||
expect(img.getAttribute('src')).toStrictEqual(expect.any(String));
|
||||
});
|
||||
});
|
||||
@@ -1,83 +0,0 @@
|
||||
<script lang="ts">
|
||||
import AlbumCard from '$lib/components/album-page/album-card.svelte';
|
||||
import { Route } from '$lib/route';
|
||||
import { albumViewSettings } from '$lib/stores/preferences.store';
|
||||
import { type AlbumGroup, isAlbumGroupCollapsed, toggleAlbumGroupCollapsing } from '$lib/utils/album-utils';
|
||||
import type { ContextMenuPosition } from '$lib/utils/context-menu';
|
||||
import type { AlbumResponseDto } from '@immich/sdk';
|
||||
import { Icon } from '@immich/ui';
|
||||
import { mdiChevronRight } from '@mdi/js';
|
||||
import { t } from 'svelte-i18n';
|
||||
import { flip } from 'svelte/animate';
|
||||
import { slide } from 'svelte/transition';
|
||||
|
||||
interface Props {
|
||||
albums: AlbumResponseDto[];
|
||||
group?: AlbumGroup | undefined;
|
||||
showOwner?: boolean;
|
||||
showDateRange?: boolean;
|
||||
showItemCount?: boolean;
|
||||
onShowContextMenu?: ((position: ContextMenuPosition, album: AlbumResponseDto) => unknown) | undefined;
|
||||
}
|
||||
|
||||
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) => {
|
||||
onShowContextMenu?.(position, album);
|
||||
};
|
||||
|
||||
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>
|
||||
|
||||
{#if group}
|
||||
<div class="grid">
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => toggleAlbumGroupCollapsing(group.id)}
|
||||
class="w-full text-start mt-2 pt-2 pe-2 pb-2 rounded-md transition-colors cursor-pointer dark:text-immich-dark-fg hover:text-primary hover:bg-subtle dark:hover:bg-immich-dark-gray"
|
||||
aria-expanded={!isCollapsed}
|
||||
>
|
||||
<Icon icon={mdiChevronRight} size="24" class="inline-block -mt-2.5 transition-all duration-250 {iconRotation}" />
|
||||
<span class="font-bold text-3xl text-black dark:text-white">{group.name}</span>
|
||||
<span class="ms-1.5">({$t('albums_count', { values: { count: albums.length } })})</span>
|
||||
</button>
|
||||
<hr class="dark:border-immich-dark-gray" />
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="mt-4">
|
||||
{#if !isCollapsed}
|
||||
<div class="grid grid-auto-fill-56 gap-y-4" transition:slide={{ duration: 300 }}>
|
||||
{#each albums as album, index (album.id)}
|
||||
<a
|
||||
href={Route.viewAlbum(album)}
|
||||
animate:flip={{ duration: 400 }}
|
||||
oncontextmenu={(event) => oncontextmenu(event, album)}
|
||||
>
|
||||
<AlbumCard
|
||||
{album}
|
||||
{showOwner}
|
||||
{showDateRange}
|
||||
{showItemCount}
|
||||
preload={index < 20}
|
||||
onShowContextMenu={onShowContextMenu ? (position) => showContextMenu(position, album) : undefined}
|
||||
/>
|
||||
</a>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -1,100 +0,0 @@
|
||||
<script lang="ts">
|
||||
import AlbumCover from '$lib/components/album-page/album-cover.svelte';
|
||||
import { user } from '$lib/stores/user.store';
|
||||
import { getContextMenuPositionFromEvent, type ContextMenuPosition } from '$lib/utils/context-menu';
|
||||
import { getShortDateRange } from '$lib/utils/date-time';
|
||||
import type { AlbumResponseDto } from '@immich/sdk';
|
||||
import { IconButton } from '@immich/ui';
|
||||
import { mdiDotsVertical } from '@mdi/js';
|
||||
import { t } from 'svelte-i18n';
|
||||
|
||||
interface Props {
|
||||
album: AlbumResponseDto;
|
||||
showOwner?: boolean;
|
||||
showDateRange?: boolean;
|
||||
showItemCount?: boolean;
|
||||
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) => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
onShowContextMenu?.(getContextMenuPositionFromEvent(e));
|
||||
};
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="group relative rounded-2xl border border-transparent p-5 hover:bg-gray-100 hover:border-gray-200 dark:hover:border-gray-800 dark:hover:bg-gray-900"
|
||||
data-testid="album-card"
|
||||
>
|
||||
{#if onShowContextMenu}
|
||||
<div
|
||||
id="icon-{album.id}"
|
||||
class="absolute end-6 top-6 opacity-0 group-hover:opacity-100 focus-within:opacity-100"
|
||||
data-testid="context-button-parent"
|
||||
>
|
||||
<IconButton
|
||||
color="secondary"
|
||||
aria-label={$t('show_album_options')}
|
||||
icon={mdiDotsVertical}
|
||||
shape="round"
|
||||
variant="filled"
|
||||
size="medium"
|
||||
class="icon-white-drop-shadow"
|
||||
onclick={showAlbumContextMenu}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<AlbumCover {album} {preload} class="transition-all duration-300 hover:shadow-lg" />
|
||||
|
||||
<div class="mt-4">
|
||||
<p
|
||||
class="w-full leading-6 text-lg line-clamp-2 font-semibold text-black dark:text-white group-hover:text-primary"
|
||||
data-testid="album-name"
|
||||
title={album.albumName}
|
||||
>
|
||||
{album.albumName}
|
||||
</p>
|
||||
|
||||
{#if showDateRange && album.startDate && album.endDate}
|
||||
<p class="flex text-sm dark:text-immich-dark-fg capitalize">
|
||||
{getShortDateRange(album.startDate, album.endDate)}
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
<span class="flex gap-2 text-sm dark:text-immich-dark-fg" data-testid="album-details">
|
||||
{#if showItemCount}
|
||||
<p>
|
||||
{$t('items_count', { values: { count: album.assetCount } })}
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
{#if (showOwner || album.shared) && showItemCount}
|
||||
<p>•</p>
|
||||
{/if}
|
||||
|
||||
{#if showOwner}
|
||||
{#if $user.id === album.ownerId}
|
||||
<p>{$t('owned')}</p>
|
||||
{:else if album.owner}
|
||||
<p>{$t('shared_by_user', { values: { user: album.owner.name } })}</p>
|
||||
{:else}
|
||||
<p>{$t('shared')}</p>
|
||||
{/if}
|
||||
{:else if album.shared}
|
||||
<p>{$t('shared')}</p>
|
||||
{/if}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,26 +0,0 @@
|
||||
<script lang="ts">
|
||||
import AssetCover from '$lib/components/sharedlinks-page/covers/asset-cover.svelte';
|
||||
import NoCover from '$lib/components/sharedlinks-page/covers/no-cover.svelte';
|
||||
import { getAssetMediaUrl } from '$lib/utils';
|
||||
import { type AlbumResponseDto } from '@immich/sdk';
|
||||
import { t } from 'svelte-i18n';
|
||||
|
||||
interface Props {
|
||||
album: AlbumResponseDto;
|
||||
preload?: boolean;
|
||||
class?: string;
|
||||
}
|
||||
|
||||
let { album, preload = false, class: className = '' }: Props = $props();
|
||||
|
||||
let alt = $derived(album.albumName || $t('unnamed_album'));
|
||||
let thumbnailUrl = $derived(
|
||||
album.albumThumbnailAssetId ? getAssetMediaUrl({ id: album.albumThumbnailAssetId }) : null,
|
||||
);
|
||||
</script>
|
||||
|
||||
{#if thumbnailUrl}
|
||||
<AssetCover {alt} class={className} src={thumbnailUrl} {preload} />
|
||||
{:else}
|
||||
<NoCover {alt} class={className} {preload} />
|
||||
{/if}
|
||||
@@ -1,18 +0,0 @@
|
||||
import AlbumDescription from '$lib/components/album-page/album-description.svelte';
|
||||
import '@testing-library/jest-dom';
|
||||
import { render, screen } from '@testing-library/svelte';
|
||||
import { describe } from 'vitest';
|
||||
|
||||
describe('AlbumDescription component', () => {
|
||||
it('shows an AutogrowTextarea component when isOwned is true', () => {
|
||||
render(AlbumDescription, { isOwned: true, id: '', description: '' });
|
||||
const autogrowTextarea = screen.getByTestId('autogrow-textarea');
|
||||
expect(autogrowTextarea).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not show an AutogrowTextarea component when isOwned is false', () => {
|
||||
render(AlbumDescription, { isOwned: false, id: '', description: '' });
|
||||
const autogrowTextarea = screen.queryByTestId('autogrow-textarea');
|
||||
expect(autogrowTextarea).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -1,50 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { shortcut } from '$lib/actions/shortcut';
|
||||
import { handleError } from '$lib/utils/handle-error';
|
||||
import { updateAlbumInfo } from '@immich/sdk';
|
||||
import { Textarea } from '@immich/ui';
|
||||
import { t } from 'svelte-i18n';
|
||||
import { fromAction } from 'svelte/attachments';
|
||||
|
||||
interface Props {
|
||||
id: string;
|
||||
description: string;
|
||||
isOwned: boolean;
|
||||
}
|
||||
|
||||
let { id, description = $bindable(), isOwned }: Props = $props();
|
||||
|
||||
const handleFocusOut = async () => {
|
||||
try {
|
||||
await updateAlbumInfo({
|
||||
id,
|
||||
updateAlbumDto: {
|
||||
description,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
handleError(error, $t('errors.unable_to_save_album'));
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
{#if isOwned}
|
||||
<Textarea
|
||||
bind:value={description}
|
||||
class="outline-none border-b max-h-32 border-transparent pl-0 bg-transparent ring-0 focus:ring-0 resize-none focus:border-b-2 focus:border-immich-primary dark:focus:border-immich-dark-primary dark:bg-transparent"
|
||||
rows={1}
|
||||
grow
|
||||
shape="rectangle"
|
||||
onfocusout={handleFocusOut}
|
||||
placeholder={$t('add_a_description')}
|
||||
data-testid="autogrow-textarea"
|
||||
{@attach fromAction(shortcut, () => ({
|
||||
shortcut: { key: 'Enter', ctrl: true },
|
||||
onShortcut: (e) => e.currentTarget.blur(),
|
||||
}))}
|
||||
/>
|
||||
{:else if description}
|
||||
<p class="wrap-break-words whitespace-pre-line w-full text-black dark:text-white text-base">
|
||||
{description}
|
||||
</p>
|
||||
{/if}
|
||||
@@ -1,71 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { authManager } from '$lib/managers/auth-manager.svelte';
|
||||
import MapModal from '$lib/modals/MapModal.svelte';
|
||||
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
|
||||
import { getAlbumInfo, type AlbumResponseDto, type MapMarkerResponseDto } from '@immich/sdk';
|
||||
import { IconButton, modalManager } from '@immich/ui';
|
||||
import { mdiMapOutline } from '@mdi/js';
|
||||
import { onDestroy, onMount } from 'svelte';
|
||||
import { t } from 'svelte-i18n';
|
||||
|
||||
interface Props {
|
||||
album: AlbumResponseDto;
|
||||
}
|
||||
|
||||
let { album }: Props = $props();
|
||||
let abortController: AbortController;
|
||||
let { setAssetId } = assetViewingStore;
|
||||
|
||||
let mapMarkers: MapMarkerResponseDto[] = $state([]);
|
||||
|
||||
onMount(async () => {
|
||||
mapMarkers = await loadMapMarkers();
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
abortController?.abort();
|
||||
assetViewingStore.showAssetViewer(false);
|
||||
});
|
||||
|
||||
async function loadMapMarkers() {
|
||||
if (abortController) {
|
||||
abortController.abort();
|
||||
}
|
||||
abortController = new AbortController();
|
||||
|
||||
let albumInfo: AlbumResponseDto = await getAlbumInfo({ id: album.id, withoutAssets: false, ...authManager.params });
|
||||
|
||||
let markers: MapMarkerResponseDto[] = [];
|
||||
for (const asset of albumInfo.assets) {
|
||||
if (asset.exifInfo?.latitude && asset.exifInfo?.longitude) {
|
||||
markers.push({
|
||||
id: asset.id,
|
||||
lat: asset.exifInfo.latitude,
|
||||
lon: asset.exifInfo.longitude,
|
||||
city: asset.exifInfo?.city ?? null,
|
||||
country: asset.exifInfo?.country ?? null,
|
||||
state: asset.exifInfo?.state ?? null,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return markers;
|
||||
}
|
||||
|
||||
async function openMap() {
|
||||
const assetIds = await modalManager.show(MapModal, { mapMarkers });
|
||||
|
||||
if (assetIds) {
|
||||
await setAssetId(assetIds[0]);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<IconButton
|
||||
variant="ghost"
|
||||
shape="round"
|
||||
color="secondary"
|
||||
icon={mdiMapOutline}
|
||||
onclick={openMap}
|
||||
aria-label={$t('map')}
|
||||
/>
|
||||
@@ -1,48 +0,0 @@
|
||||
<script lang="ts">
|
||||
import ActionButton from '$lib/components/ActionButton.svelte';
|
||||
import { getSharedLinkActions } from '$lib/services/shared-link.service';
|
||||
import { locale } from '$lib/stores/preferences.store';
|
||||
import type { AlbumResponseDto, SharedLinkResponseDto } from '@immich/sdk';
|
||||
import { Text } from '@immich/ui';
|
||||
import { DateTime } from 'luxon';
|
||||
import { t } from 'svelte-i18n';
|
||||
|
||||
type Props = {
|
||||
album: AlbumResponseDto;
|
||||
sharedLink: SharedLinkResponseDto;
|
||||
};
|
||||
|
||||
const { album, sharedLink }: Props = $props();
|
||||
|
||||
const getShareProperties = () =>
|
||||
[
|
||||
DateTime.fromISO(sharedLink.createdAt).toLocaleString(
|
||||
{
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
},
|
||||
{ locale: $locale },
|
||||
),
|
||||
sharedLink.allowUpload && $t('upload'),
|
||||
sharedLink.allowDownload && $t('download'),
|
||||
sharedLink.showMetadata && $t('exif').toUpperCase(),
|
||||
sharedLink.password && $t('password'),
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' • ');
|
||||
|
||||
const { ViewQrCode, Copy, Delete } = $derived(getSharedLinkActions($t, sharedLink));
|
||||
</script>
|
||||
|
||||
<div class="flex justify-between items-center">
|
||||
<div class="flex flex-col gap-1">
|
||||
<Text size="small">{sharedLink.description || album.albumName}</Text>
|
||||
<Text size="tiny" color="muted">{getShareProperties()}</Text>
|
||||
</div>
|
||||
<div class="flex">
|
||||
<ActionButton action={ViewQrCode} />
|
||||
<ActionButton action={Copy} />
|
||||
<ActionButton action={Delete} />
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,17 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { getAlbumDateRange } from '$lib/utils/date-time';
|
||||
import type { AlbumResponseDto } from '@immich/sdk';
|
||||
import { t } from 'svelte-i18n';
|
||||
|
||||
interface Props {
|
||||
album: AlbumResponseDto;
|
||||
}
|
||||
|
||||
let { album }: Props = $props();
|
||||
</script>
|
||||
|
||||
<span class="my-2 flex gap-2 text-sm font-medium text-gray-500" data-testid="album-details">
|
||||
<span>{getAlbumDateRange(album)}</span>
|
||||
<span>•</span>
|
||||
<span>{$t('items_count', { values: { count: album.assetCount } })}</span>
|
||||
</span>
|
||||
@@ -1,49 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { shortcut } from '$lib/actions/shortcut';
|
||||
import { handleError } from '$lib/utils/handle-error';
|
||||
import { updateAlbumInfo } from '@immich/sdk';
|
||||
import { t } from 'svelte-i18n';
|
||||
|
||||
interface Props {
|
||||
id: string;
|
||||
albumName: string;
|
||||
isOwned: boolean;
|
||||
onUpdate: (albumName: string) => void;
|
||||
}
|
||||
|
||||
let { id, albumName = $bindable(), isOwned, onUpdate }: Props = $props();
|
||||
|
||||
let newAlbumName = $derived(albumName);
|
||||
|
||||
const handleUpdateName = async () => {
|
||||
if (newAlbumName === albumName) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
({ albumName } = await updateAlbumInfo({
|
||||
id,
|
||||
updateAlbumDto: {
|
||||
albumName: newAlbumName,
|
||||
},
|
||||
}));
|
||||
onUpdate(albumName);
|
||||
} catch (error) {
|
||||
handleError(error, $t('errors.unable_to_save_album'));
|
||||
return;
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<input
|
||||
use:shortcut={{ shortcut: { key: 'Enter' }, onShortcut: (e) => e.currentTarget.blur() }}
|
||||
onblur={handleUpdateName}
|
||||
class="w-[99%] mb-2 border-b-2 border-transparent text-2xl md:text-4xl lg:text-6xl text-primary outline-none transition-all {isOwned
|
||||
? 'hover:border-gray-400'
|
||||
: 'hover:border-transparent'} focus:border-b-2 focus:border-immich-primary focus:outline-none bg-light dark:focus:border-immich-dark-primary dark:focus:bg-immich-dark-gray placeholder:text-primary/90"
|
||||
type="text"
|
||||
bind:value={newAlbumName}
|
||||
disabled={!isOwned}
|
||||
title={$t('edit_title')}
|
||||
placeholder={$t('add_a_title')}
|
||||
/>
|
||||
@@ -1,160 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { shortcut } from '$lib/actions/shortcut';
|
||||
import ActionButton from '$lib/components/ActionButton.svelte';
|
||||
import AlbumMap from '$lib/components/album-page/album-map.svelte';
|
||||
import DownloadAction from '$lib/components/timeline/actions/DownloadAction.svelte';
|
||||
import SelectAllAssets from '$lib/components/timeline/actions/SelectAllAction.svelte';
|
||||
import AssetSelectControlBar from '$lib/components/timeline/AssetSelectControlBar.svelte';
|
||||
import Timeline from '$lib/components/timeline/Timeline.svelte';
|
||||
import { featureFlagsManager } from '$lib/managers/feature-flags-manager.svelte';
|
||||
import { TimelineManager } from '$lib/managers/timeline-manager/timeline-manager.svelte';
|
||||
import { handleDownloadAlbum } from '$lib/services/album.service';
|
||||
import { getGlobalActions } from '$lib/services/app.service';
|
||||
import { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
|
||||
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
|
||||
import { dragAndDropFilesStore } from '$lib/stores/drag-and-drop-files.store';
|
||||
import { mediaQueryManager } from '$lib/stores/media-query-manager.svelte';
|
||||
import { SlideshowNavigation, SlideshowState, slideshowStore } from '$lib/stores/slideshow.store';
|
||||
import { handlePromiseError } from '$lib/utils';
|
||||
import { cancelMultiselect } from '$lib/utils/asset-utils';
|
||||
import { fileUploadHandler, openFileUploadDialog } from '$lib/utils/file-uploader';
|
||||
import type { AlbumResponseDto, SharedLinkResponseDto, UserResponseDto } from '@immich/sdk';
|
||||
import { IconButton, Logo } from '@immich/ui';
|
||||
import { mdiDownload, mdiFileImagePlusOutline, mdiPresentationPlay } from '@mdi/js';
|
||||
import { t } from 'svelte-i18n';
|
||||
import ControlAppBar from '../shared-components/control-app-bar.svelte';
|
||||
import ThemeButton from '../shared-components/theme-button.svelte';
|
||||
import AlbumSummary from './album-summary.svelte';
|
||||
|
||||
interface Props {
|
||||
sharedLink: SharedLinkResponseDto;
|
||||
user?: UserResponseDto | undefined;
|
||||
}
|
||||
|
||||
let { sharedLink, user = undefined }: Props = $props();
|
||||
|
||||
const album = sharedLink.album as AlbumResponseDto;
|
||||
|
||||
let { isViewing: showAssetViewer, setAssetId } = assetViewingStore;
|
||||
let { slideshowState, slideshowNavigation } = slideshowStore;
|
||||
|
||||
const options = $derived({ albumId: album.id, order: album.order });
|
||||
let timelineManager = $state<TimelineManager>() as TimelineManager;
|
||||
|
||||
const assetInteraction = new AssetInteraction();
|
||||
|
||||
dragAndDropFilesStore.subscribe((value) => {
|
||||
if (value.isDragging && value.files.length > 0) {
|
||||
handlePromiseError(fileUploadHandler({ files: value.files, albumId: album.id }));
|
||||
dragAndDropFilesStore.set({ isDragging: false, files: [] });
|
||||
}
|
||||
});
|
||||
|
||||
const handleStartSlideshow = async () => {
|
||||
const asset =
|
||||
$slideshowNavigation === SlideshowNavigation.Shuffle
|
||||
? await timelineManager.getRandomAsset()
|
||||
: timelineManager.months[0]?.dayGroups[0]?.viewerAssets[0]?.asset;
|
||||
if (asset) {
|
||||
handlePromiseError(setAssetId(asset.id).then(() => ($slideshowState = SlideshowState.PlaySlideshow)));
|
||||
}
|
||||
};
|
||||
|
||||
const { Cast } = $derived(getGlobalActions($t));
|
||||
</script>
|
||||
|
||||
<svelte:document
|
||||
use:shortcut={{
|
||||
shortcut: { key: 'Escape' },
|
||||
onShortcut: () => {
|
||||
if (!$showAssetViewer && assetInteraction.selectionActive) {
|
||||
cancelMultiselect(assetInteraction);
|
||||
}
|
||||
},
|
||||
}}
|
||||
/>
|
||||
|
||||
<main class="relative h-dvh overflow-hidden px-2 md:px-6 max-md:pt-(--navbar-height-md) pt-(--navbar-height)">
|
||||
<Timeline enableRouting={true} {album} bind:timelineManager {options} {assetInteraction}>
|
||||
<section class="pt-8 md:pt-24 px-2 md:px-0">
|
||||
<!-- ALBUM TITLE -->
|
||||
<h1 class="text-2xl md:text-4xl lg:text-6xl text-primary outline-none transition-all">
|
||||
{album.albumName}
|
||||
</h1>
|
||||
|
||||
{#if album.assetCount > 0}
|
||||
<AlbumSummary {album} />
|
||||
{/if}
|
||||
|
||||
<!-- ALBUM DESCRIPTION -->
|
||||
{#if album.description}
|
||||
<p
|
||||
class="whitespace-pre-line mb-12 mt-6 w-full pb-2 text-start font-medium text-base text-black dark:text-gray-300"
|
||||
>
|
||||
{album.description}
|
||||
</p>
|
||||
{/if}
|
||||
</section>
|
||||
</Timeline>
|
||||
</main>
|
||||
|
||||
<header>
|
||||
{#if assetInteraction.selectionActive}
|
||||
<AssetSelectControlBar
|
||||
ownerId={user?.id}
|
||||
assets={assetInteraction.selectedAssets}
|
||||
clearSelect={() => assetInteraction.clearMultiselect()}
|
||||
>
|
||||
<SelectAllAssets {timelineManager} {assetInteraction} />
|
||||
{#if sharedLink.allowDownload}
|
||||
<DownloadAction filename="{album.albumName}.zip" />
|
||||
{/if}
|
||||
</AssetSelectControlBar>
|
||||
{:else}
|
||||
<ControlAppBar showBackButton={false}>
|
||||
{#snippet leading()}
|
||||
<a data-sveltekit-preload-data="hover" class="ms-4" href="/">
|
||||
<Logo variant={mediaQueryManager.maxMd ? 'icon' : 'inline'} class="min-w-10" />
|
||||
</a>
|
||||
{/snippet}
|
||||
|
||||
{#snippet trailing()}
|
||||
<ActionButton action={Cast} />
|
||||
|
||||
{#if sharedLink.allowUpload}
|
||||
<IconButton
|
||||
shape="round"
|
||||
color="secondary"
|
||||
variant="ghost"
|
||||
aria-label={$t('add_photos')}
|
||||
onclick={() => openFileUploadDialog({ albumId: album.id })}
|
||||
icon={mdiFileImagePlusOutline}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
{#if album.assetCount > 0 && sharedLink.allowDownload}
|
||||
<IconButton
|
||||
shape="round"
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
aria-label={$t('slideshow')}
|
||||
onclick={handleStartSlideshow}
|
||||
icon={mdiPresentationPlay}
|
||||
/>
|
||||
<IconButton
|
||||
shape="round"
|
||||
color="secondary"
|
||||
variant="ghost"
|
||||
aria-label={$t('download')}
|
||||
onclick={() => handleDownloadAlbum(album)}
|
||||
icon={mdiDownload}
|
||||
/>
|
||||
{/if}
|
||||
{#if sharedLink.showMetadata && featureFlagsManager.value.map}
|
||||
<AlbumMap {album} />
|
||||
{/if}
|
||||
<ThemeButton />
|
||||
{/snippet}
|
||||
</ControlAppBar>
|
||||
{/if}
|
||||
</header>
|
||||
@@ -1,219 +0,0 @@
|
||||
<script lang="ts">
|
||||
import Dropdown from '$lib/elements/Dropdown.svelte';
|
||||
import GroupTab from '$lib/elements/GroupTab.svelte';
|
||||
import SearchBar from '$lib/elements/SearchBar.svelte';
|
||||
import {
|
||||
AlbumFilter,
|
||||
AlbumGroupBy,
|
||||
AlbumSortBy,
|
||||
AlbumViewMode,
|
||||
albumViewSettings,
|
||||
SortOrder,
|
||||
} from '$lib/stores/preferences.store';
|
||||
import {
|
||||
type AlbumGroupOptionMetadata,
|
||||
type AlbumSortOptionMetadata,
|
||||
collapseAllAlbumGroups,
|
||||
createAlbumAndRedirect,
|
||||
expandAllAlbumGroups,
|
||||
findFilterOption,
|
||||
findGroupOptionMetadata,
|
||||
findSortOptionMetadata,
|
||||
getSelectedAlbumGroupOption,
|
||||
groupOptionsMetadata,
|
||||
sortOptionsMetadata,
|
||||
} from '$lib/utils/album-utils';
|
||||
import { Button, IconButton, Text } from '@immich/ui';
|
||||
import {
|
||||
mdiArrowDownThin,
|
||||
mdiArrowUpThin,
|
||||
mdiFolderArrowDownOutline,
|
||||
mdiFolderArrowUpOutline,
|
||||
mdiFolderRemoveOutline,
|
||||
mdiFormatListBulletedSquare,
|
||||
mdiPlusBoxOutline,
|
||||
mdiUnfoldLessHorizontal,
|
||||
mdiUnfoldMoreHorizontal,
|
||||
mdiViewGridOutline,
|
||||
} from '@mdi/js';
|
||||
import { t } from 'svelte-i18n';
|
||||
import { fly } from 'svelte/transition';
|
||||
|
||||
interface Props {
|
||||
albumGroups: string[];
|
||||
searchQuery: string;
|
||||
}
|
||||
|
||||
let { albumGroups, searchQuery = $bindable() }: Props = $props();
|
||||
|
||||
const flipOrdering = (ordering: string) => {
|
||||
return ordering === SortOrder.Asc ? SortOrder.Desc : SortOrder.Asc;
|
||||
};
|
||||
|
||||
const handleChangeAlbumFilter = (filter: string, defaultFilter: AlbumFilter) => {
|
||||
$albumViewSettings.filter =
|
||||
Object.keys(albumFilterNames).find((key) => albumFilterNames[key as AlbumFilter] === filter) ?? defaultFilter;
|
||||
};
|
||||
|
||||
const handleChangeGroupBy = ({ id, defaultOrder }: AlbumGroupOptionMetadata) => {
|
||||
if ($albumViewSettings.groupBy === id) {
|
||||
$albumViewSettings.groupOrder = flipOrdering($albumViewSettings.groupOrder);
|
||||
} else {
|
||||
$albumViewSettings.groupBy = id;
|
||||
$albumViewSettings.groupOrder = defaultOrder;
|
||||
}
|
||||
};
|
||||
|
||||
const handleChangeSortBy = ({ id, defaultOrder }: AlbumSortOptionMetadata) => {
|
||||
if ($albumViewSettings.sortBy === id) {
|
||||
$albumViewSettings.sortOrder = flipOrdering($albumViewSettings.sortOrder);
|
||||
} else {
|
||||
$albumViewSettings.sortBy = id;
|
||||
$albumViewSettings.sortOrder = defaultOrder;
|
||||
}
|
||||
};
|
||||
|
||||
const handleChangeListMode = () => {
|
||||
$albumViewSettings.view =
|
||||
$albumViewSettings.view === AlbumViewMode.Cover ? AlbumViewMode.List : AlbumViewMode.Cover;
|
||||
};
|
||||
|
||||
let groupIcon = $derived.by(() => {
|
||||
if (selectedGroupOption?.id === AlbumGroupBy.None) {
|
||||
return mdiFolderRemoveOutline;
|
||||
}
|
||||
return $albumViewSettings.groupOrder === SortOrder.Desc ? mdiFolderArrowDownOutline : mdiFolderArrowUpOutline;
|
||||
});
|
||||
|
||||
let albumFilterNames: Record<AlbumFilter, string> = $derived({
|
||||
[AlbumFilter.All]: $t('all'),
|
||||
[AlbumFilter.Owned]: $t('owned'),
|
||||
[AlbumFilter.Shared]: $t('shared'),
|
||||
});
|
||||
|
||||
let selectedFilterOption = $derived(albumFilterNames[findFilterOption($albumViewSettings.filter)]);
|
||||
let selectedSortOption = $derived(findSortOptionMetadata($albumViewSettings.sortBy));
|
||||
let selectedGroupOption = $derived(findGroupOptionMetadata($albumViewSettings.groupBy));
|
||||
let sortIcon = $derived($albumViewSettings.sortOrder === SortOrder.Desc ? mdiArrowDownThin : mdiArrowUpThin);
|
||||
|
||||
let albumSortByNames: Record<AlbumSortBy, string> = $derived({
|
||||
[AlbumSortBy.Title]: $t('sort_title'),
|
||||
[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'),
|
||||
});
|
||||
|
||||
let albumGroupByNames: Record<AlbumGroupBy, string> = $derived({
|
||||
[AlbumGroupBy.None]: $t('group_no'),
|
||||
[AlbumGroupBy.Owner]: $t('group_owner'),
|
||||
[AlbumGroupBy.Year]: $t('group_year'),
|
||||
});
|
||||
</script>
|
||||
|
||||
<!-- Filter Albums by Sharing Status (All, Owned, Shared) -->
|
||||
<div class="hidden xl:block h-10">
|
||||
<GroupTab
|
||||
label={$t('show_albums')}
|
||||
filters={Object.values(albumFilterNames)}
|
||||
selected={selectedFilterOption}
|
||||
onSelect={(selected) => handleChangeAlbumFilter(selected, AlbumFilter.All)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Search Albums -->
|
||||
<div class="hidden xl:block h-10 xl:w-60 2xl:w-80">
|
||||
<SearchBar placeholder={$t('search_albums')} bind:name={searchQuery} showLoadingSpinner={false} />
|
||||
</div>
|
||||
|
||||
<!-- Create Album -->
|
||||
<Button
|
||||
leadingIcon={mdiPlusBoxOutline}
|
||||
onclick={() => createAlbumAndRedirect()}
|
||||
size="small"
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
>
|
||||
<p class="hidden md:block">{$t('create_album')}</p>
|
||||
</Button>
|
||||
|
||||
<!-- Sort Albums -->
|
||||
<Dropdown
|
||||
title={$t('sort_albums_by')}
|
||||
options={Object.values(sortOptionsMetadata)}
|
||||
selectedOption={selectedSortOption}
|
||||
onSelect={handleChangeSortBy}
|
||||
render={({ id }) => ({
|
||||
title: albumSortByNames[id],
|
||||
icon: sortIcon,
|
||||
})}
|
||||
/>
|
||||
|
||||
<!-- Group Albums -->
|
||||
<Dropdown
|
||||
title={$t('group_albums_by')}
|
||||
options={Object.values(groupOptionsMetadata)}
|
||||
selectedOption={selectedGroupOption}
|
||||
onSelect={handleChangeGroupBy}
|
||||
render={({ id, isDisabled }) => ({
|
||||
title: albumGroupByNames[id],
|
||||
icon: groupIcon,
|
||||
disabled: isDisabled(),
|
||||
})}
|
||||
/>
|
||||
|
||||
{#if getSelectedAlbumGroupOption($albumViewSettings) !== AlbumGroupBy.None}
|
||||
<span in:fly={{ x: -50, duration: 250 }}>
|
||||
<!-- Expand Album Groups -->
|
||||
<div class="hidden xl:flex gap-0">
|
||||
<div class="block">
|
||||
<IconButton
|
||||
title={$t('expand_all')}
|
||||
onclick={() => expandAllAlbumGroups()}
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
shape="round"
|
||||
icon={mdiUnfoldMoreHorizontal}
|
||||
aria-label={$t('expand_all')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Collapse Album Groups -->
|
||||
<div class="block">
|
||||
<IconButton
|
||||
title={$t('collapse_all')}
|
||||
onclick={() => collapseAllAlbumGroups(albumGroups)}
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
shape="round"
|
||||
icon={mdiUnfoldLessHorizontal}
|
||||
aria-label={$t('collapse_all')}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</span>
|
||||
{/if}
|
||||
|
||||
<!-- Cover/List Display Toggle -->
|
||||
{#if $albumViewSettings.view === AlbumViewMode.List}
|
||||
<Button
|
||||
leadingIcon={mdiViewGridOutline}
|
||||
onclick={() => handleChangeListMode()}
|
||||
size="small"
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
>
|
||||
<Text class="hidden md:block">{$t('covers')}</Text>
|
||||
</Button>
|
||||
{:else}
|
||||
<Button
|
||||
leadingIcon={mdiFormatListBulletedSquare}
|
||||
onclick={() => handleChangeListMode()}
|
||||
size="small"
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
>
|
||||
<Text class="hidden md:block">{$t('list')}</Text>
|
||||
</Button>
|
||||
{/if}
|
||||
@@ -1,301 +0,0 @@
|
||||
<script lang="ts">
|
||||
import AlbumCardGroup from '$lib/components/album-page/album-card-group.svelte';
|
||||
import AlbumsTable from '$lib/components/album-page/albums-table.svelte';
|
||||
import OnEvents from '$lib/components/OnEvents.svelte';
|
||||
import MenuOption from '$lib/components/shared-components/context-menu/menu-option.svelte';
|
||||
import RightClickContextMenu from '$lib/components/shared-components/context-menu/right-click-context-menu.svelte';
|
||||
import AlbumEditModal from '$lib/modals/AlbumEditModal.svelte';
|
||||
import AlbumOptionsModal from '$lib/modals/AlbumOptionsModal.svelte';
|
||||
import { handleDeleteAlbum, handleDownloadAlbum } from '$lib/services/album.service';
|
||||
import {
|
||||
AlbumFilter,
|
||||
AlbumGroupBy,
|
||||
AlbumSortBy,
|
||||
AlbumViewMode,
|
||||
locale,
|
||||
SortOrder,
|
||||
type AlbumViewSettings,
|
||||
} from '$lib/stores/preferences.store';
|
||||
import { user } from '$lib/stores/user.store';
|
||||
import { userInteraction } from '$lib/stores/user.svelte';
|
||||
import { getSelectedAlbumGroupOption, sortAlbums, stringToSortOrder, type AlbumGroup } from '$lib/utils/album-utils';
|
||||
import type { ContextMenuPosition } from '$lib/utils/context-menu';
|
||||
import { normalizeSearchString } from '$lib/utils/string-utils';
|
||||
import { type AlbumResponseDto, type SharedLinkResponseDto } from '@immich/sdk';
|
||||
import { modalManager } from '@immich/ui';
|
||||
import { mdiDeleteOutline, mdiDownload, mdiRenameOutline, mdiShareVariantOutline } from '@mdi/js';
|
||||
import { groupBy } from 'lodash-es';
|
||||
import { onMount, type Snippet } from 'svelte';
|
||||
import { t } from 'svelte-i18n';
|
||||
|
||||
interface Props {
|
||||
ownedAlbums?: AlbumResponseDto[];
|
||||
sharedAlbums?: AlbumResponseDto[];
|
||||
searchQuery?: string;
|
||||
userSettings: AlbumViewSettings;
|
||||
allowEdit?: boolean;
|
||||
showOwner?: boolean;
|
||||
albumGroupIds?: string[];
|
||||
empty?: Snippet;
|
||||
}
|
||||
|
||||
let {
|
||||
ownedAlbums = $bindable([]),
|
||||
sharedAlbums = $bindable([]),
|
||||
searchQuery = '',
|
||||
userSettings,
|
||||
allowEdit = false,
|
||||
showOwner = false,
|
||||
albumGroupIds = $bindable([]),
|
||||
empty,
|
||||
}: Props = $props();
|
||||
|
||||
interface AlbumGroupOption {
|
||||
[option: string]: (order: SortOrder, albums: AlbumResponseDto[]) => AlbumGroup[];
|
||||
}
|
||||
|
||||
const groupOptions: AlbumGroupOption = {
|
||||
/** No grouping */
|
||||
[AlbumGroupBy.None]: (order, albums): AlbumGroup[] => {
|
||||
return [
|
||||
{
|
||||
id: $t('albums'),
|
||||
name: $t('albums'),
|
||||
albums,
|
||||
},
|
||||
];
|
||||
},
|
||||
|
||||
/** Group by year */
|
||||
[AlbumGroupBy.Year]: (order, albums): AlbumGroup[] => {
|
||||
const unknownYear = $t('unknown_year');
|
||||
const useStartDate = userSettings.sortBy === AlbumSortBy.OldestPhoto;
|
||||
|
||||
const groupedByYear = groupBy(albums, (album) => {
|
||||
const date = useStartDate ? album.startDate : album.endDate;
|
||||
return date ? new Date(date).getFullYear() : unknownYear;
|
||||
});
|
||||
|
||||
const sortSign = order === SortOrder.Desc ? -1 : 1;
|
||||
const sortedByYear = Object.entries(groupedByYear).sort(([a], [b]) => {
|
||||
// We make sure empty albums stay at the end of the list
|
||||
if (a === unknownYear) {
|
||||
return 1;
|
||||
} else if (b === unknownYear) {
|
||||
return -1;
|
||||
} else {
|
||||
return (Number.parseInt(a) - Number.parseInt(b)) * sortSign;
|
||||
}
|
||||
});
|
||||
|
||||
return sortedByYear.map(([year, albums]) => ({
|
||||
id: year,
|
||||
name: year,
|
||||
albums,
|
||||
}));
|
||||
},
|
||||
|
||||
/** Group by owner */
|
||||
[AlbumGroupBy.Owner]: (order, albums): AlbumGroup[] => {
|
||||
const currentUserId = $user.id;
|
||||
const groupedByOwnerIds = groupBy(albums, 'ownerId');
|
||||
|
||||
const sortSign = order === SortOrder.Desc ? -1 : 1;
|
||||
const sortedByOwnerNames = Object.entries(groupedByOwnerIds).sort(([ownerA, albumsA], [ownerB, albumsB]) => {
|
||||
// We make sure owned albums stay either at the beginning or the end
|
||||
// of the list
|
||||
if (ownerA === currentUserId) {
|
||||
return -sortSign;
|
||||
} else if (ownerB === currentUserId) {
|
||||
return sortSign;
|
||||
} else {
|
||||
return albumsA[0].owner.name.localeCompare(albumsB[0].owner.name, $locale) * sortSign;
|
||||
}
|
||||
});
|
||||
|
||||
return sortedByOwnerNames.map(([ownerId, albums]) => ({
|
||||
id: ownerId,
|
||||
name: ownerId === currentUserId ? $t('my_albums') : albums[0].owner.name,
|
||||
albums,
|
||||
}));
|
||||
},
|
||||
};
|
||||
|
||||
let albums = $derived.by(() => {
|
||||
switch (userSettings.filter) {
|
||||
case AlbumFilter.Owned: {
|
||||
return ownedAlbums;
|
||||
}
|
||||
case AlbumFilter.Shared: {
|
||||
return sharedAlbums;
|
||||
}
|
||||
default: {
|
||||
const nonOwnedAlbums = sharedAlbums.filter((album) => album.ownerId !== $user.id);
|
||||
return nonOwnedAlbums.length > 0 ? ownedAlbums.concat(nonOwnedAlbums) : ownedAlbums;
|
||||
}
|
||||
}
|
||||
});
|
||||
const normalizedSearchQuery = $derived(normalizeSearchString(searchQuery));
|
||||
let filteredAlbums = $derived(
|
||||
normalizedSearchQuery
|
||||
? albums.filter(
|
||||
({ albumName, description }) =>
|
||||
normalizeSearchString(albumName).includes(normalizedSearchQuery) ||
|
||||
normalizeSearchString(description).includes(normalizedSearchQuery),
|
||||
)
|
||||
: albums,
|
||||
);
|
||||
|
||||
let albumGroupOption = $derived(getSelectedAlbumGroupOption(userSettings));
|
||||
let groupedAlbums = $derived.by(() => {
|
||||
const groupFunc = groupOptions[albumGroupOption] ?? groupOptions[AlbumGroupBy.None];
|
||||
const groupedAlbums = groupFunc(stringToSortOrder(userSettings.groupOrder), filteredAlbums);
|
||||
|
||||
return groupedAlbums.map((group) => ({
|
||||
id: group.id,
|
||||
name: group.name,
|
||||
albums: sortAlbums(group.albums, { sortBy: userSettings.sortBy, orderBy: userSettings.sortOrder }),
|
||||
}));
|
||||
});
|
||||
|
||||
let contextMenuPosition: ContextMenuPosition = $state({ x: 0, y: 0 });
|
||||
let selectedAlbum: AlbumResponseDto | undefined = $state();
|
||||
let isOpen = $state(false);
|
||||
|
||||
// TODO get rid of this
|
||||
$effect(() => {
|
||||
albumGroupIds = groupedAlbums.map(({ id }) => id);
|
||||
});
|
||||
|
||||
let showFullContextMenu = $derived(allowEdit && selectedAlbum && selectedAlbum.ownerId === $user.id);
|
||||
|
||||
onMount(async () => {
|
||||
if (allowEdit) {
|
||||
await removeAlbumsIfEmpty();
|
||||
}
|
||||
});
|
||||
|
||||
const showAlbumContextMenu = (contextMenuDetail: ContextMenuPosition, album: AlbumResponseDto) => {
|
||||
selectedAlbum = album;
|
||||
contextMenuPosition = {
|
||||
x: contextMenuDetail.x,
|
||||
y: contextMenuDetail.y,
|
||||
};
|
||||
isOpen = true;
|
||||
};
|
||||
|
||||
const closeAlbumContextMenu = () => {
|
||||
isOpen = false;
|
||||
};
|
||||
|
||||
const handleSelect = async (action: 'edit' | 'share' | 'download' | 'delete') => {
|
||||
closeAlbumContextMenu();
|
||||
|
||||
if (!selectedAlbum) {
|
||||
return;
|
||||
}
|
||||
|
||||
switch (action) {
|
||||
case 'edit': {
|
||||
await modalManager.show(AlbumEditModal, { album: selectedAlbum });
|
||||
break;
|
||||
}
|
||||
|
||||
case 'share': {
|
||||
await modalManager.show(AlbumOptionsModal, { album: selectedAlbum });
|
||||
break;
|
||||
}
|
||||
|
||||
case 'download': {
|
||||
await handleDownloadAlbum(selectedAlbum);
|
||||
break;
|
||||
}
|
||||
|
||||
case 'delete': {
|
||||
await handleDeleteAlbum(selectedAlbum);
|
||||
break;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const removeAlbumsIfEmpty = async () => {
|
||||
const albumsToRemove = ownedAlbums.filter((album) => album.assetCount === 0 && !album.albumName);
|
||||
await Promise.allSettled(albumsToRemove.map((album) => handleDeleteAlbum(album, { prompt: false, notify: false })));
|
||||
};
|
||||
|
||||
const findAndUpdate = (albums: AlbumResponseDto[], album: AlbumResponseDto) => {
|
||||
const target = albums.find(({ id }) => id === album.id);
|
||||
if (target) {
|
||||
Object.assign(target, album);
|
||||
}
|
||||
|
||||
return albums;
|
||||
};
|
||||
|
||||
const onUpdate = (album: AlbumResponseDto) => {
|
||||
ownedAlbums = findAndUpdate(ownedAlbums, album);
|
||||
sharedAlbums = findAndUpdate(sharedAlbums, album);
|
||||
};
|
||||
|
||||
const onAlbumUpdate = (album: AlbumResponseDto) => {
|
||||
onUpdate(album);
|
||||
userInteraction.recentAlbums = findAndUpdate(userInteraction.recentAlbums || [], album);
|
||||
};
|
||||
|
||||
const onAlbumDelete = (album: AlbumResponseDto) => {
|
||||
ownedAlbums = ownedAlbums.filter(({ id }) => id !== album.id);
|
||||
sharedAlbums = sharedAlbums.filter(({ id }) => id !== album.id);
|
||||
};
|
||||
|
||||
const onSharedLinkCreate = (sharedLink: SharedLinkResponseDto) => {
|
||||
if (sharedLink.album) {
|
||||
onUpdate(sharedLink.album);
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<OnEvents {onAlbumUpdate} {onAlbumDelete} {onSharedLinkCreate} />
|
||||
|
||||
{#if albums.length > 0}
|
||||
{#if userSettings.view === AlbumViewMode.Cover}
|
||||
<!-- Album Cards -->
|
||||
{#if albumGroupOption === AlbumGroupBy.None}
|
||||
<AlbumCardGroup
|
||||
albums={groupedAlbums[0].albums}
|
||||
{showOwner}
|
||||
showDateRange
|
||||
showItemCount
|
||||
onShowContextMenu={showAlbumContextMenu}
|
||||
/>
|
||||
{:else}
|
||||
{#each groupedAlbums as albumGroup (albumGroup.id)}
|
||||
<AlbumCardGroup
|
||||
albums={albumGroup.albums}
|
||||
group={albumGroup}
|
||||
{showOwner}
|
||||
showDateRange
|
||||
showItemCount
|
||||
onShowContextMenu={showAlbumContextMenu}
|
||||
/>
|
||||
{/each}
|
||||
{/if}
|
||||
{:else if userSettings.view === AlbumViewMode.List}
|
||||
<!-- Album Table -->
|
||||
<AlbumsTable {groupedAlbums} {albumGroupOption} onShowContextMenu={showAlbumContextMenu} />
|
||||
{/if}
|
||||
{:else}
|
||||
<!-- Empty Message -->
|
||||
{@render empty?.()}
|
||||
{/if}
|
||||
|
||||
<!-- Context Menu -->
|
||||
<RightClickContextMenu title={$t('album_options')} {...contextMenuPosition} {isOpen} onClose={closeAlbumContextMenu}>
|
||||
{#if showFullContextMenu}
|
||||
<MenuOption icon={mdiRenameOutline} text={$t('edit_album')} onClick={() => handleSelect('edit')} />
|
||||
<MenuOption icon={mdiShareVariantOutline} text={$t('share')} onClick={() => handleSelect('share')} />
|
||||
{/if}
|
||||
<MenuOption icon={mdiDownload} text={$t('download')} onClick={() => handleSelect('download')} />
|
||||
{#if showFullContextMenu}
|
||||
<MenuOption icon={mdiDeleteOutline} text={$t('delete')} onClick={() => handleSelect('delete')} />
|
||||
{/if}
|
||||
</RightClickContextMenu>
|
||||
@@ -1,46 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { albumViewSettings, SortOrder, AlbumSortBy } from '$lib/stores/preferences.store';
|
||||
import type { AlbumSortOptionMetadata } from '$lib/utils/album-utils';
|
||||
import { t } from 'svelte-i18n';
|
||||
|
||||
interface Props {
|
||||
option: AlbumSortOptionMetadata;
|
||||
}
|
||||
|
||||
let { option }: Props = $props();
|
||||
|
||||
const handleSort = () => {
|
||||
if ($albumViewSettings.sortBy === option.id) {
|
||||
$albumViewSettings.sortOrder = $albumViewSettings.sortOrder === SortOrder.Asc ? SortOrder.Desc : SortOrder.Asc;
|
||||
} else {
|
||||
$albumViewSettings.sortBy = option.id;
|
||||
$albumViewSettings.sortOrder = option.defaultOrder;
|
||||
}
|
||||
};
|
||||
|
||||
let albumSortByNames: Record<AlbumSortBy, string> = $derived({
|
||||
[AlbumSortBy.Title]: $t('sort_title'),
|
||||
[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'),
|
||||
});
|
||||
</script>
|
||||
|
||||
<th class="text-sm font-medium {option.columnStyle}">
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-lg p-2 hover:bg-immich-dark-primary hover:dark:bg-immich-dark-primary/50"
|
||||
onclick={handleSort}
|
||||
>
|
||||
{#if $albumViewSettings.sortBy === option.id}
|
||||
{#if $albumViewSettings.sortOrder === SortOrder.Desc}
|
||||
↓
|
||||
{:else}
|
||||
↑
|
||||
{/if}
|
||||
{/if}
|
||||
{albumSortByNames[option.id]}
|
||||
</button>
|
||||
</th>
|
||||
@@ -1,75 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { dateFormats } from '$lib/constants';
|
||||
import { Route } from '$lib/route';
|
||||
import { locale } from '$lib/stores/preferences.store';
|
||||
import { user } from '$lib/stores/user.store';
|
||||
import type { ContextMenuPosition } from '$lib/utils/context-menu';
|
||||
import type { AlbumResponseDto } from '@immich/sdk';
|
||||
import { Icon } from '@immich/ui';
|
||||
import { mdiShareVariantOutline } from '@mdi/js';
|
||||
import { t } from 'svelte-i18n';
|
||||
|
||||
interface Props {
|
||||
album: AlbumResponseDto;
|
||||
onShowContextMenu?: ((position: ContextMenuPosition, album: AlbumResponseDto) => unknown) | undefined;
|
||||
}
|
||||
|
||||
let { album, onShowContextMenu = undefined }: Props = $props();
|
||||
|
||||
const showContextMenu = (position: ContextMenuPosition) => {
|
||||
onShowContextMenu?.(position, album);
|
||||
};
|
||||
|
||||
const dateLocaleString = (dateString: string) => {
|
||||
return new Date(dateString).toLocaleDateString($locale, dateFormats.album);
|
||||
};
|
||||
|
||||
const oncontextmenu = (event: MouseEvent) => {
|
||||
event.preventDefault();
|
||||
showContextMenu({ x: event.x, y: event.y });
|
||||
};
|
||||
</script>
|
||||
|
||||
<tr
|
||||
class="flex w-full place-items-center border-3 border-transparent p-2 text-center even:bg-subtle/20 odd:bg-subtle/80 hover:cursor-pointer hover:border-immich-primary/75 odd:dark:bg-immich-dark-gray/75 even:dark:bg-immich-dark-gray/50 dark:hover:border-immich-dark-primary/75 md:px-5 md:py-2"
|
||||
onclick={() => goto(Route.viewAlbum(album))}
|
||||
{oncontextmenu}
|
||||
>
|
||||
<td class="text-md text-ellipsis text-start w-8/12 sm:w-4/12 md:w-4/12 xl:w-[30%] 2xl:w-[40%] items-center">
|
||||
{album.albumName}
|
||||
{#if album.shared}
|
||||
<Icon
|
||||
icon={mdiShareVariantOutline}
|
||||
size="16"
|
||||
class="inline ms-1 opacity-70"
|
||||
title={album.ownerId === $user.id
|
||||
? $t('shared_by_you')
|
||||
: $t('shared_by_user', { values: { user: album.owner.name } })}
|
||||
/>
|
||||
{/if}
|
||||
</td>
|
||||
<td class="text-md text-ellipsis text-center sm:w-2/12 md:w-2/12 xl:w-[15%] 2xl:w-[12%]">
|
||||
{$t('items_count', { values: { count: album.assetCount } })}
|
||||
</td>
|
||||
<td class="text-md hidden text-ellipsis text-center sm:block w-3/12 xl:w-[15%] 2xl:w-[12%]">
|
||||
{dateLocaleString(album.updatedAt)}
|
||||
</td>
|
||||
<td class="text-md hidden text-ellipsis text-center sm:block w-3/12 xl:w-[15%] 2xl:w-[12%]">
|
||||
{dateLocaleString(album.createdAt)}
|
||||
</td>
|
||||
<td class="text-md text-ellipsis text-center hidden xl:block xl:w-[15%] 2xl:w-[12%]">
|
||||
{#if album.endDate}
|
||||
{dateLocaleString(album.endDate)}
|
||||
{:else}
|
||||
-
|
||||
{/if}
|
||||
</td>
|
||||
<td class="text-md text-ellipsis text-center hidden xl:block xl:w-[15%] 2xl:w-[12%]">
|
||||
{#if album.startDate}
|
||||
{dateLocaleString(album.startDate)}
|
||||
{:else}
|
||||
-
|
||||
{/if}
|
||||
</td>
|
||||
</tr>
|
||||
@@ -1,80 +0,0 @@
|
||||
<script lang="ts">
|
||||
import AlbumTableHeader from '$lib/components/album-page/albums-table-header.svelte';
|
||||
import AlbumTableRow from '$lib/components/album-page/albums-table-row.svelte';
|
||||
import { AlbumGroupBy, albumViewSettings } from '$lib/stores/preferences.store';
|
||||
import {
|
||||
isAlbumGroupCollapsed,
|
||||
sortOptionsMetadata,
|
||||
toggleAlbumGroupCollapsing,
|
||||
type AlbumGroup,
|
||||
} from '$lib/utils/album-utils';
|
||||
import type { ContextMenuPosition } from '$lib/utils/context-menu';
|
||||
import type { AlbumResponseDto } from '@immich/sdk';
|
||||
import { Icon } from '@immich/ui';
|
||||
import { mdiChevronRight } from '@mdi/js';
|
||||
import { t } from 'svelte-i18n';
|
||||
import { slide } from 'svelte/transition';
|
||||
|
||||
interface Props {
|
||||
groupedAlbums: AlbumGroup[];
|
||||
albumGroupOption?: string;
|
||||
onShowContextMenu?: ((position: ContextMenuPosition, album: AlbumResponseDto) => unknown) | undefined;
|
||||
}
|
||||
|
||||
let { groupedAlbums, albumGroupOption = AlbumGroupBy.None, onShowContextMenu }: Props = $props();
|
||||
</script>
|
||||
|
||||
<table class="mt-2 w-full text-start">
|
||||
<thead
|
||||
class="mb-4 flex h-12 w-full rounded-md border bg-gray-50 text-primary dark:border-immich-dark-gray dark:bg-immich-dark-gray"
|
||||
>
|
||||
<tr class="flex w-full place-items-center p-2 md:p-5">
|
||||
{#each sortOptionsMetadata as option, index (index)}
|
||||
<AlbumTableHeader {option} />
|
||||
{/each}
|
||||
</tr>
|
||||
</thead>
|
||||
{#if albumGroupOption === AlbumGroupBy.None}
|
||||
<tbody class="block w-full overflow-y-auto rounded-md border dark:border-immich-dark-gray dark:text-immich-dark-fg">
|
||||
{#each groupedAlbums[0].albums as album (album.id)}
|
||||
<AlbumTableRow {album} {onShowContextMenu} />
|
||||
{/each}
|
||||
</tbody>
|
||||
{:else}
|
||||
{#each groupedAlbums as albumGroup (albumGroup.id)}
|
||||
{@const isCollapsed = isAlbumGroupCollapsed($albumViewSettings, albumGroup.id)}
|
||||
{@const iconRotation = isCollapsed ? 'rotate-0' : 'rotate-90'}
|
||||
<tbody
|
||||
class="block w-full overflow-y-auto rounded-md border dark:border-immich-dark-gray dark:text-immich-dark-fg mt-4"
|
||||
>
|
||||
<tr
|
||||
class="flex w-full place-items-center p-2 md:ps-5 md:pe-5 md:pt-3 md:pb-3"
|
||||
onclick={() => toggleAlbumGroupCollapsing(albumGroup.id)}
|
||||
aria-expanded={!isCollapsed}
|
||||
>
|
||||
<td class="text-md text-start -mb-1">
|
||||
<Icon
|
||||
icon={mdiChevronRight}
|
||||
size="20"
|
||||
class="inline-block -mt-2 transition-all duration-250 {iconRotation}"
|
||||
/>
|
||||
<span class="font-bold text-2xl">{albumGroup.name}</span>
|
||||
<span class="ms-1.5">
|
||||
({$t('albums_count', { values: { count: albumGroup.albums.length } })})
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
{#if !isCollapsed}
|
||||
<tbody
|
||||
class="block w-full overflow-y-auto rounded-md border dark:border-immich-dark-gray dark:text-immich-dark-fg mt-4"
|
||||
transition:slide={{ duration: 300 }}
|
||||
>
|
||||
{#each albumGroup.albums as album (album.id)}
|
||||
<AlbumTableRow {album} {onShowContextMenu} />
|
||||
{/each}
|
||||
</tbody>
|
||||
{/if}
|
||||
{/each}
|
||||
{/if}
|
||||
</table>
|
||||
@@ -1,28 +0,0 @@
|
||||
import type { AssetAction } from '$lib/constants';
|
||||
import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
|
||||
import type { AlbumResponseDto, AssetResponseDto, PersonResponseDto, StackResponseDto } from '@immich/sdk';
|
||||
|
||||
type ActionMap = {
|
||||
[AssetAction.ARCHIVE]: { asset: TimelineAsset };
|
||||
[AssetAction.UNARCHIVE]: { asset: TimelineAsset };
|
||||
[AssetAction.TRASH]: { asset: TimelineAsset };
|
||||
[AssetAction.DELETE]: { asset: TimelineAsset };
|
||||
[AssetAction.RESTORE]: { asset: TimelineAsset };
|
||||
[AssetAction.ADD]: { asset: TimelineAsset };
|
||||
[AssetAction.ADD_TO_ALBUM]: { asset: TimelineAsset; album: AlbumResponseDto };
|
||||
[AssetAction.STACK]: { stack: StackResponseDto };
|
||||
[AssetAction.UNSTACK]: { assets: TimelineAsset[] };
|
||||
[AssetAction.KEEP_THIS_DELETE_OTHERS]: { asset: TimelineAsset };
|
||||
[AssetAction.SET_STACK_PRIMARY_ASSET]: { stack: StackResponseDto };
|
||||
[AssetAction.REMOVE_ASSET_FROM_STACK]: { stack: StackResponseDto | null; asset: AssetResponseDto };
|
||||
[AssetAction.SET_VISIBILITY_LOCKED]: { asset: TimelineAsset };
|
||||
[AssetAction.SET_VISIBILITY_TIMELINE]: { asset: TimelineAsset };
|
||||
[AssetAction.SET_PERSON_FEATURED_PHOTO]: { asset: AssetResponseDto; person: PersonResponseDto };
|
||||
[AssetAction.RATING]: { asset: TimelineAsset; rating: number | null };
|
||||
};
|
||||
|
||||
export type Action = {
|
||||
[K in AssetAction]: { type: K } & ActionMap[K];
|
||||
}[AssetAction];
|
||||
export type OnAction = (action: Action) => void;
|
||||
export type PreAction = (action: Action) => void;
|
||||
@@ -1,49 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { shortcut } from '$lib/actions/shortcut';
|
||||
import type { OnAction } from '$lib/components/asset-viewer/actions/action';
|
||||
import MenuOption from '$lib/components/shared-components/context-menu/menu-option.svelte';
|
||||
import { AssetAction } from '$lib/constants';
|
||||
import AlbumPickerModal from '$lib/modals/AlbumPickerModal.svelte';
|
||||
import { addAssetsToAlbum, addAssetsToAlbums } from '$lib/utils/asset-utils';
|
||||
import { toTimelineAsset } from '$lib/utils/timeline-util';
|
||||
import type { AssetResponseDto } from '@immich/sdk';
|
||||
import { modalManager } from '@immich/ui';
|
||||
import { mdiImageAlbum, mdiShareVariantOutline } from '@mdi/js';
|
||||
import { t } from 'svelte-i18n';
|
||||
|
||||
interface Props {
|
||||
asset: AssetResponseDto;
|
||||
onAction: OnAction;
|
||||
shared?: boolean;
|
||||
}
|
||||
|
||||
let { asset, onAction, shared = false }: Props = $props();
|
||||
|
||||
const onClick = async () => {
|
||||
const albums = await modalManager.show(AlbumPickerModal, { shared });
|
||||
|
||||
if (!albums || albums.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (albums.length === 1) {
|
||||
const album = albums[0];
|
||||
await addAssetsToAlbum(album.id, [asset.id]);
|
||||
onAction({ type: AssetAction.ADD_TO_ALBUM, asset: toTimelineAsset(asset), album });
|
||||
} else {
|
||||
await addAssetsToAlbums(
|
||||
albums.map((a) => a.id),
|
||||
[asset.id],
|
||||
);
|
||||
onAction({ type: AssetAction.ADD_TO_ALBUM, asset: toTimelineAsset(asset), album: albums[0] });
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<svelte:document use:shortcut={{ shortcut: { key: 'l', shift: shared }, onShortcut: onClick }} />
|
||||
|
||||
<MenuOption
|
||||
icon={shared ? mdiShareVariantOutline : mdiImageAlbum}
|
||||
text={shared ? $t('add_to_shared_album') : $t('add_to_album')}
|
||||
{onClick}
|
||||
/>
|
||||
@@ -1,37 +0,0 @@
|
||||
<script lang="ts">
|
||||
import MenuOption from '$lib/components/shared-components/context-menu/menu-option.svelte';
|
||||
import { AssetAction } from '$lib/constants';
|
||||
import { openFileUploadDialog } from '$lib/utils/file-uploader';
|
||||
import { createStack, type AssetResponseDto, type StackResponseDto } from '@immich/sdk';
|
||||
import { mdiUploadMultiple } from '@mdi/js';
|
||||
import { t } from 'svelte-i18n';
|
||||
import type { OnAction } from './action';
|
||||
|
||||
interface Props {
|
||||
asset: AssetResponseDto;
|
||||
stack: StackResponseDto | null;
|
||||
onAction: OnAction;
|
||||
}
|
||||
|
||||
let { asset, stack, onAction }: Props = $props();
|
||||
|
||||
const handleAddUploadToStack = async () => {
|
||||
const newAssetIds = await openFileUploadDialog({ multiple: true });
|
||||
// Including the old stacks primary asset ID ensures that all assets of the
|
||||
// old stack are automatically included in the new stack.
|
||||
const primaryAssetId = stack?.primaryAssetId ?? asset.id;
|
||||
|
||||
// First asset in the list will become the new primary asset.
|
||||
const assetIds = [primaryAssetId, ...newAssetIds];
|
||||
|
||||
const newStack = await createStack({
|
||||
stackCreateDto: {
|
||||
assetIds,
|
||||
},
|
||||
});
|
||||
|
||||
onAction({ type: AssetAction.STACK, stack: newStack });
|
||||
};
|
||||
</script>
|
||||
|
||||
<MenuOption icon={mdiUploadMultiple} onClick={handleAddUploadToStack} text={$t('add_upload_to_stack')} />
|
||||
@@ -1,37 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { shortcut } from '$lib/actions/shortcut';
|
||||
import type { OnAction, PreAction } from '$lib/components/asset-viewer/actions/action';
|
||||
import MenuOption from '$lib/components/shared-components/context-menu/menu-option.svelte';
|
||||
import { AssetAction } from '$lib/constants';
|
||||
import { toggleArchive } from '$lib/utils/asset-utils';
|
||||
import { toTimelineAsset } from '$lib/utils/timeline-util';
|
||||
import type { AssetResponseDto } from '@immich/sdk';
|
||||
import { mdiArchiveArrowDownOutline, mdiArchiveArrowUpOutline } from '@mdi/js';
|
||||
import { t } from 'svelte-i18n';
|
||||
|
||||
interface Props {
|
||||
asset: AssetResponseDto;
|
||||
onAction: OnAction;
|
||||
preAction: PreAction;
|
||||
}
|
||||
|
||||
let { asset, onAction, preAction }: Props = $props();
|
||||
|
||||
const onArchive = async () => {
|
||||
if (!asset.isArchived) {
|
||||
preAction({ type: AssetAction.ARCHIVE, asset: toTimelineAsset(asset) });
|
||||
}
|
||||
const updatedAsset = await toggleArchive(asset);
|
||||
if (updatedAsset) {
|
||||
onAction({ type: asset.isArchived ? AssetAction.ARCHIVE : AssetAction.UNARCHIVE, asset: toTimelineAsset(asset) });
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<svelte:document use:shortcut={{ shortcut: { key: 'a', shift: true }, onShortcut: onArchive }} />
|
||||
|
||||
<MenuOption
|
||||
icon={asset.isArchived ? mdiArchiveArrowUpOutline : mdiArchiveArrowDownOutline}
|
||||
text={asset.isArchived ? $t('unarchive') : $t('to_archive')}
|
||||
onClick={onArchive}
|
||||
/>
|
||||
@@ -1,48 +0,0 @@
|
||||
import { renderWithTooltips } from '$tests/helpers';
|
||||
import type { AssetResponseDto } from '@immich/sdk';
|
||||
import { assetFactory } from '@test-data/factories/asset-factory';
|
||||
import '@testing-library/jest-dom';
|
||||
import DeleteAction from './delete-action.svelte';
|
||||
|
||||
let asset: AssetResponseDto;
|
||||
|
||||
describe('DeleteAction component', () => {
|
||||
beforeEach(() => {
|
||||
vi.mock(import('$lib/managers/feature-flags-manager.svelte'), () => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
return { featureFlagsManager: { init: vi.fn(), loadFeatureFlags: vi.fn(), value: { trash: true } } as any };
|
||||
});
|
||||
});
|
||||
|
||||
describe('given an asset which is not trashed yet', () => {
|
||||
beforeEach(() => {
|
||||
asset = assetFactory.build({ isTrashed: false });
|
||||
});
|
||||
|
||||
it('displays a button to move the asset to the trash bin', () => {
|
||||
const { getByLabelText, queryByTitle } = renderWithTooltips(DeleteAction, {
|
||||
asset,
|
||||
onAction: vi.fn(),
|
||||
preAction: vi.fn(),
|
||||
});
|
||||
expect(getByLabelText('delete')).toBeInTheDocument();
|
||||
expect(queryByTitle('deletePermanently')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('but if the asset is already trashed', () => {
|
||||
beforeEach(() => {
|
||||
asset = assetFactory.build({ isTrashed: true });
|
||||
});
|
||||
|
||||
it('displays a button to permanently delete the asset', () => {
|
||||
const { getByLabelText, queryByTitle } = renderWithTooltips(DeleteAction, {
|
||||
asset,
|
||||
onAction: vi.fn(),
|
||||
preAction: vi.fn(),
|
||||
});
|
||||
expect(getByLabelText('permanently_delete')).toBeInTheDocument();
|
||||
expect(queryByTitle('delete')).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,75 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { shortcuts } from '$lib/actions/shortcut';
|
||||
import { AssetAction } from '$lib/constants';
|
||||
import { featureFlagsManager } from '$lib/managers/feature-flags-manager.svelte';
|
||||
import AssetDeleteConfirmModal from '$lib/modals/AssetDeleteConfirmModal.svelte';
|
||||
import { showDeleteModal } from '$lib/stores/preferences.store';
|
||||
import { deleteAssets as deleteAssetsUtil, type OnUndoDelete } from '$lib/utils/actions';
|
||||
import { handleError } from '$lib/utils/handle-error';
|
||||
import { toTimelineAsset } from '$lib/utils/timeline-util';
|
||||
import { deleteAssets, type AssetResponseDto } from '@immich/sdk';
|
||||
import { IconButton, modalManager, toastManager } from '@immich/ui';
|
||||
import { mdiDeleteForeverOutline, mdiDeleteOutline } from '@mdi/js';
|
||||
import { t } from 'svelte-i18n';
|
||||
import type { OnAction, PreAction } from './action';
|
||||
|
||||
interface Props {
|
||||
asset: AssetResponseDto;
|
||||
onAction: OnAction;
|
||||
preAction: PreAction;
|
||||
onUndoDelete?: OnUndoDelete;
|
||||
}
|
||||
|
||||
let { asset, onAction, preAction, onUndoDelete = undefined }: Props = $props();
|
||||
|
||||
const forceDefault = $derived(asset.isTrashed || !featureFlagsManager.value.trash);
|
||||
|
||||
const trashOrDelete = async (forceRequest?: boolean) => {
|
||||
const timelineAsset = toTimelineAsset(asset);
|
||||
const force = forceDefault || forceRequest;
|
||||
|
||||
if (force) {
|
||||
if ($showDeleteModal) {
|
||||
const confirmed = await modalManager.show(AssetDeleteConfirmModal, { size: 1 });
|
||||
if (!confirmed) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
preAction({ type: AssetAction.DELETE, asset: timelineAsset });
|
||||
await deleteAssets({ assetBulkDeleteDto: { ids: [asset.id], force: true } });
|
||||
onAction({ type: AssetAction.DELETE, asset: timelineAsset });
|
||||
toastManager.success($t('permanently_deleted_asset'));
|
||||
} catch (error) {
|
||||
handleError(error, $t('errors.unable_to_delete_asset'));
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
preAction({ type: AssetAction.TRASH, asset: timelineAsset });
|
||||
await deleteAssetsUtil(
|
||||
false,
|
||||
() => onAction({ type: AssetAction.TRASH, asset: timelineAsset }),
|
||||
[timelineAsset],
|
||||
onUndoDelete,
|
||||
);
|
||||
};
|
||||
</script>
|
||||
|
||||
<svelte:document
|
||||
use:shortcuts={[
|
||||
{ shortcut: { key: 'Delete' }, onShortcut: () => trashOrDelete() },
|
||||
{ shortcut: { key: 'Delete', shift: true }, onShortcut: () => trashOrDelete(true) },
|
||||
]}
|
||||
/>
|
||||
|
||||
<IconButton
|
||||
color="secondary"
|
||||
shape="round"
|
||||
variant="ghost"
|
||||
icon={forceDefault ? mdiDeleteForeverOutline : mdiDeleteOutline}
|
||||
aria-label={forceDefault ? $t('permanently_delete') : $t('delete')}
|
||||
onclick={() => trashOrDelete()}
|
||||
/>
|
||||
@@ -1,38 +0,0 @@
|
||||
<script lang="ts">
|
||||
import MenuOption from '$lib/components/shared-components/context-menu/menu-option.svelte';
|
||||
import { AssetAction } from '$lib/constants';
|
||||
import { keepThisDeleteOthers } from '$lib/utils/asset-utils';
|
||||
import { toTimelineAsset } from '$lib/utils/timeline-util';
|
||||
import type { AssetResponseDto, StackResponseDto } from '@immich/sdk';
|
||||
import { modalManager } from '@immich/ui';
|
||||
import { mdiPinOutline } from '@mdi/js';
|
||||
import { t } from 'svelte-i18n';
|
||||
import type { OnAction } from './action';
|
||||
|
||||
interface Props {
|
||||
stack: StackResponseDto;
|
||||
asset: AssetResponseDto;
|
||||
onAction: OnAction;
|
||||
}
|
||||
|
||||
let { stack, asset, onAction }: Props = $props();
|
||||
|
||||
const handleKeepThisDeleteOthers = async () => {
|
||||
const isConfirmed = await modalManager.showDialog({
|
||||
title: $t('keep_this_delete_others'),
|
||||
prompt: $t('confirm_keep_this_delete_others'),
|
||||
confirmText: $t('delete_others'),
|
||||
});
|
||||
|
||||
if (!isConfirmed) {
|
||||
return;
|
||||
}
|
||||
|
||||
const keptAsset = await keepThisDeleteOthers(asset, stack);
|
||||
if (keptAsset) {
|
||||
onAction({ type: AssetAction.UNSTACK, assets: [toTimelineAsset(keptAsset)] });
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<MenuOption icon={mdiPinOutline} onClick={handleKeepThisDeleteOthers} text={$t('keep_this_delete_others')} />
|
||||
@@ -1,24 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { shortcuts } from '$lib/actions/shortcut';
|
||||
import { Icon } from '@immich/ui';
|
||||
import { mdiChevronRight } from '@mdi/js';
|
||||
import { t } from 'svelte-i18n';
|
||||
import NavigationArea from '../navigation-area.svelte';
|
||||
|
||||
interface Props {
|
||||
onNextAsset: () => void;
|
||||
}
|
||||
|
||||
let { onNextAsset }: Props = $props();
|
||||
</script>
|
||||
|
||||
<svelte:document
|
||||
use:shortcuts={[
|
||||
{ shortcut: { key: 'ArrowRight' }, onShortcut: onNextAsset },
|
||||
{ shortcut: { key: 'd' }, onShortcut: onNextAsset },
|
||||
]}
|
||||
/>
|
||||
|
||||
<NavigationArea onClick={onNextAsset} label={$t('view_next_asset')}>
|
||||
<Icon icon={mdiChevronRight} size="36" aria-hidden />
|
||||
</NavigationArea>
|
||||
@@ -1,24 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { shortcuts } from '$lib/actions/shortcut';
|
||||
import { Icon } from '@immich/ui';
|
||||
import { mdiChevronLeft } from '@mdi/js';
|
||||
import { t } from 'svelte-i18n';
|
||||
import NavigationArea from '../navigation-area.svelte';
|
||||
|
||||
interface Props {
|
||||
onPreviousAsset: () => void;
|
||||
}
|
||||
|
||||
let { onPreviousAsset }: Props = $props();
|
||||
</script>
|
||||
|
||||
<svelte:document
|
||||
use:shortcuts={[
|
||||
{ shortcut: { key: 'ArrowLeft' }, onShortcut: onPreviousAsset },
|
||||
{ shortcut: { key: 'a' }, onShortcut: onPreviousAsset },
|
||||
]}
|
||||
/>
|
||||
|
||||
<NavigationArea onClick={onPreviousAsset} label={$t('view_previous_asset')}>
|
||||
<Icon icon={mdiChevronLeft} size="36" aria-hidden />
|
||||
</NavigationArea>
|
||||
@@ -1,55 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { shortcuts } from '$lib/actions/shortcut';
|
||||
import type { OnAction } from '$lib/components/asset-viewer/actions/action';
|
||||
import { AssetAction } from '$lib/constants';
|
||||
import { preferences } from '$lib/stores/user.store';
|
||||
import { handleError } from '$lib/utils/handle-error';
|
||||
import { toTimelineAsset } from '$lib/utils/timeline-util';
|
||||
import { updateAsset, type AssetResponseDto } from '@immich/sdk';
|
||||
import { t } from 'svelte-i18n';
|
||||
|
||||
type Props = {
|
||||
asset: AssetResponseDto;
|
||||
onAction: OnAction;
|
||||
};
|
||||
|
||||
let { asset, onAction }: Props = $props();
|
||||
|
||||
const rateAsset = async (rating: number | null) => {
|
||||
try {
|
||||
const updateAssetDto = rating === null ? {} : { rating };
|
||||
await updateAsset({
|
||||
id: asset.id,
|
||||
updateAssetDto,
|
||||
});
|
||||
|
||||
asset = {
|
||||
...asset,
|
||||
exifInfo: {
|
||||
...asset.exifInfo,
|
||||
rating,
|
||||
},
|
||||
};
|
||||
|
||||
onAction({
|
||||
type: AssetAction.RATING,
|
||||
asset: toTimelineAsset(asset),
|
||||
rating,
|
||||
});
|
||||
} catch (error) {
|
||||
handleError(error, $t('errors.unable_to_set_rating'));
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<svelte:document
|
||||
use:shortcuts={$preferences?.ratings.enabled
|
||||
? [
|
||||
{ shortcut: { key: '0' }, onShortcut: () => rateAsset(null) },
|
||||
...[1, 2, 3, 4, 5].map((rating) => ({
|
||||
shortcut: { key: String(rating) },
|
||||
onShortcut: () => rateAsset(rating),
|
||||
})),
|
||||
]
|
||||
: []}
|
||||
/>
|
||||
@@ -1,31 +0,0 @@
|
||||
<script lang="ts">
|
||||
import MenuOption from '$lib/components/shared-components/context-menu/menu-option.svelte';
|
||||
|
||||
import { AssetAction } from '$lib/constants';
|
||||
import { removeAssetFromStack, type AssetResponseDto, type StackResponseDto } from '@immich/sdk';
|
||||
import { mdiImageMinusOutline } from '@mdi/js';
|
||||
import { t } from 'svelte-i18n';
|
||||
import type { OnAction } from './action';
|
||||
|
||||
interface Props {
|
||||
asset: AssetResponseDto;
|
||||
stack: StackResponseDto;
|
||||
onAction: OnAction;
|
||||
}
|
||||
|
||||
let { asset, stack, onAction }: Props = $props();
|
||||
|
||||
const handleRemoveFromStack = async () => {
|
||||
await removeAssetFromStack({
|
||||
id: stack.id,
|
||||
assetId: asset.id,
|
||||
});
|
||||
const updatedStack = {
|
||||
...stack,
|
||||
assets: stack.assets.filter((a) => a.id !== asset.id),
|
||||
};
|
||||
onAction({ type: AssetAction.REMOVE_ASSET_FROM_STACK, stack: updatedStack, asset });
|
||||
};
|
||||
</script>
|
||||
|
||||
<MenuOption icon={mdiImageMinusOutline} onClick={handleRemoveFromStack} text={$t('viewer_remove_from_stack')} />
|
||||
@@ -1,31 +0,0 @@
|
||||
<script lang="ts">
|
||||
import MenuOption from '$lib/components/shared-components/context-menu/menu-option.svelte';
|
||||
import { AssetAction } from '$lib/constants';
|
||||
import { handleError } from '$lib/utils/handle-error';
|
||||
import { toTimelineAsset } from '$lib/utils/timeline-util';
|
||||
import { restoreAssets, type AssetResponseDto } from '@immich/sdk';
|
||||
import { toastManager } from '@immich/ui';
|
||||
import { mdiHistory } from '@mdi/js';
|
||||
import { t } from 'svelte-i18n';
|
||||
import type { OnAction } from './action';
|
||||
|
||||
interface Props {
|
||||
asset: AssetResponseDto;
|
||||
onAction: OnAction;
|
||||
}
|
||||
|
||||
let { asset = $bindable(), onAction }: Props = $props();
|
||||
|
||||
const handleRestoreAsset = async () => {
|
||||
try {
|
||||
await restoreAssets({ bulkIdsDto: { ids: [asset.id] } });
|
||||
asset.isTrashed = false;
|
||||
onAction({ type: AssetAction.RESTORE, asset: toTimelineAsset(asset) });
|
||||
toastManager.success($t('restored_asset'));
|
||||
} catch (error) {
|
||||
handleError(error, $t('errors.unable_to_restore_assets'));
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<MenuOption icon={mdiHistory} onClick={handleRestoreAsset} text={$t('restore')} />
|
||||
@@ -1,31 +0,0 @@
|
||||
<script lang="ts">
|
||||
import MenuOption from '$lib/components/shared-components/context-menu/menu-option.svelte';
|
||||
import { handleError } from '$lib/utils/handle-error';
|
||||
import { updateAlbumInfo, type AlbumResponseDto, type AssetResponseDto } from '@immich/sdk';
|
||||
import { toastManager } from '@immich/ui';
|
||||
import { mdiImageOutline } from '@mdi/js';
|
||||
import { t } from 'svelte-i18n';
|
||||
|
||||
interface Props {
|
||||
asset: AssetResponseDto;
|
||||
album: AlbumResponseDto;
|
||||
}
|
||||
|
||||
let { asset, album }: Props = $props();
|
||||
|
||||
const handleUpdateThumbnail = async () => {
|
||||
try {
|
||||
await updateAlbumInfo({
|
||||
id: album.id,
|
||||
updateAlbumDto: {
|
||||
albumThumbnailAssetId: asset.id,
|
||||
},
|
||||
});
|
||||
toastManager.success($t('album_cover_updated'));
|
||||
} catch (error) {
|
||||
handleError(error, $t('errors.unable_to_update_album_cover'));
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<MenuOption text={$t('set_as_album_cover')} icon={mdiImageOutline} onClick={handleUpdateThumbnail} />
|
||||
@@ -1,41 +0,0 @@
|
||||
<script lang="ts">
|
||||
import MenuOption from '$lib/components/shared-components/context-menu/menu-option.svelte';
|
||||
import { AssetAction } from '$lib/constants';
|
||||
import { handleError } from '$lib/utils/handle-error';
|
||||
import { updatePerson, type AssetResponseDto, type PersonResponseDto } from '@immich/sdk';
|
||||
import { toastManager } from '@immich/ui';
|
||||
import { mdiFaceManProfile } from '@mdi/js';
|
||||
import { t } from 'svelte-i18n';
|
||||
import type { OnAction } from './action';
|
||||
|
||||
interface Props {
|
||||
asset: AssetResponseDto;
|
||||
person: PersonResponseDto;
|
||||
onAction?: OnAction;
|
||||
}
|
||||
|
||||
let { asset, person, onAction }: Props = $props();
|
||||
|
||||
const handleSelectFeaturePhoto = async () => {
|
||||
try {
|
||||
const updatedPerson = await updatePerson({
|
||||
id: person.id,
|
||||
personUpdateDto: { featureFaceAssetId: asset.id },
|
||||
});
|
||||
|
||||
person = { ...person, ...updatedPerson };
|
||||
|
||||
onAction?.({
|
||||
type: AssetAction.SET_PERSON_FEATURED_PHOTO,
|
||||
asset,
|
||||
person,
|
||||
});
|
||||
|
||||
toastManager.success($t('feature_photo_updated'));
|
||||
} catch (error) {
|
||||
handleError(error, $t('errors.unable_to_set_feature_photo'));
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<MenuOption text={$t('set_as_featured_photo')} icon={mdiFaceManProfile} onClick={handleSelectFeaturePhoto} />
|
||||
@@ -1,20 +0,0 @@
|
||||
<script lang="ts">
|
||||
import MenuOption from '$lib/components/shared-components/context-menu/menu-option.svelte';
|
||||
import ProfileImageCropperModal from '$lib/modals/ProfileImageCropperModal.svelte';
|
||||
import type { AssetResponseDto } from '@immich/sdk';
|
||||
import { modalManager } from '@immich/ui';
|
||||
import { mdiAccountCircleOutline } from '@mdi/js';
|
||||
import { t } from 'svelte-i18n';
|
||||
|
||||
interface Props {
|
||||
asset: AssetResponseDto;
|
||||
}
|
||||
|
||||
let { asset }: Props = $props();
|
||||
</script>
|
||||
|
||||
<MenuOption
|
||||
icon={mdiAccountCircleOutline}
|
||||
onClick={() => modalManager.show(ProfileImageCropperModal, { asset })}
|
||||
text={$t('set_as_profile_picture')}
|
||||
/>
|
||||
@@ -1,26 +0,0 @@
|
||||
<script lang="ts">
|
||||
import MenuOption from '$lib/components/shared-components/context-menu/menu-option.svelte';
|
||||
|
||||
import { AssetAction } from '$lib/constants';
|
||||
import { updateStack, type AssetResponseDto, type StackResponseDto } from '@immich/sdk';
|
||||
import { mdiImageCheckOutline } from '@mdi/js';
|
||||
import { t } from 'svelte-i18n';
|
||||
import type { OnAction } from './action';
|
||||
|
||||
interface Props {
|
||||
stack: StackResponseDto;
|
||||
asset: AssetResponseDto;
|
||||
onAction: OnAction;
|
||||
}
|
||||
|
||||
let { stack, asset, onAction }: Props = $props();
|
||||
|
||||
const handleSetPrimaryAsset = async () => {
|
||||
const updatedStack = await updateStack({ id: stack.id, stackUpdateDto: { primaryAssetId: asset.id } });
|
||||
if (updatedStack) {
|
||||
onAction({ type: AssetAction.SET_STACK_PRIMARY_ASSET, stack: updatedStack });
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<MenuOption icon={mdiImageCheckOutline} onClick={handleSetPrimaryAsset} text={$t('set_stack_primary_asset')} />
|
||||
@@ -1,61 +0,0 @@
|
||||
<script lang="ts">
|
||||
import MenuOption from '$lib/components/shared-components/context-menu/menu-option.svelte';
|
||||
import { AssetAction } from '$lib/constants';
|
||||
import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
|
||||
import { handleError } from '$lib/utils/handle-error';
|
||||
import { AssetVisibility, updateAssets } from '@immich/sdk';
|
||||
import { modalManager } from '@immich/ui';
|
||||
import { mdiLockOpenVariantOutline, mdiLockOutline } from '@mdi/js';
|
||||
import { t } from 'svelte-i18n';
|
||||
import type { OnAction, PreAction } from './action';
|
||||
|
||||
interface Props {
|
||||
asset: TimelineAsset;
|
||||
onAction: OnAction;
|
||||
preAction: PreAction;
|
||||
}
|
||||
|
||||
let { asset, onAction, preAction }: Props = $props();
|
||||
const isLocked = asset.visibility === AssetVisibility.Locked;
|
||||
|
||||
const toggleLockedVisibility = async () => {
|
||||
const isConfirmed = await modalManager.showDialog({
|
||||
title: isLocked ? $t('remove_from_locked_folder') : $t('move_to_locked_folder'),
|
||||
prompt: isLocked ? $t('remove_from_locked_folder_confirmation') : $t('move_to_locked_folder_confirmation'),
|
||||
confirmText: $t('move'),
|
||||
confirmColor: isLocked ? 'danger' : 'primary',
|
||||
icon: isLocked ? mdiLockOpenVariantOutline : mdiLockOutline,
|
||||
});
|
||||
|
||||
if (!isConfirmed) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
preAction({
|
||||
type: isLocked ? AssetAction.SET_VISIBILITY_TIMELINE : AssetAction.SET_VISIBILITY_LOCKED,
|
||||
asset,
|
||||
});
|
||||
|
||||
await updateAssets({
|
||||
assetBulkUpdateDto: {
|
||||
ids: [asset.id],
|
||||
visibility: isLocked ? AssetVisibility.Timeline : AssetVisibility.Locked,
|
||||
},
|
||||
});
|
||||
|
||||
onAction({
|
||||
type: isLocked ? AssetAction.SET_VISIBILITY_TIMELINE : AssetAction.SET_VISIBILITY_LOCKED,
|
||||
asset,
|
||||
});
|
||||
} catch (error) {
|
||||
handleError(error, $t('errors.unable_to_save_settings'));
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<MenuOption
|
||||
onClick={() => toggleLockedVisibility()}
|
||||
text={isLocked ? $t('move_off_locked_folder') : $t('move_to_locked_folder')}
|
||||
icon={isLocked ? mdiLockOpenVariantOutline : mdiLockOutline}
|
||||
/>
|
||||
@@ -1,26 +0,0 @@
|
||||
<script lang="ts">
|
||||
import MenuOption from '$lib/components/shared-components/context-menu/menu-option.svelte';
|
||||
import { AssetAction } from '$lib/constants';
|
||||
import { deleteStack } from '$lib/utils/asset-utils';
|
||||
import { toTimelineAsset } from '$lib/utils/timeline-util';
|
||||
import type { StackResponseDto } from '@immich/sdk';
|
||||
import { mdiImageOffOutline } from '@mdi/js';
|
||||
import { t } from 'svelte-i18n';
|
||||
import type { OnAction } from './action';
|
||||
|
||||
interface Props {
|
||||
stack: StackResponseDto;
|
||||
onAction: OnAction;
|
||||
}
|
||||
|
||||
let { stack, onAction }: Props = $props();
|
||||
|
||||
const handleUnstack = async () => {
|
||||
const unstackedAssets = await deleteStack([stack.id]);
|
||||
if (unstackedAssets) {
|
||||
onAction({ type: AssetAction.UNSTACK, assets: unstackedAssets.map((asset) => toTimelineAsset(asset)) });
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<MenuOption icon={mdiImageOffOutline} onClick={handleUnstack} text={$t('unstack')} />
|
||||
@@ -1,36 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte';
|
||||
import { locale } from '$lib/stores/preferences.store';
|
||||
import type { ActivityResponseDto } from '@immich/sdk';
|
||||
import { Icon } from '@immich/ui';
|
||||
import { mdiCommentOutline, mdiThumbUp, mdiThumbUpOutline } from '@mdi/js';
|
||||
|
||||
interface Props {
|
||||
isLiked: ActivityResponseDto | null;
|
||||
numberOfComments: number | undefined;
|
||||
numberOfLikes: number | undefined;
|
||||
disabled: boolean;
|
||||
onFavorite: () => void;
|
||||
}
|
||||
|
||||
let { isLiked, numberOfComments, numberOfLikes, disabled, onFavorite }: Props = $props();
|
||||
</script>
|
||||
|
||||
<div class="w-full flex p-4 items-center justify-center rounded-full gap-5 bg-subtle border bg-opacity-60">
|
||||
<button type="button" class={disabled ? 'cursor-not-allowed' : ''} onclick={onFavorite} {disabled}>
|
||||
<div class="flex gap-2 items-center justify-center">
|
||||
<Icon icon={isLiked ? mdiThumbUp : mdiThumbUpOutline} size="24" class={isLiked ? 'text-primary' : 'text-fg'} />
|
||||
{#if numberOfLikes}
|
||||
<div class="text-l">{numberOfLikes.toLocaleString($locale)}</div>
|
||||
{/if}
|
||||
</div>
|
||||
</button>
|
||||
<button type="button" onclick={() => assetViewerManager.toggleActivityPanel()}>
|
||||
<div class="flex gap-2 items-center justify-center">
|
||||
<Icon icon={mdiCommentOutline} class="scale-x-[-1]" size="24" />
|
||||
{#if numberOfComments}
|
||||
<div class="text-l">{numberOfComments.toLocaleString($locale)}</div>
|
||||
{/if}
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
@@ -1,291 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { shortcut } from '$lib/actions/shortcut';
|
||||
import ButtonContextMenu from '$lib/components/shared-components/context-menu/button-context-menu.svelte';
|
||||
import MenuOption from '$lib/components/shared-components/context-menu/menu-option.svelte';
|
||||
import { timeBeforeShowLoadingSpinner } from '$lib/constants';
|
||||
import { activityManager } from '$lib/managers/activity-manager.svelte';
|
||||
import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte';
|
||||
import { Route } from '$lib/route';
|
||||
import { locale } from '$lib/stores/preferences.store';
|
||||
import { getAssetMediaUrl } from '$lib/utils';
|
||||
import { getAssetType } from '$lib/utils/asset-utils';
|
||||
import { handleError } from '$lib/utils/handle-error';
|
||||
import { isTenMinutesApart } from '$lib/utils/timesince';
|
||||
import { ReactionType, type ActivityResponseDto, type AssetTypeEnum, type UserResponseDto } from '@immich/sdk';
|
||||
import { Icon, IconButton, LoadingSpinner, Textarea, toastManager } from '@immich/ui';
|
||||
import { mdiClose, mdiDeleteOutline, mdiDotsVertical, mdiSend, mdiThumbUp } from '@mdi/js';
|
||||
import * as luxon from 'luxon';
|
||||
import { t } from 'svelte-i18n';
|
||||
import { fromAction } from 'svelte/attachments';
|
||||
import UserAvatar from '../shared-components/user-avatar.svelte';
|
||||
|
||||
const units: Intl.RelativeTimeFormatUnit[] = ['year', 'month', 'week', 'day', 'hour', 'minute', 'second'];
|
||||
|
||||
const shouldGroup = (currentDate: string, nextDate: string): boolean => {
|
||||
const currentDateTime = luxon.DateTime.fromISO(currentDate, { locale: $locale });
|
||||
const nextDateTime = luxon.DateTime.fromISO(nextDate, { locale: $locale });
|
||||
|
||||
return currentDateTime.hasSame(nextDateTime, 'hour') || currentDateTime.toRelative() === nextDateTime.toRelative();
|
||||
};
|
||||
|
||||
const timeSince = (dateTime: luxon.DateTime) => {
|
||||
const diff = dateTime.diffNow().shiftTo(...units);
|
||||
const unit = units.find((unit) => diff.get(unit) !== 0) || 'second';
|
||||
|
||||
const relativeFormatter = new Intl.RelativeTimeFormat($locale, {
|
||||
numeric: 'auto',
|
||||
});
|
||||
return relativeFormatter.format(Math.trunc(diff.as(unit)), unit);
|
||||
};
|
||||
|
||||
interface Props {
|
||||
user: UserResponseDto;
|
||||
assetId?: string | undefined;
|
||||
albumId: string;
|
||||
assetType?: AssetTypeEnum | undefined;
|
||||
albumOwnerId: string;
|
||||
disabled: boolean;
|
||||
}
|
||||
|
||||
let { user, assetId = undefined, albumId, assetType = undefined, albumOwnerId, disabled }: Props = $props();
|
||||
|
||||
let innerHeight: number = $state(0);
|
||||
let activityHeight: number = $state(0);
|
||||
let chatHeight: number = $state(0);
|
||||
let divHeight = $derived(innerHeight - activityHeight);
|
||||
let previousAssetId: string | undefined = $state(assetId);
|
||||
let message = $state('');
|
||||
let isSendingMessage = $state(false);
|
||||
|
||||
const timeOptions: Intl.DateTimeFormatOptions = {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
hour12: false,
|
||||
};
|
||||
|
||||
const handleDeleteReaction = async (reaction: ActivityResponseDto, index: number) => {
|
||||
try {
|
||||
await activityManager.deleteActivity(reaction, index);
|
||||
|
||||
const deleteMessages: Record<ReactionType, string> = {
|
||||
[ReactionType.Comment]: $t('comment_deleted'),
|
||||
[ReactionType.Like]: $t('like_deleted'),
|
||||
};
|
||||
toastManager.success(deleteMessages[reaction.type]);
|
||||
} catch (error) {
|
||||
handleError(error, $t('errors.unable_to_remove_reaction'));
|
||||
}
|
||||
};
|
||||
|
||||
const handleSendComment = async () => {
|
||||
if (!message) {
|
||||
return;
|
||||
}
|
||||
const timeout = setTimeout(() => (isSendingMessage = true), timeBeforeShowLoadingSpinner);
|
||||
try {
|
||||
await activityManager.addActivity({ albumId, assetId, type: ReactionType.Comment, comment: message });
|
||||
|
||||
message = '';
|
||||
} catch (error) {
|
||||
handleError(error, $t('errors.unable_to_add_comment'));
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
isSendingMessage = false;
|
||||
};
|
||||
|
||||
$effect(() => {
|
||||
if (assetId && previousAssetId != assetId) {
|
||||
previousAssetId = assetId;
|
||||
}
|
||||
});
|
||||
|
||||
const onsubmit = async (event: Event) => {
|
||||
event.preventDefault();
|
||||
await handleSendComment();
|
||||
};
|
||||
</script>
|
||||
|
||||
<div class="overflow-y-hidden relative h-full border-l border-subtle bg-subtle" bind:offsetHeight={innerHeight}>
|
||||
<div class="w-full h-full">
|
||||
<div class="flex w-full h-fit dark:text-immich-dark-fg p-2 bg-subtle" bind:clientHeight={activityHeight}>
|
||||
<div class="flex place-items-center gap-2">
|
||||
<IconButton
|
||||
shape="round"
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
onclick={() => assetViewerManager.closeActivityPanel()}
|
||||
icon={mdiClose}
|
||||
aria-label={$t('close')}
|
||||
/>
|
||||
|
||||
<p class="text-lg text-immich-fg dark:text-immich-dark-fg">{$t('activity')}</p>
|
||||
</div>
|
||||
</div>
|
||||
{#if innerHeight}
|
||||
<div
|
||||
class="overflow-y-auto immich-scrollbar relative w-full px-2"
|
||||
style="height: {divHeight}px;padding-bottom: {chatHeight}px"
|
||||
>
|
||||
{#each activityManager.activities as reaction, index (reaction.id)}
|
||||
{#if reaction.type === ReactionType.Comment}
|
||||
<div class="flex dark:bg-gray-800 bg-gray-200 py-3 ps-3 mt-3 rounded-lg gap-4 justify-start">
|
||||
<div class="flex items-center">
|
||||
<UserAvatar user={reaction.user} size="sm" />
|
||||
</div>
|
||||
|
||||
<div class="w-full leading-4 overflow-hidden self-center wrap-break-word text-sm">{reaction.comment}</div>
|
||||
{#if assetId === undefined && reaction.assetId}
|
||||
<a class="aspect-square w-19 h-19" href={Route.viewAlbumAsset({ albumId, assetId: reaction.assetId })}>
|
||||
<img
|
||||
class="rounded-lg w-19 h-19 object-cover"
|
||||
src={getAssetMediaUrl({ id: reaction.assetId })}
|
||||
alt="Profile picture of {reaction.user.name}, who commented on this asset"
|
||||
/>
|
||||
</a>
|
||||
{/if}
|
||||
{#if reaction.user.id === user.id || albumOwnerId === user.id}
|
||||
<div class="me-4">
|
||||
<ButtonContextMenu
|
||||
icon={mdiDotsVertical}
|
||||
title={$t('comment_options')}
|
||||
align="top-right"
|
||||
direction="left"
|
||||
size="small"
|
||||
>
|
||||
<MenuOption
|
||||
activeColor="bg-red-200"
|
||||
icon={mdiDeleteOutline}
|
||||
text={$t('remove')}
|
||||
onClick={() => handleDeleteReaction(reaction, index)}
|
||||
/>
|
||||
</ButtonContextMenu>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if (index != activityManager.activities.length - 1 && !shouldGroup(activityManager.activities[index].createdAt, activityManager.activities[index + 1].createdAt)) || index === activityManager.activities.length - 1}
|
||||
<div
|
||||
class="pt-1 px-2 text-right w-full text-sm text-gray-500 dark:text-gray-300"
|
||||
title={new Date(reaction.createdAt).toLocaleDateString(undefined, timeOptions)}
|
||||
>
|
||||
{timeSince(luxon.DateTime.fromISO(reaction.createdAt, { locale: $locale }))}
|
||||
</div>
|
||||
{/if}
|
||||
{:else if reaction.type === ReactionType.Like}
|
||||
<div class="relative">
|
||||
<div class="flex py-3 ps-3 mt-3 gap-4 items-center text-sm">
|
||||
<div class="text-primary"><Icon icon={mdiThumbUp} size="20" /></div>
|
||||
|
||||
<div class="w-full" title={`${reaction.user.name} (${reaction.user.email})`}>
|
||||
{$t('user_liked', {
|
||||
values: {
|
||||
user: reaction.user.name,
|
||||
type: assetType ? getAssetType(assetType).toLowerCase() : null,
|
||||
},
|
||||
})}
|
||||
</div>
|
||||
{#if assetId === undefined && reaction.assetId}
|
||||
<a
|
||||
class="aspect-square w-19 h-19"
|
||||
href={Route.viewAlbumAsset({ albumId, assetId: reaction.assetId })}
|
||||
>
|
||||
<img
|
||||
class="rounded-lg w-19 h-19 object-cover"
|
||||
src={getAssetMediaUrl({ id: reaction.assetId })}
|
||||
alt="Profile picture of {reaction.user.name}, who liked this asset"
|
||||
/>
|
||||
</a>
|
||||
{/if}
|
||||
{#if reaction.user.id === user.id || albumOwnerId === user.id}
|
||||
<div class="me-4">
|
||||
<ButtonContextMenu
|
||||
icon={mdiDotsVertical}
|
||||
title={$t('reaction_options')}
|
||||
align="top-right"
|
||||
direction="left"
|
||||
size="small"
|
||||
>
|
||||
<MenuOption
|
||||
activeColor="bg-red-200"
|
||||
icon={mdiDeleteOutline}
|
||||
text={$t('remove')}
|
||||
onClick={() => handleDeleteReaction(reaction, index)}
|
||||
/>
|
||||
</ButtonContextMenu>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{#if (index != activityManager.activities.length - 1 && isTenMinutesApart(activityManager.activities[index].createdAt, activityManager.activities[index + 1].createdAt)) || index === activityManager.activities.length - 1}
|
||||
<div
|
||||
class="pt-1 px-2 text-right w-full text-sm text-gray-500 dark:text-gray-300"
|
||||
title={new Date(reaction.createdAt).toLocaleDateString(navigator.language, timeOptions)}
|
||||
>
|
||||
{timeSince(luxon.DateTime.fromISO(reaction.createdAt, { locale: $locale }))}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="absolute w-full bottom-0">
|
||||
<div class="flex items-center justify-center p-2" bind:clientHeight={chatHeight}>
|
||||
<div class="flex p-2 gap-4 h-fit bg-gray-200 text-immich-dark-gray rounded-3xl w-full">
|
||||
<div>
|
||||
<UserAvatar {user} size="md" noTitle />
|
||||
</div>
|
||||
<form class="flex w-full items-center max-h-56 gap-1" {onsubmit}>
|
||||
<Textarea
|
||||
{disabled}
|
||||
bind:value={message}
|
||||
rows={1}
|
||||
grow
|
||||
placeholder={disabled ? $t('comments_are_disabled') : $t('say_something')}
|
||||
{@attach fromAction(shortcut, () => ({
|
||||
shortcut: { key: 'Enter' },
|
||||
onShortcut: () => handleSendComment(),
|
||||
}))}
|
||||
class="{disabled
|
||||
? 'cursor-not-allowed'
|
||||
: ''} ring-0! w-full max-h-56 pe-2 items-center overflow-y-auto leading-4 outline-none resize-none bg-gray-200 dark:bg-gray-200"
|
||||
/>
|
||||
{#if isSendingMessage}
|
||||
<div class="flex place-items-center pb-2 ms-0">
|
||||
<div class="flex w-full place-items-center">
|
||||
<LoadingSpinner size="large" />
|
||||
</div>
|
||||
</div>
|
||||
{:else if message}
|
||||
<div class="flex items-center w-fit ms-0 light">
|
||||
<IconButton
|
||||
shape="round"
|
||||
aria-label={$t('send_message')}
|
||||
variant="ghost"
|
||||
icon={mdiSend}
|
||||
onclick={() => handleSendComment()}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
::placeholder {
|
||||
color: rgb(60, 60, 60);
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
::-ms-input-placeholder {
|
||||
/* Edge 12 -18 */
|
||||
color: white;
|
||||
}
|
||||
</style>
|
||||
@@ -1,15 +0,0 @@
|
||||
<script lang="ts">
|
||||
import type { AlbumResponseDto } from '@immich/sdk';
|
||||
import { t } from 'svelte-i18n';
|
||||
|
||||
interface Props {
|
||||
album: AlbumResponseDto;
|
||||
}
|
||||
|
||||
let { album }: Props = $props();
|
||||
</script>
|
||||
|
||||
<span>{$t('items_count', { values: { count: album.assetCount } })}</span>
|
||||
{#if album.shared}
|
||||
<span>• {$t('shared')}</span>
|
||||
{/if}
|
||||
@@ -1,173 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { SCROLL_PROPERTIES } from '$lib/components/shared-components/album-selection/album-selection-utils';
|
||||
import { mediaQueryManager } from '$lib/stores/media-query-manager.svelte';
|
||||
import { getAssetMediaUrl } from '$lib/utils';
|
||||
import { normalizeSearchString } from '$lib/utils/string-utils.js';
|
||||
import { type AlbumResponseDto } from '@immich/sdk';
|
||||
import { Icon } from '@immich/ui';
|
||||
import { mdiCheckCircle } from '@mdi/js';
|
||||
import type { Action } from 'svelte/action';
|
||||
import AlbumListItemDetails from './album-list-item-details.svelte';
|
||||
|
||||
interface Props {
|
||||
album: AlbumResponseDto;
|
||||
searchQuery?: string;
|
||||
selected: boolean;
|
||||
multiSelected?: boolean;
|
||||
onAlbumClick: () => void;
|
||||
onMultiSelect: () => void;
|
||||
}
|
||||
|
||||
let {
|
||||
album,
|
||||
searchQuery = '',
|
||||
selected = false,
|
||||
multiSelected = false,
|
||||
onAlbumClick,
|
||||
onMultiSelect,
|
||||
}: Props = $props();
|
||||
|
||||
const scrollIntoViewIfSelected: Action = (node) => {
|
||||
$effect(() => {
|
||||
if (selected) {
|
||||
node.scrollIntoView(SCROLL_PROPERTIES);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
// 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
|
||||
const albumNameArray: string[] = $derived.by(() => {
|
||||
let { albumName } = album;
|
||||
let findIndex = normalizeSearchString(albumName).indexOf(normalizeSearchString(searchQuery));
|
||||
let findLength = searchQuery.length;
|
||||
return [
|
||||
albumName.slice(0, findIndex),
|
||||
albumName.slice(findIndex, findIndex + findLength),
|
||||
albumName.slice(findIndex + findLength),
|
||||
];
|
||||
});
|
||||
|
||||
const handleMultiSelectClicked = (e?: MouseEvent) => {
|
||||
e?.stopPropagation();
|
||||
e?.preventDefault();
|
||||
onMultiSelect();
|
||||
};
|
||||
|
||||
let usingMobileDevice = $derived(mediaQueryManager.pointerCoarse);
|
||||
let mouseOver = $state(false);
|
||||
const onMouseEnter = () => {
|
||||
if (usingMobileDevice) {
|
||||
return;
|
||||
}
|
||||
mouseOver = true;
|
||||
};
|
||||
|
||||
const onMouseLeave = () => {
|
||||
mouseOver = false;
|
||||
};
|
||||
|
||||
let timer: ReturnType<typeof setTimeout> | null = null;
|
||||
const preventContextMenu = (evt: Event) => evt.preventDefault();
|
||||
const disposeables: (() => void)[] = [];
|
||||
const clearLongPressTimer = () => {
|
||||
if (!timer) {
|
||||
return;
|
||||
}
|
||||
clearTimeout(timer);
|
||||
timer = null;
|
||||
for (const dispose of disposeables) {
|
||||
dispose();
|
||||
}
|
||||
disposeables.length = 0;
|
||||
};
|
||||
function longPress(element: HTMLElement, { onLongPress }: { onLongPress: () => void }) {
|
||||
let didPress = false;
|
||||
const start = () => {
|
||||
didPress = false;
|
||||
// 350ms for longpress. For reference: iOS uses 500ms for default long press, or 200ms for fast long press.
|
||||
timer = setTimeout(() => {
|
||||
onLongPress();
|
||||
element.addEventListener('contextmenu', preventContextMenu, { once: true });
|
||||
disposeables.push(() => element.removeEventListener('contextmenu', preventContextMenu));
|
||||
didPress = true;
|
||||
}, 350);
|
||||
};
|
||||
const click = (e: MouseEvent) => {
|
||||
if (!didPress) {
|
||||
return;
|
||||
}
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
};
|
||||
element.addEventListener('click', click);
|
||||
element.addEventListener('pointerdown', start, true);
|
||||
element.addEventListener('pointerup', clearLongPressTimer, { capture: true, passive: true });
|
||||
return {
|
||||
destroy: () => {
|
||||
element.removeEventListener('click', click);
|
||||
element.removeEventListener('pointerdown', start, true);
|
||||
element.removeEventListener('pointerup', clearLongPressTimer, true);
|
||||
},
|
||||
};
|
||||
}
|
||||
</script>
|
||||
|
||||
<div
|
||||
role="group"
|
||||
class={[
|
||||
'relative flex w-full text-start justify-between transition-colors hover:bg-gray-200 dark:hover:bg-gray-700 rounded-xl my-2 hover:cursor-pointer',
|
||||
{ 'bg-primary/10 hover:bg-primary/10': multiSelected },
|
||||
]}
|
||||
onmouseenter={onMouseEnter}
|
||||
onmouseleave={onMouseLeave}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onclick={onAlbumClick}
|
||||
use:scrollIntoViewIfSelected
|
||||
class="flex w-full gap-4 px-2 py-2 text-start"
|
||||
class:bg-gray-200={selected}
|
||||
class:dark:bg-gray-700={selected}
|
||||
use:longPress={{ onLongPress: () => handleMultiSelectClicked() }}
|
||||
>
|
||||
<span class="h-16 w-16 shrink-0 rounded-xl bg-slate-300">
|
||||
{#if album.albumThumbnailAssetId}
|
||||
<img
|
||||
src={getAssetMediaUrl({ id: album.albumThumbnailAssetId })}
|
||||
alt={album.albumName}
|
||||
class={['h-full w-full rounded-xl object-cover transition-all duration-300 hover:shadow-lg']}
|
||||
data-testid="album-image"
|
||||
draggable="false"
|
||||
/>
|
||||
{/if}
|
||||
</span>
|
||||
<span class="flex h-full flex-col items-start justify-center overflow-hidden">
|
||||
<span class="w-full shrink overflow-hidden text-ellipsis whitespace-nowrap"
|
||||
>{albumNameArray[0]}<b>{albumNameArray[1]}</b>{albumNameArray[2]}</span
|
||||
>
|
||||
<span class="flex gap-1 text-sm">
|
||||
<AlbumListItemDetails {album} />
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{#if mouseOver || multiSelected}
|
||||
<button
|
||||
type="button"
|
||||
onclick={handleMultiSelectClicked}
|
||||
class="absolute right-0 top-4 p-3 focus:outline-none hover:cursor-pointer"
|
||||
role="checkbox"
|
||||
tabindex={-1}
|
||||
aria-checked={selected}
|
||||
>
|
||||
{#if multiSelected}
|
||||
<div class="rounded-full">
|
||||
<Icon icon={mdiCheckCircle} size="24" class="text-primary" />
|
||||
</div>
|
||||
{:else}
|
||||
<Icon icon={mdiCheckCircle} size="24" class="text-gray-300 hover:text-primary/75" />
|
||||
{/if}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -1,68 +0,0 @@
|
||||
import { getResizeObserverMock } from '$lib/__mocks__/resize-observer.mock';
|
||||
import { preferences as preferencesStore, resetSavedUser, user as userStore } from '$lib/stores/user.store';
|
||||
import { renderWithTooltips } from '$tests/helpers';
|
||||
import { assetFactory } from '@test-data/factories/asset-factory';
|
||||
import { preferencesFactory } from '@test-data/factories/preferences-factory';
|
||||
import { userAdminFactory } from '@test-data/factories/user-factory';
|
||||
import '@testing-library/jest-dom';
|
||||
import AssetViewerNavBar from './asset-viewer-nav-bar.svelte';
|
||||
|
||||
describe('AssetViewerNavBar component', () => {
|
||||
const additionalProps = {
|
||||
preAction: () => {},
|
||||
onAction: () => {},
|
||||
onPlaySlideshow: () => {},
|
||||
onClose: () => {},
|
||||
playOriginalVideo: false,
|
||||
setPlayOriginalVideo: () => Promise.resolve(),
|
||||
};
|
||||
|
||||
beforeAll(() => {
|
||||
Element.prototype.animate = vi.fn().mockImplementation(() => ({
|
||||
cancel: () => {},
|
||||
}));
|
||||
vi.stubGlobal('ResizeObserver', getResizeObserverMock());
|
||||
vi.mock(import('$lib/managers/feature-flags-manager.svelte'), () => {
|
||||
return {
|
||||
featureFlagsManager: {
|
||||
init: vi.fn(),
|
||||
loadFeatureFlags: vi.fn(),
|
||||
value: { trash: true, smartSearch: true },
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
} as any,
|
||||
};
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
resetSavedUser();
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('shows back button', () => {
|
||||
const prefs = preferencesFactory.build({ cast: { gCastEnabled: false } });
|
||||
preferencesStore.set(prefs);
|
||||
|
||||
const asset = assetFactory.build({ isTrashed: false });
|
||||
const { getByLabelText } = renderWithTooltips(AssetViewerNavBar, { asset, ...additionalProps });
|
||||
expect(getByLabelText('go_back')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
describe('if the current user owns the asset', () => {
|
||||
it('shows delete button', () => {
|
||||
const ownerId = 'id-of-the-user';
|
||||
const user = userAdminFactory.build({ id: ownerId });
|
||||
const asset = assetFactory.build({ ownerId, isTrashed: false });
|
||||
userStore.set(user);
|
||||
|
||||
const prefs = preferencesFactory.build({ cast: { gCastEnabled: false } });
|
||||
preferencesStore.set(prefs);
|
||||
|
||||
const { getByLabelText } = renderWithTooltips(AssetViewerNavBar, { asset, ...additionalProps });
|
||||
expect(getByLabelText('delete')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,262 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import ActionButton from '$lib/components/ActionButton.svelte';
|
||||
import ActionMenuItem from '$lib/components/ActionMenuItem.svelte';
|
||||
import type { OnAction, PreAction } from '$lib/components/asset-viewer/actions/action';
|
||||
import AddToAlbumAction from '$lib/components/asset-viewer/actions/add-to-album-action.svelte';
|
||||
import AddToStackAction from '$lib/components/asset-viewer/actions/add-to-stack-action.svelte';
|
||||
import ArchiveAction from '$lib/components/asset-viewer/actions/archive-action.svelte';
|
||||
import DeleteAction from '$lib/components/asset-viewer/actions/delete-action.svelte';
|
||||
import KeepThisDeleteOthersAction from '$lib/components/asset-viewer/actions/keep-this-delete-others.svelte';
|
||||
import RatingAction from '$lib/components/asset-viewer/actions/rating-action.svelte';
|
||||
import RemoveAssetFromStack from '$lib/components/asset-viewer/actions/remove-asset-from-stack.svelte';
|
||||
import RestoreAction from '$lib/components/asset-viewer/actions/restore-action.svelte';
|
||||
import SetAlbumCoverAction from '$lib/components/asset-viewer/actions/set-album-cover-action.svelte';
|
||||
import SetFeaturedPhotoAction from '$lib/components/asset-viewer/actions/set-person-featured-action.svelte';
|
||||
import SetProfilePictureAction from '$lib/components/asset-viewer/actions/set-profile-picture-action.svelte';
|
||||
import SetStackPrimaryAsset from '$lib/components/asset-viewer/actions/set-stack-primary-asset.svelte';
|
||||
import SetVisibilityAction from '$lib/components/asset-viewer/actions/set-visibility-action.svelte';
|
||||
import UnstackAction from '$lib/components/asset-viewer/actions/unstack-action.svelte';
|
||||
import ButtonContextMenu from '$lib/components/shared-components/context-menu/button-context-menu.svelte';
|
||||
import MenuOption from '$lib/components/shared-components/context-menu/menu-option.svelte';
|
||||
import { featureFlagsManager } from '$lib/managers/feature-flags-manager.svelte';
|
||||
import { Route } from '$lib/route';
|
||||
import { getGlobalActions } from '$lib/services/app.service';
|
||||
import { getAssetActions, handleReplaceAsset } from '$lib/services/asset.service';
|
||||
import { user } from '$lib/stores/user.store';
|
||||
import { getSharedLink, withoutIcons } from '$lib/utils';
|
||||
import type { OnUndoDelete } from '$lib/utils/actions';
|
||||
import { toTimelineAsset } from '$lib/utils/timeline-util';
|
||||
import {
|
||||
AssetTypeEnum,
|
||||
AssetVisibility,
|
||||
type AlbumResponseDto,
|
||||
type AssetResponseDto,
|
||||
type PersonResponseDto,
|
||||
type StackResponseDto,
|
||||
} from '@immich/sdk';
|
||||
import { CommandPaletteDefaultProvider, type ActionItem } from '@immich/ui';
|
||||
import {
|
||||
mdiArrowLeft,
|
||||
mdiCompare,
|
||||
mdiDotsVertical,
|
||||
mdiImageSearch,
|
||||
mdiPresentationPlay,
|
||||
mdiUpload,
|
||||
mdiVideoOutline,
|
||||
} from '@mdi/js';
|
||||
import { t } from 'svelte-i18n';
|
||||
|
||||
interface Props {
|
||||
asset: AssetResponseDto;
|
||||
album?: AlbumResponseDto | null;
|
||||
person?: PersonResponseDto | null;
|
||||
stack?: StackResponseDto | null;
|
||||
showSlideshow?: boolean;
|
||||
preAction: PreAction;
|
||||
onAction: OnAction;
|
||||
onUndoDelete?: OnUndoDelete;
|
||||
onPlaySlideshow: () => void;
|
||||
onClose?: () => void;
|
||||
playOriginalVideo: boolean;
|
||||
setPlayOriginalVideo: (value: boolean) => void;
|
||||
}
|
||||
|
||||
let {
|
||||
asset,
|
||||
album = null,
|
||||
person = null,
|
||||
stack = null,
|
||||
showSlideshow = false,
|
||||
preAction,
|
||||
onAction,
|
||||
onUndoDelete = undefined,
|
||||
onPlaySlideshow,
|
||||
onClose,
|
||||
playOriginalVideo = false,
|
||||
setPlayOriginalVideo,
|
||||
}: Props = $props();
|
||||
|
||||
const isOwner = $derived($user && asset.ownerId === $user?.id);
|
||||
const isLocked = $derived(asset.visibility === AssetVisibility.Locked);
|
||||
const smartSearchEnabled = $derived(featureFlagsManager.value.smartSearch);
|
||||
|
||||
const { Cast } = $derived(getGlobalActions($t));
|
||||
|
||||
const Close: ActionItem = $derived({
|
||||
title: $t('go_back'),
|
||||
type: $t('assets'),
|
||||
icon: mdiArrowLeft,
|
||||
$if: () => !!onClose,
|
||||
onAction: () => onClose?.(),
|
||||
shortcuts: [{ key: 'Escape' }],
|
||||
});
|
||||
|
||||
const {
|
||||
Share,
|
||||
Download,
|
||||
DownloadOriginal,
|
||||
SharedLinkDownload,
|
||||
Offline,
|
||||
Favorite,
|
||||
Unfavorite,
|
||||
PlayMotionPhoto,
|
||||
StopMotionPhoto,
|
||||
ZoomIn,
|
||||
ZoomOut,
|
||||
Copy,
|
||||
Info,
|
||||
Edit,
|
||||
RefreshFacesJob,
|
||||
RefreshMetadataJob,
|
||||
RegenerateThumbnailJob,
|
||||
TranscodeVideoJob,
|
||||
} = $derived(getAssetActions($t, asset));
|
||||
const sharedLink = getSharedLink();
|
||||
</script>
|
||||
|
||||
<CommandPaletteDefaultProvider
|
||||
name={$t('assets')}
|
||||
actions={withoutIcons([
|
||||
Close,
|
||||
Cast,
|
||||
Share,
|
||||
Download,
|
||||
DownloadOriginal,
|
||||
SharedLinkDownload,
|
||||
Offline,
|
||||
Favorite,
|
||||
Unfavorite,
|
||||
PlayMotionPhoto,
|
||||
StopMotionPhoto,
|
||||
ZoomIn,
|
||||
ZoomOut,
|
||||
Copy,
|
||||
Info,
|
||||
Edit,
|
||||
RefreshFacesJob,
|
||||
RefreshMetadataJob,
|
||||
RegenerateThumbnailJob,
|
||||
TranscodeVideoJob,
|
||||
])}
|
||||
/>
|
||||
|
||||
<div
|
||||
class="flex h-16 place-items-center justify-between bg-linear-to-b from-black/40 px-3 transition-transform duration-200"
|
||||
>
|
||||
<div class="dark">
|
||||
<ActionButton action={Close} />
|
||||
</div>
|
||||
|
||||
<div class="flex gap-2 overflow-x-auto dark" data-testid="asset-viewer-navbar-actions">
|
||||
<ActionButton action={Cast} />
|
||||
<ActionButton action={Share} />
|
||||
<ActionButton action={Offline} />
|
||||
<ActionButton action={PlayMotionPhoto} />
|
||||
<ActionButton action={StopMotionPhoto} />
|
||||
<ActionButton action={ZoomIn} />
|
||||
<ActionButton action={ZoomOut} />
|
||||
<ActionButton action={Copy} />
|
||||
<ActionButton action={SharedLinkDownload} />
|
||||
<ActionButton action={Info} />
|
||||
<ActionButton action={Favorite} />
|
||||
<ActionButton action={Unfavorite} />
|
||||
|
||||
{#if isOwner}
|
||||
<RatingAction {asset} {onAction} />
|
||||
{/if}
|
||||
|
||||
<ActionButton action={Edit} />
|
||||
|
||||
{#if isOwner}
|
||||
<DeleteAction {asset} {onAction} {preAction} {onUndoDelete} />
|
||||
{/if}
|
||||
|
||||
{#if !sharedLink}
|
||||
<ButtonContextMenu direction="left" align="top-right" color="secondary" title={$t('more')} icon={mdiDotsVertical}>
|
||||
{#if showSlideshow && !isLocked}
|
||||
<MenuOption icon={mdiPresentationPlay} text={$t('slideshow')} onClick={onPlaySlideshow} />
|
||||
{/if}
|
||||
|
||||
<ActionMenuItem action={Download} />
|
||||
<ActionMenuItem action={DownloadOriginal} />
|
||||
|
||||
{#if !isLocked}
|
||||
{#if asset.isTrashed}
|
||||
<RestoreAction {asset} {onAction} />
|
||||
{:else}
|
||||
<AddToAlbumAction {asset} {onAction} />
|
||||
<AddToAlbumAction {asset} {onAction} shared />
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
{#if isOwner}
|
||||
<AddToStackAction {asset} {stack} {onAction} />
|
||||
{#if stack}
|
||||
<UnstackAction {stack} {onAction} />
|
||||
<KeepThisDeleteOthersAction {stack} {asset} {onAction} />
|
||||
{#if stack?.primaryAssetId !== asset.id}
|
||||
<SetStackPrimaryAsset {stack} {asset} {onAction} />
|
||||
{#if stack?.assets?.length > 2}
|
||||
<RemoveAssetFromStack {asset} {stack} {onAction} />
|
||||
{/if}
|
||||
{/if}
|
||||
{/if}
|
||||
{/if}
|
||||
{#if album}
|
||||
<SetAlbumCoverAction {asset} {album} />
|
||||
{/if}
|
||||
{#if person}
|
||||
<SetFeaturedPhotoAction {asset} {person} {onAction} />
|
||||
{/if}
|
||||
{#if asset.type === AssetTypeEnum.Image && !isLocked}
|
||||
<SetProfilePictureAction {asset} />
|
||||
{/if}
|
||||
|
||||
{#if !isLocked}
|
||||
{#if isOwner}
|
||||
<ArchiveAction {asset} {onAction} {preAction} />
|
||||
<MenuOption
|
||||
icon={mdiUpload}
|
||||
onClick={() => handleReplaceAsset(asset.id)}
|
||||
text={$t('replace_with_upload')}
|
||||
/>
|
||||
{#if !asset.isArchived && !asset.isTrashed}
|
||||
<MenuOption
|
||||
icon={mdiImageSearch}
|
||||
onClick={() => goto(Route.photos({ at: stack?.primaryAssetId ?? asset.id }))}
|
||||
text={$t('view_in_timeline')}
|
||||
/>
|
||||
{/if}
|
||||
{/if}
|
||||
{#if !asset.isArchived && !asset.isTrashed && smartSearchEnabled}
|
||||
<MenuOption
|
||||
icon={mdiCompare}
|
||||
onClick={() => goto(Route.search({ queryAssetId: stack?.primaryAssetId ?? asset.id }))}
|
||||
text={$t('view_similar_photos')}
|
||||
/>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
{#if !asset.isTrashed && isOwner}
|
||||
<SetVisibilityAction asset={toTimelineAsset(asset)} {onAction} {preAction} />
|
||||
{/if}
|
||||
|
||||
{#if asset.type === AssetTypeEnum.Video}
|
||||
<MenuOption
|
||||
icon={mdiVideoOutline}
|
||||
onClick={() => setPlayOriginalVideo(!playOriginalVideo)}
|
||||
text={playOriginalVideo ? $t('play_transcoded_video') : $t('play_original_video')}
|
||||
/>
|
||||
{/if}
|
||||
{#if isOwner}
|
||||
<hr />
|
||||
<ActionMenuItem action={RefreshFacesJob} />
|
||||
<ActionMenuItem action={RefreshMetadataJob} />
|
||||
<ActionMenuItem action={RegenerateThumbnailJob} />
|
||||
<ActionMenuItem action={TranscodeVideoJob} />
|
||||
{/if}
|
||||
</ButtonContextMenu>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,677 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { focusTrap } from '$lib/actions/focus-trap';
|
||||
import type { Action, OnAction, PreAction } from '$lib/components/asset-viewer/actions/action';
|
||||
import NextAssetAction from '$lib/components/asset-viewer/actions/next-asset-action.svelte';
|
||||
import PreviousAssetAction from '$lib/components/asset-viewer/actions/previous-asset-action.svelte';
|
||||
import AssetViewerNavBar from '$lib/components/asset-viewer/asset-viewer-nav-bar.svelte';
|
||||
import OnEvents from '$lib/components/OnEvents.svelte';
|
||||
import { AssetAction, ProjectionType } from '$lib/constants';
|
||||
import { activityManager } from '$lib/managers/activity-manager.svelte';
|
||||
import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte';
|
||||
import { authManager } from '$lib/managers/auth-manager.svelte';
|
||||
import { editManager, EditToolType } from '$lib/managers/edit/edit-manager.svelte';
|
||||
import { eventManager } from '$lib/managers/event-manager.svelte';
|
||||
import { imageManager } from '$lib/managers/ImageManager.svelte';
|
||||
import { Route } from '$lib/route';
|
||||
import { getAssetActions } from '$lib/services/asset.service';
|
||||
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
|
||||
import { ocrManager } from '$lib/stores/ocr.svelte';
|
||||
import { alwaysLoadOriginalVideo } from '$lib/stores/preferences.store';
|
||||
import { SlideshowNavigation, SlideshowState, slideshowStore } from '$lib/stores/slideshow.store';
|
||||
import { user } from '$lib/stores/user.store';
|
||||
import { getSharedLink, handlePromiseError } from '$lib/utils';
|
||||
import type { OnUndoDelete } from '$lib/utils/actions';
|
||||
import { navigateToAsset } from '$lib/utils/asset-utils';
|
||||
import { handleError } from '$lib/utils/handle-error';
|
||||
import { InvocationTracker } from '$lib/utils/invocationTracker';
|
||||
import { SlideshowHistory } from '$lib/utils/slideshow-history';
|
||||
import { toTimelineAsset } from '$lib/utils/timeline-util';
|
||||
import {
|
||||
AssetTypeEnum,
|
||||
getAllAlbums,
|
||||
getAssetInfo,
|
||||
getStack,
|
||||
type AlbumResponseDto,
|
||||
type AssetResponseDto,
|
||||
type PersonResponseDto,
|
||||
type StackResponseDto,
|
||||
} from '@immich/sdk';
|
||||
import { CommandPaletteDefaultProvider } from '@immich/ui';
|
||||
import { onDestroy, onMount, untrack } from 'svelte';
|
||||
import { t } from 'svelte-i18n';
|
||||
import { fly } from 'svelte/transition';
|
||||
import Thumbnail from '../assets/thumbnail/thumbnail.svelte';
|
||||
import ActivityStatus from './activity-status.svelte';
|
||||
import ActivityViewer from './activity-viewer.svelte';
|
||||
import DetailPanel from './detail-panel.svelte';
|
||||
import EditorPanel from './editor/editor-panel.svelte';
|
||||
import CropArea from './editor/transform-tool/crop-area.svelte';
|
||||
import ImagePanoramaViewer from './image-panorama-viewer.svelte';
|
||||
import OcrButton from './ocr-button.svelte';
|
||||
import PhotoViewer from './photo-viewer.svelte';
|
||||
import SlideshowBar from './slideshow-bar.svelte';
|
||||
import VideoViewer from './video-wrapper-viewer.svelte';
|
||||
|
||||
export type AssetCursor = {
|
||||
current: AssetResponseDto;
|
||||
nextAsset?: AssetResponseDto;
|
||||
previousAsset?: AssetResponseDto;
|
||||
};
|
||||
|
||||
interface Props {
|
||||
cursor: AssetCursor;
|
||||
showNavigation?: boolean;
|
||||
withStacked?: boolean;
|
||||
isShared?: boolean;
|
||||
album?: AlbumResponseDto;
|
||||
person?: PersonResponseDto;
|
||||
onAssetChange?: (asset: AssetResponseDto) => void;
|
||||
preAction?: PreAction;
|
||||
onAction?: OnAction;
|
||||
onUndoDelete?: OnUndoDelete;
|
||||
onClose?: (asset: AssetResponseDto) => void;
|
||||
onRandom?: () => Promise<{ id: string } | undefined>;
|
||||
}
|
||||
|
||||
let {
|
||||
cursor,
|
||||
showNavigation = true,
|
||||
withStacked = false,
|
||||
isShared = false,
|
||||
album,
|
||||
person,
|
||||
onAssetChange,
|
||||
preAction,
|
||||
onAction,
|
||||
onUndoDelete,
|
||||
onClose,
|
||||
onRandom,
|
||||
}: Props = $props();
|
||||
|
||||
const { setAssetId } = assetViewingStore;
|
||||
const {
|
||||
restartProgress: restartSlideshowProgress,
|
||||
stopProgress: stopSlideshowProgress,
|
||||
slideshowNavigation,
|
||||
slideshowState,
|
||||
slideshowTransition,
|
||||
slideshowRepeat,
|
||||
} = slideshowStore;
|
||||
const stackThumbnailSize = 60;
|
||||
const stackSelectedThumbnailSize = 65;
|
||||
|
||||
const asset = $derived(cursor.current);
|
||||
const nextAsset = $derived(cursor.nextAsset);
|
||||
const previousAsset = $derived(cursor.previousAsset);
|
||||
let appearsInAlbums: AlbumResponseDto[] = $state([]);
|
||||
let sharedLink = getSharedLink();
|
||||
let previewStackedAsset: AssetResponseDto | undefined = $state();
|
||||
let fullscreenElement = $state<Element>();
|
||||
let unsubscribes: (() => void)[] = [];
|
||||
let stack: StackResponseDto | null = $state(null);
|
||||
|
||||
let playOriginalVideo = $state($alwaysLoadOriginalVideo);
|
||||
let slideshowStartAssetId = $state<string>();
|
||||
|
||||
const setPlayOriginalVideo = (value: boolean) => {
|
||||
playOriginalVideo = value;
|
||||
};
|
||||
|
||||
const refreshStack = async () => {
|
||||
if (authManager.isSharedLink) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (asset.stack) {
|
||||
stack = await getStack({ id: asset.stack.id });
|
||||
}
|
||||
|
||||
if (!stack?.assets.some(({ id }) => id === asset.id)) {
|
||||
stack = null;
|
||||
}
|
||||
|
||||
untrack(() => {
|
||||
imageManager.preload(stack?.assets[1]);
|
||||
});
|
||||
};
|
||||
|
||||
const handleFavorite = async () => {
|
||||
if (album && album.isActivityEnabled) {
|
||||
try {
|
||||
await activityManager.toggleLike();
|
||||
} catch (error) {
|
||||
handleError(error, $t('errors.unable_to_change_favorite'));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
onMount(async () => {
|
||||
unsubscribes.push(
|
||||
slideshowState.subscribe((value) => {
|
||||
if (value === SlideshowState.PlaySlideshow) {
|
||||
slideshowHistory.reset();
|
||||
slideshowHistory.queue(toTimelineAsset(asset));
|
||||
handlePromiseError(handlePlaySlideshow());
|
||||
} else if (value === SlideshowState.StopSlideshow) {
|
||||
handlePromiseError(handleStopSlideshow());
|
||||
}
|
||||
}),
|
||||
slideshowNavigation.subscribe((value) => {
|
||||
if (value === SlideshowNavigation.Shuffle) {
|
||||
slideshowHistory.reset();
|
||||
slideshowHistory.queue(toTimelineAsset(asset));
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
if (!sharedLink) {
|
||||
await handleGetAllAlbums();
|
||||
}
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
for (const unsubscribe of unsubscribes) {
|
||||
unsubscribe();
|
||||
}
|
||||
|
||||
activityManager.reset();
|
||||
assetViewerManager.closeEditor();
|
||||
});
|
||||
|
||||
const handleGetAllAlbums = async () => {
|
||||
if (authManager.isSharedLink) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
appearsInAlbums = await getAllAlbums({ assetId: asset.id });
|
||||
} catch (error) {
|
||||
console.error('Error getting album that asset belong to', error);
|
||||
}
|
||||
};
|
||||
|
||||
const closeViewer = () => {
|
||||
onClose?.(asset);
|
||||
};
|
||||
|
||||
const closeEditor = async () => {
|
||||
if (editManager.hasAppliedEdits) {
|
||||
const refreshedAsset = await getAssetInfo({ id: asset.id });
|
||||
onAssetChange?.(refreshedAsset);
|
||||
assetViewingStore.setAsset(refreshedAsset);
|
||||
}
|
||||
assetViewerManager.closeEditor();
|
||||
};
|
||||
|
||||
const tracker = new InvocationTracker();
|
||||
|
||||
const navigateAsset = (order?: 'previous' | 'next', e?: Event) => {
|
||||
if (!order) {
|
||||
if ($slideshowState === SlideshowState.PlaySlideshow) {
|
||||
order = $slideshowNavigation === SlideshowNavigation.AscendingOrder ? 'previous' : 'next';
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
e?.stopPropagation();
|
||||
imageManager.cancel(asset);
|
||||
if (tracker.isActive()) {
|
||||
return;
|
||||
}
|
||||
|
||||
void tracker.invoke(async () => {
|
||||
let hasNext = false;
|
||||
|
||||
if ($slideshowState === SlideshowState.PlaySlideshow && $slideshowNavigation === SlideshowNavigation.Shuffle) {
|
||||
hasNext = order === 'previous' ? slideshowHistory.previous() : slideshowHistory.next();
|
||||
if (!hasNext) {
|
||||
const asset = await onRandom?.();
|
||||
if (asset) {
|
||||
slideshowHistory.queue(asset);
|
||||
hasNext = true;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
hasNext =
|
||||
order === 'previous' ? await navigateToAsset(cursor.previousAsset) : await navigateToAsset(cursor.nextAsset);
|
||||
}
|
||||
|
||||
if ($slideshowState === SlideshowState.PlaySlideshow) {
|
||||
if (hasNext) {
|
||||
$restartSlideshowProgress = true;
|
||||
} else if ($slideshowRepeat && slideshowStartAssetId) {
|
||||
// Loop back to starting asset
|
||||
await setAssetId(slideshowStartAssetId);
|
||||
$restartSlideshowProgress = true;
|
||||
} else {
|
||||
await handleStopSlideshow();
|
||||
}
|
||||
}
|
||||
}, $t('error_while_navigating'));
|
||||
};
|
||||
|
||||
/**
|
||||
* Slide show mode
|
||||
*/
|
||||
|
||||
let assetViewerHtmlElement = $state<HTMLElement>();
|
||||
|
||||
const slideshowHistory = new SlideshowHistory((asset) => {
|
||||
handlePromiseError(setAssetId(asset.id).then(() => ($restartSlideshowProgress = true)));
|
||||
});
|
||||
|
||||
const handleVideoStarted = () => {
|
||||
if ($slideshowState === SlideshowState.PlaySlideshow) {
|
||||
$stopSlideshowProgress = true;
|
||||
}
|
||||
};
|
||||
|
||||
const handlePlaySlideshow = async () => {
|
||||
slideshowStartAssetId = asset.id;
|
||||
try {
|
||||
await assetViewerHtmlElement?.requestFullscreen?.();
|
||||
} catch (error) {
|
||||
handleError(error, $t('errors.unable_to_enter_fullscreen'));
|
||||
$slideshowState = SlideshowState.StopSlideshow;
|
||||
}
|
||||
};
|
||||
|
||||
const handleStopSlideshow = async () => {
|
||||
try {
|
||||
if (document.fullscreenElement) {
|
||||
document.body.style.cursor = '';
|
||||
await document.exitFullscreen();
|
||||
}
|
||||
} catch (error) {
|
||||
handleError(error, $t('errors.unable_to_exit_fullscreen'));
|
||||
} finally {
|
||||
$stopSlideshowProgress = true;
|
||||
$slideshowState = SlideshowState.None;
|
||||
}
|
||||
};
|
||||
|
||||
const handleStackedAssetMouseEvent = (isMouseOver: boolean, asset: AssetResponseDto) => {
|
||||
previewStackedAsset = isMouseOver ? asset : undefined;
|
||||
};
|
||||
const handlePreAction = (action: Action) => {
|
||||
preAction?.(action);
|
||||
};
|
||||
const handleAction = async (action: Action) => {
|
||||
switch (action.type) {
|
||||
case AssetAction.ADD_TO_ALBUM: {
|
||||
await handleGetAllAlbums();
|
||||
break;
|
||||
}
|
||||
case AssetAction.DELETE:
|
||||
case AssetAction.TRASH: {
|
||||
eventManager.emit('AssetsDelete', [asset.id]);
|
||||
break;
|
||||
}
|
||||
case AssetAction.REMOVE_ASSET_FROM_STACK: {
|
||||
stack = action.stack;
|
||||
if (stack) {
|
||||
cursor.current = stack.assets[0];
|
||||
}
|
||||
break;
|
||||
}
|
||||
case AssetAction.STACK:
|
||||
case AssetAction.SET_STACK_PRIMARY_ASSET: {
|
||||
stack = action.stack;
|
||||
break;
|
||||
}
|
||||
case AssetAction.SET_PERSON_FEATURED_PHOTO: {
|
||||
const assetInfo = await getAssetInfo({ id: asset.id });
|
||||
cursor.current = { ...asset, people: assetInfo.people };
|
||||
break;
|
||||
}
|
||||
case AssetAction.RATING: {
|
||||
cursor.current = {
|
||||
...asset,
|
||||
exifInfo: {
|
||||
...asset.exifInfo,
|
||||
rating: action.rating,
|
||||
},
|
||||
};
|
||||
break;
|
||||
}
|
||||
case AssetAction.KEEP_THIS_DELETE_OTHERS:
|
||||
case AssetAction.UNSTACK: {
|
||||
closeViewer();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
onAction?.(action);
|
||||
};
|
||||
|
||||
let isFullScreen = $derived(fullscreenElement !== null);
|
||||
|
||||
$effect(() => {
|
||||
if (album && !album.isActivityEnabled && activityManager.commentCount === 0) {
|
||||
assetViewerManager.closeActivityPanel();
|
||||
}
|
||||
});
|
||||
$effect(() => {
|
||||
if (album && isShared && asset.id) {
|
||||
handlePromiseError(activityManager.init(album.id, asset.id));
|
||||
}
|
||||
});
|
||||
|
||||
const refresh = async () => {
|
||||
await refreshStack();
|
||||
await handleGetAllAlbums();
|
||||
ocrManager.clear();
|
||||
if (!sharedLink) {
|
||||
if (previewStackedAsset) {
|
||||
await ocrManager.getAssetOcr(previewStackedAsset.id);
|
||||
}
|
||||
await ocrManager.getAssetOcr(asset.id);
|
||||
}
|
||||
};
|
||||
$effect(() => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
|
||||
asset;
|
||||
untrack(() => handlePromiseError(refresh()));
|
||||
imageManager.preload(cursor.nextAsset);
|
||||
imageManager.preload(cursor.previousAsset);
|
||||
});
|
||||
|
||||
const onAssetReplace = async ({ oldAssetId, newAssetId }: { oldAssetId: string; newAssetId: string }) => {
|
||||
if (oldAssetId !== asset.id) {
|
||||
return;
|
||||
}
|
||||
|
||||
await new Promise((promise) => setTimeout(promise, 500));
|
||||
await goto(Route.viewAsset({ id: newAssetId }));
|
||||
};
|
||||
|
||||
const onAssetUpdate = (update: AssetResponseDto) => {
|
||||
if (asset.id === update.id) {
|
||||
cursor.current = update;
|
||||
}
|
||||
};
|
||||
|
||||
const viewerKind = $derived.by(() => {
|
||||
if (previewStackedAsset) {
|
||||
return asset.type === AssetTypeEnum.Image ? 'StackPhotoViewer' : 'StackVideoViewer';
|
||||
}
|
||||
if (asset.type === AssetTypeEnum.Video) {
|
||||
return 'VideoViewer';
|
||||
}
|
||||
if (assetViewerManager.isPlayingMotionPhoto && asset.livePhotoVideoId) {
|
||||
return 'LiveVideoViewer';
|
||||
}
|
||||
if (
|
||||
asset.exifInfo?.projectionType === ProjectionType.EQUIRECTANGULAR ||
|
||||
(asset.originalPath && asset.originalPath.toLowerCase().endsWith('.insp'))
|
||||
) {
|
||||
return 'ImagePanaramaViewer';
|
||||
}
|
||||
if (assetViewerManager.isShowEditor && editManager.selectedTool?.type === EditToolType.Transform) {
|
||||
return 'CropArea';
|
||||
}
|
||||
return 'PhotoViewer';
|
||||
});
|
||||
|
||||
const showActivityStatus = $derived(
|
||||
$slideshowState === SlideshowState.None &&
|
||||
isShared &&
|
||||
((album && album.isActivityEnabled) || activityManager.commentCount > 0) &&
|
||||
!activityManager.isLoading,
|
||||
);
|
||||
|
||||
const showOcrButton = $derived(
|
||||
$slideshowState === SlideshowState.None &&
|
||||
asset.type === AssetTypeEnum.Image &&
|
||||
!(asset.exifInfo?.projectionType === 'EQUIRECTANGULAR') &&
|
||||
!assetViewerManager.isShowEditor &&
|
||||
ocrManager.hasOcrData,
|
||||
);
|
||||
|
||||
const { Tag } = $derived(getAssetActions($t, asset));
|
||||
</script>
|
||||
|
||||
<CommandPaletteDefaultProvider name={$t('assets')} actions={[Tag]} />
|
||||
<OnEvents {onAssetReplace} {onAssetUpdate} />
|
||||
|
||||
<svelte:document bind:fullscreenElement />
|
||||
|
||||
<section
|
||||
id="immich-asset-viewer"
|
||||
class="fixed start-0 top-0 grid size-full grid-cols-4 grid-rows-[64px_1fr] overflow-hidden bg-black"
|
||||
use:focusTrap
|
||||
bind:this={assetViewerHtmlElement}
|
||||
>
|
||||
<!-- Top navigation bar -->
|
||||
{#if $slideshowState === SlideshowState.None && !assetViewerManager.isShowEditor}
|
||||
<div class="col-span-4 col-start-1 row-span-1 row-start-1 transition-transform">
|
||||
<AssetViewerNavBar
|
||||
{asset}
|
||||
{album}
|
||||
{person}
|
||||
{stack}
|
||||
showSlideshow={true}
|
||||
preAction={handlePreAction}
|
||||
onAction={handleAction}
|
||||
{onUndoDelete}
|
||||
onPlaySlideshow={() => ($slideshowState = SlideshowState.PlaySlideshow)}
|
||||
onClose={onClose ? () => onClose(asset) : undefined}
|
||||
{playOriginalVideo}
|
||||
{setPlayOriginalVideo}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if $slideshowState != SlideshowState.None}
|
||||
<div class="absolute w-full flex justify-center">
|
||||
<SlideshowBar
|
||||
{isFullScreen}
|
||||
assetType={previewStackedAsset?.type ?? asset.type}
|
||||
onSetToFullScreen={() => assetViewerHtmlElement?.requestFullscreen?.()}
|
||||
onPrevious={() => navigateAsset('previous')}
|
||||
onNext={() => navigateAsset('next')}
|
||||
onClose={() => ($slideshowState = SlideshowState.StopSlideshow)}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if $slideshowState === SlideshowState.None && showNavigation && !assetViewerManager.isShowEditor && previousAsset}
|
||||
<div class="my-auto col-span-1 col-start-1 row-span-full row-start-1 justify-self-start">
|
||||
<PreviousAssetAction onPreviousAsset={() => navigateAsset('previous')} />
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Asset Viewer -->
|
||||
<div class="z-[-1] relative col-start-1 col-span-4 row-start-1 row-span-full">
|
||||
{#if viewerKind === 'StackPhotoViewer'}
|
||||
<PhotoViewer
|
||||
cursor={{ ...cursor, current: previewStackedAsset! }}
|
||||
onPreviousAsset={() => navigateAsset('previous')}
|
||||
onNextAsset={() => navigateAsset('next')}
|
||||
haveFadeTransition={false}
|
||||
{sharedLink}
|
||||
/>
|
||||
{:else if viewerKind === 'StackVideoViewer'}
|
||||
<VideoViewer
|
||||
asset={previewStackedAsset!}
|
||||
cacheKey={previewStackedAsset!.thumbhash}
|
||||
projectionType={previewStackedAsset!.exifInfo?.projectionType}
|
||||
loopVideo={true}
|
||||
onPreviousAsset={() => navigateAsset('previous')}
|
||||
onNextAsset={() => navigateAsset('next')}
|
||||
onClose={closeViewer}
|
||||
onVideoEnded={() => navigateAsset()}
|
||||
onVideoStarted={handleVideoStarted}
|
||||
{playOriginalVideo}
|
||||
/>
|
||||
{:else if viewerKind === 'LiveVideoViewer'}
|
||||
<VideoViewer
|
||||
{asset}
|
||||
assetId={asset.livePhotoVideoId!}
|
||||
cacheKey={asset.thumbhash}
|
||||
projectionType={asset.exifInfo?.projectionType}
|
||||
loopVideo={$slideshowState !== SlideshowState.PlaySlideshow}
|
||||
onPreviousAsset={() => navigateAsset('previous')}
|
||||
onNextAsset={() => navigateAsset('next')}
|
||||
onVideoEnded={() => (assetViewerManager.isPlayingMotionPhoto = false)}
|
||||
{playOriginalVideo}
|
||||
/>
|
||||
{:else if viewerKind === 'ImagePanaramaViewer'}
|
||||
<ImagePanoramaViewer {asset} />
|
||||
{:else if viewerKind === 'CropArea'}
|
||||
<CropArea {asset} />
|
||||
{:else if viewerKind === 'PhotoViewer'}
|
||||
<PhotoViewer
|
||||
{cursor}
|
||||
onPreviousAsset={() => navigateAsset('previous')}
|
||||
onNextAsset={() => navigateAsset('next')}
|
||||
{sharedLink}
|
||||
haveFadeTransition={$slideshowState !== SlideshowState.None && $slideshowTransition}
|
||||
/>
|
||||
{:else if viewerKind === 'VideoViewer'}
|
||||
<VideoViewer
|
||||
{asset}
|
||||
cacheKey={asset.thumbhash}
|
||||
projectionType={asset.exifInfo?.projectionType}
|
||||
loopVideo={$slideshowState !== SlideshowState.PlaySlideshow}
|
||||
onPreviousAsset={() => navigateAsset('previous')}
|
||||
onNextAsset={() => navigateAsset('next')}
|
||||
onClose={closeViewer}
|
||||
onVideoEnded={() => navigateAsset()}
|
||||
onVideoStarted={handleVideoStarted}
|
||||
{playOriginalVideo}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
{#if showActivityStatus}
|
||||
<div class="absolute bottom-0 end-0 mb-20 me-8">
|
||||
<ActivityStatus
|
||||
disabled={!album?.isActivityEnabled}
|
||||
isLiked={activityManager.isLiked}
|
||||
numberOfComments={activityManager.commentCount}
|
||||
numberOfLikes={activityManager.likeCount}
|
||||
onFavorite={handleFavorite}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if showOcrButton}
|
||||
<div class="absolute bottom-0 end-0 mb-6 me-6">
|
||||
<OcrButton />
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if $slideshowState === SlideshowState.None && showNavigation && !assetViewerManager.isShowEditor && nextAsset}
|
||||
<div class="my-auto col-span-1 col-start-4 row-span-full row-start-1 justify-self-end">
|
||||
<NextAssetAction onNextAsset={() => navigateAsset('next')} />
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if asset.hasMetadata && $slideshowState === SlideshowState.None && assetViewerManager.isShowDetailPanel && !assetViewerManager.isShowEditor}
|
||||
<div
|
||||
transition:fly={{ duration: 150 }}
|
||||
id="detail-panel"
|
||||
class="row-start-1 row-span-4 w-90 overflow-y-auto transition-all dark:border-l dark:border-s-immich-dark-gray bg-light"
|
||||
translate="yes"
|
||||
>
|
||||
<DetailPanel {asset} currentAlbum={album} albums={appearsInAlbums} />
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if assetViewerManager.isShowEditor}
|
||||
<div
|
||||
transition:fly={{ duration: 150 }}
|
||||
id="editor-panel"
|
||||
class="row-start-1 row-span-4 w-100 overflow-y-auto transition-all dark:border-l dark:border-s-immich-dark-gray"
|
||||
translate="yes"
|
||||
>
|
||||
<EditorPanel {asset} onClose={closeEditor} />
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if stack && withStacked && !assetViewerManager.isShowEditor}
|
||||
{@const stackedAssets = stack.assets}
|
||||
<div id="stack-slideshow" class="absolute bottom-0 w-full col-span-4 col-start-1 pointer-events-none">
|
||||
<div class="relative flex flex-row no-wrap overflow-x-auto overflow-y-hidden horizontal-scrollbar">
|
||||
{#each stackedAssets as stackedAsset (stackedAsset.id)}
|
||||
<div
|
||||
class={['inline-block px-1 relative transition-all pb-2 pointer-events-auto']}
|
||||
style:bottom={stackedAsset.id === asset.id ? '0' : '-10px'}
|
||||
>
|
||||
<Thumbnail
|
||||
imageClass={{ 'border-2 border-white': stackedAsset.id === asset.id }}
|
||||
brokenAssetClass="text-xs"
|
||||
dimmed={stackedAsset.id !== asset.id}
|
||||
asset={toTimelineAsset(stackedAsset)}
|
||||
onClick={() => {
|
||||
cursor.current = stackedAsset;
|
||||
previewStackedAsset = undefined;
|
||||
}}
|
||||
onMouseEvent={({ isMouseOver }) => handleStackedAssetMouseEvent(isMouseOver, stackedAsset)}
|
||||
readonly
|
||||
thumbnailSize={stackedAsset.id === asset.id ? stackSelectedThumbnailSize : stackThumbnailSize}
|
||||
showStackedIcon={false}
|
||||
disableLinkMouseOver
|
||||
/>
|
||||
|
||||
{#if stackedAsset.id === asset.id}
|
||||
<div class="w-full flex place-items-center place-content-center">
|
||||
<div class="w-2 h-2 bg-white rounded-full flex mt-0.5"></div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if isShared && album && assetViewerManager.isShowActivityPanel && $user}
|
||||
<div
|
||||
transition:fly={{ duration: 150 }}
|
||||
id="activity-panel"
|
||||
class="row-start-1 row-span-5 w-90 md:w-115 overflow-y-auto transition-all dark:border-l dark:border-s-immich-dark-gray"
|
||||
translate="yes"
|
||||
>
|
||||
<ActivityViewer
|
||||
user={$user}
|
||||
disabled={!album.isActivityEnabled}
|
||||
assetType={asset.type}
|
||||
albumOwnerId={album.ownerId}
|
||||
albumId={album.id}
|
||||
assetId={asset.id}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<style>
|
||||
#immich-asset-viewer {
|
||||
contain: layout;
|
||||
}
|
||||
|
||||
.horizontal-scrollbar::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 10px;
|
||||
}
|
||||
|
||||
/* Track */
|
||||
.horizontal-scrollbar::-webkit-scrollbar-track {
|
||||
background: #000000;
|
||||
border-radius: 16px;
|
||||
}
|
||||
|
||||
/* Handle */
|
||||
.horizontal-scrollbar::-webkit-scrollbar-thumb {
|
||||
background: rgba(159, 159, 159, 0.408);
|
||||
border-radius: 16px;
|
||||
}
|
||||
|
||||
/* Handle on hover */
|
||||
.horizontal-scrollbar::-webkit-scrollbar-thumb:hover {
|
||||
background: #adcbfa;
|
||||
border-radius: 16px;
|
||||
}
|
||||
</style>
|
||||
@@ -1,53 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { shortcut } from '$lib/actions/shortcut';
|
||||
import { handleError } from '$lib/utils/handle-error';
|
||||
import { updateAsset, type AssetResponseDto } from '@immich/sdk';
|
||||
import { Textarea, toastManager } from '@immich/ui';
|
||||
import { t } from 'svelte-i18n';
|
||||
import { fromAction } from 'svelte/attachments';
|
||||
|
||||
interface Props {
|
||||
asset: AssetResponseDto;
|
||||
isOwner: boolean;
|
||||
}
|
||||
|
||||
let { asset, isOwner }: Props = $props();
|
||||
|
||||
let currentDescription = $derived(asset.exifInfo?.description ?? '');
|
||||
let description = $derived(currentDescription);
|
||||
|
||||
const handleFocusOut = async () => {
|
||||
if (description === currentDescription) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await updateAsset({ id: asset.id, updateAssetDto: { description } });
|
||||
toastManager.success($t('asset_description_updated'));
|
||||
} catch (error) {
|
||||
handleError(error, $t('cannot_update_the_description'));
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
{#if isOwner}
|
||||
<section class="px-4 mt-10">
|
||||
<Textarea
|
||||
bind:value={description}
|
||||
class="max-h-40 pl-0 outline-none border-b border-gray-500 bg-transparent ring-0 focus:ring-0 resize-none focus:border-b-2 focus:border-immich-primary dark:focus:border-immich-dark-primary dark:bg-transparent"
|
||||
rows={1}
|
||||
grow
|
||||
shape="rectangle"
|
||||
onfocusout={handleFocusOut}
|
||||
placeholder={$t('add_a_description')}
|
||||
data-testid="autogrow-textarea"
|
||||
{@attach fromAction(shortcut, () => ({
|
||||
shortcut: { key: 'Enter', ctrl: true },
|
||||
onShortcut: (e) => e.currentTarget.blur(),
|
||||
}))}
|
||||
/>
|
||||
</section>
|
||||
{:else if description}
|
||||
<section class="px-4 mt-6">
|
||||
<p class="wrap-break-word whitespace-pre-line w-full text-black dark:text-white text-base">{description}</p>
|
||||
</section>
|
||||
{/if}
|
||||
@@ -1,93 +0,0 @@
|
||||
<script lang="ts">
|
||||
import ChangeLocation from '$lib/components/shared-components/change-location.svelte';
|
||||
import Portal from '$lib/elements/Portal.svelte';
|
||||
import { handleError } from '$lib/utils/handle-error';
|
||||
import { updateAsset, type AssetResponseDto } from '@immich/sdk';
|
||||
import { Icon } from '@immich/ui';
|
||||
import { mdiMapMarkerOutline, mdiPencil } from '@mdi/js';
|
||||
import { t } from 'svelte-i18n';
|
||||
|
||||
interface Props {
|
||||
isOwner: boolean;
|
||||
asset: AssetResponseDto;
|
||||
}
|
||||
|
||||
let { isOwner, asset = $bindable() }: Props = $props();
|
||||
|
||||
let isShowChangeLocation = $state(false);
|
||||
|
||||
const onClose = async (point?: { lng: number; lat: number }) => {
|
||||
isShowChangeLocation = false;
|
||||
|
||||
if (!point) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
asset = await updateAsset({
|
||||
id: asset.id,
|
||||
updateAssetDto: { latitude: point.lat, longitude: point.lng },
|
||||
});
|
||||
} catch (error) {
|
||||
handleError(error, $t('errors.unable_to_change_location'));
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
{#if asset.exifInfo?.country}
|
||||
<button
|
||||
type="button"
|
||||
class="flex w-full text-start justify-between place-items-start gap-4 py-4"
|
||||
onclick={() => (isOwner ? (isShowChangeLocation = true) : null)}
|
||||
title={isOwner ? $t('edit_location') : ''}
|
||||
class:hover:text-primary={isOwner}
|
||||
>
|
||||
<div class="flex gap-4">
|
||||
<div><Icon icon={mdiMapMarkerOutline} size="24" /></div>
|
||||
|
||||
<div>
|
||||
{#if asset.exifInfo?.city}
|
||||
<p>{asset.exifInfo.city}</p>
|
||||
{/if}
|
||||
{#if asset.exifInfo?.state}
|
||||
<div class="flex gap-2 text-sm">
|
||||
<p>{asset.exifInfo.state}</p>
|
||||
</div>
|
||||
{/if}
|
||||
{#if asset.exifInfo?.country}
|
||||
<div class="flex gap-2 text-sm">
|
||||
<p>{asset.exifInfo.country}</p>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if isOwner}
|
||||
<div>
|
||||
<Icon icon={mdiPencil} size="20" />
|
||||
</div>
|
||||
{/if}
|
||||
</button>
|
||||
{:else if !asset.exifInfo?.city && isOwner}
|
||||
<button
|
||||
type="button"
|
||||
class="flex w-full text-start justify-between place-items-start gap-4 py-4 rounded-lg hover:text-primary"
|
||||
onclick={() => (isShowChangeLocation = true)}
|
||||
title={$t('add_location')}
|
||||
>
|
||||
<div class="flex gap-4">
|
||||
<div><Icon icon={mdiMapMarkerOutline} size="24" /></div>
|
||||
|
||||
<p>{$t('add_a_location')}</p>
|
||||
</div>
|
||||
<div class="focus:outline-none p-1">
|
||||
<Icon icon={mdiPencil} size="20" />
|
||||
</div>
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
{#if isShowChangeLocation}
|
||||
<Portal>
|
||||
<ChangeLocation {asset} {onClose} />
|
||||
</Portal>
|
||||
{/if}
|
||||
@@ -1,32 +0,0 @@
|
||||
<script lang="ts">
|
||||
import StarRating from '$lib/elements/StarRating.svelte';
|
||||
import { authManager } from '$lib/managers/auth-manager.svelte';
|
||||
import { preferences } from '$lib/stores/user.store';
|
||||
import { handlePromiseError } from '$lib/utils';
|
||||
import { handleError } from '$lib/utils/handle-error';
|
||||
import { updateAsset, type AssetResponseDto } from '@immich/sdk';
|
||||
import { t } from 'svelte-i18n';
|
||||
|
||||
interface Props {
|
||||
asset: AssetResponseDto;
|
||||
isOwner: boolean;
|
||||
}
|
||||
|
||||
let { asset, isOwner }: Props = $props();
|
||||
|
||||
let rating = $derived(asset.exifInfo?.rating || 0);
|
||||
|
||||
const handleChangeRating = async (rating: number) => {
|
||||
try {
|
||||
await updateAsset({ id: asset.id, updateAssetDto: { rating } });
|
||||
} catch (error) {
|
||||
handleError(error, $t('errors.cant_apply_changes'));
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
{#if !authManager.isSharedLink && $preferences?.ratings.enabled}
|
||||
<section class="px-4 pt-2">
|
||||
<StarRating {rating} readOnly={!isOwner} onRating={(rating) => handlePromiseError(handleChangeRating(rating))} />
|
||||
</section>
|
||||
{/if}
|
||||
@@ -1,67 +0,0 @@
|
||||
<script lang="ts">
|
||||
import HeaderActionButton from '$lib/components/HeaderActionButton.svelte';
|
||||
import OnEvents from '$lib/components/OnEvents.svelte';
|
||||
import { authManager } from '$lib/managers/auth-manager.svelte';
|
||||
import { Route } from '$lib/route';
|
||||
import { getAssetActions } from '$lib/services/asset.service';
|
||||
import { removeTag } from '$lib/utils/asset-utils';
|
||||
import { getAssetInfo, type AssetResponseDto } from '@immich/sdk';
|
||||
import { Badge, IconButton, Link, Text } from '@immich/ui';
|
||||
import { mdiClose } from '@mdi/js';
|
||||
import { t } from 'svelte-i18n';
|
||||
|
||||
interface Props {
|
||||
asset: AssetResponseDto;
|
||||
isOwner: boolean;
|
||||
}
|
||||
|
||||
let { asset = $bindable(), isOwner }: Props = $props();
|
||||
|
||||
let tags = $derived(asset.tags || []);
|
||||
|
||||
const handleRemove = async (tagId: string) => {
|
||||
const ids = await removeTag({ tagIds: [tagId], assetIds: [asset.id], showNotification: false });
|
||||
if (ids) {
|
||||
asset = await getAssetInfo({ id: asset.id });
|
||||
}
|
||||
};
|
||||
|
||||
const onAssetsTag = async (ids: string[]) => {
|
||||
if (ids.includes(asset.id)) {
|
||||
asset = await getAssetInfo({ id: asset.id });
|
||||
}
|
||||
};
|
||||
|
||||
const { Tag } = $derived(getAssetActions($t, asset));
|
||||
</script>
|
||||
|
||||
<OnEvents {onAssetsTag} />
|
||||
|
||||
{#if isOwner && !authManager.isSharedLink}
|
||||
<section class="px-4 mt-4">
|
||||
<div class="flex h-10 w-full items-center justify-between text-sm">
|
||||
<Text color="muted">{$t('tags')}</Text>
|
||||
</div>
|
||||
<section class="flex flex-wrap pt-2 gap-1" data-testid="detail-panel-tags">
|
||||
{#each tags as tag (tag.id)}
|
||||
<Badge size="small" class="items-center px-0" shape="round">
|
||||
<Link
|
||||
href={Route.tags({ path: tag.value })}
|
||||
class="text-light no-underline rounded-full hover:bg-primary-400 px-2"
|
||||
>
|
||||
{tag.value}
|
||||
</Link>
|
||||
<IconButton
|
||||
aria-label={$t('remove_tag')}
|
||||
icon={mdiClose}
|
||||
onclick={() => handleRemove(tag.id)}
|
||||
size="tiny"
|
||||
class="hover:bg-primary-400"
|
||||
shape="round"
|
||||
/>
|
||||
</Badge>
|
||||
{/each}
|
||||
<HeaderActionButton action={Tag} />
|
||||
</section>
|
||||
</section>
|
||||
{/if}
|
||||
@@ -1,550 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import DetailPanelDescription from '$lib/components/asset-viewer/detail-panel-description.svelte';
|
||||
import DetailPanelLocation from '$lib/components/asset-viewer/detail-panel-location.svelte';
|
||||
import DetailPanelRating from '$lib/components/asset-viewer/detail-panel-star-rating.svelte';
|
||||
import DetailPanelTags from '$lib/components/asset-viewer/detail-panel-tags.svelte';
|
||||
import { timeToLoadTheMap } from '$lib/constants';
|
||||
import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte';
|
||||
import { authManager } from '$lib/managers/auth-manager.svelte';
|
||||
import { featureFlagsManager } from '$lib/managers/feature-flags-manager.svelte';
|
||||
import AssetChangeDateModal from '$lib/modals/AssetChangeDateModal.svelte';
|
||||
import { Route } from '$lib/route';
|
||||
import { isFaceEditMode } from '$lib/stores/face-edit.svelte';
|
||||
import { boundingBoxesArray } from '$lib/stores/people.store';
|
||||
import { locale } from '$lib/stores/preferences.store';
|
||||
import { preferences, user } from '$lib/stores/user.store';
|
||||
import { getAssetMediaUrl, getPeopleThumbnailUrl } from '$lib/utils';
|
||||
import { delay, getDimensions } from '$lib/utils/asset-utils';
|
||||
import { getByteUnitString } from '$lib/utils/byte-units';
|
||||
import { fromISODateTime, fromISODateTimeUTC, toTimelineAsset } from '$lib/utils/timeline-util';
|
||||
import { getParentPath } from '$lib/utils/tree-utils';
|
||||
import { AssetMediaSize, getAssetInfo, type AlbumResponseDto, type AssetResponseDto } from '@immich/sdk';
|
||||
import { Icon, IconButton, LoadingSpinner, modalManager, Text } from '@immich/ui';
|
||||
import {
|
||||
mdiCalendar,
|
||||
mdiCamera,
|
||||
mdiCameraIris,
|
||||
mdiClose,
|
||||
mdiEye,
|
||||
mdiEyeOff,
|
||||
mdiImageOutline,
|
||||
mdiInformationOutline,
|
||||
mdiPencil,
|
||||
mdiPlus,
|
||||
} from '@mdi/js';
|
||||
import { DateTime } from 'luxon';
|
||||
import { t } from 'svelte-i18n';
|
||||
import { slide } from 'svelte/transition';
|
||||
import ImageThumbnail from '../assets/thumbnail/image-thumbnail.svelte';
|
||||
import PersonSidePanel from '../faces-page/person-side-panel.svelte';
|
||||
import UserAvatar from '../shared-components/user-avatar.svelte';
|
||||
import AlbumListItemDetails from './album-list-item-details.svelte';
|
||||
|
||||
interface Props {
|
||||
asset: AssetResponseDto;
|
||||
albums?: AlbumResponseDto[];
|
||||
currentAlbum?: AlbumResponseDto | null;
|
||||
}
|
||||
|
||||
let { asset, albums = [], currentAlbum = null }: Props = $props();
|
||||
|
||||
let showAssetPath = $state(false);
|
||||
let showEditFaces = $state(false);
|
||||
let isOwner = $derived($user?.id === asset.ownerId);
|
||||
let people = $derived(asset.people || []);
|
||||
let unassignedFaces = $derived(asset.unassignedFaces || []);
|
||||
let showingHiddenPeople = $state(false);
|
||||
let timeZone = $derived(asset.exifInfo?.timeZone ?? undefined);
|
||||
let dateTime = $derived(
|
||||
timeZone && asset.exifInfo?.dateTimeOriginal
|
||||
? fromISODateTime(asset.exifInfo.dateTimeOriginal, timeZone)
|
||||
: fromISODateTimeUTC(asset.localDateTime),
|
||||
);
|
||||
let latlng = $derived(
|
||||
(() => {
|
||||
const lat = asset.exifInfo?.latitude;
|
||||
const lng = asset.exifInfo?.longitude;
|
||||
|
||||
if (lat && lng) {
|
||||
return { lat: Number(lat.toFixed(7)), lng: Number(lng.toFixed(7)) };
|
||||
}
|
||||
})(),
|
||||
);
|
||||
let previousId: string | undefined = $state();
|
||||
let previousRoute = $derived(currentAlbum?.id ? Route.viewAlbum(currentAlbum) : Route.photos());
|
||||
|
||||
$effect(() => {
|
||||
if (!previousId) {
|
||||
previousId = asset.id;
|
||||
}
|
||||
if (asset.id !== previousId) {
|
||||
showEditFaces = false;
|
||||
previousId = asset.id;
|
||||
}
|
||||
});
|
||||
|
||||
const getMegapixel = (width: number, height: number): number | undefined => {
|
||||
const megapixel = Math.round((height * width) / 1_000_000);
|
||||
|
||||
if (megapixel) {
|
||||
return megapixel;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const handleRefreshPeople = async () => {
|
||||
asset = await getAssetInfo({ id: asset.id });
|
||||
showEditFaces = false;
|
||||
};
|
||||
|
||||
const getAssetFolderHref = (asset: AssetResponseDto) => {
|
||||
// Remove the last part of the path to get the parent path
|
||||
return Route.folders({ path: getParentPath(asset.originalPath) });
|
||||
};
|
||||
|
||||
const toggleAssetPath = () => (showAssetPath = !showAssetPath);
|
||||
|
||||
const handleChangeDate = async () => {
|
||||
if (!isOwner) {
|
||||
return;
|
||||
}
|
||||
|
||||
await modalManager.show(AssetChangeDateModal, {
|
||||
asset: toTimelineAsset(asset),
|
||||
initialDate: dateTime,
|
||||
initialTimeZone: timeZone,
|
||||
});
|
||||
};
|
||||
</script>
|
||||
|
||||
<section class="relative p-2">
|
||||
<div class="flex place-items-center gap-2">
|
||||
<IconButton
|
||||
icon={mdiClose}
|
||||
aria-label={$t('close')}
|
||||
onclick={() => assetViewerManager.closeDetailPanel()}
|
||||
shape="round"
|
||||
color="secondary"
|
||||
variant="ghost"
|
||||
/>
|
||||
<p class="text-lg text-immich-fg dark:text-immich-dark-fg">{$t('info')}</p>
|
||||
</div>
|
||||
|
||||
{#if asset.isOffline}
|
||||
<section class="px-4 py-4">
|
||||
<div role="alert">
|
||||
<div class="rounded-t bg-red-500 px-4 py-2 font-bold text-white">
|
||||
{$t('asset_offline')}
|
||||
</div>
|
||||
<div class="border border-t-0 border-red-400 bg-red-100 px-4 py-3 text-red-700">
|
||||
<p>
|
||||
{#if $user?.isAdmin}
|
||||
{$t('admin.asset_offline_description')}
|
||||
{:else}
|
||||
{$t('asset_offline_description')}
|
||||
{/if}
|
||||
</p>
|
||||
</div>
|
||||
<div class="rounded-b bg-red-500 px-4 py-2 text-white text-sm">
|
||||
<p>{asset.originalPath}</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
<DetailPanelDescription {asset} {isOwner} />
|
||||
<DetailPanelRating {asset} {isOwner} />
|
||||
|
||||
{#if !authManager.isSharedLink && isOwner}
|
||||
<section class="px-4 pt-4 text-sm">
|
||||
<div class="flex h-10 w-full items-center justify-between">
|
||||
<Text size="small" color="muted">{$t('people')}</Text>
|
||||
<div class="flex gap-2 items-center">
|
||||
{#if people.some((person) => person.isHidden)}
|
||||
<IconButton
|
||||
aria-label={$t('show_hidden_people')}
|
||||
icon={showingHiddenPeople ? mdiEyeOff : mdiEye}
|
||||
size="medium"
|
||||
shape="round"
|
||||
color="secondary"
|
||||
variant="ghost"
|
||||
onclick={() => (showingHiddenPeople = !showingHiddenPeople)}
|
||||
/>
|
||||
{/if}
|
||||
<IconButton
|
||||
aria-label={$t('tag_people')}
|
||||
icon={mdiPlus}
|
||||
size="medium"
|
||||
shape="round"
|
||||
color="secondary"
|
||||
variant="ghost"
|
||||
onclick={() => (isFaceEditMode.value = !isFaceEditMode.value)}
|
||||
/>
|
||||
|
||||
{#if people.length > 0 || unassignedFaces.length > 0}
|
||||
<IconButton
|
||||
aria-label={$t('edit_people')}
|
||||
icon={mdiPencil}
|
||||
size="medium"
|
||||
shape="round"
|
||||
color="secondary"
|
||||
variant="ghost"
|
||||
onclick={() => (showEditFaces = true)}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-2 flex flex-wrap gap-2">
|
||||
{#each people as person, index (person.id)}
|
||||
{#if showingHiddenPeople || !person.isHidden}
|
||||
<a
|
||||
class="w-22"
|
||||
href={Route.viewPerson(person, { previousRoute })}
|
||||
onfocus={() => ($boundingBoxesArray = people[index].faces)}
|
||||
onblur={() => ($boundingBoxesArray = [])}
|
||||
onmouseover={() => ($boundingBoxesArray = people[index].faces)}
|
||||
onmouseleave={() => ($boundingBoxesArray = [])}
|
||||
>
|
||||
<div class="relative">
|
||||
<ImageThumbnail
|
||||
curve
|
||||
shadow
|
||||
url={getPeopleThumbnailUrl(person)}
|
||||
altText={person.name}
|
||||
title={person.name}
|
||||
widthStyle="90px"
|
||||
heightStyle="90px"
|
||||
hidden={person.isHidden}
|
||||
/>
|
||||
</div>
|
||||
<p class="mt-1 truncate font-medium" title={person.name}>{person.name}</p>
|
||||
{#if person.birthDate}
|
||||
{@const personBirthDate = DateTime.fromISO(person.birthDate)}
|
||||
{@const age = Math.floor(DateTime.fromISO(asset.localDateTime).diff(personBirthDate, 'years').years)}
|
||||
{@const ageInMonths = Math.floor(
|
||||
DateTime.fromISO(asset.localDateTime).diff(personBirthDate, 'months').months,
|
||||
)}
|
||||
{#if age >= 0}
|
||||
<p
|
||||
class="font-light"
|
||||
title={personBirthDate.toLocaleString(
|
||||
{
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
},
|
||||
{ locale: $locale },
|
||||
)}
|
||||
>
|
||||
{#if ageInMonths <= 11}
|
||||
{$t('age_months', { values: { months: ageInMonths } })}
|
||||
{:else if ageInMonths > 12 && ageInMonths <= 23}
|
||||
{$t('age_year_months', { values: { months: ageInMonths - 12 } })}
|
||||
{:else}
|
||||
{$t('age_years', { values: { years: age } })}
|
||||
{/if}
|
||||
</p>
|
||||
{/if}
|
||||
{/if}
|
||||
</a>
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
<div class="px-4 py-4">
|
||||
{#if asset.exifInfo}
|
||||
<div class="flex h-10 w-full items-center justify-between text-sm">
|
||||
<Text size="small" color="muted">{$t('details')}</Text>
|
||||
</div>
|
||||
{:else}
|
||||
<Text size="small" color="muted">{$t('no_exif_info_available')}</Text>
|
||||
{/if}
|
||||
|
||||
{#if dateTime}
|
||||
<button
|
||||
type="button"
|
||||
class="flex w-full text-start justify-between place-items-start gap-4 py-4"
|
||||
onclick={handleChangeDate}
|
||||
title={isOwner ? $t('edit_date') : ''}
|
||||
class:hover:text-primary={isOwner}
|
||||
>
|
||||
<div class="flex gap-4">
|
||||
<div>
|
||||
<Icon icon={mdiCalendar} size="24" />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p>
|
||||
{dateTime.toLocaleString(
|
||||
{
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
},
|
||||
{ locale: $locale },
|
||||
)}
|
||||
</p>
|
||||
<div class="flex gap-2 text-sm">
|
||||
<p>
|
||||
{dateTime.toLocaleString(
|
||||
{
|
||||
weekday: 'short',
|
||||
hour: 'numeric',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
timeZoneName: timeZone ? 'longOffset' : undefined,
|
||||
},
|
||||
{ locale: $locale },
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if isOwner}
|
||||
<div class="p-1">
|
||||
<Icon icon={mdiPencil} size="20" />
|
||||
</div>
|
||||
{/if}
|
||||
</button>
|
||||
{:else if !dateTime && isOwner}
|
||||
<div class="flex justify-between place-items-start gap-4 py-4">
|
||||
<div class="flex gap-4">
|
||||
<div>
|
||||
<Icon icon={mdiCalendar} size="24" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="p-1">
|
||||
<Icon icon={mdiPencil} size="20" />
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="flex gap-4 py-4">
|
||||
<div><Icon icon={mdiImageOutline} size="24" /></div>
|
||||
|
||||
<div>
|
||||
<p class="break-all flex place-items-center gap-2 whitespace-pre-wrap">
|
||||
{asset.originalFileName}
|
||||
{#if isOwner}
|
||||
<IconButton
|
||||
icon={mdiInformationOutline}
|
||||
aria-label={$t('show_file_location')}
|
||||
size="small"
|
||||
shape="round"
|
||||
color="secondary"
|
||||
variant="ghost"
|
||||
onclick={toggleAssetPath}
|
||||
/>
|
||||
{/if}
|
||||
</p>
|
||||
{#if showAssetPath}
|
||||
<p class="text-xs opacity-50 break-all pb-2 hover:text-primary" transition:slide={{ duration: 250 }}>
|
||||
<!-- eslint-disable-next-line svelte/no-navigation-without-resolve this is supposed to be treated as an absolute/external link -->
|
||||
<a href={getAssetFolderHref(asset)} title={$t('go_to_folder')} class="whitespace-pre-wrap">
|
||||
{asset.originalPath}
|
||||
</a>
|
||||
</p>
|
||||
{/if}
|
||||
{#if (asset.exifInfo?.exifImageHeight && asset.exifInfo?.exifImageWidth) || asset.exifInfo?.fileSizeInByte}
|
||||
<div class="flex gap-2 text-sm">
|
||||
{#if asset.exifInfo?.exifImageHeight && asset.exifInfo?.exifImageWidth}
|
||||
{#if getMegapixel(asset.exifInfo.exifImageHeight, asset.exifInfo.exifImageWidth)}
|
||||
<p>
|
||||
{getMegapixel(asset.exifInfo.exifImageHeight, asset.exifInfo.exifImageWidth)} MP
|
||||
</p>
|
||||
{/if}
|
||||
{@const { width, height } = getDimensions(asset.exifInfo)}
|
||||
<p>{width} x {height}</p>
|
||||
{/if}
|
||||
{#if asset.exifInfo?.fileSizeInByte}
|
||||
<p>{getByteUnitString(asset.exifInfo.fileSizeInByte, $locale)}</p>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if asset.exifInfo?.make || asset.exifInfo?.model || asset.exifInfo?.exposureTime || asset.exifInfo?.iso}
|
||||
<div class="flex gap-4 py-4">
|
||||
<div><Icon icon={mdiCamera} size="24" /></div>
|
||||
|
||||
<div>
|
||||
{#if asset.exifInfo?.make || asset.exifInfo?.model}
|
||||
<p>
|
||||
<a
|
||||
href={Route.search({
|
||||
make: asset.exifInfo?.make ?? undefined,
|
||||
model: asset.exifInfo?.model ?? undefined,
|
||||
})}
|
||||
title="{$t('search_for')} {asset.exifInfo.make || ''} {asset.exifInfo.model || ''}"
|
||||
class="hover:text-primary"
|
||||
>
|
||||
{asset.exifInfo.make || ''}
|
||||
{asset.exifInfo.model || ''}
|
||||
</a>
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
<div class="flex gap-2 text-sm">
|
||||
{#if asset.exifInfo.exposureTime}
|
||||
<p>{`${asset.exifInfo.exposureTime} s`}</p>
|
||||
{/if}
|
||||
|
||||
{#if asset.exifInfo.iso}
|
||||
<p>{`ISO ${asset.exifInfo.iso}`}</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if asset.exifInfo?.lensModel || asset.exifInfo?.fNumber || asset.exifInfo?.focalLength}
|
||||
<div class="flex gap-4 py-4">
|
||||
<div><Icon icon={mdiCameraIris} size="24" /></div>
|
||||
|
||||
<div>
|
||||
{#if asset.exifInfo?.lensModel}
|
||||
<p>
|
||||
<a
|
||||
href={Route.search({ lensModel: asset.exifInfo.lensModel })}
|
||||
title="{$t('search_for')} {asset.exifInfo.lensModel}"
|
||||
class="hover:text-primary line-clamp-1"
|
||||
>
|
||||
{asset.exifInfo.lensModel}
|
||||
</a>
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
<div class="flex gap-2 text-sm">
|
||||
{#if asset.exifInfo?.fNumber}
|
||||
<p>ƒ/{asset.exifInfo.fNumber.toLocaleString($locale)}</p>
|
||||
{/if}
|
||||
|
||||
{#if asset.exifInfo.focalLength}
|
||||
<p>{`${asset.exifInfo.focalLength.toLocaleString($locale)} mm`}</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<DetailPanelLocation {isOwner} {asset} />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{#if latlng && featureFlagsManager.value.map}
|
||||
<div class="h-90">
|
||||
{#await import('$lib/components/shared-components/map/map.svelte')}
|
||||
{#await delay(timeToLoadTheMap) then}
|
||||
<!-- show the loading spinner only if loading the map takes too much time -->
|
||||
<div class="flex items-center justify-center h-full w-full">
|
||||
<LoadingSpinner />
|
||||
</div>
|
||||
{/await}
|
||||
{:then { default: Map }}
|
||||
<Map
|
||||
mapMarkers={[
|
||||
{
|
||||
lat: latlng.lat,
|
||||
lon: latlng.lng,
|
||||
id: asset.id,
|
||||
city: asset.exifInfo?.city ?? null,
|
||||
state: asset.exifInfo?.state ?? null,
|
||||
country: asset.exifInfo?.country ?? null,
|
||||
},
|
||||
]}
|
||||
center={latlng}
|
||||
showSettings={false}
|
||||
zoom={12.5}
|
||||
simplified
|
||||
useLocationPin
|
||||
showSimpleControls={!showEditFaces}
|
||||
onOpenInMapView={() => goto(Route.map({ ...latlng, zoom: 12.5 }))}
|
||||
>
|
||||
{#snippet popup({ marker })}
|
||||
{@const { lat, lon } = marker}
|
||||
<div class="flex flex-col items-center gap-1">
|
||||
<p class="font-bold">{lat.toPrecision(6)}, {lon.toPrecision(6)}</p>
|
||||
<a
|
||||
href="https://www.openstreetmap.org/?mlat={lat}&mlon={lon}&zoom=13#map=15/{lat}/{lon}"
|
||||
target="_blank"
|
||||
class="font-medium text-primary underline focus:outline-none"
|
||||
>
|
||||
{$t('open_in_openstreetmap')}
|
||||
</a>
|
||||
</div>
|
||||
{/snippet}
|
||||
</Map>
|
||||
{/await}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if currentAlbum && currentAlbum.albumUsers.length > 0 && asset.owner}
|
||||
<section class="px-6 dark:text-immich-dark-fg mt-4">
|
||||
<Text size="small" color="muted">{$t('shared_by')}</Text>
|
||||
<div class="flex gap-4 pt-4">
|
||||
<div>
|
||||
<UserAvatar user={asset.owner} size="md" />
|
||||
</div>
|
||||
|
||||
<div class="mb-auto mt-auto">
|
||||
<p>
|
||||
{asset.owner.name}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
{#if albums.length > 0}
|
||||
<section class="px-6 py-6 dark:text-immich-dark-fg">
|
||||
<div class="pb-4">
|
||||
<Text size="small" color="muted">{$t('appears_in')}</Text>
|
||||
</div>
|
||||
{#each albums as album (album.id)}
|
||||
<a href={Route.viewAlbum(album)}>
|
||||
<div class="flex gap-4 pt-2 hover:cursor-pointer items-center">
|
||||
<div>
|
||||
<img
|
||||
alt={album.albumName}
|
||||
class="h-12.5 w-12.5 rounded object-cover"
|
||||
src={album.albumThumbnailAssetId &&
|
||||
getAssetMediaUrl({ id: album.albumThumbnailAssetId, size: AssetMediaSize.Preview })}
|
||||
draggable="false"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="mb-auto mt-auto">
|
||||
<p class="dark:text-immich-dark-primary">{album.albumName}</p>
|
||||
<div class="flex flex-col gap-0 text-sm">
|
||||
<div>
|
||||
<AlbumListItemDetails {album} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
{/each}
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
{#if $preferences?.tags?.enabled}
|
||||
<section class="relative px-2 pb-12 dark:bg-immich-dark-bg dark:text-immich-dark-fg">
|
||||
<DetailPanelTags {asset} {isOwner} />
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
{#if showEditFaces}
|
||||
<PersonSidePanel
|
||||
assetId={asset.id}
|
||||
assetType={asset.type}
|
||||
onClose={() => (showEditFaces = false)}
|
||||
onRefresh={handleRefreshPeople}
|
||||
/>
|
||||
{/if}
|
||||
@@ -1,59 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { type DownloadProgress, downloadManager } from '$lib/managers/download-manager.svelte';
|
||||
import { locale } from '$lib/stores/preferences.store';
|
||||
import { Heading, IconButton } from '@immich/ui';
|
||||
import { mdiClose } from '@mdi/js';
|
||||
import { t } from 'svelte-i18n';
|
||||
import { fly, slide } from 'svelte/transition';
|
||||
import { getByteUnitString } from '../../utils/byte-units';
|
||||
|
||||
const abort = (downloadKey: string, download: DownloadProgress) => {
|
||||
download.abort?.abort();
|
||||
downloadManager.clear(downloadKey);
|
||||
};
|
||||
</script>
|
||||
|
||||
{#if downloadManager.isDownloading}
|
||||
<div
|
||||
transition:fly={{ x: -100, duration: 350 }}
|
||||
class="fixed bottom-10 start-2 max-h-67.5 w-79 z-60 rounded-2xl border dark:border-white/10 p-4 shadow-lg bg-subtle"
|
||||
>
|
||||
<Heading size="tiny">{$t('downloading')}</Heading>
|
||||
<div class="my-2 mb-2 flex max-h-50 flex-col overflow-y-auto text-sm">
|
||||
{#each Object.keys(downloadManager.assets) as downloadKey (downloadKey)}
|
||||
{@const download = downloadManager.assets[downloadKey]}
|
||||
<div class="mb-2 flex place-items-center" transition:slide>
|
||||
<div class="w-full pe-10">
|
||||
<div class="flex place-items-center justify-between gap-2 text-xs font-medium">
|
||||
<p class="truncate">{downloadKey}</p>
|
||||
{#if download.total}
|
||||
<p class="whitespace-nowrap">{getByteUnitString(download.total, $locale)}</p>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="flex place-items-center gap-2">
|
||||
<div class="h-2.5 w-full rounded-full bg-neutral-200 dark:bg-neutral-600">
|
||||
<div class="h-2.5 rounded-full bg-primary" style={`width: ${download.percentage}%`}></div>
|
||||
</div>
|
||||
<p class="min-w-16 whitespace-nowrap text-right">
|
||||
<span class="text-primary">
|
||||
{(download.percentage / 100).toLocaleString($locale, { style: 'percent' })}
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="absolute end-4">
|
||||
<IconButton
|
||||
color="secondary"
|
||||
variant="outline"
|
||||
shape="round"
|
||||
aria-label={$t('close')}
|
||||
onclick={() => abort(downloadKey, download)}
|
||||
size="tiny"
|
||||
icon={mdiClose}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -1,86 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { shortcut } from '$lib/actions/shortcut';
|
||||
import { editManager, EditToolType } from '$lib/managers/edit/edit-manager.svelte';
|
||||
import { websocketEvents } from '$lib/stores/websocket';
|
||||
import { getAssetEdits, type AssetResponseDto } from '@immich/sdk';
|
||||
import { Button, HStack, IconButton } from '@immich/ui';
|
||||
import { mdiClose } from '@mdi/js';
|
||||
import { onDestroy, onMount } from 'svelte';
|
||||
import { t } from 'svelte-i18n';
|
||||
|
||||
onMount(() => {
|
||||
return websocketEvents.on('on_asset_update', (assetUpdate) => {
|
||||
if (assetUpdate.id === asset.id) {
|
||||
asset = assetUpdate;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
interface Props {
|
||||
asset: AssetResponseDto;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
onMount(async () => {
|
||||
const edits = await getAssetEdits({ id: asset.id });
|
||||
await editManager.activateTool(EditToolType.Transform, asset, edits);
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
editManager.cleanup();
|
||||
});
|
||||
|
||||
async function applyEdits() {
|
||||
const success = await editManager.applyEdits();
|
||||
|
||||
if (success) {
|
||||
onClose();
|
||||
}
|
||||
}
|
||||
|
||||
async function closeEditor() {
|
||||
if (await editManager.closeConfirm()) {
|
||||
onClose();
|
||||
}
|
||||
}
|
||||
|
||||
let { asset = $bindable(), onClose }: Props = $props();
|
||||
</script>
|
||||
|
||||
<svelte:document use:shortcut={{ shortcut: { key: 'Escape' }, onShortcut: onClose }} />
|
||||
|
||||
<section class="relative flex flex-col h-full p-2 dark:bg-immich-dark-bg dark:text-immich-dark-fg dark pt-3">
|
||||
<HStack class="justify-between me-4">
|
||||
<HStack>
|
||||
<IconButton
|
||||
shape="round"
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
icon={mdiClose}
|
||||
aria-label={$t('close')}
|
||||
onclick={closeEditor}
|
||||
/>
|
||||
<p class="text-lg text-immich-fg dark:text-immich-dark-fg capitalize">{$t('editor')}</p>
|
||||
</HStack>
|
||||
<Button shape="round" size="small" onclick={applyEdits} loading={editManager.isApplyingEdits}>{$t('save')}</Button>
|
||||
</HStack>
|
||||
|
||||
<section>
|
||||
{#if editManager.selectedTool}
|
||||
<editManager.selectedTool.component />
|
||||
{/if}
|
||||
</section>
|
||||
<div class="flex-1"></div>
|
||||
<section class="p-4">
|
||||
<Button
|
||||
variant="outline"
|
||||
onclick={() => editManager.resetAllChanges()}
|
||||
disabled={!editManager.canReset}
|
||||
class="self-start"
|
||||
shape="round"
|
||||
size="small"
|
||||
>
|
||||
{$t('editor_reset_all_changes')}
|
||||
</Button>
|
||||
</section>
|
||||
</section>
|
||||
@@ -1,39 +0,0 @@
|
||||
import { getResizeObserverMock } from '$lib/__mocks__/resize-observer.mock';
|
||||
import CropArea from '$lib/components/asset-viewer/editor/transform-tool/crop-area.svelte';
|
||||
import { transformManager } from '$lib/managers/edit/transform-manager.svelte';
|
||||
import { getAssetMediaUrl } from '$lib/utils';
|
||||
import { assetFactory } from '@test-data/factories/asset-factory';
|
||||
import { render } from '@testing-library/svelte';
|
||||
import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
vi.mock('$lib/utils');
|
||||
|
||||
describe('CropArea', () => {
|
||||
beforeAll(() => {
|
||||
vi.stubGlobal('ResizeObserver', getResizeObserverMock());
|
||||
vi.mocked(getAssetMediaUrl).mockReturnValue('/mock-image.jpg');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
transformManager.reset();
|
||||
});
|
||||
|
||||
it('clears cursor styles on reset', () => {
|
||||
const asset = assetFactory.build();
|
||||
const { getByRole } = render(CropArea, { asset });
|
||||
const cropArea = getByRole('button', { name: 'Crop area' });
|
||||
|
||||
transformManager.region = { x: 100, y: 100, width: 200, height: 200 };
|
||||
transformManager.cropImageSize = { width: 1000, height: 1000 };
|
||||
transformManager.cropImageScale = 1;
|
||||
transformManager.updateCursor(100, 150);
|
||||
|
||||
expect(document.body.style.cursor).toBe('ew-resize');
|
||||
expect(cropArea.style.cursor).toBe('ew-resize');
|
||||
|
||||
transformManager.reset();
|
||||
|
||||
expect(document.body.style.cursor).toBe('');
|
||||
expect(cropArea.style.cursor).toBe('');
|
||||
});
|
||||
});
|
||||
@@ -1,199 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { transformManager } from '$lib/managers/edit/transform-manager.svelte';
|
||||
import { getAssetMediaUrl } from '$lib/utils';
|
||||
import { getAltText } from '$lib/utils/thumbnail-util';
|
||||
import { toTimelineAsset } from '$lib/utils/timeline-util';
|
||||
import { AssetMediaSize, type AssetResponseDto } from '@immich/sdk';
|
||||
|
||||
interface Props {
|
||||
asset: AssetResponseDto;
|
||||
}
|
||||
|
||||
let { asset }: Props = $props();
|
||||
|
||||
let canvasContainer = $state<HTMLElement | null>(null);
|
||||
|
||||
let imageSrc = $derived(
|
||||
getAssetMediaUrl({ id: asset.id, cacheKey: asset.thumbhash, edited: false, size: AssetMediaSize.Preview }),
|
||||
);
|
||||
|
||||
let imageTransform = $derived.by(() => {
|
||||
const transforms: string[] = [];
|
||||
|
||||
if (transformManager.mirrorHorizontal) {
|
||||
transforms.push('scaleX(-1)');
|
||||
}
|
||||
if (transformManager.mirrorVertical) {
|
||||
transforms.push('scaleY(-1)');
|
||||
}
|
||||
|
||||
return transforms.join(' ');
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (!canvasContainer) {
|
||||
return;
|
||||
}
|
||||
|
||||
const resizeObserver = new ResizeObserver(() => {
|
||||
transformManager.resizeCanvas();
|
||||
});
|
||||
|
||||
resizeObserver.observe(canvasContainer);
|
||||
|
||||
return () => {
|
||||
resizeObserver.disconnect();
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="canvas-container" bind:this={canvasContainer}>
|
||||
<button
|
||||
class={`crop-area ${transformManager.orientationChanged ? 'changedOriention' : ''}`}
|
||||
style={`rotate:${transformManager.imageRotation}deg`}
|
||||
bind:this={transformManager.cropAreaEl}
|
||||
onmousedown={(e) => transformManager.handleMouseDown(e)}
|
||||
onmouseup={() => transformManager.handleMouseUp()}
|
||||
aria-label="Crop area"
|
||||
type="button"
|
||||
>
|
||||
<img
|
||||
draggable="false"
|
||||
src={imageSrc}
|
||||
alt={$getAltText(toTimelineAsset(asset))}
|
||||
style={imageTransform ? `transform: ${imageTransform}` : ''}
|
||||
/>
|
||||
<div
|
||||
class={`${transformManager.isInteracting ? 'resizing' : ''} crop-frame`}
|
||||
bind:this={transformManager.cropFrame}
|
||||
>
|
||||
<div class="grid"></div>
|
||||
<div class="corner top-left"></div>
|
||||
<div class="corner top-right"></div>
|
||||
<div class="corner bottom-left"></div>
|
||||
<div class="corner bottom-right"></div>
|
||||
</div>
|
||||
<div
|
||||
class={`${transformManager.isInteracting ? 'light' : ''} overlay`}
|
||||
bind:this={transformManager.overlayEl}
|
||||
></div>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.canvas-container {
|
||||
width: calc(100% - 4rem);
|
||||
margin: auto;
|
||||
margin-top: 2rem;
|
||||
height: calc(100% - 4rem);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.crop-area {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
outline: none;
|
||||
transition: rotate 0.15s ease;
|
||||
max-height: 100%;
|
||||
max-width: 100%;
|
||||
width: max-content;
|
||||
}
|
||||
.crop-area.changedOriention {
|
||||
max-width: 92vh;
|
||||
max-height: calc(100vw - 400px - 1.5rem);
|
||||
}
|
||||
|
||||
.crop-frame.transition {
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
.overlay {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.56);
|
||||
pointer-events: none;
|
||||
transition: background 0.1s;
|
||||
}
|
||||
|
||||
.overlay.light {
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.grid {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
--color: white;
|
||||
--shadow: #00000057;
|
||||
background-image:
|
||||
linear-gradient(var(--color) 1px, transparent 0), linear-gradient(90deg, var(--color) 1px, transparent 0),
|
||||
linear-gradient(var(--shadow) 3px, transparent 0), linear-gradient(90deg, var(--shadow) 3px, transparent 0);
|
||||
background-size: calc(100% / 3) calc(100% / 3);
|
||||
opacity: 0;
|
||||
transition: opacity 0.1s ease;
|
||||
}
|
||||
|
||||
.crop-frame.resizing .grid {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.crop-area img {
|
||||
display: block;
|
||||
max-width: 100%;
|
||||
height: 100%;
|
||||
user-select: none;
|
||||
transition: transform 0.15s ease;
|
||||
}
|
||||
|
||||
.crop-frame {
|
||||
position: absolute;
|
||||
border: 2px solid white;
|
||||
box-sizing: border-box;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.corner {
|
||||
position: absolute;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
--size: 5.2px;
|
||||
--mSize: calc(-0.5 * var(--size));
|
||||
border: var(--size) solid white;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.top-left {
|
||||
top: var(--mSize);
|
||||
left: var(--mSize);
|
||||
border-right: none;
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.top-right {
|
||||
top: var(--mSize);
|
||||
right: var(--mSize);
|
||||
border-left: none;
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.bottom-left {
|
||||
bottom: var(--mSize);
|
||||
left: var(--mSize);
|
||||
border-right: none;
|
||||
border-top: none;
|
||||
}
|
||||
|
||||
.bottom-right {
|
||||
bottom: var(--mSize);
|
||||
right: var(--mSize);
|
||||
border-left: none;
|
||||
border-top: none;
|
||||
}
|
||||
</style>
|
||||
@@ -1,43 +0,0 @@
|
||||
<script lang="ts">
|
||||
import type { CropAspectRatio } from '$lib/managers/edit/transform-manager.svelte';
|
||||
import { Button, Icon, type Color } from '@immich/ui';
|
||||
|
||||
interface Props {
|
||||
size: {
|
||||
icon: string;
|
||||
name: CropAspectRatio;
|
||||
viewBox: string;
|
||||
rotate?: boolean;
|
||||
};
|
||||
selectedSize: CropAspectRatio;
|
||||
rotateHorizontal: boolean;
|
||||
selectType: (size: CropAspectRatio) => void;
|
||||
}
|
||||
|
||||
let { size, selectedSize, rotateHorizontal, selectType }: Props = $props();
|
||||
|
||||
let isSelected = $derived(selectedSize === size.name);
|
||||
let buttonColor = $derived<Color>(isSelected ? 'primary' : 'secondary');
|
||||
|
||||
let rotatedTitle = $derived((title: string, toRotate: boolean) => {
|
||||
let sides = title.split(':');
|
||||
if (toRotate) {
|
||||
sides.reverse();
|
||||
}
|
||||
return sides.join(':');
|
||||
});
|
||||
|
||||
let toRotate = $derived((def: boolean | undefined) => {
|
||||
if (def === false) {
|
||||
return false;
|
||||
}
|
||||
return (def && !rotateHorizontal) || (!def && rotateHorizontal);
|
||||
});
|
||||
</script>
|
||||
|
||||
<li>
|
||||
<Button shape="round" color={buttonColor} class="flex-col gap-1" size="small" onclick={() => selectType(size.name)}>
|
||||
<Icon size="1.75em" icon={size.icon} viewBox={size.viewBox} class={toRotate(size.rotate) ? 'rotate-90' : ''} />
|
||||
<span>{rotatedTitle(size.name, rotateHorizontal)}</span>
|
||||
</Button>
|
||||
</li>
|
||||
@@ -1,150 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { transformManager } from '$lib/managers/edit/transform-manager.svelte';
|
||||
import { Button, HStack, IconButton } from '@immich/ui';
|
||||
import { mdiFlipHorizontal, mdiFlipVertical, mdiRotateLeft, mdiRotateRight } from '@mdi/js';
|
||||
import { t } from 'svelte-i18n';
|
||||
|
||||
interface AspectRatioOption {
|
||||
label: string;
|
||||
value: string;
|
||||
width?: number;
|
||||
height?: number;
|
||||
isFree?: boolean;
|
||||
}
|
||||
|
||||
const aspectRatios: AspectRatioOption[] = [
|
||||
{ label: $t('crop_aspect_ratio_free'), value: 'free', isFree: true },
|
||||
{ label: $t('crop_aspect_ratio_original'), value: 'original', width: 24, height: 18 },
|
||||
{ label: '5:4', value: '5:4', width: 22, height: 18 },
|
||||
{ label: '4:5', value: '4:5', width: 18, height: 22 },
|
||||
{ label: '4:3', value: '4:3', width: 24, height: 18 },
|
||||
{ label: '3:4', value: '3:4', width: 18, height: 24 },
|
||||
{ label: '3:2', value: '3:2', width: 24, height: 16 },
|
||||
{ label: '2:3', value: '2:3', width: 16, height: 24 },
|
||||
{ label: '16:9', value: '16:9', width: 24, height: 14 },
|
||||
{ label: '9:16', value: '9:16', width: 14, height: 24 },
|
||||
{ label: 'Square', value: '1:1', width: 20, height: 20 },
|
||||
];
|
||||
|
||||
let isRotated = $derived(transformManager.normalizedRotation % 180 !== 0);
|
||||
|
||||
function rotatedRatio(ratio: AspectRatioOption): string {
|
||||
if (ratio.value === 'free') {
|
||||
return ratio.value;
|
||||
}
|
||||
|
||||
if (isRotated) {
|
||||
let [width, height] = ratio.value.split(':');
|
||||
return `${height}:${width}`;
|
||||
} else {
|
||||
return ratio.value;
|
||||
}
|
||||
}
|
||||
|
||||
function ratioSelected(ratio: AspectRatioOption): boolean {
|
||||
let currentRatioRotated;
|
||||
if (ratio.value === 'original') {
|
||||
const { width, height } = transformManager.cropImageSize;
|
||||
// Account for rotation when comparing to original
|
||||
if (isRotated) {
|
||||
currentRatioRotated = `${height}:${width}`;
|
||||
}
|
||||
currentRatioRotated = `${width}:${height}`;
|
||||
}
|
||||
currentRatioRotated = rotatedRatio(ratio);
|
||||
|
||||
return transformManager.cropAspectRatio === currentRatioRotated;
|
||||
}
|
||||
|
||||
function selectAspectRatio(ratio: AspectRatioOption) {
|
||||
let appliedRatio;
|
||||
if (ratio.value === 'original') {
|
||||
const { width, height } = transformManager.cropImageSize;
|
||||
appliedRatio = `${width}:${height}`;
|
||||
} else {
|
||||
appliedRatio = rotatedRatio(ratio);
|
||||
}
|
||||
|
||||
transformManager.setAspectRatio(appliedRatio);
|
||||
}
|
||||
|
||||
async function rotateImage(degrees: number) {
|
||||
await transformManager.rotate(degrees);
|
||||
}
|
||||
|
||||
function mirrorImage(axis: 'horizontal' | 'vertical') {
|
||||
transformManager.mirror(axis);
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="mt-3 px-4">
|
||||
<div class="flex h-10 w-full items-center justify-between text-sm mt-2">
|
||||
<h2>{$t('editor_orientation')}</h2>
|
||||
</div>
|
||||
<HStack>
|
||||
<IconButton
|
||||
class="w-full"
|
||||
size="small"
|
||||
aria-label={$t('editor_rotate_left')}
|
||||
icon={mdiRotateLeft}
|
||||
onclick={() => rotateImage(-90)}
|
||||
/>
|
||||
<IconButton
|
||||
class="w-full"
|
||||
size="small"
|
||||
aria-label={$t('editor_rotate_right')}
|
||||
icon={mdiRotateRight}
|
||||
onclick={() => rotateImage(90)}
|
||||
/>
|
||||
<IconButton
|
||||
class="w-full"
|
||||
size="small"
|
||||
aria-label={$t('editor_flip_horizontal')}
|
||||
icon={mdiFlipHorizontal}
|
||||
onclick={() => mirrorImage('horizontal')}
|
||||
/>
|
||||
<IconButton
|
||||
class="w-full"
|
||||
size="small"
|
||||
aria-label={$t('editor_flip_vertical')}
|
||||
icon={mdiFlipVertical}
|
||||
onclick={() => mirrorImage('vertical')}
|
||||
/>
|
||||
</HStack>
|
||||
|
||||
<div class="flex h-10 w-full items-center justify-between text-sm mt-6">
|
||||
<h2>{$t('crop')}</h2>
|
||||
</div>
|
||||
|
||||
<!-- Aspect Ratio Grid -->
|
||||
<div class="grid grid-cols-2 mb-4">
|
||||
{#each aspectRatios as ratio (ratio.value)}
|
||||
<HStack>
|
||||
<Button
|
||||
class="w-14 h-14 m-2"
|
||||
shape="round"
|
||||
onclick={() => selectAspectRatio(ratio)}
|
||||
aria-label={ratio.label}
|
||||
color={ratioSelected(ratio) ? 'primary' : 'secondary'}
|
||||
variant={ratioSelected(ratio) ? 'filled' : 'outline'}
|
||||
>
|
||||
{#if ratio.isFree}
|
||||
<!-- Free crop icon with dashed border -->
|
||||
<div
|
||||
class="w-6 h-6 border-2 border-dashed rounded-xs flex-shrink-0 {ratioSelected(ratio)
|
||||
? 'border-black'
|
||||
: 'border-white'}"
|
||||
></div>
|
||||
{:else}
|
||||
<!-- Aspect ratio box -->
|
||||
<div
|
||||
class="border-2 rounded-xs flex-shrink-0 {ratioSelected(ratio) ? 'border-black' : 'border-white'}"
|
||||
style="width: {ratio.width}px; height: {ratio.height}px;"
|
||||
></div>
|
||||
{/if}
|
||||
</Button>
|
||||
<span class="text-sm text-white text-left">{ratio.label}</span>
|
||||
</HStack>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,358 +0,0 @@
|
||||
<script lang="ts">
|
||||
import ImageThumbnail from '$lib/components/assets/thumbnail/image-thumbnail.svelte';
|
||||
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
|
||||
import { isFaceEditMode } from '$lib/stores/face-edit.svelte';
|
||||
import { getPeopleThumbnailUrl } from '$lib/utils';
|
||||
import { handleError } from '$lib/utils/handle-error';
|
||||
import { createFace, getAllPeople, type PersonResponseDto } from '@immich/sdk';
|
||||
import { Button, Input, modalManager, toastManager } from '@immich/ui';
|
||||
import { Canvas, InteractiveFabricObject, Rect } from 'fabric';
|
||||
import { onMount } from 'svelte';
|
||||
import { t } from 'svelte-i18n';
|
||||
|
||||
interface Props {
|
||||
htmlElement: HTMLImageElement | HTMLVideoElement;
|
||||
containerWidth: number;
|
||||
containerHeight: number;
|
||||
assetId: string;
|
||||
}
|
||||
|
||||
let { htmlElement, containerWidth, containerHeight, assetId }: Props = $props();
|
||||
|
||||
let canvasEl: HTMLCanvasElement | undefined = $state();
|
||||
let canvas: Canvas | undefined = $state();
|
||||
let faceRect: Rect | undefined = $state();
|
||||
let faceSelectorEl: HTMLDivElement | undefined = $state();
|
||||
let page = $state(1);
|
||||
let candidates = $state<PersonResponseDto[]>([]);
|
||||
|
||||
let searchTerm = $state('');
|
||||
|
||||
let filteredCandidates = $derived(
|
||||
searchTerm
|
||||
? candidates.filter((person) => person.name.toLowerCase().includes(searchTerm.toLowerCase()))
|
||||
: candidates,
|
||||
);
|
||||
|
||||
const configureControlStyle = () => {
|
||||
InteractiveFabricObject.ownDefaults = {
|
||||
...InteractiveFabricObject.ownDefaults,
|
||||
cornerStyle: 'circle',
|
||||
cornerColor: 'rgb(153,166,251)',
|
||||
cornerSize: 10,
|
||||
padding: 8,
|
||||
transparentCorners: false,
|
||||
lockRotation: true,
|
||||
hasBorders: true,
|
||||
};
|
||||
};
|
||||
|
||||
const setupCanvas = () => {
|
||||
if (!canvasEl || !htmlElement) {
|
||||
return;
|
||||
}
|
||||
|
||||
canvas = new Canvas(canvasEl);
|
||||
configureControlStyle();
|
||||
|
||||
// eslint-disable-next-line tscompat/tscompat
|
||||
faceRect = new Rect({
|
||||
fill: 'rgba(66,80,175,0.25)',
|
||||
stroke: 'rgb(66,80,175)',
|
||||
strokeWidth: 2,
|
||||
strokeUniform: true,
|
||||
width: 112,
|
||||
height: 112,
|
||||
objectCaching: true,
|
||||
rx: 8,
|
||||
ry: 8,
|
||||
});
|
||||
|
||||
canvas.add(faceRect);
|
||||
canvas.setActiveObject(faceRect);
|
||||
};
|
||||
|
||||
onMount(async () => {
|
||||
setupCanvas();
|
||||
await getPeople();
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
const { actualWidth, actualHeight } = getContainedSize(htmlElement);
|
||||
const offsetArea = {
|
||||
width: (containerWidth - actualWidth) / 2,
|
||||
height: (containerHeight - actualHeight) / 2,
|
||||
};
|
||||
|
||||
const imageBoundingBox = {
|
||||
top: offsetArea.height,
|
||||
left: offsetArea.width,
|
||||
width: containerWidth - offsetArea.width * 2,
|
||||
height: containerHeight - offsetArea.height * 2,
|
||||
};
|
||||
|
||||
if (!canvas) {
|
||||
return;
|
||||
}
|
||||
|
||||
canvas.setDimensions({
|
||||
width: containerWidth,
|
||||
height: containerHeight,
|
||||
});
|
||||
|
||||
if (!faceRect) {
|
||||
return;
|
||||
}
|
||||
|
||||
faceRect.set({
|
||||
top: imageBoundingBox.top + 200,
|
||||
left: imageBoundingBox.left + 200,
|
||||
});
|
||||
|
||||
faceRect.setCoords();
|
||||
positionFaceSelector();
|
||||
});
|
||||
|
||||
const getContainedSize = (
|
||||
img: HTMLImageElement | HTMLVideoElement,
|
||||
): { actualWidth: number; actualHeight: number } => {
|
||||
if (img instanceof HTMLImageElement) {
|
||||
const ratio = img.naturalWidth / img.naturalHeight;
|
||||
let actualWidth = img.height * ratio;
|
||||
let actualHeight = img.height;
|
||||
if (actualWidth > img.width) {
|
||||
actualWidth = img.width;
|
||||
actualHeight = img.width / ratio;
|
||||
}
|
||||
return { actualWidth, actualHeight };
|
||||
} else if (img instanceof HTMLVideoElement) {
|
||||
const ratio = img.videoWidth / img.videoHeight;
|
||||
let actualWidth = img.clientHeight * ratio;
|
||||
let actualHeight = img.clientHeight;
|
||||
if (actualWidth > img.clientWidth) {
|
||||
actualWidth = img.clientWidth;
|
||||
actualHeight = img.clientWidth / ratio;
|
||||
}
|
||||
return { actualWidth, actualHeight };
|
||||
}
|
||||
|
||||
return { actualWidth: 0, actualHeight: 0 };
|
||||
};
|
||||
|
||||
const cancel = () => {
|
||||
isFaceEditMode.value = false;
|
||||
};
|
||||
|
||||
const getPeople = async () => {
|
||||
const { hasNextPage, people, total } = await getAllPeople({ page, size: 1000, withHidden: false });
|
||||
|
||||
if (candidates.length === total) {
|
||||
return;
|
||||
}
|
||||
|
||||
candidates = [...candidates, ...people];
|
||||
|
||||
if (hasNextPage) {
|
||||
page++;
|
||||
}
|
||||
};
|
||||
|
||||
const positionFaceSelector = () => {
|
||||
if (!faceRect || !faceSelectorEl) {
|
||||
return;
|
||||
}
|
||||
|
||||
const rect = faceRect.getBoundingRect();
|
||||
const selectorWidth = faceSelectorEl.offsetWidth;
|
||||
const selectorHeight = faceSelectorEl.offsetHeight;
|
||||
|
||||
const spaceAbove = rect.top;
|
||||
const spaceBelow = containerHeight - (rect.top + rect.height);
|
||||
const spaceLeft = rect.left;
|
||||
const spaceRight = containerWidth - (rect.left + rect.width);
|
||||
|
||||
let top, left;
|
||||
|
||||
if (
|
||||
spaceBelow >= selectorHeight ||
|
||||
(spaceBelow >= spaceAbove && spaceBelow >= spaceLeft && spaceBelow >= spaceRight)
|
||||
) {
|
||||
top = rect.top + rect.height + 15;
|
||||
left = rect.left;
|
||||
} else if (
|
||||
spaceAbove >= selectorHeight ||
|
||||
(spaceAbove >= spaceBelow && spaceAbove >= spaceLeft && spaceAbove >= spaceRight)
|
||||
) {
|
||||
top = rect.top - selectorHeight - 15;
|
||||
left = rect.left;
|
||||
} else if (
|
||||
spaceRight >= selectorWidth ||
|
||||
(spaceRight >= spaceLeft && spaceRight >= spaceAbove && spaceRight >= spaceBelow)
|
||||
) {
|
||||
top = rect.top;
|
||||
left = rect.left + rect.width + 15;
|
||||
} else {
|
||||
top = rect.top;
|
||||
left = rect.left - selectorWidth - 15;
|
||||
}
|
||||
|
||||
if (left + selectorWidth > containerWidth) {
|
||||
left = containerWidth - selectorWidth - 15;
|
||||
}
|
||||
|
||||
if (left < 0) {
|
||||
left = 15;
|
||||
}
|
||||
|
||||
if (top + selectorHeight > containerHeight) {
|
||||
top = containerHeight - selectorHeight - 15;
|
||||
}
|
||||
|
||||
if (top < 0) {
|
||||
top = 15;
|
||||
}
|
||||
|
||||
faceSelectorEl.style.top = `${top}px`;
|
||||
faceSelectorEl.style.left = `${left}px`;
|
||||
};
|
||||
|
||||
$effect(() => {
|
||||
if (faceRect) {
|
||||
faceRect.on('moving', positionFaceSelector);
|
||||
faceRect.on('scaling', positionFaceSelector);
|
||||
}
|
||||
});
|
||||
|
||||
const getFaceCroppedCoordinates = () => {
|
||||
if (!faceRect || !htmlElement) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { left, top, width, height } = faceRect.getBoundingRect();
|
||||
const { actualWidth, actualHeight } = getContainedSize(htmlElement);
|
||||
|
||||
const offsetArea = {
|
||||
width: (containerWidth - actualWidth) / 2,
|
||||
height: (containerHeight - actualHeight) / 2,
|
||||
};
|
||||
|
||||
const x1Coeff = (left - offsetArea.width) / actualWidth;
|
||||
const y1Coeff = (top - offsetArea.height) / actualHeight;
|
||||
const x2Coeff = (left + width - offsetArea.width) / actualWidth;
|
||||
const y2Coeff = (top + height - offsetArea.height) / actualHeight;
|
||||
|
||||
// transpose to the natural image location
|
||||
if (htmlElement instanceof HTMLImageElement) {
|
||||
const x1 = x1Coeff * htmlElement.naturalWidth;
|
||||
const y1 = y1Coeff * htmlElement.naturalHeight;
|
||||
const x2 = x2Coeff * htmlElement.naturalWidth;
|
||||
const y2 = y2Coeff * htmlElement.naturalHeight;
|
||||
|
||||
return {
|
||||
imageWidth: htmlElement.naturalWidth,
|
||||
imageHeight: htmlElement.naturalHeight,
|
||||
x: Math.floor(x1),
|
||||
y: Math.floor(y1),
|
||||
width: Math.floor(x2 - x1),
|
||||
height: Math.floor(y2 - y1),
|
||||
};
|
||||
} else if (htmlElement instanceof HTMLVideoElement) {
|
||||
const x1 = x1Coeff * htmlElement.videoWidth;
|
||||
const y1 = y1Coeff * htmlElement.videoHeight;
|
||||
const x2 = x2Coeff * htmlElement.videoWidth;
|
||||
const y2 = y2Coeff * htmlElement.videoHeight;
|
||||
|
||||
return {
|
||||
imageWidth: htmlElement.videoWidth,
|
||||
imageHeight: htmlElement.videoHeight,
|
||||
x: Math.floor(x1),
|
||||
y: Math.floor(y1),
|
||||
width: Math.floor(x2 - x1),
|
||||
height: Math.floor(y2 - y1),
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const tagFace = async (person: PersonResponseDto) => {
|
||||
try {
|
||||
const data = getFaceCroppedCoordinates();
|
||||
if (!data) {
|
||||
toastManager.warning($t('error_tag_face_bounding_box'));
|
||||
return;
|
||||
}
|
||||
|
||||
const isConfirmed = await modalManager.showDialog({
|
||||
prompt: person.name
|
||||
? $t('confirm_tag_face', { values: { name: person.name } })
|
||||
: $t('confirm_tag_face_unnamed'),
|
||||
});
|
||||
|
||||
if (!isConfirmed) {
|
||||
return;
|
||||
}
|
||||
|
||||
await createFace({
|
||||
assetFaceCreateDto: {
|
||||
assetId,
|
||||
personId: person.id,
|
||||
...data,
|
||||
},
|
||||
});
|
||||
|
||||
await assetViewingStore.setAssetId(assetId);
|
||||
} catch (error) {
|
||||
handleError(error, 'Error tagging face');
|
||||
} finally {
|
||||
isFaceEditMode.value = false;
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<div class="absolute start-0 top-0">
|
||||
<canvas bind:this={canvasEl} id="face-editor" class="absolute top-0 start-0"></canvas>
|
||||
|
||||
<div
|
||||
id="face-selector"
|
||||
bind:this={faceSelectorEl}
|
||||
class="absolute top-[calc(50%-250px)] start-[calc(50%-125px)] max-w-[250px] w-[250px] bg-white dark:bg-immich-dark-gray dark:text-immich-dark-fg backdrop-blur-sm px-2 py-4 rounded-xl border border-gray-200 dark:border-gray-800"
|
||||
>
|
||||
<p class="text-center text-sm">{$t('select_person_to_tag')}</p>
|
||||
|
||||
<div class="my-3 relative">
|
||||
<Input placeholder={$t('search_people')} bind:value={searchTerm} size="tiny" />
|
||||
</div>
|
||||
|
||||
<div class="h-62.5 overflow-y-auto mt-2">
|
||||
{#if filteredCandidates.length > 0}
|
||||
<div class="mt-2 rounded-lg">
|
||||
{#each filteredCandidates as person (person.id)}
|
||||
<button
|
||||
onclick={() => tagFace(person)}
|
||||
type="button"
|
||||
class="w-full flex place-items-center gap-2 rounded-lg ps-1 pe-4 py-2 hover:bg-immich-primary/25"
|
||||
>
|
||||
<ImageThumbnail
|
||||
curve
|
||||
shadow
|
||||
url={getPeopleThumbnailUrl(person)}
|
||||
altText={person.name}
|
||||
title={person.name}
|
||||
widthStyle="30px"
|
||||
heightStyle="30px"
|
||||
/>
|
||||
<p class="text-sm">
|
||||
{person.name}
|
||||
</p>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{:else}
|
||||
<div class="flex items-center justify-center py-4">
|
||||
<p class="text-sm text-gray-500">{$t('no_people_found')}</p>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<Button size="small" fullWidth onclick={cancel} color="danger" class="mt-2">{$t('cancel')}</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,29 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { authManager } from '$lib/managers/auth-manager.svelte';
|
||||
import { getAssetUrl } from '$lib/utils';
|
||||
import { AssetMediaSize, viewAsset, type AssetResponseDto } from '@immich/sdk';
|
||||
import { LoadingSpinner } from '@immich/ui';
|
||||
import { t } from 'svelte-i18n';
|
||||
import { fade } from 'svelte/transition';
|
||||
|
||||
type Props = {
|
||||
asset: AssetResponseDto;
|
||||
};
|
||||
|
||||
let { asset }: Props = $props();
|
||||
|
||||
const loadAssetData = async (id: string) => {
|
||||
const data = await viewAsset({ ...authManager.params, id, size: AssetMediaSize.Preview });
|
||||
return URL.createObjectURL(data);
|
||||
};
|
||||
</script>
|
||||
|
||||
<div transition:fade={{ duration: 150 }} class="flex h-full select-none place-content-center place-items-center">
|
||||
{#await Promise.all([loadAssetData(asset.id), import('./photo-sphere-viewer-adapter.svelte')])}
|
||||
<LoadingSpinner />
|
||||
{:then [data, { default: PhotoSphereViewer }]}
|
||||
<PhotoSphereViewer panorama={data} originalPanorama={getAssetUrl({ asset, forceOriginal: true })} />
|
||||
{:catch}
|
||||
{$t('errors.failed_to_load_asset')}
|
||||
{/await}
|
||||
</div>
|
||||
@@ -1,20 +0,0 @@
|
||||
<script lang="ts">
|
||||
import type { Snippet } from 'svelte';
|
||||
|
||||
interface Props {
|
||||
onClick: (e: MouseEvent) => void;
|
||||
label: string;
|
||||
children?: Snippet;
|
||||
}
|
||||
|
||||
let { onClick, label, children }: Props = $props();
|
||||
</script>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="my-auto mx-4 rounded-full p-3 text-gray-500 transition hover:bg-gray-500 hover:text-white"
|
||||
aria-label={label}
|
||||
onclick={onClick}
|
||||
>
|
||||
{@render children?.()}
|
||||
</button>
|
||||
@@ -1,36 +0,0 @@
|
||||
<script lang="ts">
|
||||
import type { OcrBox } from '$lib/utils/ocr-utils';
|
||||
import { calculateBoundingBoxDimensions } from '$lib/utils/ocr-utils';
|
||||
|
||||
type Props = {
|
||||
ocrBox: OcrBox;
|
||||
};
|
||||
|
||||
let { ocrBox }: Props = $props();
|
||||
|
||||
const dimensions = $derived(calculateBoundingBoxDimensions(ocrBox.points));
|
||||
|
||||
const transform = $derived(
|
||||
`translate(${dimensions.minX}px, ${dimensions.minY}px) rotate(${dimensions.rotation}deg) skew(${dimensions.skewX}deg, ${dimensions.skewY}deg)`,
|
||||
);
|
||||
|
||||
const transformOrigin = $derived(
|
||||
`${dimensions.centerX - dimensions.minX}px ${dimensions.centerY - dimensions.minY}px`,
|
||||
);
|
||||
</script>
|
||||
|
||||
<div class="absolute group left-0 top-0 pointer-events-none">
|
||||
<!-- Bounding box with CSS transforms -->
|
||||
<div
|
||||
class="absolute border-2 border-blue-500 bg-blue-500/10 cursor-pointer pointer-events-auto transition-all group-hover:bg-blue-500/30 group-hover:border-blue-600 group-hover:border-[3px]"
|
||||
style="width: {dimensions.width}px; height: {dimensions.height}px; transform: {transform}; transform-origin: {transformOrigin};"
|
||||
></div>
|
||||
|
||||
<!-- Text overlay - always rendered but invisible, allows text selection and copy -->
|
||||
<div
|
||||
class="absolute flex items-center justify-center text-transparent text-sm px-2 py-1 pointer-events-auto cursor-text whitespace-pre-wrap wrap-break-word select-text group-hover:text-white group-hover:bg-black/75 group-hover:z-10"
|
||||
style="width: {dimensions.width}px; height: {dimensions.height}px; transform: {transform}; transform-origin: {transformOrigin};"
|
||||
>
|
||||
{ocrBox.text}
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,17 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { ocrManager } from '$lib/stores/ocr.svelte';
|
||||
import { IconButton } from '@immich/ui';
|
||||
import { mdiTextRecognition } from '@mdi/js';
|
||||
import { t } from 'svelte-i18n';
|
||||
</script>
|
||||
|
||||
<IconButton
|
||||
title={ocrManager.showOverlay ? $t('hide_text_recognition') : $t('show_text_recognition')}
|
||||
icon={mdiTextRecognition}
|
||||
class={"dark {ocrStore.showOverlay ? 'bg-immich-primary text-white dark' : 'dark'}"}
|
||||
color="secondary"
|
||||
variant="ghost"
|
||||
shape="round"
|
||||
aria-label={$t('text_recognition')}
|
||||
onclick={() => ocrManager.toggleOcrBoundingBox()}
|
||||
/>
|
||||
@@ -1,178 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { shortcuts } from '$lib/actions/shortcut';
|
||||
import AssetViewerEvents from '$lib/components/AssetViewerEvents.svelte';
|
||||
import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte';
|
||||
import { boundingBoxesArray, type Faces } from '$lib/stores/people.store';
|
||||
import { alwaysLoadOriginalFile } from '$lib/stores/preferences.store';
|
||||
import {
|
||||
EquirectangularAdapter,
|
||||
Viewer,
|
||||
events,
|
||||
type AdapterConstructor,
|
||||
type PluginConstructor,
|
||||
} from '@photo-sphere-viewer/core';
|
||||
import '@photo-sphere-viewer/core/index.css';
|
||||
import { MarkersPlugin } from '@photo-sphere-viewer/markers-plugin';
|
||||
import '@photo-sphere-viewer/markers-plugin/index.css';
|
||||
import { ResolutionPlugin } from '@photo-sphere-viewer/resolution-plugin';
|
||||
import { SettingsPlugin } from '@photo-sphere-viewer/settings-plugin';
|
||||
import '@photo-sphere-viewer/settings-plugin/index.css';
|
||||
import { onDestroy, onMount } from 'svelte';
|
||||
|
||||
// Adapted as well as possible from classlist 'border-solid border-white border-3 rounded-lg'
|
||||
const FACE_BOX_SVG_STYLE = {
|
||||
fill: 'rgba(0, 0, 0, 0)',
|
||||
stroke: '#ffffff',
|
||||
strokeWidth: '3px',
|
||||
strokeLinejoin: 'round',
|
||||
};
|
||||
|
||||
type Props = {
|
||||
panorama: string | { source: string };
|
||||
originalPanorama?: string | { source: string };
|
||||
adapter?: AdapterConstructor | [AdapterConstructor, unknown];
|
||||
plugins?: (PluginConstructor | [PluginConstructor, unknown])[];
|
||||
navbar?: boolean;
|
||||
};
|
||||
|
||||
let { panorama, originalPanorama, adapter = EquirectangularAdapter, plugins = [], navbar = false }: Props = $props();
|
||||
|
||||
let container: HTMLDivElement | undefined = $state();
|
||||
let viewer: Viewer;
|
||||
|
||||
let animationInProgress: { cancel: () => void } | undefined;
|
||||
let previousFaces: Faces[] = [];
|
||||
|
||||
const boundingBoxesUnsubscribe = boundingBoxesArray.subscribe((faces: Faces[]) => {
|
||||
// Debounce; don't do anything when the data didn't actually change.
|
||||
if (faces === previousFaces) {
|
||||
return;
|
||||
}
|
||||
previousFaces = faces;
|
||||
|
||||
if (animationInProgress) {
|
||||
animationInProgress.cancel();
|
||||
animationInProgress = undefined;
|
||||
}
|
||||
if (!viewer || !viewer.state.textureData || !viewer.getPlugin(MarkersPlugin)) {
|
||||
return;
|
||||
}
|
||||
const markersPlugin = viewer.getPlugin<MarkersPlugin>(MarkersPlugin);
|
||||
|
||||
// croppedWidth is the size of the texture, which might be cropped to be less than 360/180 degrees.
|
||||
// This is what we want because the facial recognition is done on the image, not the sphere.
|
||||
const currentTextureWidth = viewer.state.textureData.panoData.croppedWidth;
|
||||
|
||||
markersPlugin.clearMarkers();
|
||||
for (const [index, face] of faces.entries()) {
|
||||
const { boundingBoxX1: x1, boundingBoxY1: y1, boundingBoxX2: x2, boundingBoxY2: y2 } = face;
|
||||
const ratio = currentTextureWidth / face.imageWidth;
|
||||
// Pixel values are translated to spherical coordinates and only then added to the panorama;
|
||||
// no need to recalculate when the texture image changes to the original size.
|
||||
markersPlugin.addMarker({
|
||||
id: `face_${index}`,
|
||||
polygonPixels: [
|
||||
[x1 * ratio, y1 * ratio],
|
||||
[x2 * ratio, y1 * ratio],
|
||||
[x2 * ratio, y2 * ratio],
|
||||
[x1 * ratio, y2 * ratio],
|
||||
],
|
||||
svgStyle: FACE_BOX_SVG_STYLE,
|
||||
});
|
||||
}
|
||||
|
||||
// Smoothly pan to the highlighted (hovered-over) face.
|
||||
if (faces.length === 1) {
|
||||
const { boundingBoxX1: x1, boundingBoxY1: y1, boundingBoxX2: x2, boundingBoxY2: y2, imageWidth: w } = faces[0];
|
||||
const ratio = currentTextureWidth / w;
|
||||
const x = ((x1 + x2) * ratio) / 2;
|
||||
const y = ((y1 + y2) * ratio) / 2;
|
||||
animationInProgress = viewer.animate({
|
||||
textureX: x,
|
||||
textureY: y,
|
||||
zoom: Math.min(viewer.getZoomLevel(), 75),
|
||||
speed: 500, // duration in ms
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const onZoom = () => {
|
||||
viewer?.animate({ zoom: assetViewerManager.zoom > 1 ? 50 : 83.3, speed: 250 });
|
||||
};
|
||||
|
||||
let hasChangedResolution: boolean = false;
|
||||
onMount(() => {
|
||||
if (!container) {
|
||||
return;
|
||||
}
|
||||
|
||||
viewer = new Viewer({
|
||||
adapter,
|
||||
plugins: [
|
||||
MarkersPlugin,
|
||||
SettingsPlugin,
|
||||
[
|
||||
ResolutionPlugin,
|
||||
{
|
||||
defaultResolution: $alwaysLoadOriginalFile && originalPanorama ? 'original' : 'default',
|
||||
resolutions: [
|
||||
{
|
||||
id: 'default',
|
||||
label: 'Default',
|
||||
panorama,
|
||||
},
|
||||
...(originalPanorama
|
||||
? [
|
||||
{
|
||||
id: 'original',
|
||||
label: 'Original',
|
||||
panorama: originalPanorama,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
],
|
||||
},
|
||||
],
|
||||
...plugins,
|
||||
],
|
||||
container,
|
||||
touchmoveTwoFingers: false,
|
||||
mousewheelCtrlKey: false,
|
||||
navbar,
|
||||
minFov: 15,
|
||||
maxFov: 90,
|
||||
zoomSpeed: 0.5,
|
||||
fisheye: false,
|
||||
});
|
||||
const resolutionPlugin = viewer.getPlugin<ResolutionPlugin>(ResolutionPlugin);
|
||||
const zoomHandler = ({ zoomLevel }: events.ZoomUpdatedEvent) => {
|
||||
// zoomLevel is 0-100
|
||||
assetViewerManager.zoom = zoomLevel / 50;
|
||||
|
||||
if (Math.round(zoomLevel) >= 75 && !hasChangedResolution) {
|
||||
// Replace the preview with the original
|
||||
void resolutionPlugin.setResolution('original');
|
||||
hasChangedResolution = true;
|
||||
}
|
||||
};
|
||||
|
||||
if (originalPanorama && !$alwaysLoadOriginalFile) {
|
||||
viewer.addEventListener(events.ZoomUpdatedEvent.type, zoomHandler, { passive: true });
|
||||
}
|
||||
|
||||
return () => viewer.removeEventListener(events.ZoomUpdatedEvent.type, zoomHandler);
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
if (viewer) {
|
||||
viewer.destroy();
|
||||
}
|
||||
boundingBoxesUnsubscribe();
|
||||
assetViewerManager.zoom = 1;
|
||||
});
|
||||
</script>
|
||||
|
||||
<AssetViewerEvents {onZoom} />
|
||||
|
||||
<svelte:document use:shortcuts={[{ shortcut: { key: 'z' }, onShortcut: onZoom, preventDefault: true }]} />
|
||||
<div class="h-full w-full mb-0" bind:this={container}></div>
|
||||
@@ -1,262 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { shortcuts } from '$lib/actions/shortcut';
|
||||
import { zoomImageAction } from '$lib/actions/zoom-image';
|
||||
import FaceEditor from '$lib/components/asset-viewer/face-editor/face-editor.svelte';
|
||||
import OcrBoundingBox from '$lib/components/asset-viewer/ocr-bounding-box.svelte';
|
||||
import BrokenAsset from '$lib/components/assets/broken-asset.svelte';
|
||||
import AssetViewerEvents from '$lib/components/AssetViewerEvents.svelte';
|
||||
import { assetViewerFadeDuration } from '$lib/constants';
|
||||
import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte';
|
||||
import { castManager } from '$lib/managers/cast-manager.svelte';
|
||||
import { imageManager } from '$lib/managers/ImageManager.svelte';
|
||||
import { isFaceEditMode } from '$lib/stores/face-edit.svelte';
|
||||
import { ocrManager } from '$lib/stores/ocr.svelte';
|
||||
import { boundingBoxesArray } from '$lib/stores/people.store';
|
||||
import { SlideshowLook, SlideshowState, slideshowLookCssMapping, slideshowStore } from '$lib/stores/slideshow.store';
|
||||
import { getAssetUrl, targetImageSize as getTargetImageSize, handlePromiseError } from '$lib/utils';
|
||||
import { canCopyImageToClipboard, copyImageToClipboard } from '$lib/utils/asset-utils';
|
||||
import { handleError } from '$lib/utils/handle-error';
|
||||
import { getOcrBoundingBoxes } from '$lib/utils/ocr-utils';
|
||||
import { getBoundingBox } from '$lib/utils/people-utils';
|
||||
import { getAltText } from '$lib/utils/thumbnail-util';
|
||||
import { toTimelineAsset } from '$lib/utils/timeline-util';
|
||||
import { AssetMediaSize, type SharedLinkResponseDto } from '@immich/sdk';
|
||||
import { LoadingSpinner, toastManager } from '@immich/ui';
|
||||
import { onDestroy, untrack } from 'svelte';
|
||||
import { useSwipe, type SwipeCustomEvent } from 'svelte-gestures';
|
||||
import { t } from 'svelte-i18n';
|
||||
import { fade } from 'svelte/transition';
|
||||
import type { AssetCursor } from './asset-viewer.svelte';
|
||||
|
||||
interface Props {
|
||||
cursor: AssetCursor;
|
||||
element?: HTMLDivElement | undefined;
|
||||
haveFadeTransition?: boolean;
|
||||
sharedLink?: SharedLinkResponseDto | undefined;
|
||||
onPreviousAsset?: (() => void) | null;
|
||||
onNextAsset?: (() => void) | null;
|
||||
}
|
||||
|
||||
let {
|
||||
cursor,
|
||||
element = $bindable(),
|
||||
haveFadeTransition = true,
|
||||
sharedLink = undefined,
|
||||
onPreviousAsset = null,
|
||||
onNextAsset = null,
|
||||
}: Props = $props();
|
||||
|
||||
const { slideshowState, slideshowLook } = slideshowStore;
|
||||
const asset = $derived(cursor.current);
|
||||
|
||||
let imageLoaded: boolean = $state(false);
|
||||
let originalImageLoaded: boolean = $state(false);
|
||||
let imageError: boolean = $state(false);
|
||||
|
||||
let loader = $state<HTMLImageElement>();
|
||||
|
||||
$effect.pre(() => {
|
||||
void asset.id;
|
||||
untrack(() => assetViewerManager.resetZoomState());
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
$boundingBoxesArray = [];
|
||||
});
|
||||
|
||||
let ocrBoxes = $derived(
|
||||
ocrManager.showOverlay && assetViewerManager.imgRef
|
||||
? getOcrBoundingBoxes(ocrManager.data, assetViewerManager.zoomState, assetViewerManager.imgRef)
|
||||
: [],
|
||||
);
|
||||
|
||||
let isOcrActive = $derived(ocrManager.showOverlay);
|
||||
|
||||
const onCopy = async () => {
|
||||
if (!canCopyImageToClipboard() || !assetViewerManager.imgRef) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await copyImageToClipboard(assetViewerManager.imgRef);
|
||||
toastManager.info($t('copied_image_to_clipboard'));
|
||||
} catch (error) {
|
||||
handleError(error, $t('copy_error'));
|
||||
}
|
||||
};
|
||||
|
||||
const onZoom = () => {
|
||||
assetViewerManager.zoom = assetViewerManager.zoom > 1 ? 1 : 2;
|
||||
};
|
||||
|
||||
const onPlaySlideshow = () => ($slideshowState = SlideshowState.PlaySlideshow);
|
||||
|
||||
$effect(() => {
|
||||
if (isFaceEditMode.value && assetViewerManager.zoom > 1) {
|
||||
onZoom();
|
||||
}
|
||||
});
|
||||
|
||||
// TODO move to action + command palette
|
||||
const onCopyShortcut = (event: KeyboardEvent) => {
|
||||
if (globalThis.getSelection()?.type === 'Range') {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
|
||||
handlePromiseError(onCopy());
|
||||
};
|
||||
|
||||
const onSwipe = (event: SwipeCustomEvent) => {
|
||||
if (assetViewerManager.zoom > 1) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (ocrManager.showOverlay) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (onNextAsset && event.detail.direction === 'left') {
|
||||
onNextAsset();
|
||||
}
|
||||
|
||||
if (onPreviousAsset && event.detail.direction === 'right') {
|
||||
onPreviousAsset();
|
||||
}
|
||||
};
|
||||
|
||||
const targetImageSize = $derived(getTargetImageSize(asset, originalImageLoaded || assetViewerManager.zoom > 1));
|
||||
|
||||
$effect(() => {
|
||||
if (imageLoaderUrl) {
|
||||
void cast(imageLoaderUrl);
|
||||
}
|
||||
});
|
||||
|
||||
const cast = async (url: string) => {
|
||||
if (!url || !castManager.isCasting) {
|
||||
return;
|
||||
}
|
||||
const fullUrl = new URL(url, globalThis.location.href);
|
||||
|
||||
try {
|
||||
await castManager.loadMedia(fullUrl.href);
|
||||
} catch (error) {
|
||||
handleError(error, 'Unable to cast');
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
const onload = () => {
|
||||
imageLoaded = true;
|
||||
originalImageLoaded = targetImageSize === AssetMediaSize.Fullsize || targetImageSize === 'original';
|
||||
};
|
||||
|
||||
const onerror = () => {
|
||||
imageError = imageLoaded = true;
|
||||
};
|
||||
|
||||
onDestroy(() => imageManager.cancelPreloadUrl(imageLoaderUrl));
|
||||
|
||||
let imageLoaderUrl = $derived(
|
||||
getAssetUrl({ asset, sharedLink, forceOriginal: originalImageLoaded || assetViewerManager.zoom > 1 }),
|
||||
);
|
||||
|
||||
let containerWidth = $state(0);
|
||||
let containerHeight = $state(0);
|
||||
|
||||
let lastUrl: string | undefined;
|
||||
|
||||
$effect(() => {
|
||||
if (lastUrl && lastUrl !== imageLoaderUrl) {
|
||||
untrack(() => {
|
||||
imageLoaded = false;
|
||||
originalImageLoaded = false;
|
||||
imageError = false;
|
||||
});
|
||||
}
|
||||
lastUrl = imageLoaderUrl;
|
||||
});
|
||||
</script>
|
||||
|
||||
<AssetViewerEvents {onCopy} {onZoom} />
|
||||
|
||||
<svelte:document
|
||||
use:shortcuts={[
|
||||
{ shortcut: { key: 'z' }, onShortcut: onZoom, preventDefault: true },
|
||||
{ shortcut: { key: 's' }, onShortcut: onPlaySlideshow, preventDefault: true },
|
||||
{ shortcut: { key: 'c', ctrl: true }, onShortcut: onCopyShortcut, preventDefault: false },
|
||||
{ shortcut: { key: 'c', meta: true }, onShortcut: onCopyShortcut, preventDefault: false },
|
||||
]}
|
||||
/>
|
||||
{#if imageError}
|
||||
<div id="broken-asset" class="h-full w-full">
|
||||
<BrokenAsset class="text-xl h-full w-full" />
|
||||
</div>
|
||||
{/if}
|
||||
<img bind:this={loader} style="display:none" src={imageLoaderUrl} alt="" aria-hidden="true" {onload} {onerror} />
|
||||
<div
|
||||
bind:this={element}
|
||||
class="relative h-full w-full select-none"
|
||||
bind:clientWidth={containerWidth}
|
||||
bind:clientHeight={containerHeight}
|
||||
>
|
||||
{#if !imageLoaded}
|
||||
<div id="spinner" class="flex h-full items-center justify-center">
|
||||
<LoadingSpinner />
|
||||
</div>
|
||||
{:else if !imageError}
|
||||
<div
|
||||
use:zoomImageAction={{ disabled: isOcrActive }}
|
||||
{...useSwipe(onSwipe)}
|
||||
class="h-full w-full"
|
||||
transition:fade={{ duration: haveFadeTransition ? assetViewerFadeDuration : 0 }}
|
||||
>
|
||||
{#if $slideshowState !== SlideshowState.None && $slideshowLook === SlideshowLook.BlurredBackground}
|
||||
<img
|
||||
src={imageLoaderUrl}
|
||||
alt=""
|
||||
class="-z-1 absolute top-0 start-0 object-cover h-full w-full blur-lg"
|
||||
draggable="false"
|
||||
/>
|
||||
{/if}
|
||||
<img
|
||||
bind:this={assetViewerManager.imgRef}
|
||||
src={imageLoaderUrl}
|
||||
alt={$getAltText(toTimelineAsset(asset))}
|
||||
class="h-full w-full {$slideshowState === SlideshowState.None
|
||||
? 'object-contain'
|
||||
: slideshowLookCssMapping[$slideshowLook]}"
|
||||
draggable="false"
|
||||
/>
|
||||
<!-- eslint-disable-next-line svelte/require-each-key -->
|
||||
{#each getBoundingBox($boundingBoxesArray, assetViewerManager.zoomState, assetViewerManager.imgRef) as boundingbox}
|
||||
<div
|
||||
class="absolute border-solid border-white border-3 rounded-lg"
|
||||
style="top: {boundingbox.top}px; left: {boundingbox.left}px; height: {boundingbox.height}px; width: {boundingbox.width}px;"
|
||||
></div>
|
||||
{/each}
|
||||
|
||||
{#each ocrBoxes as ocrBox (ocrBox.id)}
|
||||
<OcrBoundingBox {ocrBox} />
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
{#if isFaceEditMode.value}
|
||||
<FaceEditor htmlElement={assetViewerManager.imgRef} {containerWidth} {containerHeight} assetId={asset.id} />
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
@keyframes delayedVisibility {
|
||||
to {
|
||||
visibility: visible;
|
||||
}
|
||||
}
|
||||
#broken-asset,
|
||||
#spinner {
|
||||
visibility: hidden;
|
||||
animation: 0s linear 0.4s forwards delayedVisibility;
|
||||
}
|
||||
</style>
|
||||
@@ -1,244 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { shortcuts, type ShortcutOptions } from '$lib/actions/shortcut';
|
||||
import ProgressBar from '$lib/components/shared-components/progress-bar/progress-bar.svelte';
|
||||
import { ProgressBarStatus } from '$lib/constants';
|
||||
import SlideshowSettingsModal from '$lib/modals/SlideshowSettingsModal.svelte';
|
||||
import { SlideshowNavigation, slideshowStore } from '$lib/stores/slideshow.store';
|
||||
import { AssetTypeEnum } from '@immich/sdk';
|
||||
import { IconButton, modalManager } from '@immich/ui';
|
||||
import { mdiChevronLeft, mdiChevronRight, mdiClose, mdiCog, mdiFullscreen, mdiPause, mdiPlay } from '@mdi/js';
|
||||
import { onDestroy, onMount } from 'svelte';
|
||||
import { useSwipe } from 'svelte-gestures';
|
||||
import { t } from 'svelte-i18n';
|
||||
import { fly } from 'svelte/transition';
|
||||
|
||||
interface Props {
|
||||
isFullScreen: boolean;
|
||||
assetType: AssetTypeEnum;
|
||||
onNext?: () => void;
|
||||
onPrevious?: () => void;
|
||||
onClose?: () => void;
|
||||
onSetToFullScreen?: () => void;
|
||||
}
|
||||
|
||||
let {
|
||||
isFullScreen,
|
||||
assetType,
|
||||
onNext = () => {},
|
||||
onPrevious = () => {},
|
||||
onClose = () => {},
|
||||
onSetToFullScreen = () => {},
|
||||
}: Props = $props();
|
||||
|
||||
const { restartProgress, stopProgress, slideshowDelay, showProgressBar, slideshowNavigation, slideshowAutoplay } =
|
||||
slideshowStore;
|
||||
|
||||
let progressBarStatus: ProgressBarStatus | undefined = $state();
|
||||
let progressBar = $state<ReturnType<typeof ProgressBar>>();
|
||||
let showControls = $state(true);
|
||||
let timer: NodeJS.Timeout;
|
||||
let isOverControls = $state(false);
|
||||
const isVideoSlide = $derived(assetType === AssetTypeEnum.Video);
|
||||
|
||||
let unsubscribeRestart: () => void;
|
||||
let unsubscribeStop: () => void;
|
||||
|
||||
const setCursorStyle = (style: string) => {
|
||||
document.body.style.cursor = style;
|
||||
};
|
||||
|
||||
const stopControlsHideTimer = () => {
|
||||
clearTimeout(timer);
|
||||
setCursorStyle('');
|
||||
};
|
||||
|
||||
const showControlBar = () => {
|
||||
showControls = true;
|
||||
stopControlsHideTimer();
|
||||
hideControlsAfterDelay();
|
||||
};
|
||||
|
||||
const hideControlsAfterDelay = () => {
|
||||
timer = setTimeout(() => {
|
||||
if (!isOverControls) {
|
||||
showControls = false;
|
||||
setCursorStyle('none');
|
||||
}
|
||||
}, 2500);
|
||||
};
|
||||
|
||||
onMount(() => {
|
||||
hideControlsAfterDelay();
|
||||
unsubscribeRestart = restartProgress.subscribe((value) => {
|
||||
if (value) {
|
||||
progressBar?.restart();
|
||||
}
|
||||
});
|
||||
|
||||
unsubscribeStop = stopProgress.subscribe((value) => {
|
||||
if (value) {
|
||||
progressBar?.restart();
|
||||
stopControlsHideTimer();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
if (unsubscribeRestart) {
|
||||
unsubscribeRestart();
|
||||
}
|
||||
|
||||
if (unsubscribeStop) {
|
||||
unsubscribeStop();
|
||||
}
|
||||
});
|
||||
|
||||
const handleDone = async () => {
|
||||
await progressBar?.resetProgress();
|
||||
|
||||
if ($slideshowNavigation === SlideshowNavigation.AscendingOrder) {
|
||||
onPrevious();
|
||||
return;
|
||||
}
|
||||
onNext();
|
||||
};
|
||||
|
||||
const onShowSettings = async () => {
|
||||
if (document.fullscreenElement) {
|
||||
await document.exitFullscreen();
|
||||
}
|
||||
await modalManager.show(SlideshowSettingsModal);
|
||||
};
|
||||
|
||||
onMount(() => {
|
||||
function exitFullscreenHandler() {
|
||||
const doc = document as Document & {
|
||||
webkitIsFullScreen?: boolean;
|
||||
};
|
||||
|
||||
if (!document.fullscreenElement && !doc.webkitIsFullScreen) {
|
||||
onClose();
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('fullscreenchange', exitFullscreenHandler);
|
||||
document.addEventListener('webkitfullscreenchange', exitFullscreenHandler);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('fullscreenchange', exitFullscreenHandler);
|
||||
document.removeEventListener('webkitfullscreenchange', exitFullscreenHandler);
|
||||
};
|
||||
});
|
||||
|
||||
const { swipe, onswipe, onswipedown } = useSwipe(
|
||||
() => {},
|
||||
() => ({ touchAction: 'pan-x' }),
|
||||
{ onswipedown: showControlBar },
|
||||
true,
|
||||
);
|
||||
|
||||
const shortcutBindings = $derived.by((): ShortcutOptions[] => {
|
||||
const bindings: ShortcutOptions[] = [
|
||||
{ shortcut: { key: 'Escape' }, onShortcut: onClose },
|
||||
{ shortcut: { key: 'ArrowLeft' }, onShortcut: onPrevious },
|
||||
{ shortcut: { key: 'ArrowRight' }, onShortcut: onNext },
|
||||
];
|
||||
|
||||
// For videos, allow the native HTML5 element to handle space for play/pause
|
||||
if (!isVideoSlide) {
|
||||
bindings.push({
|
||||
shortcut: { key: ' ' },
|
||||
onShortcut: () => {
|
||||
if (progressBarStatus === ProgressBarStatus.Paused) {
|
||||
progressBar?.play();
|
||||
} else {
|
||||
progressBar?.pause();
|
||||
}
|
||||
},
|
||||
preventDefault: true,
|
||||
});
|
||||
}
|
||||
|
||||
return bindings;
|
||||
});
|
||||
</script>
|
||||
|
||||
<svelte:document onmousemove={showControlBar} use:shortcuts={shortcutBindings} />
|
||||
|
||||
{/* @ts-expect-error https://github.com/Rezi/svelte-gestures/issues/38#issuecomment-3315953573 */ null}
|
||||
<svelte:body {@attach swipe} {onswipe} {onswipedown} />
|
||||
|
||||
{#if showControls}
|
||||
<div
|
||||
class="m-4 flex gap-2 dark"
|
||||
onmouseenter={() => (isOverControls = true)}
|
||||
onmouseleave={() => (isOverControls = false)}
|
||||
transition:fly={{ duration: 150 }}
|
||||
role="navigation"
|
||||
>
|
||||
<IconButton
|
||||
variant="ghost"
|
||||
shape="round"
|
||||
color="secondary"
|
||||
icon={mdiClose}
|
||||
onclick={onClose}
|
||||
aria-label={$t('exit_slideshow')}
|
||||
/>
|
||||
|
||||
{#if !isVideoSlide}
|
||||
<IconButton
|
||||
variant="ghost"
|
||||
shape="round"
|
||||
color="secondary"
|
||||
icon={progressBarStatus === ProgressBarStatus.Paused ? mdiPlay : mdiPause}
|
||||
onclick={() => (progressBarStatus === ProgressBarStatus.Paused ? progressBar?.play() : progressBar?.pause())}
|
||||
aria-label={progressBarStatus === ProgressBarStatus.Paused ? $t('play') : $t('pause')}
|
||||
/>
|
||||
{/if}
|
||||
<IconButton
|
||||
variant="ghost"
|
||||
shape="round"
|
||||
color="secondary"
|
||||
icon={mdiChevronLeft}
|
||||
onclick={onPrevious}
|
||||
aria-label={$t('previous')}
|
||||
/>
|
||||
<IconButton
|
||||
variant="ghost"
|
||||
shape="round"
|
||||
color="secondary"
|
||||
icon={mdiChevronRight}
|
||||
onclick={onNext}
|
||||
aria-label={$t('next')}
|
||||
/>
|
||||
<IconButton
|
||||
variant="ghost"
|
||||
shape="round"
|
||||
color="secondary"
|
||||
icon={mdiCog}
|
||||
onclick={onShowSettings}
|
||||
aria-label={$t('slideshow_settings')}
|
||||
/>
|
||||
{#if !isFullScreen}
|
||||
<IconButton
|
||||
variant="ghost"
|
||||
shape="round"
|
||||
color="secondary"
|
||||
icon={mdiFullscreen}
|
||||
onclick={onSetToFullScreen}
|
||||
aria-label={$t('set_slideshow_to_fullscreen')}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if !isVideoSlide}
|
||||
<ProgressBar
|
||||
autoplay={$slideshowAutoplay}
|
||||
hidden={!$showProgressBar}
|
||||
duration={$slideshowDelay}
|
||||
bind:this={progressBar}
|
||||
bind:status={progressBarStatus}
|
||||
onDone={handleDone}
|
||||
/>
|
||||
{/if}
|
||||
@@ -1,175 +0,0 @@
|
||||
<script lang="ts">
|
||||
import FaceEditor from '$lib/components/asset-viewer/face-editor/face-editor.svelte';
|
||||
import VideoRemoteViewer from '$lib/components/asset-viewer/video-remote-viewer.svelte';
|
||||
import { assetViewerFadeDuration } from '$lib/constants';
|
||||
import { castManager } from '$lib/managers/cast-manager.svelte';
|
||||
import { isFaceEditMode } from '$lib/stores/face-edit.svelte';
|
||||
import {
|
||||
autoPlayVideo,
|
||||
loopVideo as loopVideoPreference,
|
||||
videoViewerMuted,
|
||||
videoViewerVolume,
|
||||
} from '$lib/stores/preferences.store';
|
||||
import { getAssetMediaUrl, getAssetPlaybackUrl } from '$lib/utils';
|
||||
import { AssetMediaSize } from '@immich/sdk';
|
||||
import { LoadingSpinner } from '@immich/ui';
|
||||
import { onDestroy, onMount } from 'svelte';
|
||||
import { useSwipe, type SwipeCustomEvent } from 'svelte-gestures';
|
||||
import { fade } from 'svelte/transition';
|
||||
|
||||
interface Props {
|
||||
assetId: string;
|
||||
loopVideo: boolean;
|
||||
cacheKey: string | null;
|
||||
playOriginalVideo: boolean;
|
||||
onPreviousAsset?: () => void;
|
||||
onNextAsset?: () => void;
|
||||
onVideoEnded?: () => void;
|
||||
onVideoStarted?: () => void;
|
||||
onClose?: () => void;
|
||||
}
|
||||
|
||||
let {
|
||||
assetId,
|
||||
loopVideo,
|
||||
cacheKey,
|
||||
playOriginalVideo,
|
||||
onPreviousAsset = () => {},
|
||||
onNextAsset = () => {},
|
||||
onVideoEnded = () => {},
|
||||
onVideoStarted = () => {},
|
||||
onClose = () => {},
|
||||
}: Props = $props();
|
||||
|
||||
let videoPlayer: HTMLVideoElement | undefined = $state();
|
||||
let isLoading = $state(true);
|
||||
let assetFileUrl = $derived(
|
||||
playOriginalVideo
|
||||
? getAssetMediaUrl({ id: assetId, size: AssetMediaSize.Original, cacheKey })
|
||||
: getAssetPlaybackUrl({ id: assetId, cacheKey }),
|
||||
);
|
||||
let isScrubbing = $state(false);
|
||||
let showVideo = $state(false);
|
||||
|
||||
onMount(() => {
|
||||
// Show video after mount to ensure fading in.
|
||||
showVideo = true;
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
// reactive on `assetFileUrl` changes
|
||||
if (assetFileUrl) {
|
||||
videoPlayer?.load();
|
||||
}
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
if (videoPlayer) {
|
||||
videoPlayer.src = '';
|
||||
}
|
||||
});
|
||||
|
||||
const handleCanPlay = async (video: HTMLVideoElement) => {
|
||||
try {
|
||||
if (!video.paused && !isScrubbing) {
|
||||
await video.play();
|
||||
onVideoStarted();
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof DOMException && error.name === 'NotAllowedError') {
|
||||
await tryForceMutedPlay(video);
|
||||
return;
|
||||
}
|
||||
|
||||
// auto-play failed
|
||||
} finally {
|
||||
isLoading = false;
|
||||
}
|
||||
};
|
||||
|
||||
const tryForceMutedPlay = async (video: HTMLVideoElement) => {
|
||||
if (video.muted) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
video.muted = true;
|
||||
await handleCanPlay(video);
|
||||
} catch {
|
||||
// muted auto-play failed
|
||||
}
|
||||
};
|
||||
|
||||
const onSwipe = (event: SwipeCustomEvent) => {
|
||||
if (event.detail.direction === 'left') {
|
||||
onNextAsset();
|
||||
}
|
||||
if (event.detail.direction === 'right') {
|
||||
onPreviousAsset();
|
||||
}
|
||||
};
|
||||
|
||||
let containerWidth = $state(0);
|
||||
let containerHeight = $state(0);
|
||||
|
||||
$effect(() => {
|
||||
if (isFaceEditMode.value) {
|
||||
videoPlayer?.pause();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
{#if showVideo}
|
||||
<div
|
||||
transition:fade={{ duration: assetViewerFadeDuration }}
|
||||
class="flex h-full select-none place-content-center place-items-center"
|
||||
bind:clientWidth={containerWidth}
|
||||
bind:clientHeight={containerHeight}
|
||||
>
|
||||
{#if castManager.isCasting}
|
||||
<div class="place-content-center h-full place-items-center">
|
||||
<VideoRemoteViewer
|
||||
poster={getAssetMediaUrl({ id: assetId, size: AssetMediaSize.Preview, cacheKey })}
|
||||
{onVideoStarted}
|
||||
{onVideoEnded}
|
||||
{assetFileUrl}
|
||||
/>
|
||||
</div>
|
||||
{:else}
|
||||
<video
|
||||
bind:this={videoPlayer}
|
||||
loop={$loopVideoPreference && loopVideo}
|
||||
autoplay={$autoPlayVideo}
|
||||
playsinline
|
||||
controls
|
||||
disablePictureInPicture
|
||||
class="h-full object-contain"
|
||||
{...useSwipe(onSwipe)}
|
||||
oncanplay={(e) => handleCanPlay(e.currentTarget)}
|
||||
onended={onVideoEnded}
|
||||
onvolumechange={(e) => ($videoViewerMuted = e.currentTarget.muted)}
|
||||
onseeking={() => (isScrubbing = true)}
|
||||
onseeked={() => (isScrubbing = false)}
|
||||
onplaying={(e) => {
|
||||
e.currentTarget.focus();
|
||||
}}
|
||||
onclose={() => onClose()}
|
||||
muted={$videoViewerMuted}
|
||||
bind:volume={$videoViewerVolume}
|
||||
poster={getAssetMediaUrl({ id: assetId, size: AssetMediaSize.Preview, cacheKey })}
|
||||
src={assetFileUrl}
|
||||
>
|
||||
</video>
|
||||
|
||||
{#if isLoading}
|
||||
<div class="absolute flex place-content-center place-items-center">
|
||||
<LoadingSpinner />
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if isFaceEditMode.value}
|
||||
<FaceEditor htmlElement={videoPlayer} {containerWidth} {containerHeight} {assetId} />
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
@@ -1,36 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { getAssetPlaybackUrl, getAssetUrl } from '$lib/utils';
|
||||
import type { AssetResponseDto } from '@immich/sdk';
|
||||
import { LoadingSpinner } from '@immich/ui';
|
||||
import { t } from 'svelte-i18n';
|
||||
import { fade } from 'svelte/transition';
|
||||
|
||||
interface Props {
|
||||
asset: AssetResponseDto;
|
||||
}
|
||||
|
||||
const { asset }: Props = $props();
|
||||
|
||||
const modules = Promise.all([
|
||||
import('./photo-sphere-viewer-adapter.svelte').then((module) => module.default),
|
||||
import('@photo-sphere-viewer/equirectangular-video-adapter').then((module) => module.EquirectangularVideoAdapter),
|
||||
import('@photo-sphere-viewer/video-plugin').then((module) => module.VideoPlugin),
|
||||
import('@photo-sphere-viewer/video-plugin/index.css'),
|
||||
]);
|
||||
</script>
|
||||
|
||||
<div transition:fade={{ duration: 150 }} class="flex h-full select-none place-content-center place-items-center">
|
||||
{#await modules}
|
||||
<LoadingSpinner />
|
||||
{:then [PhotoSphereViewer, adapter, videoPlugin]}
|
||||
<PhotoSphereViewer
|
||||
panorama={{ source: getAssetPlaybackUrl({ id: asset.id }) }}
|
||||
originalPanorama={{ source: getAssetUrl({ asset, forceOriginal: true })! }}
|
||||
plugins={[videoPlugin]}
|
||||
{adapter}
|
||||
navbar
|
||||
/>
|
||||
{:catch}
|
||||
{$t('errors.failed_to_load_asset')}
|
||||
{/await}
|
||||
</div>
|
||||
@@ -1,102 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { castManager, CastState } from '$lib/managers/cast-manager.svelte';
|
||||
import { handleError } from '$lib/utils/handle-error';
|
||||
import { Icon, IconButton, LoadingSpinner } from '@immich/ui';
|
||||
import { mdiCastConnected, mdiPause, mdiPlay } from '@mdi/js';
|
||||
import { t } from 'svelte-i18n';
|
||||
|
||||
interface Props {
|
||||
poster: string;
|
||||
assetFileUrl: string;
|
||||
onVideoStarted: () => void;
|
||||
onVideoEnded: () => void;
|
||||
}
|
||||
|
||||
let { poster, assetFileUrl, onVideoEnded, onVideoStarted }: Props = $props();
|
||||
|
||||
let previousPlayerState: CastState | null = $state(null);
|
||||
|
||||
const handlePlayPauseButton = async () => {
|
||||
switch (castManager.castState) {
|
||||
case CastState.PLAYING: {
|
||||
castManager.pause();
|
||||
break;
|
||||
}
|
||||
case CastState.IDLE: {
|
||||
await cast(assetFileUrl, true);
|
||||
break;
|
||||
}
|
||||
default: {
|
||||
castManager.play();
|
||||
break;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
$effect(() => {
|
||||
if (assetFileUrl) {
|
||||
void cast(assetFileUrl);
|
||||
}
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (castManager.castState === CastState.IDLE && previousPlayerState !== CastState.PAUSED) {
|
||||
onVideoEnded();
|
||||
}
|
||||
|
||||
previousPlayerState = castManager.castState;
|
||||
});
|
||||
|
||||
const cast = async (url: string, force: boolean = false) => {
|
||||
if (!url || !castManager.isCasting) {
|
||||
return;
|
||||
}
|
||||
const fullUrl = new URL(url, globalThis.location.href);
|
||||
|
||||
try {
|
||||
await castManager.loadMedia(fullUrl.href, force);
|
||||
onVideoStarted();
|
||||
} catch (error) {
|
||||
handleError(error, 'Unable to cast');
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
function handleSeek(event: Event) {
|
||||
const newTime = Number.parseFloat((event.target as HTMLInputElement).value);
|
||||
castManager.seekTo(newTime);
|
||||
}
|
||||
</script>
|
||||
|
||||
<span class="flex items-center space-x-2 text-gray-200 text-2xl font-bold">
|
||||
<Icon icon={mdiCastConnected} class="text-primary" size="36" />
|
||||
<span>{$t('connected_to')} {castManager.receiverName}</span>
|
||||
</span>
|
||||
|
||||
<img src={poster} alt="poster" class="rounded-xl m-4" />
|
||||
|
||||
<div class="flex place-content-center place-items-center">
|
||||
{#if castManager.castState == CastState.BUFFERING}
|
||||
<div class="p-3">
|
||||
<LoadingSpinner />
|
||||
</div>
|
||||
{:else}
|
||||
<IconButton
|
||||
color="primary"
|
||||
shape="round"
|
||||
variant="ghost"
|
||||
icon={castManager.castState == CastState.PLAYING ? mdiPause : mdiPlay}
|
||||
onclick={() => handlePlayPauseButton()}
|
||||
aria-label={castManager.castState == CastState.PLAYING ? 'Pause' : 'Play'}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max={castManager.duration}
|
||||
value={castManager.currentTime ?? 0}
|
||||
onchange={handleSeek}
|
||||
class="w-full h-4 bg-primary"
|
||||
/>
|
||||
</div>
|
||||
@@ -1,52 +0,0 @@
|
||||
<script lang="ts">
|
||||
import VideoNativeViewer from '$lib/components/asset-viewer/video-native-viewer.svelte';
|
||||
import VideoPanoramaViewer from '$lib/components/asset-viewer/video-panorama-viewer.svelte';
|
||||
import { ProjectionType } from '$lib/constants';
|
||||
import type { AssetResponseDto } from '@immich/sdk';
|
||||
|
||||
interface Props {
|
||||
asset: AssetResponseDto;
|
||||
assetId?: string;
|
||||
projectionType: string | null | undefined;
|
||||
cacheKey: string | null;
|
||||
loopVideo: boolean;
|
||||
playOriginalVideo: boolean;
|
||||
onClose?: () => void;
|
||||
onPreviousAsset?: () => void;
|
||||
onNextAsset?: () => void;
|
||||
onVideoEnded?: () => void;
|
||||
onVideoStarted?: () => void;
|
||||
}
|
||||
|
||||
let {
|
||||
asset,
|
||||
assetId,
|
||||
projectionType,
|
||||
cacheKey,
|
||||
loopVideo,
|
||||
playOriginalVideo,
|
||||
onPreviousAsset,
|
||||
onClose,
|
||||
onNextAsset,
|
||||
onVideoEnded,
|
||||
onVideoStarted,
|
||||
}: Props = $props();
|
||||
|
||||
const effectiveAssetId = $derived(assetId ?? asset.id);
|
||||
</script>
|
||||
|
||||
{#if projectionType === ProjectionType.EQUIRECTANGULAR}
|
||||
<VideoPanoramaViewer {asset} />
|
||||
{:else}
|
||||
<VideoNativeViewer
|
||||
{loopVideo}
|
||||
{cacheKey}
|
||||
assetId={effectiveAssetId}
|
||||
{playOriginalVideo}
|
||||
{onPreviousAsset}
|
||||
{onNextAsset}
|
||||
{onVideoEnded}
|
||||
{onVideoStarted}
|
||||
{onClose}
|
||||
/>
|
||||
{/if}
|
||||
@@ -1,25 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { Icon } from '@immich/ui';
|
||||
import { mdiImageBrokenVariant } from '@mdi/js';
|
||||
import { t } from 'svelte-i18n';
|
||||
|
||||
interface Props {
|
||||
class?: string;
|
||||
hideMessage?: boolean;
|
||||
width?: string | undefined;
|
||||
height?: string | undefined;
|
||||
}
|
||||
|
||||
let { class: className = '', hideMessage = false, width = undefined, height = undefined }: Props = $props();
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="flex flex-col overflow-hidden max-h-full max-w-full justify-center items-center bg-gray-100/40 dark:bg-gray-700/40 dark:text-gray-100 p-4 {className}"
|
||||
style:width
|
||||
style:height
|
||||
>
|
||||
<Icon icon={mdiImageBrokenVariant} size="7em" class="max-w-full" />
|
||||
{#if !hideMessage}
|
||||
<span class="text-center">{$t('error_loading_image')}</span>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -1,59 +0,0 @@
|
||||
import { getIntersectionObserverMock } from '$lib/__mocks__/intersection-observer.mock';
|
||||
import Thumbnail from '$lib/components/assets/thumbnail/thumbnail.svelte';
|
||||
import { getTabbable } from '$lib/utils/focus-util';
|
||||
import { assetFactory } from '@test-data/factories/asset-factory';
|
||||
import { render } from '@testing-library/svelte';
|
||||
|
||||
vi.hoisted(() => {
|
||||
Object.defineProperty(globalThis, 'matchMedia', {
|
||||
writable: true,
|
||||
enumerable: true,
|
||||
value: vi.fn().mockImplementation((query) => ({
|
||||
matches: false,
|
||||
media: query,
|
||||
onchange: null,
|
||||
addListener: vi.fn(), // deprecated
|
||||
removeListener: vi.fn(), // deprecated
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
dispatchEvent: vi.fn(),
|
||||
})),
|
||||
});
|
||||
});
|
||||
|
||||
describe('Thumbnail component', () => {
|
||||
beforeAll(() => {
|
||||
vi.stubGlobal('IntersectionObserver', getIntersectionObserverMock());
|
||||
vi.mock('$lib/utils/navigation', () => ({
|
||||
currentUrlReplaceAssetId: vi.fn(),
|
||||
isSharedLinkRoute: vi.fn().mockReturnValue(false),
|
||||
}));
|
||||
});
|
||||
|
||||
it('should only contain a single tabbable element (the container)', () => {
|
||||
const asset = assetFactory.build({ originalPath: 'image.jpg', originalMimeType: 'image/jpeg' });
|
||||
const { baseElement } = render(Thumbnail, {
|
||||
asset,
|
||||
selected: true,
|
||||
});
|
||||
|
||||
const container = baseElement.querySelector('[data-thumbnail-focus-container]');
|
||||
expect(container).not.toBeNull();
|
||||
expect(container!.getAttribute('tabindex')).toBe('0');
|
||||
|
||||
// Guarding against inserting extra tabbable elements in future in <Thumbnail/>
|
||||
const tabbables = getTabbable(container!);
|
||||
expect(tabbables.length).toBe(0);
|
||||
});
|
||||
|
||||
it('shows thumbhash while image is loading', () => {
|
||||
const asset = assetFactory.build({ originalPath: 'image.jpg', originalMimeType: 'image/jpeg' });
|
||||
const sut = render(Thumbnail, {
|
||||
asset,
|
||||
selected: true,
|
||||
});
|
||||
|
||||
const thumbhash = sut.getByTestId('thumbhash');
|
||||
expect(thumbhash).not.toBeFalsy();
|
||||
});
|
||||
});
|
||||
@@ -1,106 +0,0 @@
|
||||
<script lang="ts">
|
||||
import BrokenAsset from '$lib/components/assets/broken-asset.svelte';
|
||||
import { imageManager } from '$lib/managers/ImageManager.svelte';
|
||||
import { Icon } from '@immich/ui';
|
||||
import { mdiEyeOffOutline } from '@mdi/js';
|
||||
import type { ActionReturn } from 'svelte/action';
|
||||
import type { ClassValue } from 'svelte/elements';
|
||||
|
||||
interface Props {
|
||||
url: string;
|
||||
altText: string | undefined;
|
||||
title?: string | null;
|
||||
heightStyle?: string | undefined;
|
||||
widthStyle: string;
|
||||
curve?: boolean;
|
||||
shadow?: boolean;
|
||||
circle?: boolean;
|
||||
hidden?: boolean;
|
||||
border?: boolean;
|
||||
hiddenIconClass?: string;
|
||||
class?: ClassValue;
|
||||
brokenAssetClass?: ClassValue;
|
||||
preload?: boolean;
|
||||
onComplete?: ((errored: boolean) => void) | undefined;
|
||||
}
|
||||
|
||||
let {
|
||||
url,
|
||||
altText,
|
||||
title = null,
|
||||
heightStyle = undefined,
|
||||
widthStyle,
|
||||
curve = false,
|
||||
shadow = false,
|
||||
circle = false,
|
||||
hidden = false,
|
||||
border = false,
|
||||
hiddenIconClass = 'text-white',
|
||||
onComplete = undefined,
|
||||
class: imageClass = '',
|
||||
brokenAssetClass = '',
|
||||
preload = true,
|
||||
}: Props = $props();
|
||||
|
||||
let loaded = $state(false);
|
||||
let errored = $state(false);
|
||||
|
||||
const setLoaded = () => {
|
||||
loaded = true;
|
||||
onComplete?.(false);
|
||||
};
|
||||
const setErrored = () => {
|
||||
errored = true;
|
||||
onComplete?.(true);
|
||||
};
|
||||
|
||||
function mount(elem: HTMLImageElement): ActionReturn {
|
||||
if (elem.complete) {
|
||||
loaded = true;
|
||||
onComplete?.(false);
|
||||
}
|
||||
return {
|
||||
destroy: () => imageManager.cancelPreloadUrl(url),
|
||||
};
|
||||
}
|
||||
|
||||
let optionalClasses = $derived(
|
||||
[
|
||||
curve && 'rounded-xl',
|
||||
circle && 'rounded-full',
|
||||
shadow && 'shadow-lg',
|
||||
(circle || !heightStyle) && 'aspect-square',
|
||||
border && 'border-3 border-immich-dark-primary/80 hover:border-immich-primary',
|
||||
brokenAssetClass,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' '),
|
||||
);
|
||||
</script>
|
||||
|
||||
{#if errored}
|
||||
<BrokenAsset class={optionalClasses} width={widthStyle} height={heightStyle} />
|
||||
{:else}
|
||||
<img
|
||||
use:mount
|
||||
onload={setLoaded}
|
||||
onerror={setErrored}
|
||||
style:width={widthStyle}
|
||||
style:height={heightStyle}
|
||||
style:filter={hidden ? 'grayscale(50%)' : 'none'}
|
||||
style:opacity={hidden ? '0.5' : '1'}
|
||||
src={url}
|
||||
alt={loaded || errored ? altText : ''}
|
||||
{title}
|
||||
class={['object-cover', optionalClasses, imageClass]}
|
||||
draggable="false"
|
||||
loading={preload ? 'eager' : 'lazy'}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
{#if hidden}
|
||||
<div class="absolute start-1/2 top-1/2 translate-x-[-50%] translate-y-[-50%] transform">
|
||||
<!-- TODO fix `title` type -->
|
||||
<Icon title={title ?? undefined} icon={mdiEyeOffOutline} size="2em" class={hiddenIconClass} />
|
||||
</div>
|
||||
{/if}
|
||||
@@ -1,437 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { thumbhash } from '$lib/actions/thumbhash';
|
||||
import { ProjectionType } from '$lib/constants';
|
||||
import { authManager } from '$lib/managers/auth-manager.svelte';
|
||||
import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
|
||||
import { mediaQueryManager } from '$lib/stores/media-query-manager.svelte';
|
||||
import { locale, playVideoThumbnailOnHover } from '$lib/stores/preferences.store';
|
||||
import { getAssetMediaUrl, getAssetPlaybackUrl } from '$lib/utils';
|
||||
import { timeToSeconds } from '$lib/utils/date-time';
|
||||
import { moveFocus } from '$lib/utils/focus-util';
|
||||
import { currentUrlReplaceAssetId } from '$lib/utils/navigation';
|
||||
import { getAltText } from '$lib/utils/thumbnail-util';
|
||||
import { TUNABLES } from '$lib/utils/tunables';
|
||||
import { AssetMediaSize, AssetVisibility, type UserResponseDto } from '@immich/sdk';
|
||||
import { Icon } from '@immich/ui';
|
||||
import {
|
||||
mdiArchiveArrowDownOutline,
|
||||
mdiCameraBurst,
|
||||
mdiCheckCircle,
|
||||
mdiFileGifBox,
|
||||
mdiHeart,
|
||||
mdiMotionPauseOutline,
|
||||
mdiMotionPlayOutline,
|
||||
mdiRotate360,
|
||||
} from '@mdi/js';
|
||||
import { onMount } from 'svelte';
|
||||
import type { ClassValue } from 'svelte/elements';
|
||||
import { fade } from 'svelte/transition';
|
||||
import ImageThumbnail from './image-thumbnail.svelte';
|
||||
import VideoThumbnail from './video-thumbnail.svelte';
|
||||
interface Props {
|
||||
asset: TimelineAsset;
|
||||
groupIndex?: number;
|
||||
thumbnailSize?: number;
|
||||
thumbnailWidth?: number;
|
||||
thumbnailHeight?: number;
|
||||
selected?: boolean;
|
||||
selectionCandidate?: boolean;
|
||||
disabled?: boolean;
|
||||
disableLinkMouseOver?: boolean;
|
||||
readonly?: boolean;
|
||||
showArchiveIcon?: boolean;
|
||||
showStackedIcon?: boolean;
|
||||
imageClass?: ClassValue;
|
||||
brokenAssetClass?: ClassValue;
|
||||
dimmed?: boolean;
|
||||
albumUsers?: UserResponseDto[];
|
||||
onClick?: (asset: TimelineAsset) => void;
|
||||
onSelect?: (asset: TimelineAsset) => void;
|
||||
onMouseEvent?: (event: { isMouseOver: boolean; selectedGroupIndex: number }) => void;
|
||||
}
|
||||
|
||||
let {
|
||||
asset = $bindable(),
|
||||
groupIndex = 0,
|
||||
thumbnailSize = undefined,
|
||||
thumbnailWidth = undefined,
|
||||
thumbnailHeight = undefined,
|
||||
selected = false,
|
||||
selectionCandidate = false,
|
||||
disabled = false,
|
||||
disableLinkMouseOver = false,
|
||||
readonly = false,
|
||||
showArchiveIcon = false,
|
||||
showStackedIcon = true,
|
||||
albumUsers = [],
|
||||
onClick = undefined,
|
||||
onSelect = undefined,
|
||||
onMouseEvent = undefined,
|
||||
imageClass = '',
|
||||
brokenAssetClass = '',
|
||||
dimmed = false,
|
||||
}: Props = $props();
|
||||
|
||||
let {
|
||||
IMAGE_THUMBNAIL: { THUMBHASH_FADE_DURATION },
|
||||
} = TUNABLES;
|
||||
|
||||
let usingMobileDevice = $derived(mediaQueryManager.pointerCoarse);
|
||||
let element: HTMLElement | undefined = $state();
|
||||
let mouseOver = $state(false);
|
||||
let loaded = $state(false);
|
||||
let thumbError = $state(false);
|
||||
|
||||
let width = $derived(thumbnailSize || thumbnailWidth || 235);
|
||||
let height = $derived(thumbnailSize || thumbnailHeight || 235);
|
||||
|
||||
let assetOwner = $derived(albumUsers?.find((user) => user.id === asset.ownerId) ?? null);
|
||||
|
||||
const onIconClickedHandler = (e?: MouseEvent) => {
|
||||
e?.stopPropagation();
|
||||
e?.preventDefault();
|
||||
if (!disabled) {
|
||||
onSelect?.($state.snapshot(asset));
|
||||
}
|
||||
};
|
||||
|
||||
const callClickHandlers = () => {
|
||||
if (selected) {
|
||||
onIconClickedHandler();
|
||||
return;
|
||||
}
|
||||
onClick?.($state.snapshot(asset));
|
||||
};
|
||||
|
||||
const handleClick = (e: MouseEvent) => {
|
||||
if (e.ctrlKey || e.metaKey) {
|
||||
window.open(currentUrlReplaceAssetId(asset.id), '_blank');
|
||||
return;
|
||||
}
|
||||
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
callClickHandlers();
|
||||
};
|
||||
|
||||
const onMouseEnter = () => {
|
||||
if (usingMobileDevice) {
|
||||
return;
|
||||
}
|
||||
mouseOver = true;
|
||||
onMouseEvent?.({ isMouseOver: true, selectedGroupIndex: groupIndex });
|
||||
};
|
||||
|
||||
const onMouseLeave = () => {
|
||||
mouseOver = false;
|
||||
onMouseEvent?.({ isMouseOver: false, selectedGroupIndex: groupIndex });
|
||||
};
|
||||
|
||||
let timer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
const preventContextMenu = (evt: Event) => evt.preventDefault();
|
||||
const disposeables: (() => void)[] = [];
|
||||
|
||||
const clearLongPressTimer = () => {
|
||||
if (!timer) {
|
||||
return;
|
||||
}
|
||||
clearTimeout(timer);
|
||||
timer = null;
|
||||
for (const dispose of disposeables) {
|
||||
dispose();
|
||||
}
|
||||
disposeables.length = 0;
|
||||
};
|
||||
|
||||
let startX: number = 0;
|
||||
let startY: number = 0;
|
||||
|
||||
function longPress(element: HTMLElement, { onLongPress }: { onLongPress: () => void }) {
|
||||
let didPress = false;
|
||||
const start = (evt: PointerEvent) => {
|
||||
startX = evt.clientX;
|
||||
startY = evt.clientY;
|
||||
didPress = false;
|
||||
// 350ms for longpress. For reference: iOS uses 500ms for default long press, or 200ms for fast long press.
|
||||
timer = setTimeout(() => {
|
||||
onLongPress();
|
||||
element.addEventListener('contextmenu', preventContextMenu, { once: true });
|
||||
disposeables.push(() => element.removeEventListener('contextmenu', preventContextMenu));
|
||||
didPress = true;
|
||||
}, 350);
|
||||
};
|
||||
const click = (e: MouseEvent) => {
|
||||
if (!didPress) {
|
||||
return;
|
||||
}
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
};
|
||||
element.addEventListener('click', click);
|
||||
element.addEventListener('pointerdown', start, true);
|
||||
element.addEventListener('pointerup', clearLongPressTimer, { capture: true, passive: true });
|
||||
return {
|
||||
destroy: () => {
|
||||
element.removeEventListener('click', click);
|
||||
element.removeEventListener('pointerdown', start, true);
|
||||
element.removeEventListener('pointerup', clearLongPressTimer, true);
|
||||
},
|
||||
};
|
||||
}
|
||||
function moveHandler(e: PointerEvent) {
|
||||
if (Math.abs(startX - e.clientX) >= 10 || Math.abs(startY - e.clientY) >= 10) {
|
||||
clearLongPressTimer();
|
||||
}
|
||||
}
|
||||
onMount(() => {
|
||||
document.addEventListener('scroll', clearLongPressTimer, { capture: true, passive: true });
|
||||
document.addEventListener('wheel', clearLongPressTimer, { capture: true, passive: true });
|
||||
document.addEventListener('contextmenu', clearLongPressTimer, { capture: true, passive: true });
|
||||
document.addEventListener('pointermove', moveHandler, { capture: true, passive: true });
|
||||
return () => {
|
||||
document.removeEventListener('scroll', clearLongPressTimer, true);
|
||||
document.removeEventListener('wheel', clearLongPressTimer, true);
|
||||
document.removeEventListener('contextmenu', clearLongPressTimer, true);
|
||||
document.removeEventListener('pointermove', moveHandler, true);
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
||||
<div
|
||||
class={[
|
||||
'focus-visible:outline-none flex overflow-hidden',
|
||||
disabled ? 'bg-gray-300' : 'dark:bg-neutral-700 bg-neutral-200',
|
||||
]}
|
||||
style:width="{width}px"
|
||||
style:height="{height}px"
|
||||
onmouseenter={onMouseEnter}
|
||||
onmouseleave={onMouseLeave}
|
||||
use:longPress={{ onLongPress: () => onSelect?.($state.snapshot(asset)) }}
|
||||
onkeydown={(evt) => {
|
||||
if (evt.key === 'Enter') {
|
||||
callClickHandlers();
|
||||
}
|
||||
if (evt.key === 'x') {
|
||||
onSelect?.(asset);
|
||||
}
|
||||
if (document.activeElement === element && evt.key === 'Escape') {
|
||||
moveFocus((element) => element.dataset.thumbnailFocusContainer === undefined, 'next');
|
||||
}
|
||||
}}
|
||||
onclick={handleClick}
|
||||
bind:this={element}
|
||||
data-asset={asset.id}
|
||||
data-thumbnail-focus-container
|
||||
tabindex={0}
|
||||
role="link"
|
||||
>
|
||||
<!-- Outline on focus -->
|
||||
<div
|
||||
class={[
|
||||
'pointer-events-none absolute z-1 size-full outline-hidden outline-4 -outline-offset-4 outline-immich-primary',
|
||||
{ 'rounded-xl': selected },
|
||||
]}
|
||||
data-outline
|
||||
></div>
|
||||
|
||||
<div
|
||||
class={['group absolute top-0 bottom-0', { 'cursor-not-allowed': disabled, 'cursor-pointer': !disabled }]}
|
||||
style:width="inherit"
|
||||
style:height="inherit"
|
||||
>
|
||||
<div
|
||||
class={[
|
||||
'absolute h-full w-full select-none bg-transparent transition-transform',
|
||||
{ 'scale-[0.85]': selected },
|
||||
{ 'rounded-xl': selected },
|
||||
]}
|
||||
>
|
||||
<!-- icon overlay -->
|
||||
<div>
|
||||
<!-- Gradient overlay on hover -->
|
||||
{#if !usingMobileDevice && !disabled}
|
||||
<div
|
||||
class={[
|
||||
'absolute h-full w-full bg-linear-to-b from-black/25 via-[transparent_25%] opacity-0 transition-opacity group-hover:opacity-100',
|
||||
{ 'rounded-xl': selected },
|
||||
]}
|
||||
></div>
|
||||
{/if}
|
||||
|
||||
<!-- Dimmed support -->
|
||||
{#if dimmed && !mouseOver}
|
||||
<div id="a" class={['absolute h-full w-full bg-gray-700/40', { 'rounded-xl': selected }]}></div>
|
||||
{/if}
|
||||
|
||||
<!-- Favorite asset star -->
|
||||
{#if !authManager.isSharedLink && asset.isFavorite}
|
||||
<div class="absolute bottom-2 start-2">
|
||||
<Icon data-icon-favorite icon={mdiHeart} size="24" class="text-white" />
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if !!assetOwner}
|
||||
<div class="absolute bottom-1 end-2 max-w-[50%]">
|
||||
<p class="text-xs font-medium text-white drop-shadow-lg max-w-[100%] truncate">
|
||||
{assetOwner.name}
|
||||
</p>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if !authManager.isSharedLink && showArchiveIcon && asset.visibility === AssetVisibility.Archive}
|
||||
<div class={['absolute start-2', asset.isFavorite ? 'bottom-10' : 'bottom-2']}>
|
||||
<Icon data-icon-archive icon={mdiArchiveArrowDownOutline} size="24" class="text-white" />
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if asset.isImage && asset.projectionType === ProjectionType.EQUIRECTANGULAR}
|
||||
<div class="absolute end-0 top-0 flex place-items-center gap-1 text-xs font-medium text-white">
|
||||
<span class="pe-2 pt-2">
|
||||
<Icon data-icon-equirectangular icon={mdiRotate360} size="24" />
|
||||
</span>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if asset.isImage && asset.duration && !asset.duration.includes('0:00:00.000')}
|
||||
<div class="absolute end-0 top-0 flex place-items-center gap-1 text-xs font-medium text-white">
|
||||
<span class="pe-2 pt-2">
|
||||
<Icon data-icon-playable icon={mdiFileGifBox} size="24" />
|
||||
</span>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Stacked asset -->
|
||||
{#if asset.stack && showStackedIcon}
|
||||
<div
|
||||
class={[
|
||||
'absolute flex place-items-center gap-1 text-xs font-medium text-white',
|
||||
asset.isImage && !asset.livePhotoVideoId ? 'top-0 end-0' : 'top-7 end-1',
|
||||
]}
|
||||
>
|
||||
<span class="pe-2 pt-2 flex place-items-center gap-1">
|
||||
<p>{asset.stack.assetCount.toLocaleString($locale)}</p>
|
||||
<Icon data-icon-stack icon={mdiCameraBurst} size="24" />
|
||||
</span>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- lazy show the url on mouse over-->
|
||||
{#if !usingMobileDevice && mouseOver && !disableLinkMouseOver}
|
||||
<a
|
||||
class="absolute w-full top-0 bottom-0"
|
||||
style:cursor="unset"
|
||||
href={currentUrlReplaceAssetId(asset.id)}
|
||||
onclick={(evt) => evt.preventDefault()}
|
||||
tabindex={-1}
|
||||
aria-label="Thumbnail URL"
|
||||
>
|
||||
</a>
|
||||
{/if}
|
||||
|
||||
<ImageThumbnail
|
||||
class={imageClass}
|
||||
{brokenAssetClass}
|
||||
url={getAssetMediaUrl({ id: asset.id, size: AssetMediaSize.Thumbnail, cacheKey: asset.thumbhash })}
|
||||
altText={$getAltText(asset)}
|
||||
widthStyle="{width}px"
|
||||
heightStyle="{height}px"
|
||||
curve={selected}
|
||||
onComplete={(errored) => ((loaded = true), (thumbError = errored))}
|
||||
/>
|
||||
{#if asset.isVideo}
|
||||
<div class="absolute top-0 h-full w-full pointer-events-none">
|
||||
<VideoThumbnail
|
||||
url={getAssetPlaybackUrl({ id: asset.id, cacheKey: asset.thumbhash })}
|
||||
enablePlayback={mouseOver && $playVideoThumbnailOnHover}
|
||||
curve={selected}
|
||||
durationInSeconds={asset.duration ? timeToSeconds(asset.duration) : 0}
|
||||
playbackOnIconHover={!$playVideoThumbnailOnHover}
|
||||
/>
|
||||
</div>
|
||||
{:else if asset.isImage && asset.livePhotoVideoId}
|
||||
<div class="absolute top-0 h-full w-full pointer-events-none">
|
||||
<VideoThumbnail
|
||||
url={getAssetPlaybackUrl({ id: asset.livePhotoVideoId, cacheKey: asset.thumbhash })}
|
||||
enablePlayback={mouseOver && $playVideoThumbnailOnHover}
|
||||
pauseIcon={mdiMotionPauseOutline}
|
||||
playIcon={mdiMotionPlayOutline}
|
||||
showTime={false}
|
||||
curve={selected}
|
||||
playbackOnIconHover={!$playVideoThumbnailOnHover}
|
||||
/>
|
||||
</div>
|
||||
{:else if asset.isImage && asset.duration && !asset.duration.includes('0:00:00.000') && mouseOver}
|
||||
<!-- GIF -->
|
||||
<div class="absolute top-0 h-full w-full pointer-events-none">
|
||||
<div class="absolute h-full w-full bg-linear-to-b from-black/25 via-[transparent_25%]"></div>
|
||||
<ImageThumbnail
|
||||
class={imageClass}
|
||||
{brokenAssetClass}
|
||||
url={getAssetMediaUrl({ id: asset.id, size: AssetMediaSize.Original, cacheKey: asset.thumbhash })}
|
||||
altText={$getAltText(asset)}
|
||||
widthStyle="{width}px"
|
||||
heightStyle="{height}px"
|
||||
curve={selected}
|
||||
/>
|
||||
<div class="absolute end-0 top-0 flex place-items-center gap-1 text-xs font-medium text-white">
|
||||
<span class="pe-2 pt-2">
|
||||
<Icon data-icon-playable-pause icon={mdiMotionPauseOutline} size="24" />
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if (!loaded || thumbError) && asset.thumbhash}
|
||||
<canvas
|
||||
use:thumbhash={{ base64ThumbHash: asset.thumbhash }}
|
||||
data-testid="thumbhash"
|
||||
class="absolute top-0 object-cover"
|
||||
style:width="{width}px"
|
||||
style:height="{height}px"
|
||||
class:rounded-xl={selected}
|
||||
draggable="false"
|
||||
out:fade={{ duration: THUMBHASH_FADE_DURATION }}
|
||||
></canvas>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if selectionCandidate}
|
||||
<div
|
||||
class="absolute top-0 h-full w-full bg-immich-primary opacity-40"
|
||||
in:fade={{ duration: 100 }}
|
||||
out:fade={{ duration: 100 }}
|
||||
></div>
|
||||
{/if}
|
||||
|
||||
<!-- Select asset button -->
|
||||
{#if !readonly && (mouseOver || selected || selectionCandidate)}
|
||||
<button
|
||||
type="button"
|
||||
onclick={onIconClickedHandler}
|
||||
class={['absolute p-2 focus:outline-none', { 'cursor-not-allowed': disabled }]}
|
||||
role="checkbox"
|
||||
tabindex={-1}
|
||||
aria-checked={selected}
|
||||
{disabled}
|
||||
>
|
||||
{#if disabled}
|
||||
<Icon data-icon-select icon={mdiCheckCircle} size="24" class="text-zinc-800" />
|
||||
{:else if selected}
|
||||
<div class="rounded-full bg-[#D9DCEF] dark:bg-[#232932]">
|
||||
<Icon data-icon-select icon={mdiCheckCircle} size="24" class="text-primary" />
|
||||
</div>
|
||||
{:else}
|
||||
<Icon data-icon-select icon={mdiCheckCircle} size="24" class="text-white/80 hover:text-white" />
|
||||
{/if}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
[data-asset]:focus > [data-outline] {
|
||||
outline-style: solid;
|
||||
}
|
||||
</style>
|
||||
@@ -1,117 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { Icon, LoadingSpinner } from '@immich/ui';
|
||||
import { mdiAlertCircleOutline, mdiPauseCircleOutline, mdiPlayCircleOutline } from '@mdi/js';
|
||||
import { Duration } from 'luxon';
|
||||
|
||||
interface Props {
|
||||
url: string;
|
||||
durationInSeconds?: number;
|
||||
enablePlayback?: boolean;
|
||||
playbackOnIconHover?: boolean;
|
||||
showTime?: boolean;
|
||||
curve?: boolean;
|
||||
playIcon?: string;
|
||||
pauseIcon?: string;
|
||||
}
|
||||
|
||||
let {
|
||||
url,
|
||||
durationInSeconds = 0,
|
||||
enablePlayback = $bindable(false),
|
||||
playbackOnIconHover = false,
|
||||
showTime = true,
|
||||
curve = false,
|
||||
playIcon = mdiPlayCircleOutline,
|
||||
pauseIcon = mdiPauseCircleOutline,
|
||||
}: Props = $props();
|
||||
|
||||
let remainingSeconds = $state(durationInSeconds);
|
||||
let loading = $state(true);
|
||||
let error = $state(false);
|
||||
let player: HTMLVideoElement | undefined = $state();
|
||||
|
||||
$effect(() => {
|
||||
if (!enablePlayback) {
|
||||
// Reset remaining time when playback is disabled.
|
||||
remainingSeconds = durationInSeconds;
|
||||
|
||||
if (player) {
|
||||
// Cancel video buffering.
|
||||
player.src = '';
|
||||
}
|
||||
}
|
||||
});
|
||||
const onMouseEnter = () => {
|
||||
if (playbackOnIconHover) {
|
||||
enablePlayback = true;
|
||||
}
|
||||
};
|
||||
|
||||
const onMouseLeave = () => {
|
||||
if (playbackOnIconHover) {
|
||||
enablePlayback = false;
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
{#if enablePlayback}
|
||||
<video
|
||||
bind:this={player}
|
||||
class="h-full w-full object-cover"
|
||||
class:rounded-xl={curve}
|
||||
muted
|
||||
autoplay
|
||||
loop
|
||||
src={url}
|
||||
onplay={() => {
|
||||
loading = false;
|
||||
error = false;
|
||||
}}
|
||||
onerror={() => {
|
||||
if (!player?.src) {
|
||||
// Do not show error when the URL is empty.
|
||||
return;
|
||||
}
|
||||
error = true;
|
||||
loading = false;
|
||||
}}
|
||||
ontimeupdate={({ currentTarget }) => {
|
||||
const remaining = currentTarget.duration - currentTarget.currentTime;
|
||||
remainingSeconds = Math.min(
|
||||
Math.ceil(Number.isNaN(remaining) ? Number.POSITIVE_INFINITY : remaining),
|
||||
durationInSeconds,
|
||||
);
|
||||
}}
|
||||
></video>
|
||||
{/if}
|
||||
|
||||
<div
|
||||
class="absolute end-0 top-0 flex place-items-center gap-1 text-xs font-medium text-white text-shadow-[1px_1px_6px_rgb(0_0_0)]"
|
||||
>
|
||||
{#if showTime}
|
||||
<span class="pt-2">
|
||||
{#if remainingSeconds < 60}
|
||||
{Duration.fromObject({ seconds: remainingSeconds }).toFormat('m:ss')}
|
||||
{:else if remainingSeconds < 3600}
|
||||
{Duration.fromObject({ seconds: remainingSeconds }).toFormat('mm:ss')}
|
||||
{:else}
|
||||
{Duration.fromObject({ seconds: remainingSeconds }).toFormat('h:mm:ss')}
|
||||
{/if}
|
||||
</span>
|
||||
{/if}
|
||||
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<span class="pe-2 pt-2 drop-shadow-[1px_1px_6px_rgb(0_0_0)]" onmouseenter={onMouseEnter} onmouseleave={onMouseLeave}>
|
||||
{#if enablePlayback}
|
||||
{#if loading}
|
||||
<LoadingSpinner />
|
||||
{:else if error}
|
||||
<Icon icon={mdiAlertCircleOutline} size="24" class="text-red-600" />
|
||||
{:else}
|
||||
<Icon icon={pauseIcon} size="24" />
|
||||
{/if}
|
||||
{:else}
|
||||
<Icon icon={playIcon} size="24" />
|
||||
{/if}
|
||||
</span>
|
||||
</div>
|
||||
@@ -1,187 +0,0 @@
|
||||
<script lang="ts">
|
||||
import SearchPeople from '$lib/components/faces-page/people-search.svelte';
|
||||
import { timeBeforeShowLoadingSpinner } from '$lib/constants';
|
||||
import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte';
|
||||
import { getPeopleThumbnailUrl, handlePromiseError } from '$lib/utils';
|
||||
import { handleError } from '$lib/utils/handle-error';
|
||||
import { zoomImageToBase64 } from '$lib/utils/people-utils';
|
||||
import { getPersonNameWithHiddenValue } from '$lib/utils/person';
|
||||
import { AssetTypeEnum, getAllPeople, type AssetFaceResponseDto, type PersonResponseDto } from '@immich/sdk';
|
||||
import { IconButton, LoadingSpinner } from '@immich/ui';
|
||||
import { mdiArrowLeftThin, mdiClose, mdiMagnify, mdiPlus } from '@mdi/js';
|
||||
import { onMount } from 'svelte';
|
||||
import { t } from 'svelte-i18n';
|
||||
import { linear } from 'svelte/easing';
|
||||
import { fly } from 'svelte/transition';
|
||||
import ImageThumbnail from '../assets/thumbnail/image-thumbnail.svelte';
|
||||
|
||||
interface Props {
|
||||
editedFace: AssetFaceResponseDto;
|
||||
assetId: string;
|
||||
assetType: AssetTypeEnum;
|
||||
onClose: () => void;
|
||||
onCreatePerson: (featurePhoto: string | null) => void;
|
||||
onReassign: (person: PersonResponseDto) => void;
|
||||
}
|
||||
|
||||
let { editedFace, assetId, assetType, onClose, onCreatePerson, onReassign }: Props = $props();
|
||||
|
||||
let allPeople: PersonResponseDto[] = $state([]);
|
||||
|
||||
let isShowLoadingPeople = $state(false);
|
||||
|
||||
async function loadPeople() {
|
||||
const timeout = setTimeout(() => (isShowLoadingPeople = true), timeBeforeShowLoadingSpinner);
|
||||
try {
|
||||
const { people } = await getAllPeople({ withHidden: true, closestAssetId: editedFace.id });
|
||||
allPeople = people;
|
||||
} catch (error) {
|
||||
handleError(error, $t('errors.cant_get_faces'));
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
isShowLoadingPeople = false;
|
||||
}
|
||||
|
||||
// loading spinners
|
||||
let isShowLoadingNewPerson = $state(false);
|
||||
let isShowLoadingSearch = $state(false);
|
||||
|
||||
// search people
|
||||
let searchedPeople: PersonResponseDto[] = $state([]);
|
||||
let searchFaces = $state(false);
|
||||
let searchName = $state('');
|
||||
|
||||
let showPeople = $derived(searchName ? searchedPeople : allPeople.filter((person) => !person.isHidden));
|
||||
|
||||
onMount(() => {
|
||||
handlePromiseError(loadPeople());
|
||||
});
|
||||
|
||||
const handleCreatePerson = async () => {
|
||||
const timeout = setTimeout(() => (isShowLoadingNewPerson = true), timeBeforeShowLoadingSpinner);
|
||||
|
||||
const newFeaturePhoto = await zoomImageToBase64(editedFace, assetId, assetType, assetViewerManager.imgRef);
|
||||
|
||||
onCreatePerson(newFeaturePhoto);
|
||||
|
||||
clearTimeout(timeout);
|
||||
isShowLoadingNewPerson = false;
|
||||
onCreatePerson(newFeaturePhoto);
|
||||
};
|
||||
</script>
|
||||
|
||||
<section
|
||||
transition:fly={{ x: 360, duration: 100, easing: linear }}
|
||||
class="absolute top-0 h-full w-90 overflow-x-hidden p-2 dark:text-immich-dark-fg bg-light"
|
||||
>
|
||||
<div class="flex place-items-center justify-between gap-2">
|
||||
{#if !searchFaces}
|
||||
<div class="flex items-center gap-2">
|
||||
<IconButton
|
||||
color="secondary"
|
||||
variant="ghost"
|
||||
shape="round"
|
||||
icon={mdiArrowLeftThin}
|
||||
aria-label={$t('back')}
|
||||
onclick={onClose}
|
||||
/>
|
||||
<p class="flex text-lg text-immich-fg dark:text-immich-dark-fg">{$t('select_face')}</p>
|
||||
</div>
|
||||
<div class="flex justify-end gap-2">
|
||||
<IconButton
|
||||
color="secondary"
|
||||
variant="ghost"
|
||||
shape="round"
|
||||
icon={mdiMagnify}
|
||||
aria-label={$t('search_for_existing_person')}
|
||||
onclick={() => {
|
||||
searchFaces = true;
|
||||
}}
|
||||
/>
|
||||
{#if !isShowLoadingNewPerson}
|
||||
<IconButton
|
||||
color="secondary"
|
||||
variant="ghost"
|
||||
shape="round"
|
||||
icon={mdiPlus}
|
||||
aria-label={$t('create_new_person')}
|
||||
onclick={handleCreatePerson}
|
||||
/>
|
||||
{:else}
|
||||
<div class="flex place-content-center place-items-center">
|
||||
<LoadingSpinner />
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
<IconButton
|
||||
color="secondary"
|
||||
variant="ghost"
|
||||
shape="round"
|
||||
icon={mdiArrowLeftThin}
|
||||
aria-label={$t('back')}
|
||||
onclick={onClose}
|
||||
/>
|
||||
<div class="w-full flex">
|
||||
<SearchPeople
|
||||
type="input"
|
||||
bind:searchName
|
||||
bind:showLoadingSpinner={isShowLoadingSearch}
|
||||
bind:searchedPeopleLocal={searchedPeople}
|
||||
/>
|
||||
{#if isShowLoadingSearch}
|
||||
<div>
|
||||
<LoadingSpinner />
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
<IconButton
|
||||
color="secondary"
|
||||
variant="ghost"
|
||||
shape="round"
|
||||
icon={mdiClose}
|
||||
aria-label={$t('cancel_search')}
|
||||
onclick={() => (searchFaces = false)}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="px-4 py-4 text-sm">
|
||||
<h2 class="mb-8 mt-4">{$t('all_people')}</h2>
|
||||
{#if isShowLoadingPeople}
|
||||
<div class="flex w-full justify-center">
|
||||
<LoadingSpinner />
|
||||
</div>
|
||||
{:else}
|
||||
<div class="immich-scrollbar mt-4 flex flex-wrap gap-2 overflow-y-auto">
|
||||
{#each showPeople as person (person.id)}
|
||||
{#if !editedFace.person || person.id !== editedFace.person.id}
|
||||
<div class="w-fit">
|
||||
<button type="button" class="w-22.5" onclick={() => onReassign(person)}>
|
||||
<div class="relative">
|
||||
<ImageThumbnail
|
||||
curve
|
||||
shadow
|
||||
url={getPeopleThumbnailUrl(person)}
|
||||
altText={$getPersonNameWithHiddenValue(person.name, person.isHidden)}
|
||||
title={$getPersonNameWithHiddenValue(person.name, person.isHidden)}
|
||||
widthStyle="90px"
|
||||
heightStyle="90px"
|
||||
hidden={person.isHidden}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<p
|
||||
class="mt-1 truncate font-medium"
|
||||
title={$getPersonNameWithHiddenValue(person.name, person.isHidden)}
|
||||
>
|
||||
{person.name}
|
||||
</p>
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</section>
|
||||
@@ -1,49 +0,0 @@
|
||||
<script lang="ts">
|
||||
import SearchPeople from '$lib/components/faces-page/people-search.svelte';
|
||||
import { type PersonResponseDto } from '@immich/sdk';
|
||||
import { Button } from '@immich/ui';
|
||||
import { t } from 'svelte-i18n';
|
||||
import ImageThumbnail from '../assets/thumbnail/image-thumbnail.svelte';
|
||||
|
||||
interface Props {
|
||||
person: PersonResponseDto;
|
||||
name: string;
|
||||
suggestedPeople: PersonResponseDto[];
|
||||
thumbnailData: string;
|
||||
isSearchingPeople: boolean;
|
||||
onChange: (name: string) => void;
|
||||
}
|
||||
|
||||
let {
|
||||
person,
|
||||
name = $bindable(),
|
||||
suggestedPeople = $bindable(),
|
||||
thumbnailData,
|
||||
isSearchingPeople = $bindable(),
|
||||
onChange,
|
||||
}: Props = $props();
|
||||
|
||||
const onsubmit = (event: Event) => {
|
||||
event.preventDefault();
|
||||
onChange(name);
|
||||
};
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="flex w-full h-14 place-items-center {suggestedPeople.length > 0
|
||||
? 'rounded-t-lg dark:border-immich-dark-gray'
|
||||
: 'rounded-lg'} bg-gray-100 p-2 dark:bg-gray-700 border border-gray-200 dark:border-immich-dark-gray"
|
||||
>
|
||||
<ImageThumbnail circle shadow url={thumbnailData} altText={person.name} widthStyle="2rem" heightStyle="2rem" />
|
||||
<form class="ms-4 flex w-full justify-between gap-16" autocomplete="off" {onsubmit}>
|
||||
<SearchPeople
|
||||
bind:searchName={name}
|
||||
bind:searchedPeopleLocal={suggestedPeople}
|
||||
type="input"
|
||||
numberPeopleToSearch={5}
|
||||
inputClass="w-full gap-2 bg-gray-100 dark:bg-gray-700 dark:text-white"
|
||||
bind:showLoadingSpinner={isSearchingPeople}
|
||||
/>
|
||||
<Button size="small" shape="round" type="submit">{$t('done')}</Button>
|
||||
</form>
|
||||
</div>
|
||||
@@ -1,68 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { getPeopleThumbnailUrl } from '$lib/utils';
|
||||
import { type PersonResponseDto } from '@immich/sdk';
|
||||
import ImageThumbnail from '../assets/thumbnail/image-thumbnail.svelte';
|
||||
|
||||
interface Props {
|
||||
person: PersonResponseDto;
|
||||
selectable?: boolean;
|
||||
selected?: boolean;
|
||||
thumbnailSize?: number | null;
|
||||
circle?: boolean;
|
||||
border?: boolean;
|
||||
onClick?: (person: PersonResponseDto) => void;
|
||||
}
|
||||
|
||||
let {
|
||||
person,
|
||||
selectable = false,
|
||||
selected = false,
|
||||
thumbnailSize = null,
|
||||
circle = false,
|
||||
border = false,
|
||||
onClick = () => {},
|
||||
}: Props = $props();
|
||||
</script>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="relative rounded-lg transition-all"
|
||||
onclick={() => onClick(person)}
|
||||
disabled={!selectable}
|
||||
style:width={thumbnailSize ? thumbnailSize + 'px' : '100%'}
|
||||
style:height={thumbnailSize ? thumbnailSize + 'px' : '100%'}
|
||||
>
|
||||
<div
|
||||
class="h-full w-full border-2 brightness-90 filter"
|
||||
class:rounded-full={circle}
|
||||
class:rounded-lg={!circle}
|
||||
class:border-transparent={!border}
|
||||
class:dark:border-immich-dark-primary={border}
|
||||
class:border-immich-primary={border}
|
||||
>
|
||||
<ImageThumbnail {circle} url={getPeopleThumbnailUrl(person)} altText={person.name} widthStyle="100%" shadow />
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="absolute start-0 top-0 h-full w-full bg-immich-primary/30 opacity-0"
|
||||
class:hover:opacity-100={selectable}
|
||||
class:rounded-full={circle}
|
||||
class:rounded-lg={!circle}
|
||||
></div>
|
||||
|
||||
{#if selected}
|
||||
<div
|
||||
class="absolute start-0 top-0 h-full w-full bg-blue-500/80"
|
||||
class:rounded-full={circle}
|
||||
class:rounded-lg={!circle}
|
||||
></div>
|
||||
{/if}
|
||||
|
||||
{#if person.name}
|
||||
<span
|
||||
class="w-100 text-white-shadow absolute bottom-2 start-0 w-full text-ellipsis px-1 text-center font-medium text-white hover:cursor-pointer"
|
||||
>
|
||||
{person.name}
|
||||
</span>
|
||||
{/if}
|
||||
</button>
|
||||
@@ -1,88 +0,0 @@
|
||||
import { getIntersectionObserverMock } from '$lib/__mocks__/intersection-observer.mock';
|
||||
import { personFactory } from '@test-data/factories/person-factory';
|
||||
import { render } from '@testing-library/svelte';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { vi } from 'vitest';
|
||||
import ManagePeopleVisibilityWrapper from './manage-people-visibility.test-wrapper.svelte';
|
||||
|
||||
describe('ManagePeopleVisibility component', () => {
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal('IntersectionObserver', getIntersectionObserverMock());
|
||||
});
|
||||
|
||||
it('keeps toggled hidden state when loading more people', async () => {
|
||||
const onClose = vi.fn();
|
||||
const onUpdate = vi.fn();
|
||||
const loadNextPage = vi.fn();
|
||||
|
||||
const [personA, personB, personC] = [
|
||||
personFactory.build({ id: 'a', isHidden: false }),
|
||||
personFactory.build({ id: 'b', isHidden: false }),
|
||||
personFactory.build({ id: 'c', isHidden: true }),
|
||||
];
|
||||
|
||||
const { container, rerender } = render(ManagePeopleVisibilityWrapper, {
|
||||
props: {
|
||||
people: [personA, personB],
|
||||
totalPeopleCount: 3,
|
||||
onClose,
|
||||
onUpdate,
|
||||
loadNextPage,
|
||||
},
|
||||
});
|
||||
const user = userEvent.setup();
|
||||
|
||||
let personButtons = container.querySelectorAll('button[aria-pressed]');
|
||||
expect(personButtons).toHaveLength(2);
|
||||
|
||||
await user.click(personButtons[0]);
|
||||
expect(personButtons[0].getAttribute('aria-pressed')).toBe('true');
|
||||
|
||||
await rerender({
|
||||
people: [personA, personB, personC],
|
||||
totalPeopleCount: 3,
|
||||
onClose,
|
||||
onUpdate,
|
||||
loadNextPage,
|
||||
});
|
||||
|
||||
personButtons = container.querySelectorAll('button[aria-pressed]');
|
||||
expect(personButtons).toHaveLength(3);
|
||||
expect(personButtons[0].getAttribute('aria-pressed')).toBe('true');
|
||||
expect(personButtons[2].getAttribute('aria-pressed')).toBe('true');
|
||||
});
|
||||
|
||||
it('shows newly loaded hidden people as hidden', async () => {
|
||||
const onClose = vi.fn();
|
||||
const onUpdate = vi.fn();
|
||||
const loadNextPage = vi.fn();
|
||||
|
||||
const [personA, personB, personC] = [
|
||||
personFactory.build({ id: 'a', isHidden: false }),
|
||||
personFactory.build({ id: 'b', isHidden: false }),
|
||||
personFactory.build({ id: 'c', isHidden: true }),
|
||||
];
|
||||
|
||||
const { container, rerender } = render(ManagePeopleVisibilityWrapper, {
|
||||
props: {
|
||||
people: [personA, personB],
|
||||
totalPeopleCount: 3,
|
||||
onClose,
|
||||
onUpdate,
|
||||
loadNextPage,
|
||||
},
|
||||
});
|
||||
|
||||
await rerender({
|
||||
people: [personA, personB, personC],
|
||||
totalPeopleCount: 3,
|
||||
onClose,
|
||||
onUpdate,
|
||||
loadNextPage,
|
||||
});
|
||||
|
||||
const personButtons = container.querySelectorAll('button[aria-pressed]');
|
||||
expect(personButtons).toHaveLength(3);
|
||||
expect(personButtons[2].getAttribute('aria-pressed')).toBe('true');
|
||||
});
|
||||
});
|
||||
@@ -1,176 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { shortcut } from '$lib/actions/shortcut';
|
||||
import ImageThumbnail from '$lib/components/assets/thumbnail/image-thumbnail.svelte';
|
||||
import PeopleInfiniteScroll from '$lib/components/faces-page/people-infinite-scroll.svelte';
|
||||
import { ToggleVisibility } from '$lib/constants';
|
||||
import { locale } from '$lib/stores/preferences.store';
|
||||
import { getPeopleThumbnailUrl } from '$lib/utils';
|
||||
import { handleError } from '$lib/utils/handle-error';
|
||||
import { updatePeople, type PersonResponseDto } from '@immich/sdk';
|
||||
import { Button, IconButton, toastManager } from '@immich/ui';
|
||||
import { mdiClose, mdiEye, mdiEyeOff, mdiEyeSettings, mdiRestart } from '@mdi/js';
|
||||
import { t } from 'svelte-i18n';
|
||||
import { SvelteMap } from 'svelte/reactivity';
|
||||
|
||||
interface Props {
|
||||
people: PersonResponseDto[];
|
||||
totalPeopleCount: number;
|
||||
titleId?: string | undefined;
|
||||
onClose: () => void;
|
||||
onUpdate: (people: PersonResponseDto[]) => void;
|
||||
loadNextPage: () => void;
|
||||
}
|
||||
|
||||
let { people, totalPeopleCount, titleId = undefined, onClose, onUpdate, loadNextPage }: Props = $props();
|
||||
|
||||
let toggleVisibility = $state(ToggleVisibility.SHOW_ALL);
|
||||
let showLoadingSpinner = $state(false);
|
||||
const overrides = new SvelteMap<string, boolean>();
|
||||
|
||||
const getNextVisibility = (toggleVisibility: ToggleVisibility) => {
|
||||
if (toggleVisibility === ToggleVisibility.SHOW_ALL) {
|
||||
return ToggleVisibility.HIDE_UNNANEMD;
|
||||
} else if (toggleVisibility === ToggleVisibility.HIDE_UNNANEMD) {
|
||||
return ToggleVisibility.HIDE_ALL;
|
||||
} else {
|
||||
return ToggleVisibility.SHOW_ALL;
|
||||
}
|
||||
};
|
||||
|
||||
const handleToggleVisibility = () => {
|
||||
toggleVisibility = getNextVisibility(toggleVisibility);
|
||||
|
||||
for (const person of people) {
|
||||
let isHidden = overrides.get(person.id) ?? person.isHidden;
|
||||
|
||||
if (toggleVisibility === ToggleVisibility.HIDE_ALL) {
|
||||
isHidden = true;
|
||||
} else if (toggleVisibility === ToggleVisibility.SHOW_ALL) {
|
||||
isHidden = false;
|
||||
} else if (toggleVisibility === ToggleVisibility.HIDE_UNNANEMD && !person.name) {
|
||||
isHidden = true;
|
||||
}
|
||||
|
||||
setHiddenOverride(person, isHidden);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSaveVisibility = async () => {
|
||||
showLoadingSpinner = true;
|
||||
const changed = Array.from(overrides, ([id, isHidden]) => ({ id, isHidden }));
|
||||
|
||||
try {
|
||||
if (changed.length > 0) {
|
||||
const results = await updatePeople({ peopleUpdateDto: { people: changed } });
|
||||
const successCount = results.filter(({ success }) => success).length;
|
||||
const failCount = results.length - successCount;
|
||||
if (failCount > 0) {
|
||||
toastManager.warning($t('errors.unable_to_change_visibility', { values: { count: failCount } }));
|
||||
}
|
||||
toastManager.success($t('visibility_changed', { values: { count: successCount } }));
|
||||
}
|
||||
|
||||
for (const person of people) {
|
||||
const isHidden = overrides.get(person.id);
|
||||
if (isHidden !== undefined) {
|
||||
person.isHidden = isHidden;
|
||||
}
|
||||
}
|
||||
overrides.clear();
|
||||
|
||||
onUpdate(people);
|
||||
onClose();
|
||||
} catch (error) {
|
||||
handleError(error, $t('errors.unable_to_change_visibility', { values: { count: changed.length } }));
|
||||
} finally {
|
||||
showLoadingSpinner = false;
|
||||
}
|
||||
};
|
||||
|
||||
const setHiddenOverride = (person: PersonResponseDto, isHidden: boolean) => {
|
||||
if (isHidden === person.isHidden) {
|
||||
overrides.delete(person.id);
|
||||
return;
|
||||
}
|
||||
overrides.set(person.id, isHidden);
|
||||
};
|
||||
|
||||
let toggleButtonOptions: Record<ToggleVisibility, { icon: string; label: string }> = $derived({
|
||||
[ToggleVisibility.HIDE_ALL]: { icon: mdiEyeOff, label: $t('hide_all_people') },
|
||||
[ToggleVisibility.HIDE_UNNANEMD]: { icon: mdiEyeSettings, label: $t('hide_unnamed_people') },
|
||||
[ToggleVisibility.SHOW_ALL]: { icon: mdiEye, label: $t('show_all_people') },
|
||||
});
|
||||
let toggleButton = $derived(toggleButtonOptions[getNextVisibility(toggleVisibility)]);
|
||||
</script>
|
||||
|
||||
<svelte:document use:shortcut={{ shortcut: { key: 'Escape' }, onShortcut: onClose }} />
|
||||
|
||||
<div
|
||||
class="fixed top-0 z-1 flex h-16 w-full items-center justify-between border-b bg-white p-1 dark:border-immich-dark-gray dark:bg-black dark:text-immich-dark-fg md:p-8"
|
||||
>
|
||||
<div class="flex items-center">
|
||||
<IconButton
|
||||
shape="round"
|
||||
color="secondary"
|
||||
variant="ghost"
|
||||
aria-label={$t('close')}
|
||||
icon={mdiClose}
|
||||
onclick={onClose}
|
||||
/>
|
||||
<div class="flex gap-2 items-center">
|
||||
<p id={titleId} class="ms-2">{$t('show_and_hide_people')}</p>
|
||||
<p class="text-sm text-gray-400 dark:text-gray-600">({totalPeopleCount.toLocaleString($locale)})</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center justify-end">
|
||||
<div class="flex items-center md:me-4">
|
||||
<IconButton
|
||||
shape="round"
|
||||
color="secondary"
|
||||
variant="ghost"
|
||||
aria-label={$t('reset_people_visibility')}
|
||||
icon={mdiRestart}
|
||||
onclick={() => overrides.clear()}
|
||||
/>
|
||||
<IconButton
|
||||
shape="round"
|
||||
color="secondary"
|
||||
variant="ghost"
|
||||
aria-label={toggleButton.label}
|
||||
icon={toggleButton.icon}
|
||||
onclick={handleToggleVisibility}
|
||||
/>
|
||||
</div>
|
||||
<Button loading={showLoadingSpinner} onclick={handleSaveVisibility} size="small">{$t('done')}</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap gap-1 p-2 pb-8 md:px-8 mt-16">
|
||||
<PeopleInfiniteScroll {people} hasNextPage={true} {loadNextPage}>
|
||||
{#snippet children({ person })}
|
||||
{@const hidden = overrides.get(person.id) ?? person.isHidden}
|
||||
<button
|
||||
type="button"
|
||||
class="group relative w-full h-full"
|
||||
onclick={() => setHiddenOverride(person, !hidden)}
|
||||
aria-pressed={hidden}
|
||||
aria-label={person.name ? $t('hide_named_person', { values: { name: person.name } }) : $t('hide_person')}
|
||||
>
|
||||
<ImageThumbnail
|
||||
{hidden}
|
||||
shadow
|
||||
url={getPeopleThumbnailUrl(person)}
|
||||
altText={person.name}
|
||||
widthStyle="100%"
|
||||
hiddenIconClass="text-white group-hover:text-black transition-colors"
|
||||
preload={false}
|
||||
/>
|
||||
{#if person.name}
|
||||
<span class="absolute bottom-2 start-0 w-full select-text px-1 text-center font-medium text-white">
|
||||
{person.name}
|
||||
</span>
|
||||
{/if}
|
||||
</button>
|
||||
{/snippet}
|
||||
</PeopleInfiniteScroll>
|
||||
</div>
|
||||
@@ -1,20 +0,0 @@
|
||||
<script lang="ts">
|
||||
import type { PersonResponseDto } from '@immich/sdk';
|
||||
import { TooltipProvider } from '@immich/ui';
|
||||
import ManagePeopleVisibility from './manage-people-visibility.svelte';
|
||||
|
||||
interface Props {
|
||||
people: PersonResponseDto[];
|
||||
totalPeopleCount: number;
|
||||
titleId?: string | undefined;
|
||||
onClose: () => void;
|
||||
onUpdate: (people: PersonResponseDto[]) => void;
|
||||
loadNextPage: () => void;
|
||||
}
|
||||
|
||||
let props: Props = $props();
|
||||
</script>
|
||||
|
||||
<TooltipProvider>
|
||||
<ManagePeopleVisibility {...props} />
|
||||
</TooltipProvider>
|
||||
@@ -1,144 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { Route } from '$lib/route';
|
||||
import { handleError } from '$lib/utils/handle-error';
|
||||
import { getAllPeople, getPerson, mergePerson, type PersonResponseDto } from '@immich/sdk';
|
||||
import { Button, Icon, IconButton, modalManager, toastManager } from '@immich/ui';
|
||||
import { mdiCallMerge, mdiMerge, mdiSwapHorizontal } from '@mdi/js';
|
||||
import { onMount } from 'svelte';
|
||||
import { t } from 'svelte-i18n';
|
||||
import { flip } from 'svelte/animate';
|
||||
import { quintOut } from 'svelte/easing';
|
||||
import { fly } from 'svelte/transition';
|
||||
import ControlAppBar from '../shared-components/control-app-bar.svelte';
|
||||
import FaceThumbnail from './face-thumbnail.svelte';
|
||||
import PeopleList from './people-list.svelte';
|
||||
|
||||
interface Props {
|
||||
person: PersonResponseDto;
|
||||
onBack: () => void;
|
||||
onMerge: (mergedPerson: PersonResponseDto) => void;
|
||||
}
|
||||
|
||||
let { person = $bindable(), onBack, onMerge }: Props = $props();
|
||||
|
||||
let people: PersonResponseDto[] = $state([]);
|
||||
let selectedPeople: PersonResponseDto[] = $state([]);
|
||||
let screenHeight: number = $state(0);
|
||||
|
||||
let hasSelection = $derived(selectedPeople.length > 0);
|
||||
let peopleToNotShow = $derived([...selectedPeople, person]);
|
||||
|
||||
const handleSearch = async (sortFaces: boolean = false) => {
|
||||
const data = await getAllPeople({ withHidden: false, closestPersonId: sortFaces ? person.id : undefined });
|
||||
people = data.people;
|
||||
};
|
||||
|
||||
onMount(handleSearch);
|
||||
|
||||
const handleSwapPeople = async () => {
|
||||
[person, selectedPeople[0]] = [selectedPeople[0], person];
|
||||
await goto(Route.viewPerson(person, { previousRoute: Route.people(), action: 'merge' }));
|
||||
};
|
||||
|
||||
const onSelect = async (selected: PersonResponseDto) => {
|
||||
if (selectedPeople.includes(selected)) {
|
||||
selectedPeople = selectedPeople.filter((person) => person.id !== selected.id);
|
||||
return;
|
||||
}
|
||||
|
||||
if (selectedPeople.length >= 5) {
|
||||
toastManager.warning($t('merge_people_limit'));
|
||||
return;
|
||||
}
|
||||
|
||||
selectedPeople = [selected, ...selectedPeople];
|
||||
|
||||
if (selectedPeople.length === 1 && !person.name && selected.name) {
|
||||
await handleSwapPeople();
|
||||
}
|
||||
};
|
||||
|
||||
const handleMerge = async () => {
|
||||
const isConfirm = await modalManager.showDialog({ prompt: $t('merge_people_prompt') });
|
||||
if (!isConfirm) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
let results = await mergePerson({
|
||||
id: person.id,
|
||||
mergePersonDto: { ids: selectedPeople.map(({ id }) => id) },
|
||||
});
|
||||
const mergedPerson = await getPerson({ id: person.id });
|
||||
const count = results.filter(({ success }) => success).length;
|
||||
toastManager.success($t('merged_people_count', { values: { count } }));
|
||||
onMerge(mergedPerson);
|
||||
} catch (error) {
|
||||
handleError(error, $t('cannot_merge_people'));
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<svelte:window bind:innerHeight={screenHeight} />
|
||||
|
||||
<section
|
||||
transition:fly={{ y: 500, duration: 100, easing: quintOut }}
|
||||
class="absolute start-0 top-0 h-full w-full bg-light"
|
||||
>
|
||||
<ControlAppBar onClose={onBack}>
|
||||
{#snippet leading()}
|
||||
{#if hasSelection}
|
||||
{$t('selected_count', { values: { count: selectedPeople.length } })}
|
||||
{:else}
|
||||
{$t('merge_people')}
|
||||
{/if}
|
||||
<div></div>
|
||||
{/snippet}
|
||||
{#snippet trailing()}
|
||||
<Button leadingIcon={mdiMerge} size="small" shape="round" disabled={!hasSelection} onclick={handleMerge}>
|
||||
{$t('merge')}
|
||||
</Button>
|
||||
{/snippet}
|
||||
</ControlAppBar>
|
||||
<section class="px-17.5 pt-25">
|
||||
<section id="merge-face-selector">
|
||||
<div class="mb-10 h-50 place-content-center place-items-center">
|
||||
<p class="mb-4 text-center uppercase dark:text-white">{$t('choose_matching_people_to_merge')}</p>
|
||||
|
||||
<div class="grid grid-flow-col-dense place-content-center place-items-center gap-4">
|
||||
{#each selectedPeople as person (person.id)}
|
||||
<div animate:flip={{ duration: 250, easing: quintOut }}>
|
||||
<FaceThumbnail border circle {person} selectable thumbnailSize={120} onClick={() => onSelect(person)} />
|
||||
</div>
|
||||
{/each}
|
||||
|
||||
{#if hasSelection}
|
||||
<div class="relative h-full">
|
||||
<div class="flex flex-col h-full justify-between">
|
||||
<div class="flex h-full items-center justify-center">
|
||||
<Icon icon={mdiCallMerge} size="48" class="rotate-90 dark:text-white" />
|
||||
</div>
|
||||
{#if selectedPeople.length === 1}
|
||||
<div class="absolute bottom-2">
|
||||
<IconButton
|
||||
shape="round"
|
||||
color="secondary"
|
||||
variant="ghost"
|
||||
aria-label={$t('swap_merge_direction')}
|
||||
icon={mdiSwapHorizontal}
|
||||
size="large"
|
||||
onclick={handleSwapPeople}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
<FaceThumbnail {person} border circle selectable={false} thumbnailSize={180} />
|
||||
</div>
|
||||
</div>
|
||||
<PeopleList {people} {peopleToNotShow} {screenHeight} {onSelect} {handleSearch} />
|
||||
</section>
|
||||
</section>
|
||||
</section>
|
||||
@@ -1,88 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { focusOutside } from '$lib/actions/focus-outside';
|
||||
import ActionMenuItem from '$lib/components/ActionMenuItem.svelte';
|
||||
import ButtonContextMenu from '$lib/components/shared-components/context-menu/button-context-menu.svelte';
|
||||
import { Route } from '$lib/route';
|
||||
import { getPersonActions } from '$lib/services/person.service';
|
||||
import { getPeopleThumbnailUrl } from '$lib/utils';
|
||||
import { type PersonResponseDto } from '@immich/sdk';
|
||||
import { Icon } from '@immich/ui';
|
||||
import {
|
||||
mdiAccountMultipleCheckOutline,
|
||||
mdiDotsVertical,
|
||||
mdiEyeOffOutline,
|
||||
mdiHeart,
|
||||
mdiHeartMinusOutline,
|
||||
mdiHeartOutline,
|
||||
} from '@mdi/js';
|
||||
import { t } from 'svelte-i18n';
|
||||
import ImageThumbnail from '../assets/thumbnail/image-thumbnail.svelte';
|
||||
import MenuOption from '../shared-components/context-menu/menu-option.svelte';
|
||||
|
||||
type Props = {
|
||||
person: PersonResponseDto;
|
||||
onMergePeople: () => void;
|
||||
onHidePerson: () => void;
|
||||
onToggleFavorite: () => void;
|
||||
};
|
||||
|
||||
let { person, onMergePeople, onHidePerson, onToggleFavorite }: Props = $props();
|
||||
|
||||
let showVerticalDots = $state(false);
|
||||
|
||||
const { SetDateOfBirth } = $derived(getPersonActions($t, person));
|
||||
</script>
|
||||
|
||||
<div
|
||||
id="people-card"
|
||||
class="relative"
|
||||
onmouseenter={() => (showVerticalDots = true)}
|
||||
onmouseleave={() => (showVerticalDots = false)}
|
||||
role="group"
|
||||
use:focusOutside={{ onFocusOut: () => (showVerticalDots = false) }}
|
||||
>
|
||||
<a
|
||||
href={Route.viewPerson(person, { previousRoute: Route.people() })}
|
||||
draggable="false"
|
||||
onfocus={() => (showVerticalDots = true)}
|
||||
>
|
||||
<div class="w-full h-full rounded-xl brightness-95 filter">
|
||||
<ImageThumbnail
|
||||
shadow
|
||||
url={getPeopleThumbnailUrl(person)}
|
||||
altText={person.name}
|
||||
title={person.name}
|
||||
widthStyle="100%"
|
||||
circle
|
||||
preload={false}
|
||||
/>
|
||||
{#if person.isFavorite}
|
||||
<div class="absolute top-4 start-4">
|
||||
<Icon icon={mdiHeart} size="24" class="text-white" />
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</a>
|
||||
|
||||
{#if showVerticalDots}
|
||||
<div class="absolute top-2 end-2 z-1">
|
||||
<ButtonContextMenu
|
||||
buttonClass="icon-white-drop-shadow"
|
||||
color="secondary"
|
||||
size="medium"
|
||||
variant="filled"
|
||||
icon={mdiDotsVertical}
|
||||
title={$t('show_person_options')}
|
||||
>
|
||||
<MenuOption onClick={onHidePerson} icon={mdiEyeOffOutline} text={$t('hide_person')} />
|
||||
<ActionMenuItem action={SetDateOfBirth} />
|
||||
<MenuOption onClick={onMergePeople} icon={mdiAccountMultipleCheckOutline} text={$t('merge_people')} />
|
||||
<MenuOption
|
||||
onClick={onToggleFavorite}
|
||||
icon={person.isFavorite ? mdiHeartMinusOutline : mdiHeartOutline}
|
||||
text={person.isFavorite ? $t('unfavorite') : $t('to_favorite')}
|
||||
/>
|
||||
</ButtonContextMenu>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user