Files
bea-edit/src/lib/components/BeaArchiveProcessing.svelte
T
rehlert 367f34229c
Build and Push Docker Image / build (push) Successful in 41s
loading screen
2026-08-27 16:41:59 +02:00

333 lines
9.7 KiB
Svelte

<script lang="ts" module>
export type ZipProcessingJobStatus = 'processing' | 'complete' | 'error';
export type ZipProcessingJob = {
id: string;
file: File;
status: ZipProcessingJobStatus;
archiveCount?: number;
error?: string;
};
</script>
<script lang="ts">
import { Check, CircleDashed, TriangleAlert } from '@lucide/svelte';
import Button from '$lib/components/ui/button.svelte';
import * as Card from '$lib/components/ui/card/index';
type Props = {
jobs: ZipProcessingJob[];
compact?: boolean;
onRetry: (jobId: string) => void;
onRemove: (jobId: string) => void;
};
let { jobs, compact = false, onRetry, onRemove }: Props = $props();
let processingCount = $derived(jobs.filter((job) => job.status === 'processing').length);
let completedCount = $derived(jobs.filter((job) => job.status === 'complete').length);
let failedJobs = $derived(jobs.filter((job) => job.status === 'error'));
let completionPercent = $derived(jobs.length === 0 ? 0 : (completedCount / jobs.length) * 100);
const formatFileSize = (size: number) => {
if (size < 1024 * 1024) return `${Math.max(1, Math.round(size / 1024))} KB`;
return `${(size / (1024 * 1024)).toFixed(1)} MB`;
};
const archiveCountLabel = (count: number | undefined) => {
if (count === undefined) return 'Bereit';
return `${count} Archiv${count === 1 ? '' : 'e'} bereit`;
};
</script>
{#if compact}
<Card.Root size="sm" class="border-l-4 border-l-secondary">
<p class="sr-only" role="status" aria-live="polite">
{completedCount} von {jobs.length} Dateien bereit, {processingCount} in Bearbeitung,
{failedJobs.length} fehlgeschlagen.
</p>
<Card.Content class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div class="flex min-w-0 items-center gap-3">
<div class="relative grid size-10 shrink-0 place-items-center" aria-hidden="true">
<div class="compact-folder"></div>
{#if processingCount > 0}
<CircleDashed
class="absolute -right-0.5 -bottom-0.5 size-4 animate-spin text-secondary"
/>
{:else}
<TriangleAlert class="absolute -right-0.5 -bottom-0.5 size-4 text-destructive" />
{/if}
</div>
<div class="min-w-0">
<p class="font-bold text-primary-900">
{#if processingCount > 0}
{processingCount} ZIP-Datei{processingCount === 1 ? '' : 'en'}
{processingCount === 1 ? ' wird' : ' werden'} noch geöffnet
{:else}
{failedJobs.length} ZIP-Datei{failedJobs.length === 1 ? '' : 'en'} konnte{failedJobs.length ===
1
? ''
: 'n'} nicht geöffnet werden
{/if}
</p>
<p class="truncate text-[13px] text-primary-700">
{jobs
.filter((job) =>
processingCount > 0 ? job.status === 'processing' : job.status === 'error'
)
.map((job) => job.file.name)
.join(', ')}
</p>
</div>
</div>
<p class="shrink-0 text-[12px] font-medium text-primary-700">
Verarbeitung nur in Ihrem Browser
</p>
</Card.Content>
{#if failedJobs.length > 0}
<Card.Footer class="flex flex-col items-stretch gap-2">
{#each failedJobs as job (job.id)}
<div class="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<p class="min-w-0 text-[13px] font-medium text-destructive">
<span class="font-bold">{job.file.name}</span> konnte nicht geöffnet werden.
</p>
<div class="flex shrink-0 gap-1">
<Button variant="tertiary" class="h-8 min-w-0 px-3" onclick={() => onRetry(job.id)}
>Erneut versuchen</Button
>
<Button variant="tertiary" class="h-8 min-w-0 px-3" onclick={() => onRemove(job.id)}
>Entfernen</Button
>
</div>
</div>
{/each}
</Card.Footer>
{/if}
</Card.Root>
{:else}
<Card.Root class="mx-auto w-full max-w-2xl shadow-sm">
<p class="sr-only" role="status" aria-live="polite">
{completedCount} von {jobs.length} Dateien bereit, {processingCount} in Bearbeitung,
{failedJobs.length} fehlgeschlagen.
</p>
<Card.Header class="place-items-center px-6 pt-4 text-center sm:px-10">
<div class="case-intake" aria-hidden="true">
<div class="folder-back"></div>
<div class="document-sheet">
<span></span>
<span></span>
<span></span>
</div>
<div class="folder-front"></div>
</div>
<Card.Title class="mt-3 text-[22px] font-extrabold text-primary-900">
{#if processingCount > 0}
beA-Archive werden vorbereitet
{:else if failedJobs.length > 0 && completedCount === 0}
Archive konnten nicht vorbereitet werden
{:else}
Archive sind bereit
{/if}
</Card.Title>
<Card.Description class="max-w-md font-medium text-primary-700">
Die Dateien werden ausschließlich in Ihrem Browser geöffnet und nicht hochgeladen.
</Card.Description>
</Card.Header>
<Card.Content class="flex flex-col gap-4 px-6 sm:px-10">
<div class="flex flex-col gap-2">
<div class="flex items-center justify-between gap-4 text-[13px] font-bold text-primary-900">
<span>{completedCount} von {jobs.length} Dateien bereit</span>
{#if processingCount > 0}
<span class="font-medium text-primary-700">Bitte kurz warten</span>
{/if}
</div>
<div
class="h-1.5 overflow-hidden rounded-full bg-primary-100"
role="progressbar"
aria-label="Vorbereitete ZIP-Dateien"
aria-valuemin="0"
aria-valuemax={jobs.length}
aria-valuenow={completedCount}
>
<div
class="h-full rounded-full bg-success transition-[width] duration-500"
style:width={`${completionPercent}%`}
></div>
</div>
</div>
<ul class="flex flex-col gap-2" aria-label="Status der ZIP-Dateien">
{#each jobs as job (job.id)}
<li
class="grid grid-cols-[auto_minmax(0,1fr)] items-center gap-3 rounded-xl border bg-background/55 px-3 py-3 sm:grid-cols-[auto_minmax(0,1fr)_auto]"
>
<div class="grid size-8 place-items-center rounded-full bg-card" aria-hidden="true">
{#if job.status === 'processing'}
<CircleDashed class="size-5 animate-spin text-secondary" />
{:else if job.status === 'complete'}
<Check class="size-5 text-success" />
{:else}
<TriangleAlert class="size-5 text-destructive" />
{/if}
</div>
<div class="min-w-0">
<p class="truncate text-[14px] font-bold text-primary-900">{job.file.name}</p>
<p class="job-meta text-[12px] text-primary-700">{formatFileSize(job.file.size)}</p>
{#if job.status === 'error'}
<p class="mt-1 text-[12px] leading-snug text-destructive">
<span class="font-bold">Konnte nicht geöffnet werden.</span>
<span class="block">
{job.error ?? 'Prüfen Sie, ob die Datei ein gültiges beA-ZIP-Archiv ist.'}
</span>
</p>
{/if}
</div>
<div
class="col-start-2 flex flex-wrap items-center gap-1 sm:col-start-3 sm:row-start-1"
>
{#if job.status === 'processing'}
<span class="text-[12px] font-bold text-secondary">Wird geöffnet</span>
{:else if job.status === 'complete'}
<span class="text-[12px] font-bold text-success">
{archiveCountLabel(job.archiveCount)}
</span>
{:else}
<Button variant="tertiary" class="h-8 min-w-0 px-3" onclick={() => onRetry(job.id)}
>Erneut versuchen</Button
>
<Button variant="tertiary" class="h-8 min-w-0 px-3" onclick={() => onRemove(job.id)}
>Entfernen</Button
>
{/if}
</div>
</li>
{/each}
</ul>
</Card.Content>
</Card.Root>
{/if}
<style>
.job-meta {
font-family: 'Geist Variable', ui-monospace, monospace;
font-variant-numeric: tabular-nums;
}
.case-intake {
position: relative;
width: 7rem;
height: 5.5rem;
}
.folder-back,
.folder-front,
.compact-folder {
position: absolute;
background: color-mix(in srgb, var(--primary) 12%, white);
border: 2px solid var(--primary);
}
.folder-back {
inset: 1.55rem 0 0;
border-radius: 0.65rem 0.65rem 0.8rem 0.8rem;
}
.folder-back::before {
content: '';
position: absolute;
left: -2px;
top: -0.8rem;
width: 3.4rem;
height: 1rem;
border: 2px solid var(--primary);
border-bottom: 0;
border-radius: 0.55rem 0.55rem 0 0;
background: color-mix(in srgb, var(--primary) 12%, white);
}
.document-sheet {
position: absolute;
z-index: 1;
left: 50%;
top: 0.2rem;
display: flex;
width: 3.7rem;
height: 4.6rem;
transform: translateX(-50%);
flex-direction: column;
gap: 0.42rem;
border: 2px solid var(--primary);
border-radius: 0.35rem;
background: var(--card);
padding: 0.8rem 0.65rem;
box-shadow: 0 0.4rem 1rem rgb(18 47 98 / 12%);
animation: file-intake 1.8s ease-in-out infinite;
}
.document-sheet span {
display: block;
height: 0.18rem;
border-radius: 999px;
background: color-mix(in srgb, var(--primary) 30%, transparent);
}
.document-sheet span:nth-child(2) {
width: 72%;
}
.document-sheet span:nth-child(3) {
width: 88%;
background: var(--secondary);
}
.folder-front {
z-index: 2;
inset: 2.7rem 0 0;
border-radius: 0.45rem 0.45rem 0.8rem 0.8rem;
background: color-mix(in srgb, var(--primary) 18%, white);
transform: perspective(8rem) rotateX(-5deg);
transform-origin: bottom;
}
.compact-folder {
inset: 0.65rem 0.2rem 0.25rem;
border-radius: 0.35rem;
}
.compact-folder::before {
content: '';
position: absolute;
left: -2px;
top: -0.4rem;
width: 1rem;
height: 0.5rem;
border: 2px solid var(--primary);
border-bottom: 0;
border-radius: 0.25rem 0.25rem 0 0;
background: color-mix(in srgb, var(--primary) 12%, white);
}
@keyframes file-intake {
0%,
18% {
transform: translate(-50%, -0.45rem);
}
55%,
100% {
transform: translate(-50%, 0.65rem);
}
}
@media (prefers-reduced-motion: reduce) {
.document-sheet,
:global(.animate-spin) {
animation: none;
}
}
</style>