diff --git a/web/src/lib/components/elements/icon.svelte b/web/src/lib/components/elements/icon.svelte index bb22276286..5965928718 100644 --- a/web/src/lib/components/elements/icon.svelte +++ b/web/src/lib/components/elements/icon.svelte @@ -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} diff --git a/web/src/lib/components/shared-components/upload-asset-preview.svelte b/web/src/lib/components/shared-components/upload-asset-preview.svelte index d3f12555c7..a7ba3430a0 100644 --- a/web/src/lib/components/shared-components/upload-asset-preview.svelte +++ b/web/src/lib/components/shared-components/upload-asset-preview.svelte @@ -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> diff --git a/web/src/lib/components/shared-components/upload-panel.svelte b/web/src/lib/components/shared-components/upload-panel.svelte index ee213d7969..d536053286 100644 --- a/web/src/lib/components/shared-components/upload-panel.svelte +++ b/web/src/lib/components/shared-components/upload-panel.svelte @@ -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 diff --git a/web/src/lib/models/upload-asset.ts b/web/src/lib/models/upload-asset.ts index c213f77e3e..eb8cb44c5e 100644 --- a/web/src/lib/models/upload-asset.ts +++ b/web/src/lib/models/upload-asset.ts @@ -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; diff --git a/web/src/lib/stores/upload.ts b/web/src/lib/stores/upload.ts index 16f967edb6..4bf597b932 100644 --- a/web/src/lib/stores/upload.ts +++ b/web/src/lib/stores/upload.ts @@ -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, }; } diff --git a/web/src/lib/utils/file-uploader.ts b/web/src/lib/utils/file-uploader.ts index 2d244e9bea..d7275c8aa3 100644 --- a/web/src/lib/utils/file-uploader.ts +++ b/web/src/lib/utils/file-uploader.ts @@ -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; } }