One request per book instead of one for everything, queued in state above the dialog so closing it no longer cancels the import. A docked tray reports each book and offers a retry; it clears itself after a clean run, stays put if anything failed, and holds while hovered. Also fixes the dialog overflowing on long filenames — Dialog.Content is a grid and its body needed min-w-0 to shrink below the content's intrinsic width.
120 lines
4.0 KiB
Svelte
120 lines
4.0 KiB
Svelte
<script lang="ts">
|
|
import { fly } from 'svelte/transition';
|
|
import { prefersReducedMotion } from 'svelte/motion';
|
|
import { ChevronDown, CircleAlert, RotateCcw, X } from '@lucide/svelte';
|
|
|
|
import { Button } from '$lib/components/ui/button/index.js';
|
|
import { Spinner } from '$lib/components/ui/spinner/index';
|
|
import { getUploadQueueState } from '$lib/state/upload-queue.svelte';
|
|
import { formatFileSize } from '$lib/utils';
|
|
|
|
const queue = getUploadQueueState();
|
|
|
|
// A clean run clears itself after a few seconds, so it needs to leave rather
|
|
// than blink out. Nothing to animate for anyone who asked not to see it.
|
|
const motion = $derived(prefersReducedMotion.current ? 0 : 200);
|
|
|
|
const percent = $derived(queue.total === 0 ? 0 : Math.round((queue.settled / queue.total) * 100));
|
|
|
|
const heading = $derived.by(() => {
|
|
const noun = queue.total === 1 ? 'book' : 'books';
|
|
if (queue.active) return `Adding ${queue.settled} of ${queue.total} ${noun}`;
|
|
if (queue.failed === 0) return `Added ${queue.done} ${noun}`;
|
|
if (queue.done === 0) return `Couldn't add ${queue.failed} ${noun}`;
|
|
return `Added ${queue.done}, ${queue.failed} failed`;
|
|
});
|
|
</script>
|
|
|
|
{#if queue.total > 0}
|
|
<!--
|
|
Docked rather than a toast. This runs for minutes and carries a row per
|
|
book, which a notification surface is not built to hold still for.
|
|
-->
|
|
<aside
|
|
aria-label="Uploads"
|
|
transition:fly={{ y: 12, duration: motion }}
|
|
onmouseenter={() => queue.hold()}
|
|
onmouseleave={() => queue.release()}
|
|
onfocusin={() => queue.hold()}
|
|
onfocusout={() => queue.release()}
|
|
class="fixed right-4 bottom-4 z-50 w-80 max-w-[calc(100vw-2rem)] overflow-hidden rounded-lg border bg-card shadow-lg"
|
|
>
|
|
<div class="flex items-center gap-2 border-b px-3 py-2">
|
|
{#if queue.active}
|
|
<Spinner class="size-4 shrink-0" />
|
|
{:else if queue.failed > 0}
|
|
<CircleAlert class="size-4 shrink-0 text-destructive" />
|
|
{/if}
|
|
|
|
<span class="min-w-0 flex-1 truncate text-sm font-medium">{heading}</span>
|
|
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
class="size-7 shrink-0"
|
|
onclick={() => (queue.collapsed = !queue.collapsed)}
|
|
>
|
|
<ChevronDown class="size-4 transition-transform {queue.collapsed ? '' : 'rotate-180'}" />
|
|
<span class="sr-only">{queue.collapsed ? 'Show' : 'Hide'} the list</span>
|
|
</Button>
|
|
|
|
<!-- Only once nothing is in flight: dismissing mid-run would suggest it
|
|
stopped the upload, which it does not. -->
|
|
{#if !queue.active}
|
|
<Button variant="ghost" size="icon" class="size-7 shrink-0" onclick={() => queue.dismiss()}>
|
|
<X class="size-4" />
|
|
<span class="sr-only">Dismiss</span>
|
|
</Button>
|
|
{/if}
|
|
</div>
|
|
|
|
<div class="h-1 bg-muted">
|
|
<div
|
|
class="h-full bg-primary transition-[width] duration-300"
|
|
style="width: {percent}%"
|
|
></div>
|
|
</div>
|
|
|
|
{#if !queue.collapsed}
|
|
<ul class="flex max-h-64 flex-col divide-y overflow-y-auto">
|
|
{#each queue.jobs as job (job.id)}
|
|
<li class="flex min-w-0 items-center gap-2 px-3 py-2">
|
|
<span class="min-w-0 flex-1">
|
|
<span class="block truncate text-sm" title={job.label}>{job.label}</span>
|
|
<span class="block font-mono text-[10px] text-muted-foreground tabular-nums">
|
|
{job.error ?? formatFileSize(job.size)}
|
|
</span>
|
|
</span>
|
|
|
|
<span
|
|
class="shrink-0 font-mono text-[10px] tracking-wider uppercase
|
|
{job.status === 'done' ? 'text-primary' : ''}
|
|
{job.status === 'failed' ? 'text-destructive' : ''}
|
|
{job.status !== 'done' && job.status !== 'failed' ? 'text-muted-foreground' : ''}"
|
|
>
|
|
{#if job.status === 'uploading'}
|
|
Adding
|
|
{:else if job.status === 'done'}
|
|
Done
|
|
{:else if job.status === 'failed'}
|
|
Failed
|
|
{:else}
|
|
Queued
|
|
{/if}
|
|
</span>
|
|
</li>
|
|
{/each}
|
|
</ul>
|
|
|
|
{#if !queue.active && queue.failed > 0}
|
|
<div class="border-t p-2">
|
|
<Button variant="outline" size="sm" class="w-full" onclick={() => queue.retryFailed()}>
|
|
<RotateCcw class="size-4" />
|
|
Retry {queue.failed} failed
|
|
</Button>
|
|
</div>
|
|
{/if}
|
|
{/if}
|
|
</aside>
|
|
{/if}
|