mirror of
https://github.com/immich-app/immich.git
synced 2026-05-27 09:22:34 -04:00
Merge branch 'main' of https://github.com/immich-app/immich into chore/admin-only-library
This commit is contained in:
+11
-24
@@ -1,40 +1,27 @@
|
||||
import { isHttpError } from '@immich/sdk';
|
||||
import type { HandleClientError } from '@sveltejs/kit';
|
||||
import type { AxiosError, AxiosResponse } from 'axios';
|
||||
|
||||
const LOG_PREFIX = '[hooks.client.ts]';
|
||||
const DEFAULT_MESSAGE = 'Hmm, not sure about that. Check the logs or open a ticket?';
|
||||
|
||||
const parseError = (error: unknown) => {
|
||||
const httpError = error as AxiosError;
|
||||
const request = httpError?.request as Request & { path: string };
|
||||
const response = httpError?.response as AxiosResponse<{
|
||||
message: string;
|
||||
statusCode: number;
|
||||
error: string;
|
||||
}>;
|
||||
const httpError = isHttpError(error) ? error : undefined;
|
||||
const statusCode = httpError?.status || httpError?.data?.statusCode || 500;
|
||||
const message = httpError?.data?.message || (httpError?.data && String(httpError.data)) || httpError?.message;
|
||||
|
||||
let code = response?.data?.statusCode || response?.status || httpError.code || '500';
|
||||
if (response) {
|
||||
code += ` - ${response.data?.error || response.statusText}`;
|
||||
}
|
||||
|
||||
if (request && response) {
|
||||
console.log({
|
||||
status: response.status,
|
||||
url: `${request.method} ${request.path}`,
|
||||
response: response.data || 'No data',
|
||||
});
|
||||
}
|
||||
console.log({
|
||||
status: statusCode,
|
||||
response: httpError?.data || 'No data',
|
||||
});
|
||||
|
||||
return {
|
||||
message: response?.data?.message || httpError?.message || DEFAULT_MESSAGE,
|
||||
code,
|
||||
message: message || DEFAULT_MESSAGE,
|
||||
code: statusCode,
|
||||
stack: httpError?.stack,
|
||||
};
|
||||
};
|
||||
|
||||
export const handleError: HandleClientError = ({ error }) => {
|
||||
const result = parseError(error);
|
||||
console.error(`${LOG_PREFIX}:handleError ${result.message}`);
|
||||
console.error(`[hooks.client.ts]:handleError ${result.message}`);
|
||||
return result;
|
||||
};
|
||||
|
||||
@@ -48,11 +48,11 @@
|
||||
await handleCommand(jobId, dto);
|
||||
};
|
||||
|
||||
const onConfirm = () => {
|
||||
const onConfirm = async () => {
|
||||
if (!confirmJob) {
|
||||
return;
|
||||
}
|
||||
handleCommand(confirmJob, { command: JobCommand.Start, force: true });
|
||||
await handleCommand(confirmJob, { command: JobCommand.Start, force: true });
|
||||
confirmJob = null;
|
||||
};
|
||||
|
||||
|
||||
@@ -54,7 +54,7 @@
|
||||
});
|
||||
};
|
||||
|
||||
const resetToDefault = async (configKeys: Array<keyof SystemConfigDto>) => {
|
||||
const resetToDefault = (configKeys: Array<keyof SystemConfigDto>) => {
|
||||
for (const key of configKeys) {
|
||||
config = { ...config, [key]: defaultConfig[key] };
|
||||
}
|
||||
|
||||
+5
-5
@@ -112,8 +112,8 @@
|
||||
desc="Minimum confidence score for a face to be detected from 0-1. Lower values will detect more faces but may result in false positives."
|
||||
bind:value={config.machineLearning.facialRecognition.minScore}
|
||||
step="0.1"
|
||||
min="0"
|
||||
max="1"
|
||||
min={0}
|
||||
max={1}
|
||||
disabled={disabled || !config.machineLearning.enabled || !config.machineLearning.facialRecognition.enabled}
|
||||
isEdited={config.machineLearning.facialRecognition.minScore !==
|
||||
savedConfig.machineLearning.facialRecognition.minScore}
|
||||
@@ -125,8 +125,8 @@
|
||||
desc="Maximum distance between two faces to be considered the same person, ranging from 0-2. Lowering this can prevent labeling two people as the same person, while raising it can prevent labeling the same person as two different people. Note that it is easier to merge two people than to split one person in two, so err on the side of a lower threshold when possible."
|
||||
bind:value={config.machineLearning.facialRecognition.maxDistance}
|
||||
step="0.1"
|
||||
min="0"
|
||||
max="2"
|
||||
min={0}
|
||||
max={2}
|
||||
disabled={disabled || !config.machineLearning.enabled || !config.machineLearning.facialRecognition.enabled}
|
||||
isEdited={config.machineLearning.facialRecognition.maxDistance !==
|
||||
savedConfig.machineLearning.facialRecognition.maxDistance}
|
||||
@@ -138,7 +138,7 @@
|
||||
desc="The minimum number of recognized faces for a person to be created. Increasing this makes Facial Recognition more precise at the cost of increasing the chance that a face is not assigned to a person."
|
||||
bind:value={config.machineLearning.facialRecognition.minFaces}
|
||||
step="1"
|
||||
min="1"
|
||||
min={1}
|
||||
disabled={disabled || !config.machineLearning.enabled || !config.machineLearning.facialRecognition.enabled}
|
||||
isEdited={config.machineLearning.facialRecognition.minFaces !==
|
||||
savedConfig.machineLearning.facialRecognition.minFaces}
|
||||
|
||||
+20
-1
@@ -84,7 +84,26 @@
|
||||
};
|
||||
</script>
|
||||
|
||||
<section class="dark:text-immich-dark-fg">
|
||||
<section class="dark:text-immich-dark-fg mt-2">
|
||||
<div in:fade={{ duration: 500 }} 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/storage-template"
|
||||
class="underline"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>Storage Template
|
||||
</a>
|
||||
and its
|
||||
<a
|
||||
href="https://immich.app/docs/administration/backup-and-restore#asset-types-and-storage-locations"
|
||||
class="underline"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>implications
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
{#await getTemplateOptions() then}
|
||||
<div id="directory-path-builder" class="flex flex-col gap-4 {minified ? '' : 'ml-4 mt-4'}">
|
||||
<SettingSwitch
|
||||
|
||||
@@ -21,6 +21,7 @@
|
||||
import { shouldIgnoreShortcut } from '$lib/utils/shortcut';
|
||||
import { mdiFileImagePlusOutline, mdiFolderDownloadOutline } from '@mdi/js';
|
||||
import UpdatePanel from '../shared-components/update-panel.svelte';
|
||||
import { handlePromiseError } from '$lib/utils';
|
||||
|
||||
export let sharedLink: SharedLinkResponseDto;
|
||||
export let user: UserResponseDto | undefined = undefined;
|
||||
@@ -35,7 +36,7 @@
|
||||
|
||||
dragAndDropFilesStore.subscribe((value) => {
|
||||
if (value.isDragging && value.files.length > 0) {
|
||||
fileUploadHandler(value.files, album.id);
|
||||
handlePromiseError(fileUploadHandler(value.files, album.id));
|
||||
dragAndDropFilesStore.set({ isDragging: false, files: [] });
|
||||
}
|
||||
});
|
||||
@@ -67,7 +68,7 @@
|
||||
|
||||
const onKeyboardPress = (event: KeyboardEvent) => handleKeyboardPress(event);
|
||||
|
||||
onMount(async () => {
|
||||
onMount(() => {
|
||||
document.addEventListener('keydown', onKeyboardPress);
|
||||
});
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script lang="ts">
|
||||
import Icon from '$lib/components/elements/icon.svelte';
|
||||
import { timeBeforeShowLoadingSpinner } from '$lib/constants';
|
||||
import { getAssetThumbnailUrl } from '$lib/utils';
|
||||
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';
|
||||
@@ -79,7 +79,7 @@
|
||||
|
||||
$: {
|
||||
if (assetId && previousAssetId != assetId) {
|
||||
getReactions();
|
||||
handlePromiseError(getReactions());
|
||||
previousAssetId = assetId;
|
||||
}
|
||||
}
|
||||
@@ -95,10 +95,10 @@
|
||||
}
|
||||
};
|
||||
|
||||
const handleEnter = (event: KeyboardEvent) => {
|
||||
const handleEnter = async (event: KeyboardEvent) => {
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault();
|
||||
handleSendComment();
|
||||
await handleSendComment();
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
import { SlideshowState, slideshowStore } from '$lib/stores/slideshow.store';
|
||||
import { stackAssetsStore } from '$lib/stores/stacked-asset.store';
|
||||
import { user } from '$lib/stores/user.store';
|
||||
import { getAssetJobMessage, isSharedLink } from '$lib/utils';
|
||||
import { getAssetJobMessage, isSharedLink, handlePromiseError } from '$lib/utils';
|
||||
import { addAssetsToAlbum, downloadFile } from '$lib/utils/asset-utils';
|
||||
import { handleError } from '$lib/utils/handle-error';
|
||||
import { shouldIgnoreShortcut } from '$lib/utils/shortcut';
|
||||
@@ -174,8 +174,8 @@
|
||||
|
||||
$: {
|
||||
if (isShared && asset.id) {
|
||||
getFavorite();
|
||||
getNumberOfComments();
|
||||
handlePromiseError(getFavorite());
|
||||
handlePromiseError(getNumberOfComments());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -184,9 +184,9 @@
|
||||
if (value === SlideshowState.PlaySlideshow) {
|
||||
slideshowHistory.reset();
|
||||
slideshowHistory.queue(asset.id);
|
||||
handlePlaySlideshow();
|
||||
handlePromiseError(handlePlaySlideshow());
|
||||
} else if (value === SlideshowState.StopSlideshow) {
|
||||
handleStopSlideshow();
|
||||
handlePromiseError(handleStopSlideshow());
|
||||
}
|
||||
});
|
||||
|
||||
@@ -226,7 +226,7 @@
|
||||
}
|
||||
});
|
||||
|
||||
$: asset.id && !sharedLink && handleGetAllAlbums(); // Update the album information when the asset ID changes
|
||||
$: asset.id && !sharedLink && handlePromiseError(handleGetAllAlbums()); // Update the album information when the asset ID changes
|
||||
|
||||
const handleGetAllAlbums = async () => {
|
||||
if (isSharedLink()) {
|
||||
@@ -247,7 +247,7 @@
|
||||
isShowActivity = !isShowActivity;
|
||||
};
|
||||
|
||||
const handleKeypress = (event: KeyboardEvent) => {
|
||||
const handleKeypress = async (event: KeyboardEvent) => {
|
||||
if (shouldIgnoreShortcut(event)) {
|
||||
return;
|
||||
}
|
||||
@@ -264,7 +264,7 @@
|
||||
case 'a':
|
||||
case 'A': {
|
||||
if (shiftKey) {
|
||||
toggleArchive();
|
||||
await toggleArchive();
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -273,18 +273,18 @@
|
||||
return;
|
||||
}
|
||||
case 'ArrowRight': {
|
||||
navigateAssetForward();
|
||||
await navigateAssetForward();
|
||||
return;
|
||||
}
|
||||
case 'd':
|
||||
case 'D': {
|
||||
if (shiftKey) {
|
||||
downloadFile(asset);
|
||||
await downloadFile(asset);
|
||||
}
|
||||
return;
|
||||
}
|
||||
case 'Delete': {
|
||||
trashOrDelete(shiftKey);
|
||||
await trashOrDelete(shiftKey);
|
||||
return;
|
||||
}
|
||||
case 'Escape': {
|
||||
@@ -296,7 +296,7 @@
|
||||
return;
|
||||
}
|
||||
case 'f': {
|
||||
toggleFavorite();
|
||||
await toggleFavorite();
|
||||
return;
|
||||
}
|
||||
case 'i': {
|
||||
@@ -326,7 +326,7 @@
|
||||
|
||||
slideshowHistory.queue(asset.id);
|
||||
|
||||
setAssetId(asset.id);
|
||||
await setAssetId(asset.id);
|
||||
$restartSlideshowProgress = true;
|
||||
};
|
||||
|
||||
@@ -369,17 +369,17 @@
|
||||
$isShowDetail = !$isShowDetail;
|
||||
};
|
||||
|
||||
const trashOrDelete = (force: boolean = false) => {
|
||||
const trashOrDelete = async (force: boolean = false) => {
|
||||
if (force || !isTrashEnabled) {
|
||||
if ($showDeleteModal) {
|
||||
isShowDeleteConfirmation = true;
|
||||
return;
|
||||
}
|
||||
deleteAsset();
|
||||
await deleteAsset();
|
||||
return;
|
||||
}
|
||||
|
||||
trashAsset();
|
||||
await trashAsset();
|
||||
return;
|
||||
};
|
||||
|
||||
@@ -432,7 +432,7 @@
|
||||
message: asset.isFavorite ? `Added to favorites` : `Removed from favorites`,
|
||||
});
|
||||
} catch (error) {
|
||||
await handleError(error, `Unable to ${asset.isFavorite ? `add asset to` : `remove asset from`} favorites`);
|
||||
handleError(error, `Unable to ${asset.isFavorite ? `add asset to` : `remove asset from`} favorites`);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -472,7 +472,7 @@
|
||||
message: asset.isArchived ? `Added to archive` : `Removed from archive`,
|
||||
});
|
||||
} catch (error) {
|
||||
await handleError(error, `Unable to ${asset.isArchived ? `add asset to` : `remove asset from`} archive`);
|
||||
handleError(error, `Unable to ${asset.isArchived ? `add asset to` : `remove asset from`} archive`);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -481,7 +481,7 @@
|
||||
await runAssetJobs({ assetJobsDto: { assetIds: [asset.id], name } });
|
||||
notificationController.show({ type: NotificationType.Info, message: getAssetJobMessage(name) });
|
||||
} catch (error) {
|
||||
await handleError(error, `Unable to submit job`);
|
||||
handleError(error, `Unable to submit job`);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -492,7 +492,7 @@
|
||||
let assetViewerHtmlElement: HTMLElement;
|
||||
|
||||
const slideshowHistory = new SlideshowHistory((assetId: string) => {
|
||||
setAssetId(assetId);
|
||||
handlePromiseError(setAssetId(assetId));
|
||||
$restartSlideshowProgress = true;
|
||||
});
|
||||
|
||||
@@ -550,7 +550,7 @@
|
||||
dispatch('close');
|
||||
notificationController.show({ type: NotificationType.Info, message: 'Un-stacked', timeout: 1500 });
|
||||
} catch (error) {
|
||||
await handleError(error, `Unable to unstack`);
|
||||
handleError(error, `Unable to unstack`);
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
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 } from '$lib/utils';
|
||||
import { getAssetThumbnailUrl, getPeopleThumbnailUrl, isSharedLink, handlePromiseError } from '$lib/utils';
|
||||
import { delay, getAssetFilename } from '$lib/utils/asset-utils';
|
||||
import { autoGrowHeight } from '$lib/utils/autogrow';
|
||||
import { clickOutside } from '$lib/utils/click-outside';
|
||||
@@ -78,7 +78,7 @@
|
||||
originalDescription = description;
|
||||
};
|
||||
|
||||
$: handleNewAsset(asset);
|
||||
$: handlePromiseError(handleNewAsset(asset));
|
||||
|
||||
$: latlng = (() => {
|
||||
const lat = asset.exifInfo?.latitude;
|
||||
@@ -113,7 +113,7 @@
|
||||
switch (event.key) {
|
||||
case 'Enter': {
|
||||
if (ctrl && event.target === textArea) {
|
||||
handleFocusOut();
|
||||
await handleFocusOut();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
import { boundingBoxesArray } from '$lib/stores/people.store';
|
||||
import { alwaysLoadOriginalFile } from '$lib/stores/preferences.store';
|
||||
import { photoZoomState } from '$lib/stores/zoom-image.store';
|
||||
import { getKey } from '$lib/utils';
|
||||
import { getKey, handlePromiseError } from '$lib/utils';
|
||||
import { isWebCompatibleImage } from '$lib/utils/asset-utils';
|
||||
import { getBoundingBox } from '$lib/utils/people-utils';
|
||||
import { shouldIgnoreShortcut } from '$lib/utils/shortcut';
|
||||
@@ -102,7 +102,7 @@
|
||||
}
|
||||
};
|
||||
|
||||
const doZoomImage = async () => {
|
||||
const doZoomImage = () => {
|
||||
setZoomImageWheelState({
|
||||
currentZoom: $zoomImageWheelState.currentZoom === 1 ? 2 : 1,
|
||||
});
|
||||
@@ -120,7 +120,7 @@
|
||||
if (state.currentZoom > 1 && isWebCompatibleImage(asset) && !hasZoomed && !$alwaysLoadOriginalFile) {
|
||||
hasZoomed = true;
|
||||
|
||||
loadAssetData({ loadOriginal: true });
|
||||
handlePromiseError(loadAssetData({ loadOriginal: true }));
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -1,23 +1,16 @@
|
||||
<script lang="ts">
|
||||
import CircleIconButton from '../elements/buttons/circle-icon-button.svelte';
|
||||
import ProgressBar, { ProgressBarStatus } from '../shared-components/progress-bar/progress-bar.svelte';
|
||||
import CircleIconButton from '$lib/components/elements/buttons/circle-icon-button.svelte';
|
||||
import ProgressBar, { ProgressBarStatus } from '$lib/components/shared-components/progress-bar/progress-bar.svelte';
|
||||
import SlideshowSettings from '$lib/components/slideshow-settings.svelte';
|
||||
import { slideshowStore } from '$lib/stores/slideshow.store';
|
||||
import { mdiChevronLeft, mdiChevronRight, mdiClose, mdiCog, mdiPause, mdiPlay } from '@mdi/js';
|
||||
import { createEventDispatcher, onDestroy, onMount } from 'svelte';
|
||||
import {
|
||||
mdiChevronLeft,
|
||||
mdiChevronRight,
|
||||
mdiClose,
|
||||
mdiPause,
|
||||
mdiPlay,
|
||||
mdiShuffle,
|
||||
mdiShuffleDisabled,
|
||||
} from '@mdi/js';
|
||||
|
||||
const { slideshowShuffle } = slideshowStore;
|
||||
const { restartProgress, stopProgress } = slideshowStore;
|
||||
const { restartProgress, stopProgress, slideshowDelay, showProgressBar } = slideshowStore;
|
||||
|
||||
let progressBarStatus: ProgressBarStatus;
|
||||
let progressBar: ProgressBar;
|
||||
let showSettings = false;
|
||||
|
||||
let unsubscribeRestart: () => void;
|
||||
let unsubscribeStop: () => void;
|
||||
@@ -54,25 +47,27 @@
|
||||
</script>
|
||||
|
||||
<div class="m-4 flex gap-2">
|
||||
<CircleIconButton icon={mdiClose} on:click={() => dispatch('close')} title="Exit Slideshow" />
|
||||
{#if $slideshowShuffle}
|
||||
<CircleIconButton icon={mdiShuffle} on:click={() => ($slideshowShuffle = false)} title="Shuffle" />
|
||||
{:else}
|
||||
<CircleIconButton icon={mdiShuffleDisabled} on:click={() => ($slideshowShuffle = true)} title="No shuffle" />
|
||||
{/if}
|
||||
<CircleIconButton buttonSize="50" icon={mdiClose} on:click={() => dispatch('close')} title="Exit Slideshow" />
|
||||
<CircleIconButton
|
||||
buttonSize="50"
|
||||
icon={progressBarStatus === ProgressBarStatus.Paused ? mdiPlay : mdiPause}
|
||||
on:click={() => (progressBarStatus === ProgressBarStatus.Paused ? progressBar.play() : progressBar.pause())}
|
||||
title={progressBarStatus === ProgressBarStatus.Paused ? 'Play' : 'Pause'}
|
||||
/>
|
||||
<CircleIconButton icon={mdiChevronLeft} on:click={() => dispatch('prev')} title="Previous" />
|
||||
<CircleIconButton icon={mdiChevronRight} on:click={() => dispatch('next')} title="Next" />
|
||||
<CircleIconButton buttonSize="50" icon={mdiChevronLeft} on:click={() => dispatch('prev')} title="Previous" />
|
||||
<CircleIconButton buttonSize="50" icon={mdiChevronRight} on:click={() => dispatch('next')} title="Next" />
|
||||
<CircleIconButton buttonSize="50" icon={mdiCog} on:click={() => (showSettings = !showSettings)} title="Next" />
|
||||
</div>
|
||||
|
||||
{#if showSettings}
|
||||
<SlideshowSettings onClose={() => (showSettings = false)} />
|
||||
{/if}
|
||||
|
||||
<ProgressBar
|
||||
autoplay
|
||||
hidden={!$showProgressBar}
|
||||
duration={$slideshowDelay}
|
||||
bind:this={progressBar}
|
||||
bind:status={progressBarStatus}
|
||||
on:done={() => dispatch('next')}
|
||||
duration={5000}
|
||||
/>
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
video.muted = false;
|
||||
dispatch('onVideoStarted');
|
||||
} catch (error) {
|
||||
await handleError(error, 'Unable to play video');
|
||||
handleError(error, 'Unable to play video');
|
||||
} finally {
|
||||
isVideoLoading = false;
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import Icon from '$lib/components/elements/icon.svelte';
|
||||
import { ProjectionType } from '$lib/constants';
|
||||
import { getAssetFileUrl, getAssetThumbnailUrl, isSharedLink } from '$lib/utils';
|
||||
import { timeToSeconds } from '$lib/utils/time-to-seconds';
|
||||
import { timeToSeconds } from '$lib/utils/date-time';
|
||||
import { AssetTypeEnum, ThumbnailFormat, type AssetResponseDto } from '@immich/sdk';
|
||||
import {
|
||||
mdiArchiveArrowDownOutline,
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
<script lang="ts">
|
||||
import type { HTMLInputAttributes } from 'svelte/elements';
|
||||
|
||||
interface $$Props extends HTMLInputAttributes {
|
||||
type: 'date' | 'datetime-local';
|
||||
}
|
||||
|
||||
export let value: $$Props['value'] = undefined;
|
||||
$: updatedValue = value;
|
||||
</script>
|
||||
|
||||
<input
|
||||
{...$$restProps}
|
||||
{value}
|
||||
on:input={(e) => {
|
||||
updatedValue = e.currentTarget.value;
|
||||
|
||||
// Only update when value is not empty to prevent resetting the input
|
||||
if (updatedValue !== '') {
|
||||
value = updatedValue;
|
||||
}
|
||||
}}
|
||||
on:blur={() => (value = updatedValue)}
|
||||
/>
|
||||
@@ -6,6 +6,7 @@
|
||||
import LoadingSpinner from '../shared-components/loading-spinner.svelte';
|
||||
|
||||
export let name: string;
|
||||
export let roundedBottom = true;
|
||||
export let isSearching: boolean;
|
||||
export let placeholder: string;
|
||||
|
||||
@@ -17,7 +18,11 @@
|
||||
};
|
||||
</script>
|
||||
|
||||
<div class="flex items-center text-sm rounded-lg bg-gray-100 p-2 dark:bg-gray-700 gap-2 place-items-center h-full">
|
||||
<div
|
||||
class="flex items-center text-sm {roundedBottom
|
||||
? 'rounded-lg'
|
||||
: 'rounded-t-lg'} bg-gray-100 p-2 dark:bg-gray-700 gap-2 place-items-center h-full"
|
||||
>
|
||||
<button on:click={() => dispatch('search', { force: true })}>
|
||||
<div class="w-fit">
|
||||
<Icon path={mdiMagnify} size="24" />
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
<script lang="ts">
|
||||
import { createEventDispatcher } from 'svelte';
|
||||
|
||||
export let checked = false;
|
||||
export let disabled = false;
|
||||
|
||||
const dispatch = createEventDispatcher<{ toggle: boolean }>();
|
||||
const onToggle = (event: Event) => dispatch('toggle', (event.target as HTMLInputElement).checked);
|
||||
</script>
|
||||
|
||||
<label class="relative inline-block h-[10px] w-[36px] flex-none">
|
||||
<input
|
||||
class="disabled::cursor-not-allowed h-0 w-0 opacity-0"
|
||||
type="checkbox"
|
||||
bind:checked
|
||||
on:click={onToggle}
|
||||
{disabled}
|
||||
/>
|
||||
|
||||
{#if disabled}
|
||||
<span class="slider slider-disabled cursor-not-allowed" />
|
||||
{:else}
|
||||
<span class="slider slider-enabled cursor-pointer" />
|
||||
{/if}
|
||||
</label>
|
||||
|
||||
<style>
|
||||
.slider {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background-color: #ccc;
|
||||
-webkit-transition: 0.4s;
|
||||
transition: 0.4s;
|
||||
border-radius: 34px;
|
||||
}
|
||||
|
||||
input:disabled {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.slider:before {
|
||||
position: absolute;
|
||||
content: '';
|
||||
height: 20px;
|
||||
width: 20px;
|
||||
left: 0px;
|
||||
right: 0px;
|
||||
bottom: -4px;
|
||||
background-color: gray;
|
||||
-webkit-transition: 0.4s;
|
||||
transition: 0.4s;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
input:checked + .slider:before {
|
||||
-webkit-transform: translateX(18px);
|
||||
-ms-transform: translateX(18px);
|
||||
transform: translateX(18px);
|
||||
background-color: #4250af;
|
||||
}
|
||||
|
||||
input:checked + .slider-disabled {
|
||||
background-color: gray;
|
||||
}
|
||||
|
||||
input:checked + .slider-enabled {
|
||||
background-color: #adcbfa;
|
||||
}
|
||||
</style>
|
||||
@@ -49,7 +49,7 @@
|
||||
if (assetType === AssetTypeEnum.Image) {
|
||||
image = $photoViewer;
|
||||
} else if (assetType === AssetTypeEnum.Video) {
|
||||
const data = await getAssetThumbnailUrl(assetId, ThumbnailFormat.Webp);
|
||||
const data = getAssetThumbnailUrl(assetId, ThumbnailFormat.Webp);
|
||||
const img: HTMLImageElement = new Image();
|
||||
img.src = data;
|
||||
|
||||
|
||||
@@ -43,10 +43,10 @@
|
||||
dispatch('back');
|
||||
};
|
||||
|
||||
const handleSwapPeople = () => {
|
||||
const handleSwapPeople = async () => {
|
||||
[person, selectedPeople[0]] = [selectedPeople[0], person];
|
||||
$page.url.searchParams.set(QueryParameter.ACTION, ActionQueryParameterValue.MERGE);
|
||||
goto(`${AppRoute.PEOPLE}/${person.id}?${$page.url.searchParams.toString()}`);
|
||||
await goto(`${AppRoute.PEOPLE}/${person.id}?${$page.url.searchParams.toString()}`);
|
||||
};
|
||||
|
||||
const onSelect = (selected: PersonResponseDto) => {
|
||||
|
||||
@@ -100,7 +100,7 @@
|
||||
<div class="flex flex-wrap justify-center md:grid md:grid-cols-{potentialMergePeople.length}">
|
||||
{#each potentialMergePeople as person (person.id)}
|
||||
<div class="h-24 w-24 md:h-28 md:w-28">
|
||||
<button class="p-2" on:click={() => changePersonToMerge(person)}>
|
||||
<button class="p-2 w-full" on:click={() => changePersonToMerge(person)}>
|
||||
<ImageThumbnail
|
||||
border={true}
|
||||
circle
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { timeBeforeShowLoadingSpinner } from '$lib/constants';
|
||||
import { boundingBoxesArray } from '$lib/stores/people.store';
|
||||
import { websocketEvents } from '$lib/stores/websocket';
|
||||
import { getPeopleThumbnailUrl } from '$lib/utils';
|
||||
import { getPeopleThumbnailUrl, handlePromiseError } from '$lib/utils';
|
||||
import { handleError } from '$lib/utils/handle-error';
|
||||
import { getPersonNameWithHiddenValue } from '$lib/utils/person';
|
||||
import {
|
||||
@@ -46,8 +46,8 @@
|
||||
let allPeople: PersonResponseDto[] = [];
|
||||
|
||||
// timers
|
||||
let loaderLoadingDoneTimeout: NodeJS.Timeout;
|
||||
let automaticRefreshTimeout: NodeJS.Timeout;
|
||||
let loaderLoadingDoneTimeout: ReturnType<typeof setTimeout>;
|
||||
let automaticRefreshTimeout: ReturnType<typeof setTimeout>;
|
||||
|
||||
const dispatch = createEventDispatcher<{
|
||||
close: void;
|
||||
@@ -85,7 +85,7 @@
|
||||
};
|
||||
|
||||
onMount(() => {
|
||||
loadPeople();
|
||||
handlePromiseError(loadPeople());
|
||||
return websocketEvents.on('on_person_thumbnail', onPersonThumbnail);
|
||||
});
|
||||
|
||||
@@ -164,7 +164,7 @@
|
||||
}
|
||||
};
|
||||
|
||||
const handlePersonPicker = async (index: number) => {
|
||||
const handlePersonPicker = (index: number) => {
|
||||
editedPersonIndex = index;
|
||||
showSeletecFaces = true;
|
||||
};
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
import FullScreenModal from '../shared-components/full-screen-modal.svelte';
|
||||
import { mdiCake } from '@mdi/js';
|
||||
import Icon from '$lib/components/elements/icon.svelte';
|
||||
import DateInput from '../elements/date-input.svelte';
|
||||
|
||||
export let birthDate: string;
|
||||
|
||||
@@ -37,7 +38,7 @@
|
||||
|
||||
<form on:submit|preventDefault={() => handleSubmit()} autocomplete="off">
|
||||
<div class="m-4 flex flex-col gap-2">
|
||||
<input
|
||||
<DateInput
|
||||
class="immich-form-input"
|
||||
id="birthDate"
|
||||
name="birthDate"
|
||||
|
||||
@@ -132,9 +132,7 @@
|
||||
title={'Assign selected assets to a new person'}
|
||||
size={'sm'}
|
||||
disabled={disableButtons || hasSelection}
|
||||
on:click={() => {
|
||||
handleCreate();
|
||||
}}
|
||||
on:click={handleCreate}
|
||||
>
|
||||
{#if !showLoadingSpinnerCreate}
|
||||
<Icon path={mdiPlus} size={18} />
|
||||
@@ -147,9 +145,7 @@
|
||||
size={'sm'}
|
||||
title={'Assign selected assets to an existing person'}
|
||||
disabled={disableButtons || !hasSelection}
|
||||
on:click={() => {
|
||||
handleReassign();
|
||||
}}
|
||||
on:click={handleReassign}
|
||||
>
|
||||
{#if !showLoadingSpinnerReassign}
|
||||
<div>
|
||||
|
||||
@@ -4,14 +4,15 @@
|
||||
import { signUpAdmin } from '@immich/sdk';
|
||||
import { handleError } from '../../utils/handle-error';
|
||||
import Button from '../elements/buttons/button.svelte';
|
||||
import PasswordField from '../shared-components/password-field.svelte';
|
||||
|
||||
let errorMessage: string;
|
||||
let password = '';
|
||||
let confirmPassowrd = '';
|
||||
let confirmPassword = '';
|
||||
let canRegister = false;
|
||||
|
||||
$: {
|
||||
if (password !== confirmPassowrd && confirmPassowrd.length > 0) {
|
||||
if (password !== confirmPassword && confirmPassword.length > 0) {
|
||||
errorMessage = 'Password does not match';
|
||||
canRegister = false;
|
||||
} else {
|
||||
@@ -56,28 +57,12 @@
|
||||
|
||||
<div class="flex flex-col gap-2">
|
||||
<label class="immich-form-label" for="password">Admin Password</label>
|
||||
<input
|
||||
class="immich-form-input"
|
||||
id="password"
|
||||
name="password"
|
||||
type="password"
|
||||
autocomplete="new-password"
|
||||
required
|
||||
bind:value={password}
|
||||
/>
|
||||
<PasswordField id="password" name="password" bind:password autocomplete="new-password" />
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-2">
|
||||
<label class="immich-form-label" for="confirmPassword">Confirm Admin Password</label>
|
||||
<input
|
||||
class="immich-form-input"
|
||||
id="confirmPassword"
|
||||
name="password"
|
||||
type="password"
|
||||
autocomplete="new-password"
|
||||
required
|
||||
bind:value={confirmPassowrd}
|
||||
/>
|
||||
<PasswordField id="confirmPassword" bind:password={confirmPassword} autocomplete="new-password" />
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-2">
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { createEventDispatcher } from 'svelte';
|
||||
import Button from '../elements/buttons/button.svelte';
|
||||
import PasswordField from '../shared-components/password-field.svelte';
|
||||
import { updateUser, type UserResponseDto } from '@immich/sdk';
|
||||
|
||||
export let user: UserResponseDto;
|
||||
@@ -46,28 +47,12 @@
|
||||
<form on:submit|preventDefault={changePassword} method="post" class="mt-5 flex flex-col gap-5">
|
||||
<div class="flex flex-col gap-2">
|
||||
<label class="immich-form-label" for="password">New Password</label>
|
||||
<input
|
||||
class="immich-form-input"
|
||||
id="password"
|
||||
name="password"
|
||||
type="password"
|
||||
autocomplete="new-password"
|
||||
required
|
||||
bind:value={password}
|
||||
/>
|
||||
<PasswordField id="password" bind:password autocomplete="new-password" />
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-2">
|
||||
<label class="immich-form-label" for="confirmPassword">Confirm Password</label>
|
||||
<input
|
||||
class="immich-form-input"
|
||||
id="confirmPassword"
|
||||
name="password"
|
||||
type="password"
|
||||
autocomplete="current-password"
|
||||
required
|
||||
bind:value={passwordConfirm}
|
||||
/>
|
||||
<PasswordField id="confirmPassword" bind:password={passwordConfirm} autocomplete="new-password" />
|
||||
</div>
|
||||
|
||||
{#if errorMessage}
|
||||
|
||||
@@ -6,12 +6,13 @@
|
||||
import { createEventDispatcher } from 'svelte';
|
||||
import Button from '../elements/buttons/button.svelte';
|
||||
import ImmichLogo from '../shared-components/immich-logo.svelte';
|
||||
import PasswordField from '../shared-components/password-field.svelte';
|
||||
|
||||
let error: string;
|
||||
let success: string;
|
||||
|
||||
let password = '';
|
||||
let confirmPassowrd = '';
|
||||
let confirmPassword = '';
|
||||
|
||||
let canCreateUser = false;
|
||||
let quotaSize: number | undefined;
|
||||
@@ -20,7 +21,7 @@
|
||||
$: quotaSizeWarning = quotaSize && convertToBytes(Number(quotaSize), 'GiB') > $serverInfo.diskSizeRaw;
|
||||
|
||||
$: {
|
||||
if (password !== confirmPassowrd && confirmPassowrd.length > 0) {
|
||||
if (password !== confirmPassword && confirmPassword.length > 0) {
|
||||
error = 'Password does not match';
|
||||
canCreateUser = false;
|
||||
} else {
|
||||
@@ -91,19 +92,12 @@
|
||||
|
||||
<div class="m-4 flex flex-col gap-2">
|
||||
<label class="immich-form-label" for="password">Password</label>
|
||||
<input class="immich-form-input" id="password" name="password" type="password" required bind:value={password} />
|
||||
<PasswordField id="password" name="password" bind:password autocomplete="new-password" />
|
||||
</div>
|
||||
|
||||
<div class="m-4 flex flex-col gap-2">
|
||||
<label class="immich-form-label" for="confirmPassword">Confirm Password</label>
|
||||
<input
|
||||
class="immich-form-input"
|
||||
id="confirmPassword"
|
||||
name="password"
|
||||
type="password"
|
||||
required
|
||||
bind:value={confirmPassowrd}
|
||||
/>
|
||||
<PasswordField id="confirmPassword" bind:password={confirmPassword} autocomplete="new-password" />
|
||||
</div>
|
||||
|
||||
<div class="m-4 flex flex-col gap-2">
|
||||
|
||||
@@ -37,7 +37,7 @@
|
||||
dispatch('submit', { library, type: LibraryType.External });
|
||||
};
|
||||
|
||||
const handleAddExclusionPattern = async () => {
|
||||
const handleAddExclusionPattern = () => {
|
||||
if (!addExclusionPattern) {
|
||||
return;
|
||||
}
|
||||
@@ -60,7 +60,7 @@
|
||||
}
|
||||
};
|
||||
|
||||
const handleEditExclusionPattern = async () => {
|
||||
const handleEditExclusionPattern = () => {
|
||||
if (editExclusionPattern === null) {
|
||||
return;
|
||||
}
|
||||
@@ -79,7 +79,7 @@
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteExclusionPattern = async () => {
|
||||
const handleDeleteExclusionPattern = () => {
|
||||
if (editExclusionPattern === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
import { onMount } from 'svelte';
|
||||
import { fade } from 'svelte/transition';
|
||||
import Button from '../elements/buttons/button.svelte';
|
||||
import PasswordField from '../shared-components/password-field.svelte';
|
||||
|
||||
export let onSuccess: () => unknown | Promise<unknown>;
|
||||
export let onFirstLogin: () => unknown | Promise<unknown>;
|
||||
@@ -46,7 +47,7 @@
|
||||
return;
|
||||
}
|
||||
} catch (error) {
|
||||
await handleError(error, 'Unable to connect!');
|
||||
handleError(error, 'Unable to connect!');
|
||||
}
|
||||
|
||||
oauthLoading = false;
|
||||
@@ -112,15 +113,7 @@
|
||||
|
||||
<div class="flex flex-col gap-2">
|
||||
<label class="immich-form-label" for="password">Password</label>
|
||||
<input
|
||||
class="immich-form-input"
|
||||
id="password"
|
||||
name="password"
|
||||
type="password"
|
||||
autocomplete="current-password"
|
||||
bind:value={password}
|
||||
required
|
||||
/>
|
||||
<PasswordField id="password" bind:password autocomplete="current-password" />
|
||||
</div>
|
||||
|
||||
<div class="my-5 flex w-full">
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
import LinkButton from '../elements/buttons/link-button.svelte';
|
||||
import SettingSelect from '$lib/components/shared-components/settings/setting-select.svelte';
|
||||
import SettingSwitch from '$lib/components/shared-components/settings/setting-switch.svelte';
|
||||
import DateInput from '../elements/date-input.svelte';
|
||||
|
||||
export let settings: MapSettings;
|
||||
let customDateRange = !!settings.dateAfter || !!settings.dateBefore;
|
||||
@@ -38,7 +39,7 @@
|
||||
<div in:fly={{ y: 10, duration: 200 }} class="flex flex-col gap-4">
|
||||
<div class="flex items-center justify-between gap-8">
|
||||
<label class="immich-form-label shrink-0 text-sm" for="date-after">Date after</label>
|
||||
<input
|
||||
<DateInput
|
||||
class="immich-form-input w-40"
|
||||
type="date"
|
||||
id="date-after"
|
||||
@@ -48,7 +49,7 @@
|
||||
</div>
|
||||
<div class="flex items-center justify-between gap-8">
|
||||
<label class="immich-form-label shrink-0 text-sm" for="date-before">Date before</label>
|
||||
<input class="immich-form-input w-40" type="date" id="date-before" bind:value={settings.dateBefore} />
|
||||
<DateInput class="immich-form-input w-40" type="date" id="date-before" bind:value={settings.dateBefore} />
|
||||
</div>
|
||||
<div class="flex justify-center text-xs">
|
||||
<LinkButton
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
import { AppRoute, QueryParameter } from '$lib/constants';
|
||||
import type { Viewport } from '$lib/stores/assets.store';
|
||||
import { memoryStore } from '$lib/stores/memory.store';
|
||||
import { getAssetThumbnailUrl } from '$lib/utils';
|
||||
import { getAssetThumbnailUrl, handlePromiseError } from '$lib/utils';
|
||||
import { fromLocalDateTime } from '$lib/utils/timeline-util';
|
||||
import { ThumbnailFormat, getMemoryLane } from '@immich/sdk';
|
||||
import { mdiChevronDown, mdiChevronLeft, mdiChevronRight, mdiChevronUp, mdiPause, mdiPlay } from '@mdi/js';
|
||||
@@ -59,30 +59,30 @@
|
||||
let paused = false;
|
||||
|
||||
// Play or pause progress when the paused state changes.
|
||||
$: paused ? pause() : play();
|
||||
$: paused ? handlePromiseError(pause()) : handlePromiseError(play());
|
||||
|
||||
// Progress should be paused when it's no longer possible to advance.
|
||||
$: paused ||= !canGoForward || galleryInView;
|
||||
|
||||
// Advance to the next asset or memory when progress is complete.
|
||||
$: $progress === 1 && toNext();
|
||||
$: $progress === 1 && handlePromiseError(toNext());
|
||||
|
||||
// Progress should be resumed when reset and not paused.
|
||||
$: !$progress && !paused && play();
|
||||
$: !$progress && !paused && handlePromiseError(play());
|
||||
|
||||
// Progress should be reset when the current memory or asset changes.
|
||||
$: memoryIndex, assetIndex, reset();
|
||||
$: memoryIndex, assetIndex, handlePromiseError(reset());
|
||||
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
const handleKeyDown = async (e: KeyboardEvent) => {
|
||||
if (e.key === 'ArrowRight' && canGoForward) {
|
||||
e.preventDefault();
|
||||
toNext();
|
||||
await toNext();
|
||||
} else if (e.key === 'ArrowLeft' && canGoBack) {
|
||||
e.preventDefault();
|
||||
toPrevious();
|
||||
await toPrevious();
|
||||
} else if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
goto(AppRoute.PHOTOS);
|
||||
await goto(AppRoute.PHOTOS);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -27,18 +27,22 @@
|
||||
showAlbumPicker = false;
|
||||
|
||||
const assetIds = [...getAssets()].map((asset) => asset.id);
|
||||
createAlbum({ createAlbumDto: { albumName, assetIds } }).then((response) => {
|
||||
const { id, albumName } = response;
|
||||
createAlbum({ createAlbumDto: { albumName, assetIds } })
|
||||
.then(async (response) => {
|
||||
const { id, albumName } = response;
|
||||
|
||||
notificationController.show({
|
||||
message: `Added ${assetIds.length} to ${albumName}`,
|
||||
type: NotificationType.Info,
|
||||
notificationController.show({
|
||||
message: `Added ${assetIds.length} to ${albumName}`,
|
||||
type: NotificationType.Info,
|
||||
});
|
||||
|
||||
clearSelect();
|
||||
|
||||
await goto(`${AppRoute.ALBUMS}/${id}`);
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error(`[add-to-album.svelte]:handleAddToNewAlbum ${error}`, error);
|
||||
});
|
||||
|
||||
clearSelect();
|
||||
|
||||
goto(`${AppRoute.ALBUMS}/${id}`);
|
||||
});
|
||||
};
|
||||
|
||||
const handleAddToAlbum = async (album: AlbumResponseDto) => {
|
||||
|
||||
@@ -80,13 +80,17 @@
|
||||
});
|
||||
}
|
||||
|
||||
const assetClickHandler = (asset: AssetResponseDto, assetsInDateGroup: AssetResponseDto[], groupTitle: string) => {
|
||||
const assetClickHandler = async (
|
||||
asset: AssetResponseDto,
|
||||
assetsInDateGroup: AssetResponseDto[],
|
||||
groupTitle: string,
|
||||
) => {
|
||||
if (isSelectionMode || $isMultiSelectState) {
|
||||
assetSelectHandler(asset, assetsInDateGroup, groupTitle);
|
||||
return;
|
||||
}
|
||||
|
||||
assetViewingStore.setAssetId(asset.id);
|
||||
await assetViewingStore.setAssetId(asset.id);
|
||||
};
|
||||
|
||||
const handleSelectGroup = (title: string, assets: AssetResponseDto[]) => dispatch('select', { title, assets });
|
||||
|
||||
@@ -21,6 +21,7 @@
|
||||
import ShowShortcuts from '../shared-components/show-shortcuts.svelte';
|
||||
import AssetDateGroup from './asset-date-group.svelte';
|
||||
import DeleteAssetDialog from './delete-asset-dialog.svelte';
|
||||
import { handlePromiseError } from '$lib/utils';
|
||||
|
||||
export let isSelectionMode = false;
|
||||
export let singleSelect = false;
|
||||
@@ -47,19 +48,19 @@
|
||||
$: isEmpty = $assetStore.initialized && $assetStore.buckets.length === 0;
|
||||
$: idsSelectedAssets = [...$selectedAssets].filter((a) => !a.isExternal).map((a) => a.id);
|
||||
|
||||
const onKeyboardPress = (event: KeyboardEvent) => handleKeyboardPress(event);
|
||||
const dispatch = createEventDispatcher<{ select: AssetResponseDto; escape: void }>();
|
||||
|
||||
const onKeydown = (event: KeyboardEvent) => handlePromiseError(handleKeyboardPress(event));
|
||||
onMount(async () => {
|
||||
showSkeleton = false;
|
||||
document.addEventListener('keydown', onKeyboardPress);
|
||||
document.addEventListener('keydown', onKeydown);
|
||||
assetStore.connect();
|
||||
await assetStore.init(viewport);
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
if (browser) {
|
||||
document.removeEventListener('keydown', onKeyboardPress);
|
||||
document.removeEventListener('keydown', onKeydown);
|
||||
}
|
||||
|
||||
if ($showAssetViewer) {
|
||||
@@ -69,13 +70,13 @@
|
||||
assetStore.disconnect();
|
||||
});
|
||||
|
||||
const trashOrDelete = (force: boolean = false) => {
|
||||
const trashOrDelete = async (force: boolean = false) => {
|
||||
isShowDeleteConfirmation = false;
|
||||
deleteAssets(!(isTrashEnabled && !force), (assetId) => assetStore.removeAsset(assetId), idsSelectedAssets);
|
||||
await deleteAssets(!(isTrashEnabled && !force), (assetId) => assetStore.removeAsset(assetId), idsSelectedAssets);
|
||||
assetInteractionStore.clearMultiselect();
|
||||
};
|
||||
|
||||
const handleKeyboardPress = (event: KeyboardEvent) => {
|
||||
const handleKeyboardPress = async (event: KeyboardEvent) => {
|
||||
if ($isSearchEnabled || shouldIgnoreShortcut(event)) {
|
||||
return;
|
||||
}
|
||||
@@ -98,7 +99,7 @@
|
||||
}
|
||||
case '/': {
|
||||
event.preventDefault();
|
||||
goto(AppRoute.EXPLORE);
|
||||
await goto(AppRoute.EXPLORE);
|
||||
return;
|
||||
}
|
||||
case 'Delete': {
|
||||
@@ -112,7 +113,7 @@
|
||||
force = true;
|
||||
}
|
||||
|
||||
trashOrDelete(force);
|
||||
await trashOrDelete(force);
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -126,12 +127,12 @@
|
||||
}
|
||||
};
|
||||
|
||||
function intersectedHandler(event: CustomEvent) {
|
||||
async function intersectedHandler(event: CustomEvent) {
|
||||
const element_ = event.detail.container as HTMLElement;
|
||||
const target = element_.firstChild as HTMLElement;
|
||||
if (target) {
|
||||
const bucketDate = target.id.split('_')[1];
|
||||
assetStore.loadBucket(bucketDate, event.detail.position);
|
||||
await assetStore.loadBucket(bucketDate, event.detail.position);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -142,7 +143,7 @@
|
||||
const handlePrevious = async () => {
|
||||
const previousAsset = await assetStore.getPreviousAssetId($viewingAsset.id);
|
||||
if (previousAsset) {
|
||||
assetViewingStore.setAssetId(previousAsset);
|
||||
await assetViewingStore.setAssetId(previousAsset);
|
||||
}
|
||||
|
||||
return !!previousAsset;
|
||||
@@ -151,7 +152,7 @@
|
||||
const handleNext = async () => {
|
||||
const nextAsset = await assetStore.getNextAssetId($viewingAsset.id);
|
||||
if (nextAsset) {
|
||||
assetViewingStore.setAssetId(nextAsset);
|
||||
await assetViewingStore.setAssetId(nextAsset);
|
||||
}
|
||||
|
||||
return !!nextAsset;
|
||||
@@ -369,7 +370,7 @@
|
||||
<DeleteAssetDialog
|
||||
size={idsSelectedAssets.length}
|
||||
on:cancel={() => (isShowDeleteConfirmation = false)}
|
||||
on:confirm={() => trashOrDelete(true)}
|
||||
on:confirm={() => handlePromiseError(trashOrDelete(true))}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
@@ -456,9 +457,9 @@
|
||||
asset={$viewingAsset}
|
||||
{isShared}
|
||||
{album}
|
||||
on:previous={() => handlePrevious()}
|
||||
on:next={() => handleNext()}
|
||||
on:close={() => handleClose()}
|
||||
on:previous={handlePrevious}
|
||||
on:next={handleNext}
|
||||
on:close={handleClose}
|
||||
on:action={({ detail: action }) => handleAction(action.type, action.asset)}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import { goto } from '$app/navigation';
|
||||
import { AppRoute } from '$lib/constants';
|
||||
import { dragAndDropFilesStore } from '$lib/stores/drag-and-drop-files.store';
|
||||
import { getKey } from '$lib/utils';
|
||||
import { getKey, handlePromiseError } from '$lib/utils';
|
||||
import { downloadArchive } from '$lib/utils/asset-utils';
|
||||
import { fileUploadHandler, openFileUploadDialog } from '$lib/utils/file-uploader';
|
||||
import { handleError } from '$lib/utils/handle-error';
|
||||
@@ -29,7 +29,7 @@
|
||||
|
||||
dragAndDropFilesStore.subscribe((value) => {
|
||||
if (value.isDragging && value.files.length > 0) {
|
||||
handleUploadAssets(value.files);
|
||||
handlePromiseError(handleUploadAssets(value.files));
|
||||
dragAndDropFilesStore.set({ isDragging: false, files: [] });
|
||||
}
|
||||
});
|
||||
@@ -59,7 +59,7 @@
|
||||
type: NotificationType.Info,
|
||||
});
|
||||
} catch (error) {
|
||||
await handleError(error, 'Unable to add assets to shared link');
|
||||
handleError(error, 'Unable to add assets to shared link');
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { DateTime } from 'luxon';
|
||||
import ConfirmDialogue from './confirm-dialogue.svelte';
|
||||
import Combobox from './combobox.svelte';
|
||||
import DateInput from '../elements/date-input.svelte';
|
||||
|
||||
export let initialDate: DateTime = DateTime.now();
|
||||
|
||||
@@ -74,7 +75,7 @@
|
||||
<div class="mt-2" />
|
||||
<div class="flex flex-col">
|
||||
<label for="datetime">Date and Time</label>
|
||||
<input
|
||||
<DateInput
|
||||
class="immich-form-input text-sm my-4 w-full"
|
||||
id="datetime"
|
||||
type="datetime-local"
|
||||
|
||||
@@ -1,10 +1,15 @@
|
||||
<script lang="ts">
|
||||
import type { AssetResponseDto } from '@immich/sdk';
|
||||
import { createEventDispatcher } from 'svelte';
|
||||
import ConfirmDialogue from './confirm-dialogue.svelte';
|
||||
import { maximumLengthSearchPeople, timeBeforeShowLoadingSpinner } from '$lib/constants';
|
||||
import { handleError } from '$lib/utils/handle-error';
|
||||
|
||||
import { clickOutside } from '$lib/utils/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';
|
||||
|
||||
export const title = 'Change Location';
|
||||
export let asset: AssetResponseDto | undefined = undefined;
|
||||
@@ -14,6 +19,16 @@
|
||||
lat: number;
|
||||
}
|
||||
|
||||
let places: PlacesResponseDto[] = [];
|
||||
let suggestedPlaces: PlacesResponseDto[] = [];
|
||||
let searchWord: string;
|
||||
let isSearching = false;
|
||||
let showSpinner = false;
|
||||
let focusedElements: (HTMLButtonElement | null)[] = Array.from({ length: maximumLengthSearchPeople }, () => null);
|
||||
let indexFocus: number | null = null;
|
||||
let hideSuggestion = false;
|
||||
let addClipMapMarker: (long: number, lat: number) => void;
|
||||
|
||||
const dispatch = createEventDispatcher<{
|
||||
cancel: void;
|
||||
confirm: Point;
|
||||
@@ -23,6 +38,16 @@
|
||||
$: lng = asset?.exifInfo?.longitude || 0;
|
||||
$: zoom = lat && lng ? 15 : 1;
|
||||
|
||||
$: {
|
||||
if (places) {
|
||||
suggestedPlaces = places.slice(0, 5);
|
||||
indexFocus = null;
|
||||
}
|
||||
if (searchWord === '') {
|
||||
suggestedPlaces = [];
|
||||
}
|
||||
}
|
||||
|
||||
let point: Point | null = null;
|
||||
|
||||
const handleCancel = () => dispatch('cancel');
|
||||
@@ -38,8 +63,82 @@
|
||||
dispatch('cancel');
|
||||
}
|
||||
};
|
||||
|
||||
const getLocation = (name: string, admin1Name?: string, admin2Name?: string): string => {
|
||||
return `${name}${admin1Name ? ', ' + admin1Name : ''}${admin2Name ? ', ' + admin2Name : ''}`;
|
||||
};
|
||||
|
||||
const handleSearchPlaces = async () => {
|
||||
if (searchWord === '' || isSearching) {
|
||||
return;
|
||||
}
|
||||
|
||||
// TODO: refactor setTimeout/clearTimeout logic - there are no less than 12 places that duplicate this code
|
||||
isSearching = true;
|
||||
const timeout = setTimeout(() => (showSpinner = true), timeBeforeShowLoadingSpinner);
|
||||
try {
|
||||
places = await searchPlaces({ name: searchWord });
|
||||
} catch (error) {
|
||||
places = [];
|
||||
handleError(error, "Can't search places");
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
isSearching = false;
|
||||
showSpinner = false;
|
||||
}
|
||||
};
|
||||
|
||||
const handleUseSuggested = (latitude: number, longitude: number) => {
|
||||
hideSuggestion = true;
|
||||
point = { lng: longitude, lat: latitude };
|
||||
addClipMapMarker(longitude, latitude);
|
||||
};
|
||||
|
||||
const handleKeyboardPress = (event: KeyboardEvent) => {
|
||||
if (suggestedPlaces.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
event.stopPropagation();
|
||||
switch (event.key) {
|
||||
case 'ArrowDown': {
|
||||
event.preventDefault();
|
||||
if (indexFocus === null) {
|
||||
indexFocus = 0;
|
||||
} else if (indexFocus === suggestedPlaces.length - 1) {
|
||||
indexFocus = 0;
|
||||
} else {
|
||||
indexFocus++;
|
||||
}
|
||||
focusedElements[indexFocus]?.focus();
|
||||
return;
|
||||
}
|
||||
case 'ArrowUp': {
|
||||
if (indexFocus === null) {
|
||||
indexFocus = 0;
|
||||
return;
|
||||
}
|
||||
if (indexFocus === 0) {
|
||||
indexFocus = suggestedPlaces.length - 1;
|
||||
} else {
|
||||
indexFocus--;
|
||||
}
|
||||
focusedElements[indexFocus]?.focus();
|
||||
|
||||
return;
|
||||
}
|
||||
case 'Enter': {
|
||||
if (indexFocus !== null) {
|
||||
hideSuggestion = true;
|
||||
handleUseSuggested(suggestedPlaces[indexFocus].latitude, suggestedPlaces[indexFocus].longitude);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<svelte:document on:keydown={handleKeyboardPress} />
|
||||
|
||||
<ConfirmDialogue
|
||||
confirmColor="primary"
|
||||
cancelColor="secondary"
|
||||
@@ -49,6 +148,38 @@
|
||||
on:cancel={handleCancel}
|
||||
>
|
||||
<div slot="prompt" class="flex flex-col w-full h-full gap-2">
|
||||
<div class="relative w-64 sm:w-96" use:clickOutside on:outclick={() => (hideSuggestion = true)}>
|
||||
<button class="w-full" on:click={() => (hideSuggestion = false)}>
|
||||
<SearchBar
|
||||
placeholder="Search places"
|
||||
bind:name={searchWord}
|
||||
isSearching={showSpinner}
|
||||
on:reset={() => {
|
||||
suggestedPlaces = [];
|
||||
}}
|
||||
on:search={handleSearchPlaces}
|
||||
roundedBottom={suggestedPlaces.length === 0 || hideSuggestion}
|
||||
/>
|
||||
</button>
|
||||
<div class="absolute z-[99] w-full" id="suggestion">
|
||||
{#if !hideSuggestion}
|
||||
{#each suggestedPlaces as place, index}
|
||||
<button
|
||||
bind:this={focusedElements[index]}
|
||||
class=" flex w-full border-t border-gray-400 dark:border-immich-dark-gray h-14 place-items-center bg-gray-200 p-2 dark:bg-gray-700 hover:bg-gray-300 hover:dark:bg-[#232932] focus:bg-gray-300 focus:dark:bg-[#232932] {index ===
|
||||
suggestedPlaces.length - 1
|
||||
? 'rounded-b-lg border-b'
|
||||
: ''}"
|
||||
on:click={() => handleUseSuggested(place.latitude, place.longitude)}
|
||||
>
|
||||
<p class="ml-4 text-sm text-gray-700 dark:text-gray-100 truncate">
|
||||
{getLocation(place.name, place.admin1name, place.admin2name)}
|
||||
</p>
|
||||
</button>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
<label for="datetime">Pick a location</label>
|
||||
<div class="h-[500px] min-h-[300px] w-full">
|
||||
{#await import('../shared-components/map/map.svelte')}
|
||||
@@ -63,6 +194,7 @@
|
||||
this={component.default}
|
||||
mapMarkers={lat && lng && asset ? [{ id: asset.id, lat, lon: lng }] : []}
|
||||
{zoom}
|
||||
bind:addClipMapMarker
|
||||
center={lat && lng ? { lat, lng } : undefined}
|
||||
simplified={true}
|
||||
clickable={true}
|
||||
|
||||
@@ -3,6 +3,10 @@
|
||||
label: string;
|
||||
value: string;
|
||||
};
|
||||
|
||||
export function toComboBoxOptions(items: string[]) {
|
||||
return items.map<ComboBoxOption>((item) => ({ label: item, value: item }));
|
||||
}
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import Icon from '$lib/components/elements/icon.svelte';
|
||||
import { Theme } from '$lib/constants';
|
||||
import { colorTheme, mapSettings } from '$lib/stores/preferences.store';
|
||||
import { getAssetThumbnailUrl } from '$lib/utils';
|
||||
import { getAssetThumbnailUrl, handlePromiseError } from '$lib/utils';
|
||||
import { getMapStyle, MapTheme, type MapMarkerResponseDto } from '@immich/sdk';
|
||||
import { mdiCog, mdiMapMarker } from '@mdi/js';
|
||||
import type { Feature, GeoJsonProperties, Geometry, Point } from 'geojson';
|
||||
@@ -32,6 +32,17 @@
|
||||
export let simplified = false;
|
||||
export let clickable = false;
|
||||
export let useLocationPin = false;
|
||||
export function addClipMapMarker(lng: number, lat: number) {
|
||||
if (map) {
|
||||
if (marker) {
|
||||
marker.remove();
|
||||
}
|
||||
|
||||
center = { lng, lat };
|
||||
marker = new maplibregl.Marker().setLngLat([lng, lat]).addTo(map);
|
||||
map.setZoom(15);
|
||||
}
|
||||
}
|
||||
|
||||
let map: maplibregl.Map;
|
||||
let marker: maplibregl.Marker | null = null;
|
||||
@@ -141,9 +152,7 @@
|
||||
applyToClusters
|
||||
asButton
|
||||
let:feature
|
||||
on:click={(event) => {
|
||||
handleClusterClick(event.detail.feature.properties.cluster_id, map);
|
||||
}}
|
||||
on:click={(event) => handlePromiseError(handleClusterClick(event.detail.feature.properties.cluster_id, map))}
|
||||
>
|
||||
<div
|
||||
class="rounded-full w-[40px] h-[40px] bg-immich-primary text-immich-gray flex justify-center items-center font-mono font-bold shadow-lg hover:bg-immich-dark-primary transition-all duration-200 hover:text-immich-dark-bg opacity-90"
|
||||
|
||||
@@ -8,8 +8,8 @@
|
||||
easing: cubicOut,
|
||||
});
|
||||
|
||||
onMount(() => {
|
||||
progress.set(90);
|
||||
onMount(async () => {
|
||||
await progress.set(90);
|
||||
});
|
||||
</script>
|
||||
|
||||
|
||||
@@ -59,7 +59,7 @@
|
||||
}
|
||||
};
|
||||
|
||||
let removeNotificationTimeout: NodeJS.Timeout | undefined;
|
||||
let removeNotificationTimeout: ReturnType<typeof setTimeout> | undefined;
|
||||
|
||||
onMount(() => {
|
||||
removeNotificationTimeout = setTimeout(discard, notificationInfo.timeout);
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
<script lang="ts">
|
||||
import { mdiEyeOffOutline, mdiEyeOutline } from '@mdi/js';
|
||||
import type { HTMLInputAttributes } from 'svelte/elements';
|
||||
import Icon from '../elements/icon.svelte';
|
||||
|
||||
interface $$Props extends HTMLInputAttributes {
|
||||
password: string;
|
||||
autocomplete: string;
|
||||
required?: boolean;
|
||||
onInput?: (value: string) => void;
|
||||
}
|
||||
|
||||
export let password: $$Props['password'];
|
||||
export let required = true;
|
||||
export let onInput: $$Props['onInput'] = undefined;
|
||||
|
||||
let showPassword = false;
|
||||
</script>
|
||||
|
||||
<div class="relative w-full">
|
||||
<input
|
||||
{...$$restProps}
|
||||
class="immich-form-input w-full !pr-12"
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
{required}
|
||||
value={password}
|
||||
on:input={(e) => {
|
||||
password = e.currentTarget.value;
|
||||
onInput?.(password);
|
||||
}}
|
||||
/>
|
||||
|
||||
{#if password.length > 0}
|
||||
<button
|
||||
type="button"
|
||||
tabindex="-1"
|
||||
class="absolute inset-y-0 end-0 px-4 text-gray-700 dark:text-gray-200"
|
||||
on:click={() => (showPassword = !showPassword)}
|
||||
title={showPassword ? 'Hide password' : 'Show password'}
|
||||
>
|
||||
<Icon path={showPassword ? mdiEyeOffOutline : mdiEyeOutline} size="1.25em" />
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
input::-ms-reveal {
|
||||
display: none;
|
||||
}
|
||||
</style>
|
||||
@@ -1,4 +1,5 @@
|
||||
<script context="module" lang="ts">
|
||||
import { handlePromiseError } from '$lib/utils';
|
||||
import { tick } from 'svelte';
|
||||
|
||||
/**
|
||||
@@ -36,7 +37,7 @@
|
||||
}
|
||||
}
|
||||
|
||||
update(target);
|
||||
handlePromiseError(update(target));
|
||||
return {
|
||||
update,
|
||||
destroy,
|
||||
|
||||
@@ -6,6 +6,8 @@
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
import { handlePromiseError } from '$lib/utils';
|
||||
|
||||
import { createEventDispatcher, onMount } from 'svelte';
|
||||
import { tweened } from 'svelte/motion';
|
||||
|
||||
@@ -15,20 +17,29 @@
|
||||
*/
|
||||
export let autoplay = false;
|
||||
|
||||
/**
|
||||
* Duration in milliseconds
|
||||
* @default 5000
|
||||
*/
|
||||
export let duration = 5000;
|
||||
|
||||
/**
|
||||
* Progress bar status
|
||||
*/
|
||||
export let status: ProgressBarStatus = ProgressBarStatus.Paused;
|
||||
|
||||
let progress = tweened<number>(0, {
|
||||
duration: (from: number, to: number) => (to ? duration * (to - from) : 0),
|
||||
});
|
||||
export let hidden = false;
|
||||
|
||||
export let duration = 5;
|
||||
|
||||
const onChange = async () => {
|
||||
progress = setDuration(duration);
|
||||
await play();
|
||||
};
|
||||
|
||||
let progress = setDuration(duration);
|
||||
|
||||
$: duration, handlePromiseError(onChange());
|
||||
|
||||
$: {
|
||||
if ($progress === 1) {
|
||||
dispatch('done');
|
||||
}
|
||||
}
|
||||
|
||||
const dispatch = createEventDispatcher<{
|
||||
done: void;
|
||||
@@ -36,48 +47,44 @@
|
||||
paused: void;
|
||||
}>();
|
||||
|
||||
onMount(() => {
|
||||
onMount(async () => {
|
||||
if (autoplay) {
|
||||
play();
|
||||
await play();
|
||||
}
|
||||
});
|
||||
|
||||
export const play = () => {
|
||||
export const play = async () => {
|
||||
status = ProgressBarStatus.Playing;
|
||||
dispatch('playing');
|
||||
progress.set(1);
|
||||
await progress.set(1);
|
||||
};
|
||||
|
||||
export const pause = () => {
|
||||
export const pause = async () => {
|
||||
status = ProgressBarStatus.Paused;
|
||||
dispatch('paused');
|
||||
progress.set($progress);
|
||||
await progress.set($progress);
|
||||
};
|
||||
|
||||
export const restart = (autoplay: boolean) => {
|
||||
progress.set(0);
|
||||
export const restart = async (autoplay: boolean) => {
|
||||
await progress.set(0);
|
||||
|
||||
if (autoplay) {
|
||||
play();
|
||||
await play();
|
||||
}
|
||||
};
|
||||
|
||||
export const reset = () => {
|
||||
export const reset = async () => {
|
||||
status = ProgressBarStatus.Paused;
|
||||
progress.set(0);
|
||||
await progress.set(0);
|
||||
};
|
||||
|
||||
export const setDuration = (newDuration: number) => {
|
||||
progress = tweened<number>(0, {
|
||||
duration: (from: number, to: number) => (to ? newDuration * (to - from) : 0),
|
||||
function setDuration(newDuration: number) {
|
||||
return tweened<number>(0, {
|
||||
duration: (from: number, to: number) => (to ? newDuration * 1000 * (to - from) : 0),
|
||||
});
|
||||
};
|
||||
|
||||
progress.subscribe((value) => {
|
||||
if (value === 1) {
|
||||
dispatch('done');
|
||||
}
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<span class="absolute left-0 h-[3px] bg-immich-primary shadow-2xl" style:width={`${$progress * 100}%`} />
|
||||
{#if !hidden}
|
||||
<span class="absolute left-0 h-[3px] bg-immich-primary shadow-2xl" style:width={`${$progress * 100}%`} />
|
||||
{/if}
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
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';
|
||||
|
||||
export let value = '';
|
||||
export let grayTheme: boolean;
|
||||
@@ -21,13 +22,13 @@
|
||||
let showFilter = false;
|
||||
$: showClearIcon = value.length > 0;
|
||||
|
||||
const onSearch = (payload: SmartSearchDto | MetadataSearchDto) => {
|
||||
const onSearch = async (payload: SmartSearchDto | MetadataSearchDto) => {
|
||||
const params = getMetadataSearchQuery(payload);
|
||||
|
||||
showHistory = false;
|
||||
showFilter = false;
|
||||
$isSearchEnabled = false;
|
||||
goto(`${AppRoute.SEARCH}?${params}`);
|
||||
await goto(`${AppRoute.SEARCH}?${params}`);
|
||||
};
|
||||
|
||||
const clearSearchTerm = (searchTerm: string) => {
|
||||
@@ -63,9 +64,9 @@
|
||||
showFilter = false;
|
||||
};
|
||||
|
||||
const onHistoryTermClick = (searchTerm: string) => {
|
||||
const onHistoryTermClick = async (searchTerm: string) => {
|
||||
const searchPayload = { query: searchTerm };
|
||||
onSearch(searchPayload);
|
||||
await onSearch(searchPayload);
|
||||
};
|
||||
|
||||
const onFilterClick = () => {
|
||||
@@ -78,7 +79,7 @@
|
||||
};
|
||||
|
||||
const onSubmit = () => {
|
||||
onSearch({ query: value });
|
||||
handlePromiseError(onSearch({ query: value }));
|
||||
saveSearchTerm(value);
|
||||
};
|
||||
</script>
|
||||
@@ -141,7 +142,7 @@
|
||||
<SearchHistoryBox
|
||||
on:clearAllSearchTerms={clearAllSearchTerms}
|
||||
on:clearSearchTerm={({ detail: searchTerm }) => clearSearchTerm(searchTerm)}
|
||||
on:selectSearchTerm={({ detail: searchTerm }) => onHistoryTermClick(searchTerm)}
|
||||
on:selectSearchTerm={({ detail: searchTerm }) => handlePromiseError(onHistoryTermClick(searchTerm))}
|
||||
/>
|
||||
{/if}
|
||||
</form>
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
<script lang="ts" context="module">
|
||||
export interface SearchCameraFilter {
|
||||
make?: string;
|
||||
model?: string;
|
||||
}
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
import { SearchSuggestionType, getSearchSuggestions } from '@immich/sdk';
|
||||
import Combobox, { toComboBoxOptions } from '../combobox.svelte';
|
||||
import { handlePromiseError } from '$lib/utils';
|
||||
|
||||
export let filters: SearchCameraFilter;
|
||||
|
||||
let makes: string[] = [];
|
||||
let models: string[] = [];
|
||||
|
||||
$: makeFilter = filters.make;
|
||||
$: modelFilter = filters.model;
|
||||
$: handlePromiseError(updateMakes(modelFilter));
|
||||
$: handlePromiseError(updateModels(makeFilter));
|
||||
|
||||
async function updateMakes(model?: string) {
|
||||
makes = await getSearchSuggestions({
|
||||
$type: SearchSuggestionType.CameraMake,
|
||||
model,
|
||||
});
|
||||
}
|
||||
|
||||
async function updateModels(make?: string) {
|
||||
models = await getSearchSuggestions({
|
||||
$type: SearchSuggestionType.CameraModel,
|
||||
make,
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<div id="camera-selection">
|
||||
<p class="immich-form-label">CAMERA</p>
|
||||
|
||||
<div class="grid grid-cols-[repeat(auto-fit,minmax(10rem,1fr))] gap-5 mt-1">
|
||||
<div class="w-full">
|
||||
<label class="text-sm text-black dark:text-white" for="search-camera-make">Make</label>
|
||||
<Combobox
|
||||
id="search-camera-make"
|
||||
options={toComboBoxOptions(makes)}
|
||||
selectedOption={makeFilter ? { label: makeFilter, value: makeFilter } : undefined}
|
||||
on:select={({ detail }) => (filters.make = detail?.value)}
|
||||
placeholder="Search camera make..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="w-full">
|
||||
<label class="text-sm text-black dark:text-white" for="search-camera-model">Model</label>
|
||||
<Combobox
|
||||
id="search-camera-model"
|
||||
options={toComboBoxOptions(models)}
|
||||
selectedOption={modelFilter ? { label: modelFilter, value: modelFilter } : undefined}
|
||||
on:select={({ detail }) => (filters.model = detail?.value)}
|
||||
placeholder="Search camera model..."
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,39 @@
|
||||
<script lang="ts" context="module">
|
||||
export interface SearchDateFilter {
|
||||
takenBefore?: string;
|
||||
takenAfter?: string;
|
||||
}
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
import DateInput from '$lib/components/elements/date-input.svelte';
|
||||
|
||||
export let filters: SearchDateFilter;
|
||||
</script>
|
||||
|
||||
<div id="date-range-selection" class="grid grid-cols-[repeat(auto-fit,minmax(10rem,1fr))] gap-5">
|
||||
<label class="immich-form-label" for="start-date">
|
||||
<span>START DATE</span>
|
||||
<DateInput
|
||||
class="immich-form-input w-full mt-1 hover:cursor-pointer"
|
||||
type="date"
|
||||
id="start-date"
|
||||
name="start-date"
|
||||
max={filters.takenBefore}
|
||||
bind:value={filters.takenAfter}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label class="immich-form-label" for="end-date">
|
||||
<span>END DATE</span>
|
||||
<DateInput
|
||||
class="immich-form-input w-full mt-1 hover:cursor-pointer"
|
||||
type="date"
|
||||
id="end-date"
|
||||
name="end-date"
|
||||
placeholder=""
|
||||
min={filters.takenAfter}
|
||||
bind:value={filters.takenBefore}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
@@ -0,0 +1,32 @@
|
||||
<script lang="ts" context="module">
|
||||
export interface SearchDisplayFilters {
|
||||
isNotInAlbum?: boolean;
|
||||
isArchive?: boolean;
|
||||
isFavorite?: boolean;
|
||||
}
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
export let filters: SearchDisplayFilters;
|
||||
</script>
|
||||
|
||||
<div id="display-options-selection" class="text-sm">
|
||||
<p class="immich-form-label">DISPLAY OPTIONS</p>
|
||||
|
||||
<div class="flex flex-wrap gap-x-5 gap-y-2 mt-1">
|
||||
<label class="flex items-center gap-2">
|
||||
<input type="checkbox" class="size-5 flex-shrink-0" bind:checked={filters.isNotInAlbum} />
|
||||
<span class="pt-1">Not in any album</span>
|
||||
</label>
|
||||
|
||||
<label class="flex items-center gap-2">
|
||||
<input type="checkbox" class="size-5 flex-shrink-0" bind:checked={filters.isArchive} />
|
||||
<span class="pt-1">Archive</span>
|
||||
</label>
|
||||
|
||||
<label class="flex items-center gap-2">
|
||||
<input type="checkbox" class="size-5 flex-shrink-0" bind:checked={filters.isFavorite} />
|
||||
<span class="pt-1">Favorite</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,239 +1,97 @@
|
||||
<script lang="ts">
|
||||
import ImageThumbnail from '$lib/components/assets/thumbnail/image-thumbnail.svelte';
|
||||
import Button from '$lib/components/elements/buttons/button.svelte';
|
||||
import Icon from '$lib/components/elements/icon.svelte';
|
||||
import { getPeopleThumbnailUrl } from '$lib/utils';
|
||||
import { handleError } from '$lib/utils/handle-error';
|
||||
import {
|
||||
AssetTypeEnum,
|
||||
SearchSuggestionType,
|
||||
type PersonResponseDto,
|
||||
type SmartSearchDto,
|
||||
type MetadataSearchDto,
|
||||
} from '@immich/sdk';
|
||||
import { getAllPeople, getSearchSuggestions } from '@immich/sdk';
|
||||
import { mdiArrowRight, mdiClose } from '@mdi/js';
|
||||
import { createEventDispatcher, onMount } from 'svelte';
|
||||
import { fly } from 'svelte/transition';
|
||||
import Combobox, { type ComboBoxOption } from '../combobox.svelte';
|
||||
import { DateTime } from 'luxon';
|
||||
<script lang="ts" context="module">
|
||||
import type { SearchLocationFilter } from './search-location-section.svelte';
|
||||
import type { SearchDisplayFilters } from './search-display-section.svelte';
|
||||
import type { SearchDateFilter } from './search-date-section.svelte';
|
||||
|
||||
enum MediaType {
|
||||
export enum MediaType {
|
||||
All = 'all',
|
||||
Image = 'image',
|
||||
Video = 'video',
|
||||
}
|
||||
|
||||
type SearchSuggestion = {
|
||||
people: PersonResponseDto[];
|
||||
country: ComboBoxOption[];
|
||||
state: ComboBoxOption[];
|
||||
city: ComboBoxOption[];
|
||||
make: ComboBoxOption[];
|
||||
model: ComboBoxOption[];
|
||||
};
|
||||
|
||||
type SearchParams = {
|
||||
state?: string;
|
||||
country?: string;
|
||||
city?: string;
|
||||
cameraMake?: string;
|
||||
cameraModel?: string;
|
||||
};
|
||||
|
||||
type SearchFilter = {
|
||||
export type SearchFilter = {
|
||||
context?: string;
|
||||
people: (PersonResponseDto | Pick<PersonResponseDto, 'id'>)[];
|
||||
|
||||
location: {
|
||||
country?: ComboBoxOption;
|
||||
state?: ComboBoxOption;
|
||||
city?: ComboBoxOption;
|
||||
};
|
||||
|
||||
camera: {
|
||||
make?: ComboBoxOption;
|
||||
model?: ComboBoxOption;
|
||||
};
|
||||
|
||||
date: {
|
||||
takenAfter?: string;
|
||||
takenBefore?: string;
|
||||
};
|
||||
|
||||
isArchive?: boolean;
|
||||
isFavorite?: boolean;
|
||||
isNotInAlbum?: boolean;
|
||||
|
||||
personIds: Set<string>;
|
||||
location: SearchLocationFilter;
|
||||
camera: SearchCameraFilter;
|
||||
date: SearchDateFilter;
|
||||
display: SearchDisplayFilters;
|
||||
mediaType: MediaType;
|
||||
};
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
import Button from '$lib/components/elements/buttons/button.svelte';
|
||||
import { handleError } from '$lib/utils/handle-error';
|
||||
import { AssetTypeEnum, type SmartSearchDto, type MetadataSearchDto } from '@immich/sdk';
|
||||
import { createEventDispatcher } from 'svelte';
|
||||
import { fly } from 'svelte/transition';
|
||||
import SearchPeopleSection from './search-people-section.svelte';
|
||||
import SearchLocationSection from './search-location-section.svelte';
|
||||
import SearchCameraSection, { type SearchCameraFilter } from './search-camera-section.svelte';
|
||||
import SearchDateSection from './search-date-section.svelte';
|
||||
import SearchMediaSection from './search-media-section.svelte';
|
||||
import { parseUtcDate } from '$lib/utils/date-time';
|
||||
import SearchDisplaySection from './search-display-section.svelte';
|
||||
|
||||
export let searchQuery: MetadataSearchDto | SmartSearchDto;
|
||||
|
||||
let suggestions: SearchSuggestion = {
|
||||
people: [],
|
||||
country: [],
|
||||
state: [],
|
||||
city: [],
|
||||
make: [],
|
||||
model: [],
|
||||
};
|
||||
const parseOptionalDate = (dateString?: string) => (dateString ? parseUtcDate(dateString) : undefined);
|
||||
const toStartOfDayDate = (dateString: string) => parseUtcDate(dateString)?.startOf('day').toISODate() || undefined;
|
||||
const dispatch = createEventDispatcher<{ search: SmartSearchDto | MetadataSearchDto }>();
|
||||
|
||||
let filter: SearchFilter = {
|
||||
context: undefined,
|
||||
people: [],
|
||||
context: 'query' in searchQuery ? searchQuery.query : '',
|
||||
personIds: new Set('personIds' in searchQuery ? searchQuery.personIds : []),
|
||||
location: {
|
||||
country: undefined,
|
||||
state: undefined,
|
||||
city: undefined,
|
||||
country: searchQuery.country,
|
||||
state: searchQuery.state,
|
||||
city: searchQuery.city,
|
||||
},
|
||||
camera: {
|
||||
make: undefined,
|
||||
model: undefined,
|
||||
make: searchQuery.make,
|
||||
model: searchQuery.model,
|
||||
},
|
||||
date: {
|
||||
takenAfter: undefined,
|
||||
takenBefore: undefined,
|
||||
takenAfter: searchQuery.takenAfter ? toStartOfDayDate(searchQuery.takenAfter) : undefined,
|
||||
takenBefore: searchQuery.takenBefore ? toStartOfDayDate(searchQuery.takenBefore) : undefined,
|
||||
},
|
||||
isArchive: undefined,
|
||||
isFavorite: undefined,
|
||||
isNotInAlbum: undefined,
|
||||
mediaType: MediaType.All,
|
||||
display: {
|
||||
isArchive: searchQuery.isArchived,
|
||||
isFavorite: searchQuery.isFavorite,
|
||||
isNotInAlbum: 'isNotInAlbum' in searchQuery ? searchQuery.isNotInAlbum : undefined,
|
||||
},
|
||||
mediaType:
|
||||
searchQuery.type === AssetTypeEnum.Image
|
||||
? MediaType.Image
|
||||
: searchQuery.type === AssetTypeEnum.Video
|
||||
? MediaType.Video
|
||||
: MediaType.All,
|
||||
};
|
||||
|
||||
const dispatch = createEventDispatcher<{ search: SmartSearchDto | MetadataSearchDto }>();
|
||||
let showAllPeople = false;
|
||||
|
||||
let filterBoxWidth = 0;
|
||||
$: numberOfPeople = (filterBoxWidth - 80) / 85;
|
||||
$: peopleList = showAllPeople ? suggestions.people : suggestions.people.slice(0, numberOfPeople);
|
||||
|
||||
onMount(() => {
|
||||
getPeople();
|
||||
populateExistingFilters();
|
||||
});
|
||||
|
||||
function orderBySelectedPeopleFirst<T extends Pick<PersonResponseDto, 'id'>>(people: T[]) {
|
||||
return people.sort((a, _) => {
|
||||
if (filter.people.some((p) => p.id === a.id)) {
|
||||
return -1;
|
||||
}
|
||||
return 1;
|
||||
});
|
||||
}
|
||||
|
||||
const getPeople = async () => {
|
||||
try {
|
||||
const { people } = await getAllPeople({ withHidden: false });
|
||||
suggestions.people = orderBySelectedPeopleFirst(people);
|
||||
} catch (error) {
|
||||
handleError(error, 'Failed to get people');
|
||||
}
|
||||
};
|
||||
|
||||
const handlePeopleSelection = (id: string) => {
|
||||
if (filter.people.some((p) => p.id === id)) {
|
||||
filter.people = filter.people.filter((p) => p.id !== id);
|
||||
return;
|
||||
}
|
||||
|
||||
const person = suggestions.people.find((p) => p.id === id);
|
||||
if (person) {
|
||||
filter.people = [...filter.people, person];
|
||||
}
|
||||
};
|
||||
|
||||
const updateSuggestion = async (type: SearchSuggestionType, params: SearchParams) => {
|
||||
if (
|
||||
type === SearchSuggestionType.City ||
|
||||
type === SearchSuggestionType.State ||
|
||||
type === SearchSuggestionType.Country
|
||||
) {
|
||||
suggestions = { ...suggestions, city: [], state: [], country: [] };
|
||||
}
|
||||
|
||||
if (type === SearchSuggestionType.CameraMake || type === SearchSuggestionType.CameraModel) {
|
||||
suggestions = { ...suggestions, make: [], model: [] };
|
||||
}
|
||||
|
||||
try {
|
||||
const data = await getSearchSuggestions({
|
||||
$type: type,
|
||||
country: params.country,
|
||||
state: params.state,
|
||||
make: params.cameraMake,
|
||||
model: params.cameraModel,
|
||||
});
|
||||
|
||||
switch (type) {
|
||||
case SearchSuggestionType.Country: {
|
||||
for (const country of data) {
|
||||
suggestions.country = [...suggestions.country, { label: country, value: country }];
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case SearchSuggestionType.State: {
|
||||
for (const state of data) {
|
||||
suggestions.state = [...suggestions.state, { label: state, value: state }];
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
case SearchSuggestionType.City: {
|
||||
for (const city of data) {
|
||||
suggestions.city = [...suggestions.city, { label: city, value: city }];
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case SearchSuggestionType.CameraMake: {
|
||||
for (const make of data) {
|
||||
suggestions.make = [...suggestions.make, { label: make, value: make }];
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case SearchSuggestionType.CameraModel: {
|
||||
for (const model of data) {
|
||||
suggestions.model = [...suggestions.model, { label: model, value: model }];
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
handleError(error, 'Failed to get search suggestions');
|
||||
}
|
||||
};
|
||||
|
||||
const resetForm = () => {
|
||||
filter = {
|
||||
context: undefined,
|
||||
people: [],
|
||||
location: {
|
||||
country: undefined,
|
||||
state: undefined,
|
||||
city: undefined,
|
||||
},
|
||||
camera: {
|
||||
make: undefined,
|
||||
model: undefined,
|
||||
},
|
||||
date: {
|
||||
takenAfter: undefined,
|
||||
takenBefore: undefined,
|
||||
},
|
||||
isArchive: undefined,
|
||||
isFavorite: undefined,
|
||||
isNotInAlbum: undefined,
|
||||
personIds: new Set(),
|
||||
location: {},
|
||||
camera: {},
|
||||
date: {},
|
||||
display: {},
|
||||
mediaType: MediaType.All,
|
||||
};
|
||||
};
|
||||
|
||||
const search = async () => {
|
||||
let type: AssetTypeEnum | undefined = undefined;
|
||||
const search = () => {
|
||||
if (filter.context && filter.personIds.size > 0) {
|
||||
handleError(
|
||||
new Error('Context search does not support people filter'),
|
||||
'Context search does not support people filter',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
let type: AssetTypeEnum | undefined = undefined;
|
||||
if (filter.mediaType === MediaType.Image) {
|
||||
type = AssetTypeEnum.Image;
|
||||
} else if (filter.mediaType === MediaType.Video) {
|
||||
@@ -241,341 +99,76 @@
|
||||
}
|
||||
|
||||
let payload: SmartSearchDto | MetadataSearchDto = {
|
||||
country: filter.location.country?.value,
|
||||
state: filter.location.state?.value,
|
||||
city: filter.location.city?.value,
|
||||
make: filter.camera.make?.value,
|
||||
model: filter.camera.model?.value,
|
||||
takenAfter: filter.date.takenAfter
|
||||
? DateTime.fromFormat(filter.date.takenAfter, 'yyyy-MM-dd').toUTC().startOf('day').toString()
|
||||
: undefined,
|
||||
takenBefore: filter.date.takenBefore
|
||||
? DateTime.fromFormat(filter.date.takenBefore, 'yyyy-MM-dd').toUTC().endOf('day').toString()
|
||||
: undefined,
|
||||
/* eslint-disable unicorn/prefer-logical-operator-over-ternary */
|
||||
isArchived: filter.isArchive ? filter.isArchive : undefined,
|
||||
isFavorite: filter.isFavorite ? filter.isFavorite : undefined,
|
||||
isNotInAlbum: filter.isNotInAlbum ? filter.isNotInAlbum : undefined,
|
||||
personIds: filter.people && filter.people.length > 0 ? filter.people.map((p) => p.id) : undefined,
|
||||
query: filter.context || undefined,
|
||||
country: filter.location.country,
|
||||
state: filter.location.state,
|
||||
city: filter.location.city,
|
||||
make: filter.camera.make,
|
||||
model: filter.camera.model,
|
||||
takenAfter: parseOptionalDate(filter.date.takenAfter)?.startOf('day').toISO() || undefined,
|
||||
takenBefore: parseOptionalDate(filter.date.takenBefore)?.endOf('day').toISO() || undefined,
|
||||
isArchived: filter.display.isArchive || undefined,
|
||||
isFavorite: filter.display.isFavorite || undefined,
|
||||
isNotInAlbum: filter.display.isNotInAlbum || undefined,
|
||||
personIds: filter.personIds.size > 0 ? [...filter.personIds] : undefined,
|
||||
type,
|
||||
};
|
||||
|
||||
if (filter.context) {
|
||||
if (payload.personIds && payload.personIds.length > 0) {
|
||||
handleError(
|
||||
new Error('Context search does not support people filter'),
|
||||
'Context search does not support people filter',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
payload = {
|
||||
...payload,
|
||||
query: filter.context,
|
||||
};
|
||||
}
|
||||
|
||||
dispatch('search', payload);
|
||||
};
|
||||
|
||||
function populateExistingFilters() {
|
||||
if (searchQuery) {
|
||||
const personIds = 'personIds' in searchQuery && searchQuery.personIds ? searchQuery.personIds : [];
|
||||
|
||||
filter = {
|
||||
context: 'query' in searchQuery ? searchQuery.query : '',
|
||||
people: orderBySelectedPeopleFirst(personIds.map((id) => ({ id }))),
|
||||
location: {
|
||||
country: searchQuery.country ? { label: searchQuery.country, value: searchQuery.country } : undefined,
|
||||
state: searchQuery.state ? { label: searchQuery.state, value: searchQuery.state } : undefined,
|
||||
city: searchQuery.city ? { label: searchQuery.city, value: searchQuery.city } : undefined,
|
||||
},
|
||||
camera: {
|
||||
make: searchQuery.make ? { label: searchQuery.make, value: searchQuery.make } : undefined,
|
||||
model: searchQuery.model ? { label: searchQuery.model, value: searchQuery.model } : undefined,
|
||||
},
|
||||
date: {
|
||||
takenAfter: searchQuery.takenAfter
|
||||
? DateTime.fromISO(searchQuery.takenAfter).toUTC().toFormat('yyyy-MM-dd')
|
||||
: undefined,
|
||||
takenBefore: searchQuery.takenBefore
|
||||
? DateTime.fromISO(searchQuery.takenBefore).toUTC().toFormat('yyyy-MM-dd')
|
||||
: undefined,
|
||||
},
|
||||
isArchive: searchQuery.isArchived,
|
||||
isFavorite: searchQuery.isFavorite,
|
||||
isNotInAlbum: 'isNotInAlbum' in searchQuery ? searchQuery.isNotInAlbum : undefined,
|
||||
mediaType:
|
||||
searchQuery.type === AssetTypeEnum.Image
|
||||
? MediaType.Image
|
||||
: searchQuery.type === AssetTypeEnum.Video
|
||||
? MediaType.Video
|
||||
: MediaType.All,
|
||||
};
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div
|
||||
bind:clientWidth={filterBoxWidth}
|
||||
transition:fly={{ y: 25, duration: 250 }}
|
||||
class="absolute w-full rounded-b-3xl border border-gray-200 bg-white shadow-2xl transition-all dark:border-gray-800 dark:bg-immich-dark-gray dark:text-gray-300 px-6 pt-6 overflow-y-auto max-h-[90vh] immich-scrollbar"
|
||||
class="absolute w-full rounded-b-3xl border border-t-0 border-gray-200 bg-white shadow-2xl dark:border-gray-800 dark:bg-immich-dark-gray dark:text-gray-300"
|
||||
>
|
||||
<p class="text-xs py-2">FILTERS</p>
|
||||
<hr class="border-slate-300 dark:border-slate-700 py-2" />
|
||||
|
||||
<form
|
||||
id="search-filter-form relative"
|
||||
id="search-filter-form"
|
||||
autocomplete="off"
|
||||
class="hover:cursor-auto"
|
||||
on:submit|preventDefault={search}
|
||||
on:reset|preventDefault={resetForm}
|
||||
>
|
||||
<!-- PEOPLE -->
|
||||
<div id="people-selection" class="my-4">
|
||||
<div class="flex justify-between place-items-center gap-6">
|
||||
<div class="flex-1">
|
||||
<p class="immich-form-label">PEOPLE</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="px-4 sm:px-6 py-4 space-y-10 max-h-[calc(100dvh-12rem)] overflow-y-auto immich-scrollbar">
|
||||
<!-- PEOPLE -->
|
||||
<SearchPeopleSection width={filterBoxWidth} bind:selectedPeople={filter.personIds} />
|
||||
|
||||
{#if suggestions.people.length > 0}
|
||||
<div class="flex gap-1 mt-4 flex-wrap max-h-[300px] overflow-y-auto immich-scrollbar transition-all">
|
||||
{#each peopleList as person (person.id)}
|
||||
<button
|
||||
type="button"
|
||||
class="w-20 text-center rounded-3xl border-2 border-transparent hover:bg-immich-gray dark:hover:bg-immich-dark-primary/20 p-2 transition-all {filter.people.some(
|
||||
(p) => p.id === person.id,
|
||||
)
|
||||
? 'dark:border-slate-500 border-slate-300 bg-slate-200 dark:bg-slate-800 dark:text-white'
|
||||
: ''}"
|
||||
on:click={() => handlePeopleSelection(person.id)}
|
||||
>
|
||||
<ImageThumbnail
|
||||
circle
|
||||
shadow
|
||||
url={getPeopleThumbnailUrl(person.id)}
|
||||
altText={person.name}
|
||||
widthStyle="100%"
|
||||
/>
|
||||
<p class="mt-2 line-clamp-2 text-sm font-medium dark:text-white">{person.name}</p>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<div class="flex justify-center mt-2">
|
||||
<Button
|
||||
shadow={false}
|
||||
color="text-primary"
|
||||
type="button"
|
||||
class="flex gap-2 place-items-center place-content-center"
|
||||
on:click={() => (showAllPeople = !showAllPeople)}
|
||||
>
|
||||
{#if showAllPeople}
|
||||
<span><Icon path={mdiClose} /></span>
|
||||
Collapse
|
||||
{:else}
|
||||
<span><Icon path={mdiArrowRight} /></span>
|
||||
See all people
|
||||
{/if}
|
||||
</Button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<hr class="border-slate-300 dark:border-slate-700" />
|
||||
<!-- CONTEXT -->
|
||||
<div class="my-4">
|
||||
<label class="immich-form-label" for="context">CONTEXT</label>
|
||||
<input
|
||||
class="immich-form-input hover:cursor-text w-full mt-3"
|
||||
type="text"
|
||||
id="context"
|
||||
name="context"
|
||||
placeholder="Sunrise on the beach"
|
||||
bind:value={filter.context}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<hr class="border-slate-300 dark:border-slate-700" />
|
||||
<!-- LOCATION -->
|
||||
<div id="location-selection" class="my-4">
|
||||
<p class="immich-form-label">PLACE</p>
|
||||
|
||||
<div class="flex justify-between gap-5 mt-3">
|
||||
<div class="w-full">
|
||||
<label class="text-sm text-black dark:text-white" for="search-place-country">Country</label>
|
||||
<Combobox
|
||||
id="search-place-country"
|
||||
options={suggestions.country}
|
||||
bind:selectedOption={filter.location.country}
|
||||
placeholder="Search country..."
|
||||
on:click={() => updateSuggestion(SearchSuggestionType.Country, {})}
|
||||
<!-- CONTEXT -->
|
||||
<div>
|
||||
<label class="immich-form-label" for="context">
|
||||
<span>CONTEXT</span>
|
||||
<input
|
||||
class="immich-form-input hover:cursor-text w-full mt-1"
|
||||
type="text"
|
||||
id="context"
|
||||
name="context"
|
||||
placeholder="Sunrise on the beach"
|
||||
bind:value={filter.context}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="w-full">
|
||||
<label class="text-sm text-black dark:text-white" for="search-place-state">State</label>
|
||||
<Combobox
|
||||
id="search-place-state"
|
||||
options={suggestions.state}
|
||||
bind:selectedOption={filter.location.state}
|
||||
placeholder="Search state..."
|
||||
on:click={() => updateSuggestion(SearchSuggestionType.State, { country: filter.location.country?.value })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="w-full">
|
||||
<label class="text-sm text-black dark:text-white" for="search-place-city">City</label>
|
||||
<Combobox
|
||||
id="search-place-city"
|
||||
options={suggestions.city}
|
||||
bind:selectedOption={filter.location.city}
|
||||
placeholder="Search city..."
|
||||
on:click={() =>
|
||||
updateSuggestion(SearchSuggestionType.City, {
|
||||
country: filter.location.country?.value,
|
||||
state: filter.location.state?.value,
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<hr class="border-slate-300 dark:border-slate-700" />
|
||||
<!-- CAMERA MODEL -->
|
||||
<div id="camera-selection" class="my-4">
|
||||
<p class="immich-form-label">CAMERA</p>
|
||||
|
||||
<div class="flex justify-between gap-5 mt-3">
|
||||
<div class="w-full">
|
||||
<label class="text-sm text-black dark:text-white" for="search-camera-make">Make</label>
|
||||
<Combobox
|
||||
id="search-camera-make"
|
||||
options={suggestions.make}
|
||||
bind:selectedOption={filter.camera.make}
|
||||
placeholder="Search camera make..."
|
||||
on:click={() =>
|
||||
updateSuggestion(SearchSuggestionType.CameraMake, { cameraModel: filter.camera.model?.value })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="w-full">
|
||||
<label class="text-sm text-black dark:text-white" for="search-camera-model">Model</label>
|
||||
<Combobox
|
||||
id="search-camera-model"
|
||||
options={suggestions.model}
|
||||
bind:selectedOption={filter.camera.model}
|
||||
placeholder="Search camera model..."
|
||||
on:click={() =>
|
||||
updateSuggestion(SearchSuggestionType.CameraModel, { cameraMake: filter.camera.make?.value })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<hr class="border-slate-300 dark:border-slate-700" />
|
||||
|
||||
<!-- DATE RANGE -->
|
||||
<div id="date-range-selection" class="my-4 flex justify-between gap-5">
|
||||
<div class="mb-3 flex-1 mt">
|
||||
<label class="immich-form-label" for="start-date">START DATE</label>
|
||||
<input
|
||||
class="immich-form-input w-full mt-3 hover:cursor-pointer"
|
||||
type="date"
|
||||
id="start-date"
|
||||
name="start-date"
|
||||
bind:value={filter.date.takenAfter}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="mb-3 flex-1">
|
||||
<label class="immich-form-label" for="end-date">END DATE</label>
|
||||
<input
|
||||
class="immich-form-input w-full mt-3 hover:cursor-pointer"
|
||||
type="date"
|
||||
id="end-date"
|
||||
name="end-date"
|
||||
placeholder=""
|
||||
bind:value={filter.date.takenBefore}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<!-- LOCATION -->
|
||||
<SearchLocationSection bind:filters={filter.location} />
|
||||
|
||||
<hr class="border-slate-300 dark:border-slate-700" />
|
||||
<div class="py-3 grid grid-cols-[repeat(auto-fill,minmax(21rem,1fr))] gap-x-16 gap-y-8">
|
||||
<!-- MEDIA TYPE -->
|
||||
<div id="media-type-selection">
|
||||
<p class="immich-form-label">MEDIA TYPE</p>
|
||||
<!-- CAMERA MODEL -->
|
||||
<SearchCameraSection bind:filters={filter.camera} />
|
||||
|
||||
<div class="flex gap-5 mt-3">
|
||||
<label
|
||||
for="type-all"
|
||||
class="text-base flex place-items-center gap-1 hover:cursor-pointer text-black dark:text-white"
|
||||
>
|
||||
<input
|
||||
bind:group={filter.mediaType}
|
||||
value={MediaType.All}
|
||||
type="radio"
|
||||
name="radio-type"
|
||||
id="type-all"
|
||||
/>All</label
|
||||
>
|
||||
<!-- DATE RANGE -->
|
||||
<SearchDateSection bind:filters={filter.date} />
|
||||
|
||||
<label
|
||||
for="type-image"
|
||||
class="text-base flex place-items-center gap-1 hover:cursor-pointer text-black dark:text-white"
|
||||
>
|
||||
<input
|
||||
bind:group={filter.mediaType}
|
||||
value={MediaType.Image}
|
||||
type="radio"
|
||||
name="media-type"
|
||||
id="type-image"
|
||||
/>Image</label
|
||||
>
|
||||
<div class="grid md:grid-cols-2 gap-x-5 gap-y-8">
|
||||
<!-- MEDIA TYPE -->
|
||||
<SearchMediaSection bind:filteredMedia={filter.mediaType} />
|
||||
|
||||
<label
|
||||
for="type-video"
|
||||
class="text-base flex place-items-center gap-1 hover:cursor-pointer text-black dark:text-white"
|
||||
>
|
||||
<input
|
||||
bind:group={filter.mediaType}
|
||||
value={MediaType.Video}
|
||||
type="radio"
|
||||
name="radio-type"
|
||||
id="type-video"
|
||||
/>Video</label
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- DISPLAY OPTIONS -->
|
||||
<div id="display-options-selection">
|
||||
<p class="immich-form-label">DISPLAY OPTIONS</p>
|
||||
|
||||
<div class="flex gap-5 mt-3">
|
||||
<label class="flex items-center mb-2">
|
||||
<input type="checkbox" class="form-checkbox h-5 w-5 color" bind:checked={filter.isNotInAlbum} />
|
||||
<span class="ml-2 text-sm text-black dark:text-white pt-1">Not in any album</span>
|
||||
</label>
|
||||
|
||||
<label class="flex items-center mb-2">
|
||||
<input type="checkbox" class="form-checkbox h-5 w-5 color" bind:checked={filter.isArchive} />
|
||||
<span class="ml-2 text-sm text-black dark:text-white pt-1">Archive</span>
|
||||
</label>
|
||||
|
||||
<label class="flex items-center mb-2">
|
||||
<input type="checkbox" class="form-checkbox h-5 w-5 color" bind:checked={filter.isFavorite} />
|
||||
<span class="ml-2 text-sm text-black dark:text-white pt-1">Favorite</span>
|
||||
</label>
|
||||
</div>
|
||||
<!-- DISPLAY OPTIONS -->
|
||||
<SearchDisplaySection bind:filters={filter.display} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
id="button-row"
|
||||
class="flex justify-end gap-4 py-4 sticky bottom-0 dark:border-gray-800 dark:bg-immich-dark-gray"
|
||||
class="flex justify-end gap-4 border-t dark:border-gray-800 dark:bg-immich-dark-gray px-4 sm:py-6 py-4 mt-2 rounded-b-3xl"
|
||||
>
|
||||
<Button type="reset" color="gray">CLEAR ALL</Button>
|
||||
<Button type="submit">SEARCH</Button>
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
<script lang="ts" context="module">
|
||||
export interface SearchLocationFilter {
|
||||
country?: string;
|
||||
state?: string;
|
||||
city?: string;
|
||||
}
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
import { getSearchSuggestions, SearchSuggestionType } from '@immich/sdk';
|
||||
import Combobox, { toComboBoxOptions } from '../combobox.svelte';
|
||||
import { handlePromiseError } from '$lib/utils';
|
||||
|
||||
export let filters: SearchLocationFilter;
|
||||
|
||||
let countries: string[] = [];
|
||||
let states: string[] = [];
|
||||
let cities: string[] = [];
|
||||
|
||||
$: countryFilter = filters.country;
|
||||
$: stateFilter = filters.state;
|
||||
$: handlePromiseError(updateCountries());
|
||||
$: handlePromiseError(updateStates(countryFilter));
|
||||
$: handlePromiseError(updateCities(countryFilter, stateFilter));
|
||||
|
||||
async function updateCountries() {
|
||||
countries = await getSearchSuggestions({
|
||||
$type: SearchSuggestionType.Country,
|
||||
});
|
||||
|
||||
if (filters.country && !countries.includes(filters.country)) {
|
||||
filters.country = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
async function updateStates(country?: string) {
|
||||
states = await getSearchSuggestions({
|
||||
$type: SearchSuggestionType.State,
|
||||
country,
|
||||
});
|
||||
|
||||
if (filters.state && !states.includes(filters.state)) {
|
||||
filters.state = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
async function updateCities(country?: string, state?: string) {
|
||||
cities = await getSearchSuggestions({
|
||||
$type: SearchSuggestionType.City,
|
||||
country,
|
||||
state,
|
||||
});
|
||||
|
||||
if (filters.city && !cities.includes(filters.city)) {
|
||||
filters.city = undefined;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div id="location-selection">
|
||||
<p class="immich-form-label">PLACE</p>
|
||||
|
||||
<div class="grid grid-cols-[repeat(auto-fit,minmax(10rem,1fr))] gap-5 mt-1">
|
||||
<div class="w-full">
|
||||
<label class="text-sm text-black dark:text-white" for="search-place-country">Country</label>
|
||||
<Combobox
|
||||
id="search-place-country"
|
||||
options={toComboBoxOptions(countries)}
|
||||
selectedOption={filters.country ? { label: filters.country, value: filters.country } : undefined}
|
||||
on:select={({ detail }) => (filters.country = detail?.value)}
|
||||
placeholder="Search country..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="w-full">
|
||||
<label class="text-sm text-black dark:text-white" for="search-place-state">State</label>
|
||||
<Combobox
|
||||
id="search-place-state"
|
||||
options={toComboBoxOptions(states)}
|
||||
selectedOption={filters.state ? { label: filters.state, value: filters.state } : undefined}
|
||||
on:select={({ detail }) => (filters.state = detail?.value)}
|
||||
placeholder="Search state..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="w-full">
|
||||
<label class="text-sm text-black dark:text-white" for="search-place-city">City</label>
|
||||
<Combobox
|
||||
id="search-place-city"
|
||||
options={toComboBoxOptions(cities)}
|
||||
selectedOption={filters.city ? { label: filters.city, value: filters.city } : undefined}
|
||||
on:select={({ detail }) => (filters.city = detail?.value)}
|
||||
placeholder="Search city..."
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,47 @@
|
||||
<script lang="ts">
|
||||
import { MediaType } from './search-filter-box.svelte';
|
||||
|
||||
export let filteredMedia: MediaType;
|
||||
</script>
|
||||
|
||||
<div id="media-type-selection">
|
||||
<p class="immich-form-label">MEDIA TYPE</p>
|
||||
|
||||
<div class="flex gap-5 mt-1 text-base">
|
||||
<label for="type-all" class="flex items-center gap-1">
|
||||
<input
|
||||
bind:group={filteredMedia}
|
||||
value={MediaType.All}
|
||||
type="radio"
|
||||
name="radio-type"
|
||||
id="type-all"
|
||||
class="size-4"
|
||||
/>
|
||||
<span class="pt-0.5">All</span>
|
||||
</label>
|
||||
|
||||
<label for="type-image" class="flex items-center gap-1">
|
||||
<input
|
||||
bind:group={filteredMedia}
|
||||
value={MediaType.Image}
|
||||
type="radio"
|
||||
name="media-type"
|
||||
id="type-image"
|
||||
class="size-4"
|
||||
/>
|
||||
<span class="pt-0.5">Image</span>
|
||||
</label>
|
||||
|
||||
<label for="type-video" class="flex items-center gap-1">
|
||||
<input
|
||||
bind:group={filteredMedia}
|
||||
value={MediaType.Video}
|
||||
type="radio"
|
||||
name="radio-type"
|
||||
id="type-video"
|
||||
class="size-4"
|
||||
/>
|
||||
<span class="pt-0.5">Video</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,95 @@
|
||||
<script lang="ts">
|
||||
import ImageThumbnail from '$lib/components/assets/thumbnail/image-thumbnail.svelte';
|
||||
import Button from '$lib/components/elements/buttons/button.svelte';
|
||||
import Icon from '$lib/components/elements/icon.svelte';
|
||||
import { getPeopleThumbnailUrl } from '$lib/utils';
|
||||
import { getAllPeople, type PersonResponseDto } from '@immich/sdk';
|
||||
import { mdiClose, mdiArrowRight } from '@mdi/js';
|
||||
import { handleError } from '$lib/utils/handle-error';
|
||||
|
||||
export let width: number;
|
||||
export let selectedPeople: Set<string>;
|
||||
|
||||
let peoplePromise = getPeople();
|
||||
let showAllPeople = false;
|
||||
$: numberOfPeople = (width - 80) / 85;
|
||||
|
||||
function orderBySelectedPeopleFirst(people: PersonResponseDto[]) {
|
||||
return [
|
||||
...people.filter((p) => selectedPeople.has(p.id)), //
|
||||
...people.filter((p) => !selectedPeople.has(p.id)),
|
||||
];
|
||||
}
|
||||
|
||||
async function getPeople() {
|
||||
try {
|
||||
const res = await getAllPeople({ withHidden: false });
|
||||
return orderBySelectedPeopleFirst(res.people);
|
||||
} catch (error) {
|
||||
handleError(error, 'Failed to get people');
|
||||
}
|
||||
}
|
||||
|
||||
function togglePersonSelection(id: string) {
|
||||
if (selectedPeople.has(id)) {
|
||||
selectedPeople.delete(id);
|
||||
} else {
|
||||
selectedPeople.add(id);
|
||||
}
|
||||
selectedPeople = selectedPeople;
|
||||
}
|
||||
</script>
|
||||
|
||||
{#await peoplePromise then people}
|
||||
{#if people && people.length > 0}
|
||||
{@const peopleList = showAllPeople ? people : people.slice(0, numberOfPeople)}
|
||||
|
||||
<div id="people-selection" class="-mb-4">
|
||||
<div class="flex items-center gap-6">
|
||||
<p class="immich-form-label">PEOPLE</p>
|
||||
</div>
|
||||
|
||||
<div class="flex -mx-1 max-h-64 gap-1 mt-2 flex-wrap overflow-y-auto immich-scrollbar">
|
||||
{#each peopleList as person (person.id)}
|
||||
<button
|
||||
type="button"
|
||||
class="flex flex-col items-center w-20 rounded-3xl border-2 border-transparent hover:bg-immich-gray dark:hover:bg-immich-dark-primary/20 p-2 transition-all {selectedPeople.has(
|
||||
person.id,
|
||||
)
|
||||
? 'dark:border-slate-500 border-slate-400 bg-slate-200 dark:bg-slate-800 dark:text-white'
|
||||
: ''}"
|
||||
on:click={() => togglePersonSelection(person.id)}
|
||||
>
|
||||
<ImageThumbnail
|
||||
circle
|
||||
shadow
|
||||
url={getPeopleThumbnailUrl(person.id)}
|
||||
altText={person.name}
|
||||
widthStyle="100%"
|
||||
/>
|
||||
<p class="mt-2 line-clamp-2 text-sm font-medium dark:text-white">{person.name}</p>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
{#if showAllPeople || people.length > peopleList.length}
|
||||
<div class="flex justify-center mt-2">
|
||||
<Button
|
||||
shadow={false}
|
||||
color="text-primary"
|
||||
class="flex gap-2 place-items-center"
|
||||
on:click={() => (showAllPeople = !showAllPeople)}
|
||||
>
|
||||
{#if showAllPeople}
|
||||
<span><Icon path={mdiClose} /></span>
|
||||
Collapse
|
||||
{:else}
|
||||
<span><Icon path={mdiArrowRight} /></span>
|
||||
See all people
|
||||
{/if}
|
||||
</Button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
{/await}
|
||||
@@ -1,7 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { page } from '$app/stores';
|
||||
import { QueryParameter } from '$lib/constants';
|
||||
import { hasParamValue, updateParamList } from '$lib/utils';
|
||||
import { hasParamValue, handlePromiseError, updateParamList } from '$lib/utils';
|
||||
import { slide } from 'svelte/transition';
|
||||
|
||||
export let title: string;
|
||||
@@ -12,12 +12,12 @@
|
||||
const syncFromUrl = () => (isOpen = hasParamValue(QueryParameter.IS_OPEN, key));
|
||||
const syncToUrl = (isOpen: boolean) => updateParamList({ param: QueryParameter.IS_OPEN, value: key, add: isOpen });
|
||||
|
||||
isOpen ? syncToUrl(true) : syncFromUrl();
|
||||
isOpen ? handlePromiseError(syncToUrl(true)) : syncFromUrl();
|
||||
$: $page.url && syncFromUrl();
|
||||
|
||||
const toggle = () => {
|
||||
const toggle = async () => {
|
||||
isOpen = !isOpen;
|
||||
syncToUrl(isOpen);
|
||||
await syncToUrl(isOpen);
|
||||
};
|
||||
</script>
|
||||
|
||||
|
||||
@@ -10,11 +10,12 @@
|
||||
<script lang="ts">
|
||||
import { quintOut } from 'svelte/easing';
|
||||
import { fly } from 'svelte/transition';
|
||||
import PasswordField from '../password-field.svelte';
|
||||
|
||||
export let inputType: SettingInputFieldType;
|
||||
export let value: string | number;
|
||||
export let min = Number.MIN_SAFE_INTEGER.toString();
|
||||
export let max = Number.MAX_SAFE_INTEGER.toString();
|
||||
export let min = Number.MIN_SAFE_INTEGER;
|
||||
export let max = Number.MAX_SAFE_INTEGER;
|
||||
export let step = '1';
|
||||
export let label = '';
|
||||
export let desc = '';
|
||||
@@ -22,18 +23,27 @@
|
||||
export let required = false;
|
||||
export let disabled = false;
|
||||
export let isEdited = false;
|
||||
export let passwordAutocomplete: string = 'current-password';
|
||||
|
||||
const handleInput = (e: Event) => {
|
||||
value = (e.target as HTMLInputElement).value;
|
||||
|
||||
if (inputType === SettingInputFieldType.NUMBER) {
|
||||
value = Number(value) || 0;
|
||||
let newValue = Number(value) || 0;
|
||||
if (newValue < min) {
|
||||
newValue = min;
|
||||
}
|
||||
if (newValue > max) {
|
||||
newValue = max;
|
||||
}
|
||||
value = newValue;
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<div class="mb-4 w-full">
|
||||
<div class={`flex h-[26px] place-items-center gap-1`}>
|
||||
<label class={`immich-form-label text-sm`} for={label}>{label}</label>
|
||||
<label class="font-medium text-immich-primary dark:text-immich-dark-primary text-sm" for={label}>{label}</label>
|
||||
{#if required}
|
||||
<div class="text-red-400">*</div>
|
||||
{/if}
|
||||
@@ -56,20 +66,35 @@
|
||||
<slot name="desc" />
|
||||
{/if}
|
||||
|
||||
<input
|
||||
class="immich-form-input w-full pb-2"
|
||||
aria-describedby={desc ? `${label}-desc` : undefined}
|
||||
aria-labelledby="{label}-label"
|
||||
id={label}
|
||||
name={label}
|
||||
type={inputType}
|
||||
{min}
|
||||
{max}
|
||||
{step}
|
||||
{required}
|
||||
{value}
|
||||
on:input={handleInput}
|
||||
{disabled}
|
||||
{title}
|
||||
/>
|
||||
{#if inputType !== SettingInputFieldType.PASSWORD}
|
||||
<input
|
||||
class="immich-form-input w-full pb-2"
|
||||
aria-describedby={desc ? `${label}-desc` : undefined}
|
||||
aria-labelledby="{label}-label"
|
||||
id={label}
|
||||
name={label}
|
||||
type={inputType}
|
||||
min={min.toString()}
|
||||
max={max.toString()}
|
||||
{step}
|
||||
{required}
|
||||
{value}
|
||||
on:input={handleInput}
|
||||
{disabled}
|
||||
{title}
|
||||
/>
|
||||
{:else}
|
||||
<PasswordField
|
||||
aria-describedby={desc ? `${label}-desc` : undefined}
|
||||
aria-labelledby="{label}-label"
|
||||
id={label}
|
||||
name={label}
|
||||
autocomplete={passwordAutocomplete}
|
||||
{required}
|
||||
password={value.toString()}
|
||||
onInput={(passwordValue) => (value = passwordValue)}
|
||||
{disabled}
|
||||
{title}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -25,7 +25,9 @@
|
||||
|
||||
<div class="mb-4 w-full">
|
||||
<div class={`flex h-[26px] place-items-center gap-1`}>
|
||||
<label class={`immich-form-label text-sm`} for="{name}-select">{label}</label>
|
||||
<label class="font-medium text-immich-primary dark:text-immich-dark-primary text-sm" for="{name}-select"
|
||||
>{label}</label
|
||||
>
|
||||
|
||||
{#if isEdited}
|
||||
<div
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import { quintOut } from 'svelte/easing';
|
||||
import { fly } from 'svelte/transition';
|
||||
import { createEventDispatcher } from 'svelte';
|
||||
import Slider from '$lib/components/elements/slider.svelte';
|
||||
|
||||
export let title: string;
|
||||
export let subtitle = '';
|
||||
@@ -33,66 +34,5 @@
|
||||
<slot />
|
||||
</div>
|
||||
|
||||
<label class="relative inline-block h-[10px] w-[36px] flex-none">
|
||||
<input
|
||||
class="disabled::cursor-not-allowed h-0 w-0 opacity-0"
|
||||
type="checkbox"
|
||||
bind:checked
|
||||
on:click={onToggle}
|
||||
{disabled}
|
||||
/>
|
||||
|
||||
{#if disabled}
|
||||
<span class="slider slider-disabled cursor-not-allowed" />
|
||||
{:else}
|
||||
<span class="slider slider-enabled cursor-pointer" />
|
||||
{/if}
|
||||
</label>
|
||||
<Slider bind:checked {disabled} on:click={onToggle} />
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.slider {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background-color: #ccc;
|
||||
-webkit-transition: 0.4s;
|
||||
transition: 0.4s;
|
||||
border-radius: 34px;
|
||||
}
|
||||
|
||||
input:disabled {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.slider:before {
|
||||
position: absolute;
|
||||
content: '';
|
||||
height: 20px;
|
||||
width: 20px;
|
||||
left: 0px;
|
||||
right: 0px;
|
||||
bottom: -4px;
|
||||
background-color: gray;
|
||||
-webkit-transition: 0.4s;
|
||||
transition: 0.4s;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
input:checked + .slider:before {
|
||||
-webkit-transform: translateX(18px);
|
||||
-ms-transform: translateX(18px);
|
||||
transform: translateX(18px);
|
||||
background-color: #4250af;
|
||||
}
|
||||
|
||||
input:checked + .slider-disabled {
|
||||
background-color: gray;
|
||||
}
|
||||
|
||||
input:checked + .slider-enabled {
|
||||
background-color: #adcbfa;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -13,9 +13,9 @@
|
||||
|
||||
export let uploadAsset: UploadAsset;
|
||||
|
||||
const handleRetry = (uploadAsset: UploadAsset) => {
|
||||
const handleRetry = async (uploadAsset: UploadAsset) => {
|
||||
uploadAssetsStore.removeUploadAsset(uploadAsset.id);
|
||||
fileUploadHandler([uploadAsset.file], uploadAsset.albumId);
|
||||
await fileUploadHandler([uploadAsset.file], uploadAsset.albumId);
|
||||
};
|
||||
</script>
|
||||
|
||||
@@ -45,7 +45,8 @@
|
||||
/>
|
||||
|
||||
<div
|
||||
class="relative mt-[5px] h-[15px] w-full rounded-md bg-gray-300 text-white dark:bg-immich-dark-gray dark:text-black"
|
||||
class="relative mt-[5px] h-[15px] w-full rounded-md bg-gray-300 text-white dark:bg-immich-dark-gray"
|
||||
class:dark:text-black={uploadAsset.state === UploadState.STARTED}
|
||||
>
|
||||
{#if uploadAsset.state === UploadState.STARTED}
|
||||
<div class="h-[15px] rounded-md bg-immich-primary transition-all" style={`width: ${uploadAsset.progress}%`} />
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import noThumbnailUrl from '$lib/assets/no-thumbnail.png';
|
||||
import Icon from '$lib/components/elements/icon.svelte';
|
||||
import { AppRoute } from '$lib/constants';
|
||||
import { getAssetThumbnailUrl } from '$lib/utils';
|
||||
@@ -87,7 +86,7 @@
|
||||
{/await}
|
||||
{:else}
|
||||
<enhanced:img
|
||||
src={noThumbnailUrl}
|
||||
src="$lib/assets/no-thumbnail.png"
|
||||
alt={'Album without assets'}
|
||||
class="h-[100px] w-[100px] rounded-lg object-cover"
|
||||
loading="lazy"
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
<script lang="ts">
|
||||
import FullScreenModal from '$lib/components/shared-components/full-screen-modal.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 { slideshowStore } from '../stores/slideshow.store';
|
||||
import Button from './elements/buttons/button.svelte';
|
||||
|
||||
const { slideshowShuffle, slideshowDelay, showProgressBar } = slideshowStore;
|
||||
|
||||
export let onClose = () => {};
|
||||
</script>
|
||||
|
||||
<FullScreenModal on:clickOutside={onClose} on:escape={onClose}>
|
||||
<div
|
||||
class="flex w-96 max-w-lg flex-col gap-8 rounded-3xl border bg-white p-8 shadow-sm dark:border-immich-dark-gray dark:bg-immich-dark-gray"
|
||||
>
|
||||
<h1 class="self-center text-2xl font-medium text-immich-primary dark:text-immich-dark-primary">
|
||||
Slideshow Settings
|
||||
</h1>
|
||||
|
||||
<div class="flex flex-col gap-4 text-immich-primary dark:text-immich-dark-primary">
|
||||
<SettingSwitch title="Shuffle" bind:checked={$slideshowShuffle} />
|
||||
<SettingSwitch title="Show Progress Bar" bind:checked={$showProgressBar} />
|
||||
<SettingInputField
|
||||
inputType={SettingInputFieldType.NUMBER}
|
||||
label="Delay"
|
||||
desc="Number of seconds to display each image"
|
||||
min={1}
|
||||
bind:value={$slideshowDelay}
|
||||
/>
|
||||
|
||||
<Button class="w-full" color="gray" on:click={onClose}>Done</Button>
|
||||
</div>
|
||||
</div>
|
||||
</FullScreenModal>
|
||||
@@ -47,6 +47,7 @@
|
||||
label="PASSWORD"
|
||||
bind:value={password}
|
||||
required={true}
|
||||
passwordAutocomplete="current-password"
|
||||
/>
|
||||
|
||||
<SettingInputField
|
||||
@@ -54,6 +55,7 @@
|
||||
label="NEW PASSWORD"
|
||||
bind:value={newPassword}
|
||||
required={true}
|
||||
passwordAutocomplete="new-password"
|
||||
/>
|
||||
|
||||
<SettingInputField
|
||||
@@ -61,6 +63,7 @@
|
||||
label="CONFIRM PASSWORD"
|
||||
bind:value={confirmPassword}
|
||||
required={true}
|
||||
passwordAutocomplete="new-password"
|
||||
/>
|
||||
|
||||
<div class="flex justify-end">
|
||||
|
||||
@@ -28,7 +28,7 @@
|
||||
} catch (error) {
|
||||
handleError(error, 'Unable to link OAuth account');
|
||||
} finally {
|
||||
goto('?open=oauth');
|
||||
await goto('?open=oauth');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -31,8 +31,8 @@
|
||||
let removePartnerDto: PartnerResponseDto | null = null;
|
||||
let partners: Array<PartnerSharing> = [];
|
||||
|
||||
onMount(() => {
|
||||
refreshPartners();
|
||||
onMount(async () => {
|
||||
await refreshPartners();
|
||||
});
|
||||
|
||||
const refreshPartners = async () => {
|
||||
|
||||
@@ -172,15 +172,17 @@ export class AssetStore {
|
||||
this.emit(false);
|
||||
|
||||
let height = 0;
|
||||
const loaders = [];
|
||||
for (const bucket of this.buckets) {
|
||||
if (height < viewport.height) {
|
||||
height += bucket.bucketHeight;
|
||||
this.loadBucket(bucket.bucketDate, BucketPosition.Visible);
|
||||
loaders.push(this.loadBucket(bucket.bucketDate, BucketPosition.Visible));
|
||||
continue;
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
await Promise.all(loaders);
|
||||
}
|
||||
|
||||
async loadBucket(bucketDate: string, position: BucketPosition): Promise<void> {
|
||||
|
||||
@@ -14,6 +14,9 @@ function createSlideshowStore() {
|
||||
const slideshowShuffle = persisted<boolean>('slideshow-shuffle', true);
|
||||
const slideshowState = writable<SlideshowState>(SlideshowState.None);
|
||||
|
||||
const showProgressBar = persisted<boolean>('slideshow-show-progressbar', true);
|
||||
const slideshowDelay = persisted<number>('slideshow-delay', 5, {});
|
||||
|
||||
return {
|
||||
restartProgress: {
|
||||
subscribe: restartState.subscribe,
|
||||
@@ -39,6 +42,8 @@ function createSlideshowStore() {
|
||||
},
|
||||
slideshowShuffle,
|
||||
slideshowState,
|
||||
slideshowDelay,
|
||||
showProgressBar,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -47,7 +47,7 @@ websocket
|
||||
.on('on_new_release', (releaseVersion) => websocketStore.release.set(releaseVersion))
|
||||
.on('connect_error', (e) => console.log('Websocket Connect Error', e));
|
||||
|
||||
export const openWebsocketConnection = async () => {
|
||||
export const openWebsocketConnection = () => {
|
||||
try {
|
||||
if (!get(user)) {
|
||||
return;
|
||||
|
||||
@@ -194,3 +194,13 @@ export const findLocale = (code: string | undefined) => {
|
||||
name: language?.name,
|
||||
};
|
||||
};
|
||||
|
||||
export const asyncTimeout = (ms: number) => {
|
||||
return new Promise((resolve) => {
|
||||
setTimeout(resolve, ms);
|
||||
});
|
||||
};
|
||||
|
||||
export const handlePromiseError = <T>(promise: Promise<T>): void => {
|
||||
promise.catch((error) => console.error(`[utils.ts]:handlePromiseError ${error}`, error));
|
||||
};
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { timeToSeconds } from './time-to-seconds';
|
||||
import { timeToSeconds } from './date-time';
|
||||
|
||||
describe('converting time to seconds', () => {
|
||||
it('parses hh:mm:ss correctly', () => {
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Duration } from 'luxon';
|
||||
import { DateTime, Duration } from 'luxon';
|
||||
|
||||
/**
|
||||
* Convert time like `01:02:03.456` to seconds.
|
||||
@@ -11,3 +11,7 @@ export function timeToSeconds(time: string) {
|
||||
|
||||
return Duration.fromObject({ hours, minutes, seconds }).as('seconds');
|
||||
}
|
||||
|
||||
export function parseUtcDate(date: string) {
|
||||
return DateTime.fromISO(date, { zone: 'UTC' }).toUTC();
|
||||
}
|
||||
@@ -28,10 +28,14 @@ describe('Executor Queue test', function () {
|
||||
});
|
||||
|
||||
// The first 3 should be finished within 200ms (concurrency 3)
|
||||
// eslint-disable-next-line @typescript-eslint/no-floating-promises
|
||||
eq.addTask(() => timeoutPromiseBuilder(100, 'T1'));
|
||||
// eslint-disable-next-line @typescript-eslint/no-floating-promises
|
||||
eq.addTask(() => timeoutPromiseBuilder(200, 'T2'));
|
||||
// eslint-disable-next-line @typescript-eslint/no-floating-promises
|
||||
eq.addTask(() => timeoutPromiseBuilder(150, 'T3'));
|
||||
// The last task will be executed after 200ms and will finish at 400ms
|
||||
// eslint-disable-next-line @typescript-eslint/no-floating-promises
|
||||
eq.addTask(() => timeoutPromiseBuilder(200, 'T4'));
|
||||
|
||||
expect(finished).not.toBeCalled();
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { handlePromiseError } from '$lib/utils';
|
||||
|
||||
interface Options {
|
||||
concurrency: number;
|
||||
}
|
||||
@@ -66,6 +68,6 @@ export class ExecutorQueue {
|
||||
return;
|
||||
}
|
||||
|
||||
runnable();
|
||||
handlePromiseError(runnable());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,7 +29,7 @@ export const openFileUploadDialog = async (albumId?: string | undefined) => {
|
||||
fileSelector.type = 'file';
|
||||
fileSelector.multiple = true;
|
||||
fileSelector.accept = extensions.join(',');
|
||||
fileSelector.addEventListener('change', async (e: Event) => {
|
||||
fileSelector.addEventListener('change', (e: Event) => {
|
||||
const target = e.target as HTMLInputElement;
|
||||
if (!target.files) {
|
||||
return;
|
||||
@@ -119,7 +119,7 @@ async function fileUploader(asset: File, albumId: string | undefined = undefined
|
||||
}
|
||||
})
|
||||
.catch(async (error) => {
|
||||
await handleError(error, 'Unable to upload file');
|
||||
handleError(error, 'Unable to upload file');
|
||||
const reason = (await getServerErrorMessage(error)) || error;
|
||||
uploadAssetsStore.updateAsset(deviceAssetId, { state: UploadState.ERROR, error: reason });
|
||||
return undefined;
|
||||
|
||||
@@ -1,34 +1,46 @@
|
||||
import type { HttpError } from '@sveltejs/kit';
|
||||
import { isHttpError } from '@immich/sdk';
|
||||
import { isAxiosError } from 'axios';
|
||||
import { notificationController, NotificationType } from '../components/shared-components/notification/notification';
|
||||
|
||||
export async function getServerErrorMessage(error: unknown) {
|
||||
let data = (error as HttpError)?.body;
|
||||
if (data instanceof Blob) {
|
||||
const response = await data.text();
|
||||
try {
|
||||
data = JSON.parse(response);
|
||||
} catch {
|
||||
data = { message: response };
|
||||
}
|
||||
if (isHttpError(error)) {
|
||||
return error.data?.message || error.data;
|
||||
}
|
||||
|
||||
return data?.message || null;
|
||||
if (isAxiosError(error)) {
|
||||
let data = error.response?.data;
|
||||
if (data instanceof Blob) {
|
||||
const response = await data.text();
|
||||
try {
|
||||
data = JSON.parse(response);
|
||||
} catch {
|
||||
data = { message: response };
|
||||
}
|
||||
}
|
||||
|
||||
return data?.message;
|
||||
}
|
||||
}
|
||||
|
||||
export async function handleError(error: unknown, message: string) {
|
||||
export function handleError(error: unknown, message: string) {
|
||||
if ((error as Error)?.name === 'AbortError') {
|
||||
return;
|
||||
}
|
||||
|
||||
console.error(`[handleError]: ${message}`, error, (error as Error)?.stack);
|
||||
|
||||
let serverMessage = await getServerErrorMessage(error);
|
||||
if (serverMessage) {
|
||||
serverMessage = `${String(serverMessage).slice(0, 75)}\n(Immich Server Error)`;
|
||||
}
|
||||
getServerErrorMessage(error)
|
||||
.then((serverMessage) => {
|
||||
if (serverMessage) {
|
||||
serverMessage = `${String(serverMessage).slice(0, 75)}\n(Immich Server Error)`;
|
||||
}
|
||||
|
||||
notificationController.show({
|
||||
message: serverMessage || message,
|
||||
type: NotificationType.Error,
|
||||
});
|
||||
notificationController.show({
|
||||
message: serverMessage || message,
|
||||
type: NotificationType.Error,
|
||||
});
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error(error);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -196,7 +196,7 @@
|
||||
const handleCreateAlbum = async () => {
|
||||
const newAlbum = await createAlbum();
|
||||
if (newAlbum) {
|
||||
goto(`${AppRoute.ALBUMS}/${newAlbum.id}`);
|
||||
await goto(`${AppRoute.ALBUMS}/${newAlbum.id}`);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -204,8 +204,8 @@
|
||||
return new Date(dateString).toLocaleDateString($locale, dateFormats.album);
|
||||
};
|
||||
|
||||
onMount(() => {
|
||||
removeAlbumsIfEmpty();
|
||||
onMount(async () => {
|
||||
await removeAlbumsIfEmpty();
|
||||
});
|
||||
|
||||
const removeAlbumsIfEmpty = async () => {
|
||||
|
||||
@@ -220,12 +220,11 @@
|
||||
|
||||
onMount(async () => {
|
||||
if (album.sharedUsers.length > 0) {
|
||||
getFavorite();
|
||||
getNumberOfComments();
|
||||
await Promise.all([getFavorite(), getNumberOfComments()]);
|
||||
}
|
||||
});
|
||||
|
||||
const handleKeypress = async (event: KeyboardEvent) => {
|
||||
const handleKeypress = (event: KeyboardEvent) => {
|
||||
if (event.target !== textArea) {
|
||||
return;
|
||||
}
|
||||
@@ -242,12 +241,12 @@
|
||||
const handleStartSlideshow = async () => {
|
||||
const asset = $slideshowShuffle ? await assetStore.getRandomAsset() : assetStore.assets[0];
|
||||
if (asset) {
|
||||
setAssetId(asset.id);
|
||||
await setAssetId(asset.id);
|
||||
$slideshowState = SlideshowState.PlaySlideshow;
|
||||
}
|
||||
};
|
||||
|
||||
const handleEscape = () => {
|
||||
const handleEscape = async () => {
|
||||
if (viewMode === ViewMode.SELECT_USERS) {
|
||||
viewMode = ViewMode.VIEW;
|
||||
return;
|
||||
@@ -275,7 +274,7 @@
|
||||
assetInteractionStore.clearMultiselect();
|
||||
return;
|
||||
}
|
||||
goto(backUrl);
|
||||
await goto(backUrl);
|
||||
return;
|
||||
};
|
||||
|
||||
@@ -371,7 +370,7 @@
|
||||
|
||||
const handleRemoveUser = async (userId: string) => {
|
||||
if (userId == 'me' || userId === $user.id) {
|
||||
goto(backUrl);
|
||||
await goto(backUrl);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -390,7 +389,7 @@
|
||||
const handleRemoveAlbum = async () => {
|
||||
try {
|
||||
await deleteAlbum({ id: album.id });
|
||||
goto(backUrl);
|
||||
await goto(backUrl);
|
||||
} catch (error) {
|
||||
handleError(error, 'Unable to delete album');
|
||||
} finally {
|
||||
@@ -413,8 +412,6 @@
|
||||
albumThumbnailAssetId: assetId,
|
||||
},
|
||||
});
|
||||
|
||||
notificationController.show({ type: NotificationType.Info, message: 'Updated album cover' });
|
||||
} catch (error) {
|
||||
handleError(error, 'Unable to update album cover');
|
||||
}
|
||||
@@ -433,7 +430,6 @@
|
||||
},
|
||||
});
|
||||
currentAlbumName = album.albumName;
|
||||
notificationController.show({ type: NotificationType.Info, message: 'New album name has been saved' });
|
||||
} catch (error) {
|
||||
handleError(error, 'Unable to update album name');
|
||||
}
|
||||
@@ -450,10 +446,7 @@
|
||||
description,
|
||||
},
|
||||
});
|
||||
notificationController.show({
|
||||
type: NotificationType.Info,
|
||||
message: 'Album description has been updated',
|
||||
});
|
||||
|
||||
album.description = description;
|
||||
} catch (error) {
|
||||
handleError(error, 'Error updating album description');
|
||||
|
||||
@@ -2,7 +2,7 @@ import { AppRoute } from '$lib/constants';
|
||||
import { redirect } from '@sveltejs/kit';
|
||||
import type { PageLoad } from './$types';
|
||||
|
||||
export const load: PageLoad = async ({ params }) => {
|
||||
export const load: PageLoad = ({ params }) => {
|
||||
const albumId = params.albumId;
|
||||
|
||||
if (albumId) {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { OnShowContextMenuDetail } from '$lib/components/album-page/album-card';
|
||||
import { notificationController, NotificationType } from '$lib/components/shared-components/notification/notification';
|
||||
import { asyncTimeout } from '$lib/utils';
|
||||
import { handleError } from '$lib/utils/handle-error';
|
||||
import { createAlbum, deleteAlbum, getAllAlbums, type AlbumResponseDto } from '@immich/sdk';
|
||||
import { derived, get, writable } from 'svelte/store';
|
||||
@@ -20,9 +21,8 @@ export const useAlbums = (properties: AlbumsProperties) => {
|
||||
// Delete album that has no photos and is named ''
|
||||
for (const album of data) {
|
||||
if (album.albumName === '' && album.assetCount === 0) {
|
||||
setTimeout(async () => {
|
||||
await handleDeleteAlbum(album);
|
||||
}, 500);
|
||||
await asyncTimeout(500);
|
||||
await handleDeleteAlbum(album);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
@@ -46,10 +46,7 @@ export const useAlbums = (properties: AlbumsProperties) => {
|
||||
albums.set(get(albums).filter(({ id }) => id !== albumToDelete.id));
|
||||
}
|
||||
|
||||
async function showAlbumContextMenu(
|
||||
contextMenuDetail: OnShowContextMenuDetail,
|
||||
album: AlbumResponseDto,
|
||||
): Promise<void> {
|
||||
function showAlbumContextMenu(contextMenuDetail: OnShowContextMenuDetail, album: AlbumResponseDto): void {
|
||||
contextMenuTargetAlbum.set(album);
|
||||
|
||||
contextMenuPosition.set({
|
||||
|
||||
@@ -2,6 +2,6 @@ import { AppRoute } from '$lib/constants';
|
||||
import { redirect } from '@sveltejs/kit';
|
||||
import type { PageLoad } from './$types';
|
||||
|
||||
export const load: PageLoad = async () => {
|
||||
export const load: PageLoad = () => {
|
||||
redirect(302, AppRoute.ARCHIVE);
|
||||
};
|
||||
|
||||
@@ -2,6 +2,6 @@ import { AppRoute } from '$lib/constants';
|
||||
import { redirect } from '@sveltejs/kit';
|
||||
import type { PageLoad } from './$types';
|
||||
|
||||
export const load: PageLoad = async () => {
|
||||
export const load: PageLoad = () => {
|
||||
redirect(302, AppRoute.FAVORITES);
|
||||
};
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
import { DateTime, Duration } from 'luxon';
|
||||
import { onDestroy, onMount } from 'svelte';
|
||||
import type { PageData } from './$types';
|
||||
import { handlePromiseError } from '$lib/utils';
|
||||
|
||||
export let data: PageData;
|
||||
|
||||
@@ -26,8 +27,8 @@
|
||||
let viewingAssetCursor = 0;
|
||||
let showSettingsModal = false;
|
||||
|
||||
onMount(() => {
|
||||
loadMapMarkers().then((data) => (mapMarkers = data));
|
||||
onMount(async () => {
|
||||
mapMarkers = await loadMapMarkers();
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
@@ -35,7 +36,7 @@
|
||||
assetViewingStore.showAssetViewer(false);
|
||||
});
|
||||
|
||||
$: $featureFlags.map || goto(AppRoute.PHOTOS);
|
||||
$: $featureFlags.map || handlePromiseError(goto(AppRoute.PHOTOS));
|
||||
const omit = (obj: MapSettings, key: string) => {
|
||||
return Object.fromEntries(Object.entries(obj).filter(([k]) => k !== key));
|
||||
};
|
||||
@@ -85,21 +86,21 @@
|
||||
}
|
||||
}
|
||||
|
||||
function onViewAssets(assetIds: string[]) {
|
||||
assetViewingStore.setAssetId(assetIds[0]);
|
||||
async function onViewAssets(assetIds: string[]) {
|
||||
await assetViewingStore.setAssetId(assetIds[0]);
|
||||
viewingAssets = assetIds;
|
||||
viewingAssetCursor = 0;
|
||||
}
|
||||
|
||||
function navigateNext() {
|
||||
async function navigateNext() {
|
||||
if (viewingAssetCursor < viewingAssets.length - 1) {
|
||||
assetViewingStore.setAssetId(viewingAssets[++viewingAssetCursor]);
|
||||
await assetViewingStore.setAssetId(viewingAssets[++viewingAssetCursor]);
|
||||
}
|
||||
}
|
||||
|
||||
function navigatePrevious() {
|
||||
async function navigatePrevious() {
|
||||
if (viewingAssetCursor > 0) {
|
||||
assetViewingStore.setAssetId(viewingAssets[--viewingAssetCursor]);
|
||||
await assetViewingStore.setAssetId(viewingAssets[--viewingAssetCursor]);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -2,6 +2,6 @@ import { AppRoute } from '$lib/constants';
|
||||
import { redirect } from '@sveltejs/kit';
|
||||
import type { PageLoad } from './$types';
|
||||
|
||||
export const load = (async () => {
|
||||
export const load = (() => {
|
||||
redirect(302, AppRoute.PHOTOS);
|
||||
}) satisfies PageLoad;
|
||||
|
||||
@@ -81,12 +81,12 @@
|
||||
|
||||
const onKeyboardPress = (event: KeyboardEvent) => handleKeyboardPress(event);
|
||||
|
||||
onMount(() => {
|
||||
onMount(async () => {
|
||||
document.addEventListener('keydown', onKeyboardPress);
|
||||
const getSearchedPeople = $page.url.searchParams.get(QueryParameter.SEARCHED_PEOPLE);
|
||||
if (getSearchedPeople) {
|
||||
searchName = getSearchedPeople;
|
||||
handleSearchPeople(true);
|
||||
await handleSearchPeople(true);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -108,10 +108,10 @@
|
||||
}
|
||||
};
|
||||
|
||||
const handleSearch = (force: boolean) => {
|
||||
const handleSearch = async (force: boolean) => {
|
||||
$page.url.searchParams.set(QueryParameter.SEARCHED_PEOPLE, searchName);
|
||||
goto($page.url);
|
||||
handleSearchPeople(force);
|
||||
await goto($page.url);
|
||||
await handleSearchPeople(force);
|
||||
};
|
||||
|
||||
const handleCloseClick = () => {
|
||||
@@ -212,7 +212,9 @@
|
||||
|
||||
people = people.filter((person: PersonResponseDto) => person.id !== personToMerge.id);
|
||||
people = people.map((person: PersonResponseDto) => (person.id === personToBeMergedIn.id ? mergedPerson : person));
|
||||
countHiddenPeople--;
|
||||
if (personToMerge.isHidden) {
|
||||
countHiddenPeople--;
|
||||
}
|
||||
countTotalPeople--;
|
||||
notificationController.show({
|
||||
message: 'Merge people successfully',
|
||||
@@ -291,8 +293,8 @@
|
||||
}
|
||||
};
|
||||
|
||||
const handleMergePeople = (detail: PersonResponseDto) => {
|
||||
goto(
|
||||
const handleMergePeople = async (detail: PersonResponseDto) => {
|
||||
await goto(
|
||||
`${AppRoute.PEOPLE}/${detail.id}?${QueryParameter.ACTION}=${ActionQueryParameterValue.MERGE}&${QueryParameter.PREVIOUS_ROUTE}=${AppRoute.PEOPLE}`,
|
||||
);
|
||||
};
|
||||
@@ -301,7 +303,7 @@
|
||||
if (searchName === '') {
|
||||
if ($page.url.searchParams.has(QueryParameter.SEARCHED_PEOPLE)) {
|
||||
$page.url.searchParams.delete(QueryParameter.SEARCHED_PEOPLE);
|
||||
goto($page.url);
|
||||
await goto($page.url);
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -329,7 +331,7 @@
|
||||
return;
|
||||
}
|
||||
if (personName === '') {
|
||||
changeName();
|
||||
await changeName();
|
||||
return;
|
||||
}
|
||||
const data = await searchPerson({ name: personName, withHidden: true });
|
||||
@@ -357,7 +359,7 @@
|
||||
.slice(0, 3);
|
||||
return;
|
||||
}
|
||||
changeName();
|
||||
await changeName();
|
||||
};
|
||||
|
||||
const submitBirthDateChange = async (value: string) => {
|
||||
|
||||
@@ -185,7 +185,7 @@
|
||||
}
|
||||
};
|
||||
|
||||
const handleEscape = () => {
|
||||
const handleEscape = async () => {
|
||||
if ($showAssetViewer || viewMode === ViewMode.SUGGEST_MERGE) {
|
||||
return;
|
||||
}
|
||||
@@ -193,7 +193,7 @@
|
||||
assetInteractionStore.clearMultiselect();
|
||||
return;
|
||||
} else {
|
||||
goto(previousRoute);
|
||||
await goto(previousRoute);
|
||||
return;
|
||||
}
|
||||
};
|
||||
@@ -235,7 +235,7 @@
|
||||
type: NotificationType.Info,
|
||||
});
|
||||
|
||||
goto(previousRoute, { replaceState: true });
|
||||
await goto(previousRoute, { replaceState: true });
|
||||
} catch (error) {
|
||||
handleError(error, 'Unable to hide person');
|
||||
}
|
||||
@@ -244,7 +244,7 @@
|
||||
const handleMerge = async (person: PersonResponseDto) => {
|
||||
const { assets } = await getPersonStatistics({ id: person.id });
|
||||
numberOfAssets = assets;
|
||||
handleGoBack();
|
||||
await handleGoBack();
|
||||
|
||||
data.person = person;
|
||||
|
||||
@@ -292,7 +292,7 @@
|
||||
refreshAssetGrid = !refreshAssetGrid;
|
||||
return;
|
||||
}
|
||||
goto(`${AppRoute.PEOPLE}/${personToBeMergedIn.id}`, { replaceState: true });
|
||||
await goto(`${AppRoute.PEOPLE}/${personToBeMergedIn.id}`, { replaceState: true });
|
||||
} catch (error) {
|
||||
handleError(error, 'Unable to save name');
|
||||
}
|
||||
@@ -341,7 +341,7 @@
|
||||
return;
|
||||
}
|
||||
if (name === '') {
|
||||
changeName();
|
||||
await changeName();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -366,7 +366,7 @@
|
||||
viewMode = ViewMode.SUGGEST_MERGE;
|
||||
return;
|
||||
}
|
||||
changeName();
|
||||
await changeName();
|
||||
};
|
||||
|
||||
const handleSetBirthDate = async (birthDate: string) => {
|
||||
@@ -392,11 +392,11 @@
|
||||
}
|
||||
};
|
||||
|
||||
const handleGoBack = () => {
|
||||
const handleGoBack = async () => {
|
||||
viewMode = ViewMode.VIEW_ASSETS;
|
||||
if ($page.url.searchParams.has(QueryParameter.ACTION)) {
|
||||
$page.url.searchParams.delete(QueryParameter.ACTION);
|
||||
goto($page.url);
|
||||
await goto($page.url);
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
@@ -2,6 +2,6 @@ import { AppRoute } from '$lib/constants';
|
||||
import { redirect } from '@sveltejs/kit';
|
||||
import type { PageLoad } from './$types';
|
||||
|
||||
export const load = (async ({ params }) => {
|
||||
export const load = (({ params }) => {
|
||||
redirect(302, `${AppRoute.PEOPLE}/${params.personId}`);
|
||||
}) satisfies PageLoad;
|
||||
|
||||
@@ -2,6 +2,6 @@ import { AppRoute } from '$lib/constants';
|
||||
import { redirect } from '@sveltejs/kit';
|
||||
import type { PageLoad } from './$types';
|
||||
|
||||
export const load = (async () => {
|
||||
export const load = (() => {
|
||||
redirect(302, AppRoute.PHOTOS);
|
||||
}) satisfies PageLoad;
|
||||
|
||||
@@ -35,6 +35,8 @@
|
||||
import type { Viewport } from '$lib/stores/assets.store';
|
||||
import { locale } from '$lib/stores/preferences.store';
|
||||
import LoadingSpinner from '$lib/components/shared-components/loading-spinner.svelte';
|
||||
import { handlePromiseError } from '$lib/utils';
|
||||
import { parseUtcDate } from '$lib/utils/date-time';
|
||||
|
||||
const MAX_ASSET_COUNT = 5000;
|
||||
let { isViewing: showAssetViewer } = assetViewingStore;
|
||||
@@ -52,7 +54,7 @@
|
||||
|
||||
const onKeyboardPress = (event: KeyboardEvent) => handleKeyboardPress(event);
|
||||
|
||||
const handleKeyboardPress = (event: KeyboardEvent) => {
|
||||
const handleKeyboardPress = async (event: KeyboardEvent) => {
|
||||
if (shouldIgnoreShortcut(event)) {
|
||||
return;
|
||||
}
|
||||
@@ -64,7 +66,7 @@
|
||||
return;
|
||||
}
|
||||
if (!$preventRaceConditionSearchBar) {
|
||||
goto(previousRoute);
|
||||
await goto(previousRoute);
|
||||
}
|
||||
$preventRaceConditionSearchBar = false;
|
||||
return;
|
||||
@@ -107,13 +109,13 @@
|
||||
return searchQuery ? JSON.parse(searchQuery) : {};
|
||||
})();
|
||||
|
||||
$: terms, onSearchQueryUpdate();
|
||||
$: terms, handlePromiseError(onSearchQueryUpdate());
|
||||
|
||||
async function onSearchQueryUpdate() {
|
||||
nextPage = 1;
|
||||
searchResultAssets = [];
|
||||
searchResultAlbums = [];
|
||||
loadNextPage();
|
||||
await loadNextPage();
|
||||
}
|
||||
|
||||
export const loadNextPage = async () => {
|
||||
@@ -143,13 +145,16 @@
|
||||
isLoading = false;
|
||||
};
|
||||
|
||||
function getHumanReadableDate(date: string) {
|
||||
const d = new Date(date);
|
||||
return d.toLocaleDateString($locale, {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
});
|
||||
function getHumanReadableDate(dateString: string) {
|
||||
const date = parseUtcDate(dateString).startOf('day');
|
||||
return date.toLocaleString(
|
||||
{
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
},
|
||||
{ locale: $locale },
|
||||
);
|
||||
}
|
||||
|
||||
function getHumanReadableSearchKey(key: keyof SearchTerms): string {
|
||||
@@ -286,11 +291,7 @@
|
||||
</section>
|
||||
{/if}
|
||||
<section id="search-content" class="relative bg-immich-bg dark:bg-immich-dark-bg">
|
||||
{#if isLoading}
|
||||
<div class="flex justify-center py-16 items-center">
|
||||
<LoadingSpinner size="48" />
|
||||
</div>
|
||||
{:else if searchResultAssets.length > 0}
|
||||
{#if searchResultAssets.length > 0}
|
||||
<GalleryViewer
|
||||
assets={searchResultAssets}
|
||||
bind:selectedAssets
|
||||
@@ -298,7 +299,7 @@
|
||||
showArchiveIcon={true}
|
||||
{viewport}
|
||||
/>
|
||||
{:else}
|
||||
{:else if !isLoading}
|
||||
<div class="flex min-h-[calc(66vh_-_11rem)] w-full place-content-center items-center dark:text-white">
|
||||
<div class="flex flex-col content-center items-center text-center">
|
||||
<Icon path={mdiImageOffOutline} size="3.5em" />
|
||||
@@ -307,6 +308,12 @@
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if isLoading}
|
||||
<div class="flex justify-center py-16 items-center">
|
||||
<LoadingSpinner size="48" />
|
||||
</div>
|
||||
{/if}
|
||||
</section>
|
||||
</section>
|
||||
</section>
|
||||
|
||||
@@ -2,6 +2,6 @@ import { AppRoute } from '$lib/constants';
|
||||
import { redirect } from '@sveltejs/kit';
|
||||
import type { PageLoad } from './$types';
|
||||
|
||||
export const load = (async () => {
|
||||
export const load = (() => {
|
||||
redirect(302, AppRoute.SEARCH);
|
||||
}) satisfies PageLoad;
|
||||
|
||||
@@ -1,7 +1,14 @@
|
||||
<script lang="ts">
|
||||
import { page } from '$app/stores';
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Opps! Error - Immich</title>
|
||||
</svelte:head>
|
||||
|
||||
<section class="flex h-screen w-screen place-content-center place-items-center">
|
||||
<div class="p-20 text-4xl text-immich-primary dark:text-immich-dark-primary">Page not found :/</div>
|
||||
<section class="flex flex-col px-4 h-screen w-screen place-content-center place-items-center">
|
||||
<h1 class="py-10 text-4xl text-immich-primary dark:text-immich-dark-primary">Page not found :/</h1>
|
||||
{#if $page.error?.message}
|
||||
<h2 class="text-xl text-immich-fg dark:text-immich-dark-fg">{$page.error.message}</h2>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { getAssetThumbnailUrl } from '$lib/utils';
|
||||
import { authenticate } from '$lib/utils/auth';
|
||||
import { ThumbnailFormat, getMySharedLink } from '@immich/sdk';
|
||||
import { error as throwError, type HttpError } from '@sveltejs/kit';
|
||||
import { ThumbnailFormat, getMySharedLink, isHttpError } from '@immich/sdk';
|
||||
import type { PageLoad } from './$types';
|
||||
|
||||
export const load = (async ({ params }) => {
|
||||
@@ -22,9 +21,7 @@ export const load = (async ({ params }) => {
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
// handle unauthorized error
|
||||
// TODO this doesn't allow for 404 shared links anymore
|
||||
if ((error as HttpError).status === 401) {
|
||||
if (isHttpError(error) && error.data.message === 'Invalid password') {
|
||||
return {
|
||||
passwordRequired: true,
|
||||
sharedLinkKey: key,
|
||||
@@ -34,8 +31,6 @@ export const load = (async ({ params }) => {
|
||||
};
|
||||
}
|
||||
|
||||
throwError(404, {
|
||||
message: 'Invalid shared link',
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}) satisfies PageLoad;
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
const createSharedAlbum = async () => {
|
||||
try {
|
||||
const newAlbum = await createAlbum({ createAlbumDto: { albumName: '' } });
|
||||
goto(`${AppRoute.ALBUMS}/${newAlbum.id}`);
|
||||
await goto(`${AppRoute.ALBUMS}/${newAlbum.id}`);
|
||||
} catch (error) {
|
||||
handleError(error, 'Unable to create album');
|
||||
}
|
||||
|
||||
@@ -40,7 +40,7 @@
|
||||
deleteLinkId = null;
|
||||
await refresh();
|
||||
} catch (error) {
|
||||
await handleError(error, 'Unable to delete shared link');
|
||||
handleError(error, 'Unable to delete shared link');
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -24,10 +24,11 @@
|
||||
import { emptyTrash, restoreTrash } from '@immich/sdk';
|
||||
import { mdiDeleteOutline, mdiHistory } from '@mdi/js';
|
||||
import type { PageData } from './$types';
|
||||
import { handlePromiseError } from '$lib/utils';
|
||||
|
||||
export let data: PageData;
|
||||
|
||||
$: $featureFlags.trash || goto(AppRoute.PHOTOS);
|
||||
$featureFlags.trash || handlePromiseError(goto(AppRoute.PHOTOS));
|
||||
|
||||
const assetStore = new AssetStore({ isTrashed: true });
|
||||
const assetInteractionStore = createAssetInteractionStore();
|
||||
|
||||
@@ -2,6 +2,6 @@ import { AppRoute } from '$lib/constants';
|
||||
import { redirect } from '@sveltejs/kit';
|
||||
import type { PageLoad } from './$types';
|
||||
|
||||
export const load = (async () => {
|
||||
export const load = (() => {
|
||||
redirect(302, AppRoute.TRASH);
|
||||
}) satisfies PageLoad;
|
||||
|
||||
@@ -3,7 +3,7 @@ import type { LayoutLoad } from './$types';
|
||||
export const ssr = false;
|
||||
export const csr = true;
|
||||
|
||||
export const load = (async () => {
|
||||
export const load = (() => {
|
||||
return {
|
||||
meta: {
|
||||
title: 'Immich',
|
||||
|
||||
@@ -2,6 +2,6 @@ import { AppRoute } from '$lib/constants';
|
||||
import { redirect } from '@sveltejs/kit';
|
||||
import type { PageLoad } from './$types';
|
||||
|
||||
export const load = (async () => {
|
||||
export const load = (() => {
|
||||
redirect(302, AppRoute.ADMIN_USER_MANAGEMENT);
|
||||
}) satisfies PageLoad;
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
import Icon from '$lib/components/elements/icon.svelte';
|
||||
import UserPageLayout from '$lib/components/layouts/user-page-layout.svelte';
|
||||
import { AppRoute } from '$lib/constants';
|
||||
import { asyncTimeout } from '$lib/utils';
|
||||
import { getAllJobsStatus, type AllJobStatusResponseDto } from '@immich/sdk';
|
||||
import { mdiCog } from '@mdi/js';
|
||||
import { onDestroy, onMount } from 'svelte';
|
||||
@@ -11,21 +12,19 @@
|
||||
|
||||
export let data: PageData;
|
||||
|
||||
let timer: ReturnType<typeof setInterval>;
|
||||
|
||||
let jobs: AllJobStatusResponseDto;
|
||||
|
||||
const load = async () => {
|
||||
jobs = await getAllJobsStatus();
|
||||
};
|
||||
let running = true;
|
||||
|
||||
onMount(async () => {
|
||||
await load();
|
||||
timer = setInterval(load, 5000);
|
||||
while (running) {
|
||||
jobs = await getAllJobsStatus();
|
||||
await asyncTimeout(5000);
|
||||
}
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
clearInterval(timer);
|
||||
running = false;
|
||||
});
|
||||
</script>
|
||||
|
||||
|
||||
@@ -4,19 +4,21 @@
|
||||
import { getServerStatistics } from '@immich/sdk';
|
||||
import { onDestroy, onMount } from 'svelte';
|
||||
import type { PageData } from './$types';
|
||||
import { asyncTimeout } from '$lib/utils';
|
||||
|
||||
export let data: PageData;
|
||||
|
||||
let setIntervalHandler: ReturnType<typeof setInterval>;
|
||||
let running = true;
|
||||
|
||||
onMount(async () => {
|
||||
setIntervalHandler = setInterval(async () => {
|
||||
while (running) {
|
||||
data.stats = await getServerStatistics();
|
||||
}, 5000);
|
||||
await asyncTimeout(5000);
|
||||
}
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
clearInterval(setIntervalHandler);
|
||||
running = false;
|
||||
});
|
||||
</script>
|
||||
|
||||
|
||||
@@ -51,7 +51,7 @@
|
||||
shouldShowCreateUserForm = false;
|
||||
};
|
||||
|
||||
const editUserHandler = async (user: UserResponseDto) => {
|
||||
const editUserHandler = (user: UserResponseDto) => {
|
||||
selectedUser = user;
|
||||
shouldShowEditUserForm = true;
|
||||
};
|
||||
@@ -67,7 +67,7 @@
|
||||
shouldShowInfoPanel = true;
|
||||
};
|
||||
|
||||
const deleteUserHandler = async (user: UserResponseDto) => {
|
||||
const deleteUserHandler = (user: UserResponseDto) => {
|
||||
selectedUser = user;
|
||||
shouldShowDeleteConfirmDialog = true;
|
||||
};
|
||||
@@ -82,7 +82,7 @@
|
||||
shouldShowDeleteConfirmDialog = false;
|
||||
};
|
||||
|
||||
const restoreUserHandler = async (user: UserResponseDto) => {
|
||||
const restoreUserHandler = (user: UserResponseDto) => {
|
||||
selectedUser = user;
|
||||
shouldShowRestoreDialog = true;
|
||||
};
|
||||
|
||||
@@ -3,10 +3,17 @@
|
||||
import ChangePasswordForm from '$lib/components/forms/change-password-form.svelte';
|
||||
import FullscreenContainer from '$lib/components/shared-components/fullscreen-container.svelte';
|
||||
import { AppRoute } from '$lib/constants';
|
||||
import { user } from '$lib/stores/user.store';
|
||||
import { resetSavedUser, user } from '$lib/stores/user.store';
|
||||
import { logout } from '@immich/sdk';
|
||||
import type { PageData } from './$types';
|
||||
|
||||
export let data: PageData;
|
||||
|
||||
const onSuccess = async () => {
|
||||
await goto(AppRoute.AUTH_LOGIN);
|
||||
resetSavedUser();
|
||||
await logout();
|
||||
};
|
||||
</script>
|
||||
|
||||
<FullscreenContainer title={data.meta.title}>
|
||||
@@ -18,5 +25,5 @@
|
||||
enter the new password below.
|
||||
</p>
|
||||
|
||||
<ChangePasswordForm user={$user} on:success={() => goto(AppRoute.AUTH_LOGIN)} />
|
||||
<ChangePasswordForm user={$user} on:success={onSuccess} />
|
||||
</FullscreenContainer>
|
||||
|
||||
@@ -1,21 +1,12 @@
|
||||
<script lang="ts">
|
||||
import { afterNavigate, goto } from '$app/navigation';
|
||||
import { goto } from '$app/navigation';
|
||||
import LoginForm from '$lib/components/forms/login-form.svelte';
|
||||
import FullscreenContainer from '$lib/components/shared-components/fullscreen-container.svelte';
|
||||
import { AppRoute } from '$lib/constants';
|
||||
import { featureFlags, serverConfig } from '$lib/stores/server-config.store';
|
||||
import { resetSavedUser } from '$lib/stores/user.store';
|
||||
import { logout } from '@immich/sdk';
|
||||
import type { PageData } from './$types';
|
||||
|
||||
export let data: PageData;
|
||||
|
||||
afterNavigate(async ({ from }) => {
|
||||
if (from?.url?.pathname === AppRoute.AUTH_CHANGE_PASSWORD) {
|
||||
resetSavedUser();
|
||||
await logout();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
{#if $featureFlags.loaded}
|
||||
|
||||
@@ -29,17 +29,17 @@
|
||||
const handleDoneClicked = async () => {
|
||||
if (index >= onboardingSteps.length - 1) {
|
||||
await setAdminOnboarding();
|
||||
goto(AppRoute.PHOTOS);
|
||||
await goto(AppRoute.PHOTOS);
|
||||
} else {
|
||||
index++;
|
||||
goto(`${AppRoute.AUTH_ONBOARDING}?${QueryParameter.ONBOARDING_STEP}=${onboardingSteps[index].name}`);
|
||||
await goto(`${AppRoute.AUTH_ONBOARDING}?${QueryParameter.ONBOARDING_STEP}=${onboardingSteps[index].name}`);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePrevious = () => {
|
||||
const handlePrevious = async () => {
|
||||
if (index >= 1) {
|
||||
index--;
|
||||
goto(`${AppRoute.AUTH_ONBOARDING}?${QueryParameter.ONBOARDING_STEP}=${onboardingSteps[index].name}`);
|
||||
await goto(`${AppRoute.AUTH_ONBOARDING}?${QueryParameter.ONBOARDING_STEP}=${onboardingSteps[index].name}`);
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
Reference in New Issue
Block a user