merge main
This commit is contained in:
@@ -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"
|
||||
|
||||
+32
@@ -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>
|
||||
-68
@@ -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>
|
||||
Reference in New Issue
Block a user