Files
chitai/frontend/src/lib/components/layout/upload-tray.svelte
T
patrick 96789620bb feat: run book uploads in a background tray
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.
2026-08-12 13:42:40 -04:00

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}