This commit is contained in:
@@ -67,11 +67,15 @@ src/
|
|||||||
│ ├── assets/ # Favicon and local Nunito fonts
|
│ ├── assets/ # Favicon and local Nunito fonts
|
||||||
│ ├── components/
|
│ ├── components/
|
||||||
│ │ ├── AttachmentPreview.svelte # Browser-rendered PDF/image thumbnails
|
│ │ ├── AttachmentPreview.svelte # Browser-rendered PDF/image thumbnails
|
||||||
|
│ │ ├── BeaArchiveProcessing.svelte
|
||||||
|
│ │ ├── BeaWorkspaceControls.svelte # beA workspace toolbar, settings popover, dialogs
|
||||||
│ │ ├── ProcessedZipArchiveEditor.svelte
|
│ │ ├── ProcessedZipArchiveEditor.svelte
|
||||||
│ │ ├── SubDocumentEditor.svelte
|
│ │ ├── SubDocumentEditor.svelte
|
||||||
│ │ ├── PdfDropzone.svelte # Shared PDF/image/ZIP file picker and drop target
|
│ │ ├── FileDropzone.svelte # Shared PDF/image/ZIP file picker and drop target
|
||||||
|
│ │ ├── ZipFilePicker.svelte # Compact ZIP picker button with hidden resettable input
|
||||||
│ │ ├── PdfPageGrid.svelte # Accessible PDF thumbnail selection grid
|
│ │ ├── PdfPageGrid.svelte # Accessible PDF thumbnail selection grid
|
||||||
│ │ ├── ZipDropzone.svelte # beA-specific wrapper around PdfDropzone
|
│ │ ├── RasterizationWarning.svelte
|
||||||
|
│ │ ├── ZipDropzone.svelte # beA-specific wrapper around FileDropzone
|
||||||
│ │ ├── icons/ # Project-specific SVG components
|
│ │ ├── icons/ # Project-specific SVG components
|
||||||
│ │ └── ui/ # Local shadcn-svelte component source
|
│ │ └── ui/ # Local shadcn-svelte component source
|
||||||
│ ├── services/
|
│ ├── services/
|
||||||
@@ -106,8 +110,26 @@ committed.
|
|||||||
`/tools/bea`; the file is not uploaded or persisted.
|
`/tools/bea`; the file is not uploaded or persisted.
|
||||||
- `tools/bea/+page.svelte` owns selected files, processed archives, pending work, and the
|
- `tools/bea/+page.svelte` owns selected files, processed archives, pending work, and the
|
||||||
thumbnail-width preference. Thumbnail width is the only persisted UI setting and uses localStorage.
|
thumbnail-width preference. Thumbnail width is the only persisted UI setting and uses localStorage.
|
||||||
|
- The loaded workspace renders `BeaWorkspaceControls.svelte`, a top-aligned toolbar with the
|
||||||
|
workspace status and two archive lifecycle actions:
|
||||||
|
- **“Neue ZIP bearbeiten”** (replace) opens a ZIP picker and, after the user confirms the
|
||||||
|
replacement dialog, calls `replaceWithZipFiles(files)` which resets the workspace and enqueues the
|
||||||
|
new files against the incremented generation. Files picked for replacement stay in temporary
|
||||||
|
component state until confirmation; cancelling the picker or the dialog must never mutate the
|
||||||
|
current workspace.
|
||||||
|
- **“Weitere ZIP hinzufügen”** (append) lives in the settings popover, uses the plain append path
|
||||||
|
(`handleFilesSelected`), and never clears current archives. Keep the two labels distinct:
|
||||||
|
replace is “Neue ZIP bearbeiten”, append is “Weitere ZIP hinzufügen”.
|
||||||
|
- `BeaWorkspaceControls.svelte` owns only ephemeral UI state (popover/dialog open state and pending
|
||||||
|
replacement files). The route remains the source of truth for files, jobs, archives, and the
|
||||||
|
persisted thumbnail width.
|
||||||
|
- `resetWorkspace()` is the single teardown helper: it increments `archiveGeneration` and clears
|
||||||
|
selected files, processed archives, and jobs. Never clear those arrays without going through it.
|
||||||
|
- Accepted ZIP types and the file filter for the dropzone and both compact pickers are centralized in
|
||||||
|
`src/lib/zip-selection.ts`; `ZipFilePicker.svelte` resets its input after every selection so the
|
||||||
|
same file can be chosen again.
|
||||||
- ZIP files are processed concurrently. `archiveGeneration` prevents results from an old batch from
|
- ZIP files are processed concurrently. `archiveGeneration` prevents results from an old batch from
|
||||||
reappearing after the user deletes all archives.
|
reappearing after the user deletes all archives **or** confirms a replacement.
|
||||||
- `ProcessedZipArchiveEditor.svelte` edits one archive and emits immutable replacements through
|
- `ProcessedZipArchiveEditor.svelte` edits one archive and emits immutable replacements through
|
||||||
`onArchiveChange`; the route-level archive array remains the source of truth.
|
`onArchiveChange`; the route-level archive array remains the source of truth.
|
||||||
- Attachments may remain loose or belong to exactly one sub-document. Drag-and-drop supports
|
- Attachments may remain loose or belong to exactly one sub-document. Drag-and-drop supports
|
||||||
|
|||||||
+1
-1
@@ -35,7 +35,7 @@
|
|||||||
"svelte": "^5.56.1",
|
"svelte": "^5.56.1",
|
||||||
"svelte-check": "^4.6.0",
|
"svelte-check": "^4.6.0",
|
||||||
"tailwind-merge": "3.6.0",
|
"tailwind-merge": "3.6.0",
|
||||||
"tailwind-variants": "^3.2.2",
|
"tailwind-variants": "^3.3.1",
|
||||||
"tailwindcss": "^4.3.0",
|
"tailwindcss": "^4.3.0",
|
||||||
"tw-animate-css": "^1.4.0",
|
"tw-animate-css": "^1.4.0",
|
||||||
"typescript": "^6.0.3",
|
"typescript": "^6.0.3",
|
||||||
|
|||||||
@@ -1,256 +0,0 @@
|
|||||||
# Plan: Sub-documents within a ZIP archive
|
|
||||||
|
|
||||||
## Goal
|
|
||||||
|
|
||||||
For a processed ZIP archive the user can already **reorder** attachments and **export** them as a single merged PDF. This feature adds the ability to:
|
|
||||||
|
|
||||||
1. **Select** some of the archive's attachments and **group them into a "sub-document"** (a named, ordered subset of attachments).
|
|
||||||
2. **Reorder** attachments *within* a sub-document — and **reorder** the sub-documents themselves — exactly like the parent archive today.
|
|
||||||
3. **Choose an export mode** for the sub-documents:
|
|
||||||
- **Separate files** → each sub-document becomes its own downloaded PDF.
|
|
||||||
- **One PDF** → all sub-documents are merged into a single downloaded PDF (the existing top-level "PDF herunterladen" behaviour, now scoped to sub-documents when they exist).
|
|
||||||
|
|
||||||
The existing flow (no sub-documents created → one merged PDF for the whole archive) stays intact as a fallback.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Open decisions (please confirm before implementation)
|
|
||||||
|
|
||||||
These affect the data model and UI. Default proposals are marked ✅.
|
|
||||||
|
|
||||||
### D1 — Partition model
|
|
||||||
- ✅ **A. Optional grouping.** Attachments can be "loose" (not part of any sub-document) *or* members of a sub-document. Sub-documents are created on demand by selecting attachments.
|
|
||||||
- B. **Exclusive partition.** Once the user starts creating sub-documents, every attachment must belong to exactly one.
|
|
||||||
- C. **Shared/multi-membership.** An attachment can appear in multiple sub-documents (references, not moves).
|
|
||||||
|
|
||||||
> Recommendation: **A**. It is the least disruptive, matches the current "flat list" mental model, and degrades gracefully when the user does nothing.
|
|
||||||
|
|
||||||
### D2 — Selection lifecycle
|
|
||||||
- ✅ **Selection mode toggle.** A small "Auswählen" button in the archive header switches the editor into selection mode. Only then are checkboxes rendered on each thumbnail; in normal mode the editor looks exactly like today (no permanent checkbox clutter). While in selection mode the user toggles attachments, then clicks "Teildokument erstellen" to consume the selection (which clears the selection but leaves selection mode on, so they can immediately create another sub-document). Exiting selection mode via a "Fertig" button (or Esc) clears any pending selection.
|
|
||||||
- A sub-document is created from the *current* selection. Afterwards each attachment can be moved into / out of / between sub-documents via drag-drop or a context action.
|
|
||||||
- Alternative considered: persistent always-visible checkboxes — rejected as visually noisy for the common case where the user only wants to reorder.
|
|
||||||
|
|
||||||
### D3 — Download scope when sub-documents exist
|
|
||||||
The existing single archive-level "PDF herunterladen" button behaviour needs clarification. Proposed:
|
|
||||||
- ✅ When **no** sub-documents exist → today's behaviour (merge all attachments into one PDF).
|
|
||||||
- When sub-documents **do** exist → show a **download mode toggle** offered to the user at export time:
|
|
||||||
- **Getrennt** (separate): one PDF per sub-document (loose attachments are downloaded individually, matching their kind — PDF passthrough, image as-is or as a tiny PDF — TBD in D4).
|
|
||||||
- **Eine PDF** (one): all sub-documents (and loose attachments, optionally — see D5) merged in sub-document order, with attachment order preserved inside each.
|
|
||||||
- ✅ "Separate" downloads are triggered sequentially (same pattern as today's `exportAllZipArchivesAsPdf`).
|
|
||||||
|
|
||||||
### D4 — Format of "separate" downloads
|
|
||||||
- ✅ Each sub-document is downloaded as a **PDF** (merge its attachments with the existing `mergeProcessedZipArchive` logic, scoped to the sub-document's attachments). Naming: `{archiveName} - {subDocumentName}.pdf`.
|
|
||||||
- B. Each sub-document is downloaded as a **ZIP** containing its original files.
|
|
||||||
- C. Mixed: keep each attachment's original format (PDFs passthrough, images as image files).
|
|
||||||
|
|
||||||
> Recommendation: **A** for consistency with the existing PDF-first export. We can revisit if the actual need is "preserve original files."
|
|
||||||
|
|
||||||
### D5 — Treatment of loose (ungrouped) attachments at export
|
|
||||||
- ✅ When exporting **separate**: loose attachments are downloaded each as their own PDF (or skipped — needs a quick confirm).
|
|
||||||
- ✅ When exporting **one PDF**: loose attachments are appended at the end (in their existing order) — or skipped.
|
|
||||||
- B. Ignore loose attachments entirely during export.
|
|
||||||
|
|
||||||
> Recommendation: **include loose attachments** at the end of both modes, so nothing is silently lost. Confirm with user.
|
|
||||||
|
|
||||||
### D6 — Naming of sub-documents
|
|
||||||
- ✅ A sub-document has a user-editable name (default e.g. `Teildokument 1`, `Teildokument 2`, …) shown next to its attachments, mirroring the archive name input.
|
|
||||||
|
|
||||||
### D7 — Persistence
|
|
||||||
- ✅ Sub-document structure lives only in component state (same as today's reordering) — no localStorage persistence required for v1. (We already do not persist reordering.)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Data model (`src/lib/zip-processing.ts`)
|
|
||||||
|
|
||||||
Introduce a `SubDocument` type alongside `ZipAttachment` and extend `ProcessedZipArchive`.
|
|
||||||
|
|
||||||
```ts
|
|
||||||
export type SubDocument = {
|
|
||||||
id: string; // stable id for keyed {#each} and drag state
|
|
||||||
name: string; // user-editable
|
|
||||||
attachments: ZipAttachment[];
|
|
||||||
};
|
|
||||||
|
|
||||||
export type ProcessedZipArchive = {
|
|
||||||
name: string;
|
|
||||||
attachments: ZipAttachment[]; // loose attachments (unchanged shape)
|
|
||||||
subDocuments: SubDocument[]; // NEW — defaults to [] for backward compat
|
|
||||||
};
|
|
||||||
```
|
|
||||||
|
|
||||||
Notes:
|
|
||||||
- Attachments inside `SubDocument` reuse the existing `ZipAttachment` type unchanged.
|
|
||||||
- `extractZipEntries` populates `subDocuments: []`. All existing call sites continue to work.
|
|
||||||
- A sub-document's `id` is generated with `crypto.randomUUID()` (available in browser + modern Node) — never re-derived from index, so reordering stays stable.
|
|
||||||
|
|
||||||
### New pure helpers (unit-test-friendly, no DOM)
|
|
||||||
|
|
||||||
```ts
|
|
||||||
// Create a sub-document from a list of attachments.
|
|
||||||
export const createSubDocument = (
|
|
||||||
name: string,
|
|
||||||
attachments: ZipAttachment[]
|
|
||||||
): SubDocument => ({ id: crypto.randomUUID(), name, attachments });
|
|
||||||
|
|
||||||
// Move an attachment from the loose list (or another sub-document) into a sub-document.
|
|
||||||
export const moveAttachmentIntoSubDocument = (
|
|
||||||
archive: ProcessedZipArchive,
|
|
||||||
attachmentPath: string,
|
|
||||||
source: { kind: 'loose' } | { kind: 'subDocument'; id: string },
|
|
||||||
target: { kind: 'loose'; index: number } | { kind: 'subDocument'; id: string; index: number }
|
|
||||||
): ProcessedZipArchive => { /* immutable update */ };
|
|
||||||
|
|
||||||
// Reorder sub-documents (same shape as the existing moveAttachment helper).
|
|
||||||
export const moveSubDocument = (
|
|
||||||
archive: ProcessedZipArchive,
|
|
||||||
fromIndex: number,
|
|
||||||
toIndex: number
|
|
||||||
): ProcessedZipArchive => { /* ... */ };
|
|
||||||
|
|
||||||
// Flatten the archive into an ordered list of attachments for "one PDF" export.
|
|
||||||
// sub-document order is preserved; loose attachments appended at the end (per D5).
|
|
||||||
export const flattenArchiveForMerge = (
|
|
||||||
archive: ProcessedZipArchive
|
|
||||||
): ZipAttachment[] => {
|
|
||||||
const ordered: ZipAttachment[] = [];
|
|
||||||
for (const sub of archive.subDocuments) ordered.push(...sub.attachments);
|
|
||||||
ordered.push(...archive.attachments);
|
|
||||||
return ordered;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Build one merged-PDF byte array per export unit.
|
|
||||||
// - mode 'single' → returns [ { name, bytes } ] from the whole archive (existing behaviour)
|
|
||||||
// - mode 'separate' → returns one entry per sub-document (+ loose attachments per D5)
|
|
||||||
export type ExportUnit = { name: string; bytes: Uint8Array };
|
|
||||||
export const buildArchiveExport = async (
|
|
||||||
archive: ProcessedZipArchive,
|
|
||||||
mode: 'single' | 'separate'
|
|
||||||
): Promise<ExportUnit[]> => { /* ... */ };
|
|
||||||
```
|
|
||||||
|
|
||||||
`mergeProcessedZipArchive` is refactored to delegate to a new `mergeAttachments(attachments): Promise<Uint8Array>` (a thin rename of the existing inner loop) so both "single" and "separate" share the same merging code.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## UI
|
|
||||||
|
|
||||||
### `ProcessedZipArchiveEditor.svelte` (main work)
|
|
||||||
|
|
||||||
State additions:
|
|
||||||
- `subDocuments = $state<SubDocument[]>([])` synced from `archive.subDocuments` in the existing `$effect`.
|
|
||||||
- `selectionMode = $state<boolean>(false)` — whether selection checkboxes are currently shown on thumbnails.
|
|
||||||
- `selectedPaths = $state<Set<string>>([])` — currently selected attachment paths (keyed by `attachment.path`, which is unique within an archive after extraction). Only meaningful while `selectionMode` is on.
|
|
||||||
- `downloadMode = $state<'single' | 'separate'>('single')` (only shown when sub-documents exist).
|
|
||||||
- Existing drag handlers generalised to handle three drag sources/targets:
|
|
||||||
- loose attachment ↔ loose attachment (today's behaviour),
|
|
||||||
- loose attachment → sub-document,
|
|
||||||
- sub-document attachment ↔ sub-document attachment,
|
|
||||||
- sub-document ↔ sub-document (reorder).
|
|
||||||
- Drag-and-drop is disabled while in selection mode (so a click toggles selection rather than starting a drag).
|
|
||||||
|
|
||||||
Layout:
|
|
||||||
- Keep the existing horizontal `<ul>` for **loose** attachments.
|
|
||||||
- In the header, add a small toggle button labelled **"Auswählen"** (icon: `IconMenu` or a checkmark lucide icon). When active it reads **"Fertig"** (and selection mode is on). In normal mode thumbnails have no checkbox at all — the editor looks identical to today.
|
|
||||||
- While in selection mode, each thumbnail card shows a **checkbox** in the top-left corner and the whole card becomes a click target that toggles its membership in `selectedPaths`. Drag-to-reorder is suspended in this mode to avoid click/drag ambiguity.
|
|
||||||
- A new **inline action bar** appears above the loose-attachment list when `selectionMode && selectedPaths.size > 0`:
|
|
||||||
- Button **"Teildokument erstellen"** → calls `createSubDocument` with the selected attachments, removes them from the loose list, clears `selectedPaths`, and keeps `selectionMode` on so the user can immediately build the next sub-document.
|
|
||||||
- `Esc` exits selection mode (clearing `selectedPaths`); the "Fertig" button does the same.
|
|
||||||
- Below the loose list, render `subDocuments` as a **vertical stack of "sub-document blocks"**. Each block is itself a horizontal reorderable `<ul>` of attachments (same `AttachmentPreview`, same drag handle `::`, same thumbnail width). Each block has:
|
|
||||||
- an editable name input (mirrors archive name input styling),
|
|
||||||
- a count label,
|
|
||||||
- a "remove" action that returns its attachments back to the loose list,
|
|
||||||
- a draggable header (`::`) to reorder the block among its siblings.
|
|
||||||
- Sub-document blocks are draggable as a whole via their header (HTML5 DnD, mirroring the existing pattern in `handleDragStart/Drop`).
|
|
||||||
|
|
||||||
Export controls (header area):
|
|
||||||
- If `subDocuments.length === 0` → existing **"PDF herunterladen"** button + behaviour (unchanged).
|
|
||||||
- If `subDocuments.length > 0` → replace with a **segmented control** (`Eine PDF` / `Getrennt`) plus the download button labeled accordingly:
|
|
||||||
- `Eine PDF` → `buildArchiveExport(archive, 'single')`, one `downloadPdfBytes` call.
|
|
||||||
- `Getrennt` → `buildArchiveExport(archive, 'separate')`, sequential `downloadPdfBytes` per unit (matches today's `exportAllZipArchivesAsPdf` loop).
|
|
||||||
|
|
||||||
All state changes flow through `onArchiveChange` (already plumbed to `updateProcessedZipFile` in `+page.svelte`) so the top-level array stays the source of truth.
|
|
||||||
|
|
||||||
### `+page.svelte`
|
|
||||||
- `exportAllZipArchivesAsPdf` needs to honour per-archive export mode. Two options:
|
|
||||||
- ✅ Keep it simple: bulk export uses the existing **"single"** mode for every archive (sub-documents flattened). Add a note in the tooltip.
|
|
||||||
- B. Read each archive's last-chosen mode (requires propagating `downloadMode` up). Defer to a follow-up if desired.
|
|
||||||
- The settings-drawer bulk export button label stays; behaviour unchanged for v1.
|
|
||||||
|
|
||||||
### No changes required
|
|
||||||
- `ZipDropzone`, `AttachmentPreview`, services, `xml-reading.service`, `zip-inflating.service`, layout.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Implementation phases
|
|
||||||
|
|
||||||
### Phase 1 — Data model + pure helpers
|
|
||||||
- Edit `src/lib/zip-processing.ts`:
|
|
||||||
- Add `SubDocument` type and extend `ProcessedZipArchive` with `subDocuments: SubDocument[]` (default `[]`).
|
|
||||||
- Set `subDocuments: []` in `extractZipEntries`.
|
|
||||||
- Extract `mergeAttachments(attachments: ZipAttachment[]): Promise<Uint8Array>` from `mergeProcessedZipArchive`.
|
|
||||||
- Add the pure helpers: `createSubDocument`, `moveAttachmentIntoSubDocument`, `moveSubDocument`, `removeAttachmentFromSubDocument` (returns attachments to the loose list), `flattenArchiveForMerge`, `buildArchiveExport`.
|
|
||||||
- Verification: TypeScript compiles (`pnpm check` / `svelte-check`); existing tests (if any) still pass; existing UI still renders unchanged.
|
|
||||||
|
|
||||||
### Phase 2 — Sub-document block component
|
|
||||||
- New `src/lib/components/SubDocumentEditor.svelte`:
|
|
||||||
- Props: `subDocument`, `thumbnailWidth`, `onSubDocumentChange`, `onRemove`, `drag handlers` (or pass a generic reorder callback).
|
|
||||||
- Renders the editable name, count, remove button, draggable header, horizontal attachment list (reusing `AttachmentPreview` + the same DnD styling).
|
|
||||||
- Internally mirrors `ProcessedZipArchiveEditor`'s drag logic, scoped to the block.
|
|
||||||
- Verify: can be unit-rendered in isolation (storybook-equivalent or a throwaway page snippet).
|
|
||||||
|
|
||||||
### Phase 3 — Selection mode + sub-document creation in `ProcessedZipArchiveEditor`
|
|
||||||
- Add `selectionMode` and `selectedPaths` state and the "Auswählen" / "Fertig" header toggle.
|
|
||||||
- While `selectionMode` is on, render a checkbox on each loose thumbnail and make the whole card a click-to-toggle target; suspend drag-to-reorder to avoid click/drag ambiguity.
|
|
||||||
- Add the inline action bar with **"Teildokument erstellen"**, visible when a selection exists.
|
|
||||||
- On create: call `createSubDocument`, remove the selected attachments from the loose list, clear `selectedPaths`, keep `selectionMode` on, and emit `onArchiveChange`.
|
|
||||||
- `Esc` / "Fertig" exits selection mode and clears the selection.
|
|
||||||
- On sub-document removal: push its attachments back into the loose list (appended at the end by default).
|
|
||||||
- Verify: toggling selection mode shows/hides checkboxes; in normal mode the editor looks identical to today; creating, populating, and removing sub-documents works end-to-end with reordering still working for loose attachments.
|
|
||||||
|
|
||||||
### Phase 4 — Sub-document drag interactions
|
|
||||||
- Generalise the existing HTML5 DnD handlers to support the three move scenarios listed above. Use the `dataTransfer` payload to encode `{ source: 'loose' | `sub:${id}`, index }`.
|
|
||||||
- Reorder sub-document blocks via header drag.
|
|
||||||
- Verify: items can be moved loose↔sub and sub↔sub; sub-document order can be changed; nothing is duplicated or lost (count invariant: `loose.length + Σ sub.attachments.length === total attachments`).
|
|
||||||
|
|
||||||
### Phase 5 — Export modes
|
|
||||||
- Replace header download button with the segmented control + button when sub-documents exist.
|
|
||||||
- Wire `buildArchiveExport(archive, mode)` to `downloadPdfBytes` (single) and the sequential loop (separate).
|
|
||||||
- `+page.svelte`: keep `exportAllZipArchivesAsPdf` in single mode; optionally surface a small hint when an archive has sub-documents.
|
|
||||||
- Verify:
|
|
||||||
- No sub-documents → existing single-PDF download unchanged.
|
|
||||||
- With sub-documents, "Eine PDF" flattens and downloads one PDF.
|
|
||||||
- With sub-documents, "Getrennt" downloads N files named `{archiveName} - {subName}.pdf`.
|
|
||||||
|
|
||||||
### Phase 6 — Polish
|
|
||||||
- Empty states (e.g. empty sub-document block, no loose attachments).
|
|
||||||
- Disable the create-sub-document button when selection is empty.
|
|
||||||
- Accessible labels + keyboard operability for checkboxes / segmented control (`bits-ui` SegmentedControl if available, otherwise native radio group).
|
|
||||||
- Update `agents.md` "ZIP/PDF processing details" + "UI/state details" sections to mention sub-documents.
|
|
||||||
- Manual smoke test on a real `xjustiz` ZIP with nested archives.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Verification checklist (definition of done)
|
|
||||||
|
|
||||||
- [ ] `pnpm check` (or `svelte-check`) passes with no new errors.
|
|
||||||
- [ ] `pnpm build` succeeds.
|
|
||||||
- [ ] Loading a ZIP without creating sub-documents produces the exact same merged PDF as today.
|
|
||||||
- [ ] Toggling "Auswählen" shows checkboxes on thumbnails; toggling "Fertig" (or pressing Esc) hides them and clears the selection. In normal mode the editor looks identical to today (no checkboxes).
|
|
||||||
- [ ] Selecting ≥1 attachments and clicking "Teildokument erstellen" removes them from the loose list and adds a named sub-document block, then clears the selection while staying in selection mode for the next group.
|
|
||||||
- [ ] Drag-reordering works for: loose attachments, attachments inside a sub-document, sub-document blocks themselves, and moving an attachment loose↔sub.
|
|
||||||
- [ ] With sub-documents present:
|
|
||||||
- "Eine PDF" downloads a single PDF containing every attachment (sub-documents first, then loose).
|
|
||||||
- "Getrennt" downloads one PDF per sub-document, each correctly named.
|
|
||||||
- [ ] Removing a sub-document returns its attachments to the loose list; total attachment count is invariant.
|
|
||||||
- [ ] Bulk "Alle ZIP-Archive als PDF exportieren" still works (single mode).
|
|
||||||
- [ ] `agents.md` updated.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Out of scope (follow-ups)
|
|
||||||
|
|
||||||
- Persisting sub-document structure to `localStorage`.
|
|
||||||
- Renaming attachments themselves (only sub-document names are editable in v1).
|
|
||||||
- Exporting "separate" as ZIPs or original-format files (see D4-C).
|
|
||||||
- Per-archive export mode remembered by the bulk exporter (D3 follow-up).
|
|
||||||
- Drag-and-drop between *different archives* in `+page.svelte` (today each editor is self-contained).
|
|
||||||
Generated
+9
-8
@@ -82,8 +82,8 @@ importers:
|
|||||||
specifier: 3.6.0
|
specifier: 3.6.0
|
||||||
version: 3.6.0
|
version: 3.6.0
|
||||||
tailwind-variants:
|
tailwind-variants:
|
||||||
specifier: ^3.2.2
|
specifier: ^3.3.1
|
||||||
version: 3.2.2(tailwind-merge@3.6.0)(tailwindcss@4.3.0)
|
version: 3.3.1(tailwind-merge@3.6.0)(tailwindcss@4.3.0)
|
||||||
tailwindcss:
|
tailwindcss:
|
||||||
specifier: ^4.3.0
|
specifier: ^4.3.0
|
||||||
version: 4.3.0
|
version: 4.3.0
|
||||||
@@ -1155,15 +1155,17 @@ packages:
|
|||||||
tailwind-merge@3.6.0:
|
tailwind-merge@3.6.0:
|
||||||
resolution: {integrity: sha512-uxL7qAVQriqRQPAyK3pj66VqskWqoZ37PW94jwOTwNfq/z9oyu1V+eqrZqtR2+fCiXdYOZe/Modt8GtvqNzu+w==}
|
resolution: {integrity: sha512-uxL7qAVQriqRQPAyK3pj66VqskWqoZ37PW94jwOTwNfq/z9oyu1V+eqrZqtR2+fCiXdYOZe/Modt8GtvqNzu+w==}
|
||||||
|
|
||||||
tailwind-variants@3.2.2:
|
tailwind-variants@3.3.1:
|
||||||
resolution: {integrity: sha512-Mi4kHeMTLvKlM98XPnK+7HoBPmf4gygdFmqQPaDivc3DpYS6aIY6KiG/PgThrGvii5YZJqRsPz0aPyhoFzmZgg==}
|
resolution: {integrity: sha512-4pAvwUtM4HKBiRZftncAbpn6V9Hhwoa5Fl7O2u5zbp7Z5Cvu+/o/6+176WY3WCEES209543quG8zFIcXCsc5Jw==}
|
||||||
engines: {node: '>=16.x', pnpm: '>=7.x'}
|
engines: {node: '>=16.9.x', pnpm: '>=7.x'}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
tailwind-merge: '>=3.0.0'
|
tailwind-merge: '>=3.0.0'
|
||||||
tailwindcss: '*'
|
tailwindcss: '*'
|
||||||
peerDependenciesMeta:
|
peerDependenciesMeta:
|
||||||
tailwind-merge:
|
tailwind-merge:
|
||||||
optional: true
|
optional: true
|
||||||
|
tailwindcss:
|
||||||
|
optional: true
|
||||||
|
|
||||||
tailwindcss@4.3.0:
|
tailwindcss@4.3.0:
|
||||||
resolution: {integrity: sha512-y6nxMGB1nMW9R6k96e5gdIFzcfL/gTJRNaqGes1YvkLnPVXzWgbqFF2yLC0T8G774n24cx3Pe8XrKoniCOAH+Q==}
|
resolution: {integrity: sha512-y6nxMGB1nMW9R6k96e5gdIFzcfL/gTJRNaqGes1YvkLnPVXzWgbqFF2yLC0T8G774n24cx3Pe8XrKoniCOAH+Q==}
|
||||||
@@ -2102,11 +2104,10 @@ snapshots:
|
|||||||
|
|
||||||
tailwind-merge@3.6.0: {}
|
tailwind-merge@3.6.0: {}
|
||||||
|
|
||||||
tailwind-variants@3.2.2(tailwind-merge@3.6.0)(tailwindcss@4.3.0):
|
tailwind-variants@3.3.1(tailwind-merge@3.6.0)(tailwindcss@4.3.0):
|
||||||
dependencies:
|
|
||||||
tailwindcss: 4.3.0
|
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
tailwind-merge: 3.6.0
|
tailwind-merge: 3.6.0
|
||||||
|
tailwindcss: 4.3.0
|
||||||
|
|
||||||
tailwindcss@4.3.0: {}
|
tailwindcss@4.3.0: {}
|
||||||
|
|
||||||
|
|||||||
@@ -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">
|
<script lang="ts">
|
||||||
import FileDropzone from '$lib/components/FileDropzone.svelte';
|
import FileDropzone from '$lib/components/FileDropzone.svelte';
|
||||||
|
import { ZIP_ACCEPT_TYPES } from '$lib/zip-selection';
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
onFilesSelected: (files: File[]) => void;
|
onFilesSelected: (files: File[]) => void;
|
||||||
@@ -11,7 +12,7 @@
|
|||||||
|
|
||||||
<FileDropzone
|
<FileDropzone
|
||||||
{onFilesSelected}
|
{onFilesSelected}
|
||||||
accept=".zip,application/zip,application/x-zip-compressed"
|
accept={ZIP_ACCEPT_TYPES}
|
||||||
multiple={true}
|
multiple={true}
|
||||||
ariaLabel="beA-ZIP-Dateien hinzufügen"
|
ariaLabel="beA-ZIP-Dateien hinzufügen"
|
||||||
label="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">
|
<script lang="ts">
|
||||||
import { onMount } from 'svelte';
|
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 ProcessedZipArchiveEditor from '$lib/components/ProcessedZipArchiveEditor.svelte';
|
||||||
import ZipDropzone from '$lib/components/ZipDropzone.svelte';
|
import ZipDropzone from '$lib/components/ZipDropzone.svelte';
|
||||||
import BeaArchiveProcessing, {
|
import BeaArchiveProcessing, {
|
||||||
@@ -131,12 +131,19 @@
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const deleteAllZipArchives = () => {
|
const resetWorkspace = () => {
|
||||||
archiveGeneration += 1;
|
archiveGeneration += 1;
|
||||||
selectedZipFiles = [];
|
selectedZipFiles = [];
|
||||||
processedZipFiles = [];
|
processedZipFiles = [];
|
||||||
zipJobs = [];
|
zipJobs = [];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const replaceWithZipFiles = (files: File[]) => {
|
||||||
|
resetWorkspace();
|
||||||
|
// handleFilesSelected captures the incremented archiveGeneration, so results
|
||||||
|
// from the previous workspace can no longer append.
|
||||||
|
handleFilesSelected(files);
|
||||||
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#snippet content()}
|
{#snippet content()}
|
||||||
@@ -146,63 +153,19 @@
|
|||||||
<ZipDropzone onFilesSelected={handleFilesSelected} />
|
<ZipDropzone onFilesSelected={handleFilesSelected} />
|
||||||
</Card.Content>
|
</Card.Content>
|
||||||
</Card.Root>
|
</Card.Root>
|
||||||
{:else}
|
{:else if zipJobs.length > 0}
|
||||||
<details class="fixed bottom-4 right-4 z-20">
|
<div class="flex w-full grow flex-col gap-4 px-4 py-6">
|
||||||
<summary
|
<BeaWorkspaceControls
|
||||||
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"
|
selectedZipCount={selectedZipFiles.length}
|
||||||
>
|
processedArchiveCount={processedZipFiles.length}
|
||||||
<span class="sr-only">Settings</span>
|
{pendingZipCount}
|
||||||
<span>⚙️</span>
|
{thumbnailWidth}
|
||||||
</summary>
|
onThumbnailWidthChange={(value) => (thumbnailWidth = value)}
|
||||||
|
onRequestReplacement={replaceWithZipFiles}
|
||||||
<div
|
onAppendFiles={handleFilesSelected}
|
||||||
class="absolute bottom-14 right-0 w-72 rounded-xl border border-primary-100 bg-white p-4 shadow-lg"
|
onExportAll={exportAllZipArchivesAsPdf}
|
||||||
>
|
onClear={resetWorkspace}
|
||||||
<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"
|
|
||||||
>
|
|
||||||
<div class="mx-auto flex w-full flex-col gap-3">
|
<div class="mx-auto flex w-full flex-col gap-3">
|
||||||
{#if processedZipFiles.length === 0}
|
{#if processedZipFiles.length === 0}
|
||||||
<BeaArchiveProcessing jobs={zipJobs} onRetry={retryZipFile} onRemove={removeZipFile} />
|
<BeaArchiveProcessing jobs={zipJobs} onRetry={retryZipFile} onRemove={removeZipFile} />
|
||||||
|
|||||||
@@ -79,6 +79,39 @@ const pdfDropzone = (page: Page) => page.getByRole('button', { name: 'PDF-Dateie
|
|||||||
const zipDropzone = (page: Page) =>
|
const zipDropzone = (page: Page) =>
|
||||||
page.getByRole('button', { name: 'beA-ZIP-Dateien hinzufügen' });
|
page.getByRole('button', { name: 'beA-ZIP-Dateien hinzufügen' });
|
||||||
|
|
||||||
|
const createZip = async (label: string, pageCount = 1) =>
|
||||||
|
Buffer.from(zipSync({ [`${label}.pdf`]: await createPdf(pageCount, label) }));
|
||||||
|
|
||||||
|
const newZipButton = (page: Page) => page.getByRole('button', { name: 'Neue ZIP bearbeiten' });
|
||||||
|
const settingsButton = (page: Page) =>
|
||||||
|
page.getByRole('button', { name: 'Einstellungen', exact: true });
|
||||||
|
const replaceZipInput = (page: Page) => page.locator('input[data-zip-picker="replace-zip"]');
|
||||||
|
const appendZipInput = (page: Page) => page.locator('input[data-zip-picker="append-zip"]');
|
||||||
|
const workspaceStatus = (page: Page, pattern: RegExp) => page.getByText(pattern);
|
||||||
|
|
||||||
|
const waitForLoadedWorkspace = async (
|
||||||
|
page: Page,
|
||||||
|
status: RegExp,
|
||||||
|
options?: { timeout?: number }
|
||||||
|
) => {
|
||||||
|
await expect(newZipButton(page)).toBeVisible();
|
||||||
|
await expect(workspaceStatus(page, status)).toBeVisible({ timeout: options?.timeout ?? 15_000 });
|
||||||
|
};
|
||||||
|
|
||||||
|
const replaceThroughDialog = async (page: Page, zip: Buffer, confirm: boolean) => {
|
||||||
|
await newZipButton(page).click();
|
||||||
|
await replaceZipInput(page).setInputFiles([
|
||||||
|
{ name: 'archiv-b.zip', mimeType: 'application/zip', buffer: zip }
|
||||||
|
]);
|
||||||
|
await expect(page.getByText('Aktuelle Bearbeitung ersetzen?')).toBeVisible();
|
||||||
|
if (confirm) {
|
||||||
|
await page.getByRole('button', { name: 'Neue ZIP öffnen' }).click();
|
||||||
|
} else {
|
||||||
|
await page.getByRole('button', { name: 'Abbrechen' }).click();
|
||||||
|
}
|
||||||
|
await expect(page.getByText('Aktuelle Bearbeitung ersetzen?')).toBeHidden();
|
||||||
|
};
|
||||||
|
|
||||||
test('start page exposes only working tools and forwards a dropped ZIP to beA', async ({
|
test('start page exposes only working tools and forwards a dropped ZIP to beA', async ({
|
||||||
page
|
page
|
||||||
}) => {
|
}) => {
|
||||||
@@ -264,3 +297,250 @@ test('convert bundles a multi-page PDF as a correctly named ZIP', async ({ page
|
|||||||
expect(download.suggestedFilename()).not.toContain('.pdf');
|
expect(download.suggestedFilename()).not.toContain('.pdf');
|
||||||
expect(pageErrors).toEqual([]);
|
expect(pageErrors).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('cancelling the replacement confirmation keeps the current archive', async ({ page }) => {
|
||||||
|
const pageErrors = trackPageErrors(page);
|
||||||
|
const zipA = await createZip('archiv-a');
|
||||||
|
const zipB = await createZip('archiv-b');
|
||||||
|
|
||||||
|
await page.goto('/tools/bea');
|
||||||
|
await dropFiles(page, zipDropzone(page), [
|
||||||
|
{ name: 'archiv-a.zip', mimeType: 'application/zip', buffer: zipA }
|
||||||
|
]);
|
||||||
|
await waitForLoadedWorkspace(page, /1 ZIP · 1 Archiv geöffnet/);
|
||||||
|
|
||||||
|
await replaceThroughDialog(page, zipB, false);
|
||||||
|
|
||||||
|
await expect(workspaceStatus(page, /1 ZIP · 1 Archiv geöffnet/)).toBeVisible();
|
||||||
|
await expect(page.getByText('2 ZIPs')).toHaveCount(0);
|
||||||
|
expect(pageErrors).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('confirming the replacement opens only the new ZIP and drops stale results', async ({
|
||||||
|
page
|
||||||
|
}) => {
|
||||||
|
const pageErrors = trackPageErrors(page);
|
||||||
|
const zipA = await createZip('archiv-a', 3);
|
||||||
|
const zipB = await createZip('archiv-b');
|
||||||
|
|
||||||
|
await page.goto('/tools/bea');
|
||||||
|
await dropFiles(page, zipDropzone(page), [
|
||||||
|
{ name: 'archiv-a.zip', mimeType: 'application/zip', buffer: zipA }
|
||||||
|
]);
|
||||||
|
// Replace while the first ZIP may still be extracting; generation guarding
|
||||||
|
// must ensure no late result from archiv-a reappears.
|
||||||
|
await replaceThroughDialog(page, zipB, true);
|
||||||
|
|
||||||
|
await waitForLoadedWorkspace(page, /1 ZIP · 1 Archiv geöffnet/);
|
||||||
|
await expect(page.getByText('1 lose Datei')).toHaveCount(1);
|
||||||
|
expect(pageErrors).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('cancelling the system file picker leaves the workspace unchanged', async ({ page }) => {
|
||||||
|
const pageErrors = trackPageErrors(page);
|
||||||
|
const zipA = await createZip('archiv-a');
|
||||||
|
|
||||||
|
await page.goto('/tools/bea');
|
||||||
|
await dropFiles(page, zipDropzone(page), [
|
||||||
|
{ name: 'archiv-a.zip', mimeType: 'application/zip', buffer: zipA }
|
||||||
|
]);
|
||||||
|
await waitForLoadedWorkspace(page, /1 ZIP · 1 Archiv geöffnet/);
|
||||||
|
|
||||||
|
await newZipButton(page).click();
|
||||||
|
await replaceZipInput(page).setInputFiles([]);
|
||||||
|
await expect(page.getByText('Aktuelle Bearbeitung ersetzen?')).toHaveCount(0);
|
||||||
|
await expect(workspaceStatus(page, /1 ZIP · 1 Archiv geöffnet/)).toBeVisible();
|
||||||
|
expect(pageErrors).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the replacement input accepts the same ZIP twice', async ({ page }) => {
|
||||||
|
const pageErrors = trackPageErrors(page);
|
||||||
|
const zipA = await createZip('archiv-a');
|
||||||
|
const zipB = await createZip('archiv-b');
|
||||||
|
|
||||||
|
await page.goto('/tools/bea');
|
||||||
|
await dropFiles(page, zipDropzone(page), [
|
||||||
|
{ name: 'archiv-a.zip', mimeType: 'application/zip', buffer: zipA }
|
||||||
|
]);
|
||||||
|
await waitForLoadedWorkspace(page, /1 ZIP · 1 Archiv geöffnet/);
|
||||||
|
|
||||||
|
await replaceThroughDialog(page, zipB, true);
|
||||||
|
await waitForLoadedWorkspace(page, /1 ZIP · 1 Archiv geöffnet/);
|
||||||
|
|
||||||
|
await replaceThroughDialog(page, zipB, true);
|
||||||
|
await waitForLoadedWorkspace(page, /1 ZIP · 1 Archiv geöffnet/);
|
||||||
|
await expect(page.getByText('1 lose Datei')).toHaveCount(1);
|
||||||
|
expect(pageErrors).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('appending a ZIP keeps both archive editors visible', async ({ page }) => {
|
||||||
|
const pageErrors = trackPageErrors(page);
|
||||||
|
const zipA = await createZip('archiv-a');
|
||||||
|
const zipB = await createZip('archiv-b');
|
||||||
|
|
||||||
|
await page.goto('/tools/bea');
|
||||||
|
await dropFiles(page, zipDropzone(page), [
|
||||||
|
{ name: 'archiv-a.zip', mimeType: 'application/zip', buffer: zipA }
|
||||||
|
]);
|
||||||
|
await waitForLoadedWorkspace(page, /1 ZIP · 1 Archiv geöffnet/);
|
||||||
|
|
||||||
|
await settingsButton(page).click();
|
||||||
|
await appendZipInput(page).setInputFiles([
|
||||||
|
{ name: 'archiv-b.zip', mimeType: 'application/zip', buffer: zipB }
|
||||||
|
]);
|
||||||
|
|
||||||
|
await waitForLoadedWorkspace(page, /2 ZIPs · 2 Archive geöffnet/);
|
||||||
|
await expect(page.getByText('1 lose Datei')).toHaveCount(2);
|
||||||
|
expect(pageErrors).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('bulk export only appears for two or more processed archives', async ({ page }) => {
|
||||||
|
const pageErrors = trackPageErrors(page);
|
||||||
|
const zipA = await createZip('archiv-a');
|
||||||
|
const zipB = await createZip('archiv-b');
|
||||||
|
|
||||||
|
await page.goto('/tools/bea');
|
||||||
|
await dropFiles(page, zipDropzone(page), [
|
||||||
|
{ name: 'archiv-a.zip', mimeType: 'application/zip', buffer: zipA }
|
||||||
|
]);
|
||||||
|
await waitForLoadedWorkspace(page, /1 ZIP · 1 Archiv geöffnet/);
|
||||||
|
|
||||||
|
await settingsButton(page).click();
|
||||||
|
await expect(
|
||||||
|
page.getByRole('button', { name: 'Alle Archive als PDF herunterladen' })
|
||||||
|
).toHaveCount(0);
|
||||||
|
|
||||||
|
await appendZipInput(page).setInputFiles([
|
||||||
|
{ name: 'archiv-b.zip', mimeType: 'application/zip', buffer: zipB }
|
||||||
|
]);
|
||||||
|
await waitForLoadedWorkspace(page, /2 ZIPs · 2 Archive geöffnet/);
|
||||||
|
|
||||||
|
// The popover stays open, so the new bulk export action appears in place.
|
||||||
|
const exportButton = page.getByRole('button', { name: 'Alle Archive als PDF herunterladen' });
|
||||||
|
await expect(exportButton).toBeVisible();
|
||||||
|
|
||||||
|
const downloadPromise = page.waitForEvent('download');
|
||||||
|
await exportButton.click();
|
||||||
|
await expectPdfDownload(await downloadPromise, /\.pdf$/);
|
||||||
|
expect(pageErrors).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('clearing the workspace requires confirmation and returns to the dropzone', async ({
|
||||||
|
page
|
||||||
|
}) => {
|
||||||
|
const pageErrors = trackPageErrors(page);
|
||||||
|
const zipA = await createZip('archiv-a');
|
||||||
|
|
||||||
|
await page.goto('/tools/bea');
|
||||||
|
await dropFiles(page, zipDropzone(page), [
|
||||||
|
{ name: 'archiv-a.zip', mimeType: 'application/zip', buffer: zipA }
|
||||||
|
]);
|
||||||
|
await waitForLoadedWorkspace(page, /1 ZIP · 1 Archiv geöffnet/);
|
||||||
|
|
||||||
|
await settingsButton(page).click();
|
||||||
|
await page.getByRole('button', { name: 'Aktuelle Bearbeitung leeren' }).click();
|
||||||
|
await expect(page.getByText('Aktuelle Bearbeitung leeren?')).toBeVisible();
|
||||||
|
await page.getByRole('button', { name: 'Abbrechen' }).click();
|
||||||
|
await expect(workspaceStatus(page, /1 ZIP · 1 Archiv geöffnet/)).toBeVisible();
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: 'Aktuelle Bearbeitung leeren' }).click();
|
||||||
|
await page.getByRole('button', { name: 'Bearbeitung leeren', exact: true }).click();
|
||||||
|
await expect(zipDropzone(page)).toBeVisible();
|
||||||
|
await expect(newZipButton(page)).toHaveCount(0);
|
||||||
|
expect(pageErrors).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the preview-size slider works with arrow keys and survives a reload', async ({ page }) => {
|
||||||
|
const pageErrors = trackPageErrors(page);
|
||||||
|
const zipA = await createZip('archiv-a');
|
||||||
|
|
||||||
|
await page.goto('/tools/bea');
|
||||||
|
await dropFiles(page, zipDropzone(page), [
|
||||||
|
{ name: 'archiv-a.zip', mimeType: 'application/zip', buffer: zipA }
|
||||||
|
]);
|
||||||
|
await waitForLoadedWorkspace(page, /1 ZIP · 1 Archiv geöffnet/);
|
||||||
|
await expect(page.locator('li[style*="width: 200px"]').first()).toBeVisible();
|
||||||
|
|
||||||
|
await settingsButton(page).click();
|
||||||
|
const slider = page.getByRole('slider', { name: 'Vorschaugröße' });
|
||||||
|
await expect(page.getByText('200 px')).toBeVisible();
|
||||||
|
await slider.focus();
|
||||||
|
await page.keyboard.press('ArrowRight');
|
||||||
|
await expect(page.getByText('201 px')).toBeVisible();
|
||||||
|
await expect(page.locator('li[style*="width: 201px"]').first()).toBeVisible();
|
||||||
|
|
||||||
|
await page.reload();
|
||||||
|
await page.locator('[data-dropzone-ready="true"]').first().waitFor();
|
||||||
|
await dropFiles(page, zipDropzone(page), [
|
||||||
|
{ name: 'archiv-a.zip', mimeType: 'application/zip', buffer: zipA }
|
||||||
|
]);
|
||||||
|
await waitForLoadedWorkspace(page, /1 ZIP · 1 Archiv geöffnet/);
|
||||||
|
await settingsButton(page).click();
|
||||||
|
await expect(page.getByText('201 px')).toBeVisible();
|
||||||
|
await expect(await page.evaluate(() => localStorage.getItem('thumbnailWidth'))).toBe('201');
|
||||||
|
expect(pageErrors).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('settings closes on Escape and outside click and returns focus to its trigger', async ({
|
||||||
|
page
|
||||||
|
}) => {
|
||||||
|
const pageErrors = trackPageErrors(page);
|
||||||
|
const zipA = await createZip('archiv-a');
|
||||||
|
|
||||||
|
await page.goto('/tools/bea');
|
||||||
|
await dropFiles(page, zipDropzone(page), [
|
||||||
|
{ name: 'archiv-a.zip', mimeType: 'application/zip', buffer: zipA }
|
||||||
|
]);
|
||||||
|
await waitForLoadedWorkspace(page, /1 ZIP · 1 Archiv geöffnet/);
|
||||||
|
|
||||||
|
await expect(settingsButton(page)).toHaveAccessibleName('Einstellungen');
|
||||||
|
await settingsButton(page).click();
|
||||||
|
await expect(page.getByText('Vorschaugröße')).toBeVisible();
|
||||||
|
await page.keyboard.press('Escape');
|
||||||
|
await expect(page.getByText('Vorschaugröße')).toHaveCount(0);
|
||||||
|
await expect(settingsButton(page)).toBeFocused();
|
||||||
|
|
||||||
|
await settingsButton(page).click();
|
||||||
|
await expect(page.getByText('Vorschaugröße')).toBeVisible();
|
||||||
|
await page.getByRole('heading', { name: 'beA-Edit' }).click();
|
||||||
|
await expect(page.getByText('Vorschaugröße')).toHaveCount(0);
|
||||||
|
expect(pageErrors).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('mobile toolbar and settings popover stay inside the viewport', async ({ page }) => {
|
||||||
|
const pageErrors = trackPageErrors(page);
|
||||||
|
const zipA = await createZip('archiv-a');
|
||||||
|
|
||||||
|
await page.setViewportSize({ width: 390, height: 844 });
|
||||||
|
await page.goto('/tools/bea');
|
||||||
|
await dropFiles(page, zipDropzone(page), [
|
||||||
|
{ name: 'archiv-a.zip', mimeType: 'application/zip', buffer: zipA }
|
||||||
|
]);
|
||||||
|
await waitForLoadedWorkspace(page, /1 ZIP · 1 Archiv geöffnet/);
|
||||||
|
|
||||||
|
await settingsButton(page).click();
|
||||||
|
const popover = page.locator('[data-slot="popover-content"]');
|
||||||
|
await expect(popover).toBeVisible();
|
||||||
|
await expect(page.getByText('Weitere ZIP hinzufügen')).toBeVisible();
|
||||||
|
|
||||||
|
const popoverBox = await popover.boundingBox();
|
||||||
|
expect(popoverBox).not.toBeNull();
|
||||||
|
expect(popoverBox!.x).toBeGreaterThanOrEqual(0);
|
||||||
|
expect(popoverBox!.x + popoverBox!.width).toBeLessThanOrEqual(390);
|
||||||
|
expect(popoverBox!.y).toBeGreaterThanOrEqual(0);
|
||||||
|
expect(popoverBox!.y + popoverBox!.height).toBeLessThanOrEqual(844);
|
||||||
|
const popoverMetrics = await popover.evaluate((element) => ({
|
||||||
|
scrollWidth: element.scrollWidth,
|
||||||
|
clientWidth: element.clientWidth
|
||||||
|
}));
|
||||||
|
expect(popoverMetrics.scrollWidth).toBeLessThanOrEqual(popoverMetrics.clientWidth);
|
||||||
|
|
||||||
|
// The toolbar sits in normal flow, so it must not cover the archive header.
|
||||||
|
const toolbarBox = await workspaceStatus(page, /1 ZIP · 1 Archiv geöffnet/).boundingBox();
|
||||||
|
const archiveHeaderBox = await page.getByText('1 lose Datei').first().boundingBox();
|
||||||
|
expect(toolbarBox).not.toBeNull();
|
||||||
|
expect(archiveHeaderBox).not.toBeNull();
|
||||||
|
expect(toolbarBox!.y + toolbarBox!.height).toBeLessThanOrEqual(archiveHeaderBox!.y);
|
||||||
|
|
||||||
|
expect(pageErrors).toEqual([]);
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user