merge main

This commit is contained in:
martabal
2024-05-26 22:14:36 +02:00
1208 changed files with 13676 additions and 34138 deletions
@@ -8,6 +8,7 @@
import { handleError } from '$lib/utils/handle-error';
import { JobCommand, JobName, sendJobCommand, type AllJobStatusResponseDto, type JobCommandDto } from '@immich/sdk';
import {
mdiContentDuplicate,
mdiFaceRecognition,
mdiFileJpgBox,
mdiFileXmlBox,
@@ -88,6 +89,12 @@
subtitle: 'Run machine learning on assets to support smart search',
disabled: !$featureFlags.smartSearch,
},
[JobName.DuplicateDetection]: {
icon: mdiContentDuplicate,
title: getJobName(JobName.DuplicateDetection),
subtitle: 'Run machine learning on assets to detect similar images. Relies on Smart Search',
disabled: !$featureFlags.duplicateDetection,
},
[JobName.FaceDetection]: {
icon: mdiFaceRecognition,
title: getJobName(JobName.FaceDetection),
@@ -0,0 +1,242 @@
<script lang="ts">
import ConfirmDialogue from '$lib/components/shared-components/confirm-dialogue.svelte';
import SettingAccordion from '$lib/components/shared-components/settings/setting-accordion.svelte';
import SettingButtonsRow from '$lib/components/shared-components/settings/setting-buttons-row.svelte';
import SettingInputField, {
SettingInputFieldType,
} from '$lib/components/shared-components/settings/setting-input-field.svelte';
import SettingSwitch from '$lib/components/shared-components/settings/setting-switch.svelte';
import { type SystemConfigDto } from '@immich/sdk';
import { isEqual } from 'lodash-es';
import { createEventDispatcher } from 'svelte';
import { fade } from 'svelte/transition';
import type { SettingsEventType } from '../admin-settings';
export let savedConfig: SystemConfigDto;
export let defaultConfig: SystemConfigDto;
export let config: SystemConfigDto; // this is the config that is being edited
export let disabled = false;
const dispatch = createEventDispatcher<SettingsEventType>();
let isConfirmOpen = false;
const handleToggleOverride = () => {
// click runs before bind
const previouslyEnabled = config.oauth.mobileOverrideEnabled;
if (!previouslyEnabled && !config.oauth.mobileRedirectUri) {
config.oauth.mobileRedirectUri = window.location.origin + '/api/oauth/mobile-redirect';
}
};
const handleSave = (skipConfirm: boolean) => {
const allMethodsDisabled = !config.oauth.enabled && !config.passwordLogin.enabled;
if (allMethodsDisabled && !skipConfirm) {
isConfirmOpen = true;
return;
}
isConfirmOpen = false;
dispatch('save', { passwordLogin: config.passwordLogin, oauth: config.oauth });
};
</script>
{#if isConfirmOpen}
<ConfirmDialogue
id="disable-login-modal"
title="Disable login"
onClose={() => (isConfirmOpen = false)}
onConfirm={() => handleSave(true)}
>
<svelte:fragment slot="prompt">
<div class="flex flex-col gap-4">
<p>Are you sure you want to disable all login methods? Login will be completely disabled.</p>
<p>
To re-enable, use a
<a
href="https://immich.app/docs/administration/server-commands"
rel="noreferrer"
target="_blank"
class="underline"
>
Server Command</a
>.
</p>
</div>
</svelte:fragment>
</ConfirmDialogue>
{/if}
<div>
<div in:fade={{ duration: 500 }}>
<form autocomplete="off" on:submit|preventDefault>
<div class="ml-4 mt-4 flex flex-col gap-4">
<SettingAccordion key="oauth" title="OAuth" subtitle="Manage OAuth login settings">
<div class="ml-4 mt-4 flex flex-col gap-4">
<p class="text-sm dark:text-immich-dark-fg">
For more details about this feature, refer to the <a
href="https://immich.app/docs/administration/oauth"
class="underline"
target="_blank"
rel="noreferrer">docs</a
>.
</p>
<SettingSwitch
id="login-with-oauth"
{disabled}
title="ENABLE"
subtitle="Login with OAuth"
bind:checked={config.oauth.enabled}
/>
{#if config.oauth.enabled}
<hr />
<SettingInputField
inputType={SettingInputFieldType.TEXT}
label="ISSUER URL"
bind:value={config.oauth.issuerUrl}
required={true}
disabled={disabled || !config.oauth.enabled}
isEdited={!(config.oauth.issuerUrl == savedConfig.oauth.issuerUrl)}
/>
<SettingInputField
inputType={SettingInputFieldType.TEXT}
label="CLIENT ID"
bind:value={config.oauth.clientId}
required={true}
disabled={disabled || !config.oauth.enabled}
isEdited={!(config.oauth.clientId == savedConfig.oauth.clientId)}
/>
<SettingInputField
inputType={SettingInputFieldType.TEXT}
label="CLIENT SECRET"
bind:value={config.oauth.clientSecret}
required={true}
disabled={disabled || !config.oauth.enabled}
isEdited={!(config.oauth.clientSecret == savedConfig.oauth.clientSecret)}
/>
<SettingInputField
inputType={SettingInputFieldType.TEXT}
label="SCOPE"
bind:value={config.oauth.scope}
required={true}
disabled={disabled || !config.oauth.enabled}
isEdited={!(config.oauth.scope == savedConfig.oauth.scope)}
/>
<SettingInputField
inputType={SettingInputFieldType.TEXT}
label="SIGNING ALGORITHM"
bind:value={config.oauth.signingAlgorithm}
required={true}
disabled={disabled || !config.oauth.enabled}
isEdited={!(config.oauth.signingAlgorithm == savedConfig.oauth.signingAlgorithm)}
/>
<SettingInputField
inputType={SettingInputFieldType.TEXT}
label="STORAGE LABEL CLAIM"
desc="Automatically set the user's storage label to the value of this claim."
bind:value={config.oauth.storageLabelClaim}
required={true}
disabled={disabled || !config.oauth.enabled}
isEdited={!(config.oauth.storageLabelClaim == savedConfig.oauth.storageLabelClaim)}
/>
<SettingInputField
inputType={SettingInputFieldType.TEXT}
label="STORAGE QUOTA CLAIM"
desc="Automatically set the user's storage quota to the value of this claim."
bind:value={config.oauth.storageQuotaClaim}
required={true}
disabled={disabled || !config.oauth.enabled}
isEdited={!(config.oauth.storageQuotaClaim == savedConfig.oauth.storageQuotaClaim)}
/>
<SettingInputField
inputType={SettingInputFieldType.NUMBER}
label="DEFAULT STORAGE QUOTA (GiB)"
desc="Quota in GiB to be used when no claim is provided (Enter 0 for unlimited quota)."
bind:value={config.oauth.defaultStorageQuota}
required={true}
disabled={disabled || !config.oauth.enabled}
isEdited={!(config.oauth.defaultStorageQuota == savedConfig.oauth.defaultStorageQuota)}
/>
<SettingInputField
inputType={SettingInputFieldType.TEXT}
label="BUTTON TEXT"
bind:value={config.oauth.buttonText}
required={false}
disabled={disabled || !config.oauth.enabled}
isEdited={!(config.oauth.buttonText == savedConfig.oauth.buttonText)}
/>
<SettingSwitch
id="auto-register-new-users"
title="AUTO REGISTER"
subtitle="Automatically register new users after signing in with OAuth"
bind:checked={config.oauth.autoRegister}
disabled={disabled || !config.oauth.enabled}
/>
<SettingSwitch
id="auto-launch-oauth"
title="AUTO LAUNCH"
subtitle="Start the OAuth login flow automatically upon navigating to the login page"
disabled={disabled || !config.oauth.enabled}
bind:checked={config.oauth.autoLaunch}
/>
<SettingSwitch
id="mobile-redirect-uri-override"
title="MOBILE REDIRECT URI OVERRIDE"
subtitle="Enable when 'app.immich:/' is an invalid redirect URI."
disabled={disabled || !config.oauth.enabled}
on:click={() => handleToggleOverride()}
bind:checked={config.oauth.mobileOverrideEnabled}
/>
{#if config.oauth.mobileOverrideEnabled}
<SettingInputField
inputType={SettingInputFieldType.TEXT}
label="MOBILE REDIRECT URI"
bind:value={config.oauth.mobileRedirectUri}
required={true}
disabled={disabled || !config.oauth.enabled}
isEdited={!(config.oauth.mobileRedirectUri == savedConfig.oauth.mobileRedirectUri)}
/>
{/if}
{/if}
</div>
</SettingAccordion>
<SettingAccordion key="password" title="Password" subtitle="Manage password login settings">
<div class="ml-4 mt-4 flex flex-col gap-4">
<div class="ml-4 mt-4 flex flex-col">
<SettingSwitch
id="enable-password-login"
title="ENABLED"
{disabled}
subtitle="Login with email and password"
bind:checked={config.passwordLogin.enabled}
/>
</div>
</div>
</SettingAccordion>
<SettingButtonsRow
showResetToDefault={!isEqual(savedConfig.passwordLogin, defaultConfig.passwordLogin) ||
!isEqual(savedConfig.oauth, defaultConfig.oauth)}
{disabled}
on:reset={({ detail }) => dispatch('reset', { ...detail, configKeys: ['passwordLogin', 'oauth'] })}
on:save={() => handleSave(false)}
/>
</div>
</form>
</div>
</div>
@@ -1,25 +0,0 @@
<script lang="ts">
import ConfirmDialogue from '$lib/components/shared-components/confirm-dialogue.svelte';
export let onCancel: () => void;
export let onConfirm: () => void;
</script>
<ConfirmDialogue id="disable-login-modal" title="Disable login" onClose={onCancel} {onConfirm}>
<svelte:fragment slot="prompt">
<div class="flex flex-col gap-4">
<p>Are you sure you want to disable all login methods? Login will be completely disabled.</p>
<p>
To re-enable, use a
<a
href="https://immich.app/docs/administration/server-commands"
rel="noreferrer"
target="_blank"
class="underline"
>
Server Command</a
>.
</p>
</div>
</svelte:fragment>
</ConfirmDialogue>
@@ -276,6 +276,15 @@
isEdited={config.ffmpeg.accel !== savedConfig.ffmpeg.accel}
/>
<SettingSwitch
id="hardware-decoding"
title="HARDWARE DECODING"
{disabled}
subtitle="Applies only to NVENC and RKMPP. Enables end-to-end acceleration instead of only accelerating encoding. May not work on all videos."
bind:checked={config.ffmpeg.accelDecode}
isEdited={config.ffmpeg.accelDecode !== savedConfig.ffmpeg.accelDecode}
/>
<SettingSelect
label="CONSTANT QUALITY MODE"
desc="ICQ is better than CQP, but some hardware acceleration devices do not support this mode. Setting this option will prefer the specified mode when using quality-based encoding. Ignored by NVENC as it does not support ICQ."
@@ -297,6 +306,7 @@
bind:checked={config.ffmpeg.temporalAQ}
isEdited={config.ffmpeg.temporalAQ !== savedConfig.ffmpeg.temporalAQ}
/>
<SettingInputField
inputType={SettingInputFieldType.TEXT}
label="PREFERRED HARDWARE DEVICE"
@@ -11,6 +11,7 @@
} 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 { featureFlags } from '$lib/stores/server-config.store';
export let savedConfig: SystemConfigDto;
export let defaultConfig: SystemConfigDto;
@@ -77,6 +78,37 @@
</div>
</SettingAccordion>
<SettingAccordion
key="duplicate-detection"
title="Duplicate Detection"
subtitle="Use CLIP embeddings to find likely duplicates"
>
<div class="ml-4 mt-4 flex flex-col gap-4">
<SettingSwitch
id="enable-duplicate-detection"
title="ENABLED"
subtitle="If disabled, exactly identical assets will still be de-duplicated."
bind:checked={config.machineLearning.duplicateDetection.enabled}
disabled={disabled || !config.machineLearning.enabled || !config.machineLearning.clip.enabled}
/>
<hr />
<SettingInputField
inputType={SettingInputFieldType.NUMBER}
label="MAX DETECTION DISTANCE"
bind:value={config.machineLearning.duplicateDetection.maxDistance}
step="0.01"
min={0.001}
max={0.1}
desc="Maximum distance between two images to consider them duplicates, ranging from 0.001-0.1. Higher values will detect more duplicates, but may result in false positives."
disabled={disabled || $featureFlags.duplicateDetection}
isEdited={config.machineLearning.duplicateDetection.maxDistance !==
savedConfig.machineLearning.duplicateDetection.maxDistance}
/>
</div>
</SettingAccordion>
<SettingAccordion
key="facial-recognition"
title="Facial Recognition"
@@ -1,213 +0,0 @@
<script lang="ts">
import type { SystemConfigDto } from '@immich/sdk';
import { isEqual } from 'lodash-es';
import { createEventDispatcher } from 'svelte';
import { fade } from 'svelte/transition';
import type { SettingsEventType } from '../admin-settings';
import ConfirmDisableLogin from '../confirm-disable-login.svelte';
import SettingButtonsRow from '$lib/components/shared-components/settings/setting-buttons-row.svelte';
import SettingInputField, {
SettingInputFieldType,
} from '$lib/components/shared-components/settings/setting-input-field.svelte';
import SettingSwitch from '$lib/components/shared-components/settings/setting-switch.svelte';
export let savedConfig: SystemConfigDto;
export let defaultConfig: SystemConfigDto;
export let config: SystemConfigDto; // this is the config that is being edited
export let disabled = false;
const dispatch = createEventDispatcher<SettingsEventType>();
const handleToggleOverride = () => {
// click runs before bind
const previouslyEnabled = config.oauth.mobileOverrideEnabled;
if (!previouslyEnabled && !config.oauth.mobileRedirectUri) {
config.oauth.mobileRedirectUri = window.location.origin + '/api/oauth/mobile-redirect';
}
};
let isConfirmOpen = false;
let handleConfirm: (value: boolean) => void;
const openConfirmModal = () => {
return new Promise((resolve) => {
handleConfirm = (value: boolean) => {
isConfirmOpen = false;
resolve(value);
};
isConfirmOpen = true;
});
};
const handleSave = async () => {
if (!savedConfig.passwordLogin.enabled && savedConfig.oauth.enabled && !config.oauth.enabled) {
const confirmed = await openConfirmModal();
if (!confirmed) {
return;
}
}
if (!config.oauth.mobileOverrideEnabled) {
config.oauth.mobileRedirectUri = '';
}
dispatch('save', { oauth: config.oauth });
};
</script>
{#if isConfirmOpen}
<ConfirmDisableLogin onCancel={() => handleConfirm(false)} onConfirm={() => handleConfirm(true)} />
{/if}
<div class="mt-2">
<div in:fade={{ duration: 500 }}>
<form autocomplete="off" on:submit|preventDefault class="mx-4 flex flex-col gap-4 py-4">
<p class="text-sm dark:text-immich-dark-fg">
For more details about this feature, refer to the <a
href="https://immich.app/docs/administration/oauth"
class="underline"
target="_blank"
rel="noreferrer">docs</a
>.
</p>
<SettingSwitch
id="login-with-oauth"
{disabled}
title="ENABLE"
subtitle="Login with OAuth"
bind:checked={config.oauth.enabled}
/>
{#if config.oauth.enabled}
<hr />
<SettingInputField
inputType={SettingInputFieldType.TEXT}
label="ISSUER URL"
bind:value={config.oauth.issuerUrl}
required={true}
disabled={disabled || !config.oauth.enabled}
isEdited={!(config.oauth.issuerUrl == savedConfig.oauth.issuerUrl)}
/>
<SettingInputField
inputType={SettingInputFieldType.TEXT}
label="CLIENT ID"
bind:value={config.oauth.clientId}
required={true}
disabled={disabled || !config.oauth.enabled}
isEdited={!(config.oauth.clientId == savedConfig.oauth.clientId)}
/>
<SettingInputField
inputType={SettingInputFieldType.TEXT}
label="CLIENT SECRET"
bind:value={config.oauth.clientSecret}
required={true}
disabled={disabled || !config.oauth.enabled}
isEdited={!(config.oauth.clientSecret == savedConfig.oauth.clientSecret)}
/>
<SettingInputField
inputType={SettingInputFieldType.TEXT}
label="SCOPE"
bind:value={config.oauth.scope}
required={true}
disabled={disabled || !config.oauth.enabled}
isEdited={!(config.oauth.scope == savedConfig.oauth.scope)}
/>
<SettingInputField
inputType={SettingInputFieldType.TEXT}
label="SIGNING ALGORITHM"
bind:value={config.oauth.signingAlgorithm}
required={true}
disabled={disabled || !config.oauth.enabled}
isEdited={!(config.oauth.signingAlgorithm == savedConfig.oauth.signingAlgorithm)}
/>
<SettingInputField
inputType={SettingInputFieldType.TEXT}
label="STORAGE LABEL CLAIM"
desc="Automatically set the user's storage label to the value of this claim."
bind:value={config.oauth.storageLabelClaim}
required={true}
disabled={disabled || !config.oauth.enabled}
isEdited={!(config.oauth.storageLabelClaim == savedConfig.oauth.storageLabelClaim)}
/>
<SettingInputField
inputType={SettingInputFieldType.TEXT}
label="STORAGE QUOTA CLAIM"
desc="Automatically set the user's storage quota to the value of this claim."
bind:value={config.oauth.storageQuotaClaim}
required={true}
disabled={disabled || !config.oauth.enabled}
isEdited={!(config.oauth.storageQuotaClaim == savedConfig.oauth.storageQuotaClaim)}
/>
<SettingInputField
inputType={SettingInputFieldType.NUMBER}
label="DEFAULT STORAGE QUOTA (GiB)"
desc="Quota in GiB to be used when no claim is provided (Enter 0 for unlimited quota)."
bind:value={config.oauth.defaultStorageQuota}
required={true}
disabled={disabled || !config.oauth.enabled}
isEdited={!(config.oauth.defaultStorageQuota == savedConfig.oauth.defaultStorageQuota)}
/>
<SettingInputField
inputType={SettingInputFieldType.TEXT}
label="BUTTON TEXT"
bind:value={config.oauth.buttonText}
required={false}
disabled={disabled || !config.oauth.enabled}
isEdited={!(config.oauth.buttonText == savedConfig.oauth.buttonText)}
/>
<SettingSwitch
id="auto-register-new-users"
title="AUTO REGISTER"
subtitle="Automatically register new users after signing in with OAuth"
bind:checked={config.oauth.autoRegister}
disabled={disabled || !config.oauth.enabled}
/>
<SettingSwitch
id="auto-launch-oauth"
title="AUTO LAUNCH"
subtitle="Start the OAuth login flow automatically upon navigating to the login page"
disabled={disabled || !config.oauth.enabled}
bind:checked={config.oauth.autoLaunch}
/>
<SettingSwitch
id="mobile-redirect-uri-override"
title="MOBILE REDIRECT URI OVERRIDE"
subtitle="Enable when 'app.immich:/' is an invalid redirect URI."
disabled={disabled || !config.oauth.enabled}
on:click={() => handleToggleOverride()}
bind:checked={config.oauth.mobileOverrideEnabled}
/>
{#if config.oauth.mobileOverrideEnabled}
<SettingInputField
inputType={SettingInputFieldType.TEXT}
label="MOBILE REDIRECT URI"
bind:value={config.oauth.mobileRedirectUri}
required={true}
disabled={disabled || !config.oauth.enabled}
isEdited={!(config.oauth.mobileRedirectUri == savedConfig.oauth.mobileRedirectUri)}
/>
{/if}
{/if}
<SettingButtonsRow
on:reset={({ detail }) => dispatch('reset', { ...detail, configKeys: ['oauth'] })}
on:save={() => handleSave()}
showResetToDefault={!isEqual(savedConfig.oauth, defaultConfig.oauth)}
{disabled}
/>
</form>
</div>
</div>
@@ -1,68 +0,0 @@
<script lang="ts">
import type { SystemConfigDto } from '@immich/sdk';
import { isEqual } from 'lodash-es';
import { createEventDispatcher } from 'svelte';
import { fade } from 'svelte/transition';
import type { SettingsEventType } from '../admin-settings';
import ConfirmDisableLogin from '../confirm-disable-login.svelte';
import SettingButtonsRow from '$lib/components/shared-components/settings/setting-buttons-row.svelte';
import SettingSwitch from '$lib/components/shared-components/settings/setting-switch.svelte';
export let savedConfig: SystemConfigDto;
export let defaultConfig: SystemConfigDto;
export let config: SystemConfigDto; // this is the config that is being edited
export let disabled = false;
const dispatch = createEventDispatcher<SettingsEventType>();
let isConfirmOpen = false;
let handleConfirm: (value: boolean) => void;
const openConfirmModal = () => {
return new Promise((resolve) => {
handleConfirm = (value: boolean) => {
isConfirmOpen = false;
resolve(value);
};
isConfirmOpen = true;
});
};
async function handleSave() {
if (!savedConfig.oauth.enabled && savedConfig.passwordLogin.enabled && !config.passwordLogin.enabled) {
const confirmed = await openConfirmModal();
if (!confirmed) {
return;
}
}
dispatch('save', { passwordLogin: config.passwordLogin });
}
</script>
{#if isConfirmOpen}
<ConfirmDisableLogin onCancel={() => handleConfirm(false)} onConfirm={() => handleConfirm(true)} />
{/if}
<div>
<div in:fade={{ duration: 500 }}>
<form autocomplete="off" on:submit|preventDefault>
<div class="ml-4 mt-4 flex flex-col">
<SettingSwitch
id="enable-password-login"
title="ENABLED"
{disabled}
subtitle="Login with email and password"
bind:checked={config.passwordLogin.enabled}
/>
<SettingButtonsRow
on:reset={({ detail }) => dispatch('reset', { ...detail, configKeys: ['passwordLogin'] })}
on:save={() => handleSave()}
showResetToDefault={!isEqual(savedConfig.passwordLogin, defaultConfig.passwordLogin)}
{disabled}
/>
</div>
</form>
</div>
</div>
@@ -29,17 +29,19 @@
{#if group}
<div class="grid">
<!-- svelte-ignore a11y-click-events-have-key-events -->
<!-- svelte-ignore a11y-no-noninteractive-element-interactions -->
<p on:click={() => toggleAlbumGroupCollapsing(group.id)} class="w-fit mt-2 pt-2 pr-2 mb-2 hover:cursor-pointer">
<button
on:click={() => toggleAlbumGroupCollapsing(group.id)}
class="w-fit mt-2 pt-2 pr-2 mb-2 dark:text-immich-dark-fg"
aria-expanded={!isCollapsed}
>
<Icon
path={mdiChevronRight}
size="24"
class="inline-block -mt-2.5 transition-all duration-[250ms] {iconRotation}"
/>
<span class="font-bold text-3xl text-black dark:text-white">{group.name}</span>
<span class="ml-1.5 dark:text-immich-dark-fg">({albums.length} {albums.length > 1 ? 'albums' : 'album'})</span>
</p>
<span class="ml-1.5">({albums.length} {albums.length > 1 ? 'albums' : 'album'})</span>
</button>
<hr class="dark:border-immich-dark-gray" />
</div>
{/if}
@@ -7,6 +7,7 @@
import { getShortDateRange } from '$lib/utils/date-time';
import AlbumCover from '$lib/components/album-page/album-cover.svelte';
import CircleIconButton from '$lib/components/elements/buttons/circle-icon-button.svelte';
import { s } from '$lib/utils';
export let album: AlbumResponseDto;
export let showOwner = false;
@@ -61,11 +62,11 @@
</p>
{/if}
<span class="flex gap-2 text-sm" data-testid="album-details">
<span class="flex gap-2 text-sm dark:text-immich-dark-fg" data-testid="album-details">
{#if showItemCount}
<p>
{album.assetCount.toLocaleString($locale)}
{album.assetCount === 1 ? `item` : `items`}
item{s(album.assetCount)}
</p>
{/if}
@@ -1,8 +1,8 @@
<script lang="ts">
import { autoGrowHeight } from '$lib/utils/autogrow';
import { autoGrowHeight } from '$lib/actions/autogrow';
import { updateAlbumInfo } from '@immich/sdk';
import { handleError } from '$lib/utils/handle-error';
import { shortcut } from '$lib/utils/shortcut';
import { shortcut } from '$lib/actions/shortcut';
export let id: string;
export let description: string;
@@ -88,7 +88,7 @@
<div class="w-full">{user.name}</div>
<div>Owner</div>
</div>
{#each album.sharedUsers as user (user.id)}
{#each album.albumUsers as { user } (user.id)}
<div class="flex items-center gap-2 py-2">
<div>
<UserAvatar {user} size="md" />
@@ -26,7 +26,7 @@
</script>
<span class="my-2 flex gap-2 text-sm font-medium text-gray-500" data-testid="album-details">
<p>{getDateRange(startDate, endDate)}</p>
<p>·</p>
<p>{album.assetCount} items</p>
<span>{getDateRange(startDate, endDate)}</span>
<span></span>
<span>{album.assetCount} items</span>
</span>
@@ -1,7 +1,7 @@
<script lang="ts">
import { updateAlbumInfo } from '@immich/sdk';
import { handleError } from '$lib/utils/handle-error';
import { shortcut } from '$lib/utils/shortcut';
import { shortcut } from '$lib/actions/shortcut';
export let id: string;
export let albumName: string;
@@ -14,7 +14,7 @@
import ControlAppBar from '../shared-components/control-app-bar.svelte';
import ImmichLogo from '../shared-components/immich-logo.svelte';
import ThemeButton from '../shared-components/theme-button.svelte';
import { shortcut } from '$lib/utils/shortcut';
import { shortcut } from '$lib/actions/shortcut';
import { mdiFileImagePlusOutline, mdiFolderDownloadOutline } from '@mdi/js';
import { handlePromiseError } from '$lib/utils';
import AlbumSummary from './album-summary.svelte';
@@ -75,7 +75,7 @@
{#if sharedLink.allowUpload}
<CircleIconButton
title="Add Photos"
on:click={() => openFileUploadDialog(album.id)}
on:click={() => openFileUploadDialog({ albumId: album.id })}
icon={mdiFileImagePlusOutline}
/>
{/if}
@@ -40,24 +40,30 @@
{#each groupedAlbums as albumGroup (albumGroup.id)}
{@const isCollapsed = isAlbumGroupCollapsed($albumViewSettings, albumGroup.id)}
{@const iconRotation = isCollapsed ? 'rotate-0' : 'rotate-90'}
<!-- svelte-ignore a11y-click-events-have-key-events -->
<!-- svelte-ignore a11y-no-noninteractive-element-interactions -->
<tbody
class="block w-full overflow-y-auto rounded-md border dark:border-immich-dark-gray dark:text-immich-dark-fg mt-4 hover:cursor-pointer"
<button
on:click={() => toggleAlbumGroupCollapsing(albumGroup.id)}
class="flex w-full mt-4 rounded-md"
aria-expanded={!isCollapsed}
>
<tr class="flex w-full place-items-center p-2 md:pl-5 md:pr-5 md:pt-3 md:pb-3">
<td class="text-md text-left -mb-1">
<Icon
path={mdiChevronRight}
size="20"
class="inline-block -mt-2 transition-all duration-[250ms] {iconRotation}"
/>
<span class="font-bold text-2xl">{albumGroup.name}</span>
<span class="ml-1.5">({albumGroup.albums.length} {albumGroup.albums.length > 1 ? 'albums' : 'album'})</span>
</td>
</tr>
</tbody>
<tbody
class="block w-full overflow-y-auto rounded-md border dark:border-immich-dark-gray dark:text-immich-dark-fg"
>
<tr class="flex w-full place-items-center p-2 md:pl-5 md:pr-5 md:pt-3 md:pb-3">
<td class="text-md text-left -mb-1">
<Icon
path={mdiChevronRight}
size="20"
class="inline-block -mt-2 transition-all duration-[250ms] {iconRotation}"
/>
<span class="font-bold text-2xl">{albumGroup.name}</span>
<span class="ml-1.5">
({albumGroup.albums.length}
{albumGroup.albums.length > 1 ? 'albums' : 'album'})
</span>
</td>
</tr>
</tbody>
</button>
{#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"
@@ -42,8 +42,8 @@
users = data.filter((user) => !(user.deletedAt || user.id === album.ownerId));
// Remove the existed shared users from the album
for (const sharedUser of album.sharedUsers) {
users = users.filter((user) => user.id !== sharedUser.id);
for (const sharedUser of album.albumUsers) {
users = users.filter((user) => user.id !== sharedUser.user.id);
}
});
@@ -3,8 +3,8 @@
import { AppRoute, timeBeforeShowLoadingSpinner } from '$lib/constants';
import { getAssetThumbnailUrl, handlePromiseError } from '$lib/utils';
import { getAssetType } from '$lib/utils/asset-utils';
import { autoGrowHeight } from '$lib/utils/autogrow';
import { clickOutside } from '$lib/utils/click-outside';
import { autoGrowHeight } from '$lib/actions/autogrow';
import { clickOutside } from '$lib/actions/click-outside';
import { handleError } from '$lib/utils/handle-error';
import { isTenMinutesApart } from '$lib/utils/timesince';
import {
@@ -25,7 +25,7 @@
import { NotificationType, notificationController } from '../shared-components/notification/notification';
import UserAvatar from '../shared-components/user-avatar.svelte';
import { locale } from '$lib/stores/preferences.store';
import { shortcut } from '$lib/utils/shortcut';
import { shortcut } from '$lib/actions/shortcut';
const units: Intl.RelativeTimeFormatUnit[] = ['year', 'month', 'week', 'day', 'hour', 'minute', 'second'];
@@ -0,0 +1,10 @@
<script lang="ts">
import type { AlbumResponseDto } from '@immich/sdk';
export let album: AlbumResponseDto;
</script>
<span>{album.assetCount} items</span>
{#if album.shared}
<span>• Shared</span>
{/if}
@@ -3,13 +3,13 @@
import { ThumbnailFormat, type AlbumResponseDto } from '@immich/sdk';
import { createEventDispatcher } from 'svelte';
import { normalizeSearchString } from '$lib/utils/string-utils.js';
import AlbumListItemDetails from './album-list-item-details.svelte';
const dispatch = createEventDispatcher<{
album: void;
}>();
export let album: AlbumResponseDto;
export let variant: 'simple' | 'full' = 'full';
export let searchQuery = '';
let albumNameArray: string[] = ['', '', ''];
@@ -31,7 +31,7 @@
on:click={() => dispatch('album')}
class="flex w-full gap-4 px-6 py-2 text-left transition-colors hover:bg-gray-200 dark:hover:bg-gray-700 rounded-xl"
>
<div class="h-12 w-12 shrink-0 rounded-xl bg-slate-300">
<span class="h-12 w-12 shrink-0 rounded-xl bg-slate-300">
{#if album.albumThumbnailAssetId}
<img
src={getAssetThumbnailUrl(album.albumThumbnailAssetId, ThumbnailFormat.Webp)}
@@ -41,18 +41,13 @@
draggable="false"
/>
{/if}
</div>
<div class="flex h-12 flex-col items-start justify-center overflow-hidden">
</span>
<span class="flex h-12 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">
{#if variant === 'simple'}
<span>{album.shared ? 'Shared' : ''}</span>
{:else}
<span>{album.assetCount} items</span>
<span>{album.shared ? ' · Shared' : ''} </span>
{/if}
<AlbumListItemDetails {album} />
</span>
</div>
</span>
</button>
@@ -3,9 +3,10 @@
import { user } from '$lib/stores/user.store';
import { photoZoomState } from '$lib/stores/zoom-image.store';
import { getAssetJobName } from '$lib/utils';
import { clickOutside } from '$lib/utils/click-outside';
import { clickOutside } from '$lib/actions/click-outside';
import { getContextMenuPosition } from '$lib/utils/context-menu';
import { AssetJobName, AssetTypeEnum, type AssetResponseDto, type AlbumResponseDto } from '@immich/sdk';
import { openFileUploadDialog } from '$lib/utils/file-uploader';
import { AssetJobName, AssetTypeEnum, type AlbumResponseDto, type AssetResponseDto } from '@immich/sdk';
import {
mdiAccountCircleOutline,
mdiAlertOutline,
@@ -32,6 +33,7 @@
mdiPlaySpeed,
mdiPresentationPlay,
mdiShareVariantOutline,
mdiUpload,
} from '@mdi/js';
import { createEventDispatcher } from 'svelte';
import ContextMenu from '../shared-components/context-menu/context-menu.svelte';
@@ -49,7 +51,7 @@
export let showSlideshow = false;
export let hasStackChildren = false;
$: isOwner = asset.ownerId === $user?.id;
$: isOwner = $user && asset.ownerId === $user?.id;
type MenuItemEvent =
| 'addToAlbum'
@@ -107,7 +109,10 @@
<div class="text-white">
<CircleIconButton color="opaque" icon={mdiArrowLeft} title="Go back" on:click={() => dispatch('back')} />
</div>
<div class="flex w-[calc(100%-3rem)] justify-end gap-2 overflow-hidden text-white">
<div
class="flex w-[calc(100%-3rem)] justify-end gap-2 overflow-hidden text-white"
data-testid="asset-viewer-navbar-actions"
>
{#if showShareButton}
<CircleIconButton
color="opaque"
@@ -243,6 +248,11 @@
icon={asset.isArchived ? mdiArchiveArrowUpOutline : mdiArchiveArrowDownOutline}
text={asset.isArchived ? 'Unarchive' : 'Archive'}
/>
<MenuOption
icon={mdiUpload}
on:click={() => openFileUploadDialog({ multiple: false, assetId: asset.id })}
text="Replace with upload"
/>
<hr />
<MenuOption
icon={mdiDatabaseRefreshOutline}
@@ -14,7 +14,7 @@
import { getAssetJobMessage, getSharedLink, handlePromiseError, isSharedLink } from '$lib/utils';
import { addAssetsToAlbum, addAssetsToNewAlbum, downloadFile, unstackAssets } from '$lib/utils/asset-utils';
import { handleError } from '$lib/utils/handle-error';
import { shortcuts } from '$lib/utils/shortcut';
import { shortcuts } from '$lib/actions/shortcut';
import { SlideshowHistory } from '$lib/utils/slideshow-history';
import {
AssetJobName,
@@ -52,6 +52,7 @@
import SlideshowBar from './slideshow-bar.svelte';
import VideoViewer from './video-wrapper-viewer.svelte';
import { navigate } from '$lib/utils/navigation';
import { websocketEvents } from '$lib/stores/websocket';
export let assetStore: AssetStore | null = null;
export let asset: AssetResponseDto;
@@ -88,7 +89,7 @@
let isShowProfileImageCrop = false;
let sharedLink = getSharedLink();
let shouldShowDownloadButton = sharedLink ? sharedLink.allowDownload : !asset.isOffline;
let shouldShowDetailButton = asset.hasMetadata;
let enableDetailPanel = asset.hasMetadata;
let shouldShowShareModal = !asset.isTrashed;
let canCopyImagesToClipboard: boolean;
let slideshowStateUnsubscribe: () => void;
@@ -98,7 +99,7 @@
let isLiked: ActivityResponseDto | null = null;
let numberOfComments: number;
let fullscreenElement: Element;
let unsubscribe: () => void;
$: isFullScreen = fullscreenElement !== null;
$: {
@@ -192,6 +193,11 @@
}
onMount(async () => {
unsubscribe = websocketEvents.on('on_upload_success', (assetUpdate) => {
if (assetUpdate.id === asset.id) {
asset = assetUpdate;
}
});
await navigate({ targetRoute: 'current', assetId: asset.id });
slideshowStateUnsubscribe = slideshowState.subscribe((value) => {
if (value === SlideshowState.PlaySlideshow) {
@@ -237,6 +243,7 @@
if (shuffleSlideshowUnsubscribe) {
shuffleSlideshowUnsubscribe();
}
unsubscribe?.();
});
$: asset.id && !sharedLink && handlePromiseError(handleGetAllAlbums()); // Update the album information when the asset ID changes
@@ -574,7 +581,7 @@
showZoomButton={asset.type === AssetTypeEnum.Image}
showMotionPlayButton={!!asset.livePhotoVideoId}
showDownloadButton={shouldShowDownloadButton}
showDetailButton={shouldShowDetailButton}
showDetailButton={enableDetailPanel}
showSlideshow={!!assetStore}
hasStackChildren={$stackAssetsStore.length > 0}
showShareButton={shouldShowShareModal}
@@ -600,7 +607,7 @@
{/if}
{#if $slideshowState === SlideshowState.None && showNavigation}
<div class="z-[1001] column-span-1 col-start-1 row-span-1 row-start-2 justify-self-start">
<div class="z-[1001] my-auto column-span-1 col-start-1 row-span-full row-start-1 justify-self-start">
<NavigationArea onClick={(e) => navigateAsset('previous', e)} label="View previous asset">
<Icon path={mdiChevronLeft} size="36" ariaHidden />
</NavigationArea>
@@ -633,7 +640,9 @@
{:else}
<VideoViewer
assetId={previewStackedAsset.id}
checksum={previewStackedAsset.checksum}
projectionType={previewStackedAsset.exifInfo?.projectionType}
loopVideo={true}
on:close={closeViewer}
on:onVideoEnded={() => navigateAsset()}
on:onVideoStarted={handleVideoStarted}
@@ -654,7 +663,9 @@
{#if shouldPlayMotionPhoto && asset.livePhotoVideoId}
<VideoViewer
assetId={asset.livePhotoVideoId}
checksum={asset.checksum}
projectionType={asset.exifInfo?.projectionType}
loopVideo={$slideshowState !== SlideshowState.PlaySlideshow}
on:close={closeViewer}
on:onVideoEnded={() => (shouldPlayMotionPhoto = false)}
/>
@@ -668,7 +679,9 @@
{:else}
<VideoViewer
assetId={asset.id}
checksum={asset.checksum}
projectionType={asset.exifInfo?.projectionType}
loopVideo={$slideshowState !== SlideshowState.PlaySlideshow}
on:close={closeViewer}
on:onVideoEnded={() => navigateAsset()}
on:onVideoStarted={handleVideoStarted}
@@ -691,14 +704,14 @@
</div>
{#if $slideshowState === SlideshowState.None && showNavigation}
<div class="z-[1001] col-span-1 col-start-4 row-span-1 row-start-2 justify-self-end">
<div class="z-[1001] my-auto col-span-1 col-start-4 row-span-full row-start-1 justify-self-end">
<NavigationArea onClick={(e) => navigateAsset('next', e)} label="View next asset">
<Icon path={mdiChevronRight} size="36" ariaHidden />
</NavigationArea>
</div>
{/if}
{#if $slideshowState === SlideshowState.None && $isShowDetail}
{#if enableDetailPanel && $slideshowState === SlideshowState.None && $isShowDetail}
<div
transition:fly={{ duration: 150 }}
id="detail-panel"
@@ -0,0 +1,62 @@
<script lang="ts">
import { autoGrowHeight } from '$lib/actions/autogrow';
import { clickOutside } from '$lib/actions/click-outside';
import { shortcut } from '$lib/actions/shortcut';
import {
NotificationType,
notificationController,
} from '$lib/components/shared-components/notification/notification';
import { handleError } from '$lib/utils/handle-error';
import { updateAsset, type AssetResponseDto } from '@immich/sdk';
import { tick } from 'svelte';
export let asset: AssetResponseDto;
export let isOwner: boolean;
let textarea: HTMLTextAreaElement;
$: description = asset.exifInfo?.description || '';
$: newDescription = description;
$: if (textarea) {
newDescription;
void tick().then(() => autoGrowHeight(textarea));
}
const handleFocusOut = async () => {
if (description === newDescription) {
return;
}
try {
await updateAsset({ id: asset.id, updateAssetDto: { description: newDescription } });
notificationController.show({
type: NotificationType.Info,
message: 'Asset description has been updated',
});
} catch (error) {
handleError(error, 'Cannot update the description');
}
};
</script>
{#if isOwner}
<section class="px-4 mt-10">
<textarea
bind:this={textarea}
class="max-h-[500px] w-full resize-none border-b border-gray-500 bg-transparent text-base text-black outline-none transition-all focus:border-b-2 focus:border-immich-primary disabled:border-none dark:text-white dark:focus:border-immich-dark-primary immich-scrollbar"
placeholder="Add a description"
on:focusout={handleFocusOut}
on:input={(e) => (newDescription = e.currentTarget.value)}
value={description}
use:clickOutside={{ onOutclick: void handleFocusOut }}
use:shortcut={{
shortcut: { key: 'Enter', ctrl: true },
onShortcut: (e) => e.currentTarget.blur(),
}}
/>
</section>
{:else if description}
<section class="px-4 mt-6">
<p class="break-words whitespace-pre-line w-full text-black dark:text-white text-base">{description}</p>
</section>
{/if}
@@ -0,0 +1,87 @@
<script lang="ts">
import Icon from '$lib/components/elements/icon.svelte';
import ChangeLocation from '$lib/components/shared-components/change-location.svelte';
import Portal from '$lib/components/shared-components/portal/portal.svelte';
import { handleError } from '$lib/utils/handle-error';
import { updateAsset, type AssetResponseDto } from '@immich/sdk';
import { mdiMapMarkerOutline, mdiPencil } from '@mdi/js';
export let isOwner: boolean;
export let asset: AssetResponseDto;
let isShowChangeLocation = false;
async function handleConfirmChangeLocation(gps: { lng: number; lat: number }) {
isShowChangeLocation = false;
try {
asset = await updateAsset({
id: asset.id,
updateAssetDto: { latitude: gps.lat, longitude: gps.lng },
});
} catch (error) {
handleError(error, 'Unable to change location');
}
}
</script>
{#if asset.exifInfo?.city}
<button
type="button"
class="flex w-full text-left justify-between place-items-start gap-4 py-4"
on:click={() => (isOwner ? (isShowChangeLocation = true) : null)}
title={isOwner ? 'Edit location' : ''}
class:hover:dark:text-immich-dark-primary={isOwner}
class:hover:text-immich-primary={isOwner}
>
<div class="flex gap-4">
<div><Icon path={mdiMapMarkerOutline} size="24" /></div>
<div>
<p>{asset.exifInfo.city}</p>
{#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 path={mdiPencil} size="20" />
</div>
{/if}
</button>
{:else if !asset.exifInfo?.city && isOwner}
<button
type="button"
class="flex w-full text-left justify-between place-items-start gap-4 py-4 rounded-lg hover:dark:text-immich-dark-primary hover:text-immich-primary"
on:click={() => (isShowChangeLocation = true)}
title="Add location"
>
<div class="flex gap-4">
<div><Icon path={mdiMapMarkerOutline} size="24" /></div>
<p>Add a location</p>
</div>
<div class="focus:outline-none p-1">
<Icon path={mdiPencil} size="20" />
</div>
</button>
{/if}
{#if isShowChangeLocation}
<Portal>
<ChangeLocation
{asset}
on:confirm={({ detail: gps }) => handleConfirmChangeLocation(gps)}
on:cancel={() => (isShowChangeLocation = false)}
/>
</Portal>
{/if}
@@ -1,4 +1,5 @@
<script lang="ts">
import DetailPanelLocation from '$lib/components/asset-viewer/detail-panel-location.svelte';
import Icon from '$lib/components/elements/icon.svelte';
import ChangeDate from '$lib/components/shared-components/change-date.svelte';
import { AppRoute, QueryParameter, timeToLoadTheMap } from '$lib/constants';
@@ -7,16 +8,15 @@
import { featureFlags } from '$lib/stores/server-config.store';
import { user } from '$lib/stores/user.store';
import { websocketEvents } from '$lib/stores/websocket';
import { getAssetThumbnailUrl, getPeopleThumbnailUrl, isSharedLink, handlePromiseError } from '$lib/utils';
import { delay } from '$lib/utils/asset-utils';
import { autoGrowHeight } from '$lib/utils/autogrow';
import { clickOutside } from '$lib/utils/click-outside';
import { getAssetThumbnailUrl, getPeopleThumbnailUrl, handlePromiseError, isSharedLink } from '$lib/utils';
import { delay, isFlipped } from '$lib/utils/asset-utils';
import {
ThumbnailFormat,
getAssetInfo,
updateAsset,
type AlbumResponseDto,
type AssetResponseDto,
type ExifResponseDto,
} from '@immich/sdk';
import {
mdiCalendar,
@@ -26,31 +26,35 @@
mdiEyeOff,
mdiImageOutline,
mdiInformationOutline,
mdiMapMarkerOutline,
mdiPencil,
} from '@mdi/js';
import { DateTime } from 'luxon';
import { createEventDispatcher, onMount } from 'svelte';
import { slide } from 'svelte/transition';
import { asByteUnitString } from '../../utils/byte-units';
import { handleError } from '../../utils/handle-error';
import { asByteUnitString } from '$lib/utils/byte-units';
import { handleError } from '$lib/utils/handle-error';
import ImageThumbnail from '../assets/thumbnail/image-thumbnail.svelte';
import CircleIconButton from '../elements/buttons/circle-icon-button.svelte';
import PersonSidePanel from '../faces-page/person-side-panel.svelte';
import ChangeLocation from '../shared-components/change-location.svelte';
import UserAvatar from '../shared-components/user-avatar.svelte';
import LoadingSpinner from '../shared-components/loading-spinner.svelte';
import { NotificationType, notificationController } from '../shared-components/notification/notification';
import { shortcut } from '$lib/utils/shortcut';
import AlbumListItemDetails from './album-list-item-details.svelte';
import DetailPanelDescription from '$lib/components/asset-viewer/detail-panel-description.svelte';
export let asset: AssetResponseDto;
export let albums: AlbumResponseDto[] = [];
export let currentAlbum: AlbumResponseDto | null = null;
const getDimensions = (exifInfo: ExifResponseDto) => {
const { exifImageWidth: width, exifImageHeight: height } = exifInfo;
if (isFlipped(exifInfo.orientation)) {
return { width: height, height: width };
}
return { width, height };
};
let showAssetPath = false;
let textArea: HTMLTextAreaElement;
let description: string;
let originalDescription: string;
let showEditFaces = false;
let previousId: string;
@@ -67,16 +71,11 @@
$: isOwner = $user?.id === asset.ownerId;
const handleNewAsset = async (newAsset: AssetResponseDto) => {
description = newAsset?.exifInfo?.description || '';
// Get latest description from server
// TODO: check if reloading asset data is necessary
if (newAsset.id && !isSharedLink()) {
const data = await getAssetInfo({ id: asset.id });
people = data?.people || undefined;
description = data.exifInfo?.description || '';
}
originalDescription = description;
};
$: handlePromiseError(handleNewAsset(asset));
@@ -119,28 +118,10 @@
const handleRefreshPeople = async () => {
await getAssetInfo({ id: asset.id }).then((data) => {
people = data?.people || undefined;
textArea.value = data?.exifInfo?.description || '';
});
showEditFaces = false;
};
const handleFocusOut = async () => {
textArea.blur();
if (description === originalDescription) {
return;
}
originalDescription = description;
try {
await updateAsset({ id: asset.id, updateAssetDto: { description } });
notificationController.show({
type: NotificationType.Info,
message: 'Asset description has been updated',
});
} catch (error) {
handleError(error, 'Cannot update the description');
}
};
const toggleAssetPath = () => (showAssetPath = !showAssetPath);
let isShowChangeDate = false;
@@ -153,18 +134,6 @@
handleError(error, 'Unable to change date');
}
}
let isShowChangeLocation = false;
async function handleConfirmChangeLocation(gps: { lng: number; lat: number }) {
isShowChangeLocation = false;
try {
await updateAsset({ id: asset.id, updateAssetDto: { latitude: gps.lat, longitude: gps.lng } });
} catch (error) {
handleError(error, 'Unable to change location');
}
}
</script>
<section class="relative p-2 dark:bg-immich-dark-bg dark:text-immich-dark-fg">
@@ -187,31 +156,7 @@
</section>
{/if}
{#if isOwner}
<section class="px-4 mt-10">
{#key asset.id}
<textarea
disabled={!isOwner || isSharedLink()}
bind:this={textArea}
class="max-h-[500px]
w-full resize-none border-b border-gray-500 bg-transparent text-base text-black outline-none transition-all focus:border-b-2 focus:border-immich-primary disabled:border-none dark:text-white dark:focus:border-immich-dark-primary immich-scrollbar"
placeholder={isOwner ? 'Add a description' : ''}
on:focusout={handleFocusOut}
on:input={() => autoGrowHeight(textArea)}
bind:value={description}
use:autoGrowHeight
use:clickOutside
on:outclick={handleFocusOut}
use:shortcut={{
shortcut: { key: 'Enter', ctrl: true },
onShortcut: () => handlePromiseError(handleFocusOut()),
}}
/>
{/key}
</section>
{:else if description}
<p class="px-4 break-words whitespace-pre-line w-full text-black dark:text-white text-base">{description}</p>
{/if}
<DetailPanelDescription {asset} {isOwner} />
{#if !isSharedLink() && people?.numberOfFaces && people?.numberOfFaces > 0}
<section class="px-4 py-4 text-sm">
@@ -410,8 +355,8 @@
{getMegapixel(asset.exifInfo.exifImageHeight, asset.exifInfo.exifImageWidth)} MP
</p>
{/if}
<p>{asset.exifInfo.exifImageHeight} x {asset.exifInfo.exifImageWidth}</p>
{@const { width, height } = getDimensions(asset.exifInfo)}
<p>{width} x {height}</p>
{/if}
<p>{asByteUnitString(asset.exifInfo.fileSizeInByte, $locale)}</p>
</div>
@@ -453,65 +398,7 @@
</div>
{/if}
{#if asset.exifInfo?.city}
<button
type="button"
class="flex w-full text-left justify-between place-items-start gap-4 py-4"
on:click={() => (isOwner ? (isShowChangeLocation = true) : null)}
title={isOwner ? 'Edit location' : ''}
class:hover:dark:text-immich-dark-primary={isOwner}
class:hover:text-immich-primary={isOwner}
>
<div class="flex gap-4">
<div><Icon path={mdiMapMarkerOutline} size="24" /></div>
<div>
<p>{asset.exifInfo.city}</p>
{#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 path={mdiPencil} size="20" />
</div>
{/if}
</button>
{:else if !asset.exifInfo?.city && isOwner}
<button
type="button"
class="flex w-full text-left justify-between place-items-start gap-4 py-4 rounded-lg hover:dark:text-immich-dark-primary hover:text-immich-primary"
on:click={() => (isShowChangeLocation = true)}
title="Add location"
>
<div class="flex gap-4">
<div>
<div><Icon path={mdiMapMarkerOutline} size="24" /></div>
</div>
<p>Add a location</p>
</div>
<div class="focus:outline-none p-1">
<Icon path={mdiPencil} size="20" />
</div>
</button>
{/if}
{#if isShowChangeLocation}
<ChangeLocation
{asset}
on:confirm={({ detail: gps }) => handleConfirmChangeLocation(gps)}
on:cancel={() => (isShowChangeLocation = false)}
/>
{/if}
<DetailPanelLocation {isOwner} {asset} />
</div>
</section>
@@ -560,7 +447,7 @@
</div>
{/if}
{#if currentAlbum && currentAlbum.sharedUsers.length > 0 && asset.owner}
{#if currentAlbum && currentAlbum.albumUsers.length > 0 && asset.owner}
<section class="px-6 dark:text-immich-dark-fg mt-4">
<p class="text-sm">SHARED BY</p>
<div class="flex gap-4 pt-4">
@@ -597,10 +484,7 @@
<p class="dark:text-immich-dark-primary">{album.albumName}</p>
<div class="flex flex-col gap-0 text-sm">
<div>
<span>{album.assetCount} items</span>
{#if album.shared}
<span> • Shared</span>
{/if}
<AlbumListItemDetails {album} />
</div>
</div>
</div>
@@ -5,7 +5,7 @@
<!-- svelte-ignore a11y-no-static-element-interactions -->
<!-- svelte-ignore a11y-click-events-have-key-events -->
<div class="group flex h-full place-items-center hover:cursor-pointer" on:click={onClick}>
<div class="my-auto group hover:cursor-pointer" on:click={onClick}>
<button
class="mx-4 rounded-full p-3 text-gray-500 transition group-hover:bg-gray-500 group-hover:text-white"
aria-label={label}
@@ -3,10 +3,10 @@
import { boundingBoxesArray } from '$lib/stores/people.store';
import { alwaysLoadOriginalFile } from '$lib/stores/preferences.store';
import { photoZoomState } from '$lib/stores/zoom-image.store';
import { downloadRequest, getAssetFileUrl, handlePromiseError } from '$lib/utils';
import { getAssetFileUrl, handlePromiseError } from '$lib/utils';
import { isWebCompatibleImage } from '$lib/utils/asset-utils';
import { getBoundingBox } from '$lib/utils/people-utils';
import { shortcuts } from '$lib/utils/shortcut';
import { shortcuts } from '$lib/actions/shortcut';
import { type AssetResponseDto, AssetTypeEnum } from '@immich/sdk';
import { useZoomImageWheel } from '@zoom-image/svelte';
import { onDestroy, onMount } from 'svelte';
@@ -24,12 +24,14 @@
export let haveFadeTransition = true;
let imgElement: HTMLDivElement;
let assetData: string;
let abortController: AbortController;
let hasZoomed = false;
let assetFileUrl: string = '';
let copyImageToClipboard: (source: string) => Promise<Blob>;
let canCopyImagesToClipboard: () => boolean;
let imageLoaded: boolean = false;
let imageError: boolean = false;
// set to true when am image has been zoomed, to force loading of the original image regardless
// of app settings
let forceLoadOriginal: boolean = false;
const loadOriginalByDefault = $alwaysLoadOriginalFile && isWebCompatibleImage(asset);
@@ -40,60 +42,53 @@
});
}
$: {
preload({ preloadAssets, loadOriginal: loadOriginalByDefault });
}
$: assetFileUrl = load(asset.id, !loadOriginalByDefault || forceLoadOriginal, false, asset.checksum);
onMount(async () => {
// Import hack :( see https://github.com/vadimkorr/svelte-carousel/issues/27#issuecomment-851022295
// TODO: Move to regular import once the package correctly supports ESM.
const module = await import('copy-image-clipboard');
copyImageToClipboard = module.copyImageToClipboard;
canCopyImagesToClipboard = module.canCopyImagesToClipboard;
imageLoaded = false;
await loadAssetData({ loadOriginal: loadOriginalByDefault });
});
onDestroy(() => {
$boundingBoxesArray = [];
abortController?.abort();
});
const loadAssetData = async ({ loadOriginal }: { loadOriginal: boolean }) => {
try {
abortController?.abort();
abortController = new AbortController();
// TODO: Use sdk once it supports signals
const { data } = await downloadRequest({
url: getAssetFileUrl(asset.id, !loadOriginal, false),
signal: abortController.signal,
});
assetData = URL.createObjectURL(data);
imageLoaded = true;
if (!preloadAssets) {
return;
const preload = ({
preloadAssets,
loadOriginal,
}: {
preloadAssets: AssetResponseDto[] | null;
loadOriginal: boolean;
}) => {
for (const preloadAsset of preloadAssets || []) {
if (preloadAsset.type === AssetTypeEnum.Image) {
let img = new Image();
img.src = getAssetFileUrl(preloadAsset.id, !loadOriginal, false, preloadAsset.checksum);
}
for (const preloadAsset of preloadAssets) {
if (preloadAsset.type === AssetTypeEnum.Image) {
await downloadRequest({
url: getAssetFileUrl(preloadAsset.id, !loadOriginal, false),
signal: abortController.signal,
});
}
}
} catch {
imageLoaded = false;
}
};
const load = (assetId: string, isWeb: boolean, isThumb: boolean, checksum: string) => {
const assetUrl = getAssetFileUrl(assetId, isWeb, isThumb, checksum);
// side effect, only flag imageLoaded when url is different
imageLoaded = assetFileUrl === assetUrl;
return assetUrl;
};
const doCopy = async () => {
if (!canCopyImagesToClipboard()) {
return;
}
try {
await copyImageToClipboard(assetData);
await copyImageToClipboard(assetFileUrl);
notificationController.show({
type: NotificationType.Info,
message: 'Copied image to clipboard.',
@@ -122,18 +117,14 @@
zoomImageWheelState.subscribe((state) => {
photoZoomState.set(state);
if (state.currentZoom > 1 && isWebCompatibleImage(asset) && !hasZoomed && !$alwaysLoadOriginalFile) {
hasZoomed = true;
handlePromiseError(loadAssetData({ loadOriginal: true }));
}
forceLoadOriginal = state.currentZoom > 1 && isWebCompatibleImage(asset) ? true : false;
});
const onCopyShortcut = () => {
const onCopyShortcut = (event: KeyboardEvent) => {
if (window.getSelection()?.type === 'Range') {
return;
}
event.preventDefault();
handlePromiseError(doCopy());
};
</script>
@@ -142,45 +133,57 @@
on:copyImage={doCopy}
on:zoomImage={doZoomImage}
use:shortcuts={[
{ shortcut: { key: 'c', ctrl: true }, onShortcut: onCopyShortcut },
{ shortcut: { key: 'c', meta: true }, onShortcut: onCopyShortcut },
{ shortcut: { key: 'c', ctrl: true }, onShortcut: onCopyShortcut, preventDefault: false },
{ shortcut: { key: 'c', meta: true }, onShortcut: onCopyShortcut, preventDefault: false },
]}
/>
<div
bind:this={element}
transition:fade={{ duration: haveFadeTransition ? 150 : 0 }}
class="relative h-full select-none"
>
{#if imageError}
<div class="h-full flex items-center justify-center">Error loading image</div>
{/if}
<div bind:this={element} class="relative h-full select-none">
<img
style="display:none"
src={assetFileUrl}
alt={getAltText(asset)}
on:load={() => (imageLoaded = true)}
on:error={() => (imageError = imageLoaded = true)}
/>
{#if !imageLoaded}
<div class="flex h-full items-center justify-center">
<div class:hidden={imageLoaded} class="flex h-full items-center justify-center">
<LoadingSpinner />
</div>
{:else}
<div bind:this={imgElement} class="h-full w-full" transition:fade={{ duration: haveFadeTransition ? 150 : 0 }}>
{#if $slideshowState !== SlideshowState.None && $slideshowLook === SlideshowLook.BlurredBackground}
{:else if !imageError}
{#key assetFileUrl}
<div
bind:this={imgElement}
class:hidden={!imageLoaded}
class="h-full w-full"
transition:fade={{ duration: haveFadeTransition ? 150 : 0 }}
>
{#if $slideshowState !== SlideshowState.None && $slideshowLook === SlideshowLook.BlurredBackground}
<img
src={assetFileUrl}
alt={getAltText(asset)}
class="absolute top-0 left-0 -z-10 object-cover h-full w-full blur-lg"
draggable="false"
/>
{/if}
<img
src={assetData}
bind:this={$photoViewer}
src={assetFileUrl}
alt={getAltText(asset)}
class="absolute top-0 left-0 -z-10 object-cover h-full w-full blur-lg"
class="h-full w-full {$slideshowState === SlideshowState.None
? 'object-contain'
: slideshowLookCssMapping[$slideshowLook]}"
draggable="false"
/>
{/if}
<img
bind:this={$photoViewer}
src={assetData}
alt={getAltText(asset)}
class="h-full w-full {$slideshowState === SlideshowState.None
? 'object-contain'
: slideshowLookCssMapping[$slideshowLook]}"
draggable="false"
/>
{#each getBoundingBox($boundingBoxesArray, $photoZoomState, $photoViewer) as boundingbox}
<div
class="absolute border-solid border-white border-[3px] rounded-lg"
style="top: {boundingbox.top}px; left: {boundingbox.left}px; height: {boundingbox.height}px; width: {boundingbox.width}px;"
/>
{/each}
</div>
{#each getBoundingBox($boundingBoxesArray, $photoZoomState, $photoViewer) as boundingbox}
<div
class="absolute border-solid border-white border-[3px] rounded-lg"
style="top: {boundingbox.top}px; left: {boundingbox.left}px; height: {boundingbox.height}px; width: {boundingbox.width}px;"
/>
{/each}
</div>
{/key}
{/if}
</div>
@@ -1,5 +1,5 @@
<script lang="ts">
import { videoViewerVolume } from '$lib/stores/preferences.store';
import { loopVideo as loopVideoPreference, videoViewerVolume, videoViewerMuted } from '$lib/stores/preferences.store';
import { getAssetFileUrl, getAssetThumbnailUrl } from '$lib/utils';
import { handleError } from '$lib/utils/handle-error';
import { ThumbnailFormat } from '@immich/sdk';
@@ -8,18 +8,27 @@
import LoadingSpinner from '../shared-components/loading-spinner.svelte';
export let assetId: string;
export let loopVideo: boolean;
export let checksum: string;
let element: HTMLVideoElement | undefined = undefined;
let isVideoLoading = true;
let assetFileUrl: string;
$: {
const next = getAssetFileUrl(assetId, false, true, checksum);
if (assetFileUrl !== next) {
assetFileUrl = next;
element && element.load();
}
}
const dispatch = createEventDispatcher<{ onVideoEnded: void; onVideoStarted: void }>();
const handleCanPlay = async (event: Event) => {
try {
const video = event.currentTarget as HTMLVideoElement;
video.muted = true;
await video.play();
video.muted = false;
dispatch('onVideoStarted');
} catch (error) {
handleError(error, 'Unable to play video');
@@ -36,16 +45,18 @@
>
<video
bind:this={element}
loop={$loopVideoPreference && loopVideo}
autoplay
playsinline
controls
class="h-full object-contain"
on:canplay={handleCanPlay}
on:ended={() => dispatch('onVideoEnded')}
bind:muted={$videoViewerMuted}
bind:volume={$videoViewerVolume}
poster={getAssetThumbnailUrl(assetId, ThumbnailFormat.Jpeg)}
poster={getAssetThumbnailUrl(assetId, ThumbnailFormat.Jpeg, checksum)}
>
<source src={getAssetFileUrl(assetId, false, true)} type="video/mp4" />
<source src={assetFileUrl} type="video/mp4" />
<track kind="captions" />
</video>
@@ -6,10 +6,12 @@
export let assetId: string;
export let projectionType: string | null | undefined;
export let checksum: string;
export let loopVideo: boolean;
</script>
{#if projectionType === ProjectionType.EQUIRECTANGULAR}
<PanoramaViewer asset={{ id: assetId, type: AssetTypeEnum.Video }} />
{:else}
<VideoNativeViewer {assetId} on:onVideoEnded on:onVideoStarted />
<VideoNativeViewer {loopVideo} {checksum} {assetId} on:onVideoEnded on:onVideoStarted />
{/if}
@@ -21,7 +21,7 @@
import { fade } from 'svelte/transition';
import ImageThumbnail from './image-thumbnail.svelte';
import VideoThumbnail from './video-thumbnail.svelte';
import { shortcut } from '$lib/utils/shortcut';
import { shortcut } from '$lib/actions/shortcut';
const dispatch = createEventDispatcher<{
select: { asset: AssetResponseDto };
@@ -180,7 +180,7 @@
{#if asset.resized}
<ImageThumbnail
url={getAssetThumbnailUrl(asset.id, format)}
url={getAssetThumbnailUrl(asset.id, format, asset.checksum)}
altText={getAltText(asset)}
widthStyle="{width}px"
heightStyle="{height}px"
@@ -196,7 +196,7 @@
{#if asset.type === AssetTypeEnum.Video}
<div class="absolute top-0 h-full w-full">
<VideoThumbnail
url={getAssetFileUrl(asset.id, false, true)}
url={getAssetFileUrl(asset.id, false, true, asset.checksum)}
enablePlayback={mouseOver && $playVideoThumbnailOnHover}
curve={selected}
durationInSeconds={timeToSeconds(asset.duration)}
@@ -208,7 +208,7 @@
{#if asset.type === AssetTypeEnum.Image && asset.livePhotoVideoId}
<div class="absolute top-0 h-full w-full">
<VideoThumbnail
url={getAssetFileUrl(asset.livePhotoVideoId, false, true)}
url={getAssetFileUrl(asset.livePhotoVideoId, false, true, asset.checksum)}
pauseIcon={mdiMotionPauseOutline}
playIcon={mdiMotionPlayOutline}
showTime={false}
@@ -43,7 +43,7 @@
'text-immich-primary dark:text-immich-dark-primary enabled:dark:hover:bg-immich-dark-primary/10 enabled:hover:bg-immich-primary/10',
'light-red': 'bg-[#F9DEDC] text-[#410E0B] enabled:hover:bg-red-50',
red: 'bg-red-500 text-white enabled:hover:bg-red-400',
green: 'bg-green-500 text-gray-800 enabled:hover:bg-green-400/90',
green: 'bg-green-400 text-gray-800 enabled:hover:bg-green-400/90',
gray: 'bg-gray-500 dark:bg-gray-200 enabled:hover:bg-gray-500/75 enabled:dark:hover:bg-gray-200/80 text-white dark:text-immich-dark-gray',
'transparent-gray':
'dark:text-immich-dark-fg enabled:hover:bg-immich-primary/5 enabled:hover:text-gray-700 enabled:hover:dark:text-immich-dark-fg enabled:dark:hover:bg-immich-dark-primary/25',
@@ -17,4 +17,9 @@
{value}
on:input={(e) => (updatedValue = e.currentTarget.value)}
on:blur={() => (value = updatedValue)}
on:keydown={(e) => {
if (e.key === 'Enter') {
value = updatedValue;
}
}}
/>
@@ -17,7 +17,7 @@
import { isEqual } from 'lodash-es';
import LinkButton from './buttons/link-button.svelte';
import { clickOutside } from '$lib/utils/click-outside';
import { clickOutside } from '$lib/actions/click-outside';
import { fly } from 'svelte/transition';
import { createEventDispatcher } from 'svelte';
@@ -1,4 +1,5 @@
<script lang="ts">
import { initInput } from '$lib/actions/focus';
import SearchBar from '$lib/components/elements/search-bar.svelte';
import { maximumLengthSearchPeople, timeBeforeShowLoadingSpinner } from '$lib/constants';
import { handleError } from '$lib/utils/handle-error';
@@ -70,10 +71,6 @@
}
};
const initInput = (element: HTMLInputElement) => {
element.focus();
};
const handleReset = () => {
reset();
onReset();
@@ -1,24 +1,46 @@
<script lang="ts" context="module">
export enum ToggleVisibilty {
HIDE_ALL = 'hide-all',
HIDE_UNNANEMD = 'hide-unnamed',
VIEW_ALL = 'view-all',
}
</script>
<script lang="ts">
import { fly } from 'svelte/transition';
import CircleIconButton from '../elements/buttons/circle-icon-button.svelte';
import { quintOut } from 'svelte/easing';
import { createEventDispatcher } from 'svelte';
import LoadingSpinner from '$lib/components/shared-components/loading-spinner.svelte';
import { mdiClose, mdiEye, mdiEyeOff, mdiRestart } from '@mdi/js';
import { mdiClose, mdiEye, mdiEyeOff, mdiEyeSettings, mdiRestart } from '@mdi/js';
import { locale } from '$lib/stores/preferences.store';
import Button from '$lib/components/elements/buttons/button.svelte';
const dispatch = createEventDispatcher<{
close: void;
reset: void;
change: void;
done: void;
}>();
export let showLoadingSpinner: boolean;
export let toggleVisibility: boolean;
export let toggleVisibility: ToggleVisibilty = ToggleVisibilty.VIEW_ALL;
export let screenHeight: number;
export let countTotalPeople: number;
export let onClose: () => void;
export let onReset: () => void;
export let onChange: (toggleVisibility: ToggleVisibilty) => void;
export let onDone: () => void;
const getNextVisibility = (toggleVisibility: ToggleVisibilty) => {
if (toggleVisibility === ToggleVisibilty.VIEW_ALL) {
return ToggleVisibilty.HIDE_UNNANEMD;
} else if (toggleVisibility === ToggleVisibilty.HIDE_UNNANEMD) {
return ToggleVisibilty.HIDE_ALL;
} else {
return ToggleVisibilty.VIEW_ALL;
}
};
const toggleIconOptions: Record<ToggleVisibilty, string> = {
[ToggleVisibilty.HIDE_ALL]: mdiEyeOff,
[ToggleVisibilty.HIDE_UNNANEMD]: mdiEyeSettings,
[ToggleVisibilty.VIEW_ALL]: mdiEye,
};
$: toggleIcon = toggleIconOptions[toggleVisibility];
</script>
<section
@@ -29,7 +51,7 @@
class="fixed top-0 z-10 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">
<CircleIconButton title="Close" icon={mdiClose} on:click={() => dispatch('close')} />
<CircleIconButton title="Close" icon={mdiClose} on:click={onClose} />
<div class="flex gap-2 items-center">
<p class="ml-2">Show & hide people</p>
<p class="text-sm text-gray-400 dark:text-gray-600">({countTotalPeople.toLocaleString($locale)})</p>
@@ -37,15 +59,15 @@
</div>
<div class="flex items-center justify-end">
<div class="flex items-center md:mr-8">
<CircleIconButton title="Reset people visibility" icon={mdiRestart} on:click={() => dispatch('reset')} />
<CircleIconButton title="Reset people visibility" icon={mdiRestart} on:click={onReset} />
<CircleIconButton
title="Toggle visibility"
icon={toggleVisibility ? mdiEye : mdiEyeOff}
on:click={() => dispatch('change')}
icon={toggleIcon}
on:click={() => onChange(getNextVisibility(toggleVisibility))}
/>
</div>
{#if !showLoadingSpinner}
<Button on:click={() => dispatch('done')} size="sm" rounded="lg">Done</Button>
<Button on:click={onDone} size="sm" rounded="lg">Done</Button>
{:else}
<LoadingSpinner />
{/if}
@@ -20,6 +20,7 @@
import { NotificationType, notificationController } from '../shared-components/notification/notification';
import FaceThumbnail from './face-thumbnail.svelte';
import PeopleList from './people-list.svelte';
import { s } from '$lib/utils';
export let assetIds: string[];
export let personAssets: PersonResponseDto;
@@ -78,7 +79,7 @@
await reassignFaces({ id: data.id, assetFaceUpdateDto: { data: selectedPeople } });
notificationController.show({
message: `Re-assigned ${assetIds.length} asset${assetIds.length > 1 ? 's' : ''} to a new person`,
message: `Re-assigned ${assetIds.length} asset${s(assetIds.length)} to a new person`,
type: NotificationType.Info,
});
} catch (error) {
@@ -98,7 +99,7 @@
if (selectedPerson) {
await reassignFaces({ id: selectedPerson.id, assetFaceUpdateDto: { data: selectedPeople } });
notificationController.show({
message: `Re-assigned ${assetIds.length} asset${assetIds.length > 1 ? 's' : ''} to ${
message: `Re-assigned ${assetIds.length} asset${s(assetIds.length)} to ${
selectedPerson.name || 'an existing person'
}`,
type: NotificationType.Info,
@@ -1,7 +1,7 @@
<script lang="ts">
import { copyToClipboard } from '$lib/utils';
import { mdiKeyVariant } from '@mdi/js';
import { createEventDispatcher, onMount } from 'svelte';
import { createEventDispatcher } from 'svelte';
import Button from '../elements/buttons/button.svelte';
import FullScreenModal from '../shared-components/full-screen-modal.svelte';
@@ -11,12 +11,6 @@
done: void;
}>();
const handleDone = () => dispatch('done');
let canCopyImagesToClipboard = true;
onMount(async () => {
const module = await import('copy-image-clipboard');
canCopyImagesToClipboard = module.canCopyImagesToClipboard();
});
</script>
<FullScreenModal id="api-key-secret-modal" title="API key" icon={mdiKeyVariant} onClose={() => handleDone()}>
@@ -32,9 +26,7 @@
</div>
<svelte:fragment slot="sticky-bottom">
{#if canCopyImagesToClipboard}
<Button on:click={() => copyToClipboard(secret)} fullwidth>Copy to Clipboard</Button>
{/if}
<Button on:click={() => copyToClipboard(secret)} fullwidth>Copy to Clipboard</Button>
<Button on:click={() => handleDone()} fullwidth>Done</Button>
</svelte:fragment>
</FullScreenModal>
@@ -40,7 +40,7 @@
folders that contain files you don't want to import, such as RAW files.
<br /><br />
Add exclusion patterns. Globbing using *, **, and ? is supported. To ignore all files in any directory named "Raw",
use "**/Raw/**". To ignore all files ending in ".tif", use "**/*.tif". To ignore an absolute path, use "/path/to/ignore".
use "**/Raw/**". To ignore all files ending in ".tif", use "**/*.tif". To ignore an absolute path, use "/path/to/ignore/**".
</p>
<div class="my-4 flex flex-col gap-2">
<label class="immich-form-label" for="exclusionPattern">Pattern</label>
@@ -9,6 +9,7 @@
import type { ValidateLibraryImportPathResponseDto } from '@immich/sdk';
import { NotificationType, notificationController } from '../shared-components/notification/notification';
import CircleIconButton from '$lib/components/elements/buttons/circle-icon-button.svelte';
import { s } from '$lib/utils';
export let library: LibraryResponseDto;
@@ -56,14 +57,9 @@
type: NotificationType.Info,
});
}
} else if (failedPaths === 1) {
notificationController.show({
message: `${failedPaths} path failed validation`,
type: NotificationType.Warning,
});
} else {
notificationController.show({
message: `${failedPaths} paths failed validation`,
message: `${failedPaths} path${s(failedPaths)} failed validation`,
type: NotificationType.Warning,
});
}
@@ -1,5 +1,5 @@
<script lang="ts">
import { LibraryType, type LibraryResponseDto } from '@immich/sdk';
import { type LibraryResponseDto } from '@immich/sdk';
import { mdiPencilOutline } from '@mdi/js';
import { createEventDispatcher, onMount } from 'svelte';
import { handleError } from '../../utils/handle-error';
@@ -27,14 +27,14 @@
const dispatch = createEventDispatcher<{
cancel: void;
submit: { library: Partial<LibraryResponseDto>; type: LibraryType };
submit: Partial<LibraryResponseDto>;
}>();
const handleCancel = () => {
dispatch('cancel');
};
const handleSubmit = () => {
dispatch('submit', { library, type: LibraryType.External });
dispatch('submit', library);
};
const handleAddExclusionPattern = () => {
@@ -30,7 +30,12 @@
<SettingSwitch id="allow-dark-mode" title="Allow dark mode" bind:checked={settings.allowDarkMode} />
<SettingSwitch id="only-favorites" title="Only favorites" bind:checked={settings.onlyFavorites} />
<SettingSwitch id="include-archived" title="Include archived" bind:checked={settings.includeArchived} />
<SettingSwitch id="include-shared-with-me" title="Include shared with me" bind:checked={settings.withPartners} />
<SettingSwitch
id="include-shared-partner-assets"
title="Include shared partner assets"
bind:checked={settings.withPartners}
/>
<SettingSwitch id="include-shared-albums" title="Include shared albums" bind:checked={settings.withSharedAlbums} />
{#if customDateRange}
<div in:fly={{ y: 10, duration: 200 }} class="flex flex-col gap-4">
<div class="flex items-center justify-between gap-8">
@@ -19,7 +19,7 @@
import { type Viewport } from '$lib/stores/assets.store';
import { memoryStore } from '$lib/stores/memory.store';
import { getAssetThumbnailUrl, handlePromiseError, memoryLaneTitle } from '$lib/utils';
import { shortcuts } from '$lib/utils/shortcut';
import { shortcuts } from '$lib/actions/shortcut';
import { fromLocalDateTime } from '$lib/utils/timeline-util';
import { ThumbnailFormat, getMemoryLane, type AssetResponseDto } from '@immich/sdk';
import {
@@ -9,6 +9,7 @@
import { mdiDeleteOutline, mdiImageRemoveOutline } from '@mdi/js';
import MenuOption from '../../shared-components/context-menu/menu-option.svelte';
import { getAssetControlContext } from '../asset-select-control-bar.svelte';
import { s } from '$lib/utils';
export let album: AlbumResponseDto;
export let onRemove: ((assetIds: string[]) => void) | undefined;
@@ -33,7 +34,7 @@
const count = results.filter(({ success }) => success).length;
notificationController.show({
type: NotificationType.Info,
message: `Removed ${count} asset${count === 1 ? '' : 's'}`,
message: `Removed ${count} asset${s(count)}`,
});
clearSelect();
@@ -153,7 +153,7 @@
</div>
{/if}
<span class="truncate first-letter:capitalize" title={groupTitle}>
<span class="w-full truncate first-letter:capitalize" title={groupTitle}>
{groupTitle}
</span>
</div>
@@ -8,7 +8,7 @@
import { isSearchEnabled } from '$lib/stores/search.store';
import { featureFlags } from '$lib/stores/server-config.store';
import { deleteAssets } from '$lib/utils/actions';
import { type ShortcutOptions, shortcuts } from '$lib/utils/shortcut';
import { type ShortcutOptions, shortcuts } from '$lib/actions/shortcut';
import { formatGroupTitle, splitBucketIntoDateGroups } from '$lib/utils/timeline-util';
import type { AlbumResponseDto, AssetResponseDto } from '@immich/sdk';
import { DateTime } from 'luxon';
@@ -101,6 +101,12 @@
}
};
const focusElement = () => {
if (document.activeElement === document.body) {
element.focus();
}
};
$: shortcutList = (() => {
if ($isSearchEnabled || $showAssetViewer) {
return [];
@@ -111,8 +117,8 @@
{ shortcut: { key: '?', shift: true }, onShortcut: () => (showShortcuts = !showShortcuts) },
{ shortcut: { key: '/' }, onShortcut: () => goto(AppRoute.EXPLORE) },
{ shortcut: { key: 'A', ctrl: true }, onShortcut: () => selectAllAssets(assetStore, assetInteractionStore) },
{ shortcut: { key: 'PageUp' }, onShortcut: () => (element.scrollTop = 0) },
{ shortcut: { key: 'PageDown' }, onShortcut: () => (element.scrollTop = viewport.height) },
{ shortcut: { key: 'PageDown' }, preventDefault: false, onShortcut: focusElement },
{ shortcut: { key: 'PageUp' }, preventDefault: false, onShortcut: focusElement },
];
if ($isMultiSelectState) {
@@ -406,7 +412,8 @@
<!-- Right margin MUST be equal to the width of immich-scrubbable-scrollbar -->
<section
id="asset-grid"
class="scrollbar-hidden h-full overflow-y-auto pb-[60px] {isEmpty ? 'm-0' : 'ml-4 tall:ml-0 mr-[60px]'}"
class="scrollbar-hidden h-full overflow-y-auto outline-none pb-[60px] {isEmpty ? 'm-0' : 'ml-4 tall:ml-0 mr-[60px]'}"
tabindex="-1"
bind:clientHeight={viewport.height}
bind:clientWidth={viewport.width}
bind:this={element}
@@ -1,5 +1,5 @@
<script lang="ts" context="module">
import { clickOutside } from '$lib/utils/click-outside';
import { clickOutside } from '$lib/actions/click-outside';
import { createContext } from '$lib/utils/context';
const { get: getMenuContext, set: setContext } = createContext<() => void>();
@@ -3,6 +3,7 @@
import ConfirmDialogue from '../shared-components/confirm-dialogue.svelte';
import { showDeleteModal } from '$lib/stores/preferences.store';
import Checkbox from '$lib/components/elements/checkbox.svelte';
import { s } from '$lib/utils';
export let size: number;
@@ -23,7 +24,7 @@
<ConfirmDialogue
id="permanently-delete-asset-modal"
title="Permanently delete asset{size > 1 ? 's' : ''}"
title="Permanently delete asset{s(size)}"
confirmText="Delete"
onConfirm={handleConfirm}
onClose={() => dispatch('cancel')}
@@ -6,6 +6,7 @@
import AlbumListItem from '../asset-viewer/album-list-item.svelte';
import { normalizeSearchString } from '$lib/utils/string-utils';
import FullScreenModal from '$lib/components/shared-components/full-screen-modal.svelte';
import { initInput } from '$lib/actions/focus';
let albums: AlbumResponseDto[] = [];
let recentAlbums: AlbumResponseDto[] = [];
@@ -72,6 +73,7 @@
class="border-b-4 border-immich-bg bg-immich-bg px-6 py-2 text-2xl focus:border-immich-primary dark:border-immich-dark-gray dark:bg-immich-dark-gray dark:focus:border-immich-dark-primary"
placeholder="Search"
bind:value={search}
use:initInput
/>
<div class="immich-scrollbar overflow-y-auto">
<button
@@ -89,7 +91,7 @@
{#if !shared && search.length === 0}
<p class="px-5 py-3 text-xs">RECENT</p>
{#each recentAlbums as album (album.id)}
<AlbumListItem variant="simple" {album} on:album={() => handleSelect(album)} />
<AlbumListItem {album} on:album={() => handleSelect(album)} />
{/each}
{/if}
@@ -4,13 +4,13 @@
import { timeDebounceOnSearch } from '$lib/constants';
import { handleError } from '$lib/utils/handle-error';
import { clickOutside } from '$lib/utils/click-outside';
import { clickOutside } from '$lib/actions/click-outside';
import LoadingSpinner from './loading-spinner.svelte';
import { delay } from '$lib/utils/asset-utils';
import { timeToLoadTheMap } from '$lib/constants';
import { searchPlaces, type AssetResponseDto, type PlacesResponseDto } from '@immich/sdk';
import SearchBar from '../elements/search-bar.svelte';
import { listNavigation } from '$lib/utils/list-navigation';
import { listNavigation } from '$lib/actions/list-navigation';
export let asset: AssetResponseDto | undefined = undefined;
@@ -15,9 +15,9 @@
import { mdiMagnify, mdiUnfoldMoreHorizontal, mdiClose } from '@mdi/js';
import { createEventDispatcher, tick } from 'svelte';
import type { FormEventHandler } from 'svelte/elements';
import { shortcuts } from '$lib/utils/shortcut';
import { clickOutside } from '$lib/utils/click-outside';
import { focusOutside } from '$lib/utils/focus-outside';
import { shortcuts } from '$lib/actions/shortcut';
import { clickOutside } from '$lib/actions/click-outside';
import { focusOutside } from '$lib/actions/focus-outside';
import CircleIconButton from '$lib/components/elements/buttons/circle-icon-button.svelte';
/**
@@ -1,7 +1,7 @@
<script lang="ts">
import { quintOut } from 'svelte/easing';
import { slide } from 'svelte/transition';
import { clickOutside } from '$lib/utils/click-outside';
import { clickOutside } from '$lib/actions/click-outside';
export let direction: 'left' | 'right' = 'right';
export let x = 0;
@@ -1,5 +1,5 @@
<script lang="ts">
import { shortcuts } from '$lib/utils/shortcut';
import { shortcuts } from '$lib/actions/shortcut';
import { onMount, onDestroy } from 'svelte';
let container: HTMLElement;
@@ -1,5 +1,5 @@
<script lang="ts">
import { clickOutside } from '../../utils/click-outside';
import { clickOutside } from '$lib/actions/click-outside';
import { fade } from 'svelte/transition';
import FocusTrap from '$lib/components/shared-components/focus-trap.svelte';
import ModalHeader from '$lib/components/shared-components/modal-header.svelte';
@@ -6,7 +6,7 @@
import Icon from '$lib/components/elements/icon.svelte';
import { featureFlags } from '$lib/stores/server-config.store';
import { resetSavedUser, user } from '$lib/stores/user.store';
import { clickOutside } from '$lib/utils/click-outside';
import { clickOutside } from '$lib/actions/click-outside';
import { logout } from '@immich/sdk';
import { mdiCog, mdiMagnify, mdiTrayArrowUp } from '@mdi/js';
import { createEventDispatcher } from 'svelte';
@@ -2,15 +2,15 @@
import { AppRoute } from '$lib/constants';
import { goto } from '$app/navigation';
import { isSearchEnabled, preventRaceConditionSearchBar, savedSearchTerms } from '$lib/stores/search.store';
import { clickOutside } from '$lib/utils/click-outside';
import { clickOutside } from '$lib/actions/click-outside';
import { mdiClose, mdiMagnify, mdiTune } from '@mdi/js';
import SearchHistoryBox from './search-history-box.svelte';
import SearchFilterBox from './search-filter-box.svelte';
import type { MetadataSearchDto, SmartSearchDto } from '@immich/sdk';
import { getMetadataSearchQuery } from '$lib/utils/metadata-search';
import { handlePromiseError } from '$lib/utils';
import { shortcuts } from '$lib/utils/shortcut';
import { focusOutside } from '$lib/utils/focus-outside';
import { shortcuts } from '$lib/actions/shortcut';
import { focusOutside } from '$lib/actions/focus-outside';
import CircleIconButton from '$lib/components/elements/buttons/circle-icon-button.svelte';
export let value = '';
@@ -0,0 +1,30 @@
import { render } from '@testing-library/svelte';
import userEvent from '@testing-library/user-event';
// @ts-expect-error the import works but tsc check errors
import SettingInputField, { SettingInputFieldType } from './setting-input-field.svelte';
describe('SettingInputField component', () => {
it('validates number input on blur', async () => {
const { getByRole } = render(SettingInputField, {
props: {
label: 'test-number-input',
inputType: SettingInputFieldType.NUMBER,
value: 0,
min: 0,
max: 100,
step: '0.1',
},
});
const user = userEvent.setup();
const numberInput = getByRole('spinbutton') as HTMLInputElement;
expect(numberInput.value).toEqual('0');
await user.click(numberInput);
await user.keyboard('100.1');
expect(numberInput.value).toEqual('100.1');
await user.click(document.body);
expect(numberInput.value).toEqual('100');
});
});
@@ -25,9 +25,7 @@
export let isEdited = false;
export let passwordAutocomplete: string = 'current-password';
const handleInput = (e: Event) => {
value = (e.target as HTMLInputElement).value;
const validateInput = () => {
if (inputType === SettingInputFieldType.NUMBER) {
let newValue = Number(value) || 0;
if (newValue < min) {
@@ -79,7 +77,8 @@
{step}
{required}
{value}
on:input={handleInput}
on:input={(e) => (value = e.currentTarget.value)}
on:blur={validateInput}
{disabled}
{title}
/>
@@ -4,17 +4,23 @@
import { getAlbumCount, getAssetStatistics } from '@immich/sdk';
import {
mdiAccount,
mdiAccountOutline,
mdiAccountMultiple,
mdiAccountMultipleOutline,
mdiArchiveArrowDown,
mdiArchiveArrowDownOutline,
mdiHeartMultiple,
mdiHeartMultipleOutline,
mdiHeart,
mdiHeartOutline,
mdiImageAlbum,
mdiImageMultiple,
mdiImageMultipleOutline,
mdiMagnify,
mdiMap,
mdiMapOutline,
mdiTrashCan,
mdiTrashCanOutline,
mdiToolbox,
mdiToolboxOutline,
} from '@mdi/js';
import LoadingSpinner from '../loading-spinner.svelte';
import StatusBox from '../status-box.svelte';
@@ -31,9 +37,14 @@
}
};
let isArchiveSelected: boolean;
let isFavoritesSelected: boolean;
let isMapSelected: boolean;
let isPeopleSelected: boolean;
let isPhotosSelected: boolean;
let isSharingSelected: boolean;
let isTrashSelected: boolean;
let isUtilitiesSelected: boolean;
</script>
<SideBarSection>
@@ -60,11 +71,21 @@
{/if}
{#if $featureFlags.map}
<SideBarLink title="Map" routeId="/(user)/map" icon={mdiMap} />
<SideBarLink
title="Map"
routeId="/(user)/map"
bind:isSelected={isMapSelected}
icon={isMapSelected ? mdiMap : mdiMapOutline}
/>
{/if}
{#if $sidebarSettings.people}
<SideBarLink title="People" routeId="/(user)/people" icon={mdiAccount} />
<SideBarLink
title="People"
routeId="/(user)/people"
bind:isSelected={isPeopleSelected}
icon={isPeopleSelected ? mdiAccount : mdiAccountOutline}
/>
{/if}
{#if $sidebarSettings.sharing}
<SideBarLink
@@ -92,7 +113,7 @@
<SideBarLink
title="Favorites"
routeId="/(user)/favorites"
icon={isFavoritesSelected ? mdiHeartMultiple : mdiHeartMultipleOutline}
icon={isFavoritesSelected ? mdiHeart : mdiHeartOutline}
bind:isSelected={isFavoritesSelected}
>
<svelte:fragment slot="moreInformation">
@@ -118,7 +139,19 @@
</svelte:fragment>
</SideBarLink>
<SideBarLink title="Archive" routeId="/(user)/archive" icon={mdiArchiveArrowDownOutline}>
<SideBarLink
title="Utilities"
routeId="/(user)/utilities"
bind:isSelected={isUtilitiesSelected}
icon={isUtilitiesSelected ? mdiToolbox : mdiToolboxOutline}
></SideBarLink>
<SideBarLink
title="Archive"
routeId="/(user)/archive"
bind:isSelected={isArchiveSelected}
icon={isArchiveSelected ? mdiArchiveArrowDown : mdiArchiveArrowDownOutline}
>
<svelte:fragment slot="moreInformation">
{#await getStats({ isArchived: true })}
<LoadingSpinner />
@@ -132,7 +165,12 @@
</SideBarLink>
{#if $featureFlags.trash}
<SideBarLink title="Trash" routeId="/(user)/trash" icon={mdiTrashCanOutline}>
<SideBarLink
title="Trash"
routeId="/(user)/trash"
bind:isSelected={isTrashSelected}
icon={isTrashSelected ? mdiTrashCan : mdiTrashCanOutline}
>
<svelte:fragment slot="moreInformation">
{#await getStats({ isTrashed: true })}
<LoadingSpinner />
@@ -24,8 +24,8 @@
out:fade={{ duration: 100 }}
class="flex flex-col rounded-lg bg-immich-bg text-xs dark:bg-immich-dark-bg"
>
<div class="grid grid-cols-[65px_auto_auto]">
<div class="relative h-[65px]">
<div class="grid grid-cols-[65px_auto_auto] max-h-[70px]">
<div class="relative">
<div in:fade={{ duration: 250 }}>
<ImmichLogo noText class="h-[65px] w-[65px] rounded-bl-lg rounded-tl-lg object-cover p-2" />
</div>
@@ -83,18 +83,14 @@
</div>
</div>
{#if uploadAsset.state === UploadState.ERROR}
<div class="flex h-full flex-col place-content-center place-items-center justify-items-center pr-2">
<button
on:click={() => handleRetry(uploadAsset)}
title="Retry upload"
class="flex h-full w-full place-content-center place-items-center text-sm"
>
<div class="flex h-full flex-col place-content-evenly place-items-center justify-items-center pr-2">
<button on:click={() => handleRetry(uploadAsset)} title="Retry upload" class="flex text-sm">
<span class="text-immich-dark-gray dark:text-immich-dark-fg"><Icon path={mdiRefresh} size="20" /></span>
</button>
<button
on:click={() => uploadAssetsStore.removeUploadAsset(uploadAsset.id)}
title="Dismiss error"
class="flex h-full w-full place-content-center place-items-center text-sm"
class="flex text-sm"
>
<span class="text-immich-error"><Icon path={mdiCancel} size="20" /></span>
</button>
@@ -104,7 +100,7 @@
{#if uploadAsset.state === UploadState.ERROR}
<div class="flex flex-row justify-between">
<p class="w-full rounded-md p-1 px-2 text-justify text-[10px] text-immich-error">
<p class="w-full rounded-md py-1 px-2 text-justify text-[10px] text-immich-error">
{uploadAsset.error}
</p>
</div>
@@ -8,6 +8,7 @@
import { uploadExecutionQueue } from '$lib/utils/file-uploader';
import CircleIconButton from '../elements/buttons/circle-icon-button.svelte';
import { mdiCog, mdiWindowMinimize, mdiCancel, mdiCloudUploadOutline } from '@mdi/js';
import { s } from '$lib/utils';
let showDetail = false;
let showOptions = false;
@@ -36,7 +37,7 @@
on:outroend={() => {
if ($errorCounter > 0) {
notificationController.show({
message: `Upload completed with ${$errorCounter} error${$errorCounter > 1 ? 's' : ''}, refresh the page to see new upload assets.`,
message: `Upload completed with ${$errorCounter} error${s($errorCounter)}, refresh the page to see new upload assets.`,
type: NotificationType.Warning,
});
} else if ($successCounter > 0) {
@@ -47,7 +48,7 @@
}
if ($duplicateCounter > 0) {
notificationController.show({
message: `Skipped ${$duplicateCounter} duplicate asset${$duplicateCounter > 1 ? 's' : ''}`,
message: `Skipped ${$duplicateCounter} duplicate asset${s($duplicateCounter)}`,
type: NotificationType.Warning,
});
}
@@ -3,9 +3,15 @@
import SettingCombobox from '$lib/components/shared-components/settings/setting-combobox.svelte';
import SettingSwitch from '$lib/components/shared-components/settings/setting-switch.svelte';
import { fallbackLocale, locales } from '$lib/constants';
import { sidebarSettings } from '$lib/stores/preferences.store';
import { alwaysLoadOriginalFile, playVideoThumbnailOnHover, showDeleteModal } from '$lib/stores/preferences.store';
import { colorTheme, locale } from '$lib/stores/preferences.store';
import {
alwaysLoadOriginalFile,
colorTheme,
locale,
loopVideo,
playVideoThumbnailOnHover,
showDeleteModal,
sidebarSettings,
} from '$lib/stores/preferences.store';
import { findLocale } from '$lib/utils';
import { onMount } from 'svelte';
import { fade } from 'svelte/transition';
@@ -117,6 +123,15 @@
on:toggle={() => ($playVideoThumbnailOnHover = !$playVideoThumbnailOnHover)}
/>
</div>
<div class="ml-4">
<SettingSwitch
id="loop-video"
title="Loop videos"
subtitle="Enable to automatically loop a video in the detail viewer."
bind:checked={$loopVideo}
on:toggle={() => ($loopVideo = !$loopVideo)}
/>
</div>
<div class="ml-4">
<SettingSwitch
@@ -0,0 +1,133 @@
<script lang="ts">
import Button from '$lib/components/elements/buttons/button.svelte';
import Icon from '$lib/components/elements/icon.svelte';
import { getAssetThumbnailUrl } from '$lib/utils';
import { ThumbnailFormat, type AssetResponseDto, type DuplicateResponseDto, getAllAlbums } from '@immich/sdk';
import { mdiCheck, mdiTrashCanOutline } from '@mdi/js';
import { onMount } from 'svelte';
import { s } from '$lib/utils';
import { getAssetResolution, getFileSize } from '$lib/utils/asset-utils';
import { sortBy } from 'lodash-es';
export let duplicate: DuplicateResponseDto;
export let onResolve: (duplicateAssetIds: string[], trashIds: string[]) => void;
let selectedAssetIds = new Set<string>();
$: trashCount = duplicate.assets.length - selectedAssetIds.size;
onMount(() => {
const suggestedAsset = sortBy(duplicate.assets, (asset) => asset.exifInfo?.fileSizeInByte).pop();
if (!suggestedAsset) {
selectedAssetIds = new Set(duplicate.assets[0].id);
return;
}
selectedAssetIds.add(suggestedAsset.id);
selectedAssetIds = selectedAssetIds;
});
const onSelectAsset = (asset: AssetResponseDto) => {
if (selectedAssetIds.has(asset.id)) {
selectedAssetIds.delete(asset.id);
} else {
selectedAssetIds.add(asset.id);
}
selectedAssetIds = selectedAssetIds;
};
const handleResolve = () => {
const trashIds = duplicate.assets.map((asset) => asset.id).filter((id) => !selectedAssetIds.has(id));
const duplicateAssetIds = duplicate.assets.map((asset) => asset.id);
onResolve(duplicateAssetIds, trashIds);
};
</script>
<div class="pt-4 rounded-3xl border dark:border-2 border-gray-300 dark:border-gray-700 max-w-[900px] m-auto mb-16">
<div class="flex flex-wrap gap-1 place-items-center place-content-center px-4 pt-4">
{#each duplicate.assets as asset, index (index)}
{@const isSelected = selectedAssetIds.has(asset.id)}
{@const isFromExternalLibrary = !!asset.libraryId}
{@const assetData = JSON.stringify(asset, null, 2)}
<div class="relative">
<button on:click={() => onSelectAsset(asset)} class="block relative">
<!-- THUMBNAIL-->
<img
src={getAssetThumbnailUrl(asset.id, ThumbnailFormat.Webp)}
alt={asset.id}
title={`${assetData}`}
class={`w-[250px] h-[250px] object-cover rounded-t-xl border-t-[4px] border-l-[4px] border-r-[4px] border-gray-300 ${isSelected ? 'border-immich-primary dark:border-immich-dark-primary' : 'dark:border-gray-800'} transition-all`}
draggable="false"
/>
<!-- OVERLAY CHIP -->
<div
class={`absolute bottom-2 right-3 ${isSelected ? 'bg-green-400/90' : 'bg-red-300/90'} px-4 py-1 rounded-xl text-xs font-semibold`}
>
{isSelected ? 'Keep' : 'Trash'}
</div>
<!-- EXTERNAL LIBRARY CHIP-->
{#if isFromExternalLibrary}
<div
class="absolute top-2 right-3 bg-immich-primary/90 px-4 py-1 rounded-xl text-xs font-semibold text-white"
>
External
</div>
{/if}
</button>
<!-- ASSET INFO-->
<table
class={`text-xs w-full rounded-b-xl font-semibold ${isSelected ? 'bg-immich-primary text-white dark:bg-immich-dark-primary dark:text-black' : 'bg-gray-200 dark:bg-gray-800 dark:text-white'} mt-0 transition-all`}
>
<tr
class={`h-[32px] ${isSelected ? 'border-immich-primary rounded-xl dark:border-immich-dark-primary' : 'border-gray-300'} text-center `}
>
<td>{asset.originalFileName}</td>
</tr>
<tr
class={`h-[32px] ${isSelected ? 'border-immich-primary rounded-xl dark:border-immich-dark-primary' : 'border-gray-300'} text-center`}
>
<td>{getAssetResolution(asset)} - {getFileSize(asset)}</td>
</tr>
<tr
class={`h-[32px] ${isSelected ? 'border-immich-primary rounded-xl dark:border-immich-dark-primary' : 'border-gray-300'} text-center `}
>
<td>
{#await getAllAlbums({ assetId: asset.id })}
Scanning for album...
{:then albums}
{#if albums.length === 0}
Not in any album
{:else}
In {albums.length} album{s(albums.length)}
{/if}
{/await}
</td>
</tr>
</table>
</div>
{/each}
</div>
<!-- CONFIRM BUTTONS -->
<div class="flex gap-4 my-4 border-transparent w-full justify-end p-4 h-[85px]">
{#if trashCount === 0}
<Button size="sm" color="primary" class="flex place-items-center gap-2" on:click={handleResolve}
><Icon path={mdiCheck} size="20" />Keep All
</Button>
{:else}
<Button size="sm" color="red" class="flex place-items-center gap-2" on:click={handleResolve}
><Icon path={mdiTrashCanOutline} size="20" />{trashCount === duplicate.assets.length
? 'Trash All'
: `Trash ${trashCount}`}
</Button>
{/if}
</div>
</div>
@@ -0,0 +1,18 @@
<script lang="ts">
import { mdiContentDuplicate } from '@mdi/js';
import Icon from '$lib/components/elements/icon.svelte';
import { AppRoute } from '$lib/constants';
</script>
<a href={AppRoute.DUPLICATES}>
<div class="border border-gray-300 dark:border-immich-dark-gray rounded-3xl pt-1 pb-6 dark:text-white">
<p class="text-xs font-medium p-4">ORGANIZE YOUR LIBRARY</p>
<button class="w-full hover:bg-gray-100 dark:hover:bg-immich-dark-gray flex gap-4 p-4">
<span
><Icon path={mdiContentDuplicate} class="text-immich-primary dark:text-immich-dark-primary" size="24" />
</span>
Review duplicates
</button>
</div>
</a>