strip down core

This commit is contained in:
Alex Tran
2026-02-06 20:29:31 +00:00
parent a356497d96
commit 5ccf4a596c
1420 changed files with 667 additions and 260723 deletions
@@ -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}
&#8595;
{:else}
&#8593;
{/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