This commit is contained in:
@@ -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>
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
};
|
||||
@@ -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} />
|
||||
@@ -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}
|
||||
/>
|
||||
@@ -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>
|
||||
@@ -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());
|
||||
};
|
||||
@@ -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} />
|
||||
|
||||
Reference in New Issue
Block a user