bea settings
Build and Push Docker Image / build (push) Successful in 54s

This commit is contained in:
2026-08-27 18:15:22 +02:00
parent 367f34229c
commit 57b38fe2c7
36 changed files with 1159 additions and 328 deletions
@@ -0,0 +1,230 @@
<script lang="ts">
import { Download, FilePlus2, Plus, Settings2, Trash2 } from '@lucide/svelte';
import Button from '$lib/components/ui/button.svelte';
import * as AlertDialog from '$lib/components/ui/alert-dialog/index.js';
import * as Popover from '$lib/components/ui/popover/index.js';
import { Slider } from '$lib/components/ui/slider/index.js';
import { Separator } from '$lib/components/ui/separator/index.js';
import ZipFilePicker from '$lib/components/ZipFilePicker.svelte';
type Props = {
selectedZipCount: number;
processedArchiveCount: number;
pendingZipCount: number;
thumbnailWidth: number;
onThumbnailWidthChange: (value: number) => void;
/** Replaces the current workspace with the confirmed files. */
onRequestReplacement: (files: File[]) => void | Promise<void>;
/** Appends the files to the current workspace. */
onAppendFiles: (files: File[]) => void;
onExportAll: () => void | Promise<void>;
onClear: () => void | Promise<void>;
};
let {
selectedZipCount,
processedArchiveCount,
pendingZipCount,
thumbnailWidth,
onThumbnailWidthChange,
onRequestReplacement,
onAppendFiles,
onExportAll,
onClear
}: Props = $props();
// Replacement files stay in temporary component state until the user confirms;
// cancelling the confirmation must never touch the current workspace.
let pendingReplacementFiles: File[] | null = $state(null);
let clearDialogOpen = $state(false);
let isCommitting = $state(false);
let isExporting = $state(false);
const statusText = $derived.by(() => {
const zipText = `${selectedZipCount} ${selectedZipCount === 1 ? 'ZIP' : 'ZIPs'}`;
const archiveText = `${processedArchiveCount} ${
processedArchiveCount === 1 ? 'Archiv' : 'Archive'
} geöffnet`;
const pendingText =
pendingZipCount > 0
? ` · ${pendingZipCount} ${pendingZipCount === 1 ? 'wird' : 'werden'} geöffnet`
: '';
return `${zipText} · ${archiveText}${pendingText}`;
});
const commit = async (action: () => void | Promise<void>) => {
isCommitting = true;
try {
await action();
} finally {
isCommitting = false;
}
};
const confirmReplacement = () => {
const files = pendingReplacementFiles;
pendingReplacementFiles = null;
if (!files || files.length === 0) return;
void commit(() => onRequestReplacement(files));
};
const confirmClear = () => {
clearDialogOpen = false;
void commit(onClear);
};
const exportAll = async () => {
isExporting = true;
try {
await onExportAll();
} finally {
isExporting = false;
}
};
</script>
<div
class="flex w-full flex-col gap-3 border-b border-primary-100 pb-4 sm:flex-row sm:items-center sm:justify-between"
>
<p class="text-sm font-bold text-primary-700" aria-live="polite">
{statusText}
</p>
<div class="flex flex-wrap items-center gap-2">
<ZipFilePicker
pickerId="replace-zip"
ariaLabel="Neue ZIP bearbeiten"
disabled={isCommitting}
onFilesSelected={(files) => (pendingReplacementFiles = files)}
>
<FilePlus2 class="size-4" aria-hidden="true" />
Neue ZIP bearbeiten
</ZipFilePicker>
<Popover.Root>
<Popover.Trigger aria-label="Einstellungen">
{#snippet child({ props })}
<Button variant="bordered" {...props}>
<Settings2 class="size-4" aria-hidden="true" />
<span class="hidden sm:inline">Einstellungen</span>
</Button>
{/snippet}
</Popover.Trigger>
<Popover.Content
align="end"
sideOffset={8}
class="w-[min(22rem,calc(100vw-2rem))] gap-4 rounded-2xl border border-primary-100 p-4 shadow-lg ring-0"
>
<span class="sr-only">Einstellungen</span>
<div class="flex flex-col gap-3">
<span class="text-[12px] font-bold uppercase tracking-wide text-primary-700">
Ansicht
</span>
<div class="flex items-center justify-between gap-3 text-sm font-bold text-primary-900">
<span>Vorschaugröße</span>
<span class="tabular-nums text-primary-700">{thumbnailWidth} px</span>
</div>
<Slider
type="single"
thumbAriaLabel="Vorschaugröße"
min={100}
max={400}
step={1}
value={thumbnailWidth}
onValueChange={(value: number | number[]) => {
if (typeof value === 'number') onThumbnailWidthChange(value);
}}
/>
<div
class="flex justify-between text-[12px] font-medium text-primary-700"
aria-hidden="true"
>
<span>Klein</span>
<span>Groß</span>
</div>
</div>
<div class="flex flex-col gap-2">
<span class="text-[12px] font-bold uppercase tracking-wide text-primary-700">
Archiv-Aktionen
</span>
<ZipFilePicker
pickerId="append-zip"
ariaLabel="Weitere ZIP hinzufügen"
variant="bordered"
disabled={isCommitting}
class="w-full justify-start"
onFilesSelected={onAppendFiles}
>
<Plus class="size-4" aria-hidden="true" />
Weitere ZIP hinzufügen
</ZipFilePicker>
{#if processedArchiveCount >= 2}
<Button
variant="bordered"
disabled={isExporting || isCommitting}
class="w-full justify-start"
onclick={() => void exportAll()}
>
<Download class="size-4" aria-hidden="true" />
Alle Archive als PDF herunterladen
</Button>
{/if}
<Separator class="my-1" />
<Button
variant="tertiary"
disabled={isCommitting}
class="w-full justify-start text-destructive hover:bg-destructive/10 hover:text-destructive"
onclick={() => (clearDialogOpen = true)}
>
<Trash2 class="size-4" aria-hidden="true" />
Aktuelle Bearbeitung leeren
</Button>
</div>
</Popover.Content>
</Popover.Root>
</div>
</div>
<AlertDialog.Root
open={pendingReplacementFiles !== null}
onOpenChange={(open) => {
if (!open) pendingReplacementFiles = null;
}}
>
<AlertDialog.Content class="max-w-sm">
<AlertDialog.Title class="text-base font-bold text-primary-900">
Aktuelle Bearbeitung ersetzen?
</AlertDialog.Title>
<AlertDialog.Description>
Archive und nicht heruntergeladene Änderungen in dieser Ansicht werden entfernt. Bereits
heruntergeladene PDFs bleiben erhalten.
</AlertDialog.Description>
<AlertDialog.Footer>
<AlertDialog.Cancel>Abbrechen</AlertDialog.Cancel>
<AlertDialog.Action onclick={confirmReplacement}>Neue ZIP öffnen</AlertDialog.Action>
</AlertDialog.Footer>
</AlertDialog.Content>
</AlertDialog.Root>
<AlertDialog.Root bind:open={clearDialogOpen}>
<AlertDialog.Content class="max-w-sm">
<AlertDialog.Title class="text-base font-bold text-primary-900">
Aktuelle Bearbeitung leeren?
</AlertDialog.Title>
<AlertDialog.Description>
Alle Archive und nicht heruntergeladene Änderungen in dieser Ansicht werden entfernt. Bereits
heruntergeladene PDFs bleiben erhalten.
</AlertDialog.Description>
<AlertDialog.Footer>
<AlertDialog.Cancel>Abbrechen</AlertDialog.Cancel>
<AlertDialog.Action onclick={confirmClear}>Bearbeitung leeren</AlertDialog.Action>
</AlertDialog.Footer>
</AlertDialog.Content>
</AlertDialog.Root>
+2 -1
View File
@@ -1,5 +1,6 @@
<script lang="ts">
import FileDropzone from '$lib/components/FileDropzone.svelte';
import { ZIP_ACCEPT_TYPES } from '$lib/zip-selection';
type Props = {
onFilesSelected: (files: File[]) => void;
@@ -11,7 +12,7 @@
<FileDropzone
{onFilesSelected}
accept=".zip,application/zip,application/x-zip-compressed"
accept={ZIP_ACCEPT_TYPES}
multiple={true}
ariaLabel="beA-ZIP-Dateien hinzufügen"
label="Dateien hinzufügen"
+62
View File
@@ -0,0 +1,62 @@
<script lang="ts">
import Button from '$lib/components/ui/button.svelte';
import type { ButtonVariant } from '$lib/components/ui/button.svelte';
import { isAcceptedZipFile, ZIP_ACCEPT_TYPES } from '$lib/zip-selection';
import type { Snippet } from 'svelte';
type Props = {
/** Identifies the hidden input for tests, e.g. `replace-zip` or `append-zip`. */
pickerId: string;
ariaLabel: string;
onFilesSelected: (files: File[]) => void;
children: Snippet;
multiple?: boolean;
disabled?: boolean;
variant?: ButtonVariant;
class?: string;
};
let {
pickerId,
ariaLabel,
onFilesSelected,
children,
multiple = true,
disabled = false,
variant = 'primary',
class: className = ''
}: Props = $props();
let fileInput = $state<HTMLInputElement | null>(null);
const handleFileSelection = (event: Event) => {
const input = event.currentTarget as HTMLInputElement;
const files = Array.from(input.files ?? []).filter(isAcceptedZipFile);
// Reset first so selecting the same file again still fires `change`.
input.value = '';
if (files.length === 0) return;
onFilesSelected(multiple ? files : files.slice(0, 1));
};
</script>
<input
bind:this={fileInput}
class="hidden"
type="file"
accept={ZIP_ACCEPT_TYPES}
{multiple}
data-zip-picker={pickerId}
onchange={handleFileSelection}
/>
<Button
{variant}
{disabled}
aria-label={ariaLabel}
class={className}
onclick={() => fileInput?.click()}
>
{@render children()}
</Button>
@@ -0,0 +1,27 @@
<script lang="ts">
import { AlertDialog as AlertDialogPrimitive } from 'bits-ui';
import {
buttonVariants,
type ButtonVariant,
type ButtonSize
} from '$lib/components/ui/button.svelte';
import { cn } from '$lib/utils.js';
let {
ref = $bindable(null),
class: className,
variant = 'primary' as ButtonVariant,
size = 'standard' as ButtonSize,
...restProps
}: AlertDialogPrimitive.ActionProps & {
variant?: ButtonVariant;
size?: ButtonSize;
} = $props();
</script>
<AlertDialogPrimitive.Action
bind:ref
data-slot="alert-dialog-action"
class={cn(buttonVariants({ variant, size }), 'cn-alert-dialog-action', className)}
{...restProps}
/>
@@ -0,0 +1,27 @@
<script lang="ts">
import { AlertDialog as AlertDialogPrimitive } from 'bits-ui';
import {
buttonVariants,
type ButtonVariant,
type ButtonSize
} from '$lib/components/ui/button.svelte';
import { cn } from '$lib/utils.js';
let {
ref = $bindable(null),
class: className,
variant = 'bordered' as ButtonVariant,
size = 'standard' as ButtonSize,
...restProps
}: AlertDialogPrimitive.CancelProps & {
variant?: ButtonVariant;
size?: ButtonSize;
} = $props();
</script>
<AlertDialogPrimitive.Cancel
bind:ref
data-slot="alert-dialog-cancel"
class={cn(buttonVariants({ variant, size }), 'cn-alert-dialog-cancel', className)}
{...restProps}
/>
@@ -0,0 +1,32 @@
<script lang="ts">
import { AlertDialog as AlertDialogPrimitive } from 'bits-ui';
import { cn, type WithoutChild, type WithoutChildrenOrChild } from '$lib/utils.js';
import AlertDialogOverlay from './alert-dialog-overlay.svelte';
import AlertDialogPortal from './alert-dialog-portal.svelte';
import type { ComponentProps } from 'svelte';
let {
ref = $bindable(null),
class: className,
size = 'default',
portalProps,
...restProps
}: WithoutChild<AlertDialogPrimitive.ContentProps> & {
size?: 'default' | 'sm';
portalProps?: WithoutChildrenOrChild<ComponentProps<typeof AlertDialogPortal>>;
} = $props();
</script>
<AlertDialogPortal {...portalProps}>
<AlertDialogOverlay />
<AlertDialogPrimitive.Content
bind:ref
data-slot="alert-dialog-content"
data-size={size}
class={cn(
'gap-4 rounded-xl bg-popover p-4 text-popover-foreground ring-1 ring-foreground/10 duration-100 data-[size=default]:max-w-xs data-[size=sm]:max-w-xs data-[size=default]:sm:max-w-sm data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95 group/alert-dialog-content fixed top-1/2 left-1/2 z-50 grid w-full -translate-x-1/2 -translate-y-1/2 outline-none',
className
)}
{...restProps}
/>
</AlertDialogPortal>
@@ -0,0 +1,20 @@
<script lang="ts">
import { AlertDialog as AlertDialogPrimitive } from 'bits-ui';
import { cn } from '$lib/utils.js';
let {
ref = $bindable(null),
class: className,
...restProps
}: AlertDialogPrimitive.DescriptionProps = $props();
</script>
<AlertDialogPrimitive.Description
bind:ref
data-slot="alert-dialog-description"
class={cn(
'text-sm text-balance text-muted-foreground md:text-pretty *:[a]:underline *:[a]:underline-offset-3 *:[a]:hover:text-foreground',
className
)}
{...restProps}
/>
@@ -0,0 +1,23 @@
<script lang="ts">
import { cn, type WithElementRef } from '$lib/utils.js';
import type { HTMLAttributes } from 'svelte/elements';
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
</script>
<div
bind:this={ref}
data-slot="alert-dialog-footer"
class={cn(
'-mx-4 -mb-4 rounded-b-xl border-t bg-muted/50 p-4 flex flex-col-reverse gap-2 group-data-[size=sm]/alert-dialog-content:grid group-data-[size=sm]/alert-dialog-content:grid-cols-2 sm:flex-row sm:justify-end',
className
)}
{...restProps}
>
{@render children?.()}
</div>
@@ -0,0 +1,23 @@
<script lang="ts">
import { cn, type WithElementRef } from '$lib/utils.js';
import type { HTMLAttributes } from 'svelte/elements';
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
</script>
<div
bind:this={ref}
data-slot="alert-dialog-header"
class={cn(
'grid grid-rows-[auto_1fr] place-items-center gap-1.5 text-center has-data-[slot=alert-dialog-media]:grid-rows-[auto_auto_1fr] has-data-[slot=alert-dialog-media]:gap-x-4 sm:group-data-[size=default]/alert-dialog-content:place-items-start sm:group-data-[size=default]/alert-dialog-content:text-left sm:group-data-[size=default]/alert-dialog-content:has-data-[slot=alert-dialog-media]:grid-rows-[auto_1fr]',
className
)}
{...restProps}
>
{@render children?.()}
</div>
@@ -0,0 +1,23 @@
<script lang="ts">
import { cn, type WithElementRef } from '$lib/utils.js';
import type { HTMLAttributes } from 'svelte/elements';
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
</script>
<div
bind:this={ref}
data-slot="alert-dialog-media"
class={cn(
"mb-2 inline-flex size-10 items-center justify-center rounded-md bg-muted sm:group-data-[size=default]/alert-dialog-content:row-span-2 *:[svg:not([class*='size-'])]:size-6",
className
)}
{...restProps}
>
{@render children?.()}
</div>
@@ -0,0 +1,20 @@
<script lang="ts">
import { AlertDialog as AlertDialogPrimitive } from 'bits-ui';
import { cn } from '$lib/utils.js';
let {
ref = $bindable(null),
class: className,
...restProps
}: AlertDialogPrimitive.OverlayProps = $props();
</script>
<AlertDialogPrimitive.Overlay
bind:ref
data-slot="alert-dialog-overlay"
class={cn(
'bg-black/10 duration-100 supports-backdrop-filter:backdrop-blur-xs data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0 fixed inset-0 z-50',
className
)}
{...restProps}
/>
@@ -0,0 +1,7 @@
<script lang="ts">
import { AlertDialog as AlertDialogPrimitive } from 'bits-ui';
let { ...restProps }: AlertDialogPrimitive.PortalProps = $props();
</script>
<AlertDialogPrimitive.Portal {...restProps} />
@@ -0,0 +1,20 @@
<script lang="ts">
import { AlertDialog as AlertDialogPrimitive } from 'bits-ui';
import { cn } from '$lib/utils.js';
let {
ref = $bindable(null),
class: className,
...restProps
}: AlertDialogPrimitive.TitleProps = $props();
</script>
<AlertDialogPrimitive.Title
bind:ref
data-slot="alert-dialog-title"
class={cn(
'text-base font-medium sm:group-data-[size=default]/alert-dialog-content:group-has-data-[slot=alert-dialog-media]/alert-dialog-content:col-start-2',
className
)}
{...restProps}
/>
@@ -0,0 +1,7 @@
<script lang="ts">
import { AlertDialog as AlertDialogPrimitive } from 'bits-ui';
let { ref = $bindable(null), ...restProps }: AlertDialogPrimitive.TriggerProps = $props();
</script>
<AlertDialogPrimitive.Trigger bind:ref data-slot="alert-dialog-trigger" {...restProps} />
@@ -0,0 +1,7 @@
<script lang="ts">
import { AlertDialog as AlertDialogPrimitive } from 'bits-ui';
let { open = $bindable(false), ...restProps }: AlertDialogPrimitive.RootProps = $props();
</script>
<AlertDialogPrimitive.Root bind:open {...restProps} />
@@ -0,0 +1,40 @@
import Action from './alert-dialog-action.svelte';
import Cancel from './alert-dialog-cancel.svelte';
import Content from './alert-dialog-content.svelte';
import Description from './alert-dialog-description.svelte';
import Footer from './alert-dialog-footer.svelte';
import Header from './alert-dialog-header.svelte';
import Media from './alert-dialog-media.svelte';
import Overlay from './alert-dialog-overlay.svelte';
import Portal from './alert-dialog-portal.svelte';
import Title from './alert-dialog-title.svelte';
import Trigger from './alert-dialog-trigger.svelte';
import Root from './alert-dialog.svelte';
export {
Root,
Title,
Action,
Cancel,
Portal,
Footer,
Header,
Trigger,
Overlay,
Content,
Description,
Media,
//
Root as AlertDialog,
Title as AlertDialogTitle,
Action as AlertDialogAction,
Cancel as AlertDialogCancel,
Portal as AlertDialogPortal,
Footer as AlertDialogFooter,
Header as AlertDialogHeader,
Trigger as AlertDialogTrigger,
Overlay as AlertDialogOverlay,
Content as AlertDialogContent,
Description as AlertDialogDescription,
Media as AlertDialogMedia
};
+28
View File
@@ -0,0 +1,28 @@
import Close from './popover-close.svelte';
import Content from './popover-content.svelte';
import Description from './popover-description.svelte';
import Header from './popover-header.svelte';
import Portal from './popover-portal.svelte';
import Title from './popover-title.svelte';
import Trigger from './popover-trigger.svelte';
import Root from './popover.svelte';
export {
Root,
Content,
Description,
Header,
Title,
Trigger,
Close,
Portal,
//
Root as Popover,
Content as PopoverContent,
Description as PopoverDescription,
Header as PopoverHeader,
Title as PopoverTitle,
Trigger as PopoverTrigger,
Close as PopoverClose,
Portal as PopoverPortal
};
@@ -0,0 +1,7 @@
<script lang="ts">
import { Popover as PopoverPrimitive } from 'bits-ui';
let { ref = $bindable(null), ...restProps }: PopoverPrimitive.CloseProps = $props();
</script>
<PopoverPrimitive.Close bind:ref data-slot="popover-close" {...restProps} />
@@ -0,0 +1,31 @@
<script lang="ts">
import { Popover as PopoverPrimitive } from 'bits-ui';
import { cn, type WithoutChildrenOrChild } from '$lib/utils.js';
import PopoverPortal from './popover-portal.svelte';
import type { ComponentProps } from 'svelte';
let {
ref = $bindable(null),
class: className,
sideOffset = 4,
align = 'center',
portalProps,
...restProps
}: PopoverPrimitive.ContentProps & {
portalProps?: WithoutChildrenOrChild<ComponentProps<typeof PopoverPortal>>;
} = $props();
</script>
<PopoverPortal {...portalProps}>
<PopoverPrimitive.Content
bind:ref
data-slot="popover-content"
{sideOffset}
{align}
class={cn(
'flex flex-col gap-2.5 rounded-lg bg-popover p-2.5 text-sm text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 z-50 w-72 origin-(--transform-origin) outline-hidden',
className
)}
{...restProps}
/>
</PopoverPortal>
@@ -0,0 +1,20 @@
<script lang="ts">
import { cn, type WithElementRef } from '$lib/utils.js';
import type { HTMLAttributes } from 'svelte/elements';
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
</script>
<div
bind:this={ref}
data-slot="popover-description"
class={cn('text-muted-foreground', className)}
{...restProps}
>
{@render children?.()}
</div>
@@ -0,0 +1,20 @@
<script lang="ts">
import { cn, type WithElementRef } from '$lib/utils.js';
import type { HTMLAttributes } from 'svelte/elements';
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
</script>
<div
bind:this={ref}
data-slot="popover-header"
class={cn('flex flex-col gap-0.5 text-sm', className)}
{...restProps}
>
{@render children?.()}
</div>
@@ -0,0 +1,7 @@
<script lang="ts">
import { Popover as PopoverPrimitive } from 'bits-ui';
let { ...restProps }: PopoverPrimitive.PortalProps = $props();
</script>
<PopoverPrimitive.Portal {...restProps} />
@@ -0,0 +1,15 @@
<script lang="ts">
import { cn, type WithElementRef } from '$lib/utils.js';
import type { HTMLAttributes } from 'svelte/elements';
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
</script>
<div bind:this={ref} data-slot="popover-title" class={cn('font-medium', className)} {...restProps}>
{@render children?.()}
</div>
@@ -0,0 +1,17 @@
<script lang="ts">
import { Popover as PopoverPrimitive } from 'bits-ui';
import { cn } from '$lib/utils.js';
let {
ref = $bindable(null),
class: className,
...restProps
}: PopoverPrimitive.TriggerProps = $props();
</script>
<PopoverPrimitive.Trigger
bind:ref
data-slot="popover-trigger"
class={cn('', className)}
{...restProps}
/>
@@ -0,0 +1,7 @@
<script lang="ts">
import { Popover as PopoverPrimitive } from 'bits-ui';
let { open = $bindable(false), ...restProps }: PopoverPrimitive.RootProps = $props();
</script>
<PopoverPrimitive.Root bind:open {...restProps} />
+7
View File
@@ -0,0 +1,7 @@
import Root from './separator.svelte';
export {
Root,
//
Root as Separator
};
@@ -0,0 +1,23 @@
<script lang="ts">
import { Separator as SeparatorPrimitive } from 'bits-ui';
import { cn } from '$lib/utils.js';
let {
ref = $bindable(null),
class: className,
'data-slot': dataSlot = 'separator',
...restProps
}: SeparatorPrimitive.RootProps = $props();
</script>
<SeparatorPrimitive.Root
bind:ref
data-slot={dataSlot}
class={cn(
'shrink-0 bg-border data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:w-px',
// this is different in shadcn/ui but self-stretch breaks things for us
'data-[orientation=vertical]:h-full',
className
)}
{...restProps}
/>
+7
View File
@@ -0,0 +1,7 @@
import Root from './slider.svelte';
export {
Root,
//
Root as Slider
};
@@ -0,0 +1,57 @@
<script lang="ts">
import { Slider as SliderPrimitive } from 'bits-ui';
import { cn, type WithoutChildrenOrChild } from '$lib/utils.js';
let {
ref = $bindable(null),
value = $bindable(),
orientation = 'horizontal',
thumbId = undefined,
thumbAriaLabel = undefined,
class: className,
...restProps
}: WithoutChildrenOrChild<SliderPrimitive.RootProps> & {
thumbId?: string | undefined;
thumbAriaLabel?: string | undefined;
} = $props();
</script>
<!--
Discriminated Unions + Destructing (required for bindable) do not
get along, so we shut typescript up by casting `value` to `never`.
-->
<SliderPrimitive.Root
bind:ref
bind:value={value as never}
data-slot="slider"
{orientation}
class={cn(
'data-vertical:min-h-40 relative flex w-full touch-none items-center select-none data-disabled:opacity-50 data-vertical:h-full data-vertical:w-auto data-vertical:flex-col',
className
)}
{...restProps}
>
{#snippet children({ thumbItems })}
<span
data-slot="slider-track"
data-orientation={orientation}
class={cn(
'rounded-full bg-muted data-horizontal:h-1 data-horizontal:w-full data-vertical:h-full data-vertical:w-1 relative grow overflow-hidden bg-muted data-horizontal:w-full data-vertical:h-full'
)}
>
<SliderPrimitive.Range
data-slot="slider-range"
class={cn('bg-primary absolute select-none data-horizontal:h-full data-vertical:w-full')}
/>
</span>
{#each thumbItems as thumb (thumb.index)}
<SliderPrimitive.Thumb
data-slot="slider-thumb"
index={thumb.index}
id={thumbId}
aria-label={thumbAriaLabel}
class="relative size-3 rounded-full border border-ring bg-white ring-ring/50 transition-[color,box-shadow] after:absolute after:-inset-2 hover:ring-3 focus-visible:ring-3 focus-visible:outline-hidden active:ring-3 block shrink-0 select-none disabled:pointer-events-none disabled:opacity-50"
/>
{/each}
{/snippet}
</SliderPrimitive.Root>
+6
View File
@@ -0,0 +1,6 @@
export const ZIP_ACCEPT_TYPES = '.zip,application/zip,application/x-zip-compressed';
export const isAcceptedZipFile = (file: File): boolean => {
if (file.name.toLowerCase().endsWith('.zip')) return true;
return ['application/zip', 'application/x-zip-compressed'].includes(file.type.toLowerCase());
};
+22 -59
View File
@@ -1,6 +1,6 @@
<script lang="ts">
import { onMount } from 'svelte';
import Button from '$lib/components/ui/button.svelte';
import BeaWorkspaceControls from '$lib/components/BeaWorkspaceControls.svelte';
import ProcessedZipArchiveEditor from '$lib/components/ProcessedZipArchiveEditor.svelte';
import ZipDropzone from '$lib/components/ZipDropzone.svelte';
import BeaArchiveProcessing, {
@@ -131,12 +131,19 @@
}
};
const deleteAllZipArchives = () => {
const resetWorkspace = () => {
archiveGeneration += 1;
selectedZipFiles = [];
processedZipFiles = [];
zipJobs = [];
};
const replaceWithZipFiles = (files: File[]) => {
resetWorkspace();
// handleFilesSelected captures the incremented archiveGeneration, so results
// from the previous workspace can no longer append.
handleFilesSelected(files);
};
</script>
{#snippet content()}
@@ -146,63 +153,19 @@
<ZipDropzone onFilesSelected={handleFilesSelected} />
</Card.Content>
</Card.Root>
{:else}
<details class="fixed bottom-4 right-4 z-20">
<summary
class="flex h-12 w-12 cursor-pointer list-none items-center justify-center rounded-full border border-primary-200 bg-white text-lg font-semibold text-primary-800 shadow-lg transition hover:border-primary-300 hover:bg-primary-50"
>
<span class="sr-only">Settings</span>
<span>⚙️</span>
</summary>
<div
class="absolute bottom-14 right-0 w-72 rounded-xl border border-primary-100 bg-white p-4 shadow-lg"
>
<div class="flex flex-col gap-3">
<div class="flex gap-2">
<Button
class="flex-1"
type="button"
disabled={processedZipFiles.length === 0}
onclick={exportAllZipArchivesAsPdf}
>
Alle ZIP-Archive als PDF exportieren
</Button>
<Button
class="flex-1"
type="button"
disabled={selectedZipFiles.length === 0}
variant="secondary"
onclick={deleteAllZipArchives}
>
Alle ZIP-Archive löschen
</Button>
</div>
<label class="flex flex-col gap-2">
<div
class="flex items-center justify-between gap-3 text-sm font-medium text-primary-900"
>
<span>Thumbnail Breite</span>
<span class="tabular-nums text-primary-700">{thumbnailWidth}px</span>
</div>
<input
class="w-full accent-primary-700"
type="range"
min="100"
max="400"
step="1"
bind:value={thumbnailWidth}
/>
</label>
</div>
</div>
</details>
<div
class="flex min-h-[calc(-180px+100vh)] w-full grow flex-col justify-center gap-4 px-4 py-6"
>
{:else if zipJobs.length > 0}
<div class="flex w-full grow flex-col gap-4 px-4 py-6">
<BeaWorkspaceControls
selectedZipCount={selectedZipFiles.length}
processedArchiveCount={processedZipFiles.length}
{pendingZipCount}
{thumbnailWidth}
onThumbnailWidthChange={(value) => (thumbnailWidth = value)}
onRequestReplacement={replaceWithZipFiles}
onAppendFiles={handleFilesSelected}
onExportAll={exportAllZipArchivesAsPdf}
onClear={resetWorkspace}
/>
<div class="mx-auto flex w-full flex-col gap-3">
{#if processedZipFiles.length === 0}
<BeaArchiveProcessing jobs={zipJobs} onRetry={retryZipFile} onRemove={removeZipFile} />