refactor(web): co-locate single-use components in /routes (#27921)

* co-locate single use components to /routes

* revert accidentally changed paths

* fix mangled path

* fmt

* fix accidentally moved multi-use components
This commit is contained in:
Freddie Floydd
2026-04-18 02:21:36 +01:00
committed by GitHub
parent b0e4850d76
commit 03af669856
128 changed files with 108 additions and 108 deletions
@@ -1,191 +0,0 @@
<script lang="ts">
import { page } from '$app/state';
import { shouldIgnoreEvent } from '$lib/actions/shortcut';
import { authManager } from '$lib/managers/auth-manager.svelte';
import { dragAndDropFilesStore } from '$lib/stores/drag-and-drop-files.store';
import { fileUploadHandler } from '$lib/utils/file-uploader';
import { isAlbumsRoute, isLockedFolderRoute } from '$lib/utils/navigation';
import { Logo } from '@immich/ui';
import { t } from 'svelte-i18n';
import { fade } from 'svelte/transition';
let albumId = $derived(isAlbumsRoute(page.route?.id) ? page.params.albumId : undefined);
let isInLockedFolder = $derived(isLockedFolderRoute(page.route.id));
let dragStartTarget: EventTarget | null = $state(null);
let isInternalDrag = false;
const onDragEnter = (e: DragEvent) => {
if (e.dataTransfer && e.dataTransfer.types.includes('Files')) {
dragStartTarget = e.target;
}
};
const onDragLeave = (e: DragEvent) => {
if (dragStartTarget === e.target) {
dragStartTarget = null;
}
};
const onDrop = async (e: DragEvent) => {
dragStartTarget = null;
await handleDataTransfer(e.dataTransfer);
};
const onPaste = (event: ClipboardEvent) => {
if (shouldIgnoreEvent(event)) {
return;
}
return handleDataTransfer(event.clipboardData);
};
const handleDataTransfer = async (dataTransfer?: DataTransfer | null) => {
if (!dataTransfer) {
return;
}
if (!browserSupportsDirectoryUpload()) {
return handleFiles(dataTransfer.files);
}
const entries: FileSystemEntry[] = [];
const files: File[] = [];
for (const item of dataTransfer.items) {
// eslint-disable-next-line tscompat/tscompat
const entry = item.webkitGetAsEntry();
if (entry) {
entries.push(entry);
continue;
}
const file = item.getAsFile();
if (file) {
files.push(file);
}
}
const directoryFiles = await getAllFilesFromTransferEntries(entries);
return handleFiles([...files, ...directoryFiles]);
};
// eslint-disable-next-line tscompat/tscompat
const browserSupportsDirectoryUpload = () => typeof DataTransferItem.prototype.webkitGetAsEntry === 'function';
const getAllFilesFromTransferEntries = async (transferEntries: FileSystemEntry[]): Promise<File[]> => {
const allFiles: File[] = [];
let entriesToCheckForSubDirectories = [...transferEntries];
while (entriesToCheckForSubDirectories.length > 0) {
const currentEntry = entriesToCheckForSubDirectories.pop();
if (isFileSystemDirectoryEntry(currentEntry)) {
entriesToCheckForSubDirectories = entriesToCheckForSubDirectories.concat(
await getContentsFromFileSystemDirectoryEntry(currentEntry),
);
} else if (isFileSystemFileEntry(currentEntry)) {
allFiles.push(await getFileFromFileSystemEntry(currentEntry));
}
}
return allFiles;
};
const isFileSystemDirectoryEntry = (entry?: FileSystemEntry): entry is FileSystemDirectoryEntry =>
!!entry && entry.isDirectory;
const isFileSystemFileEntry = (entry?: FileSystemEntry): entry is FileSystemFileEntry => !!entry && entry.isFile;
const getFileFromFileSystemEntry = async (fileSystemFileEntry: FileSystemFileEntry): Promise<File> => {
return new Promise((resolve, reject) => {
fileSystemFileEntry.file(resolve, reject);
});
};
const readEntriesAsync = (reader: FileSystemDirectoryReader) => {
return new Promise<FileSystemEntry[]>((resolve, reject) => {
reader.readEntries(resolve, reject);
});
};
const getContentsFromFileSystemDirectoryEntry = async (
fileSystemDirectoryEntry: FileSystemDirectoryEntry,
): Promise<FileSystemEntry[]> => {
const reader = fileSystemDirectoryEntry.createReader();
const files: FileSystemEntry[] = [];
let entries: FileSystemEntry[];
do {
entries = await readEntriesAsync(reader);
files.push(...entries);
} while (entries.length > 0);
return files;
};
const handleFiles = async (files?: FileList | File[]) => {
if (!files) {
return;
}
const filesArray: File[] = Array.from<File>(files);
if (authManager.isSharedLink) {
dragAndDropFilesStore.set({ isDragging: true, files: filesArray });
} else {
await fileUploadHandler({ files: filesArray, albumId, isLockedAssets: isInLockedFolder });
}
};
const ondragstart = () => {
isInternalDrag = true;
};
const ondragend = () => {
isInternalDrag = false;
};
const ondragenter = (e: DragEvent) => {
if (isInternalDrag) {
return;
}
e.preventDefault();
e.stopPropagation();
onDragEnter(e);
};
const ondragleave = (e: DragEvent) => {
e.preventDefault();
e.stopPropagation();
onDragLeave(e);
};
const ondrop = async (e: DragEvent) => {
if (isInternalDrag) {
return;
}
e.preventDefault();
e.stopPropagation();
await onDrop(e);
};
const onDragOver = (e: DragEvent) => {
e.preventDefault();
e.stopPropagation();
};
</script>
<svelte:window onpaste={onPaste} />
<svelte:body {ondragstart} {ondragend} {ondragenter} {ondragleave} {ondrop} />
{#if dragStartTarget}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="fixed inset-0 flex h-full w-full flex-col items-center justify-center bg-gray-100/90 text-immich-dark-gray dark:bg-immich-dark-bg/90 dark:text-immich-gray"
transition:fade={{ duration: 250 }}
ondragover={onDragOver}
>
<Logo variant="icon" size="giant" class="m-16 animate-bounce" />
<div class="text-2xl">{$t('drop_files_to_upload')}</div>
</div>
{/if}
@@ -1,180 +0,0 @@
<script lang="ts">
import ActionMenuItem from '$lib/components/ActionMenuItem.svelte';
import ButtonContextMenu from '$lib/components/shared-components/context-menu/button-context-menu.svelte';
import type { SelectionBBox } from '$lib/components/shared-components/map/types';
import ArchiveAction from '$lib/components/timeline/actions/ArchiveAction.svelte';
import ChangeDate from '$lib/components/timeline/actions/ChangeDateAction.svelte';
import ChangeDescription from '$lib/components/timeline/actions/ChangeDescriptionAction.svelte';
import ChangeLocation from '$lib/components/timeline/actions/ChangeLocationAction.svelte';
import CreateSharedLink from '$lib/components/timeline/actions/CreateSharedLinkAction.svelte';
import DeleteAssets from '$lib/components/timeline/actions/DeleteAssetsAction.svelte';
import DownloadAction from '$lib/components/timeline/actions/DownloadAction.svelte';
import FavoriteAction from '$lib/components/timeline/actions/FavoriteAction.svelte';
import LinkLivePhotoAction from '$lib/components/timeline/actions/LinkLivePhotoAction.svelte';
import SelectAllAssets from '$lib/components/timeline/actions/SelectAllAction.svelte';
import SetVisibilityAction from '$lib/components/timeline/actions/SetVisibilityAction.svelte';
import StackAction from '$lib/components/timeline/actions/StackAction.svelte';
import TagAction from '$lib/components/timeline/actions/TagAction.svelte';
import AssetSelectControlBar from '$lib/components/timeline/AssetSelectControlBar.svelte';
import Timeline from '$lib/components/timeline/Timeline.svelte';
import Portal from '$lib/elements/Portal.svelte';
import { assetMultiSelectManager } from '$lib/managers/asset-multi-select-manager.svelte';
import { authManager } from '$lib/managers/auth-manager.svelte';
import { TimelineManager } from '$lib/managers/timeline-manager/timeline-manager.svelte';
import { getAssetBulkActions } from '$lib/services/asset.service';
import { mapSettings } from '$lib/stores/preferences.store';
import {
updateStackedAssetInTimeline,
updateUnstackedAssetInTimeline,
type OnLink,
type OnUnlink,
} from '$lib/utils/actions';
import { AssetVisibility } from '@immich/sdk';
import { ActionButton, CloseButton, CommandPaletteDefaultProvider, Icon } from '@immich/ui';
import { mdiDotsVertical, mdiImageMultiple } from '@mdi/js';
import { ceil, floor } from 'lodash-es';
import { t } from 'svelte-i18n';
interface Props {
bbox: SelectionBBox;
selectedClusterIds: Set<string>;
assetCount: number;
onClose: () => void;
}
let { bbox, selectedClusterIds, assetCount, onClose }: Props = $props();
let timelineManager = $state<TimelineManager>() as TimelineManager;
let selectedAssets = $derived(assetMultiSelectManager.assets);
let isAssetStackSelected = $derived(selectedAssets.length === 1 && !!selectedAssets[0].stack);
let isLinkActionAvailable = $derived.by(() => {
const isLivePhoto = selectedAssets.length === 1 && !!selectedAssets[0].livePhotoVideoId;
const isLivePhotoCandidate =
selectedAssets.length === 2 &&
selectedAssets.some((asset) => asset.isImage) &&
selectedAssets.some((asset) => asset.isVideo);
return assetMultiSelectManager.isAllUserOwned && (isLivePhoto || isLivePhotoCandidate);
});
const handleLink: OnLink = ({ still, motion }) => {
timelineManager.removeAssets([motion.id]);
timelineManager.upsertAssets([still]);
};
const handleUnlink: OnUnlink = ({ still, motion }) => {
timelineManager.upsertAssets([motion]);
timelineManager.upsertAssets([still]);
};
const handleSetVisibility = (assetIds: string[]) => {
timelineManager.removeAssets(assetIds);
assetMultiSelectManager.clear();
};
const handleEscape = () => {
assetMultiSelectManager.clear();
};
const timelineBoundingBox = $derived(
`${floor(bbox.west, 6)},${floor(bbox.south, 6)},${ceil(bbox.east, 6)},${ceil(bbox.north, 6)}`,
);
const timelineOptions = $derived({
bbox: timelineBoundingBox,
visibility: $mapSettings.includeArchived ? undefined : AssetVisibility.Timeline,
isFavorite: $mapSettings.onlyFavorites || undefined,
withPartners: $mapSettings.withPartners || undefined,
assetFilter: selectedClusterIds,
});
$effect.pre(() => {
void timelineOptions;
assetMultiSelectManager.clear();
});
</script>
<aside class="h-full w-full overflow-hidden bg-immich-bg dark:bg-immich-dark-bg flex flex-col contain-content">
<div class="flex items-center justify-between border-b border-gray-200 dark:border-immich-dark-gray pb-1 pe-1">
<div class="flex items-center gap-2">
<Icon icon={mdiImageMultiple} size="20" />
<p class="text-sm font-medium text-immich-fg dark:text-immich-dark-fg">
{$t('assets_count', { values: { count: assetCount } })}
</p>
</div>
<CloseButton onclick={onClose} />
</div>
<div class="min-h-0 flex-1">
<Timeline
bind:timelineManager
enableRouting={false}
options={timelineOptions}
onEscape={handleEscape}
assetInteraction={assetMultiSelectManager}
showArchiveIcon
/>
</div>
</aside>
{#if assetMultiSelectManager.selectionActive}
{@const Actions = getAssetBulkActions($t)}
<CommandPaletteDefaultProvider name={$t('assets')} actions={Object.values(Actions)} />
<Portal target="body">
<AssetSelectControlBar>
<CreateSharedLink />
<SelectAllAssets {timelineManager} assetInteraction={assetMultiSelectManager} />
<ActionButton action={Actions.AddToAlbum} />
{#if assetMultiSelectManager.isAllUserOwned}
<FavoriteAction
removeFavorite={assetMultiSelectManager.isAllFavorite}
onFavorite={(ids, isFavorite) => timelineManager.update(ids, (asset) => (asset.isFavorite = isFavorite))}
/>
<ButtonContextMenu icon={mdiDotsVertical} title={$t('menu')}>
<DownloadAction menuItem />
{#if assetMultiSelectManager.assets.length > 1 || isAssetStackSelected}
<StackAction
unstack={isAssetStackSelected}
onStack={(result) => updateStackedAssetInTimeline(timelineManager, result)}
onUnstack={(assets) => updateUnstackedAssetInTimeline(timelineManager, assets)}
/>
{/if}
{#if isLinkActionAvailable}
<LinkLivePhotoAction
menuItem
unlink={assetMultiSelectManager.assets.length === 1}
onLink={handleLink}
onUnlink={handleUnlink}
/>
{/if}
<ChangeDate menuItem />
<ChangeDescription menuItem />
<ChangeLocation menuItem />
<ArchiveAction
menuItem
unarchive={assetMultiSelectManager.isAllArchived}
onArchive={(ids, visibility) => timelineManager.update(ids, (asset) => (asset.visibility = visibility))}
/>
{#if authManager.preferences.tags.enabled}
<TagAction menuItem />
{/if}
<DeleteAssets
menuItem
onAssetDelete={(assetIds) => timelineManager.removeAssets(assetIds)}
onUndoDelete={(assets) => timelineManager.upsertAssets(assets)}
/>
<SetVisibilityAction menuItem onVisibilitySet={handleSetVisibility} />
<hr />
<ActionMenuItem action={Actions.RegenerateThumbnailJob} />
<ActionMenuItem action={Actions.RefreshMetadataJob} />
<ActionMenuItem action={Actions.TranscodeVideoJob} />
</ButtonContextMenu>
{:else}
<DownloadAction />
{/if}
</AssetSelectControlBar>
</Portal>
{/if}
@@ -1,32 +0,0 @@
<script lang="ts">
import { onMount } from 'svelte';
import { cubicOut } from 'svelte/easing';
import { tweened } from 'svelte/motion';
let showing = $state(false);
// delay showing any progress for a little bit so very fast loads
// do not cause flicker
const delay = 100;
const progress = tweened(0, {
duration: 1000,
easing: cubicOut,
});
function animate() {
showing = true;
void progress.set(90);
}
onMount(() => {
const timer = setTimeout(animate, delay);
return () => clearTimeout(timer);
});
</script>
{#if showing}
<div class="absolute start-0 top-0 h-[3px] w-dvw bg-white">
<span class="absolute h-[3px] bg-immich-primary" style:width={`${$progress}%`}></span>
</div>
{/if}
@@ -1,67 +0,0 @@
<script lang="ts">
import { Checkbox, Label } from '@immich/ui';
import { t } from 'svelte-i18n';
import { quintOut } from 'svelte/easing';
import { fly } from 'svelte/transition';
interface Props {
value: string[];
options: { value: string; text: string }[];
label?: string;
desc?: string;
name?: string;
isEdited?: boolean;
disabled?: boolean;
}
let {
value = $bindable(),
options,
label = '',
desc = '',
name = '',
isEdited = false,
disabled = false,
}: Props = $props();
function handleCheckboxChange(option: string) {
value = value.includes(option) ? value.filter((item) => item !== option) : [...value, option];
}
</script>
<div class="mb-4 w-full">
<div class="flex h-6.5 place-items-center gap-1">
<label class="font-medium text-primary text-sm" for="{name}-select">
{label}
</label>
{#if isEdited}
<div
transition:fly={{ x: 10, duration: 200, easing: quintOut }}
class="rounded-full bg-orange-100 px-2 text-[10px] text-orange-900"
>
{$t('unsaved_change')}
</div>
{/if}
</div>
{#if desc}
<p class="immich-form-label pb-2 text-sm" id="{name}-desc">
{desc}
</p>
{/if}
<div class="flex flex-col gap-2">
{#each options as option (option.value)}
<div class="flex gap-2 items-center">
<Checkbox
size="tiny"
id="{option.value}-checkbox"
checked={value.includes(option.value)}
{disabled}
onCheckedChange={() => handleCheckboxChange(option.value)}
/>
<Label label={option.text} for="{option.value}-checkbox" size="small" />
</div>
{/each}
</div>
</div>
@@ -1,50 +0,0 @@
<script lang="ts">
import Combobox, { type ComboBoxOption } from '$lib/components/shared-components/combobox.svelte';
import { Label, Text } from '@immich/ui';
import type { Snippet } from 'svelte';
import { t } from 'svelte-i18n';
import { quintOut } from 'svelte/easing';
import { fly } from 'svelte/transition';
interface Props {
title: string;
comboboxPlaceholder: string;
subtitle?: string;
isEdited?: boolean;
options: ComboBoxOption[];
selectedOption: ComboBoxOption;
onSelect: (combobox: ComboBoxOption | undefined) => void;
children?: Snippet;
}
let {
title,
comboboxPlaceholder,
subtitle = '',
isEdited = false,
options,
selectedOption,
onSelect,
children,
}: Props = $props();
</script>
<div>
<div class="flex h-6.5 place-items-center gap-1">
<Label size="small">{title}</Label>
{#if isEdited}
<div
transition:fly={{ x: 10, duration: 200, easing: quintOut }}
class="rounded-full bg-orange-100 px-2 text-[10px] text-orange-900"
>
{$t('unsaved_change')}
</div>
{/if}
</div>
<Text size="small" color="muted">{subtitle}</Text>
<div class="flex items-center mt-2 max-w-[300px]">
<Combobox label={title} hideLabel={true} {selectedOption} {options} placeholder={comboboxPlaceholder} {onSelect} />
{@render children?.()}
</div>
</div>
@@ -1,84 +0,0 @@
<script lang="ts">
import { Icon } from '@immich/ui';
import { mdiChevronDown } from '@mdi/js';
import { t } from 'svelte-i18n';
import { quintOut } from 'svelte/easing';
import { fly } from 'svelte/transition';
interface Props {
value: string | number | undefined;
options: { value: string | number; text: string }[];
label?: string;
desc?: string;
name?: string;
isEdited?: boolean;
number?: boolean;
disabled?: boolean;
onSelect?: (setting: string | number) => void;
}
let {
value = $bindable(),
options,
label = '',
desc = '',
name = '',
isEdited = false,
number = false,
disabled = false,
onSelect = () => {},
}: Props = $props();
const handleChange = (e: Event) => {
value = (e.target as HTMLInputElement).value;
if (number) {
value = Number.parseInt(value);
}
onSelect(value);
};
</script>
<div class="mb-4 w-full">
<div class="flex h-6.5 place-items-center gap-1">
<label class="font-medium text-primary text-sm" for="{name}-select">{label}</label>
{#if isEdited}
<div
transition:fly={{ x: 10, duration: 200, easing: quintOut }}
class="rounded-full bg-orange-100 px-2 text-[10px] text-orange-900"
>
{$t('unsaved_change')}
</div>
{/if}
</div>
{#if desc}
<p class="immich-form-label pb-2 text-sm" id="{name}-desc">
{desc}
</p>
{/if}
<div class="grid">
<Icon
icon={mdiChevronDown}
size="1.2em"
aria-hidden
class="pointer-events-none end-1 relative col-start-1 row-start-1 self-center justify-self-end {disabled
? 'text-immich-bg'
: 'text-immich-fg dark:text-immich-bg'}"
/>
<select
class="immich-form-input w-full appearance-none row-start-1 col-start-1 pe-6!"
{disabled}
aria-describedby={desc ? `${name}-desc` : undefined}
{name}
id="{name}-select"
bind:value
onchange={handleChange}
>
{#each options as option (option.value)}
<option value={option.value}>{option.text}</option>
{/each}
</select>
</div>
</div>
@@ -1,68 +0,0 @@
<script lang="ts">
import type { Snippet } from 'svelte';
import { t } from 'svelte-i18n';
import { quintOut } from 'svelte/easing';
import { fly } from 'svelte/transition';
interface Props {
value: string;
label?: string;
description?: string;
required?: boolean;
disabled?: boolean;
isEdited?: boolean;
descriptionSnippet?: Snippet;
}
let {
value = $bindable(),
label = '',
description = '',
required = false,
disabled = false,
isEdited = false,
descriptionSnippet,
}: Props = $props();
const handleInput = (e: Event) => {
value = (e.target as HTMLInputElement).value;
};
</script>
<div class="mb-4 w-full">
<div class="flex h-6.5 place-items-center gap-1">
<label class="font-medium text-primary text-sm" for={label}>{label}</label>
{#if required}
<div class="text-red-400">*</div>
{/if}
{#if isEdited}
<div
transition:fly={{ x: 10, duration: 200, easing: quintOut }}
class="rounded-full bg-orange-100 px-2 text-[10px] text-orange-900"
>
{$t('unsaved_change')}
</div>
{/if}
</div>
{#if description}
<p class="immich-form-label pb-2 text-sm" id="{label}-desc">
{description}
</p>
{:else}
{@render descriptionSnippet?.()}
{/if}
<textarea
class="immich-form-input w-full pb-2"
aria-describedby={description ? `${label}-desc` : undefined}
aria-labelledby="{label}-label"
id={label}
name={label}
{required}
{value}
oninput={handleInput}
{disabled}
></textarea>
</div>
@@ -1,53 +0,0 @@
<script lang="ts">
import { Logo } from '@immich/ui';
import { t } from 'svelte-i18n';
interface Props {
centered?: boolean;
logoSize?: 'sm' | 'lg';
}
let { centered = false, logoSize = 'sm' }: Props = $props();
</script>
<div
class="flex gap-1 mt-2 place-items-center dark:bg-immich-dark-primary/10 bg-gray-200/50 p-2 rounded-lg bg-clip-padding border border-transparent relative supporter-effect"
class:place-content-center={centered}
>
<Logo variant="icon" size={logoSize === 'sm' ? 'tiny' : 'small'} />
<p class="dark:text-gray-100">{$t('purchase_account_info')}</p>
</div>
<style lang="postcss">
@reference "tailwindcss";
.supporter-effect::after {
@apply absolute inset-0 rounded-lg opacity-0 transition-opacity content-[''];
}
.supporter-effect:hover::after {
@apply opacity-100;
background: linear-gradient(
to right,
rgba(16, 132, 254, 0.25),
rgba(229, 125, 175, 0.25),
rgba(254, 36, 29, 0.25),
rgba(255, 183, 0, 0.25),
rgba(22, 193, 68, 0.25)
);
animation: gradient 10s ease infinite;
background-size: 400% 400%;
}
@keyframes gradient {
0% {
background-position: 0% 50%;
}
50% {
background-position: 100% 50%;
}
100% {
background-position: 0% 50%;
}
}
</style>
@@ -1,116 +0,0 @@
<script lang="ts">
import { Route } from '$lib/route';
import { locale } from '$lib/stores/preferences.store';
import { uploadAssetsStore } from '$lib/stores/upload';
import type { UploadAsset } from '$lib/types';
import { UploadState } from '$lib/types';
import { getByteUnitString } from '$lib/utils/byte-units';
import { fileUploadHandler } from '$lib/utils/file-uploader';
import { Icon } from '@immich/ui';
import {
mdiAlertCircle,
mdiCheckCircle,
mdiCircleOutline,
mdiClose,
mdiLoading,
mdiOpenInNew,
mdiRestart,
mdiTrashCan,
} from '@mdi/js';
import { t } from 'svelte-i18n';
import { fade } from 'svelte/transition';
interface Props {
uploadAsset: UploadAsset;
}
let { uploadAsset }: Props = $props();
const handleDismiss = (uploadAsset: UploadAsset) => {
uploadAssetsStore.removeItem(uploadAsset.id);
};
const handleRetry = async (uploadAsset: UploadAsset) => {
uploadAssetsStore.removeItem(uploadAsset.id);
await fileUploadHandler({ files: [uploadAsset.file], albumId: uploadAsset.albumId });
};
</script>
<div
in:fade={{ duration: 250 }}
out:fade={{ duration: 100 }}
class="flex flex-col rounded-xl text-xs p-2 gap-1 border border-gray-300 dark:border-subtle bg-primary/10"
>
<div class="flex items-center gap-2">
<div class="flex items-center justify-center">
{#if uploadAsset.state === UploadState.PENDING}
<Icon icon={mdiCircleOutline} size="24" class="text-primary" title={$t('pending')} />
{:else if uploadAsset.state === UploadState.STARTED}
<Icon icon={mdiLoading} size="24" spin class="text-primary" title={$t('asset_skipped')} />
{:else if uploadAsset.state === UploadState.ERROR}
<Icon icon={mdiAlertCircle} size="24" class="text-danger" title={$t('error')} />
{:else if uploadAsset.state === UploadState.DUPLICATED}
{#if uploadAsset.isTrashed}
<Icon icon={mdiTrashCan} size="24" class="text-gray-500" title={$t('asset_skipped_in_trash')} />
{:else}
<Icon icon={mdiAlertCircle} size="24" class="text-warning" title={$t('asset_skipped')} />
{/if}
{:else if uploadAsset.state === UploadState.DONE}
<Icon icon={mdiCheckCircle} size="24" class="text-success" title={$t('asset_uploaded')} />
{/if}
</div>
<!-- <span>[{getByteUnitString(uploadAsset.file.size, $locale)}]</span> -->
<span class="grow break-all">{uploadAsset.file.name}</span>
{#if uploadAsset.state === UploadState.DUPLICATED && uploadAsset.assetId}
<div class="flex items-center justify-between gap-1">
<a
href={uploadAsset.isTrashed
? Route.viewTrashedAsset({ id: uploadAsset.assetId })
: Route.viewAsset({ id: uploadAsset.assetId })}
target="_blank"
rel="noopener noreferrer"
class=""
aria-hidden="true"
tabindex={-1}
>
<Icon icon={mdiOpenInNew} size="20" />
</a>
<button type="button" onclick={() => handleDismiss(uploadAsset)} class="" aria-hidden="true" tabindex={-1}>
<Icon icon={mdiClose} size="20" />
</button>
</div>
{:else if uploadAsset.state === UploadState.ERROR}
<div class="flex items-center justify-between gap-1">
<button type="button" onclick={() => handleRetry(uploadAsset)} class="" aria-hidden="true" tabindex={-1}>
<Icon icon={mdiRestart} size="20" />
</button>
<button type="button" onclick={() => handleDismiss(uploadAsset)} class="" aria-hidden="true" tabindex={-1}>
<Icon icon={mdiClose} size="20" />
</button>
</div>
{/if}
</div>
{#if uploadAsset.state === UploadState.STARTED}
<div class="text-black relative mt-[5px] h-4.5 w-full rounded-md bg-gray-300 dark:bg-gray-700">
<div class="h-4.5 rounded-md bg-immich-primary transition-all" style={`width: ${uploadAsset.progress}%`}></div>
<p class="absolute top-0.5 h-full w-full text-center text-white text-[10px]">
{#if uploadAsset.message === $t('asset_hashing')}
{uploadAsset.message}
{:else}
{uploadAsset.message}
{uploadAsset.progress}% - {getByteUnitString(uploadAsset.speed || 0, $locale)}/s - {uploadAsset.eta}s
{/if}
</p>
</div>
{/if}
{#if uploadAsset.state === UploadState.ERROR}
<div class="flex flex-row justify-between">
<p class="w-full rounded-md text-justify text-danger">
{uploadAsset.error}
</p>
</div>
{/if}
</div>
@@ -1,161 +0,0 @@
<script lang="ts">
import { locale } from '$lib/stores/preferences.store';
import { uploadAssetsStore } from '$lib/stores/upload';
import { uploadExecutionQueue } from '$lib/utils/file-uploader';
import { Icon, IconButton, toastManager } from '@immich/ui';
import { mdiCancel, mdiCloudUploadOutline, mdiCog, mdiWindowMinimize } from '@mdi/js';
import { t } from 'svelte-i18n';
import { quartInOut } from 'svelte/easing';
import { fade, scale } from 'svelte/transition';
import UploadAssetPreview from './upload-asset-preview.svelte';
let showDetail = $state(false);
let showOptions = $state(false);
let concurrency = $state(uploadExecutionQueue.concurrency);
let { stats, isDismissible, isUploading, remainingUploads } = uploadAssetsStore;
$effect(() => {
if ($isUploading) {
showDetail = true;
}
});
</script>
{#if $isUploading}
<div
in:fade={{ duration: 250 }}
out:fade={{ duration: 250 }}
onoutroend={() => {
if ($stats.errors > 0) {
toastManager.danger($t('upload_errors', { values: { count: $stats.errors } }));
} else if ($stats.success > 0) {
toastManager.primary($t('upload_success'));
}
if ($stats.duplicates > 0) {
toastManager.warning($t('upload_skipped_duplicates', { values: { count: $stats.duplicates } }));
}
uploadAssetsStore.reset();
}}
class="fixed bottom-6 end-16"
>
{#if showDetail}
<div
in:scale={{ duration: 250, easing: quartInOut }}
class="w-81 rounded-xl border border-gray-200 dark:border-subtle p-4 text-sm shadow-xs bg-subtle"
>
<div class="place-item-center mb-4 flex justify-between">
<div class="flex flex-col gap-1">
<p class="immich-form-label text-xm">
{$t('upload_progress', {
values: {
remaining: $remainingUploads,
processed: $stats.total - $remainingUploads,
total: $stats.total,
},
})}
</p>
<p class="immich-form-label text-xs">
{$t('upload_status_uploaded')}
<span class="text-success">{$stats.success.toLocaleString($locale)}</span>
-
{$t('upload_status_errors')}
<span class="text-danger">{$stats.errors.toLocaleString($locale)}</span>
-
{$t('upload_status_duplicates')}
<span class="text-warning">{$stats.duplicates.toLocaleString($locale)}</span>
</p>
</div>
<div class="flex flex-col items-end">
<div class="flex flex-row">
<IconButton
variant="ghost"
shape="round"
color="secondary"
icon={mdiCog}
size="small"
onclick={() => (showOptions = !showOptions)}
aria-label={$t('toggle_settings')}
/>
<IconButton
variant="ghost"
shape="round"
color="secondary"
aria-label={$t('minimize')}
icon={mdiWindowMinimize}
size="small"
onclick={() => (showDetail = false)}
/>
</div>
{#if $isDismissible}
<IconButton
variant="ghost"
shape="round"
color="secondary"
aria-label={$t('dismiss_all_errors')}
icon={mdiCancel}
size="small"
onclick={() => uploadAssetsStore.dismissErrors()}
/>
{/if}
</div>
</div>
{#if showOptions}
<div class="immich-scrollbar mb-4 max-h-100 overflow-y-auto rounded-lg">
<div class="flex h-6.5 place-items-center gap-1">
<label class="immich-form-label" for="upload-concurrency">{$t('upload_concurrency')}</label>
</div>
<input
class="immich-form-input w-full"
aria-labelledby={$t('upload_concurrency')}
id="upload-concurrency"
name={$t('upload_concurrency')}
type="number"
min="1"
max="50"
step="1"
bind:value={concurrency}
onchange={() => (uploadExecutionQueue.concurrency = concurrency)}
/>
</div>
{/if}
<div class="immich-scrollbar flex max-h-[400px] flex-col gap-2 overflow-y-auto rounded-lg">
{#each $uploadAssetsStore as uploadAsset (uploadAsset.id)}
<UploadAssetPreview {uploadAsset} />
{/each}
</div>
</div>
{:else}
<div class="rounded-full">
<button
type="button"
in:scale={{ duration: 250, easing: quartInOut }}
onclick={() => (showDetail = true)}
class="absolute -start-4 -top-4 flex h-10 w-10 place-content-center place-items-center rounded-full bg-primary p-5 text-xs text-light"
>
{$remainingUploads.toLocaleString($locale)}
</button>
{#if $stats.errors > 0}
<button
type="button"
in:scale={{ duration: 250, easing: quartInOut }}
onclick={() => (showDetail = true)}
class="absolute -end-4 -top-4 flex h-10 w-10 place-content-center place-items-center rounded-full bg-danger p-5 text-xs text-light"
>
{$stats.errors.toLocaleString($locale)}
</button>
{/if}
<button
type="button"
in:scale={{ duration: 250, easing: quartInOut }}
onclick={() => (showDetail = true)}
class="flex h-16 w-16 place-content-center place-items-center rounded-full bg-subtle p-5 text-sm text-primary shadow-lg"
>
<div class="animate-pulse">
<Icon icon={mdiCloudUploadOutline} size="30" />
</div>
</button>
</div>
{/if}
</div>
{/if}