1
0
Fork 0
mirror of https://github.com/immich-app/immich.git synced 2025-01-16 00:36:47 +01:00

refactor(web): upload panel (#12326)

Co-authored-by: Alex <alex.tran1502@gmail.com>
This commit is contained in:
Jason Rasmussen 2024-09-04 23:38:55 -04:00 committed by GitHub
parent 0d6bef2c05
commit f4ec842577
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 184 additions and 140 deletions

View file

@ -16,13 +16,14 @@
export let ariaLabelledby: string | undefined = undefined;
export let strokeWidth: number = 0;
export let strokeColor: string = 'currentColor';
export let spin = false;
</script>
<svg
width={size}
height={size}
{viewBox}
class="{className} {flipped ? '-scale-x-100' : ''}"
class="{className} {flipped ? '-scale-x-100' : ''} {spin ? 'animate-spin' : ''}"
{role}
stroke={strokeColor}
stroke-width={strokeWidth}

View file

@ -1,21 +1,32 @@
<script lang="ts">
import Icon from '$lib/components/elements/icon.svelte';
import { AppRoute } from '$lib/constants';
import type { UploadAsset } from '$lib/models/upload-asset';
import { UploadState } from '$lib/models/upload-asset';
import { locale } from '$lib/stores/preferences.store';
import { getByteUnitString } from '$lib/utils/byte-units';
import { fade } from 'svelte/transition';
import ImmichLogo from './immich-logo.svelte';
import { getFilenameExtension } from '$lib/utils/asset-utils';
import { uploadAssetsStore } from '$lib/stores/upload';
import Icon from '$lib/components/elements/icon.svelte';
import { getByteUnitString } from '$lib/utils/byte-units';
import { fileUploadHandler } from '$lib/utils/file-uploader';
import { mdiRefresh, mdiCancel } from '@mdi/js';
import {
mdiAlertCircle,
mdiCheckCircle,
mdiCircleOutline,
mdiClose,
mdiLoading,
mdiOpenInNew,
mdiRestart,
} from '@mdi/js';
import { t } from 'svelte-i18n';
import { fade } from 'svelte/transition';
export let uploadAsset: UploadAsset;
const handleDismiss = (uploadAsset: UploadAsset) => {
uploadAssetsStore.removeItem(uploadAsset.id);
};
const handleRetry = async (uploadAsset: UploadAsset) => {
uploadAssetsStore.removeUploadAsset(uploadAsset.id);
uploadAssetsStore.removeItem(uploadAsset.id);
await fileUploadHandler([uploadAsset.file], uploadAsset.albumId);
};
</script>
@ -23,86 +34,69 @@
<div
in:fade={{ duration: 250 }}
out:fade={{ duration: 100 }}
class="flex flex-col rounded-lg bg-immich-bg text-xs dark:bg-immich-dark-bg"
class="flex flex-col rounded-lg bg-immich-bg text-xs dark:bg-immich-dark-bg p-2 gap-1"
>
<div class="grid grid-cols-[65px_auto_auto] max-h-[70px]">
<div class="relative">
<div in:fade={{ duration: 250 }}>
<ImmichLogo noText class="h-[65px] w-[65px] rounded-bl-lg rounded-tl-lg object-cover p-2" />
</div>
<div class="absolute bottom-0 left-0 h-[25px] w-full rounded-bl-md bg-immich-primary/30">
<p
class="absolute bottom-1 right-1 stroke-immich-primary object-right-bottom font-semibold uppercase text-white/95 dark:text-gray-100"
>
.{getFilenameExtension(uploadAsset.file.name)}
</p>
</div>
<div class="flex items-center gap-2">
<div class="flex items-center justify-center">
{#if uploadAsset.state === UploadState.PENDING}
<Icon path={mdiCircleOutline} size="24" class="text-immich-primary" title={$t('pending')} />
{:else if uploadAsset.state === UploadState.STARTED}
<Icon path={mdiLoading} size="24" spin class="text-immich-primary" title={$t('asset_skipped')} />
{:else if uploadAsset.state === UploadState.ERROR}
<Icon path={mdiAlertCircle} size="24" class="text-immich-error" title={$t('error')} />
{:else if uploadAsset.state === UploadState.DUPLICATED}
<Icon path={mdiAlertCircle} size="24" class="text-immich-warning" title={$t('asset_skipped')} />
{:else if uploadAsset.state === UploadState.DONE}
<Icon path={mdiCheckCircle} size="24" class="text-immich-success" title={$t('asset_uploaded')} />
{/if}
</div>
<div class="flex flex-col justify-between p-2 pr-2">
<input
disabled
class="w-full rounded-md border bg-gray-100 p-1 px-2 text-[10px] dark:border-immich-dark-gray dark:bg-gray-900"
value={`[${getByteUnitString(uploadAsset.file.size, $locale)}] ${uploadAsset.file.name}`}
/>
<!-- <span>[{getByteUnitString(uploadAsset.file.size, $locale)}]</span> -->
<span class="grow break-all">{uploadAsset.file.name}</span>
<div
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}%`} />
<p class="absolute top-0 h-full w-full text-center text-[10px]">
{#if uploadAsset.message}
{uploadAsset.message}
{:else}
{uploadAsset.progress}% - {getByteUnitString(uploadAsset.speed || 0, $locale)}/s - {uploadAsset.eta}s
{/if}
</p>
{:else if uploadAsset.state === UploadState.PENDING}
<div class="h-[15px] rounded-md bg-immich-dark-gray transition-all dark:bg-immich-gray" style="width: 100%" />
<p class="absolute top-0 h-full w-full text-center text-[10px]">{$t('pending')}</p>
{:else if uploadAsset.state === UploadState.ERROR}
<div class="h-[15px] rounded-md bg-immich-error transition-all" style="width: 100%" />
<p class="absolute top-0 h-full w-full text-center text-[10px]">{$t('error')}</p>
{:else if uploadAsset.state === UploadState.DUPLICATED}
<div class="h-[15px] rounded-md bg-immich-warning transition-all" style="width: 100%" />
<p class="absolute top-0 h-full w-full text-center text-[10px]">
{$t('asset_skipped')}
{#if uploadAsset.message}
({uploadAsset.message})
{/if}
</p>
{:else if uploadAsset.state === UploadState.DONE}
<div class="h-[15px] rounded-md bg-immich-success transition-all" style="width: 100%" />
<p class="absolute top-0 h-full w-full text-center text-[10px]">
{$t('asset_uploaded')}
{#if uploadAsset.message}
({uploadAsset.message})
{/if}
</p>
{/if}
</div>
</div>
{#if uploadAsset.state === UploadState.ERROR}
<div class="flex h-full flex-col place-content-evenly place-items-center justify-items-center pr-2">
<button type="button" on:click={() => handleRetry(uploadAsset)} title={$t('retry_upload')} class="flex text-sm">
<span class="text-immich-dark-gray dark:text-immich-dark-fg"><Icon path={mdiRefresh} size="20" /></span>
</button>
<button
type="button"
on:click={() => uploadAssetsStore.removeUploadAsset(uploadAsset.id)}
title={$t('dismiss_error')}
class="flex text-sm"
{#if uploadAsset.state === UploadState.DUPLICATED && uploadAsset.assetId}
<div class="flex items-center justify-between gap-1">
<a
href="{AppRoute.PHOTOS}/{uploadAsset.assetId}"
target="_blank"
rel="noopener noreferrer"
class=""
aria-hidden="true"
tabindex={-1}
>
<span class="text-immich-error"><Icon path={mdiCancel} size="20" /></span>
<Icon path={mdiOpenInNew} size="20" />
</a>
<button type="button" on:click={() => handleDismiss(uploadAsset)} class="" aria-hidden="true" tabindex={-1}>
<Icon path={mdiClose} size="20" />
</button>
</div>
{:else if uploadAsset.state === UploadState.ERROR}
<div class="flex items-center justify-between gap-1">
<button type="button" on:click={() => handleRetry(uploadAsset)} class="" aria-hidden="true" tabindex={-1}>
<Icon path={mdiRestart} size="20" />
</button>
<button type="button" on:click={() => handleDismiss(uploadAsset)} class="" aria-hidden="true" tabindex={-1}>
<Icon path={mdiClose} size="20" />
</button>
</div>
{/if}
</div>
{#if uploadAsset.state === UploadState.STARTED}
<div class="text-black relative mt-[5px] h-[15px] w-full rounded-md bg-gray-300 dark:bg-immich-dark-gray">
<div class="h-[15px] rounded-md bg-immich-primary transition-all" style={`width: ${uploadAsset.progress}%`} />
<p class="absolute top-0 h-full w-full text-center text-[10px]">
{#if uploadAsset.message}
{uploadAsset.message}
{:else}
{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 py-1 px-2 text-justify text-[10px] text-immich-error">
<p class="w-full rounded-md text-justify text-immich-error">
{uploadAsset.error}
</p>
</div>

View file

@ -15,8 +15,7 @@
let showOptions = false;
let concurrency = uploadExecutionQueue.concurrency;
let { isUploading, hasError, remainingUploads, errorCounter, duplicateCounter, successCounter, totalUploadCounter } =
uploadAssetsStore;
let { stats, isDismissible, isUploading, remainingUploads } = uploadAssetsStore;
const autoHide = () => {
if (!$isUploading && showDetail) {
@ -33,29 +32,29 @@
}
</script>
{#if $hasError || $isUploading}
{#if $isUploading}
<div
in:fade={{ duration: 250 }}
out:fade={{ duration: 250 }}
on:outroend={() => {
if ($errorCounter > 0) {
if ($stats.errors > 0) {
notificationController.show({
message: $t('upload_errors', { values: { count: $errorCounter } }),
message: $t('upload_errors', { values: { count: $stats.errors } }),
type: NotificationType.Warning,
});
} else if ($successCounter > 0) {
} else if ($stats.success > 0) {
notificationController.show({
message: $t('upload_success'),
type: NotificationType.Info,
});
}
if ($duplicateCounter > 0) {
if ($stats.duplicates > 0) {
notificationController.show({
message: $t('upload_skipped_duplicates', { values: { count: $duplicateCounter } }),
message: $t('upload_skipped_duplicates', { values: { count: $stats.duplicates } }),
type: NotificationType.Warning,
});
}
uploadAssetsStore.resetStore();
uploadAssetsStore.reset();
}}
class="fixed bottom-6 right-6 z-[10000]"
>
@ -70,20 +69,20 @@
{$t('upload_progress', {
values: {
remaining: $remainingUploads,
processed: $successCounter + $errorCounter,
total: $totalUploadCounter,
processed: $stats.total - $remainingUploads,
total: $stats.total,
},
})}
</p>
<p class="immich-form-label text-xs">
{$t('upload_status_uploaded')}
<span class="text-immich-success">{$successCounter.toLocaleString($locale)}</span>
<span class="text-immich-success">{$stats.success.toLocaleString($locale)}</span>
-
{$t('upload_status_errors')}
<span class="text-immich-error">{$errorCounter.toLocaleString($locale)}</span>
<span class="text-immich-error">{$stats.errors.toLocaleString($locale)}</span>
-
{$t('upload_status_duplicates')}
<span class="text-immich-warning">{$duplicateCounter.toLocaleString($locale)}</span>
<span class="text-immich-warning">{$stats.duplicates.toLocaleString($locale)}</span>
</p>
</div>
<div class="flex flex-col items-end">
@ -103,7 +102,7 @@
on:click={() => (showDetail = false)}
/>
</div>
{#if $hasError}
{#if $isDismissible}
<CircleIconButton
title={$t('dismiss_all_errors')}
icon={mdiCancel}
@ -115,7 +114,7 @@
</div>
</div>
{#if showOptions}
<div class="immich-scrollbar mb-4 max-h-[400px] overflow-y-auto rounded-lg pr-2">
<div class="immich-scrollbar mb-4 max-h-[400px] overflow-y-auto rounded-lg">
<div class="flex h-[26px] place-items-center gap-1">
<label class="immich-form-label" for="upload-concurrency">{$t('upload_concurrency')}</label>
</div>
@ -133,7 +132,7 @@
/>
</div>
{/if}
<div class="immich-scrollbar flex max-h-[400px] flex-col gap-2 overflow-y-auto rounded-lg pr-2">
<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}
@ -149,14 +148,14 @@
>
{$remainingUploads.toLocaleString($locale)}
</button>
{#if $hasError}
{#if $stats.errors > 0}
<button
type="button"
in:scale={{ duration: 250, easing: quartInOut }}
on:click={() => (showDetail = true)}
class="absolute -right-4 -top-4 flex h-10 w-10 place-content-center place-items-center rounded-full bg-immich-error p-5 text-xs text-gray-200"
>
{$errorCounter.toLocaleString($locale)}
{$stats.errors.toLocaleString($locale)}
</button>
{/if}
<button

View file

@ -9,8 +9,8 @@ export enum UploadState {
export type UploadAsset = {
id: string;
file: File;
albumId?: string;
assetId?: string;
albumId?: string;
progress?: number;
state?: UploadState;
startDate?: number;

View file

@ -3,32 +3,36 @@ import { UploadState, type UploadAsset } from '../models/upload-asset';
function createUploadStore() {
const uploadAssets = writable<Array<UploadAsset>>([]);
const duplicateCounter = writable(0);
const successCounter = writable(0);
const totalUploadCounter = writable(0);
const stats = writable<{ errors: number; duplicates: number; success: number; total: number }>({
errors: 0,
duplicates: 0,
success: 0,
total: 0,
});
const { subscribe } = uploadAssets;
const isUploading = derived(uploadAssets, ($uploadAssets) => {
return $uploadAssets.length > 0;
});
const errorsAssets = derived(uploadAssets, (a) => a.filter((e) => e.state === UploadState.ERROR));
const errorCounter = derived(errorsAssets, (values) => values.length);
const hasError = derived(errorCounter, (values) => values > 0);
const isUploading = derived(uploadAssets, (items) => items.length > 0);
const isDismissible = derived(uploadAssets, (items) =>
items.some((item) => item.state === UploadState.ERROR || item.state === UploadState.DUPLICATED),
);
const remainingUploads = derived(
uploadAssets,
(values) => values.filter((a) => a.state === UploadState.PENDING || a.state === UploadState.STARTED).length,
);
const addNewUploadAsset = (newAsset: UploadAsset) => {
const addItem = (newAsset: UploadAsset) => {
uploadAssets.update(($assets) => {
const duplicate = $assets.find((asset) => asset.id === newAsset.id);
if (duplicate) {
return $assets.map((asset) => (asset.id === newAsset.id ? newAsset : asset));
}
totalUploadCounter.update((c) => c + 1);
stats.update((stats) => {
stats.total++;
return stats;
});
$assets.push({
...newAsset,
speed: 0,
@ -36,6 +40,7 @@ function createUploadStore() {
progress: 0,
eta: 0,
});
return $assets;
});
};
@ -53,7 +58,7 @@ function createUploadStore() {
};
const markStarted = (id: string) => {
updateAsset(id, {
updateItem(id, {
state: UploadState.STARTED,
startDate: Date.now(),
});
@ -70,39 +75,61 @@ function createUploadStore() {
});
};
const updateAsset = (id: string, partialObject: Partial<UploadAsset>) => {
const updateItem = (id: string, partialObject: Partial<UploadAsset>) => {
updateAssetMap(id, (v) => ({ ...v, ...partialObject }));
};
const removeUploadAsset = (id: string) => {
const removeItem = (id: string) => {
uploadAssets.update((uploadingAsset) => uploadingAsset.filter((a) => a.id != id));
};
const dismissErrors = () => uploadAssets.update((value) => value.filter((e) => e.state !== UploadState.ERROR));
const dismissErrors = () =>
uploadAssets.update((value) =>
value.filter((e) => e.state !== UploadState.ERROR && e.state !== UploadState.DUPLICATED),
);
const resetStore = () => {
const reset = () => {
uploadAssets.set([]);
duplicateCounter.set(0);
successCounter.set(0);
totalUploadCounter.set(0);
stats.set({ errors: 0, duplicates: 0, success: 0, total: 0 });
};
const track = (value: 'success' | 'duplicate' | 'error') => {
stats.update((stats) => {
switch (value) {
case 'success': {
stats.success++;
break;
}
case 'duplicate': {
stats.duplicates++;
break;
}
case 'error': {
stats.errors++;
break;
}
}
return stats;
});
};
return {
subscribe,
errorCounter,
duplicateCounter,
successCounter,
totalUploadCounter,
stats,
remainingUploads,
hasError,
dismissErrors,
isDismissible,
isUploading,
resetStore,
addNewUploadAsset,
track,
dismissErrors,
reset,
markStarted,
addItem,
updateItem,
removeItem,
updateProgress,
updateAsset,
removeUploadAsset,
subscribe,
};
}

View file

@ -17,6 +17,25 @@ import { t } from 'svelte-i18n';
import { get } from 'svelte/store';
import { getServerErrorMessage, handleError } from './handle-error';
export const addDummyItems = () => {
uploadAssetsStore.addItem({ id: 'asset-0', file: { name: 'asset0.jpg', size: 123_456 } as File });
uploadAssetsStore.updateItem('asset-0', { state: UploadState.PENDING });
uploadAssetsStore.addItem({ id: 'asset-1', file: { name: 'asset1.jpg', size: 123_456 } as File });
uploadAssetsStore.updateItem('asset-1', { state: UploadState.STARTED });
uploadAssetsStore.updateProgress('asset-1', 75, 100);
uploadAssetsStore.addItem({ id: 'asset-2', file: { name: 'asset2.jpg', size: 123_456 } as File });
uploadAssetsStore.updateItem('asset-2', { state: UploadState.ERROR, error: new Error('Internal server error') });
uploadAssetsStore.addItem({ id: 'asset-3', file: { name: 'asset3.jpg', size: 123_456 } as File });
uploadAssetsStore.updateItem('asset-3', { state: UploadState.DUPLICATED, assetId: 'asset-2' });
uploadAssetsStore.addItem({ id: 'asset-4', file: { name: 'asset3.jpg', size: 123_456 } as File });
uploadAssetsStore.updateItem('asset-4', { state: UploadState.DONE });
uploadAssetsStore.track('error');
uploadAssetsStore.track('success');
uploadAssetsStore.track('duplicate');
};
// addDummyItems();
let _extensions: string[];
export const uploadExecutionQueue = new ExecutorQueue({ concurrency: 2 });
@ -68,7 +87,7 @@ export const fileUploadHandler = async (files: File[], albumId?: string, assetId
for (const file of files) {
const name = file.name.toLowerCase();
if (extensions.some((extension) => name.endsWith(extension))) {
uploadAssetsStore.addNewUploadAsset({ id: getDeviceAssetId(file), file, albumId, assetId });
uploadAssetsStore.addItem({ id: getDeviceAssetId(file), file, albumId });
promises.push(uploadExecutionQueue.addTask(() => fileUploader(file, albumId, assetId)));
}
}
@ -106,7 +125,7 @@ async function fileUploader(assetFile: File, albumId?: string, replaceAssetId?:
let responseData: AssetMediaResponseDto | undefined;
const key = getKey();
if (crypto?.subtle?.digest && !key) {
uploadAssetsStore.updateAsset(deviceAssetId, { message: $t('asset_hashing') });
uploadAssetsStore.updateItem(deviceAssetId, { message: $t('asset_hashing') });
await tick();
try {
const bytes = await assetFile.arrayBuffer();
@ -127,7 +146,7 @@ async function fileUploader(assetFile: File, albumId?: string, replaceAssetId?:
}
if (!responseData) {
uploadAssetsStore.updateAsset(deviceAssetId, { message: $t('asset_uploading') });
uploadAssetsStore.updateItem(deviceAssetId, { message: $t('asset_uploading') });
if (replaceAssetId) {
const response = await uploadRequest<AssetMediaResponseDto>({
url: getBaseUrl() + getAssetOriginalPath(replaceAssetId) + (key ? `?key=${key}` : ''),
@ -152,30 +171,34 @@ async function fileUploader(assetFile: File, albumId?: string, replaceAssetId?:
}
if (responseData.status === AssetMediaStatus.Duplicate) {
uploadAssetsStore.duplicateCounter.update((count) => count + 1);
uploadAssetsStore.track('duplicate');
} else {
uploadAssetsStore.successCounter.update((c) => c + 1);
uploadAssetsStore.track('success');
}
if (albumId) {
uploadAssetsStore.updateAsset(deviceAssetId, { message: $t('asset_adding_to_album') });
uploadAssetsStore.updateItem(deviceAssetId, { message: $t('asset_adding_to_album') });
await addAssetsToAlbum(albumId, [responseData.id], false);
uploadAssetsStore.updateAsset(deviceAssetId, { message: $t('asset_added_to_album') });
uploadAssetsStore.updateItem(deviceAssetId, { message: $t('asset_added_to_album') });
}
uploadAssetsStore.updateAsset(deviceAssetId, {
uploadAssetsStore.updateItem(deviceAssetId, {
state: responseData.status === AssetMediaStatus.Duplicate ? UploadState.DUPLICATED : UploadState.DONE,
assetId: responseData.id,
});
setTimeout(() => {
uploadAssetsStore.removeUploadAsset(deviceAssetId);
}, 1000);
if (responseData.status !== AssetMediaStatus.Duplicate) {
setTimeout(() => {
uploadAssetsStore.removeItem(deviceAssetId);
}, 1000);
}
return responseData.id;
} catch (error) {
handleError(error, $t('errors.unable_to_upload_file'));
const reason = getServerErrorMessage(error) || error;
uploadAssetsStore.updateAsset(deviceAssetId, { state: UploadState.ERROR, error: reason });
uploadAssetsStore.track('error');
uploadAssetsStore.updateItem(deviceAssetId, { state: UploadState.ERROR, error: reason });
return;
}
}