mirror of
https://github.com/immich-app/immich.git
synced 2026-05-18 03:10:24 +03:00
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:
@@ -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}
|
||||
Reference in New Issue
Block a user