Author SHA1 Message Date
rehlert 3b04039aa9 refactoring, numbering plan 2026-10-28 16:09:53 +01:00
rehlert b5279434b2 implements page-numbers-plan 2026-10-28 09:25:15 +01:00
rehlert 39da8dac4d updates
Build and Push Docker Image / build (push) Successful in 1m39s
2026-10-24 16:43:50 +02:00
rehlert f7fb00e52c Merge pull request 'Features/pwa' (#3) from features/pwa into develop
Build and Push Docker Image / build (push) Successful in 1m38s
Reviewed-on: #3
2026-10-06 20:55:04 +00:00
rehlert ca06ca2050 subdocument-splitting-plan 2026-10-06 22:52:35 +02:00
rehlert f09384d2e6 pwa / offline mode
- @vite-pwa/sveltekit: workbox precache (incl. pdfjs worker), generated manifest, prompt-style updates via PwaUpdatePrompt toast
- prerendered routes for offline navigation, PWA icons, theme-color + apple-touch-icon
- pwa offline/update e2e suite (pnpm test:pwa, playwright.pwa.config.ts)
- datenschutz: offline/install section; prettier cleanup of shadcn components
2026-10-04 12:55:46 +02:00
rehlert b7969b4c9e metadata
Build and Push Docker Image / build (push) Successful in 45s
2026-08-29 12:31:06 +02:00
rehlert d71b25f834 splitting tests 2026-08-29 12:10:26 +02:00
rehlert 09cc5c06fb rotate + hover on start-page
Build and Push Docker Image / build (push) Successful in 1m26s
2026-08-29 12:04:29 +02:00
rehlert ea21fc000f batch + stamping
Build and Push Docker Image / build (push) Successful in 1m35s
2026-08-28 08:02:10 +02:00
rehlert 57b38fe2c7 bea settings
Build and Push Docker Image / build (push) Successful in 54s
2026-08-27 18:15:22 +02:00
rehlert 367f34229c loading screen
Build and Push Docker Image / build (push) Successful in 41s
2026-08-27 16:41:59 +02:00
rehlert dea6a1ba77 rewording
Build and Push Docker Image / build (push) Successful in 42s
2026-08-27 15:46:24 +02:00
rehlert 365c57847c favicon
Build and Push Docker Image / build (push) Successful in 40s
2026-08-27 10:41:48 +02:00
rehlert 2b22fcb0e8 layout footer fix
Build and Push Docker Image / build (push) Successful in 41s
2026-08-27 09:48:36 +02:00
rehlert 037050e1c1 styling FileDropzone
Build and Push Docker Image / build (push) Successful in 43s
2026-08-27 08:26:57 +02:00
rehlert 1a2673f708 merges box on start-page
Build and Push Docker Image / build (push) Successful in 41s
2026-08-27 07:45:17 +02:00
rehlert adaae41e44 adds features
Build and Push Docker Image / build (push) Successful in 1m28s
2026-08-27 07:38:35 +02:00
rehlert 891026ccbb legal
Build and Push Docker Image / build (push) Successful in 29s
2026-07-22 22:26:53 +02:00
rehlert a450c6f5dd Merge branch 'features/shadcn' into develop
# Conflicts:
#	.idea/workspace.xml
#	agents.md
2026-07-22 22:18:59 +02:00
rehlert 47ed80bd24 adds impressum / privacy 2026-07-22 22:14:50 +02:00
rehlert 44ca534cfa adds ci/cd 2026-07-22 21:28:50 +02:00
rehlert 0f7bb4b6eb adds icons 2026-06-19 07:05:54 +02:00
rehlert 52ca2c24f4 adds icons to buttons 2026-06-16 18:46:11 +02:00
rehlert 0b032b2cd8 adds shadcn, removes skeleton, implements button after styleguide 2026-06-16 18:07:58 +02:00
rehlert 9785973e8b removes skeleton 2026-06-16 08:11:01 +02:00
rehlert 1c6d9cce0f adds buttons to export and delete all zips 2026-06-15 20:52:57 +02:00
rehlert 8a5ee9fe20 adds agents.md, splits zip-processing.ts 2026-06-15 20:13:39 +02:00
rehlert ef96f523e3 The git changes introduce functionality to merge attachments from a processed ZIP archive into a single PDF file. Here is a summary of the changes: ### New Features - PDF Merging: Added the ability to merge multiple attachments (PDFs and images) from a processed ZIP archive into a single PDF document. - Image Conversion: Implemented automatic conversion of non-PNG images (like JPGs) to PNG format before embedding them into the merged PDF. ### File Changes - Dependencies: Added pdf-lib to the project's dependencies. - src/lib/zip-processing.ts:
- Added mergeProcessedZipArchive to handle the core logic of creating a merged PDF.                         - Implemented helpers to embed PDF pages and convert/embed images.     - Added toArrayBuffer and convertImageBytesToPng utilities for handling binary data and canvas-based image processing.
     - src/lib/components/ProcessedZipArchiveEditor.svelte:
 - Added a "PDF herunterladen" (Download PDF) button.              - Added UI states to handle the merging process (loading state, error messages).
     - Integrated the mergeProcessedZipArchive function with the UI.
      - src/lib/components/AttachmentPreview.svelte:
 - Added a fix to ensure attachment.data is treated as a Uint8Array when loading PDF documents.
2026-06-13 13:32:55 +02:00
rehlert e3518eaed0 name generation improvement 2026-06-12 14:40:46 +02:00
rehlert 0629ca88c9 range settings 2026-06-12 14:35:54 +02:00
rehlert b400bf2f7c handles zips in zip correctly 2026-06-12 13:42:48 +02:00
rehlert 33f0804c53 ignores mac special folders 2026-06-12 13:36:23 +02:00
rehlert 1a9f0e876c renders thumbnails 2026-06-12 13:33:00 +02:00
rehlert 0cbbc6f46d design anpassungen 2026-06-12 12:59:46 +02:00
rehlert d00a7e2783 renders preview of zips 2026-06-12 12:31:44 +02:00
rehlert 15a3fc5877 parses xjustiz and orders attachments 2026-06-12 12:17:13 +02:00
rehlert 187f7b3a6a zips are getting extracted 2026-06-12 11:58:22 +02:00
185 changed files with 25293 additions and 1190 deletions

No files matched your search

+11
View File
@@ -0,0 +1,11 @@
.git
.gitignore
.idea
.vscode
.svelte-kit
build
node_modules
npm-debug.log*
pnpm-debug.log*
.env
.env.*
+37
View File
@@ -0,0 +1,37 @@
name: Build and Push Docker Image
on:
push:
branches:
- main
workflow_dispatch:
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Docker login to Gitea Registry
run: |
echo "${{ secrets.REGISTRY_PASSWORD }}" | docker login "${{ vars.REGISTRY_URL }}" \
-u "${{ secrets.REGISTRY_USER }}" \
--password-stdin
- name: Build Docker image
run: |
docker build \
-t "${{ vars.REGISTRY_URL }}/${{ vars.IMAGE_NAME }}:latest" \
-t "${{ vars.REGISTRY_URL }}/${{ vars.IMAGE_NAME }}:${{ gitea.sha }}" \
.
- name: Push Docker image
run: |
docker push "${{ vars.REGISTRY_URL }}/${{ vars.IMAGE_NAME }}:latest"
docker push "${{ vars.REGISTRY_URL }}/${{ vars.IMAGE_NAME }}:${{ gitea.sha }}"
- name: Deploy
run: |
curl https://dokploy.robosoft-solutions.de/api/deploy/compose/vETS7jxOixux3wDPobAaL
+37
View File
@@ -0,0 +1,37 @@
name: Build and Push Docker Image
on:
push:
branches:
- develop
workflow_dispatch:
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Docker login to Gitea Registry
run: |
echo "${{ secrets.REGISTRY_PASSWORD }}" | docker login "${{ vars.REGISTRY_URL }}" \
-u "${{ secrets.REGISTRY_USER }}" \
--password-stdin
- name: Build Docker image
run: |
docker build \
-t "${{ vars.REGISTRY_URL }}/${{ vars.IMAGE_NAME }}:dev" \
-t "${{ vars.REGISTRY_URL }}/${{ vars.IMAGE_NAME }}:${{ gitea.sha }}" \
.
- name: Push Docker image
run: |
docker push "${{ vars.REGISTRY_URL }}/${{ vars.IMAGE_NAME }}:dev"
docker push "${{ vars.REGISTRY_URL }}/${{ vars.IMAGE_NAME }}:${{ gitea.sha }}"
- name: Deploy
run: |
curl https://dokploy.robosoft-solutions.de/api/deploy/compose/Miw8GJXF_v4Ebavr3mJwa
+2
View File
@@ -7,6 +7,8 @@ node_modules
.wrangler
/.svelte-kit
/build
/playwright-report
/test-results
# OS
.DS_Store
+2615
View File
File diff suppressed because it is too large. Load diff
+106 -19
View File
@@ -1,46 +1,133 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="AutoImportSettings">
<option name="autoReloadType" value="SELECTIVE" />
</component>
<component name="CMakeSettings">
<configurations />
</component>
<component name="ChangeListManager">
<list default="true" id="89f9fc3a-63ad-41cb-8d25-f07df352eb98" name="Changes" comment="">
<change beforePath="$PROJECT_DIR$/.idea/workspace.xml" beforeDir="false" afterPath="$PROJECT_DIR$/.idea/workspace.xml" afterDir="false" />
<change beforePath="$PROJECT_DIR$/src/routes/+page.svelte" beforeDir="false" afterPath="$PROJECT_DIR$/src/routes/+page.svelte" afterDir="false" />
<change beforePath="$PROJECT_DIR$/src/routes/+layout.svelte" beforeDir="false" afterPath="$PROJECT_DIR$/src/routes/+layout.svelte" afterDir="false" />
<change beforePath="$PROJECT_DIR$/src/routes/datenschutz/+page.svelte" beforeDir="false" afterPath="$PROJECT_DIR$/src/routes/datenschutz/+page.svelte" afterDir="false" />
<change beforePath="$PROJECT_DIR$/src/routes/impressum/+page.svelte" beforeDir="false" afterPath="$PROJECT_DIR$/src/routes/impressum/+page.svelte" afterDir="false" />
<change beforePath="$PROJECT_DIR$/uat.yml" beforeDir="false" afterPath="$PROJECT_DIR$/.gitea/workflows/uat.yml" afterDir="false" />
</list>
<option name="SHOW_DIALOG" value="false" />
<option name="HIGHLIGHT_CONFLICTS" value="true" />
<option name="HIGHLIGHT_NON_ACTIVE_CHANGELIST" value="false" />
<option name="LAST_RESOLUTION" value="IGNORE" />
</component>
<component name="FileTemplateManagerImpl">
<option name="RECENT_TEMPLATES">
<list>
<option value="+page.svelte" />
</list>
</option>
</component>
<component name="Git.Settings">
<option name="RECENT_BRANCH_BY_REPOSITORY">
<map>
<entry key="$PROJECT_DIR$" value="features/shadcn" />
</map>
</option>
<option name="RECENT_GIT_ROOT_PATH" value="$PROJECT_DIR$" />
</component>
<component name="McpProjectServerCommands">
<commands />
<urls />
</component>
<component name="MetaFilesCheckinStateConfiguration" checkMetaFiles="true" />
<component name="NextEditCompletionFeaturesState">
<decayedCancelled>
<entry key="MS100" value="1.0000000193915048" />
<entry key="MS500" value="1.028676250370633" />
<entry key="S2" value="1.4194868298714414" />
<entry key="S5" value="1.8458418253662012" />
<entry key="S10" value="2.2177989694912164" />
<entry key="S30" value="2.6766437274014185" />
<entry key="S60" value="2.959677574191515" />
<entry key="M2" value="3.4366229865704883" />
<entry key="M5" value="4.462442318201847" />
<entry key="M10" value="5.916664349028232" />
<entry key="M15" value="6.940237970746977" />
<entry key="M30" value="8.521362268212844" />
<entry key="H1" value="9.62023487151045" />
<entry key="H2" value="10.270683493430917" />
<entry key="H4" value="10.62488416631901" />
<entry key="D1" value="10.935974404270617" />
<entry key="W1" value="10.990815874256214" />
</decayedCancelled>
<decayedSelected>
<entry key="MS100" value="0.0" />
<entry key="MS500" value="0.0" />
<entry key="S2" value="0.0" />
<entry key="S5" value="0.0" />
<entry key="S10" value="0.0" />
<entry key="S30" value="0.0" />
<entry key="S60" value="0.0" />
<entry key="M2" value="0.0" />
<entry key="M5" value="0.0" />
<entry key="M10" value="0.0" />
<entry key="M15" value="0.0" />
<entry key="M30" value="0.0" />
<entry key="H1" value="0.0" />
<entry key="H2" value="0.0" />
<entry key="H4" value="0.0" />
<entry key="D1" value="0.0" />
<entry key="W1" value="0.0" />
</decayedSelected>
<decayedShown>
<entry key="MS100" value="0.15496346249522763" />
<entry key="MS500" value="0.6936352317970043" />
<entry key="S2" value="1.1789457258652456" />
<entry key="S5" value="1.6521017306909382" />
<entry key="S10" value="2.0660150480217916" />
<entry key="S30" value="2.60082115522875" />
<entry key="S60" value="2.9160446418159838" />
<entry key="M2" value="3.4117712141940064" />
<entry key="M5" value="4.4508993349183115" />
<entry key="M10" value="5.910338911687691" />
<entry key="M15" value="6.935829221677054" />
<entry key="M30" value="8.519029212377378" />
<entry key="H1" value="9.619027375474417" />
<entry key="H2" value="10.270068085857725" />
<entry key="H4" value="10.624573345818598" />
<entry key="D1" value="10.935922149562563" />
<entry key="W1" value="10.990808397996608" />
</decayedShown>
</component>
<component name="ProjectColorInfo">{
&quot;associatedIndex&quot;: 0,
&quot;fromUser&quot;: false
}</component>
<component name="ProjectId" id="3Ez6dWfFxFKqFN56n3L3nruEC06" />
<component name="ProjectLevelVcsManager">
<ConfirmationsSetting value="1" id="Add" />
</component>
<component name="ProjectViewState">
<option name="hideEmptyMiddlePackages" value="true" />
<option name="showLibraryContents" value="true" />
</component>
<component name="PropertiesComponent"><![CDATA[{
"keyToString": {
"ModuleVcsDetector.initialDetectionPerformed": "true",
"RunOnceActivity.ShowReadmeOnStart": "true",
"RunOnceActivity.TerminalTabsStorage.copyFrom.TerminalArrangementManager.252": "true",
"RunOnceActivity.cidr.known.project.marker": "true",
"RunOnceActivity.readMode.enableVisualFormatting": "true",
"RunOnceActivity.typescript.service.memoryLimit.init": "true",
"cidr.known.project.marker": "true",
"codeWithMe.voiceChat.enabledByDefault": "false",
"git-widget-placeholder": "main",
"last_opened_file_path": "/home/rehlert/repos/juri-merger",
"nodejs_package_manager_path": "pnpm",
"settings.editor.selected.configurable": "preferences.pluginManager",
"ts.external.directory.path": "/home/rehlert/repos/juri-merger/node_modules/typescript/lib",
"vue.rearranger.settings.migration": "true"
<component name="PropertiesComponent">{
&quot;keyToString&quot;: {
&quot;ModuleVcsDetector.initialDetectionPerformed&quot;: &quot;true&quot;,
&quot;RunOnceActivity.ShowReadmeOnStart&quot;: &quot;true&quot;,
&quot;RunOnceActivity.TerminalTabsStorage.copyFrom.TerminalArrangementManager.252&quot;: &quot;true&quot;,
&quot;RunOnceActivity.cidr.known.project.marker&quot;: &quot;true&quot;,
&quot;RunOnceActivity.readMode.enableVisualFormatting&quot;: &quot;true&quot;,
&quot;RunOnceActivity.typescript.service.memoryLimit.init&quot;: &quot;true&quot;,
&quot;cidr.known.project.marker&quot;: &quot;true&quot;,
&quot;codeWithMe.voiceChat.enabledByDefault&quot;: &quot;false&quot;,
&quot;git-widget-placeholder&quot;: &quot;develop&quot;,
&quot;last_opened_file_path&quot;: &quot;/home/rehlert/repos/juri-merger&quot;,
&quot;node.js.selected.package.tslint&quot;: &quot;(autodetect)&quot;,
&quot;nodejs_package_manager_path&quot;: &quot;pnpm&quot;,
&quot;settings.editor.selected.configurable&quot;: &quot;preferences.pluginManager&quot;,
&quot;ts.external.directory.path&quot;: &quot;/home/rehlert/repos/juri-merger/node_modules/typescript/lib&quot;,
&quot;vue.rearranger.settings.migration&quot;: &quot;true&quot;
}
}]]></component>
}</component>
<component name="TaskManager">
<task active="true" id="Default" summary="Default task">
<changelist id="89f9fc3a-63ad-41cb-8d25-f07df352eb98" name="Changes" comment="" />
+29
View File
@@ -0,0 +1,29 @@
# syntax=docker/dockerfile:1
FROM node:24-alpine AS base
ENV PNPM_HOME="/pnpm"
ENV PATH="$PNPM_HOME:$PATH"
RUN corepack enable && corepack prepare pnpm@11.15.1 --activate
FROM base AS dependencies
WORKDIR /app
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml .npmrc ./
RUN pnpm install --frozen-lockfile
FROM dependencies AS build
COPY . .
RUN pnpm build && pnpm prune --prod --ignore-scripts
FROM node:24-alpine AS runtime
WORKDIR /app
ENV NODE_ENV="production"
ENV HOST="0.0.0.0"
ENV PORT="3000"
COPY --from=build --chown=node:node /app/package.json ./
COPY --from=build --chown=node:node /app/node_modules ./node_modules
COPY --from=build --chown=node:node /app/build ./build
USER node
EXPOSE 3000
CMD ["node", "build/index.js"]
+17
View File
@@ -0,0 +1,17 @@
# PWA / Offline-Fähigkeit (plan/pwa-plan.md)
- [x] Add `@vite-pwa/sveltekit` dev dependency (pnpm, plus explicit `vite-plugin-pwa`/`workbox-window` for pnpm strict resolution)
- [x] Generate PWA icons (192 / 512 / maskable / apple-touch) from `static/favicon.svg`
- [x] Configure `SvelteKitPWA` in `vite.config.ts` (manifest, workbox glob patterns incl. pdfjs `.mjs` worker, navigateFallback)
- [x] Enable prerendering so route documents are precached (`src/routes/+layout.ts`)
- [x] Add PWA client type references (`src/app.d.ts`)
- [x] Update toast component + `virtual:pwa-info` manifest link in root layout
- [x] Add `theme-color` meta + apple-touch-icon
- [x] Production-build verification: SW registers, offline document/tool navigation, offline ZIP + merge chain, update toast (defer + activate) — `pnpm test:pwa`
- [x] Run `pnpm check` (0 errors), `pnpm build`
- [x] Update `agents.md`
- [x] `pnpm lint`: 19 formatting failures fixed via `pnpm format` (shadcn-generated
`src/lib/components/ui/` and `src/lib/components/icons/`, plus `AttachmentPreview.svelte`;
pure reformatting, no logic changes)
- [ ] `datenschutz` wording: draft added to section 4 (“Offline-Nutzung als App (Progressive Web
App)”) in `src/routes/datenschutz/+page.svelte` — pending user review/approval (D3)
+434
View File
@@ -0,0 +1,434 @@
# Project Agent Guide
## Project overview
This repository contains **juri-merger**, a German-language SvelteKit application branded as
**beA-Edit**. Users add one or more beA ZIP archives or PDF files in the browser, inspect and
reorder supported attachments, group attachments into named sub-documents (including page-level
PDF splitting), and export merged PDFs.
Nested ZIP archives and XJustiz metadata are handled client-side; there is no upload, server-side
archive processing, database, analytics, or persistence of case data.
The application also contains German imprint and privacy-policy routes.
## Technology stack
- SvelteKit 2 and Svelte 5, with runes mode forced for project source files
- TypeScript in strict mode
- Vite 8
- Tailwind CSS 4 through `@tailwindcss/vite`
- shadcn-svelte 1 with the `nova` style and CSS-variable theme tokens
- bits-ui, tailwind-variants, clsx, and tailwind-merge
- Lucide Svelte icons plus project-specific SVG icon components
- fflate for ZIP extraction and image-export ZIP creation
- pdf-lib for PDF assembly, `@cantoo/pdf-lib` for encryption, and pdfjs-dist for rendering
- `@sveltejs/adapter-node`
- PWA/offline support through `@vite-pwa/sveltekit` (Workbox precache, generated web manifest)
- pnpm with a committed lockfile
- Prettier with Svelte and Tailwind plugins
Use **pnpm**, not npm or yarn. The production container uses Node 24 and pnpm 11.15.1.
## Commands
```sh
pnpm install # Install dependencies
pnpm dev # Start the development server
pnpm build # Create the adapter-node production build
pnpm preview # Preview the production build
pnpm check # Run Svelte and TypeScript diagnostics
pnpm check:watch # Run diagnostics in watch mode
pnpm test:e2e # Run the toolbox specs (tests/e2e) and the unit specs (tests/unit)
pnpm test:pwa # PWA offline/update tests against the production build (requires pnpm build)
pnpm lint # Check repository formatting with Prettier
pnpm format # Format the repository with Prettier
```
For code changes, run at least:
```sh
pnpm check
pnpm test:e2e
pnpm lint
pnpm build
```
For documentation-only changes, a targeted Prettier check is sufficient. If pre-existing warnings
or formatting failures remain outside changed files, report them instead of making unrelated edits.
The Docker image can be built from the repository root. It installs with `--frozen-lockfile`, builds
and prunes dependencies, then runs `node build/index.js` as a non-root user on port 3000.
## Repository structure
```text
src/
├── app.html # German HTML shell
├── app.d.ts # SvelteKit application declarations
├── lib/
│ ├── actions/ripple.ts # Button ripple Svelte action
│ ├── assets/ # Favicon and local Nunito fonts
│ ├── components/
│ │ ├── AttachmentPreview.svelte # Browser-rendered PDF/image thumbnails
│ │ ├── AttachmentMenu.svelte # Per-attachment dropdown menu (move, split, remove)
│ │ ├── BatchFileList.svelte # Shared batch rows: status chips, downloads, retry
│ │ ├── BeaArchiveProcessing.svelte
│ │ ├── BeaWorkspaceControls.svelte # beA workspace toolbar, settings popover, dialogs
│ │ ├── ExhibitFileList.svelte # Reorderable Anlagen list: labels, renames, results
│ │ ├── ProcessedZipArchiveEditor.svelte
│ │ ├── PaginationPreview.svelte # Rendered page preview incl. shrink hints and page nav
│ │ ├── PdfSplitDialog.svelte # Page-level PDF splitting into Teildokumente
│ │ ├── SinglePdfTool.svelte # Shell for one-PDF tools (header, dropzone, slots)
│ │ ├── SubDocumentEditor.svelte
│ │ ├── ToolError.svelte # Shared error banner (role="alert")
│ │ ├── 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
│ │ ├── PwaUpdatePrompt.svelte # Service-worker registration and update/offline toast
│ │ ├── RasterizationWarning.svelte
│ │ ├── ZipDropzone.svelte # beA-specific wrapper around FileDropzone
│ │ ├── icons/ # Project-specific SVG components
│ │ └── ui/ # Local shadcn-svelte component source
│ ├── services/
│ │ ├── xml-reading.service.ts # Namespace-tolerant XJustiz parsing
│ │ └── zip-inflating.service.ts # Async fflate wrapper
│ ├── batch.ts # Sequential batch engine for multi-file tools
│ ├── download.ts # Blob/download helpers (pdfBytesToBlob, downloadBlobFile)
│ ├── exhibit-naming.ts # Anlagen label, start number and file-name helpers
│ ├── latest-task.ts # "Only the latest async call wins" tokens
│ ├── pdf-compression.ts # Shared compression presets and rasterize pipeline
│ ├── pdf-errors.ts # Shared PDF error classification and German messages
│ ├── pdf-metadata.ts # PDF metadata inspection and cleaning (pdf-lib only)
│ ├── pdf-overlay.ts # Shared overlay positions, preview CSS and color parsing
│ ├── pdf-pagination.ts # Sheet-number labels and the pagination pipeline
│ ├── pdf-preview-text.ts # Canvas text measurement for HTML previews
│ ├── pdf-processing.ts # Reusable PDF merge helper
│ ├── pdf-session.svelte.ts # Shared single-PDF lifecycle (file, bytes, handle, errors)
│ ├── pdf-text-overlay.ts # Shared framed/rotated text and image overlay drawing
│ ├── pdf-thumbnails.ts # Managed pdfjs thumbnails, on-demand page previews, cleanup
│ ├── utils.ts # cn() and shared component utility types
│ └── zip-processing.ts # Archive model, transforms, and PDF export
└── routes/
├── +layout.svelte # Shared logo header and branded footer
├── +layout.ts # `prerender = true` for all routes (PWA offline documents)
├── +page.svelte # Toolbox hub and global beA ZIP drop target
├── layout.css # Tailwind, shadcn, theme, fonts, global CSS
├── tools/
│ ├── +layout.svelte # Shared back-navigation shell
│ ├── bea/+page.svelte # Existing ZIP archive workflow
│ └── {exhibits,pagination,merge,separate,stamp,watermark,encrypt,decrypt,compress,convert,rotate,metadata}/
├── datenschutz/+page.svelte # Privacy policy
└── impressum/+page.svelte # Imprint
static/ # Public logo, footer art, PWA icons, and robots.txt
components.json # shadcn-svelte registry configuration
Dockerfile # Node adapter production image
playwright.pwa.config.ts # Playwright config for `pnpm test:pwa`
tests/e2e/ # Playwright toolbox specs
tests/unit/ # Pure-function specs (run by the same Playwright config)
plan/ # Design and implementation notes
```
Generated directories such as `.svelte-kit/`, `build/`, and `node_modules/` must not be edited or
committed.
## PWA / offline mode
- `SvelteKitPWA` is configured in `vite.config.ts` with `registerType: 'prompt'`: a waiting service
worker never activates by itself. `PwaUpdatePrompt.svelte` registers the worker in the browser and
shows a toast (“Neue Version verfügbar” with “Neu laden”/“Später”, plus a one-time hint when the
app is ready offline). Never force a reload — an in-progress archive must survive a declined
update.
- All routes are prerendered (`src/routes/+layout.ts`), so route documents land in the precache and
offline navigation works for every page, including a direct deep link. `navigateFallback: '/'`
covers unknown paths.
- Precache contents come from `workbox.globPatterns`: the plugin's client/prerendered defaults plus
`mjs` (the pdfjs worker chunk, required for offline rendering) and the local fonts; the unused
`static/Nunito/**` font drop is excluded via `globIgnores`. Precache size is ~4.5 MB.
- PWA icons in `static/icons/` (192/512, maskable, apple-touch) were generated once from
`static/favicon.svg`; regenerate them with ImageMagick/rsvg if the logo changes. The manifest link
is injected client-side from `virtual:pwa-info` in the root layout, `theme-color` and the
apple-touch-icon live in `app.html`.
- `devOptions.enabled` stays `false`: `pnpm dev` never registers a service worker, so development
caches stay clean. Verify PWA behaviour only against a production build (`pnpm build` and
`pnpm preview`, or `pnpm test:pwa`, which also covers the offline processing chain and the update
toast).
- Simulating a deployment in tests requires restarting `node build/index.js`: adapter-node's static
handler (sirv, non-dev mode) walks the build directory once at startup and caches the ETags, so
swapped files are invisible to a running process. Browsers revalidate `/sw.js` via its ETag on
navigation and `registration.update()`, so the normal container restart on deploy is sufficient.
## Application flow and state
- `/` is the toolbox hub. A beA ZIP or PDF dropped anywhere on that route is queued in memory and
forwarded to `/tools/bea`; the file is not uploaded or persisted. The same `queuePendingFiles` /
`takePendingFiles` handoff carries the merged PDF from `/tools/merge` to `/tools/pagination`.
- `/tools/pagination` is a single-PDF tool: it renders one page at a time through
`loadPdfPageRenderer` (never the all-pages thumbnail array), composes every label with
`buildPaginationLabel` — the single source for both the live preview and the export — and stamps
all pages through `drawTextOverlay`. The page number is always on every page; the optional free
text (Aktenzeichen) follows its own “every page” / “first page only” scope.
- `/tools/exhibits` is the Anlagen tool and the only batch tool with an order. One dropped PDF is
one Anlage: `src/lib/exhibit-naming.ts` owns the label (`buildExhibitLabel`, no padded numbers in
the stamp) and the optional file name (`buildExhibitFileName`, `{prefixSlug}_{paddedNumber}_…`,
padding from `getExhibitNumberWidth`). Numbers are frozen per row when the run starts
(`assignments` map), so reordering or changing the start number never rewrites a created Anlage —
the list keeps showing the frozen label while pending rows follow the live order. The stamp is
`drawTextOverlay` with `framed: true`, `compensatePageRotation: true`, first page by default.
- `/tools/metadata` is a single-PDF inspection tool. `src/lib/pdf-metadata.ts` reads document
properties with pdf-lib only (no rasterization), returns a discriminated `{ status: 'encrypted' }`
result for password-protected files, and detects embedded-file markers by scanning raw bytes.
Cleaning neutralizes the standard Info dictionary fields and re-saves through pdf-lib; embedded
files and XMP streams are out of scope and are disclosed in the UI instead.
- `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.
- The loaded workspace renders `BeaWorkspaceControls.svelte`, a top-aligned toolbar with the
workspace status and two archive lifecycle actions:
- **“Neue Datei bearbeiten”** (replace) opens a file 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 Datei 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 Datei bearbeiten”, append is “Weitere Datei 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 file types for the workspace (beA ZIP **and** PDF) are centralized in
`src/lib/zip-selection.ts` and shared by the dropzone, the start-page drop target, and both
compact pickers; `ZipFilePicker.svelte` resets its input after every selection so the
same file can be chosen again.
- Workspace files are processed concurrently. PDF files become single-attachment archives via
`pdfFileToArchive`; ZIP files go through `extractZipArchives`. `archiveGeneration` prevents
results from an old batch from reappearing after the user deletes all archives **or** confirms a
replacement.
- `ProcessedZipArchiveEditor.svelte` edits one archive and emits immutable replacements through
`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
reordering and moving attachments between lists, while sub-document headers can be reordered.
- **Gruppieren** (grouping mode) offers archive-wide multi-select over loose and sub-document
attachments: create a new Teildokument from the selection, move the selection into an existing
Teildokument, select all/none. Exiting with **Fertig** or Escape clears the pending selection.
New Teildokumente are numbered via `nextSubDocumentNumber` (max existing trailing number, not
list length), and the created block's name input is focused and scrolled into view.
- Each attachment card has an **“⋯” menu** (`AttachmentMenu.svelte`) as the keyboard/touch path:
move into an existing or new Teildokument, move back to the loose list, split PDFs, and remove
(with confirmation). It must never become the only way to perform an action that drag-and-drop
also covers.
- **PDF splitting**: `PdfSplitDialog.svelte` opens per PDF attachment, renders pages through
`loadPdfDocument` + `PdfPageGrid`, and assigns page sets to named groups (manual selection,
“Jede Seite einzeln”, or “Alle N Seiten”). Cut is the default; “Seiten im Original behalten”
switches to copy. `applyPdfSplit` replaces the source with the remaining pages (or removes it),
inserts the new Teildokumente after the source's parent block, and the synthetic attachments
get unique `#seiten-…` path suffixes because `path` is the identity for keyed lists, drag
payloads, and selection.
- Per-archive export supports one flattened PDF or separate PDFs for sub-documents. Each
sub-document also has its own direct download action that exports only that sub-document's
attachments. Bulk export from the page always creates one flattened PDF per archive.
When changing archive transforms, preserve these invariants:
- An attachment move must not duplicate or discard data. Splitting cuts pages by default and only
duplicates them when the user explicitly chooses copy mode.
- Sub-document IDs remain stable across edits and reordering.
- Single-PDF order is all sub-documents in order, each attachment in order, followed by loose
attachments in order.
- Dissolving a sub-document appends its attachments to the loose list.
- Splitting inserts the new Teildokumente directly after the source's parent block (loose source:
appended at the end) so the reading order is preserved.
- State helpers in `zip-processing.ts` should return new archive/list objects rather than mutating
component-owned state.
## ZIP, XML, and PDF processing
All case-file processing is intentionally local to the browser.
- `extractZipArchives` recursively inflates nested ZIP files.
- Ignore macOS metadata (`__MACOSX`, `.DS_Store`, and `._*`) and unsupported entries.
- `xjustiz_nachricht.xml` is metadata, not an export attachment. Its sender, receiver, and declared
document names determine the generated PDF name and preferred attachment order.
- XJustiz element matching must remain namespace tolerant. Malformed or absent metadata falls back
safely instead of preventing extraction.
- Supported attachments are PDFs and the image extensions listed in `zip-processing.ts`. Update the
extension classification and downstream preview/export behavior together when adding formats.
- PDF attachments are copied with pdf-lib. JPEG and PNG files can be embedded directly; other image
formats are decoded through browser image/canvas APIs and converted to PNG first.
- Page-set copies (separate/extract tools, PDF splitting) share
`copyPagesToNewPdf(sourceBytes, pageIndexes)` in `src/lib/pdf-processing.ts` so the copy
semantics cannot drift apart.
- `src/lib/pdf-text-overlay.ts` owns text fitting, the rotated-box placement and the optional
`/Rotate` compensation. Stamp keeps `compensatePageRotation` off so its output does not change;
pagination turns it on so numbers stay at the visual position on rotated scans. `drawImageOverlay`
is the image counterpart. Share both instead of re-implementing overlay maths in a route, and take
the margin/padding presets (`TEXT_OVERLAY_MARGIN`, `TEXT_OVERLAY_PADDING`,
`MIN_TEXT_OVERLAY_SIZE`) from the module rather than redefining them locally.
- `src/lib/pdf-errors.ts` classifies PDF failures (`classifyPdfError`, `TextOverlayError`,
`isEncryptedPdfError`) and owns the German copy (`pdfLoadErrorMessage`, `pdfProcessingErrorMessage`,
both with a tool-specific fallback). All tools use it; never match on pdf-lib/pdfjs error text in a
route, and never copy the password hint locally.
- `src/lib/pdf-session.svelte.ts` (`createPdfSession`) is the shared single-PDF lifecycle for rotate,
separate and pagination: it reads the bytes once, builds the tool handle, maps load errors and
invalidates stale loads. Keep new single-PDF tools on it instead of re-declaring
`file`/`bytes`/`handle`/`isLoading`/`loadingError` and a generation counter.
- `src/lib/latest-task.ts` (`createLatestToken`) is the guard for "only the newest async call may
publish its result" (file loads, preview renders). Do not add another hand-rolled counter.
- Markup is shared the same way: `ToolError.svelte` owns the red `role="alert"` banner (never
inline the classes in a route), `SinglePdfTool.svelte` owns the header + dropzone + toolbar +
footer layout for one-PDF tools (rotate, separate — keep new ones on it), and
`PaginationPreview.svelte` owns the page preview with its shrink hints. Tool-specific copy is
passed in as props; keep it out of the shared components.
- `src/lib/pending-files.ts` hands files between routes. Queue with an explicit target
(`queuePendingFiles(files, 'bea' | 'pagination')`) and consume with `takePendingFiles(target)` so a
tool cannot silently swallow a file queued for another one.
- `src/lib/pdf-thumbnails.ts` offers two loaders: `loadPdfDocument` (all thumbnails, for pickers and
page grids) and `loadPdfPageRenderer` (one page at a time, for long documents). Both share
`PdfPageImage` and one rendering helper, revoke object URLs and tear down the pdfjs loading task —
including when the initial `getDocument` promise rejects. Superseded renders throw
`PreviewRenderCancelledError`, which callers swallow instead of logging.
- `AttachmentPreview.svelte` dynamically loads pdfjs-dist in `onMount`, copies PDF bytes before
rendering, and revokes object URLs during cleanup. Preserve those memory and data-integrity
safeguards.
- Empty archives/export groups should continue to fail explicitly rather than creating invalid PDFs.
Browser-only APIs used by this flow include `File`, `DOMParser`, `Image`, canvas, `Blob`, `URL`,
localStorage, and `crypto.randomUUID()`. Do not invoke browser-only work during SSR or at module
initialization. Keep it in event handlers, `onMount`, effects guarded by hydration, or functions only
called in the browser.
## Batch processing pattern for single-PDF tools
Tools that transform one PDF per run (compress, watermark, stamp, encrypt, decrypt) accept multiple
files. Follow the established compress retrofit when adding the pattern to another tool; see
`plan/done/batch-processing-plan.md` for the full design.
- `src/lib/batch.ts` owns the engine: `createBatchItem(file)`, `runBatch(items, process, update,
options?)`, `formatBytes`, `batchStatusLabel`/`batchStatusClass` and
`shouldWarnAboutBatchMemory`. `process` is `(file, item) => Promise<{ bytes, name }>`: the item is
passed so a tool can resolve per-row data frozen at run start (the Anlagen tool resolves its
exhibit number that way; `(file) => …` callbacks stay valid). `runBatch` processes strictly
sequentially (one PDF in flight for memory safety), never throws — per-item failures are caught
and written to the item as a German error message (a string, or a formatter that can inspect the
thrown error) — and consults the optional `shouldProcess` predicate so rows removed mid-run are
skipped.
- `src/lib/batch.ts` also owns the bulk export: `uniqueEntryName` (duplicate suffixes) and
`downloadBatchZip(items, zipBaseName)` → `{toolname}_{date}.zip`. Keep new batch tools on it
instead of building ZIPs inside a component.
- `src/lib/components/BatchFileList.svelte` renders the rows: name, size, status chip (`batchStatusClass`/`batchStatusLabel`)
(Wartet / Verarbeite… / Fertig ✓ / Fehler), per-row download, remove, and retry buttons with
accessible names including the file name, a progress line (“Datei 3 von 7 wird verarbeitet…”), a
total result-size summary, a memory warning (>20 files or any file >25 MB), and the bulk
“Alle herunterladen” ZIP action (via fflate, named `{toolname}_{date}.zip`) shown once ≥2 results
exist. Duplicate result names inside the ZIP are suffixed instead of overwriting.
- Page wiring: keep `items = $state<BatchItem[]>([])`, append newly dropped files (do not replace;
deduplicate by name + size), pass a `process(file)` callback that reads the current option state
so option changes mid-run affect only subsequent files, and gate the run button on pending items.
Files too large to rasterize (>100 MB) are marked as error rows immediately on selection instead
of blocking the batch. Keep thumbnails, previews, and page-specific selection to a single dropped
file; multi-file runs apply the tool to every page of every file.
- `src/lib/components/ExhibitFileList.svelte` is the other batch list, for tools where order and a
derived per-row label matter (currently `/tools/exhibits`): drag handle plus up/down buttons that
both call one `onMove(from, to)`, position badge, the resolved label, the renamed file name, and
the same status/download/retry/remove/ZIP surface as `BatchFileList`. Reordering and preview
selection are disabled while a run is active.
- Per-tool ZIP base names: compress uses `komprimiert`, watermark `wasserzeichen`, stamp `stempel`,
exhibits `anlagen`, encrypt `geschuetzt`, decrypt `entsperrt`. Choose an analogous German name
per tool and cover the
batch flow with Playwright tests (multi-file ZIP contents, failure continuation, retry, removal).
## Svelte conventions
- Use Svelte 5 runes and current event syntax: `$props()`, `$state`, `$derived`, `$effect`, snippets,
`{@render ...}`, and handlers such as `onclick` rather than legacy APIs.
- Keep component scripts typed with `<script lang="ts">`.
- Define a local `Props` type for non-trivial component props.
- Use callback props for child-to-parent updates; do not mutate prop objects in place.
- Use `$lib/...` aliases for application imports instead of long relative paths.
- Name reusable components in PascalCase and route files according to SvelteKit conventions.
- Preserve semantic HTML, accessible names, visible focus states, and keyboard alternatives. Do not
make drag-and-drop the only way to perform a critical action.
- Clean up global listeners, object URLs, PDF loading tasks, and other browser resources.
The Vite configuration forces runes mode for project files, so do not introduce legacy Svelte
component patterns.
## shadcn-svelte and component conventions
`components.json` configures the official shadcn-svelte registry, the `nova` style, Lucide icons, and
these aliases:
- UI components: `$lib/components/ui`
- General components: `$lib/components`
- Utilities: `$lib/utils`
Use existing local UI components before creating replacements. Follow the exports actually present
on disk: the card is composed through `import * as Card from '$lib/components/ui/card'`, while the
current button is a default import from `$lib/components/ui/button.svelte`.
When adding a registry component, use the project runner, inspect every generated file, and preserve
local customizations:
```sh
pnpm dlx shadcn-svelte@latest add <component>
```
Do not overwrite or bulk-update customized components without explicit approval. Use `cn()` for
conditional class merging and existing variants before adding one-off component styling.
## Styling conventions
Use Tailwind utilities in Svelte markup and semantic theme tokens from `src/routes/layout.css`.
- Prefer `bg-background`, `text-foreground`, `bg-primary`, `text-muted-foreground`, `border-border`,
and other configured semantic utilities over raw brand colors.
- Preserve the established Nominandum visual language: pale blue-gray background, white cards, dark
blue primary text, red/coral secondary actions, rounded containers, and Nunito typography.
- Use `flex`/`grid` with `gap-*`, responsive variants, and `size-*` when width and height are equal.
- Use `cn()` rather than constructing partial Tailwind class names. Keep complete class names visible
to Tailwind's scanner.
- Prefer component variants and theme variables over overriding UI-component colors in call sites.
- Add global CSS only for imports, tokens, font faces, animations, or behavior that utilities cannot
reasonably express.
- Tailwind 4 is configured through Vite and `layout.css`; there is no `tailwind.config` file.
- Maintain contrast, responsive layouts, drag-state feedback, and visible focus treatment.
Theme-level changes belong in `src/routes/layout.css`. Do not reintroduce the removed Skeleton theme
or refer to the old `nom-theme.css` architecture.
## Formatting
Follow the checked-in Prettier configuration:
- Tabs for indentation
- Single quotes in scripts and styles where supported
- No trailing commas
- 100-character print width
- Svelte-aware and Tailwind-aware formatting
Use `pnpm exec prettier --write <changed-files>` for targeted formatting. Avoid formatting unrelated
files solely to clean up existing differences.
## Product and content constraints
- User-facing copy is German unless a task explicitly requests another language.
- Preserve legal text in `datenschutz/+page.svelte` and `impressum/+page.svelte` unless explicitly
asked to change it.
- Keep the Nominandum logo, footer artwork, and theme consistent across routes.
- ZIP selection accepts multiple files matching `.zip`, `application/zip`, or
`application/x-zip-compressed`.
- Resetting the file input after selection is intentional so the same archive can be selected again.
- Do not add uploads, network requests, server processing, analytics, or case-data persistence without
an explicit requirement and privacy review.
## Change discipline
- Keep changes scoped and preserve unrelated work, including untracked files.
- Inspect existing components, archive helpers, and theme tokens before adding abstractions.
- Keep browser processing behavior and archive-order invariants covered when editing shared helpers.
- Do not hand-edit generated output or lockfile contents. Update the lockfile only through pnpm when
dependency changes are required.
- Avoid dependencies when the task can be completed with Svelte, browser APIs, Tailwind, or existing
packages.
- After implementation, review changed files, run relevant checks, and mention remaining warnings or
limitations.
+20
View File
@@ -0,0 +1,20 @@
{
"$schema": "https://shadcn-svelte.com/schema.json",
"tailwind": {
"css": "src/routes/layout.css",
"baseColor": "neutral"
},
"aliases": {
"components": "$lib/components",
"utils": "$lib/utils",
"ui": "$lib/components/ui",
"hooks": "$lib/hooks",
"lib": "$lib"
},
"typescript": true,
"registry": "https://shadcn-svelte.com/registry",
"style": "nova",
"iconLibrary": "lucide",
"menuColor": "default",
"menuAccent": "subtle"
}
+33 -15
View File
@@ -10,27 +10,45 @@
"prepare": "svelte-kit sync || echo ''",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
"test:e2e": "playwright test",
"lint": "prettier --check .",
"format": "prettier --write ."
"format": "prettier --write .",
"test:pwa": "playwright test -c playwright.pwa.config.ts"
},
"devDependencies": {
"@sveltejs/adapter-auto": "^7.0.1",
"@sveltejs/kit": "^2.63.0",
"@sveltejs/vite-plugin-svelte": "^7.1.2",
"@fontsource-variable/geist": "^5.3.0",
"@lucide/svelte": "^1.61.0",
"@playwright/test": "^1.64.1",
"@sveltejs/adapter-node": "^5.5.7",
"@sveltejs/kit": "^2.70.3",
"@sveltejs/vite-plugin-svelte": "^7.3.1",
"@tailwindcss/forms": "^0.5.11",
"@tailwindcss/typography": "^0.5.19",
"@tailwindcss/vite": "^4.3.0",
"prettier": "^3.8.3",
"prettier-plugin-svelte": "^4.1.0",
"prettier-plugin-tailwindcss": "^0.8.0",
"svelte": "^5.56.1",
"svelte-check": "^4.6.0",
"tailwindcss": "^4.3.0",
"@tailwindcss/typography": "^0.5.20",
"@tailwindcss/vite": "^4.3.3",
"@types/node": "^26.6.5",
"@vite-pwa/sveltekit": "^1.1.0",
"bits-ui": "2.18.1",
"class-variance-authority": "0.7.1",
"clsx": "2.1.1",
"prettier": "^3.9.10",
"prettier-plugin-svelte": "^4.1.1",
"prettier-plugin-tailwindcss": "^0.8.1",
"shadcn-svelte": "1.3.0",
"svelte": "^5.57.2",
"svelte-check": "^4.7.7",
"tailwind-merge": "3.6.0",
"tailwind-variants": "^3.3.1",
"tailwindcss": "^4.3.3",
"tw-animate-css": "^1.4.0",
"typescript": "^6.0.3",
"vite": "^8.0.16"
"vite": "^8.3.5",
"vite-plugin-pwa": "^1.3.0",
"workbox-window": "^7.4.1"
},
"dependencies": {
"@skeletonlabs/skeleton": "^4.15.2",
"@skeletonlabs/skeleton-svelte": "^4.15.2"
"@cantoo/pdf-lib": "^2.11.1",
"fflate": "^0.8.3",
"pdf-lib": "^1.17.1",
"pdfjs-dist": "^6.4.299"
}
}
+143
View File
@@ -0,0 +1,143 @@
# Plan: Batch-Verarbeitung in den Einzel-PDF-Tools
## Goal
Several tools process exactly one PDF at a time, but real legal work is repetitive: compress ten
scans, watermark five drafts, encrypt three exhibits. This plan generalizes the single-file
tools to accept **multiple files**, process them sequentially with per-file status, and download
results individually or as one ZIP.
---
## Open decisions (defaults marked ✅)
### D1 — Rollout scope
- ✅ **A. Phased rollout, starting with compress.** Compress is the highest-repetition tool and
already has the cleanest input→output shape. Phase 2: watermark (needs option application per
file), stamp/page-numbers (same pattern). Phase 3: encrypt/decrypt (decrypt needs a password
for all files — one shared password input).
- B. All tools at once. Too much surface; the shared pattern should prove itself first.
### D2 — UX model
- ✅ **A. Always multi-file.** The dropzone accepts multiple PDFs from day one; a single dropped
file simply renders a one-row list. No mode switch. Download: one button per finished row
plus "Alle als ZIP", which appears once ≥2 files are done.
- B. Explicit "Batch-Modus" toggle. Extra clicks and duplicated code paths.
### D3 — Failure behavior
- ✅ **Continue on error.** A failing file is marked with a German error message in its row; the
queue keeps processing the rest. "Erneut versuchen" per failed row. This matches how lawyers
will use it (one broken scan must not kill the run).
---
## UX
Per retrofitted tool page:
1. `PdfDropzone` with `multiple` enabled; dropping more files appends to the current list
(does not replace it) — matches how the start page queues ZIPs.
2. **File list** below the options card: one row per file —
name, size, status chip (Wartet / Verarbeite… / Fertig ✓ mit neuer Größe / Fehler),
per-row download button (enabled when done), per-row remove, "Erneut versuchen" on error.
3. Progress line during a run: "Datei 3 von 7 wird verarbeitet…".
4. Footer actions: **"Alle herunterladen"** (ZIP via fflate, named
`{toolname}_{date}.zip`, e.g. `komprimiert_2026-02-12.zip`) visible when ≥2 results exist.
5. Options (preset, watermark settings, password…) apply to **all** files in the run; changing
options while a run is active affects only subsequent files (documented in helper text).
## Technical approach
### Shared batch engine (`src/lib/components/BatchFileList.svelte` + `src/lib/batch.ts`)
```ts
// src/lib/batch.ts
export type BatchItemStatus = 'pending' | 'processing' | 'done' | 'error';
export type BatchItem = {
id: string; // crypto.randomUUID()
file: File;
status: BatchItemStatus;
resultBytes?: Uint8Array;
resultName?: string;
errorMessage?: string | null;
};
export const runBatch = async (
items: BatchItem[],
process: (file: File) => Promise<{ bytes: Uint8Array; name: string }>,
update: (id: string, patch: Partial<BatchItem>) => void
): Promise<void>;
```
- `runBatch` iterates **sequentially** (memory protection: only one PDF in flight), calls
`update` per lifecycle step so the component re-renders status chips, and never throws —
per-item errors are caught and written to the item.
- `BatchFileList.svelte` renders rows, emits callbacks (`onDownload(item)`, `onRemove(id)`,
`onRetry(id)`); row buttons are real buttons with accessible labels including the file name.
- Result bytes stay in memory until downloaded or removed; revocation concerns don't apply
(downloads use the existing `downloadBlob` object-URL pattern with timeout revocation). A
summary line shows total result size so users notice runaway memory situations.
- Very large runs: warn (>20 files or >25 MB per file in rasterizing tools) with a German note;
do not hard-block.
### Retrofit pilot: compress
- Extract per-file compression into `compressWithPreset` (already planned in
`bea-size-limit-plan.md` — coordinate: whichever ships first creates `pdf-compression.ts`).
- Page state becomes `items = $state<BatchItem[]>([])` + options; the old single-result UI
(`compressedBytes` etc.) is replaced by the list. Zielgröße mode + batch: each file runs the
preset loop independently.
- Rasterization guards apply per file: files >100 MB are immediately marked as error rows with
the rasterization message instead of blocking the whole batch.
### Rollout pattern for later tools
- Watermark/stamp/page-numbers: identical wiring; the pdf-lib overlay loop is already per-page
and per-file load is isolated.
- Encrypt: one password for all files (password inputs stay at the top); per-file failure shows
which file broke.
- Decrypt: one shared password input + rasterization caveat once, per-file status as above.
## Files changed
- `src/lib/batch.ts` — new batch engine.
- `src/lib/components/BatchFileList.svelte` — new shared list component.
- `src/routes/tools/compress/+page.svelte` — pilot retrofit.
- (Phase 2/3) watermark/stamp/page-numbers/encrypt/decrypt pages.
- `tests/e2e/toolbox.spec.ts` — spec blocks per retrofitted tool.
- `agents.md` — document the batch pattern for future tools.
## Implementation phases
1. `batch.ts` + `BatchFileList.svelte` (build against a mock process function first).
2. Compress pilot retrofit (depends on `pdf-compression.ts` extraction).
3. E2E for compress batch: 2 PDFs → both done, ZIP contains 2 entries; one corrupt file →
error row, other still succeeds; retry works.
4. Phase 2 tools, Phase 3 tools (separate PRs).
5. Polish: copy, memory summary line, warning thresholds.
## Verification
- Single file dropped → identical outcome to today's single-file flow (same output bytes for the
same preset).
- Mixed success/failure batch: statuses correct, ZIP includes only successful results.
- Remove a finished row → its bytes freed (no download offered afterwards).
- Option change mid-run affects only following files.
- `pnpm check`, `pnpm lint`, `pnpm build`, `pnpm test:e2e` pass.
## Risks
- **Memory with many results held simultaneously**: 30 compressed PDFs × several MB can strain
low-end machines. Mitigations: per-row remove, total-size summary, and a follow-up "download
and clear" convenience. Do not stream to disk (browser constraint).
- **Sequential processing is slow for many large files** — inherent to memory safety; progress
line sets expectations.
## Out of scope
- Parallel processing.
- Cross-tool pipelines (compress → watermark in one run) — separate roadmap item.
- Per-file option overrides (v1 applies one option set to the whole batch).
@@ -0,0 +1,315 @@
# Plan: beA settings menu and “next ZIP” workflow
**Status: Done**
## Current review
The loaded-archive state in `src/routes/tools/bea/+page.svelte` uses a native `<details>` menu fixed
in the lower-right corner. I reviewed it at 1440 × 900 and 390 × 844 with a real one-PDF ZIP.
### What is not working
- The floating gear is detached from the archive it controls and competes with the footer/content
edge. On mobile, the open panel overlaps the archive card.
- Two long actions are forced side by side into a 288 px panel. Their labels visibly clip to
“Archive als PDF” and “Archive”, so the difference between export and delete is unclear.
- The emoji gear does not match the app's Lucide icon language. Its accessible name is the English
word “Settings” in an otherwise German interface.
- View configuration, bulk export, and destructive session deletion are mixed without hierarchy.
“Alle ZIP-Archive löschen” receives nearly the same visual weight as export and has no
confirmation.
- “Thumbnail Breite” is an implementation-oriented, partly English label. The control should be
described as the user-visible result: “Vorschaugröße”.
- Native `<details>` does not provide the expected menu behavior here: no reliable outside-click
dismissal, weak focus management, and no deliberate focus return.
- Once any ZIP has loaded, the dropzone disappears. Although the route can append files internally,
the user has no control to open another ZIP or begin a clean next task.
- The loaded archive is vertically centered in a viewport-height container. This creates a large
gap below “beA-Edit” on both desktop and mobile and makes the fixed settings control feel even more
disconnected.
## Product decision
Use two explicit archive lifecycle actions:
1. **“Neue ZIP bearbeiten”** is the prominent next-task action. It replaces the current workspace
only after the user has selected a valid ZIP and confirmed the replacement. This directly serves
the requested “I am done; give me the next ZIP” workflow.
2. **“Weitere ZIP hinzufügen”** remains available inside the actions panel for users intentionally
working with multiple ZIPs at once. It appends to the current workspace, preserving the behavior
already supported by `handleFilesSelected`.
The replacement confirmation is shown _after_ file selection. Cancelling either the system picker or
confirmation must leave the current work untouched. The confirmation says that the current view and
unexported changes will be removed, while files already downloaded remain on the device.
Do not try to infer that a user is “done” from a download click. A download does not prove that every
wanted output was saved, and the browser cannot reliably verify what happened to the file afterward.
## UX direction
### Desktop
```text
← Zurück
beA-Edit 1 ZIP · 1 Archiv geöffnet [+ Neue ZIP bearbeiten] [⚙ Einstellungen]
──────────────────────────────────────────────────────────────────────────────────────────
┌ Current archive editor ────────────────────────────────────────────────────────────────┐
│ archive name [Auswählen] [PDF herunterladen] │
│ attachment previews … │
└────────────────────────────────────────────────────────────────────────────────────────┘
```
Settings popover, aligned to the right edge of the toolbar:
```text
┌ Einstellungen ─────────────────────────────┐
│ ANSICHT │
│ Vorschaugröße 200 px │
│ Klein ─────────●────────────── Groß │
│ │
│ ARCHIV-AKTIONEN │
│ [+ Weitere ZIP hinzufügen] │
│ [⇩ Alle Archive als PDF herunterladen] │ shown only when useful
│ ───────────────────────────────────────── │
│ [🗑 Aktuelle Bearbeitung leeren] │ quiet destructive action
└───────────────────────────────────────────┘
```
### Mobile
The toolbar becomes a two-row block immediately below the title and remains in normal document flow.
The status occupies the first row; the two buttons share the second row. The popover uses
`width: min(22rem, calc(100vw - 2rem))`, stacks every action vertically, and relies on collision-aware
positioning rather than fixed bottom/right coordinates. No control or panel may cover the archive
header.
The loaded workspace becomes top-aligned (`justify-start`) instead of viewport-centered, removing the
large empty band between the page heading and first archive.
## Visual system
Preserve the existing Nominandum identity rather than introducing a new theme:
- **Ink** `#122F62`: headings, primary actions, and active controls.
- **Workspace** `#EEF4F6`: page background.
- **Paper** `#FFFFFF`: editor and popover surfaces.
- **Handoff coral** `#EA544B`: restrained accent only; do not use it to make deletion look primary.
- **Success** `#4E8A72`: completed processing state.
- Typography remains **Nunito** for interface copy; use the existing Geist utility face only where
numeric/file metadata already uses it.
- Use existing radii and semantic CSS variables from `src/routes/layout.css`; no theme-level CSS
changes should be necessary.
The distinctive element is the **archive handoff row**: current-work status on the left and “Neue ZIP
bearbeiten” on the right. It expresses the actual legal-document rhythm—finish one beA export, hand
it off, open the next—rather than presenting a generic floating settings cog.
Self-critique: a card full of settings tabs would be a generic dashboard solution and is unnecessary
for one view preference. The revised direction removes the floating action, keeps the next-case task
visible, and spends visual emphasis only on the archive handoff.
## Interaction details
### Toolbar
- Status copy derives from state:
- `1 ZIP · 1 Archiv geöffnet`
- `2 ZIPs · 3 Archive geöffnet`
- while work is pending, append `· 1 wird geöffnet` in an `aria-live="polite"` status.
- “Neue ZIP bearbeiten” uses Lucide `FilePlus2` and remains available after the first archive or an
error. It opens a ZIP-only file picker.
- “Einstellungen” uses Lucide `Settings2`, has a visible German label at desktop sizes, and retains
an explicit accessible name at all widths.
- Do not make the toolbar fixed or sticky in v1. Normal flow avoids covering previews and footer
content; sticky behavior can be reconsidered after testing long archives.
### New ZIP replacement
1. User activates “Neue ZIP bearbeiten”.
2. The system file picker opens and accepts the same ZIP types as `ZipDropzone`.
3. If the picker is cancelled, nothing changes.
4. If valid files are selected, show an `AlertDialog`:
- title: **“Aktuelle Bearbeitung ersetzen?”**
- description: **“Archive und nicht heruntergeladene Änderungen in dieser Ansicht werden
entfernt. Bereits heruntergeladene PDFs bleiben erhalten.”**
- cancel: **“Abbrechen”**
- confirm: **“Neue ZIP öffnen”**
5. On confirm, increment `archiveGeneration`, clear selected files, processed archives, and jobs, then
enqueue the newly selected files against the new generation.
6. If extraction fails, show the existing processing error state for the new ZIP and allow retry or
removal; do not restore the old workspace implicitly.
Keep the selected replacement files in temporary component state until confirmation. This prevents a
cancelled dialog from mutating or clearing the current workspace. Reset the underlying file input
after every selection so choosing the same ZIP again still fires `change`.
### Append, export, and clear
- “Weitere ZIP hinzufügen” uses the existing append path and never clears current archives.
- Show bulk export only when there are at least two processed archives; one archive already has its
own clearer “PDF herunterladen” action.
- Rename bulk export to **“Alle Archive als PDF herunterladen”**.
- Rename deletion to **“Aktuelle Bearbeitung leeren”** and place it after a separator as a quiet
destructive menu item with `Trash2`.
- Clearing requires its own `AlertDialog`; confirmation calls the existing reset path and returns the
user to the initial ZIP dropzone.
- Disable replacement, append, export, and clear while a destructive transition is being committed,
but allow a new selection while archive extraction is in progress; generation guarding prevents
stale results from returning.
### View setting
- Replace the raw range with the shadcn `Slider` labeled **“Vorschaugröße”**.
- Keep the current `100–400 px` range, `1 px` step, clamp function, and `thumbnailWidth`
localStorage key for backward compatibility.
- Display the current value as tabular numerals and include “Klein”/“Groß” endpoint hints.
- The label and current value must be associated with the slider for assistive technology. Arrow-key
adjustment remains available.
## Component and state design
### New `BeaWorkspaceControls.svelte`
Create `src/lib/components/BeaWorkspaceControls.svelte` to keep the already state-heavy route from
absorbing menu markup. Suggested props:
```ts
type Props = {
selectedZipCount: number;
processedArchiveCount: number;
pendingZipCount: number;
thumbnailWidth: number;
onThumbnailWidthChange: (value: number) => void;
onRequestReplacement: () => void;
onAppendFiles: (files: File[]) => void;
onExportAll: () => void;
onClear: () => void;
};
```
The component owns only ephemeral UI state (popover/dialog open state and temporarily selected
replacement files). The route remains the source of truth for files, jobs, archives, and the
persisted thumbnail width.
Centralize the ZIP accept string and validation used by the initial dropzone and both compact pickers.
Prefer extracting a small reusable ZIP picker/input rather than duplicating MIME and extension checks
inside the controls component.
### Route changes
In `src/routes/tools/bea/+page.svelte`:
- Replace `deleteAllZipArchives` with a single `resetWorkspace()` helper that increments
`archiveGeneration` and clears all three state arrays.
- Add `replaceWithZipFiles(files)` that calls `resetWorkspace()` and then
`handleFilesSelected(files)`; the new jobs must capture the incremented generation.
- Keep `handleFilesSelected` as the append operation.
- Render `BeaWorkspaceControls` whenever at least one selected file/job/archive exists.
- Remove the fixed `<details>` block.
- Top-align the loaded-workspace container and render status/controls before processing and editor
cards.
- Preserve `thumbnailWidth` hydration and persistence exactly as today.
### shadcn-svelte pieces
Add only the required official components with the pnpm CLI, reviewing generated files and imports:
- `popover`
- `slider`
- `separator`
- `alert-dialog`
Do not overwrite the customized button component. The project currently imports
`$lib/components/ui/button.svelte` directly, so adapt generated imports to the local export shape if
the registry assumes a barrel. Use Lucide Svelte icons (`FilePlus2`, `Settings2`, `Download`,
`Trash2`) with `data-icon` inside buttons.
## Files
- `src/routes/tools/bea/+page.svelte` — workspace lifecycle, reset/replace handlers, and layout.
- `src/lib/components/BeaWorkspaceControls.svelte` — new toolbar, popover, dialogs, and picker
coordination.
- `src/lib/components/ZipDropzone.svelte` and/or a new small ZIP picker helper — shared accept and
selection behavior.
- `src/lib/components/ui/{popover,slider,separator,alert-dialog}/` — official shadcn-svelte source.
- `tests/e2e/toolbox.spec.ts` — archive handoff, menu, keyboard, and responsive coverage.
- `agents.md` — document append versus replace semantics and the shared reset invariant.
No changes are expected in `zip-processing.ts`, archive transforms, export ordering, or server code.
## Implementation phases
1. **Shared ZIP selection and state helpers**
- Centralize accepted ZIP types.
- Introduce `resetWorkspace` and `replaceWithZipFiles`.
- Verify generation guarding when replacement occurs during extraction.
2. **Workspace toolbar and layout**
- Add the top-aligned status/action row.
- Implement the replacement picker and confirmation without removing current work on cancel.
- Remove the fixed gear and viewport-centering.
3. **Settings popover**
- Add the responsive popover, slider, append, conditional bulk export, and confirmed clear.
- Verify keyboard focus, Escape/outside-click dismissal, and focus return.
4. **Tests and polish**
- Add e2e coverage below.
- Run desktop/mobile browser review and remove any clipped or overlapping labels.
- Update `agents.md` and run the full validation set.
## Verification
### Automated
Add Playwright cases for:
- Load ZIP A → “Neue ZIP bearbeiten” appears → select ZIP B → cancel confirmation → ZIP A remains.
- Repeat replacement → confirm → only ZIP B is shown and no late result from ZIP A can reappear.
- Cancel the system file picker → workspace is unchanged.
- Select the same ZIP twice through replacement → the second `change` is processed.
- “Weitere ZIP hinzufügen” appends ZIP B and both archive editors remain visible.
- Bulk export is absent for one processed archive and available for two or more.
- “Aktuelle Bearbeitung leeren” cancel preserves state; confirm returns to the initial dropzone.
- Slider changes preview width, works with arrow keys, and survives a reload through localStorage.
- Settings opens with a German accessible name, closes on Escape/outside click, and returns focus to
its trigger.
- At 390 × 844, toolbar actions and every popover label fit without horizontal clipping; the panel
stays inside the viewport and does not cover the archive header.
Then run:
```sh
pnpm check
pnpm test:e2e
pnpm lint
pnpm build
```
### Manual
- Review 1440 × 900 and 390 × 844 with one archive, multiple archives, an extraction in progress,
and an extraction error.
- Confirm file processing remains entirely local and no case data is persisted.
- Confirm tab order is title/status → new ZIP → settings → popover controls → archive controls.
- Confirm reduced-motion mode introduces no menu animation requirement.
## Risks and mitigations
- **Accidental loss during replacement:** current state is not cleared until valid files are selected
and the user confirms.
- **Stale async extraction:** every reset increments `archiveGeneration`; old jobs cannot append
results afterward.
- **Duplicate picker logic:** centralize accept/filter/reset behavior before adding compact actions.
- **Component bloat for a small menu:** use one popover and shared alert-dialog primitives; do not add
tabs, a permanent sidebar, or a settings route.
- **Ambiguous “new” versus “additional”:** keep the labels distinct and stable everywhere:
“Neue ZIP bearbeiten” replaces; “Weitere ZIP hinzufügen” appends.
## Out of scope
- Persisting archives or edits across reloads.
- Detecting whether the operating system completed a download.
- Undoing a confirmed workspace replacement.
- Changing PDF composition, attachment ordering, or sub-document behavior.
- A global application settings page.
+237
View File
@@ -0,0 +1,237 @@
# Plan: Anlagen-Bezeichnung — Anlagen nummerieren und benennen
## Goal
Add a new tool **„Anlagen nummerieren“** (`/tools/exhibits`): users drop any number of PDFs, sort
them, choose an exhibit label („Anlage“, „Anlage K“, „Anlage B“, „Anlage XYZ“ or free text), a start
number and the page scope, and receive each PDF stamped with its exhibit number — optionally renamed
to `Anlage_K_06_Originalname.pdf` for court submission.
Context: mail thread with Nikolai Klute (26.09.2026 / 06.10.2026). The requirement is explicitly
_one PDF = one Anlage = one number_, with sortable order, configurable label and counter start, and
optional filename prefixing. Per-page counters are the separate pagination plan.
Related: `plan/done/page-numbers-plan.md` (shared overlay/renderer), `plan/done/stamp-plan.md`,
`plan/done/batch-processing-plan.md`.
---
## Open decisions (defaults marked ✅)
### D1 — Numbering unit
- ✅ **A. One uploaded PDF = one Anlage = one number.** An „Anlage K 6“ may have many pages; the
stamp identifies the document, not the sheet.
- B. Group several PDFs into one Anlage. Not requested; deferred (a group would need its own reorder
UI inside a group).
### D2 — Stamped pages
- ✅ **A. First page by default,** checkbox „Auf jeder Seite stempeln“ for every page of that
exhibit.
- B. Every page by default. Real exhibit stamps usually mark the first page; the stamp tool already
covers „everywhere“.
### D3 — Look
- ✅ **A. Framed exhibit stamp:** `Anlage K 6` in title case, HelveticaBold 14 pt (slider 10–24),
black `#1a1a1a` (color input included, same control as the stamp tool), 2 pt border, 6 pt padding,
no rotation, no date, default position **oben rechts**, 20 pt margin; the six other positions are
selectable.
- B. Reuse the red authority stamp look. Wrong signal: an exhibit label is a designation, not an
endorsement.
### D4 — Label and presets
- ✅ **A. Preset chips + editable text.** Chips `Anlage`, `Anlage K`, `Anlage B`, `Anlage XYZ`
(`maxlength="40"`, default `Anlage`). The stamp text is `${prefix} ${number}` with a single space —
no leading zeros in the stamp (`Anlage K 6`, not `Anlage K 06`).
- B. Fixed list only. Firms use their own exhibit letters.
### D5 — Filename renaming
- ✅ **A. Optional, default on.** Pattern `{prefixSlug}_{paddedNumber}_{originalBase}.pdf`, e.g.
`Anlage_K_06_Klageschrift.pdf`. Prefix slug: trim, collapse whitespace to `_`, strip
`/\:*?"<>|` and control characters, keep German letters. Padding = `max(2, digits of highest
assigned number)`, so 1…99 → `06`, 1…100 → `006`.
Renaming off keeps the original file name exactly (ZIP duplicate names are still deduplicated by
the batch ZIP helper).
- B. Always rename. Users sometimes want to control file names themselves.
### D6 — Ordering
- ✅ **A. Drag-and-drop with a visible handle, plus up/down buttons.** Buttons are the keyboard and
Playwright path; both call the same `moveItem(from, to)`. Reordering is disabled while a run is
active.
- B. Up/down only. Too slow for 30 exhibits.
### D7 — Number freezing
- ✅ **A. Numbers are frozen when a run starts** (`itemId → start + index`). Results keep the number
they were stamped with even if the user reorders afterwards; the list shows
„Bereits erstellte Anlagen behalten ihre Nummer.“ Retry uses the frozen number. A clean renumber
means removing results and running again.
- B. Live renumbering after processing. Would silently invalidate downloaded files and produce
duplicate numbers.
### D8 — Deliverables
- ✅ **A. Per-row downloads + one ZIP „Alle herunterladen“** via the existing batch ZIP engine
(`anlagen_2026-10-06.zip`). A merged „Anlagenkonvolut“ stays out of scope — „PDFs zusammenfügen“
exists.
- B. Always one merged PDF. Not requested and destroys per-exhibit files.
### D9 — Preview
- ✅ **A. Preview only for the selected row**, page 1 rendered on demand (shared
`loadPdfPageRenderer` from the pagination plan), overlay scaled like the stamp preview; selecting
another row disposes the previous handle.
- B. Thumbnail per row. Renders every dropped PDF completely — memory trap.
---
## UX
Route `/tools/exhibits`, title „Anlagen nummerieren“, registry entry next to `stamp`:
```ts
{
slug: 'exhibits',
title: 'Anlagen nummerieren',
description: 'Nummerieren und benennen Sie Anlagen für die Einreichung — mit Anlagenstempel.',
icon: 'files'
}
```
- **Header/description:** „Versehen Sie mehrere PDFs mit fortlaufenden Anlagen-Bezeichnungen und
benennen Sie die Dateien passend um.“
- **FileDropzone** (multiple) — „PDFs als Anlagen hinzufügen“; appending keeps the current list
(same dedupe by name + size as the stamp tool).
- **Numbered list** (`ExhibitFileList.svelte`, not `BatchFileList` — it needs reordering and number
labels):
- per row: drag handle, order badge, computed label („Anlage K 6“), file name, renamed preview
(`→ Anlage_K_06_Klageschrift.pdf`), status chip, per-row download / retry / remove, and a
„Vorschau“ selection.
- live label updates for pending rows when order, prefix or start number changes; done rows show
the frozen label.
- **Options card**
- Bezeichnung: preset chips + text input (`maxlength="40"`), live example „Anlage K 6“.
- Startnummer (number input, default 1, min 0) + hint „3 Dateien → Anlage K 6, 7, 8“.
- „Auf jeder Seite stempeln“ checkbox (default off).
- Position (default oben rechts), Schriftgröße slider 10–24 (default 14), Farbe (default
`#1a1a1a`).
- „Dateien umbenennen“ checkbox (default on) with the filename pattern as helper text.
- **Preview card:** selected row, page 1, overlay of the row's label at the chosen position; page
count of the selected file shown (requires loading it — the renderer reports both).
- **Primary action** „Anlagen erstellen“ → `runBatch` over all pending rows; then per-row downloads
and the ZIP action. While running: „Datei 3 von 7 wird verarbeitet…“ (batch pattern) and
reordering disabled.
- Errors: encrypted PDFs become per-row error rows with the shared decryption hint; an empty prefix
blocks the run with a German validation hint; too-long labels use the overlay's fit/error path.
## Technical approach
### Shared overlay and errors
- Uses `drawTextOverlay` from `src/lib/pdf-text-overlay.ts` (introduced by
`plan/done/page-numbers-plan.md`) with `framed: true`, bold font, `compensatePageRotation: true`.
- Uses `src/lib/pdf-errors.ts` for the encrypted-PDF message.
- If the exhibit plan ships first, it creates both modules and the pagination plan reuses them.
### Batch engine
- Extend `runBatch`'s callback to `(file: File, item: BatchItem) => Promise<BatchProcessResult>` so
the tool can resolve the frozen number per row (existing `(file) => …` callbacks remain valid).
- Keep `shouldProcess`, per-row error formatting and the sequential loop unchanged.
- Extract the ZIP/status logic that currently lives inline in `BatchFileList.svelte` into
`src/lib/batch.ts` (`downloadBatchZip(items, zipBaseName)`, `batchStatusLabel`,
`batchStatusClass`) and reuse it from `BatchFileList` and `ExhibitFileList` without behaviour
changes.
### Naming helper (`src/lib/exhibit-naming.ts`)
```ts
export const buildExhibitLabel = (prefix: string, number: number) => `${prefix.trim()} ${number}`;
export const buildExhibitFileName = (options: {
prefix: string;
number: number;
highestNumber: number;
originalName: string;
}): string => {
const base = options.originalName.replace(/\.pdf$/i, '');
const slug = options.prefix
.trim()
.replace(/\s+/g, '_')
.replace(/[/\\:*?"<>|\u0000-\u001f]/g, '');
const width = Math.max(2, String(options.highestNumber).length);
return `${slug}_${String(options.number).padStart(width, '0')}_${base}.pdf`;
};
```
### Stamping
- Load the PDF with pdf-lib, embed `StandardFonts.HelveticaBold`, draw the label on page 0 (or all
pages), save.
- Result name from the naming helper (or the original name when renaming is off). The batch result
keeps the bytes in memory for download/ZIP.
- Numbering is frozen at run start; `process` resolves `numbers.get(item.id) ?? freeze(item)`.
## Files changed
- `src/routes/tools/exhibits/+page.svelte` — new route.
- `src/lib/components/ExhibitFileList.svelte` — reorderable list with status/downloads.
- `src/lib/exhibit-naming.ts` — label + filename helpers.
- `src/lib/batch.ts`, `src/lib/components/BatchFileList.svelte` — `runBatch` item context, shared
ZIP/status helpers.
- `src/lib/pdf-text-overlay.ts`, `src/lib/pdf-errors.ts`, `src/lib/pdf-thumbnails.ts` — shared
helpers (whichever plan ships first creates them).
- `src/lib/tools.ts`, `src/routes/+page.svelte` — registry + icon map (`Files`).
- `tests/e2e/exhibits.spec.ts` — new spec.
- `agents.md` — route, batch and shared-helper documentation.
## Implementation phases
1. Shared helpers (`pdf-text-overlay`, `pdf-errors`, page renderer) + stamp/compress regression.
2. Batch refactor: `runBatch` item context, ZIP/status extraction, `BatchFileList` unchanged in
behaviour.
3. Naming helper + list component with reorder, live labels and selection.
4. Route: options, stamping, per-row results, ZIP, error rows.
5. Preview panel.
6. Registry/icon/docs.
7. Playwright coverage + command gauntlet.
## Verification
- Three PDFs (1, 2, 3 pages), reorder the second to the front, prefix `Anlage K`, start 6, renaming
on → labels „Anlage K 6/7/8“, downloads named `Anlage_K_06_…`, `Anlage_K_07_…`, `Anlage_K_08_…`;
page 1 of each contains the label, page 2 is untouched (`getPageContentStreamCount` unchanged).
- Start 98 with three files → padding switches to three digits (`098`, `099`, `100`).
- Renaming off → downloads keep the original names; ZIP entries deduplicate on collisions.
- „Auf jeder Seite stempeln“ → every page of every exhibit contains the label.
- Reorder after a run → done rows keep their label and the hint is visible.
- Prefix validation (empty), encrypted PDF (row error, queue continues), 30-file ZIP.
- Single-page PDFs do not break the run.
- `pnpm check`, targeted `pnpm test:e2e`, `pnpm build`, Prettier on touched files.
## Risks
- **Number drift after edits:** mitigated by frozen numbers + hint; no silent renumber.
- **Drag-and-drop accessibility:** buttons remain the primary path and the tested one; DnD must have
a visible handle and keyboard alternative.
- **Memory:** results stay in memory like every batch tool; the existing warning (>20 files or
> 25 MB) applies. The preview renders only one page — never a thumbnail array.
- **Filename edge cases:** slugs from unusual prefixes (`Anlage K/1`) must not produce path
separators or empty names; validate and fall back to `Anlage`.
- **Rotated scans:** the shared overlay compensates page rotation for the new tools; stamp output
must stay unchanged.
- **Already-prefixed input files:** re-running the tool on its own output stacks prefixes. Document
it; no auto-detection in v1.
## Out of scope
- Grouping multiple PDFs into one Anlage.
- Anlagenverzeichnis / index page (a merged table of contents is a follow-up candidate).
- One merged „Anlagenkonvolut“ export.
- Per-page counters (pagination plan).
- Persisted prefixes or exhibit libraries.
+139
View File
@@ -0,0 +1,139 @@
# Plan: PDF-Metadaten — anzeigen und bereinigen
## Goal
Add a new tool **"Metadaten"** (`/tools/metadata`) with two jobs:
1. **Inspect** a PDF: show document metadata (title, author, subject, keywords, creator,
producer, creation/modification dates), page count, file size, and encryption state —
lawyers need to know what a document reveals before forwarding it.
2. **Clean** a PDF: strip the metadata fields and re-save, so the cleaned file can be shared
without leaking software, author names, or edit history.
No rasterization is involved — this tool works directly with pdf-lib and preserves the document
fully (vector text stays selectable). No new dependencies.
---
## Open decisions (defaults marked ✅)
### D1 — Cleaning strategy
- ✅ **A. Neutralize fields + re-save with pdf-lib.** Set title/author/subject/keywords to empty
strings, creator/producer to neutral values, reset creation/modification dates to the cleaning
moment. Saving through pdf-lib rebuilds the file without unreferenced objects. Simple and
dependency-free.
- B. Also attempt to remove embedded files/attachments. pdf-lib has no reliable public API to
enumerate or remove all embedded-file entries; defer to D2.
### D2 — Embedded-file detection
- ✅ **A. Best-effort heuristic warning.** Scan the raw bytes for `/EmbeddedFile` and
`/EmbeddedFiles` markers; if found, show "Die Datei enthält möglicherweise eingebettete
Anlagen. Diese werden beim Bereinigen nicht entfernt." No removal in v1.
- B. No detection at all. Rejected: silent ignorance is worse than an honest warning.
### D3 — Encrypted PDFs
- ✅ **A. Friendly rejection.** pdf-lib cannot load encrypted files; show a German hint pointing
to `tools/decrypt` first. No silent failure.
---
## UX
- Route `/tools/metadata`, new entry in `src/lib/tools.ts`:
`{ slug: 'metadata', title: 'Metadaten', description: 'Zeigen Sie Metadaten eines PDFs an und entfernen Sie sie.', icon: 'info' }`
- icon mapping on the start page.
- Flow: `PdfDropzone` (single PDF) → results card with:
- **Info table** (label/value rows, empty values shown as "–"): Titel, Autor, Betreff,
Schlüsselwörter, Erstellt mit (creator), PDF-Erzeuger (producer), Erstellt am, Geändert am,
Seiten, Größe, Verschlüsselung (Ja/Nein — from load success/failure is insufficient; use
pdf-lib's load error for encrypted detection and show it as a state, or report "Unbekannt"
when not determinable).
- **Embedded-file warning block** when the heuristic fires (amber styling consistent with
existing warning patterns).
- **Quality note**: "Beim Bereinigen bleibt der Inhalt vollständig erhalten (kein Qualitätsverlust)."
- Actions:
- **"Bereinigt herunterladen"** (primary, disabled for encrypted files) → cleaned PDF named
`{baseName}_bereinigt.pdf`.
- Secondary: "Original herunterladen" is _not_ offered (the user already has the original).
- German inline error area for corrupt/encrypted files, matching existing tool pages.
## Technical approach
New route `src/routes/tools/metadata/+page.svelte` plus a small lib module
`src/lib/pdf-metadata.ts`:
```ts
export type PdfMetadataInfo = {
title: string;
author: string;
subject: string;
keywords: string[];
creator: string;
producer: string;
creationDate: Date | null;
modificationDate: Date | null;
pageCount: number;
embeddedFileSuspected: boolean;
};
export const readPdfMetadata = async (bytes: Uint8Array): Promise<PdfMetadataInfo>;
export const stripPdfMetadata = async (bytes: Uint8Array): Promise<Uint8Array>;
```
- `readPdfMetadata`: `PDFDocument.load(bytes)` (with `{ ignoreEncryption: false }` default so
encrypted files throw), then the `getTitle()`/`getAuthor()`/`getSubject()`/`getKeywords()`/
`getCreator()`/`getProducer()`/`getCreationDate()`/`getModificationDate()`/`getPageCount()`
accessors. Encryption detection: catch the pdf-lib `EncryptedPDFError` and return a distinct
`{ encrypted: true }` result shape (discriminated union) so the UI can branch.
- `stripPdfMetadata`: load, apply setters (`setTitle('')`, `setAuthor('')`, `setSubject('')`,
`setKeywords([])`, `setCreator('')`, `setProducer('beA-Edit')`, `setCreationDate(new Date())`,
`setModificationDate(new Date())`), `save()`.
- Embedded-file heuristic: decode bytes with `TextDecoder` once and search for the two marker
strings; cheap, runs before pdf-lib load.
- Reading metadata does **not** need pdfjs or thumbnails — no rasterization limits apply; large
files are fine because pdf-lib parses lazily enough for this purpose.
## Files changed
- `src/lib/pdf-metadata.ts` — new.
- `src/routes/tools/metadata/+page.svelte` — new route.
- `src/lib/tools.ts`, `src/routes/+page.svelte` — registry entry + icon map.
- `tests/e2e/toolbox.spec.ts` — new spec block.
- `agents.md` — structure update.
## Implementation phases
1. `pdf-metadata.ts` with read + strip + heuristic.
2. Route UI: info table, warning block, clean action, encrypted-file branch.
3. Registry entry + icon.
4. Playwright tests: PDF created with `createPdf` + set metadata fields → fields displayed;
clean download loads with pdf-lib and has empty author/title; encrypted-PDF rejection hint
(reuse the encrypt tool to produce a fixture inline).
5. Polish, German copy review, `pnpm check`/`lint`/`build`/`test:e2e`.
## Verification
- Metadata of a known PDF renders completely; empty fields show "–".
- Cleaned PDF: open in viewer, metadata fields empty; content and page count identical; file
still selectable/searchable (no rasterization happened).
- Encrypted input shows the decrypt hint instead of crashing.
- `/EmbeddedFile`-containing fixture triggers the amber warning.
- Standard command gauntlet passes.
## Risks
- **XMP metadata**: pdf-lib's setters touch the Info dict; a separate XMP metadata stream can
survive. Mitigation: note in the UI copy that cleaning covers standard document properties.
(Full XMP removal could be a follow-up by deleting the metadata stream object.)
- **Producer fingerprint**: setting `beA-Edit` as producer is itself a small fingerprint; neutral
but acceptable — alternative is an empty string; pick empty string if reviewers prefer.
## Out of scope
- Removing embedded files/attachments (see D2).
- XMP stream removal (follow-up).
- Metadata editing (rename title, etc.) — inspection + removal only.
- Hooking metadata cleaning into the beA export pipeline.
+250
View File
@@ -0,0 +1,250 @@
# Plan: Paginierung — fortlaufende Blattnummern mit Aktenzeichen
## Goal
Add a new tool **„Paginieren“** (`/tools/pagination`) that stamps every page of one PDF with an
ascending sheet number — the way authorities paginate a case file („Bl. 26“) — optionally combined
with a static free text (e.g. an Aktenzeichen `123 Ja 60/26`) that repeats on every page (default) or
only on the first page. The same operation is offered as a follow-up action after **„PDFs
zusammenfügen“**.
Context: mail thread with Nikolai Klute (26.09.2026 / 06.10.2026). Two clarifications shape this
plan: pagination needs a _simple_ free text next to the tool-generated number (no `{0}/{1}` template
language), and that text should repeat „auf der ersten oder auf jeder Seite (Default)“.
Related: `plan/done/stamp-plan.md` already references this plan as `page-numbers-plan`.
---
## Open decisions (defaults marked ✅)
### D1 — Free-text model
- ✅ **A. Static free text + tool-generated number.** No placeholder syntax: the number is always
produced by the tool, the free text is an optional label rendered with it. Directly matches
Nikolai's answer („Einfach nur ein Text im Zusammenhang mit der Seitenzahl“).
- B. Template field (`Seite {n} von {total}`). Rejected for v1: more validation and support surface
than the use case needs; `{total}` is not requested.
### D2 — What repeats, what increments
- ✅ **A. Two independent scopes.** Page numbers always on every page (that is the point of
pagination). The free text has its own scope: „Auf jeder Seite“ (default) or „Nur auf der ersten
Seite“ — on page 2+ only the number remains.
- B. One shared scope (text scope also gates numbering). Rejected: a first-page-only pagination is
not a thing.
### D3 — Number format
- ✅ **A. Select with four fixed formats** — `Bl. {n}` (default), `Seite {n}`, `S. {n}`, `{n}`.
Internally a prefix/suffix pair, so no user-visible template language, but „Bl.“ as the
German-authority default. Free text is prepended as `„{Text}, “`.
- B. Free prefix/suffix inputs. More knobs, more invalid combinations.
- C. One free format string. Reopened D1.
### D4 — Look and placement
- ✅ **A. Restrained authority style:** Helvetica, 11 pt (slider 8–18), black `#1a1a1a`, no frame,
no rotation, default position **unten mittig** with 28 pt margin; the six other positions are
selectable via the existing `getPositionCoords` presets.
- B. Reuse the stamp tool's framed/bold look. Wrong semantics: a page number is a neutral mark, not
an authority stamp.
### D5 — Files per run
- ✅ **A. Exactly one PDF.** A Gesamtdokument is paginated in one pass; multi-document cases are
handled by „PDFs zusammenfügen“ + handoff. Continuous numbering across several dropped files is a
different (and silently wrong) semantic.
- B. Batch with the same start number per file. Ambiguous for users; deferred until requested.
### D6 — Integration into „PDF Zusammenfügen“
- ✅ **A. Handoff to the pagination tool.** Merge writes the merged bytes into a `File`, queues it via
the existing in-memory file handoff, and navigates to `/tools/pagination`, where options and live
preview live. One implementation of pagination, preview included.
- B. Inline pagination options on the merge page. Duplicated UI and no preview.
### D7 — Preview renderer
- ✅ **A. On-demand single-page renderer.** A paginated case file can have hundreds of pages;
`loadPdfDocument` rasterises _every_ page up front. A new
`loadPdfPageRenderer(bytes)` renders only the visible page and disposes the previous one.
- B. Reuse `loadPdfDocument` (all thumbnails). Unacceptable for long documents.
---
## UX
Route `/tools/pagination`, title „Paginieren“, registry entry after `stamp`:
```ts
{
slug: 'pagination',
title: 'Paginieren',
description: 'Versehen Sie alle Seiten mit fortlaufenden Blattnummern und optional einem Aktenzeichen.',
icon: 'list-ordered'
}
```
Page layout (stamp tool as the pattern: options left, preview right):
- **FileDropzone** (single PDF) — „PDF zum Paginieren hinzufügen“; shows the file name and page count
after loading.
- **Options card**
- **Startnummer** (number input, default 1, min 1, max 99999) with hint „Seite 1 erhält diese
Nummer.“
- **Zusatztext (optional)** — e.g. Aktenzeichen, `maxlength="60"`, placeholder
„z. B. 123 Ja 60/26“, plus radio/checkbox „Auf jeder Seite“ (default) / „Nur auf der ersten
Seite“.
- **Zahlenformat** select: `Bl. {n}`, `Seite {n}`, `S. {n}`, `{n}`.
- **Position** select (default „Unten mittig“), **Schriftgröße** slider 8–18 pt (default 11).
- Live example line: `123 Ja 60/26, Bl. 26`.
- **Preview card** — the current page rendered at reading size with an absolutely positioned overlay
(same technique as the stamp preview), page counter „Seite 3 von 42“, prev/next buttons and a
„Springe zu Seite“ number input (long documents). The overlay shows the number that page will
actually receive (`start + pageIndex`). Label it „Annäherung an die Ausgabe im PDF“.
- **Primary action** „Paginieren & herunterladen“ → `{base}_paginiert.pdf`. Options are read at click
time, so the button can be pressed again after changing settings.
- Errors: encrypted PDF → the shared German hint pointing to „Passwort entfernen“; text too long for
a page → German error instead of silent clipping.
## Technical approach
### Shared text overlay (`src/lib/pdf-text-overlay.ts`)
Extract the reusable parts of `src/routes/tools/stamp/+page.svelte` into a module used by stamp,
pagination and exhibit numbering:
```ts
export type TextOverlayOptions = {
font: PDFFont;
text: string;
size: number;
color: RGB;
position: OverlayPosition;
margin?: number;
padding?: number;
framed?: boolean;
borderWidth?: number;
rotation?: number; // degrees
minSize?: number; // shrink floor before throwing
compensatePageRotation?: boolean; // default false; new tools set true
};
export const drawTextOverlay = (page: PDFPage, options: TextOverlayOptions) => void;
```
- Owns fit-to-page shrinking (loop down to `minSize`), the rotated placement maths already present in
the stamp route (`getRotatedStampPlacement`), optional border, and the text draw.
- The stamp route is rewired to call it with `framed: true`, `rotation: value` and its bold font;
behaviour must stay identical (guard: `tests/e2e/stamp.spec.ts`).
- `compensatePageRotation` maps bottom-center on a `/Rotate 90` scan to the correct user-space anchor
and counter-rotates the text. Stamp keeps `false` so its output does not change.
- Move `isEncryptedPdfError` / the encrypted-PDF message into `src/lib/pdf-errors.ts` so all three
tools share one wording.
### Pagination
```ts
const numberText = `${formatPrefix}${startNumber + pageIndex}${formatSuffix}`;
const textVisible = textScope === 'all' || pageIndex === 0;
const line = textVisible && freeText.trim() ? `${freeText.trim()}, ${numberText}` : numberText;
for (const [pageIndex, page] of pdfDoc.getPages().entries()) {
drawTextOverlay(page, {
font,
text: line,
size,
color: parseHexColor('#1a1a1a'),
position,
margin: 28,
compensatePageRotation: true
});
}
```
- Embed one `StandardFonts.Helvetica` per document, save, name `${base}_paginiert.pdf`.
- Page count comes from pdfjs (`numPages`) for the preview and from pdf-lib for processing; both are
cheap, no full rasterisation.
- **Renderer:** extend `src/lib/pdf-thumbnails.ts` with
`loadPdfPageRenderer(bytes): Promise<{ pageCount, render(pageNumber), destroy() }>`; `render`
builds the canvas from the requested page's own viewport (mixed page sizes stay correct), revokes
the previous object URL, and `destroy` tears down the pdfjs loading task. Reused by the exhibit
tool's page-1 preview.
### Merge integration
- Generalise `src/lib/pending-files.ts`: `queueZipFiles` / `takeQueuedZipFiles` become
`queuePendingFiles` / `takePendingFiles` (same behaviour; update `src/routes/+page.svelte` and
`src/routes/tools/bea/+page.svelte`).
- `src/routes/tools/merge/+page.svelte`: keep „PDFs zusammenfügen“ (direct download) and add
„Zusammenfügen & paginieren“, which merges, wraps the bytes in
`new File([bytes], 'zusammengefuegt.pdf', { type: 'application/pdf' })`, queues it and calls
`goto('/tools/pagination')`. Keep the change minimal — the page still uses older styling tokens;
do not restyle it in this task.
- The pagination route consumes `takePendingFiles()` in `onMount`, accepts the first PDF and ignores
anything else defensively.
### Registry and docs
- `src/lib/tools.ts` + `iconMap` in `src/routes/+page.svelte` (`ListOrdered`).
- `agents.md`: new route, shared overlay/error helpers, renderer, pending-file naming, merge handoff.
## Files changed
- `src/routes/tools/pagination/+page.svelte` — new route.
- `src/lib/pdf-text-overlay.ts`, `src/lib/pdf-errors.ts` — extracted shared helpers.
- `src/routes/tools/stamp/+page.svelte` — use the shared helpers (no behaviour change).
- `src/lib/pdf-thumbnails.ts` — add `loadPdfPageRenderer`.
- `src/lib/pending-files.ts`, `src/routes/+page.svelte`, `src/routes/tools/bea/+page.svelte` —
generic pending-file naming.
- `src/routes/tools/merge/+page.svelte` — handoff button.
- `src/lib/tools.ts`, `src/routes/+page.svelte` — registry + icon map.
- `tests/e2e/pagination.spec.ts`, `tests/e2e/merge.spec.ts` — new/updated specs.
- `agents.md` — structure and flow documentation.
## Implementation phases
1. Extract `pdf-text-overlay.ts` + `pdf-errors.ts`, rewire stamp, keep `stamp.spec.ts` green.
2. Add `loadPdfPageRenderer` and use it for the preview.
3. Build the pagination route (options, live preview, export, errors).
4. Merge handoff + generic pending files.
5. Registry/icon/docs.
6. Playwright coverage + command gauntlet.
## Verification
- 3-page PDF, start 26, text `123 Ja 60/26`, all pages → each page's content stream contains
`123 Ja 60/26, Bl. 26/27/28`; page count and page order preserved; download named
`akte_paginiert.pdf`.
- First-page-only text → page 1 contains the Aktenzeichen, page 2 contains `Bl. 2` but not the
Aktenzeichen; the original page content is preserved on every page.
- Formats `Seite {n}` / `{n}` and empty free text → expected strings.
- Preview: navigating to page 2 shows `Bl. 27` (start 26); changing the start number updates the
preview before export.
- Rotated input (`createPreRotatedPdf`) → number appears at the visual bottom-center (test asserts
the draw coordinates in the content stream or compares stream text plus position).
- Merge: two PDFs (1 + 2 pages) → „Zusammenfügen & paginieren“ lands on `/tools/pagination` with a
3-page document; running produces continuous `Bl. 1…3`.
- Encrypted PDF → German error, no download.
- `pnpm check`, targeted `pnpm test:e2e`, `pnpm build`, Prettier on touched files.
## Risks
- **Long documents:** never call `loadPdfDocument` here; one page in memory at a time. The preview
navigation must not leak object URLs or pdfjs tasks.
- **Text length on narrow pages:** fit-to-page shrinks to `minSize`; below that, fail with a German
message rather than clipping.
- **Unsupported characters:** StandardFonts use WinAnsi; validate the free text (e.g. no emoji) and
show a German hint instead of a pdf-lib save error.
- **Rotated pages:** compensation is new logic; if the mapping turns out unreliable, ship v1 with a
visible warning for rotated pages instead of silently misplaced numbers.
- **Preview fidelity:** browser font metrics differ slightly from pdf-lib; the panel is labelled as
an approximation.
## Out of scope
- Placeholder templates (`{total}`, Roman numerals, ranges) and numbering only selected pages.
- Multiple files per run with continuous numbering (use merge first).
- Persisted pagination settings.
- Automatic pagination inside the beA archive export.
+140
View File
@@ -0,0 +1,140 @@
# Plan: Seiten drehen
## Goal
Add a new tool **"Seiten drehen"** (`/tools/rotate`) that fixes page orientation — the classic
case being scanned court mail with individual pages rotated 90°/180°. Users rotate pages
individually or all at once; the result is saved via the standard PDF `/Rotate` mechanism, so the
operation is **lossless and instant** (no rasterization, no re-encoding).
No new dependencies.
---
## Open decisions (defaults marked ✅)
### D1 — How rotation is applied
- ✅ **A. pdf-lib `setRotation` (document-level `/Rotate`).** `page.setRotation(degrees(angle))`
writes the page's rotation entry; every viewer honors it. Lossless, fast, file size unchanged.
Existing rotation of a page (from scanners that already set `/Rotate`) is read via
`page.getRotation().angle` and added to, so the user's delta is always applied on top of the
current state.
- B. Rasterize-and-rotate pixels. Unnecessary quality loss and memory cost; rejected.
### D2 — Grid capabilities
- ✅ **Extend `PdfPageGrid` with an optional action snippet.** The current grid renders fixed
cards. Add an optional snippet prop (e.g. `pageActions`) rendered below each thumbnail; existing
call sites (separate, watermark, extract) pass nothing and look unchanged. The rotate page uses
it for per-page rotate buttons and a rotation badge.
- B. Fork the grid inside the rotate page. Rejected: duplicates the accessible grid work.
---
## UX
- Route `/tools/rotate`, new `src/lib/tools.ts` entry:
`{ slug: 'rotate', title: 'Seiten drehen', description: 'Drehen Sie einzelne oder alle Seiten eines PDFs.', icon: 'rotate-cw' }`
- icon map entry.
- After load (`loadPdfDocument` + thumbnails like separate):
- Quick-action bar: **"Alle 90° rechts"**, **"Alle 90° links"**, **"Alle 180°"**,
**"Zurücksetzen"** (clears all deltas).
- Page grid: each card shows the thumbnail with a CSS rotation preview
(`transform: rotate({total}deg)` on the `<img>`, contained in a square-ish frame so rotated
pages still fit), a small badge when the total rotation is non-zero (e.g. "90°"), and two
icon buttons (rotate left / rotate right) provided through the new `pageActions` snippet.
Buttons are real `<button>` elements with `aria-label="Seite 3 um 90° nach rechts drehen"`.
- Count label: "4 von 12 Seiten gedreht".
- Primary button **"Drehen & herunterladen"** → `{baseName}_gedreht.pdf`. Disabled when no page
has a non-zero total rotation change.
- Keyboard operability is inherent (all buttons); no drag interactions required at all.
## Technical approach
### State
```ts
let rotations = $state(new Map<number, number>()); // pageNumber → delta degrees (0/90/180/270)
```
- `rotatePage(pageNumber, delta)`: `next = ((current + delta) % 360 + 360) % 360`; store; delete
entry when back to 0 (keeps "gedreht" count honest).
- Preview angle: `originalRotation(page) + delta` — the thumbnail renderer cannot know the
original `/Rotate` without pdf-lib; simplest correct-enough approach: apply only the delta to
the preview (pdfjs already renders pages honoring `/Rotate`, so the preview matches what the
user sees plus their delta). No need to read pdf-lib during preview.
### Export
```ts
const pdfDoc = await PDFDocument.load(bytes);
for (const page of pdfDoc.getPages()) {
const delta = rotations.get(pageIndexPlusOne);
if (!delta) continue;
const current = page.getRotation().angle; // respects existing /Rotate
page.setRotation(degrees((((current + delta) % 360) + 360) % 360));
}
downloadBlob(await pdfDoc.save(), `${baseName}_gedreht.pdf`);
```
- Instant for any file size — no rasterization limits needed, but keep the shared load error
handling (encrypted files: pdf-lib load fails → German error; optionally hint to decrypt like
the metadata plan does).
### `PdfPageGrid` extension (backward compatible)
```ts
type Props = {
thumbnails: PdfThumbnail[];
selectedPages?: Set<number>;
onPageClick?: (pageNumber: number) => void;
thumbnailWidth?: number;
class?: string;
pageActions?: Snippet<[pageNumber: number]>; // NEW, optional
cardClass?: (pageNumber: number) => string; // NEW, optional — rotate preview transform hook
};
```
- When `pageActions` is provided, render `{@render pageActions(thumb.pageNumber)}` inside the
card. When `cardClass` is provided, merge it into the image wrapper via `cn()`. Existing pages
need no changes.
## Files changed
- `src/lib/components/PdfPageGrid.svelte` — optional snippet/class props.
- `src/routes/tools/rotate/+page.svelte` — new route.
- `src/lib/tools.ts`, `src/routes/+page.svelte` — registry + icon map.
- `tests/e2e/toolbox.spec.ts` — spec block.
- `agents.md` — structure update.
## Implementation phases
1. `PdfPageGrid` extension; run existing e2e to prove separate/watermark visuals unchanged.
2. Rotate route: state, quick actions, grid with badges/buttons, CSS preview rotation.
3. Export via `setRotation`.
4. Registry + icon.
5. Playwright: rotate page 2 by 90° twice via its buttons, download, load output with pdf-lib,
assert `getPage(1).getRotation().angle === 180`; "Zurücksetzen" disables the download button.
6. Polish + `pnpm check`/`lint`/`build`/`test:e2e`.
## Verification
- Single page rotate, all-page rotate, reset flows.
- A PDF whose pages already carry `/Rotate=90` gains a correct cumulative rotation (assert in e2e
by creating such a fixture with pdf-lib).
- Output opens with correct orientation in a viewer; file size ~unchanged.
- Grid cards stay within layout bounds when rotated (object-contain + fixed aspect frame).
## Risks
- **CSS preview vs. real rotation mismatch** for pages that already carry `/Rotate`: pdfjs
thumbnails render the page as displayed; the preview adds the delta visually — acceptable.
- Rotated landscape images inside the grid may look small in portrait cards; use `object-contain`
and a square min-height frame.
## Out of scope
- Auto-detection of upside-down scans (needs heuristics/OCR).
- Rotating arbitrary selections only via a separate tool (quick actions + per-page buttons cover
it).
+139
View File
@@ -0,0 +1,139 @@
# Plan: Stempeln — Textstempel auf PDFs
## Goal
Add a new tool **"Stempeln"** (`/tools/stamp`) that places opaque, framed text stamps on PDF
pages — "BEGLAUBIGT", "EILT", "ENTWURF", "KOPIE", "AUSFERTIGUNG", plus free text with an optional
date, or an image. This is a staple of German legal/case-file work and is deliberately separate from the
watermark tool:
- Watermark = semi-transparent, decorative/repetitive, many positions.
- Stamp = fully opaque, framed, authoritative marking, usually one per page.
Pure pdf-lib drawing (text + rectangle border): lossless, instant. No new dependencies.
---
## Open decisions (defaults marked ✅)
### D1 — Stamp look
- ✅ **A. Uppercase bold text inside a plain rectangle border** (2 pt border, padding 8 pt,
default color red `#cc1a1a`, matching the existing brand red used by the watermark default).
pdf-lib has no rounded-rectangle primitive; plain rectangle is the honest option.
- B. Borderless colored text. Possible but reads as a watermark, not a stamp.
### D2 — Presets
- ✅ **Editable preset chips + free text.** Chips: BEGLAUBIGT, AUSFERTIGUNG, KOPIE, EILT,
ENTWURF, ERLEDIGT. Selecting a chip fills the text input; the input stays editable. Optional
checkbox "Datum anhängen" appends `dd.mm.yyyy` (German format).
- B. Fixed presets only. Rejected: courts/firms love their own wording.
### D3 — Relation to the watermark tool
- ✅ **Separate tool**, sharing extracted helpers from `src/lib/pdf-overlay.ts`
(position coordinate computation + hex color parsing — see page-numbers-plan; whichever plan is
implemented first creates the module). Keeps discovery simple (one tile per job) and avoids
overloading the watermark UI with a mode switch.
---
## UX
- Route `/tools/stamp`, new `src/lib/tools.ts` entry:
`{ slug: 'stamp', title: 'Stempeln', description: 'Versehen Sie Seiten mit Textstempeln wie „Beglaubigt“ oder „Eilt“.', icon: 'stamp' }`
(`stamp` is a real Lucide icon name) + icon map entry.
- After load (`loadPdfDocument` for thumbnails):
- **Options card**:
- Preset chip row (buttons; selected chip highlighted).
- Text input (bound, default "BEGLAUBIGT"), "Datum anhängen" checkbox.
- Schriftgröße slider (12–48 pt, default 24).
- Farbe color input (default `#cc1a1a`).
- Position select — same six positions + center as watermark
(reuses shared position helper; no "diagonal" for stamps).
- Rotation slider (−45°…45°, default 0) — stamps are often applied slightly tilted.
- Apply-to: "Auf alle Seiten anwenden" checkbox + `PdfPageGrid` selection when off
(watermark pattern).
- **Live preview**: rendered current page (pdfjs) with an absolutely-positioned overlay div
styled like the stamp (border, uppercase, color, rotation) at the chosen position; page
navigation. Approximation is fine, same approach as page-numbers-plan.
- Primary button **"Stempel anwenden & herunterladen"** → `{baseName}_stempel.pdf`.
## Technical approach
### Export (pdf-lib)
```ts
const font = await pdfDoc.embedFont(StandardFonts.HelveticaBold);
const stampText = `${textInput.trim().toUpperCase()}${withDate ? ` ${formatGermanDate(new Date())}` : ''}`;
const textWidth = font.widthOfTextAtSize(stampText, size);
const boxWidth = textWidth + 2 * padding;
const boxHeight = font.heightAtSize(size) + 2 * padding;
for (const pageIndex of targetPages) {
const { width, height } = pages[pageIndex].getSize();
const { x, y } = getPositionCoords(width, height, boxWidth, boxHeight, position); // shared helper
pages[pageIndex].drawRectangle({
x, y, width: boxWidth, height: boxHeight,
borderColor: parseHexColor(colorValue), borderWidth: 2
});
pages[pageIndex].drawText(stampText, {
x: x + padding, y: y + padding + /* baseline adjust */, size, font,
color: parseHexColor(colorValue), rotate: degrees(rotationValue)
});
}
```
Details:
- Rotation is applied to the text; the border rectangle stays axis-aligned for v1 (rotated
rectangles need manual path drawing — follow-up if users care).
- Baseline adjustment: use `font.heightAtSize(size) * 0.2` descent approximation; verify visually.
- Uppercasing happens in JS (`toUpperCase()` — German umlauts are fine in WinAnsi: ä/ö/ü/ß are
supported except ẞ; uppercase ß → SS via `toLocaleUpperCase('de')` acceptable).
- Encrypted input → German error + decrypt hint (shared pattern).
### Shared helpers
Depends on `src/lib/pdf-overlay.ts` (position presets + `parseHexColor`). If page-numbers or
stamp ships first, it creates the module and rewires watermark; the second consumer just imports.
## Files changed
- `src/routes/tools/stamp/+page.svelte` — new route.
- `src/lib/pdf-overlay.ts` — shared helpers (created here if not already present).
- `src/lib/tools.ts`, `src/routes/+page.svelte` — registry + icon map.
- `tests/e2e/toolbox.spec.ts` — spec block.
- `agents.md` — structure update.
## Implementation phases
1. Shared overlay helpers (or import if they exist) — watermark regression must stay green.
2. Route UI: presets, inputs, preview overlay, page selection.
3. Export + download.
4. Registry + icon.
5. Playwright: preset "EILT" on all pages of a 3-page PDF → output valid, 3 pages; custom text
- date checkbox adds text; selection-only mode leaves other pages unmodified (compare page
content-stream sizes via pdf-lib if practical, else file-level validity + count).
6. Polish + command gauntlet.
## Verification
- Every preset chip fills the input; the stamp renders uppercase in preview and output.
- Date uses German `dd.mm.yyyy` and updates on the export day (not cached).
- Rotation visually matches preview within tolerance.
- Watermark tool unchanged (regression).
## Risks
- **Text width overflow** on small pages with long custom text + huge font: clamp computed box to
page size minus margins and shrink font if needed (simple guard loop), with a German hint.
- Preview overlay approximation may differ a few points from export — acceptable, labeled.
## Out of scope
- Image stamps (logo stamps) — users can route through the watermark image mode for that.
- Rotated border rectangles, multi-stamps per page in one run (run the tool twice; output can be
re-loaded).
- Stamp "libraries" persisted across sessions.
+126
View File
@@ -0,0 +1,126 @@
# Plan: Start page — make the beA context obvious
**Status: Done**
## Problem
The start page (`src/routes/+page.svelte`) presents itself as a generic "PDF-Werkzeuge" site:
- Hero headline: **"PDF-Werkzeuge"**
- ZIP dropzone: label **"ZIP-Archiv bearbeiten"**, sublabel "ZIP-Datei hier ablegen oder zum
Auswählen klicken", aria label "ZIP-Archiv bearbeiten"
- Drag overlay: **"ZIP-Archiv hier ablegen"**
- `<title>`: `juri-merger — PDF-Werkzeuge`
The ZIP the user is supposed to drop here is, in reality, an archive exported from the
**besondere elektronische Anwaltspostfach (beA)** — that's what `/tools/bea` processes and what
the whole product ("beA-Edit", see `agents.md`) exists for. Nothing on the start page says so.
A user seeing any random ZIP has no hint which one belongs in this slot.
Goal: keep the toolbox-hub structure (hero + beA entry point + tool tiles), but reframe the copy
so that (a) the beA origin of the expected ZIP is unmistakable and (b) the overall positioning
shifts from generic "PDF tools" to "workbench for beA exports (+ extra PDF tools)".
## Target copy (proposal)
### 1. Hero / page identity
- `<title>`: from `juri-merger — PDF-Werkzeuge` to
`beA-Edit — Werkzeugkasten für das besondere elektronische Anwaltspostfach` (long form) or
`… für beA-Archive` (short form, see D1).
- H1: from `PDF-Werkzeuge` to **`Werkzeugkasten für beA`**.
- Subline: currently only the generic privacy line. Replace with a lead sentence explaining beA:
„Bereiten Sie ZIP-Archive aus dem besonderen elektronischen Anwaltspostfach (beA) auf — plus
praktische PDF-Werkzeuge.“ — then keep the existing privacy line as a second sentence.
This keeps "PDF-Werkzeuge" discoverable for anyone who knew the old wording but leads with beA.
### 2. ZIP entry point (the core fix)
- Label: **„beA-ZIP öffnen"** instead of "ZIP-Archiv bearbeiten".
- Sublabel: „ZIP aus dem besonderen elektronischen Anwaltspostfach (beA) hier ablegen oder zum
Auswählen klicken".
- Add a small example/hint line inside or under the dropzone:
„Tipp: In beA exportierte Archive enden meist auf `.zip` und enthalten Anlagen als PDF."
(exact wording see D2 — including whether to show typical archive contents)
- Icon inside the dropzone next to the "+" (e.g. Lucide `Mail`/`Postbox`-like or `Archive`)
to visually distinguish it from the plain-PDF tool tiles below.
- Dropzone keeps `accept=".zip,…"`. No functional change to filtering — dropping any ZIP still
routes to `/tools/bea`.
### 3. Full-window drag overlay
- Text: **„beA-ZIP hier ablegen"** (currently "ZIP-Archiv hier ablegen"), optionally with the
subline „Archive aus dem besonderen elektronischen Anwaltspostfach".
### 4. Tool grid section heading
- Currently: „Werkzeuge". Change to **„Weitere PDF-Werkzeuge"** — this is where the words
"PDF-Werkzeuge" live on after the hero shift, clarifying these tiles are the _secondary_,
general-purpose part while beA sits above.
## Code touchpoints
All copy lives in exactly three files; no component API changes required:
1. `src/routes/+page.svelte`
- `<title>`, H1 + sublines in the Hero block
- props passed to `FileDropzone`: `ariaLabel`, `label`, `sublabel` (add a hint element below
the dropzone if D2 decides for it)
- drag overlay text
- "Werkzeuge" → "Weitere PDF-Werkzeuge"
1. `src/routes/+layout.svelte` — only if global header/footer copy mentions the product role
(check during implementation; likely unchanged).
2. `tests/e2e/toolbox.spec.ts`
- Line ~88 asserts the heading name `'PDF-Werkzeuge'` → update to the new H1
(`'Werkzeugkasten für beA'`).
- Any other selectors keyed on the old labels must be swept (search tests for
"ZIP-Archiv" / "PDF-Werkzeuge").
Accessibility notes:
- The new sublabel contains the full term + abbreviation once per page; use `abbr`-free plain
text (screen readers handle "beA" fine as-is). Keep `aria-label` identical to the visible
label to avoid divergence.
## Open decisions (defaults marked ✅)
### D1 — How far to push the rebrand?
- ✅ **A. Lead with beA, keep toolbox**: H1 "Werkzeugkasten für beA", tool grid becomes
"Weitere PDF-Werkzeuge". Minimal risk, preserves both audiences.
- B. Full rename to "beA-Editor": hero only about beA, tools demoted into an opt-in section
further down. Stronger positioning, hides the newer tools behind scroll/click.
### D2 — Where does the explainer text live?
- ✅ **A. Short hint line inside/below the dropzone** (one sentence naming "besonderes
elektronisches Anwaltspostfach"). Cheap, always visible where it matters.
- B. Info popover/question-mark icon expanding details (typical file names, how to export from
beA). More room, more moving parts.
- C. Both A and B if users actually need export instructions (needs user feedback).
### D3 — Also update other beA-named strings for consistency?
- ✅ **A. Yes, sweep for consistency**: e.g. documents/README references and `ZipDropzone.svelte`
sublabels inside `/tools/bea` should also say "beA-ZIP" so terminology matches across pages.
Copy-only sweep, no behavior change.
- B. Only the start page; leave inner pages untouched.
## Implementation phases
Single phase — this is copy + one test file.
1. Apply copy changes in `src/routes/+page.svelte` (+ optional consistency sweep per D3).
2. Update `tests/e2e/toolbox.spec.ts` expectations.
3. Run `pnpm check`, `pnpm lint`, `pnpm build`, and the e2e suite.
4. Manual browser pass: hero, dropzone, drag-overlay, keyboard focus order unchanged;
verify window-wide drag of a ZIP still lands on `/tools/bea`.
5. Update `agents.md` / README wording if D3 = A touches those files.
## Risks
- Very low. Pure copy change; only test breakage possible (heading/label assertions), covered
by running the spec suite. SSR-safe: no new logic, all changes static markup.
+259
View File
@@ -0,0 +1,259 @@
# Plan: Start-page redesign — PDF toolbox
## Goal
Today the start page (`src/routes/+page.svelte`) offers exactly one entry point: the ZIP dropzone
that starts the beA archive workflow. This redesign turns the start page into a **toolbox hub**:
1. The existing **ZIP upload** (beA workflow) stays available — visually prominent as the
"hero" action of the product.
2. Eight additional tools become reachable as tiles from the start page:
- **Wasserzeichen** — add a watermark to a PDF
- **Signieren** — sign a PDF
- **Passwort setzen** — encrypt a PDF with a password
- **Passwort entfernen** — unlock a password-protected PDF
- **Konvertieren** — convert a PDF
- **Komprimieren** — compress a PDF
- **Seiten entfernen** — remove pages from (separate) a PDF
- **PDFs zusammenfügen** — merge PDFs
All processing remains **100 % client-side** (no uploads, no network requests, no persistence),
in line with the project's privacy constraints.
---
## Target UX
### Start page (`/`)
- The Nominandum header/footer and overall visual language stay untouched.
- A compact hero area with the product name and a one-line value proposition.
- A **large primary tile** for the beA workflow ("ZIP-Archiv bearbeiten") — it keeps the
full-height dropzone feel: dragging a ZIP anywhere on the start page still starts the workflow.
- A **grid of secondary tool tiles** (2–3 columns responsive, `grid` + `gap-*`, white cards on the
pale blue-gray background, Lucide icon + German title + one-line description, visible focus and
keyboard operability — tiles are real links/buttons, not divs).
- German copy throughout (project constraint).
### Tool pages
- Each tool gets its **own route** under `src/routes/tools/…` (see D1):
`tools/bea`, `tools/merge`, `tools/separate`, `tools/watermark`, `tools/sign`,
`tools/encrypt`, `tools/decrypt`, `tools/convert`, `tools/compress`.
- A shared layout (`tools/+layout.svelte`) provides a consistent shell: back-link to `/`,
tool title, and the file-pick area.
- The existing ZIP workflow component tree (`ZipDropzone`, `ProcessedZipArchiveEditor`,
`SubDocumentEditor`, `AttachmentPreview`) moves to `tools/bea` essentially unchanged.
---
## Technical approach per tool
| Tool | Route | Input | Processing | Output |
| ----------------------- | ----------------- | ------------------------ | ------------------------------------ | ---------------------- |
| beA workflow | `tools/bea` | `.zip` (multiple) | unchanged existing pipeline | merged/separate PDFs |
| Merge | `tools/merge` | multiple PDFs | pdf-lib copy of pages in order | one PDF |
| Separate / remove pages | `tools/separate` | one PDF | pdf-lib copy with page selection | one PDF |
| Watermark | `tools/watermark` | one PDF | pdf-lib overlay on every page | one PDF |
| Sign | `tools/sign` | one PDF | pdf-lib places signature image | one PDF |
| Encrypt | `tools/encrypt` | one PDF | encryption library (see D5) | password-protected PDF |
| Unlock | `tools/decrypt` | protected PDF + password | pdfjs (password) → rebuild (see D6) | unprotected PDF |
| Compress | `tools/compress` | one PDF | pdfjs rasterize + re-encode (see D7) | smaller PDF |
| Convert | `tools/convert` | one PDF | pdfjs render pages (see D4) | images or PDF variant |
### 1. Merge PDFs
- Load each selected PDF with pdf-lib, copy pages in the (reorderable) list order into a new
document. This is a generalization of the existing `mergeProcessedZipArchive` logic — extract a
reusable `mergePdfFiles(files: File[]): Promise<Uint8Array>` helper.
### 2. Separate / remove pages
- Render page thumbnails (reuse the pdfjs pattern from `AttachmentPreview.svelte`), show a page
grid with selection; the user deselects/removes pages and downloads the remaining document.
- Page selection must also work with keyboard focus (project accessibility rule).
### 3. Watermark
- Options: text (or uploaded PNG/JPG image), position (center/diagonal/corners), opacity, font
size, page range (all / selected). Drawn with pdf-lib `drawText`/`drawImage` on each selected page.
- No new dependency.
### 4. Sign
- Default proposal (see D3): **visual signature**. The user draws a signature in a canvas element
(mouse/touch/pen), or types a name in a handwriting-like font, or uploads a signature image.
The signature is exported as a transparent PNG and placed by clicking/tapping the desired
position on a rendered page (pdfjs preview + pdf-lib placement).
- PAdES/cryptographic signing is **not** part of the default scope.
### 5. Set password (encrypt)
- pdf-lib does **not** support encryption → a new dependency is unavoidable (see D5).
Primary candidate: `@cantoo/pdf-lib` (drop-in fork of pdf-lib with AES encryption support),
alternative: `qpdf-wasm`.
- UI: password + confirmation field, optional permissions flags (print/copy allowed?).
### 6. Unlock (remove password)
- pdfjs-dist can open password-protected PDFs when given the password; pdf-lib cannot load them.
- Default approach (see D6): open with pdfjs using the password, then **rasterize** each page to a
canvas and rebuild a PDF via pdf-lib. Known tradeoff: text becomes images (no selectable text,
larger file, no vector quality). This is documented to the user in the UI.
- Failing passwords must surface a clear German error message, not a console-only error.
### 7. Compress
- Browser-only compression is realistically rasterization-based: render each page with pdfjs at a
chosen DPI/scale, encode as JPEG at a chosen quality, rebuild the PDF.
- UI presents target presets (e.g. "Starke Komprimierung" / "Ausgewogen" / "Leicht") and shows the
resulting size estimate before download. Same vector→raster caveat as unlock, shown in the UI.
### 8. Convert
- Default scope (see D4): **PDF → images** (PNG or JPEG, one file per page, downloaded as separate
files or a ZIP via fflate — fflate is already a dependency).
- Other directions (images/Office → PDF) already exist in the beA flow for images; extending
convert beyond PDF→images is out of scope for v1.
---
## Shared infrastructure (new `src/lib` pieces)
- **`PdfDropzone` component** — generalization of `ZipDropzone` with configurable `accept` filter
(`.pdf` vs `.zip`) and drag state. `ZipDropzone` is refactored on top of it (or replaced).
- **`usePdfThumbnails` helper / `PdfPageGrid` component** — shared page-thumbnail rendering for
separate/sign/watermark/convert/compress, modeled on `AttachmentPreview.svelte`'s memory rules
(dynamic pdfjs import, byte copies before render, revoked object URLs, destroyed loading tasks).
- **`downloadBlob()` helper** — the current inline `downloadPdfBytes` in `+page.svelte` moves to a
shared `src/lib/download.ts` and gains a generic filename/MIME signature.
- **Tool registry** (`src/lib/tools.ts`) — typed list of `{ slug, title, description, icon }`
consumed by the start page tile grid, so adding a tool later means one entry + one route.
- All browser-only work stays in event handlers / `onMount` (SSR-safe, per `agents.md`).
- File-size/memory guard: cap or warn on very large PDFs before rasterization-heavy tools
(unlock/compress/convert).
## Dependencies
- **New runtime dependency** (only for encrypt): `@cantoo/pdf-lib` **or** `qpdf-wasm` — decision D5.
All other tools run on the existing stack (pdf-lib, pdfjs-dist, fflate).
---
## Implementation phases
Each phase ends with `pnpm check`, `pnpm lint`, `pnpm build` and a manual browser pass
(prettier formatting on changed files).
### Phase 0 — Groundwork
1. Create `tools/+layout.svelte` shell and `src/lib/tools.ts` registry.
2. Move the existing ZIP workflow from `+page.svelte` to `tools/bea/+page.svelte` unchanged
(keep the settings popover, thumbnail width localStorage, bulk export).
3. Build the new start page: hero + beA tile + (initially) tool tiles that link to stub routes
with a "coming soon" state? — no: see D8; default is to ship phases together and leave
tiles hidden until their tool exists.
4. Update `agents.md` repository structure and flow sections.
### Phase 1 — No-new-dependency tools
1. `PdfDropzone`, `download.ts`, `PdfPageGrid`/thumbnail helper.
2. `tools/merge` (reusable `mergePdfFiles` helper).
3. `tools/separate` (page grid, selection, export).
4. `tools/watermark`.
### Phase 2 — Signature
1. `tools/sign`: canvas signature capture (draw/type/upload), placement preview, export.
### Phase 3 — Password tools
1. Add the chosen encryption dependency; `tools/encrypt`.
2. `tools/decrypt` (pdfjs password + rasterized rebuild) with explicit quality warning in UI.
### Phase 4 — Rasterization tools
1. `tools/compress` (DPI/quality presets, size preview).
2. `tools/convert` (PDF → PNG/JPEG, separate files or ZIP).
### Phase 5 — Polish
1. Start-page tile copy and icons review (German, Nominandum visual language).
2. Final `check`/`lint`/`build`, update `agents.md`, README feature list.
---
## Open decisions (please confirm before implementation)
Defaults are marked ✅.
### D1 — One page per tool vs. single-page app
- ✅ **A. Separate routes** (`/tools/<slug>`). Better deep-linking/bookmarking, per-tool state is
isolated, back-navigation is natural, and SvelteKit gives us the structure for free.
- B. Single start page where each tile opens an in-place panel/modal. Keeps everything on `/` but
concentrates a lot of state in one component and hurts URL shareability.
### D2 — Where does the ZIP workflow live?
- ✅ **A. `/tools/bea`, start page shows it as the large primary tile.** Drag-a-ZIP-anywhere on the
start page still routes into the workflow, preserving today's fastest path.
- B. ZIP workflow stays on `/` and tools branch off it. Rejected: makes `/` a mixed
dropzone+dashboard and complicates the page.
### D3 — What does "sign" mean?
- ✅ **A. Visual signature** (drawn/typed/uploaded image placed on the page). No crypto, no
certificates, no PAdES.
- B. Cryptographic/PAdES signing. Large scope (certificate handling, new heavy dependencies,
legal implications) — recommend a separate project if needed.
### D4 — What does "convert" mean (v1)?
- ✅ **A. PDF → PNG/JPEG** (per-page images, optionally bundled into a ZIP with fflate).
- B. PDF → PDF/A. There is no solid browser-only PDF/A converter; would need a new heavy
dependency or a server.
- C. Images/Office → PDF. Image→PDF already exists inside the beA flow; Office formats are
out of scope.
### D5 — Encryption dependency
- ✅ **A. `@cantoo/pdf-lib`** — API-compatible fork of pdf-lib with encryption; smallest migration
risk (drop-in swap of the pdf-lib import in the encrypt tool only).
- B. `qpdf-wasm` — battle-tested engine, but WASM bundle size and wiring cost are significant.
- C. Server-side encryption — violates the no-upload constraint; rejected.
### D6 — Unlock strategy
- ✅ **A. pdfjs (with password) → rasterize pages → rebuild with pdf-lib.** Works fully in the
browser; document the vector→raster quality loss in the UI before the user commits.
- B. Await/patch pdf-lib fork decryption support — currently not available for browser use.
- C. Server-side qpdf — violates the no-upload constraint; rejected.
### D7 — Compress strategy
- ✅ **Rasterize-and-re-encode** (pdfjs render + JPEG at chosen quality). Predictable, browser-only;
shows the same quality caveat as unlock.
- B. "Structure-only" optimization (remove metadata, deduplicate objects). pdf-lib offers little
here; savings are usually marginal. Could be added as a light pre-step within the same tool.
### D8 — Release strategy for tiles
- ✅ **A. Ship it whole / hide unfinished tiles.** Only tiles whose route works are rendered
(driven by the tool registry), so phases can be merged without dead links.
- B. Show all tiles immediately with "in Kürze verfügbar" badges — acceptable only if the
redesign itself must go live before all tools are done.
---
## Risks and mitigations
- **Rasterization quality/memory** (unlock, compress, convert): large PDFs can exhaust browser
memory when rendering many pages at high DPI. Mitigate with sequential page processing, DPI
caps, and an upfront file-size warning.
- **`@cantoo/pdf-lib` maturity**: it is a fork; keep the pdf-lib import isolated to the encrypt
tool so a swap back (or to qpdf-wasm) stays local.
- **Scope creep on "sign"/"convert"**: the open decisions above pin v1 scope deliberately; crypto
signing and PDF/A conversion are explicitly out.
- **Regression risk for the existing workflow**: Phase 0 is a pure move of the current page;
verify sub-document editing, XJustiz naming, and bulk export still work after relocation.
+188
View File
@@ -0,0 +1,188 @@
# Plan: Download an individual sub-document
**Status: Done**
## Goal
Each sub-document displayed inside a processed ZIP archive shall have its own download action. The
action creates and downloads one PDF containing only that sub-document's attachments, in their
current order.
This is an additional interaction. The existing archive-level export modes remain available:
- **Eine PDF** continues to flatten all sub-documents and loose attachments into one PDF.
- **Getrennt** continues to download every non-empty sub-document plus the loose-attachment PDF.
- The new sub-document action downloads only the selected sub-document and does not include loose
attachments or sibling sub-documents.
## Current implementation
- `ProcessedZipArchiveEditor.svelte` owns archive-level download state and the browser download helper.
- `SubDocumentEditor.svelte` renders each sub-document but currently exposes only edit, drag/drop, and
dissolve callbacks.
- `mergeAttachments()` in `src/lib/zip-processing.ts` already creates a PDF from an ordered attachment
list.
- `buildArchiveExport(archive, 'separate')` already defines the desired sub-document naming convention:
`{archiveName} - {subDocumentName}.pdf`. It currently creates exports for every sub-document, so it
should not be called for an individual download.
## UX and behavior
Add a **PDF herunterladen** button to each sub-document header, before **Auflösen**.
- Clicking it downloads one PDF for that sub-document.
- The PDF page order matches `subDocument.attachments` exactly.
- The filename remains consistent with separate archive export:
`{archiveName without .pdf} - {subDocumentName without .pdf}.pdf`.
- The action is disabled when the sub-document has no attachments.
- While an individual PDF is being generated, its button reads **PDF wird erstellt** and cannot be
triggered again.
- Only one PDF-generation operation may run in an archive editor at a time. While an individual
download is active, archive-level and sibling download actions are disabled; while archive-level
export is active, individual actions are disabled.
- A failure leaves the archive and sub-document unchanged and shows the existing inline error area
with a sub-document-specific German message.
- Existing drag/drop, rename, selection, dissolve, and archive export behavior must remain unchanged.
## Implementation
### 1. Add a focused export helper
Update `src/lib/zip-processing.ts` with an exported helper:
```ts
export const buildSubDocumentExport = async (
archiveName: string,
subDocument: SubDocument
): Promise<ExportUnit>
```
Behavior:
1. Reject an empty attachment list with the same explicit empty-export behavior used by
`mergeAttachments()`.
2. Merge `subDocument.attachments` by calling `mergeAttachments()`.
3. Return one `ExportUnit` using the existing archive/sub-document filename convention.
4. Keep `.pdf` normalization centralized through the existing `stripPdfExtension()` helper.
Refactor the `mode === 'separate'` branch of `buildArchiveExport()` to call
`buildSubDocumentExport()` for each non-empty sub-document. This ensures bulk-separate and
individual exports cannot drift in PDF contents or naming. Preserve sequential generation and the
existing loose-attachment export behavior.
Do not construct a temporary `ProcessedZipArchive` and do not invoke `buildArchiveExport(...,
'separate')` for one item: both approaches encode the wrong scope and risk exporting unrelated
attachments later.
### 2. Add individual download orchestration to the archive editor
Update `src/lib/components/ProcessedZipArchiveEditor.svelte`:
- Import `buildSubDocumentExport`.
- Add `downloadingSubDocumentId: string | null` state alongside `isMerging` and `mergeError`.
- Derive whether any export is active from `isMerging || downloadingSubDocumentId !== null`.
- Add `downloadSubDocument(subDocument: SubDocument)` that:
1. Returns when another export is active or the sub-document is empty.
2. Stores the selected sub-document ID and clears `mergeError`.
3. Calls `buildSubDocumentExport(archiveName, subDocument)`.
4. Passes the returned bytes and name to the existing `downloadPdfBytes()` helper.
5. On failure, logs context including the archive and sub-document and sets
`Teildokument „{name}“ konnte nicht als PDF erstellt werden.`.
6. Clears the active ID in `finally`.
- Update `downloadArchive()` to guard against any active export, not only `isMerging`.
- Disable the archive-level download button and export-mode controls while an individual export is
active. The mode controls should remain visible and retain their selected value.
- Pass these props to every `SubDocumentEditor`:
- `onDownload={() => downloadSubDocument(subDocument)}`
- `isDownloading={downloadingSubDocumentId === subDocument.id}`
- `downloadDisabled={isMerging || downloadingSubDocumentId !== null}`
The browser download implementation stays in the parent so Blob/object-URL creation, filename
normalization, loading coordination, and errors remain centralized.
### 3. Add the sub-document header action
Update `src/lib/components/SubDocumentEditor.svelte`:
- Import the existing local `Button` component.
- Extend `Props` with:
- `onDownload?: () => void`
- `isDownloading?: boolean`
- `downloadDisabled?: boolean`
- Give all new props safe defaults.
- Render a bordered button before **Auflösen**:
- Normal label: **PDF herunterladen**
- Active label: **PDF wird erstellt**
- Disabled when `downloadDisabled`, `isDownloading`, or `attachments.length === 0`
- Keep the control as a native button through the shared `Button` component, with a clear accessible
label that includes the sub-document name if the visible label alone is ambiguous in assistive
technology.
- Prevent the button from participating in drag initiation in the same way as the existing name and
dissolve controls.
- Keep **Auflösen** visually distinct as the destructive action; do not change its behavior.
Use existing button variants and semantic theme utilities. Do not introduce a new UI dependency or a
second download utility in the child component.
## Files changed
- `src/lib/zip-processing.ts`
- Add `buildSubDocumentExport()`.
- Reuse it from separate archive export.
- `src/lib/components/ProcessedZipArchiveEditor.svelte`
- Coordinate individual export state, errors, and browser download.
- `src/lib/components/SubDocumentEditor.svelte`
- Add the individual download button and callback props.
- `agents.md`
- Clarify that per-archive separate export and direct per-sub-document export are both supported.
No route, server endpoint, persistence, package, lockfile, or Docker changes are required.
## Verification
### Automated checks
Run:
```sh
pnpm exec prettier --write \
src/lib/zip-processing.ts \
src/lib/components/ProcessedZipArchiveEditor.svelte \
src/lib/components/SubDocumentEditor.svelte \
agents.md
pnpm check
pnpm lint
pnpm build
```
There is no test framework in the repository. Do not add one solely for this feature. If a test
framework is introduced separately, cover `buildSubDocumentExport()` with non-empty, empty, ordering,
and filename cases.
### Manual scenarios
1. Create an archive with two sub-documents and at least one loose attachment.
2. Download the first sub-document and verify exactly one PDF is triggered.
3. Confirm the PDF contains only the first sub-document's attachments and preserves their displayed
order.
4. Confirm the filename is `{archive} - {sub-document}.pdf` without duplicate `.pdf` suffixes.
5. Download the second sub-document and verify it has independent contents and naming.
6. Confirm an empty sub-document's download action is disabled.
7. Start an individual export and verify duplicate, sibling, and archive-level export actions cannot
start until it finishes.
8. Force an invalid PDF/image merge and verify a German inline error appears, loading state clears,
and editor state is unchanged.
9. Verify **Eine PDF** still exports all sub-documents followed by loose attachments.
10. Verify **Getrennt** still exports every non-empty sub-document and the loose-attachment PDF.
11. Verify rename, reorder, move, selection, Escape, and **Auflösen** interactions still work.
12. Verify the new control remains usable on a narrow viewport and by keyboard.
## Acceptance criteria
- Every non-empty sub-document has a directly accessible download action.
- The action downloads one PDF containing only that sub-document's ordered attachments.
- Individual and archive-level exports use the same filename convention and merge implementation.
- Empty sub-documents cannot be exported.
- Loading and error states are visible, duplicate operations are prevented, and state always resets.
- Existing archive-level export behavior and all editor interactions are preserved.
- `pnpm check`, `pnpm lint`, and `pnpm build` pass without new issues.
+330
View File
@@ -0,0 +1,330 @@
# Plan: Teildokumente besser aufteilen (PDF & beA-ZIP)
## Goal
Splitting an archive's content into multiple Teildokumente is a core workflow of the beA editor,
but the current UX handles only the easy half of it. This plan closes two gaps:
1. **PDFs split into sub-documents.** A single PDF attachment (e.g. one scan containing several
documents) cannot be divided at page level today. The user has to leave the app, split the PDF
with an external tool, and re-zip the results — the beA route only accepts ZIPs.
2. **Grouping UX.** Creating and re-arranging Teildokumente relies on a modal-ish selection mode
and HTML5 drag-and-drop only. It is hard to discover, hard to use on touch devices (relevant
since the app is an installable PWA), and offers no path for regrouping attachments that are
already inside a Teildokument except dissolving the whole block.
All features stay 100 % client-side, German UI copy, no new dependencies.
---
## Current-state analysis (UX pain points)
| # | Pain point | Where |
| --- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------- |
| P1 | A multi-document PDF attachment cannot be split into several Teildokumente at all. | `ProcessedZipArchiveEditor.svelte` — attachments are atomic |
| P2 | PDFs cannot enter the beA workspace; only ZIPs are accepted. Splitting a PDF means zipping it first. | `zip-selection.ts`, `ZipDropzone.svelte` |
| P3 | Grouping is hidden behind an unlabeled-purpose "Auswählen" mode toggle; the action bar with "Teildokument erstellen" only appears after the first checkbox, so the goal of the mode is unclear. | `ProcessedZipArchiveEditor.svelte` |
| P4 | Selection mode only covers loose attachments. To regroup attachments that already sit in a Teildokument, the user must dissolve the whole block (all-or-nothing) or drag each file individually. | `ProcessedZipArchiveEditor.svelte`, `SubDocumentEditor.svelte` |
| P5 | Moving attachments relies exclusively on HTML5 drag-and-drop, which does not work with touch input and has no keyboard alternative. | both editors |
| P6 | New Teildokumente get the name `Teildokument ${subDocuments.length + 1}` — dissolving and re-creating produces duplicate names — and the name input is not focused, so the rename step is easy to miss. | `createSubDocumentFromSelection` |
| P7 | The newly created Teildokument renders far below the loose list; there is no scroll-into-view or other confirmation. | `ProcessedZipArchiveEditor.svelte` |
| P8 | No quick actions for the most common grouping patterns: "every attachment becomes its own Teildokument", "create empty Teildokument and fill it". | — |
| P9 | `tests/e2e/bea.spec.ts` has zero coverage of the sub-document flow (no grouping, no drag, no export-mode tests). | `tests/e2e/bea.spec.ts` |
---
## Open decisions (defaults marked ✅)
### D1 — Entry point for PDFs
- ✅ **A. Accept PDFs in the beA workspace.** Extend the beA dropzone/pickers to also accept PDF
files. A PDF is turned into a `ProcessedZipArchive` with `name: {fileName}.pdf`,
`attachments: [ {kind: 'pdf', data, path: fileName, name: fileName} ]`, `subDocuments: []`.
The archive editor then offers the full splitting workflow on it.
- B. Keep beA ZIP-only; route PDF splitting to the planned standalone `/tools/extract`
(extract-split-plan.md). Rejected: its output is a ZIP of PDFs, which the user would then have
to re-import — a disconnected round trip.
> Naming note: the route heading and dropzone copy should mention PDFs
> ("beA-ZIP oder PDF auswählen") so the entry point is discoverable.
### D2 — Page-level split interaction
- ✅ **A. Split dialog per PDF attachment.** A "Teilen" action on a PDF attachment opens a dialog
that reuses `loadPdfDocument` + `PdfPageGrid` (same pattern as `tools/separate`). The user
selects a page set, clicks "Als Teildokument übernehmen", the dialog stays open with the
remaining pages and a fresh empty selection for the next Teildokument, until "Fertig".
This guided loop mirrors how documents are actually separated (page range by page range).
- B. Inline page expansion in the archive editor (expand thumbnails for every attachment).
Rejected: doubles memory (pdfjs documents for every PDF stay alive) and clutters the editor.
### D3 — What happens to the source PDF
- ✅ **A. "Ausschneiden" (cut) by default.** The selected pages move into the new Teildokument;
the source attachment keeps the remaining pages. If all pages are consumed, the source
attachment is removed. A checkbox "Seiten im Original behalten" (copy mode) is available for
the rare case where pages should appear in two Teildokumente.
- B. Copy-only. Rejected: the feature's purpose is _splitting_; copy-only leaves the user with
manual cleanup every time.
### D4 — Grouping mode redesign
- ✅ **A. Keep a temporary grouping mode, but make it self-explanatory and archive-wide.**
Rename the toggle to **"Gruppieren"** (with a layers/group icon). While active:
- the action bar is always visible (disabled at 0 selected), showing the count and the
"Teildokument erstellen" button,
- checkboxes appear on loose **and** sub-document attachments, so regrouping no longer
requires dissolving,
- the bar offers **"In Teildokument verschieben"** (a select of existing Teildokumente) next
to "Teildokument erstellen", covering both create-new and move-into-existing in one place,
- "Alle auswählen" / "Auswahl aufheben" buttons are added (parity with the separate tool).
- B. Remove the mode entirely in favour of per-attachment menus only. Rejected: multi-select is
the fastest way to group many attachments; menus are one-file-at-a-time.
### D5 — Touch/keyboard-friendly moves
- ✅ **A. Per-attachment action menu.** Each attachment card gets a small "⋯" menu (visible on
hover/focus, always rendered for screen readers and touch) with:
- "In Teildokument verschieben →" (existing Teildokumente + "Neues Teildokument"),
- "Teilen…" (PDFs only, opens the D2 dialog),
- "Entfernen" (remove attachment from the archive, with confirm).
Drag-and-drop stays as the fast path for pointer users.
- B. Up/down move buttons on every card. Rejected: six buttons per card is visual noise, and it
cannot express "into another Teildokument" without a target chooser anyway.
### D6 — Bulk split modes in the dialog
- ✅ **A. Offer "Jede Seite einzeln" and "Alle N Seiten"** alongside the manual page selection
(same modes as extract-split-plan.md D2). Each chunk becomes one Teildokument named
`Seiten {first}–{last}`; the source attachment is removed (D3 cut semantics).
- B. Manual ranges only for v1. Kept as the fallback if the chunking logic proves fiddly.
### D7 — Naming of new Teildokumente
- ✅ **A.** Keep the editable inline name, but number with `Math.max` of existing numbers + 1
(parse trailing digits of names starting with "Teildokument") so dissolve/re-create cycles no
longer produce duplicates, and **focus + select the name input** of the freshly created block,
then scroll it into view. The split dialog (D2) pre-fills the name from the page range
("Seiten 1–3") and lets the user edit it inline per iteration.
---
## UX concept
### Workstream A — Grouping UX (beA-ZIP → Teildokumente)
1. **"Gruppieren" toggle** in the archive header (replaces "Auswählen"/"Fertig"). Archive-wide
multi-select with an always-visible action bar:
`N Dateien ausgewählt` · "Teildokument erstellen" · "In Teildokument verschieben ▾" ·
"Alle auswählen" / "Auswahl aufheben". Escape/"Fertig" exits and clears.
2. **Per-attachment "⋯" menu** (D5) for single-file moves and splitting without entering any mode.
3. **Empty Teildokument**: new header button "Neues Teildokument" creates an empty block (the
existing empty-drop-zone state in `SubDocumentEditor` is already the right affordance).
4. **Quick action**: "Jede Datei als Teildokument" in the header menu converts all loose
attachments into one Teildokument each, in current order.
5. **Feedback**: newly created blocks are scrolled into view; their name input is focused.
### Workstream B — Page-level splitting (PDF → Teildokumente)
1. **PDF acceptance** in the beA workspace (D1): dropzone, file picker, and the start-page
drop-forward accept `.pdf` alongside ZIPs; a dropped PDF opens the same workspace.
2. **"Teilen…" dialog** (D6/D3): page grid via `PdfPageGrid`, selection via click, live label
("3 von 12 Seiten ausgewählt"), "Als Teildokument übernehmen" applies the cut and re-opens
for the next range; bulk radio "Jede Seite einzeln" / "Alle N Seiten" with a file-count preview
("Es werden 4 Teildokumente erstellt."). "Fertig" closes.
3. The generated chunks are regular PDF attachments (kind `'pdf'`), so export, reordering, and
previews need no special cases.
---
## Technical approach
### Data model (`src/lib/zip-processing.ts`)
`SubDocument` and `ProcessedZipArchive` stay unchanged. Splitting produces **synthetic
attachments** (implemented helpers; page _sets_ instead of plain ranges so arbitrary grid
selections work — a range is the special case used by the bulk modes):
```ts
export type PdfSplitResult = {
groups: Array<{ name: string; attachment: ZipAttachment }>;
source: ZipAttachment | null; // remaining pages; null removes the source
};
// New helpers (pure, immutable — same style as moveAttachmentIntoSubDocument):
createAttachmentFromPages(source, pages: number[], name?) // copy a 1-based page set into a new attachment
removePagesFromAttachment(source, pages: number[]) // complement; null when empty
applyPdfSplit(archive, sourcePath, result) // replace source, insert new sub-documents after its parent block
pdfFileToArchive(file) // D1: PDF → single-attachment archive
nextSubDocumentNumber(subDocuments) // max trailing "Teildokument N" number + 1
createSubDocumentFromPaths(archive, paths) // group any selection into a new Teildokument
moveAttachmentsByPathsToSubDocument(archive, paths, id) // move selection into an existing Teildokument
groupEachAttachmentAsSubDocument(archive) // quick action: one Teildokument per loose file
removeAttachmentFromArchive(archive, path) // menu "Entfernen"
```
- **Unique `path`s are mandatory**: `path` is the `{#each}` key and the drag/selection identity
(`selectedPaths`). The `#seiten-…-uuid` suffix guarantees uniqueness even when the same range
is split twice (copy mode).
- Splitting an attachment **inside a Teildokument** inserts the new Teildokument **directly
after the source's parent block** and applies the cut to the copy inside that block, so the
user's reading order is preserved.
### Shared page extraction
Implement `copyPagesToNewPdf(sourceBytes, pageNumberList): Promise<Uint8Array>` in
`src/lib/pdf-processing.ts` exactly as specified in extract-split-plan.md D3, and rewire
`tools/separate` to use it. If `/tools/extract` is built first, reuse the helper — the two plans
must not fork the code.
### Components
- **New `src/lib/components/PdfSplitDialog.svelte`**: props `{ attachment, onConfirm }`.
Loads thumbnails through `loadPdfDocument` and destroys the handle on close (same memory
safeguards as `AttachmentPreview`); rendering happens only while open. Uses the existing
shadcn dialog primitives.
- **New `src/lib/components/AttachmentMenu.svelte`**: the "⋯" menu (D5), built on the existing
shadcn dropdown-menu primitives; used by both editors.
- **`ProcessedZipArchiveEditor.svelte`**: grouping-mode rework (D4), quick actions, "Neues
Teildokument" button, split-dialog wiring, scroll-into-view + name focus after creation.
- **`SubDocumentEditor.svelte`**: render the "⋯" menu per attachment; grouping-mode checkboxes
forward selection to the parent (selection stays owned by the archive editor).
### PDF acceptance (D1)
- `zip-selection.ts`: add `PDF_ACCEPT_TYPES`/`isAcceptedPdfFile` and a combined
`isAcceptedWorkspaceFile`.
- `ZipDropzone.svelte`, both compact pickers, and the start-page drop-forward: accept PDFs; the
beA page routes PDF files to a `pdfFileToArchive(file)` wrapper that produces the synthetic
single-attachment archive.
- Jobs list: a PDF job shows the same processing row; it cannot fail like a nested-ZIP extraction
can (only a pdf-lib load error, surfaced with the existing German error copy).
### Invariants (from agents.md, restated as acceptance criteria)
- No move/split operation may duplicate or discard attachment data (cut moves bytes, copy
duplicates them deliberately and only on explicit user request).
- Teildokument IDs stay stable across edits and reordering.
- Single-PDF export order stays: Teildokumente in order, each attachment in order, loose
attachments last.
- Dissolving a Teildokument appends its attachments to the loose list (unchanged).
- All state helpers return new archive objects; the route remains the source of truth.
---
## Files changed
| File | Change |
| ------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------- |
| `src/lib/pdf-processing.ts` | `copyPagesToNewPdf` (shared with extract-split-plan) |
| `src/lib/zip-processing.ts` | page-range helpers, `createSubDocumentPerAttachment`, `nextSubDocumentNumber`, `pdfFileToArchive` |
| `src/lib/components/PdfSplitDialog.svelte` | new — page-selection dialog with manual + bulk modes |
| `src/lib/components/AttachmentMenu.svelte` | new — per-attachment actions |
| `src/lib/components/ProcessedZipArchiveEditor.svelte` | grouping-mode rework, quick actions, split wiring |
| `src/lib/components/SubDocumentEditor.svelte` | attachment menu, grouping-mode checkboxes |
| `src/lib/zip-selection.ts`, `ZipDropzone.svelte`, `ZipFilePicker.svelte` | PDF acceptance |
| `src/routes/tools/bea/+page.svelte`, `src/routes/+page.svelte` | PDF → archive wrapper, drop-forward |
| `tests/e2e/bea.spec.ts` (+ fixtures) | sub-document, grouping, splitting coverage |
| `agents.md` | flow + invariants updates |
---
## Implementation phases
### Phase 1 — Shared extraction helper + pure helpers
`copyPagesToNewPdf`, page-range/splitting helpers, `nextSubDocumentNumber`,
`pdfFileToArchive`. Unit-verifiable pure functions, no UI. Rewire `tools/separate` to the shared
helper.
### Phase 2 — Grouping UX rework
"Gruppieren" mode with always-visible action bar, archive-wide selection, move-into-existing
select, select all/none, naming/numbering fix, focus + scroll-into-view feedback.
### Phase 3 — Attachment menu + quick actions
`AttachmentMenu.svelte` in both editors (touch/keyboard path), "Neues Teildokument",
"Jede Datei als Teildokument".
### Phase 4 — Split dialog
`PdfSplitDialog.svelte` with manual cut/copy loop and bulk modes; wire "Teilen…" from the
attachment menu; insertion after the source block.
### Phase 5 — PDF acceptance in the beA workspace
Selection types, dropzone/picker/start-page plumbing, synthetic archive, job error copy.
### Phase 6 — Polish, agents.md, tests
Copy pass, empty/edge states, `agents.md` update, full e2e suite.
Phases 2–5 are independent enough to land separately (each behind the Phase 1 helpers where it
needs them).
---
## Verification
- `pnpm check`, `pnpm lint`, `pnpm build` — no new errors.
- `pnpm test:e2e` with new cases in `bea.spec.ts` (current suite has no sub-document coverage):
- group loose attachments via "Gruppieren" → names/counts correct, order preserved;
- select attachments inside existing Teildokumente and move them into another Teildokument
without dissolving;
- regroup without drag-and-drop (menu path — this is also the touch-equivalent path);
- split a PDF attachment: cut three pages into a new Teildokument, remainder stays as source,
both export correctly via "Eine PDF" and "Getrennt";
- bulk mode "Jede Seite einzeln" produces one Teildokument per page, source removed;
- drop a bare PDF onto the beA dropzone and onto the start page → workspace with one
attachment; split and export;
- duplicate-name regression: dissolve + re-create → "Teildokument 2", not a second
"Teildokument 1".
- Manual: run the split dialog on a 100+ page scan (thumbnail memory, dialog close cleanup);
touch device pass for menu-based moves.
- `pnpm test:pwa` still passes (PDF acceptance changes the dropzone precached flow inputs only).
---
## Risks
- **Memory**: the split dialog keeps a pdfjs document + thumbnails alive while open. Mitigated by
the dialog lifecycle (load on open, `destroy()` on close/discard) and the existing managed
thumbnail service.
- **Path collisions**: synthetic paths must be globally unique per archive; the uuid suffix
makes this safe, but the grouping/selection code must never assume `path` is a real file-system
path (it already only uses it as identity).
- **Forking with extract-split-plan**: both plans need `copyPagesToNewPdf`. Whichever lands first
owns the helper; the other must reuse it.
- **Copy-mode confusion**: cut vs copy must be visually explicit in the dialog (default checked
= cut), otherwise users may assume data loss.
## Out of scope
- Undo/redo for archive edits (separate plan; this plan's helpers are immutable, which is the
prerequisite for it).
- ZIP export of the edited structure (export-zip-plan.md).
- Free-form range input ("1-3, 5, 8-10") in the split dialog — follow-up after v1.
- Splitting image attachments into pages (images are single-page by definition).
- XJustiz-aware auto-grouping (e.g. pre-filling Teildokument names from document names) —
candidate for a later plan once the metadata panel (xjustiz-metadata-panel-plan.md) exists.
---
## Status: Implemented
Deviations from the original text, made during implementation:
- Page helpers work on **1-based page sets** (`createAttachmentFromPages`,
`removePagesFromAttachment`) instead of `PageRange` only, because the manual dialog mode selects
arbitrary grid pages; the bulk modes pass contiguous sets.
- The split dialog applies the cut **once on "Teilen"** (accumulating groups with "Als Teildokument
übernehmen" first) rather than re-encoding the source after every assignment; the semantics are
identical and the grid shows assigned pages with a "✓ Zugewiesen" badge.
- Split-chunk attachments use the source-based default names (`vertrag_Seiten_1-2.pdf`); the
user-facing names live on the Teildokumente.
- Toolbar labels became file-neutral ("Neue Datei bearbeiten", "Weitere Datei hinzufügen") because
the same pickers now accept PDFs (D1); `agents.md` and the e2e helpers were updated accordingly.
- `PdfPageGrid.svelte` renders the `pageActions` snippet in its clickable branch too (previously
only the static branch), needed for the assigned-page badges.
- The workspace status counts files ("1 Datei · 1 Archiv geöffnet") instead of ZIPs.
+283
View File
@@ -0,0 +1,283 @@
# Plan: Sub-documents within a ZIP archive
**Status: Done**
## 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)
- [x] `pnpm check` (or `svelte-check`) passes with no new errors.
- [x] `pnpm build` succeeds.
- [x] Loading a ZIP without creating sub-documents produces the exact same merged PDF as today.
- [x] 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).
- [x] 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.
- [x] Drag-reordering works for: loose attachments, attachments inside a sub-document, sub-document blocks themselves, and moving an attachment loose↔sub.
- [x] 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.
- [x] Removing a sub-document returns its attachments to the loose list; total attachment count is invariant.
- [x] Bulk "Alle ZIP-Archive als PDF exportieren" still works (single mode).
- [x] `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).
+146
View File
@@ -0,0 +1,146 @@
# Plan: PWA / Offline-Fähigkeit
## Goal
The app already processes everything locally — but the browser still has to fetch the app shell
from the server on every load. Making **beA-Edit an installable, offline-capable PWA** reinforces
the privacy story ("works without internet after first load"), enables home-screen/desktop
installation, and is essentially free given there are no network calls to cache-around.
---
## Open decisions (defaults marked ✅)
### D1 — Implementation approach
- ✅ **A. `@vite-pwa/sveltekit`** (new **dev** dependency). Mature integration: precaching of the
built assets via Workbox, generated manifest, virtual `RegisterSW` module, works with
`adapter-node` builds. The dependency is justified (agents.md allows deps when the task cannot
reasonably be done without) — hand-rolling a correct service worker + update flow is
error-prone.
- B. Hand-written minimal `sw.js` in `static/` + manual manifest. Zero deps but custom update
logic, manual asset hashing, more long-term maintenance.
### D2 — Update UX
- ✅ **A. Auto-update with a toast.** New versions activate on next navigation; a small toast
"Neue Version verfügbar – Seite neu laden" appears when an update is waiting (Workbox
`onNeedRefresh`/`onOfflineReady` hooks). Never force-reload mid-work (users may have unsaved
archive state).
- B. Silent auto-reload. Rejected: could interrupt an in-progress merge.
### D3 — Legal pages
- ✅ **Review required, not auto-edited.** A service worker + install prompt is client-side
storage/behavior; `datenschutz/+page.svelte` should likely gain a sentence. Flag for the user;
do not rewrite legal copy unilaterally (project constraint).
---
## Technical approach
### Vite config
```ts
// vite.config.ts
import { SvelteKitPWA } from '@vite-pwa/sveltekit';
export default defineConfig({
plugins: [
sveltekit(),
tailwindcss(),
SvelteKitPWA({
registerType: 'prompt',
manifest: {
name: 'beA-Edit',
short_name: 'beA-Edit',
description: 'PDF-Werkzeuge für die beA-Kanzlei – 100 % lokal im Browser.',
lang: 'de',
start_url: '/',
display: 'standalone',
background_color: '#f2f5f8', // align with theme background token
theme_color: '#…', // align with primary token
icons: [
/* 192 + 512 + maskable, derived from static logo */
]
},
workbox: {
globPatterns: ['**/*.{js,css,html,svg,woff2}'],
navigateFallback: '/' // SPA-style offline fallback for app routes
},
devOptions: { enabled: false }
})
]
});
```
Notes:
- pdfjs worker chunk (`pdf.worker.mjs?url`) is part of the built assets → precached by glob.
- Legal routes (`/impressum`, `/datenschutz`) precache like any route; offline navigation works
via the fallback.
- No runtime caching strategies needed: the app makes **no** network requests by design.
### Registration UI
- Root `+layout.svelte`: import the virtual `virtual:pwa-register` module (SSR-guarded), wire
`onNeedRefresh` → toast state; render a small dismissible toast component (build with existing
card/button styling; no new UI dep) with "Neu laden" / "Später".
- `onOfflineReady` → optional one-time hint "beA-Edit funktioniert jetzt offline." (keep subtle).
- Install prompt: rely on the browser's native install affordance (manifest makes it appear);
optionally an "Installieren" hint on the start page later — out of scope for v1.
### Icons
- Generate 192/512/maskable PNGs from the existing logo (`static/` logo asset) once, committed
to `static/icons/`. Use an offline tool/imagemagick during implementation; no runtime dep.
### Docker / adapter-node
- Service worker + manifest are static build output → served by `node build/index.js` without
changes. Verify `Cache-Control` headers don't block SW updates (adapter-node defaults are fine;
the SW itself must not be cached aggressively — Vite PWA emits the right filenames).
## Files changed
- `vite.config.ts` — SvelteKitPWA plugin + manifest.
- `package.json` / lockfile — `@vite-pwa/sveltekit` dev dependency (via pnpm only).
- `src/routes/+layout.svelte` — registration + update toast.
- `static/icons/*` — generated icons; `static/manifest.webmanifest` handled by plugin.
- `src/app.html` — `theme-color` meta if plugin doesn't inject.
- `agents.md` — document PWA setup + update behavior.
- `datenschutz/+page.svelte` — only after explicit user approval (D3).
## Implementation phases
1. Add dependency, configure plugin + manifest + icons.
2. Registration + update toast in root layout.
3. Build + `pnpm preview`: verify `/` loads, then devtools → Application → service worker
registered; toggle "Offline" → reload → app still works.
4. Production image smoke test (Docker build, offline reload).
5. Update `agents.md`; flag datenschutz review.
## Verification
- Fresh load registers the SW (devtools), `onOfflineReady` path reachable.
- Offline reload after precache: start page + all tool routes render; dropping a ZIP still works
fully (entire processing chain offline).
- New build deployed → toast offers reload; choosing it loads the new version; declining does
not interrupt current state.
- No console errors on iOS Safari (manifest parsing quirks) — best-effort check.
- `pnpm check`, `pnpm lint`, `pnpm build` pass.
## Risks
- **Stale cache confusion** during development: keep `devOptions.enabled: false`; document in
agents.md that preview/production test the SW, not dev mode.
- **Large precache manifest**: the pdfjs worker + fonts add a few MB to the first precache —
acceptable for an installable tool; monitor build output stats.
- **SW + SvelteKit hydration edge cases** on first offline visit are known upstream; pin a
current stable plugin version and test the exact offline-reload sequence.
## Out of scope
- Background sync (nothing to sync — no network by design).
- App-store packaging.
- Custom install button UI beyond the native affordance.
+31
View File
@@ -0,0 +1,31 @@
/// <reference types="node" />
import { defineConfig } from '@playwright/test';
import { existsSync } from 'node:fs';
const chromiumExecutable =
process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH ??
(existsSync('/usr/bin/chromium') ? '/usr/bin/chromium' : undefined);
export default defineConfig({
// `tests/unit` holds pure-function specs (no browser); Playwright runs both.
testDir: './tests',
fullyParallel: false,
retries: 0,
reporter: 'line',
use: {
baseURL: 'http://127.0.0.1:4174',
trace: 'retain-on-failure',
video: 'retain-on-failure',
launchOptions: {
executablePath: chromiumExecutable,
args: ['--no-sandbox']
}
},
webServer: {
command: 'pnpm dev --host 127.0.0.1 --port 4174 --strictPort',
url: 'http://127.0.0.1:4174',
reuseExistingServer: true,
timeout: 120_000
}
});
+29
View File
@@ -0,0 +1,29 @@
/// <reference types="node" />
// TEMPORARY: verification harness for the PWA offline/update behaviour.
// The spec starts the production `node build/index.js` server itself, because a "deployment"
// has to restart the process (adapter-node caches build-file stats at startup).
import { defineConfig } from '@playwright/test';
import { existsSync } from 'node:fs';
const chromiumExecutable =
process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH ??
(existsSync('/usr/bin/chromium') ? '/usr/bin/chromium' : undefined);
export default defineConfig({
testDir: './tests/e2e',
testMatch: 'pwa.spec.ts',
fullyParallel: false,
workers: 1,
retries: 0,
reporter: 'list',
timeout: 420_000,
use: {
baseURL: 'http://127.0.0.1:4179',
trace: 'retain-on-failure',
launchOptions: {
executablePath: chromiumExecutable,
args: ['--no-sandbox']
}
}
});
+4324 -859
View File
File diff suppressed because it is too large. Load diff
+3
View File
@@ -1,3 +1,6 @@
/// <reference types="vite-plugin-pwa/client" />
/// <reference types="vite-plugin-pwa/info" />
// See https://svelte.dev/docs/kit/types#app.d.ts
// for information about these interfaces
declare global {
+3
View File
@@ -3,7 +3,10 @@
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#122f62" />
<meta name="text-scale" content="scale" />
<link rel="icon" href="%sveltekit.assets%/favicon.svg" type="image/svg+xml" />
<link rel="apple-touch-icon" href="%sveltekit.assets%/icons/apple-touch-icon.png" />
%sveltekit.head%
</head>
<body data-sveltekit-preload-data="hover">
+30
View File
@@ -0,0 +1,30 @@
export function ripple(node: HTMLElement) {
function handleClick(event: MouseEvent) {
const circle = document.createElement('span');
const rect = node.getBoundingClientRect();
const size = Math.max(rect.width, rect.height);
circle.style.width = `${size}px`;
circle.style.height = `${size}px`;
circle.style.left = `${event.clientX - rect.left - size / 2}px`;
circle.style.top = `${event.clientY - rect.top - size / 2}px`;
circle.className = 'absolute rounded-full bg-white/30 animate-ripple pointer-events-none';
node.appendChild(circle);
circle.addEventListener('animationend', () => {
circle.remove();
});
}
node.addEventListener('click', handleClick);
return {
destroy() {
node.removeEventListener('click', handleClick);
}
};
}
+163
View File
@@ -0,0 +1,163 @@
import { zipSync } from 'fflate';
import { downloadBlob } from './download';
export type BatchItemStatus = 'pending' | 'processing' | 'done' | 'error';
export type BatchItem = {
id: string;
file: File;
status: BatchItemStatus;
resultBytes?: Uint8Array;
resultName?: string;
errorMessage?: string | null;
};
export type BatchProcessResult = { bytes: Uint8Array; name: string };
export type BatchRunOptions = {
/**
* Error message written to a row when `process` throws for it. A function
* receives the thrown error so tools can surface a specific German message.
*/
errorMessage?: string | ((error: unknown, file: File) => string);
/**
* Optional predicate consulted before each item; when it returns `false`
* (for example because the row was removed mid-run), the item is skipped.
*/
shouldProcess?: (id: string) => boolean;
};
export const createBatchItem = (file: File): BatchItem => ({
id: crypto.randomUUID(),
file,
status: 'pending'
});
/**
* Processes the given items strictly one after another so only one PDF is in
* flight at a time. Never throws: a failing item is marked as an error row and
* the queue continues with the remaining files.
*
* `process` receives the item as well, so tools can resolve per-row data (for
* example the exhibit number frozen when the run started).
*/
export const runBatch = async (
items: BatchItem[],
process: (file: File, item: BatchItem) => Promise<BatchProcessResult>,
update: (id: string, patch: Partial<BatchItem>) => void,
options: BatchRunOptions = {}
): Promise<void> => {
const { errorMessage = 'Die Datei konnte nicht verarbeitet werden.', shouldProcess } = options;
for (const item of items) {
if (shouldProcess && !shouldProcess(item.id)) continue;
update(item.id, { status: 'processing', errorMessage: null });
try {
const result = await process(item.file, item);
update(item.id, {
status: 'done',
resultBytes: result.bytes,
resultName: result.name,
errorMessage: null
});
} catch (error) {
console.error(`Batch processing failed for ${item.file.name}:`, error);
update(item.id, {
status: 'error',
resultBytes: undefined,
resultName: undefined,
errorMessage:
typeof errorMessage === 'function' ? errorMessage(error, item.file) : errorMessage
});
}
}
};
export const formatBytes = (bytes: number) => {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
};
/** Badge colours for a batch row, shared by every batch list component. */
export const batchStatusClass = (status: BatchItemStatus) => {
switch (status) {
case 'processing':
return 'bg-amber-100 text-amber-800';
case 'done':
return 'bg-emerald-100 text-emerald-800';
case 'error':
return 'bg-red-100 text-red-800';
default:
return 'bg-accent text-primary';
}
};
/** German badge label for a batch row. */
export const batchStatusLabel = (status: BatchItemStatus) => {
switch (status) {
case 'processing':
return 'Verarbeite…';
case 'done':
return 'Fertig ✓';
case 'error':
return 'Fehler';
default:
return 'Wartet';
}
};
/** Above this many files (or one file above the threshold) memory gets tight. */
const BATCH_MEMORY_FILE_COUNT = 20;
const BATCH_MEMORY_FILE_BYTES = 25 * 1024 * 1024;
/**
* Shared memory hint for batch tools: very many or very large files keep every
* result in memory until the page is closed.
*/
export const shouldWarnAboutBatchMemory = (items: BatchItem[]) =>
items.length > BATCH_MEMORY_FILE_COUNT ||
items.some((item) => item.file.size > BATCH_MEMORY_FILE_BYTES);
/** Appends `_2`, `_3`, … when a name is already taken inside the archive. */
export const uniqueEntryName = (name: string, usedNames: Set<string>) => {
if (!usedNames.has(name)) {
usedNames.add(name);
return name;
}
const dotIndex = name.lastIndexOf('.');
const base = dotIndex > 0 ? name.slice(0, dotIndex) : name;
const extension = dotIndex > 0 ? name.slice(dotIndex) : '';
let counter = 2;
let candidate = `${base}_2${extension}`;
while (usedNames.has(candidate)) {
counter += 1;
candidate = `${base}_${counter}${extension}`;
}
usedNames.add(candidate);
return candidate;
};
/**
* Bundles every finished row into one `{zipBaseName}_{YYYY-MM-DD}.zip`. Kept out
* of the component so the next batch tool (exhibits) reuses the naming and the
* duplicate handling instead of copying them.
*/
export const downloadBatchZip = (items: BatchItem[], zipBaseName: string) => {
const entries: Record<string, Uint8Array> = {};
const usedNames = new Set<string>();
for (const item of items) {
if (item.status !== 'done' || !item.resultBytes || !item.resultName) continue;
entries[uniqueEntryName(item.resultName, usedNames)] = item.resultBytes;
}
if (Object.keys(entries).length === 0) return;
const dateStamp = new Date().toISOString().slice(0, 10);
downloadBlob(zipSync(entries), `${zipBaseName}_${dateStamp}.zip`, 'application/zip');
};
+100
View File
@@ -0,0 +1,100 @@
<script lang="ts">
import { File, Folder, FolderPlus, MoreHorizontal, Scissors, Trash2 } from '@lucide/svelte';
import * as DropdownMenu from '$lib/components/ui/dropdown-menu/index.js';
import type { ZipAttachment } from '$lib/zip-processing';
export type AttachmentMoveTarget = {
id: string;
name: string;
};
type Props = {
attachment: ZipAttachment;
/** Existing sub-document move targets (excluding the attachment's own). */
moveTargets: AttachmentMoveTarget[];
canSplit?: boolean;
onMoveToTarget: (targetId: string) => void;
onMoveToLoose?: () => void;
onSplit?: () => void;
onRemove?: () => void;
};
let {
attachment,
moveTargets,
canSplit = false,
onMoveToTarget,
onMoveToLoose,
onSplit,
onRemove
}: Props = $props();
</script>
<DropdownMenu.Root>
<DropdownMenu.Trigger aria-label={`Aktionen für ${attachment.name}`}>
{#snippet child({ props })}
<button
{...props}
type="button"
class="flex h-6 w-6 shrink-0 cursor-pointer items-center justify-center rounded-md border border-primary-200 bg-white text-primary-700 transition hover:border-primary-400 hover:bg-primary-50 focus-visible:ring-2 focus-visible:ring-primary-300"
aria-label={`Aktionen für ${attachment.name}`}
data-attachment-menu={attachment.path}
draggable="false"
ondragstart={(event) => event.preventDefault()}
>
<MoreHorizontal class="size-3.5" aria-hidden="true" />
</button>
{/snippet}
</DropdownMenu.Trigger>
<DropdownMenu.Content align="end" side="top" sideOffset={6} class="w-56">
<DropdownMenu.Group>
<DropdownMenu.GroupHeading>Verschieben</DropdownMenu.GroupHeading>
{#each moveTargets as target (target.id)}
<DropdownMenu.Item
aria-label={`Nach „${target.name}“ verschieben`}
onclick={() => onMoveToTarget(target.id)}
>
<Folder aria-hidden="true" />
<span class="truncate">{target.name}</span>
</DropdownMenu.Item>
{/each}
<DropdownMenu.Item
aria-label="In neues Teildokument verschieben"
onclick={() => onMoveToTarget('new')}
>
<FolderPlus aria-hidden="true" />
<span class="truncate">Neues Teildokument</span>
</DropdownMenu.Item>
{#if onMoveToLoose}
<DropdownMenu.Item aria-label="Zu den losen Dateien verschieben" onclick={onMoveToLoose}>
<File aria-hidden="true" />
<span class="truncate">Zu den losen Dateien</span>
</DropdownMenu.Item>
{/if}
</DropdownMenu.Group>
{#if canSplit && onSplit}
<DropdownMenu.Separator />
<DropdownMenu.Item aria-label={`Teilen: ${attachment.name}`} onclick={onSplit}>
<Scissors aria-hidden="true" />
<span class="truncate">Teilen…</span>
</DropdownMenu.Item>
{/if}
{#if onRemove}
<DropdownMenu.Separator />
<DropdownMenu.Item
variant="destructive"
onclick={onRemove}
aria-label={`Entfernen: ${attachment.name}`}
>
<Trash2 aria-hidden="true" />
<span class="truncate">Entfernen</span>
</DropdownMenu.Item>
{/if}
</DropdownMenu.Content>
</DropdownMenu.Root>
+136
View File
@@ -0,0 +1,136 @@
<script lang="ts">
import { onMount } from 'svelte';
import { pdfLoadErrorMessage } from '$lib/pdf-errors';
import type { ZipAttachment } from '$lib/zip-processing';
type Props = {
attachment: ZipAttachment;
class?: string;
};
let { attachment, class: className = '' }: Props = $props();
let imageUrl = $state<string | null>(null);
let previewError = $state<string | null>(null);
onMount(() => {
let objectUrl: string | null = null;
let cancelled = false;
const revokeObjectUrl = () => {
if (objectUrl) {
URL.revokeObjectURL(objectUrl);
objectUrl = null;
}
};
const setBlobImage = (blob: Blob) => {
revokeObjectUrl();
objectUrl = URL.createObjectURL(blob);
imageUrl = objectUrl;
};
(async () => {
const pdfjsLib = await import('pdfjs-dist');
const { getDocument, GlobalWorkerOptions } = pdfjsLib;
GlobalWorkerOptions.workerSrc = await import('pdfjs-dist/build/pdf.worker.mjs?url').then(
(m) => m.default
);
const loadPreview = async () => {
previewError = null;
imageUrl = null;
if (attachment.kind === 'image') {
const imageBuffer = attachment.data.buffer.slice(
attachment.data.byteOffset,
attachment.data.byteOffset + attachment.data.byteLength
) as ArrayBuffer;
setBlobImage(new Blob([imageBuffer]));
return;
}
try {
// create a copy, getDocument seems to manipulate the data -> creating pdfs fails
const buffer = new Uint8Array(attachment.data);
const loadingTask = getDocument({ data: buffer });
const pdfDocument = await loadingTask.promise;
const page = await pdfDocument.getPage(1);
if (cancelled) {
void loadingTask.destroy();
return;
}
const viewport = page.getViewport({ scale: 1 });
const canvasElement = document.createElement('canvas');
canvasElement.width = viewport.width;
canvasElement.height = viewport.height;
const canvasContext = canvasElement.getContext('2d');
if (!canvasContext) {
throw new Error('Could not create thumbnail canvas context.');
}
await page.render({ canvas: canvasElement, canvasContext, viewport }).promise;
if (cancelled) {
void loadingTask.destroy();
return;
}
const blob = await new Promise<Blob>((resolve, reject) => {
canvasElement.toBlob((result) => {
if (result) {
resolve(result);
return;
}
reject(new Error('Could not create thumbnail blob.'));
}, 'image/png');
});
setBlobImage(blob);
await loadingTask.destroy();
} catch (error) {
console.error(`Failed to render preview for ${attachment.path}`, error);
previewError = pdfLoadErrorMessage(error, 'PDF-Vorschau konnte nicht geladen werden.');
}
};
void loadPreview();
})();
return () => {
cancelled = true;
revokeObjectUrl();
};
});
</script>
<div class={className}>
{#if previewError}
<div
class="border-primary-200 bg-primary-50 text-primary-800 flex h-full min-h-0 items-center justify-center rounded-md border border-dashed px-3 py-4 text-center text-[12px]"
>
{previewError}
</div>
{:else if imageUrl}
<img
alt={attachment.name}
class="border-primary-100 block rounded-md border bg-white object-contain"
src={imageUrl}
/>
{:else}
<div
class="border-primary-200 bg-primary-50 text-primary-800 flex items-center justify-center rounded-md border border-dashed px-3 py-4 text-center text-[12px]"
>
Vorschau wird erstellt
</div>
{/if}
</div>
+124
View File
@@ -0,0 +1,124 @@
<script lang="ts">
import { Download, RotateCcw, Trash2 } from '@lucide/svelte';
import Button from '$lib/components/ui/button.svelte';
import {
batchStatusClass,
batchStatusLabel,
downloadBatchZip,
formatBytes,
shouldWarnAboutBatchMemory,
type BatchItem
} from '$lib/batch';
type Props = {
items: BatchItem[];
/** Base file name of the bulk ZIP, e.g. `komprimiert` → `komprimiert_2026-02-12.zip`. */
zipBaseName: string;
onDownload: (item: BatchItem) => void;
onRemove: (id: string) => void;
onRetry: (id: string) => void;
};
let { items, zipBaseName, onDownload, onRemove, onRetry }: Props = $props();
const doneItems = $derived(items.filter((item) => item.status === 'done'));
const totalResultBytes = $derived(
doneItems.reduce((sum, item) => sum + (item.resultBytes?.byteLength ?? 0), 0)
);
const processingIndex = $derived(items.findIndex((item) => item.status === 'processing'));
const showMemoryWarning = $derived(shouldWarnAboutBatchMemory(items));
const downloadZip = () => downloadBatchZip(doneItems, zipBaseName);
</script>
<div class="space-y-3 rounded-2xl border border-border bg-card p-4">
<div class="flex flex-wrap items-center justify-between gap-2">
<h2 class="text-lg font-semibold text-foreground">Dateien ({items.length})</h2>
{#if doneItems.length > 0}
<p class="text-sm text-primary">
Ergebnisse gesamt: <span class="font-semibold">{formatBytes(totalResultBytes)}</span>
</p>
{/if}
</div>
{#if processingIndex !== -1}
<p class="text-sm text-primary" role="status">
Datei {processingIndex + 1} von {items.length} wird verarbeitet…
</p>
{/if}
{#if showMemoryWarning}
<div class="rounded-lg border border-amber-200 bg-amber-50 p-3 text-sm text-amber-800">
Bei sehr vielen oder sehr großen Dateien kann der Arbeitsspeicher knapp werden. Laden Sie
fertige Ergebnisse früh herunter und entfernen Sie sie aus der Liste.
</div>
{/if}
<ul class="divide-y divide-border">
{#each items as item (item.id)}
<li class="flex flex-wrap items-center gap-3 py-3" data-batch-item={item.file.name}>
<div class="min-w-0 flex-1">
<p class="truncate text-sm font-medium text-foreground">{item.file.name}</p>
<p class="text-xs text-primary">
{formatBytes(item.file.size)}
{#if item.status === 'done' && item.resultBytes}
→ {formatBytes(item.resultBytes.length)}
{/if}
</p>
{#if item.status === 'error' && item.errorMessage}
<p class="mt-0.5 text-xs text-red-700" role="alert">{item.errorMessage}</p>
{/if}
</div>
<span
class="shrink-0 rounded-full px-2.5 py-0.5 text-xs font-semibold {batchStatusClass(
item.status
)}"
>
{batchStatusLabel(item.status)}
</span>
<div class="flex shrink-0 items-center gap-1">
{#if item.status === 'done' && item.resultBytes && item.resultName}
<button
type="button"
class="focus-visible:ring-primary-400 rounded-lg p-2 text-primary transition hover:bg-accent focus:outline-none focus-visible:ring-2"
aria-label="{item.file.name} herunterladen"
title="{item.file.name} herunterladen"
onclick={() => onDownload(item)}
>
<Download class="h-4 w-4" />
</button>
{/if}
{#if item.status === 'error'}
<button
type="button"
class="focus-visible:ring-primary-400 rounded-lg p-2 text-primary transition hover:bg-accent focus:outline-none focus-visible:ring-2"
aria-label="{item.file.name} erneut versuchen"
title="{item.file.name} erneut versuchen"
onclick={() => onRetry(item.id)}
>
<RotateCcw class="h-4 w-4" />
</button>
{/if}
<button
type="button"
class="focus-visible:ring-primary-400 rounded-lg p-2 text-primary transition hover:bg-accent focus:outline-none focus-visible:ring-2 disabled:cursor-not-allowed disabled:opacity-40"
aria-label="{item.file.name} entfernen"
title="{item.file.name} entfernen"
disabled={item.status === 'processing'}
onclick={() => onRemove(item.id)}
>
<Trash2 class="h-4 w-4" />
</button>
</div>
</li>
{/each}
</ul>
{#if doneItems.length >= 2}
<div class="flex justify-end pt-1">
<Button onclick={downloadZip} icon={Download}>Alle herunterladen</Button>
</div>
{/if}
</div>
@@ -0,0 +1,332 @@
<script lang="ts" module>
export type ZipProcessingJobStatus = 'processing' | 'complete' | 'error';
export type ZipProcessingJob = {
id: string;
file: File;
status: ZipProcessingJobStatus;
archiveCount?: number;
error?: string;
};
</script>
<script lang="ts">
import { Check, CircleDashed, TriangleAlert } from '@lucide/svelte';
import Button from '$lib/components/ui/button.svelte';
import * as Card from '$lib/components/ui/card/index';
type Props = {
jobs: ZipProcessingJob[];
compact?: boolean;
onRetry: (jobId: string) => void;
onRemove: (jobId: string) => void;
};
let { jobs, compact = false, onRetry, onRemove }: Props = $props();
let processingCount = $derived(jobs.filter((job) => job.status === 'processing').length);
let completedCount = $derived(jobs.filter((job) => job.status === 'complete').length);
let failedJobs = $derived(jobs.filter((job) => job.status === 'error'));
let completionPercent = $derived(jobs.length === 0 ? 0 : (completedCount / jobs.length) * 100);
const formatFileSize = (size: number) => {
if (size < 1024 * 1024) return `${Math.max(1, Math.round(size / 1024))} KB`;
return `${(size / (1024 * 1024)).toFixed(1)} MB`;
};
const archiveCountLabel = (count: number | undefined) => {
if (count === undefined) return 'Bereit';
return `${count} Archiv${count === 1 ? '' : 'e'} bereit`;
};
</script>
{#if compact}
<Card.Root size="sm" class="border-l-4 border-l-secondary">
<p class="sr-only" role="status" aria-live="polite">
{completedCount} von {jobs.length} Dateien bereit, {processingCount} in Bearbeitung,
{failedJobs.length} fehlgeschlagen.
</p>
<Card.Content class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div class="flex min-w-0 items-center gap-3">
<div class="relative grid size-10 shrink-0 place-items-center" aria-hidden="true">
<div class="compact-folder"></div>
{#if processingCount > 0}
<CircleDashed
class="absolute -right-0.5 -bottom-0.5 size-4 animate-spin text-secondary"
/>
{:else}
<TriangleAlert class="absolute -right-0.5 -bottom-0.5 size-4 text-destructive" />
{/if}
</div>
<div class="min-w-0">
<p class="font-bold text-primary-900">
{#if processingCount > 0}
{processingCount} ZIP-Datei{processingCount === 1 ? '' : 'en'}
{processingCount === 1 ? ' wird' : ' werden'} noch geöffnet
{:else}
{failedJobs.length} ZIP-Datei{failedJobs.length === 1 ? '' : 'en'} konnte{failedJobs.length ===
1
? ''
: 'n'} nicht geöffnet werden
{/if}
</p>
<p class="truncate text-[13px] text-primary-700">
{jobs
.filter((job) =>
processingCount > 0 ? job.status === 'processing' : job.status === 'error'
)
.map((job) => job.file.name)
.join(', ')}
</p>
</div>
</div>
<p class="shrink-0 text-[12px] font-medium text-primary-700">
Verarbeitung nur in Ihrem Browser
</p>
</Card.Content>
{#if failedJobs.length > 0}
<Card.Footer class="flex flex-col items-stretch gap-2">
{#each failedJobs as job (job.id)}
<div class="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<p class="min-w-0 text-[13px] font-medium text-destructive">
<span class="font-bold">{job.file.name}</span> konnte nicht geöffnet werden.
</p>
<div class="flex shrink-0 gap-1">
<Button variant="tertiary" class="h-8 min-w-0 px-3" onclick={() => onRetry(job.id)}
>Erneut versuchen</Button
>
<Button variant="tertiary" class="h-8 min-w-0 px-3" onclick={() => onRemove(job.id)}
>Entfernen</Button
>
</div>
</div>
{/each}
</Card.Footer>
{/if}
</Card.Root>
{:else}
<Card.Root class="mx-auto w-full max-w-2xl shadow-sm">
<p class="sr-only" role="status" aria-live="polite">
{completedCount} von {jobs.length} Dateien bereit, {processingCount} in Bearbeitung,
{failedJobs.length} fehlgeschlagen.
</p>
<Card.Header class="place-items-center px-6 pt-4 text-center sm:px-10">
<div class="case-intake" aria-hidden="true">
<div class="folder-back"></div>
<div class="document-sheet">
<span></span>
<span></span>
<span></span>
</div>
<div class="folder-front"></div>
</div>
<Card.Title class="mt-3 text-[22px] font-extrabold text-primary-900">
{#if processingCount > 0}
beA-Archive werden vorbereitet
{:else if failedJobs.length > 0 && completedCount === 0}
Archive konnten nicht vorbereitet werden
{:else}
Archive sind bereit
{/if}
</Card.Title>
<Card.Description class="max-w-md font-medium text-primary-700">
Die Dateien werden ausschließlich in Ihrem Browser geöffnet und nicht hochgeladen.
</Card.Description>
</Card.Header>
<Card.Content class="flex flex-col gap-4 px-6 sm:px-10">
<div class="flex flex-col gap-2">
<div class="flex items-center justify-between gap-4 text-[13px] font-bold text-primary-900">
<span>{completedCount} von {jobs.length} Dateien bereit</span>
{#if processingCount > 0}
<span class="font-medium text-primary-700">Bitte kurz warten</span>
{/if}
</div>
<div
class="h-1.5 overflow-hidden rounded-full bg-primary-100"
role="progressbar"
aria-label="Vorbereitete ZIP-Dateien"
aria-valuemin="0"
aria-valuemax={jobs.length}
aria-valuenow={completedCount}
>
<div
class="h-full rounded-full bg-success transition-[width] duration-500"
style:width={`${completionPercent}%`}
></div>
</div>
</div>
<ul class="flex flex-col gap-2" aria-label="Status der ZIP-Dateien">
{#each jobs as job (job.id)}
<li
class="grid grid-cols-[auto_minmax(0,1fr)] items-center gap-3 rounded-xl border bg-background/55 px-3 py-3 sm:grid-cols-[auto_minmax(0,1fr)_auto]"
>
<div class="grid size-8 place-items-center rounded-full bg-card" aria-hidden="true">
{#if job.status === 'processing'}
<CircleDashed class="size-5 animate-spin text-secondary" />
{:else if job.status === 'complete'}
<Check class="size-5 text-success" />
{:else}
<TriangleAlert class="size-5 text-destructive" />
{/if}
</div>
<div class="min-w-0">
<p class="truncate text-[14px] font-bold text-primary-900">{job.file.name}</p>
<p class="job-meta text-[12px] text-primary-700">{formatFileSize(job.file.size)}</p>
{#if job.status === 'error'}
<p class="mt-1 text-[12px] leading-snug text-destructive">
<span class="font-bold">Konnte nicht geöffnet werden.</span>
<span class="block">
{job.error ?? 'Prüfen Sie, ob die Datei ein gültiges beA-ZIP-Archiv ist.'}
</span>
</p>
{/if}
</div>
<div
class="col-start-2 flex flex-wrap items-center gap-1 sm:col-start-3 sm:row-start-1"
>
{#if job.status === 'processing'}
<span class="text-[12px] font-bold text-secondary">Wird geöffnet</span>
{:else if job.status === 'complete'}
<span class="text-[12px] font-bold text-success">
{archiveCountLabel(job.archiveCount)}
</span>
{:else}
<Button variant="tertiary" class="h-8 min-w-0 px-3" onclick={() => onRetry(job.id)}
>Erneut versuchen</Button
>
<Button variant="tertiary" class="h-8 min-w-0 px-3" onclick={() => onRemove(job.id)}
>Entfernen</Button
>
{/if}
</div>
</li>
{/each}
</ul>
</Card.Content>
</Card.Root>
{/if}
<style>
.job-meta {
font-family: 'Geist Variable', ui-monospace, monospace;
font-variant-numeric: tabular-nums;
}
.case-intake {
position: relative;
width: 7rem;
height: 5.5rem;
}
.folder-back,
.folder-front,
.compact-folder {
position: absolute;
background: color-mix(in srgb, var(--primary) 12%, white);
border: 2px solid var(--primary);
}
.folder-back {
inset: 1.55rem 0 0;
border-radius: 0.65rem 0.65rem 0.8rem 0.8rem;
}
.folder-back::before {
content: '';
position: absolute;
left: -2px;
top: -0.8rem;
width: 3.4rem;
height: 1rem;
border: 2px solid var(--primary);
border-bottom: 0;
border-radius: 0.55rem 0.55rem 0 0;
background: color-mix(in srgb, var(--primary) 12%, white);
}
.document-sheet {
position: absolute;
z-index: 1;
left: 50%;
top: 0.2rem;
display: flex;
width: 3.7rem;
height: 4.6rem;
transform: translateX(-50%);
flex-direction: column;
gap: 0.42rem;
border: 2px solid var(--primary);
border-radius: 0.35rem;
background: var(--card);
padding: 0.8rem 0.65rem;
box-shadow: 0 0.4rem 1rem rgb(18 47 98 / 12%);
animation: file-intake 1.8s ease-in-out infinite;
}
.document-sheet span {
display: block;
height: 0.18rem;
border-radius: 999px;
background: color-mix(in srgb, var(--primary) 30%, transparent);
}
.document-sheet span:nth-child(2) {
width: 72%;
}
.document-sheet span:nth-child(3) {
width: 88%;
background: var(--secondary);
}
.folder-front {
z-index: 2;
inset: 2.7rem 0 0;
border-radius: 0.45rem 0.45rem 0.8rem 0.8rem;
background: color-mix(in srgb, var(--primary) 18%, white);
transform: perspective(8rem) rotateX(-5deg);
transform-origin: bottom;
}
.compact-folder {
inset: 0.65rem 0.2rem 0.25rem;
border-radius: 0.35rem;
}
.compact-folder::before {
content: '';
position: absolute;
left: -2px;
top: -0.4rem;
width: 1rem;
height: 0.5rem;
border: 2px solid var(--primary);
border-bottom: 0;
border-radius: 0.25rem 0.25rem 0 0;
background: color-mix(in srgb, var(--primary) 12%, white);
}
@keyframes file-intake {
0%,
18% {
transform: translate(-50%, -0.45rem);
}
55%,
100% {
transform: translate(-50%, 0.65rem);
}
}
@media (prefers-reduced-motion: reduce) {
.document-sheet,
:global(.animate-spin) {
animation: none;
}
}
</style>
@@ -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 fileText = `${selectedZipCount} ${selectedZipCount === 1 ? 'Datei' : 'Dateien'}`;
const archiveText = `${processedArchiveCount} ${
processedArchiveCount === 1 ? 'Archiv' : 'Archive'
} geöffnet`;
const pendingText =
pendingZipCount > 0
? ` · ${pendingZipCount} ${pendingZipCount === 1 ? 'wird' : 'werden'} geöffnet`
: '';
return `${fileText} · ${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 Datei bearbeiten"
disabled={isCommitting}
onFilesSelected={(files) => (pendingReplacementFiles = files)}
>
<FilePlus2 class="size-4" aria-hidden="true" />
Neue Datei 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 Datei hinzufügen"
variant="bordered"
disabled={isCommitting}
class="w-full justify-start"
onFilesSelected={onAppendFiles}
>
<Plus class="size-4" aria-hidden="true" />
Weitere Datei 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 Datei ö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>
-38
View File
@@ -1,38 +0,0 @@
<script lang="ts">
// Define the types for your props
type ButtonType = 'button' | 'submit' | 'reset'
type ButtonVariant = 'primary' | 'secondary'
// Define the props object including the Snippet for children
let {
type = 'button',
disabled = false,
variant = 'primary',
children
}: {
type?: ButtonType;
disabled?: boolean;
variant?: ButtonVariant
children: import('svelte').Snippet
} = $props();
const getBgColor = (variant: ButtonVariant) => {
return variant === 'primary' ? '#122f62' : 'transparent'
}
const getTextColor = (variant: ButtonVariant) => {
return variant === 'primary' ? 'white': 'primary-900'
}
let bgColor = $derived(getBgColor(variant))
let textColor = $derived(getTextColor(variant))
</script>
<button
type="{type}"
class="rounded-container not-disabled:bg-[{bgColor}] disabled:bg-[#0000001f] not-disabled:text-{textColor} disabled:text-[#00000042] whitespace-nowrap text-center text-[14px] leading-9 font-bold px-4 py-0 focus:outline-none focus:ring-2 focus:ring-primary-300 shadow-sm"
disabled={disabled}
>
{@render children()}
</button>
-23
View File
@@ -1,23 +0,0 @@
<script lang="ts">
import type {Snippet} from "svelte";
type Props = {
title?: Snippet
content?: Snippet
footer?: Snippet
class?: string
}
let {title, content, footer, class: className = ""}: Props = $props()
</script>
<div class="bg-white rounded-2xl shadow-sm py-2 px-2 {className}">
{#if title}
<header>{@render title()}</header>
{/if}
{#if content}
<main class="flex grow items-center">{@render content()}</main>
{/if}
{#if footer}
<footer>{@render footer()}</footer>
{/if}
</div>
+269
View File
@@ -0,0 +1,269 @@
<script lang="ts">
import { Download, Eye, GripVertical, RotateCcw, Trash2 } from '@lucide/svelte';
import Button from '$lib/components/ui/button.svelte';
import {
batchStatusClass,
batchStatusLabel,
downloadBatchZip,
formatBytes,
shouldWarnAboutBatchMemory,
type BatchItem
} from '$lib/batch';
type Props = {
items: BatchItem[];
/** Resolved stamp label per row; frozen once the row was created. */
labels: Record<string, string>;
/** Renamed file per row, or `null` when renaming is switched off. */
fileNames: Record<string, string | null>;
selectedId: string | null;
/** Base file name of the bulk ZIP, e.g. `anlagen` → `anlagen_2026-10-06.zip`. */
zipBaseName: string;
/** Reordering and preview selection are blocked while a run is active. */
disabled?: boolean;
onSelect: (id: string) => void;
onMove: (from: number, to: number) => void;
onDownload: (item: BatchItem) => void;
onRemove: (id: string) => void;
onRetry: (id: string) => void;
};
let {
items,
labels,
fileNames,
selectedId,
zipBaseName,
disabled = false,
onSelect,
onMove,
onDownload,
onRemove,
onRetry
}: Props = $props();
let draggingId = $state<string | null>(null);
let dragOverId = $state<string | null>(null);
const doneItems = $derived(items.filter((item) => item.status === 'done'));
const totalResultBytes = $derived(
doneItems.reduce((sum, item) => sum + (item.resultBytes?.byteLength ?? 0), 0)
);
const processingIndex = $derived(items.findIndex((item) => item.status === 'processing'));
const showMemoryWarning = $derived(shouldWarnAboutBatchMemory(items));
const downloadZip = () => downloadBatchZip(doneItems, zipBaseName);
const moveUp = (index: number) => {
if (disabled || index === 0) return;
onMove(index, index - 1);
};
const moveDown = (index: number) => {
if (disabled || index === items.length - 1) return;
onMove(index, index + 1);
};
const handleDragStart = (event: DragEvent, item: BatchItem) => {
if (disabled) return;
draggingId = item.id;
event.dataTransfer?.setData('text/plain', item.id);
if (event.dataTransfer) event.dataTransfer.effectAllowed = 'move';
};
const handleDragOver = (event: DragEvent, item: BatchItem) => {
if (disabled || !draggingId || draggingId === item.id) return;
event.preventDefault();
dragOverId = item.id;
};
const handleDrop = (event: DragEvent, item: BatchItem) => {
event.preventDefault();
const sourceId = draggingId;
draggingId = null;
dragOverId = null;
if (disabled || !sourceId || sourceId === item.id) return;
const from = items.findIndex((entry) => entry.id === sourceId);
const to = items.findIndex((entry) => entry.id === item.id);
if (from === -1 || to === -1) return;
onMove(from, to);
};
const handleDragEnd = () => {
draggingId = null;
dragOverId = null;
};
</script>
<div class="space-y-3 rounded-2xl border border-border bg-card p-4">
<div class="flex flex-wrap items-center justify-between gap-2">
<h2 class="text-lg font-semibold text-foreground">Anlagen ({items.length})</h2>
{#if doneItems.length > 0}
<p class="text-sm text-primary">
Ergebnisse gesamt: <span class="font-semibold">{formatBytes(totalResultBytes)}</span>
</p>
{/if}
</div>
{#if processingIndex !== -1}
<p class="text-sm text-primary" role="status">
Datei {processingIndex + 1} von {items.length} wird verarbeitet…
</p>
{/if}
{#if showMemoryWarning}
<div class="rounded-lg border border-amber-200 bg-amber-50 p-3 text-sm text-amber-800">
Bei sehr vielen oder sehr großen Dateien kann der Arbeitsspeicher knapp werden. Laden Sie
fertige Ergebnisse früh herunter und entfernen Sie sie aus der Liste.
</div>
{/if}
<ul class="divide-y divide-border">
{#each items as item, index (item.id)}
{@const label = labels[item.id] ?? ''}
{@const renamed = fileNames[item.id] ?? null}
<li
class="flex flex-wrap items-center gap-3 py-3 {dragOverId === item.id
? 'bg-accent/40'
: ''} {draggingId === item.id ? 'opacity-60' : ''}"
data-exhibit-item={item.file.name}
data-exhibit-index={index + 1}
data-exhibit-label={label}
draggable={!disabled}
ondragstart={(event) => handleDragStart(event, item)}
ondragover={(event) => handleDragOver(event, item)}
ondrop={(event) => handleDrop(event, item)}
ondragend={handleDragEnd}
>
<div class="flex shrink-0 items-center gap-1">
<span
class="cursor-grab p-1 text-muted-foreground {disabled ? 'opacity-40' : ''}"
aria-hidden="true"
title="Ziehen zum Verschieben"
>
<GripVertical class="h-4 w-4" />
</span>
<span
class="flex h-6 w-6 items-center justify-center rounded-full bg-accent text-xs font-bold text-primary"
title="Position in der Liste"
>
{index + 1}
</span>
<div class="flex flex-col">
<button
type="button"
class="rounded p-0.5 text-primary transition hover:bg-accent focus:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-40"
aria-label="{item.file.name} nach oben verschieben"
title="Nach oben"
disabled={disabled || index === 0}
onclick={() => moveUp(index)}
>
<svg
viewBox="0 0 24 24"
class="h-3 w-3"
fill="none"
stroke="currentColor"
stroke-width="3"
aria-hidden="true"><path d="M6 15l6-6 6 6" /></svg
>
</button>
<button
type="button"
class="rounded p-0.5 text-primary transition hover:bg-accent focus:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-40"
aria-label="{item.file.name} nach unten verschieben"
title="Nach unten"
disabled={disabled || index === items.length - 1}
onclick={() => moveDown(index)}
>
<svg
viewBox="0 0 24 24"
class="h-3 w-3"
fill="none"
stroke="currentColor"
stroke-width="3"
aria-hidden="true"><path d="M6 9l6 6 6-6" /></svg
>
</button>
</div>
</div>
<div class="min-w-0 flex-1">
<p class="truncate text-sm font-semibold text-foreground">
{label}
</p>
<p class="truncate text-xs text-primary">{item.file.name}</p>
{#if renamed}
<p class="truncate text-xs text-muted-foreground" data-exhibit-filename>
→ {renamed}
</p>
{/if}
{#if item.status === 'error' && item.errorMessage}
<p class="mt-0.5 text-xs text-red-700" role="alert">{item.errorMessage}</p>
{/if}
</div>
<span
class="shrink-0 rounded-full px-2.5 py-0.5 text-xs font-semibold {batchStatusClass(
item.status
)}"
>
{batchStatusLabel(item.status)}
</span>
<div class="flex shrink-0 items-center gap-1">
<button
type="button"
class="focus-visible:ring-primary-400 rounded-lg p-2 text-primary transition hover:bg-accent focus:outline-none focus-visible:ring-2 disabled:cursor-not-allowed disabled:opacity-40 aria-pressed:bg-accent"
aria-label="Vorschau für {item.file.name}"
title="Vorschau für {item.file.name}"
aria-pressed={selectedId === item.id}
{disabled}
onclick={() => onSelect(item.id)}
>
<Eye class="h-4 w-4" />
</button>
{#if item.status === 'done' && item.resultBytes && item.resultName}
<button
type="button"
class="focus-visible:ring-primary-400 rounded-lg p-2 text-primary transition hover:bg-accent focus:outline-none focus-visible:ring-2"
aria-label="{item.file.name} herunterladen"
title="{item.file.name} herunterladen"
onclick={() => onDownload(item)}
>
<Download class="h-4 w-4" />
</button>
{/if}
{#if item.status === 'error'}
<button
type="button"
class="focus-visible:ring-primary-400 rounded-lg p-2 text-primary transition hover:bg-accent focus:outline-none focus-visible:ring-2 disabled:cursor-not-allowed disabled:opacity-40"
aria-label="{item.file.name} erneut versuchen"
title="{item.file.name} erneut versuchen"
{disabled}
onclick={() => onRetry(item.id)}
>
<RotateCcw class="h-4 w-4" />
</button>
{/if}
<button
type="button"
class="focus-visible:ring-primary-400 rounded-lg p-2 text-primary transition hover:bg-accent focus:outline-none focus-visible:ring-2 disabled:cursor-not-allowed disabled:opacity-40"
aria-label="{item.file.name} entfernen"
title="{item.file.name} entfernen"
disabled={item.status === 'processing'}
onclick={() => onRemove(item.id)}
>
<Trash2 class="h-4 w-4" />
</button>
</div>
</li>
{/each}
</ul>
{#if doneItems.length >= 2}
<div class="flex justify-end pt-1">
<Button onclick={downloadZip} icon={Download}>Alle herunterladen</Button>
</div>
{/if}
</div>
+139
View File
@@ -0,0 +1,139 @@
<script lang="ts">
import { Archive } from '@lucide/svelte';
type Props = {
onFilesSelected: (files: File[]) => void;
onFilesRejected?: (files: File[]) => void;
accept?: string;
multiple?: boolean;
ariaLabel?: string;
label?: string;
sublabel?: string;
icon?: typeof Archive;
class?: string;
};
let {
onFilesSelected,
onFilesRejected,
accept = '.pdf,application/pdf',
multiple = false,
ariaLabel = 'PDF-Dateien hinzufügen',
label = 'Dateien hinzufügen',
sublabel = 'PDF hier ablegen oder zum Auswählen klicken',
icon = undefined,
class: className = ''
}: Props = $props();
let fileInput = $state<HTMLInputElement | null>(null);
let isDragging = $state(false);
let rejectionMessage = $state<string | null>(null);
let dragDepth = 0;
const matchesAccept = (file: File) =>
accept
.split(',')
.map((value) => value.trim().toLowerCase())
.filter(Boolean)
.some((filter) => {
if (filter.startsWith('.')) return file.name.toLowerCase().endsWith(filter);
if (filter.endsWith('/*')) return file.type.toLowerCase().startsWith(filter.slice(0, -1));
return file.type.toLowerCase() === filter;
});
const processFiles = (files: FileList | null | undefined) => {
if (!files) return;
const received = Array.from(files);
const accepted = received.filter(matchesAccept);
const rejected = received.filter((file) => !matchesAccept(file));
if (rejected.length > 0) {
rejectionMessage = `${rejected.map((file) => file.name).join(', ')}: Dateityp nicht unterstützt.`;
onFilesRejected?.(rejected);
}
if (accepted.length > 0) {
rejectionMessage = null;
onFilesSelected(multiple ? accepted : accepted.slice(0, 1));
}
};
const openFileDialog = () => fileInput?.click();
const handleFileSelection = (event: Event) => {
const input = event.currentTarget as HTMLInputElement;
processFiles(input.files);
input.value = '';
};
const handleDragEnter = (event: DragEvent) => {
event.preventDefault();
dragDepth += 1;
isDragging = true;
};
const handleDragOver = (event: DragEvent) => {
event.preventDefault();
if (event.dataTransfer) event.dataTransfer.dropEffect = 'copy';
};
const handleDragLeave = (event: DragEvent) => {
event.preventDefault();
dragDepth = Math.max(0, dragDepth - 1);
if (dragDepth === 0) isDragging = false;
};
const handleDrop = (event: DragEvent) => {
event.preventDefault();
event.stopPropagation();
dragDepth = 0;
isDragging = false;
processFiles(event.dataTransfer?.files);
};
</script>
<input
bind:this={fileInput}
class="hidden"
type="file"
{accept}
{multiple}
onchange={handleFileSelection}
/>
<button
type="button"
class="flex w-full cursor-pointer flex-col items-center justify-center gap-2 rounded-2xl border-2 border-dashed px-6 py-10 text-center transition-all duration-150 focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-400 {isDragging
? 'scale-[1.02] border-primary bg-primary-100/70 shadow-lg ring-4 ring-primary/25'
: 'border-primary-300 bg-white hover:border-primary-400 hover:bg-primary-50/60'} {className}"
aria-label={ariaLabel}
data-dropzone-ready={fileInput ? 'true' : 'false'}
onclick={openFileDialog}
ondragenter={handleDragEnter}
ondragover={handleDragOver}
ondragleave={handleDragLeave}
ondrop={handleDrop}
>
{#if icon}
{@const IconComponent = icon}
<IconComponent
class="h-10 w-10 text-primary transition-transform duration-150 {isDragging
? 'scale-125'
: ''}"
/>
{/if}
<div class="flex flex-row items-baseline justify-center gap-2">
<span class="text-[18px] font-bold text-primary-900">{label}</span>
<span class="text-[20px] font-extrabold text-primary">+</span>
</div>
{#if sublabel}
<p class="text-[13px] text-primary-700">{sublabel}</p>
{/if}
{#if isDragging}
<p class="text-[13px] font-bold text-primary">Jetzt loslassen zum Hinzufügen</p>
{/if}
</button>
{#if rejectionMessage}
<p class="mt-2 text-sm font-medium text-red-700" role="alert">{rejectionMessage}</p>
{/if}
+164
View File
@@ -0,0 +1,164 @@
<script lang="ts">
import { ChevronLeft, ChevronRight } from '@lucide/svelte';
import Button from '$lib/components/ui/button.svelte';
import { MIN_FONT_SIZE, PAGE_NUMBER_COLOR, PAGE_NUMBER_MARGIN } from '$lib/pdf-pagination';
import { getPreviewPositionStyle, type OverlayPosition } from '$lib/pdf-overlay';
import { getTextFitScale } from '$lib/pdf-preview-text';
import { PDF_PREVIEW_SCALE, type PdfPageImage } from '$lib/pdf-thumbnails';
type Props = {
/** Rendered page bitmap, or `null` while the first render is pending. */
preview: PdfPageImage | null;
/** Message shown instead of the bitmap when rendering failed. */
error: string | null;
/** Label the export will stamp on this page. */
label: string;
fontSize: number;
position: OverlayPosition;
currentPage: number;
pageCount: number;
onRetry: () => void;
onShowPage: (pageNumber: number) => void;
};
let {
preview,
error,
label,
fontSize,
position,
currentPage,
pageCount,
onRetry,
onShowPage
}: Props = $props();
const pageSize = $derived(
preview
? { width: preview.width / PDF_PREVIEW_SCALE, height: preview.height / PDF_PREVIEW_SCALE }
: null
);
/**
* The export shrinks a label that does not fit (down to `MIN_FONT_SIZE`), so
* the preview applies the same factor instead of clipping.
*/
const fit = $derived.by(() => {
if (!pageSize) return { scale: 1, shrunk: false, tooLong: false };
const raw = getTextFitScale(
label,
fontSize,
pageSize.width - PAGE_NUMBER_MARGIN * 2,
pageSize.height - PAGE_NUMBER_MARGIN * 2
);
const minScale = MIN_FONT_SIZE / fontSize;
return { scale: Math.max(raw, minScale), shrunk: raw < 0.999, tooLong: raw < minScale };
});
const previewFontSize = $derived(
preview ? (fontSize * fit.scale * PDF_PREVIEW_SCALE * 100) / preview.width : 0
);
const shrunkFontSize = $derived(Math.max(MIN_FONT_SIZE, Math.round(fontSize * fit.scale)));
</script>
<section
class="flex flex-col gap-4 rounded-2xl border border-border bg-card p-5"
aria-labelledby="pagination-preview-title"
>
<div class="flex items-start justify-between gap-3">
<div>
<h2 id="pagination-preview-title" class="text-lg font-semibold text-foreground">Vorschau</h2>
<p class="mt-1 text-xs text-muted-foreground">Annäherung an die Ausgabe im PDF</p>
</div>
<span class="text-xs font-semibold text-primary">Seite {currentPage} von {pageCount}</span>
</div>
{#if error}
<div
class="flex flex-col items-center gap-3 rounded-lg border border-border bg-muted/30 p-6 text-center"
role="alert"
>
<p class="text-sm text-foreground">{error}</p>
<Button variant="tertiary" onclick={onRetry}>Erneut versuchen</Button>
</div>
{:else if preview}
<div
class="relative mx-auto w-full max-w-lg overflow-hidden rounded-lg border border-border bg-white shadow-sm"
style="container-type: inline-size; aspect-ratio: {preview.width} / {preview.height};"
>
<img
src={preview.imageUrl}
alt="Vorschau von Seite {currentPage}"
class="block h-full w-full"
/>
<div
class="absolute max-w-[92%] leading-tight whitespace-nowrap"
style="{getPreviewPositionStyle(
position
)} font-size: {previewFontSize}cqw; color: {PAGE_NUMBER_COLOR};"
data-testid="pagination-preview"
data-position={position}
>
{label}
</div>
</div>
{:else}
<div
class="flex aspect-[1/1.414] items-center justify-center rounded-lg border border-border bg-muted/30 text-xs text-muted-foreground"
>
Vorschau wird geladen …
</div>
{/if}
{#if fit.tooLong}
<p class="text-xs text-destructive" role="status">
Der Text ist für diese Seite zu lang. Kürzen Sie den Zusatztext oder wählen Sie ein kürzeres
Zahlenformat.
</p>
{:else if fit.shrunk}
<p class="text-xs text-muted-foreground" role="status">
Auf dieser Seite wird der Text auf {shrunkFontSize} pt verkleinert.
</p>
{/if}
{#if pageCount > 1}
<div class="flex flex-wrap items-center justify-center gap-3">
<Button
variant="tertiary"
class="min-w-0 px-3"
onclick={() => onShowPage(currentPage - 1)}
disabled={currentPage === 1}
aria-label="Vorherige Seite"
>
<ChevronLeft aria-hidden="true" />
</Button>
<Button
variant="tertiary"
class="min-w-0 px-3"
onclick={() => onShowPage(currentPage + 1)}
disabled={currentPage === pageCount}
aria-label="Nächste Seite"
>
<ChevronRight aria-hidden="true" />
</Button>
<label class="flex items-center gap-2 text-sm text-foreground" for="pagination-page">
<span class="text-xs text-muted-foreground">Seite</span>
<input
id="pagination-page"
type="number"
inputmode="numeric"
step="1"
min="1"
max={pageCount}
value={currentPage}
oninput={(event) => onShowPage(Number(event.currentTarget.value))}
onblur={(event) => (event.currentTarget.value = String(currentPage))}
aria-label="Springe zu Seite"
class="w-20 rounded-lg border border-input bg-background px-2 py-1 text-sm"
/>
</label>
</div>
{/if}
</section>
+112
View File
@@ -0,0 +1,112 @@
<script lang="ts">
import type { Snippet } from 'svelte';
import type { PdfThumbnail } from '$lib/pdf-thumbnails';
import { cn } from '$lib/utils';
type Props = {
thumbnails: PdfThumbnail[];
selectedPages?: Set<number>;
onPageClick?: (pageNumber: number) => void;
thumbnailWidth?: number;
class?: string;
pageActions?: Snippet<[pageNumber: number]>;
cardClass?: (pageNumber: number) => string;
};
let {
thumbnails,
selectedPages = new Set<number>(),
onPageClick,
thumbnailWidth = 120,
class: className = '',
pageActions,
cardClass
}: Props = $props();
</script>
<div
class={cn('grid gap-3', className)}
style="grid-template-columns: repeat(auto-fill, minmax({thumbnailWidth}px, 1fr));"
>
{#each thumbnails as thumb}
{@const isSelected = selectedPages.has(thumb.pageNumber)}
{#if onPageClick}
<button
type="button"
class={cn(
'group relative flex flex-col items-center gap-2 rounded-xl border-2 p-2 transition focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-400',
isSelected
? 'border-primary bg-primary/5'
: 'border-transparent hover:border-primary-200 hover:bg-primary-50/50'
)}
onclick={() => onPageClick(thumb.pageNumber)}
>
{#if cardClass}
<div
class={cn(
'flex aspect-square w-full items-center justify-center',
cardClass(thumb.pageNumber)
)}
>
<img
src={thumb.imageUrl}
alt="Seite {thumb.pageNumber}"
class="max-h-full max-w-full rounded border border-primary-100 bg-white object-contain"
/>
</div>
{:else}
<img
src={thumb.imageUrl}
alt="Seite {thumb.pageNumber}"
class="w-full rounded border border-primary-100 bg-white object-contain"
style="aspect-ratio: {thumb.width} / {thumb.height};"
/>
{/if}
<span class="text-[12px] font-medium text-primary-700">Seite {thumb.pageNumber}</span>
{#if isSelected}
<div
class="absolute top-1 right-1 flex h-5 w-5 items-center justify-center rounded-full bg-primary text-[10px] font-bold text-white"
>
✓
</div>
{/if}
{#if pageActions}
{@render pageActions(thumb.pageNumber)}
{/if}
</button>
{:else}
<div
class={cn(
'group relative flex flex-col items-center gap-2 rounded-xl border-2 border-transparent p-2',
isSelected && 'border-primary bg-primary/5'
)}
>
{#if cardClass}
<div
class={cn(
'flex aspect-square w-full items-center justify-center',
cardClass(thumb.pageNumber)
)}
>
<img
src={thumb.imageUrl}
alt="Seite {thumb.pageNumber}"
class="max-h-full max-w-full rounded border border-primary-100 bg-white object-contain"
/>
</div>
{:else}
<img
src={thumb.imageUrl}
alt="Seite {thumb.pageNumber}"
class="w-full rounded border border-primary-100 bg-white object-contain"
style="aspect-ratio: {thumb.width} / {thumb.height};"
/>
{/if}
<span class="text-[12px] font-medium text-primary-700">Seite {thumb.pageNumber}</span>
{#if pageActions}
{@render pageActions(thumb.pageNumber)}
{/if}
</div>
{/if}
{/each}
</div>
+439
View File
@@ -0,0 +1,439 @@
<script lang="ts">
import { onDestroy, onMount } from 'svelte';
import { X } from '@lucide/svelte';
import Button from '$lib/components/ui/button.svelte';
import PdfPageGrid from '$lib/components/PdfPageGrid.svelte';
import { pdfLoadErrorMessage, pdfProcessingErrorMessage } from '$lib/pdf-errors';
import { loadPdfDocument, type PdfDocumentHandle } from '$lib/pdf-thumbnails';
import {
createAttachmentFromPages,
removePagesFromAttachment,
type PdfSplitResult,
type ZipAttachment
} from '$lib/zip-processing';
import { cn } from '$lib/utils';
type SplitMode = 'manual' | 'perPage' | 'everyN';
type AssignedGroup = {
name: string;
pages: number[];
};
type Props = {
source: ZipAttachment;
onApply: (result: PdfSplitResult) => void;
onClose: () => void;
};
let { source, onApply, onClose }: Props = $props();
let panel = $state<HTMLDivElement | null>(null);
let pdfHandle = $state<PdfDocumentHandle | null>(null);
let loadError = $state<string | null>(null);
let splitError = $state<string | null>(null);
let isProcessing = $state(false);
let mode = $state<SplitMode>('manual');
let selectedPages = $state(new Set<number>());
let assignedGroups = $state<AssignedGroup[]>([]);
let groupName = $state('');
let keepSource = $state(false);
let chunkSize = $state(2);
let cancelled = false;
const pageCount = $derived(pdfHandle?.pageCount ?? 0);
const assignedPages = $derived(new Set(assignedGroups.flatMap((group) => group.pages)));
const modes: Array<{ value: SplitMode; label: string }> = [
{ value: 'manual', label: 'Seiten auswählen' },
{ value: 'perPage', label: 'Jede Seite einzeln' },
{ value: 'everyN', label: 'Alle N Seiten' }
];
const isContiguous = (pages: number[]) => {
const sorted = [...pages].sort((a, b) => a - b);
return sorted.every((page, index) => index === 0 || page === sorted[index - 1] + 1);
};
const describePages = (pages: number[]) => {
const sorted = [...pages].sort((a, b) => a - b);
if (sorted.length === 1) {
return `Seite ${sorted[0]}`;
}
if (isContiguous(sorted)) {
return `Seiten ${sorted[0]}–${sorted[sorted.length - 1]}`;
}
return `Seiten ${sorted.join(', ')}`;
};
const defaultGroupName = (pages: number[]) =>
pages.length === 1 ? `Seite ${pages[0]}` : describePages(pages);
const setMode = (nextMode: SplitMode) => {
mode = nextMode;
selectedPages = new Set();
};
const togglePage = (pageNumber: number) => {
if (assignedPages.has(pageNumber)) {
return;
}
const next = new Set(selectedPages);
if (next.has(pageNumber)) {
next.delete(pageNumber);
} else {
next.add(pageNumber);
}
selectedPages = next;
groupName = defaultGroupName([...next]);
};
const assignSelection = () => {
if (selectedPages.size === 0) {
return;
}
const pages = [...selectedPages].sort((a, b) => a - b);
const name = groupName.trim() || defaultGroupName(pages);
assignedGroups = [...assignedGroups, { name, pages }];
selectedPages = new Set();
groupName = '';
};
const removeGroup = (index: number) => {
assignedGroups = assignedGroups.filter((_, groupIndex) => groupIndex !== index);
};
const bulkChunks = $derived.by(() => {
if (mode !== 'perPage' && mode !== 'everyN') {
return [];
}
const chunks: number[][] = [];
if (mode === 'perPage') {
for (let pageNumber = 1; pageNumber <= pageCount; pageNumber += 1) {
chunks.push([pageNumber]);
}
return chunks;
}
const size = Math.max(2, Number.isFinite(chunkSize) ? Math.floor(chunkSize) : 2);
for (let first = 1; first <= pageCount; first += size) {
const chunk: number[] = [];
for (let page = first; page < Math.min(first + size, pageCount + 1); page += 1) {
chunk.push(page);
}
if (chunk.length > 0) {
chunks.push(chunk);
}
}
return chunks;
});
const buildResult = async (groups: AssignedGroup[]): Promise<PdfSplitResult> => {
const splitGroups = [];
for (const group of groups) {
const attachment = await createAttachmentFromPages(source, group.pages);
splitGroups.push({ name: group.name, attachment });
}
const assignedAllPages = groups.flatMap((group) => group.pages);
const remainingSource = keepSource
? source
: await removePagesFromAttachment(source, assignedAllPages);
return { groups: splitGroups, source: remainingSource };
};
const applyGroups = async (groups: AssignedGroup[]) => {
if (groups.length === 0 || isProcessing) {
return;
}
isProcessing = true;
splitError = null;
try {
onApply(await buildResult(groups));
} catch (error) {
console.error(`Failed to split PDF attachment ${source.path}`, error);
splitError = pdfProcessingErrorMessage(error, 'Das PDF konnte nicht geteilt werden.');
} finally {
isProcessing = false;
}
};
const applyBulk = () => {
void applyGroups(bulkChunks.map((pages) => ({ name: defaultGroupName(pages), pages })));
};
const applyManual = () => {
void applyGroups(assignedGroups);
};
const handleKeydown = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
onClose();
}
};
onMount(() => {
panel?.focus();
(async () => {
try {
const loaded = await loadPdfDocument(new Uint8Array(source.data));
if (cancelled) {
loaded.destroy();
return;
}
pdfHandle = loaded;
} catch (error) {
console.error(`Failed to load PDF attachment ${source.path}`, error);
if (!cancelled) {
loadError = pdfLoadErrorMessage(error);
}
}
})();
});
onDestroy(() => {
cancelled = true;
pdfHandle?.destroy();
pdfHandle = null;
});
</script>
<svelte:window onkeydown={handleKeydown} />
<div
class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4"
role="presentation"
>
<div
bind:this={panel}
class="flex max-h-[90vh] w-full max-w-3xl flex-col overflow-hidden rounded-2xl bg-white shadow-xl outline-none"
role="dialog"
aria-modal="true"
aria-label="PDF teilen"
data-split-dialog
tabindex="-1"
>
<header class="border-primary-100 flex items-center justify-between gap-3 border-b px-5 py-4">
<div class="min-w-0">
<h2 class="text-primary-900 text-[16px] font-bold">PDF teilen</h2>
<p class="text-primary-700 truncate text-[13px]" title={source.name}>
{source.name} — {pageCount} Seite{pageCount === 1 ? '' : 'n'}
</p>
</div>
<button
type="button"
class="border-primary-200 text-primary-700 hover:border-primary-400 hover:bg-primary-50 flex h-8 w-8 shrink-0 cursor-pointer items-center justify-center rounded-full border bg-white transition"
aria-label="Teilen abbrechen"
onclick={onClose}
>
<X class="size-4" aria-hidden="true" />
</button>
</header>
<div class="flex-1 space-y-4 overflow-y-auto px-5 py-4">
<div
class="border-primary-200 inline-flex flex-wrap items-center rounded-[16px] border bg-white p-0.5"
role="radiogroup"
aria-label="Art des Teilens"
>
{#each modes as modeOption (modeOption.value)}
<label
class={cn(
'cursor-pointer rounded-[14px] px-3 py-1 text-[13px] font-semibold transition',
mode === modeOption.value
? 'bg-primary text-primary-foreground'
: 'text-primary-700 hover:bg-primary-50'
)}
>
<input
class="sr-only"
type="radio"
name="split-mode"
value={modeOption.value}
checked={mode === modeOption.value}
onchange={() => setMode(modeOption.value)}
/>
{modeOption.label}
</label>
{/each}
</div>
{#if mode === 'everyN'}
<label class="text-primary-900 flex flex-wrap items-center gap-2 text-[13px] font-semibold">
Seiten pro Teildokument
<input
class="border-primary-200 text-primary-900 focus:border-primary-400 focus:ring-primary-200 w-20 rounded-md border bg-white px-2 py-1 text-[13px] font-medium transition outline-none focus:ring-2"
type="number"
min={2}
value={chunkSize}
oninput={(event) => {
chunkSize = Number((event.currentTarget as HTMLInputElement).value);
}}
/>
</label>
{/if}
{#if mode !== 'manual' && pageCount > 0}
<p class="text-primary-700 text-[13px] font-medium" aria-live="polite">
Es werden {bulkChunks.length}
Teildokument{bulkChunks.length === 1 ? '' : 'e'} erstellt.
</p>
{/if}
{#if loadError}
<div class="rounded-container bg-red-50 px-4 py-3 text-[14px] font-medium text-red-900">
{loadError}
</div>
{:else if pdfHandle}
<PdfPageGrid
thumbnails={pdfHandle.thumbnails}
{selectedPages}
onPageClick={mode === 'manual' ? togglePage : undefined}
class={mode === 'manual' ? '' : 'pointer-events-none'}
cardClass={(pageNumber) =>
assignedPages.has(pageNumber) ? 'bg-primary/10 rounded-md p-1' : ''}
>
{#snippet pageActions(pageNumber)}
{#if assignedPages.has(pageNumber)}
<span
class="mt-1 rounded-full bg-primary px-2 py-0.5 text-[11px] font-bold text-primary-foreground"
>
✓ Zugewiesen
</span>
{/if}
{/snippet}
</PdfPageGrid>
{:else}
<div
class="border-primary-200 bg-primary-50 text-primary-700 flex items-center justify-center rounded-md border border-dashed px-4 py-10 text-[13px]"
>
Vorschau wird erstellt
</div>
{/if}
{#if mode === 'manual' && pdfHandle}
<div class="flex flex-wrap items-center gap-2">
<label class="flex min-w-0 flex-1 items-center gap-2">
<span class="sr-only">Name des Teildokuments</span>
<input
class="border-primary-200 text-primary-900 focus:border-primary-400 focus:ring-primary-200 w-full min-w-40 rounded-md border bg-white px-3 py-1.5 text-[13px] font-semibold transition outline-none focus:ring-2"
type="text"
placeholder={selectedPages.size > 0
? defaultGroupName([...selectedPages])
: 'Name des Teildokuments'}
value={groupName}
oninput={(event) => {
groupName = (event.currentTarget as HTMLInputElement).value;
}}
/>
</label>
<Button
type="button"
disabled={selectedPages.size === 0 || isProcessing}
onclick={assignSelection}
>
Als Teildokument übernehmen
</Button>
</div>
<p class="text-primary-700 text-[13px]" aria-live="polite">
{selectedPages.size} von {pageCount} Seiten ausgewählt
</p>
{/if}
{#if assignedGroups.length > 0}
<ul class="flex flex-col gap-2">
{#each assignedGroups as group, index (index)}
<li
class="border-primary-200 bg-primary-50 flex flex-wrap items-center gap-2 rounded-xl border px-3 py-1.5"
>
<span class="text-primary-900 min-w-0 flex-1 truncate text-[13px] font-semibold">
{group.name}
</span>
<span class="text-primary-700 text-[12px] font-medium">
{describePages(group.pages)}
</span>
<button
type="button"
class="text-primary-600 flex h-6 w-6 cursor-pointer items-center justify-center rounded-full transition hover:bg-white hover:text-red-700"
aria-label={`Zuordnung „${group.name}“ aufheben`}
onclick={() => removeGroup(index)}
>
<X class="size-3.5" aria-hidden="true" />
</button>
</li>
{/each}
</ul>
{/if}
{#if splitError}
<div class="rounded-container bg-red-50 px-4 py-3 text-[14px] font-medium text-red-900">
{splitError}
</div>
{/if}
<label class="text-primary-900 flex items-center gap-2 text-[13px] font-medium">
<input
class="accent-primary-700 h-4 w-4"
type="checkbox"
checked={keepSource}
onchange={(event) => {
keepSource = (event.currentTarget as HTMLInputElement).checked;
}}
/>
Seiten im Original behalten (kopieren statt ausschneiden)
</label>
</div>
<footer
class="border-primary-100 flex flex-wrap items-center justify-end gap-2 border-t px-5 py-4"
>
<Button type="button" variant="bordered" disabled={isProcessing} onclick={onClose}>
Abbrechen
</Button>
{#if mode === 'manual'}
<Button
type="button"
disabled={assignedGroups.length === 0 || isProcessing}
onclick={applyManual}
data-split-apply
>
{isProcessing ? 'Teildokumente werden erstellt' : 'Teilen'}
</Button>
{:else}
<Button
type="button"
disabled={bulkChunks.length === 0 || isProcessing}
onclick={applyBulk}
data-split-apply
>
{isProcessing ? 'Teildokumente werden erstellt' : 'Teilen'}
</Button>
{/if}
</footer>
</div>
</div>
@@ -0,0 +1,777 @@
<script lang="ts">
import { FilePlus2, Layers } from '@lucide/svelte';
import AttachmentMenu from '$lib/components/AttachmentMenu.svelte';
import AttachmentPreview from '$lib/components/AttachmentPreview.svelte';
import Button from '$lib/components/ui/button.svelte';
import * as AlertDialog from '$lib/components/ui/alert-dialog/index.js';
import PdfSplitDialog from '$lib/components/PdfSplitDialog.svelte';
import { pdfProcessingErrorMessage } from '$lib/pdf-errors';
import SubDocumentEditor from '$lib/components/SubDocumentEditor.svelte';
import {
applyPdfSplit,
buildArchiveExport,
buildSubDocumentExport,
createSubDocument,
createSubDocumentFromPaths,
dissolveSubDocument,
groupEachAttachmentAsSubDocument,
moveAttachmentIntoSubDocument,
moveAttachmentsByPathsToSubDocument,
moveSubDocument,
nextSubDocumentNumber,
removeAttachmentFromArchive,
removeAttachmentFromSubDocument,
type AttachmentLocation,
type ExportMode,
type PdfSplitResult,
type ProcessedZipArchive,
type SubDocument,
type ZipAttachment
} from '$lib/zip-processing';
import { cn } from '$lib/utils';
type Props = {
archive: ProcessedZipArchive;
thumbnailWidth: number;
onArchiveChange?: (archive: ProcessedZipArchive) => void;
};
let { archive, thumbnailWidth = 200, onArchiveChange = () => {} }: Props = $props();
const ATTACHMENT_MIME = 'application/x-juri-merger-attachment';
let archiveName = $state('');
let looseAttachments = $state<ZipAttachment[]>([]);
let subDocuments = $state<SubDocument[]>([]);
let groupingMode = $state(false);
let selectedPaths = $state<Set<string>>(new Set());
let focusSubDocumentId = $state<string | null>(null);
let splitSource = $state<ZipAttachment | null>(null);
let removeCandidate = $state<ZipAttachment | null>(null);
let dragIndex = $state<number | null>(null);
let dropIndex = $state<number | null>(null);
let blockDragIndex = $state<number | null>(null);
let blockDropIndex = $state<number | null>(null);
let downloadMode = $state<ExportMode>('single');
let isMerging = $state(false);
let downloadingSubDocumentId = $state<string | null>(null);
let mergeError = $state<string | null>(null);
const isExportActive = $derived(isMerging || downloadingSubDocumentId !== null);
const hasSubDocuments = $derived(subDocuments.length > 0);
const subDocumentTargets = $derived(
subDocuments.map((subDocument) => ({ id: subDocument.id, name: subDocument.name }))
);
const allAttachmentPaths = $derived.by(() => {
const paths = new Set<string>();
for (const subDocument of subDocuments) {
for (const attachment of subDocument.attachments) {
paths.add(attachment.path);
}
}
for (const attachment of looseAttachments) {
paths.add(attachment.path);
}
return paths;
});
const selectedCount = $derived(
[...selectedPaths].filter((path) => allAttachmentPaths.has(path)).length
);
$effect(() => {
archiveName = archive.name;
looseAttachments = archive.attachments.map((attachment) => ({ ...attachment }));
subDocuments = archive.subDocuments.map((subDocument) => ({
...subDocument,
attachments: subDocument.attachments.map((attachment) => ({ ...attachment }))
}));
});
const toArrayBuffer = (bytes: Uint8Array) => {
const copy = new Uint8Array(bytes.byteLength);
copy.set(bytes);
return copy.buffer;
};
const normalizeFileName = (name: string) =>
name.toLowerCase().endsWith('.pdf') ? name : `${name}.pdf`;
const downloadPdfBytes = (bytes: Uint8Array, fileName: string) => {
const pdfBlob = new Blob([toArrayBuffer(bytes)], { type: 'application/pdf' });
const objectUrl = URL.createObjectURL(pdfBlob);
const downloadLink = document.createElement('a');
downloadLink.href = objectUrl;
downloadLink.download = normalizeFileName(fileName);
downloadLink.rel = 'noopener';
downloadLink.click();
window.setTimeout(() => URL.revokeObjectURL(objectUrl), 1000);
};
const composedArchive = (): ProcessedZipArchive => ({
...archive,
name: archiveName,
attachments: looseAttachments,
subDocuments
});
const emitArchiveChange = (nextArchive: ProcessedZipArchive) => {
archiveName = nextArchive.name;
looseAttachments = nextArchive.attachments;
subDocuments = nextArchive.subDocuments;
onArchiveChange(nextArchive);
};
const commit = () => {
onArchiveChange(composedArchive());
};
const toggleSelection = (path: string) => {
const next = new Set(selectedPaths);
if (next.has(path)) {
next.delete(path);
} else {
next.add(path);
}
selectedPaths = next;
};
const toggleGroupingMode = () => {
groupingMode = !groupingMode;
selectedPaths = new Set();
};
const exitGroupingMode = () => {
groupingMode = false;
selectedPaths = new Set();
};
const selectAllAttachments = () => {
selectedPaths = new Set(allAttachmentPaths);
};
const selectNoAttachments = () => {
selectedPaths = new Set();
};
const handleWindowKeydown = (event: KeyboardEvent) => {
if (event.key === 'Escape' && groupingMode && !splitSource && !removeCandidate) {
exitGroupingMode();
}
};
const focusLastSubDocument = (nextArchive: ProcessedZipArchive) => {
focusSubDocumentId = nextArchive.subDocuments[nextArchive.subDocuments.length - 1]?.id ?? null;
};
const createGroupFromSelection = () => {
if (selectedCount === 0) {
return;
}
const nextArchive = createSubDocumentFromPaths(composedArchive(), selectedPaths);
emitArchiveChange(nextArchive);
focusLastSubDocument(nextArchive);
selectedPaths = new Set();
};
const moveSelectionToTarget = (targetId: string) => {
if (targetId === '' || selectedCount === 0) {
return;
}
emitArchiveChange(
moveAttachmentsByPathsToSubDocument(composedArchive(), selectedPaths, targetId)
);
selectedPaths = new Set();
};
const createEmptySubDocument = () => {
const nextSubDocument = createSubDocument(
`Teildokument ${nextSubDocumentNumber(subDocuments)}`,
[]
);
const nextArchive = {
...composedArchive(),
subDocuments: [...subDocuments, nextSubDocument]
};
emitArchiveChange(nextArchive);
focusSubDocumentId = nextSubDocument.id;
};
const groupEachAttachment = () => {
if (looseAttachments.length === 0) {
return;
}
emitArchiveChange(groupEachAttachmentAsSubDocument(composedArchive()));
focusSubDocumentId = null;
};
const locateAttachment = (
sourceArchive: ProcessedZipArchive,
path: string
): AttachmentLocation | null => {
const looseIndex = sourceArchive.attachments.findIndex(
(attachment) => attachment.path === path
);
if (looseIndex !== -1) {
return { kind: 'loose', index: looseIndex };
}
for (const subDocument of sourceArchive.subDocuments) {
const index = subDocument.attachments.findIndex((attachment) => attachment.path === path);
if (index !== -1) {
return { kind: 'subDocument', id: subDocument.id, index };
}
}
return null;
};
const moveAttachmentToTarget = (path: string, targetId: string) => {
if (targetId === 'new') {
const nextArchive = createSubDocumentFromPaths(composedArchive(), new Set([path]));
emitArchiveChange(nextArchive);
focusLastSubDocument(nextArchive);
return;
}
emitArchiveChange(
moveAttachmentsByPathsToSubDocument(composedArchive(), new Set([path]), targetId)
);
};
const moveAttachmentToLoose = (path: string) => {
const location = locateAttachment(composedArchive(), path);
if (!location || location.kind !== 'subDocument') {
return;
}
emitArchiveChange(
removeAttachmentFromSubDocument(composedArchive(), location.id, location.index)
);
};
const confirmRemoveAttachment = () => {
if (!removeCandidate) {
return;
}
emitArchiveChange(removeAttachmentFromArchive(composedArchive(), removeCandidate.path));
removeCandidate = null;
};
const handleSplitApply = (result: PdfSplitResult) => {
if (!splitSource) {
return;
}
emitArchiveChange(applyPdfSplit(composedArchive(), splitSource.path, result));
splitSource = null;
};
const dissolveSubDocumentBlock = (subDocumentId: string) => {
const nextArchive = dissolveSubDocument(composedArchive(), subDocumentId);
emitArchiveChange(nextArchive);
};
const handleSubDocumentChange = (next: SubDocument) => {
subDocuments = subDocuments.map((subDocument) =>
subDocument.id === next.id ? next : subDocument
);
commit();
};
const handleAttachmentMove = (source: AttachmentLocation, target: AttachmentLocation) => {
const nextArchive = moveAttachmentIntoSubDocument(composedArchive(), source, target);
emitArchiveChange(nextArchive);
};
const handleBlockDragStart = (blockIndex: number, _event: DragEvent) => {
blockDragIndex = blockIndex;
blockDropIndex = blockIndex;
};
const handleBlockDragOver = (blockIndex: number, _event: DragEvent) => {
blockDropIndex = blockIndex;
};
const handleBlockDrop = (blockIndex: number, _event: DragEvent) => {
if (blockDragIndex === null) {
return;
}
const fromIndex = blockDragIndex;
const nextArchive = moveSubDocument(composedArchive(), fromIndex, blockIndex);
emitArchiveChange(nextArchive);
blockDragIndex = null;
blockDropIndex = null;
};
const handleBlockDragEnd = () => {
blockDragIndex = null;
blockDropIndex = null;
};
const moveLooseAttachment = (fromIndex: number, toIndex: number) => {
if (
fromIndex === toIndex ||
fromIndex < 0 ||
toIndex < 0 ||
fromIndex >= looseAttachments.length ||
toIndex >= looseAttachments.length
) {
return;
}
const nextAttachments = [...looseAttachments];
const [movedAttachment] = nextAttachments.splice(fromIndex, 1);
nextAttachments.splice(toIndex, 0, movedAttachment);
looseAttachments = nextAttachments;
commit();
};
const handleLooseDragStart = (index: number, event: DragEvent) => {
if (groupingMode) {
event.preventDefault();
return;
}
dragIndex = index;
dropIndex = index;
const source: AttachmentLocation = { kind: 'loose', index };
if (event.dataTransfer) {
event.dataTransfer.effectAllowed = 'move';
event.dataTransfer.setData(ATTACHMENT_MIME, JSON.stringify(source));
event.dataTransfer.setData('text/plain', String(index));
}
};
const parseAttachmentPayload = (event: DragEvent): AttachmentLocation | null => {
const raw = event.dataTransfer?.getData(ATTACHMENT_MIME);
if (!raw) {
return null;
}
try {
const parsed = JSON.parse(raw) as AttachmentLocation;
if (parsed.kind === 'loose' || parsed.kind === 'subDocument') {
return parsed;
}
return null;
} catch {
return null;
}
};
const handleLooseDragOver = (index: number, event: DragEvent) => {
if (event.dataTransfer?.types.includes(ATTACHMENT_MIME)) {
event.preventDefault();
dropIndex = index;
}
};
const handleLooseDrop = (index: number, event: DragEvent) => {
const source = parseAttachmentPayload(event);
if (!source) {
return;
}
event.preventDefault();
if (source.kind === 'loose') {
moveLooseAttachment(source.index, index);
} else {
const nextArchive = moveAttachmentIntoSubDocument(composedArchive(), source, {
kind: 'loose',
index
});
emitArchiveChange(nextArchive);
}
dragIndex = null;
dropIndex = null;
};
const handleLooseDragEnd = () => {
dragIndex = null;
dropIndex = null;
};
const downloadArchive = async () => {
if (looseAttachments.length === 0 && subDocuments.length === 0) {
return;
}
if (isExportActive) {
return;
}
isMerging = true;
mergeError = null;
try {
const exportUnits = await buildArchiveExport(composedArchive(), downloadMode);
for (const unit of exportUnits) {
downloadPdfBytes(unit.bytes, unit.name);
}
} catch (error) {
console.error(`Failed to export archive ${archive.name}`, error);
mergeError = pdfProcessingErrorMessage(error, 'PDF konnte nicht erstellt werden.');
} finally {
isMerging = false;
}
};
const downloadSubDocument = async (subDocument: SubDocument) => {
if (isExportActive || subDocument.attachments.length === 0) {
return;
}
downloadingSubDocumentId = subDocument.id;
mergeError = null;
try {
const exportUnit = await buildSubDocumentExport(archiveName, subDocument);
downloadPdfBytes(exportUnit.bytes, exportUnit.name);
} catch (error) {
console.error(
`Failed to export sub-document "${subDocument.name}" from archive ${archive.name}`,
error
);
mergeError = pdfProcessingErrorMessage(
error,
`Teildokument „${subDocument.name}“ konnte nicht als PDF erstellt werden.`
);
} finally {
downloadingSubDocumentId = null;
}
};
</script>
<svelte:window onkeydown={handleWindowKeydown} />
<section
class="border-primary-100 flex flex-col gap-4 rounded-2xl border bg-white px-4 py-4 shadow-sm"
>
<header class="flex flex-wrap items-baseline justify-between gap-2 text-primary">
<div class="min-w-0 flex-1">
<label class="block min-w-0">
<span class="sr-only">Archivname</span>
<input
class="border-primary-200 text-primary-900 focus:border-primary-400 focus:ring-primary-200 w-full max-w-xl rounded-md border bg-white px-3 py-2 text-[16px] font-bold transition outline-none focus:ring-2"
type="text"
value={archiveName}
draggable="false"
oninput={(event) => {
archiveName = (event.currentTarget as HTMLInputElement).value;
commit();
}}
/>
</label>
<p class="mt-1 text-[13px] text-primary">
{looseAttachments.length} lose Datei{looseAttachments.length === 1 ? '' : 'en'}
{#if hasSubDocuments}
· {subDocuments.length} Teildokument{subDocuments.length === 1 ? '' : 'e'}
{/if}
</p>
</div>
<div class="flex flex-wrap items-center gap-2">
{#if !groupingMode}
<Button type="button" variant="bordered" onclick={createEmptySubDocument}>
<FilePlus2 class="size-4" aria-hidden="true" />
Neues Teildokument
</Button>
{#if looseAttachments.length >= 2}
<Button type="button" variant="bordered" onclick={groupEachAttachment}>
<Layers class="size-4" aria-hidden="true" />
Jede Datei als Teildokument
</Button>
{/if}
{/if}
<Button
type="button"
variant={groupingMode ? 'secondary' : 'bordered'}
onclick={toggleGroupingMode}
>
<Layers class="size-4" aria-hidden="true" />
{groupingMode ? 'Fertig' : 'Gruppieren'}
</Button>
{#if hasSubDocuments}
<div
class={cn(
'border-primary-200 inline-flex items-center rounded-[16px] border bg-white p-0.5',
downloadingSubDocumentId !== null ? 'opacity-60' : ''
)}
role="radiogroup"
aria-label="Export-Modus"
>
{#each [{ value: 'single', label: 'Eine PDF' }, { value: 'separate', label: 'Getrennt' }] as option (option.value)}
<label
class={cn(
'rounded-[14px] px-3 py-1 text-[13px] font-semibold transition',
downloadMode === option.value
? 'bg-primary text-primary-foreground'
: 'text-primary-700 hover:bg-primary-50',
downloadingSubDocumentId !== null ? 'cursor-not-allowed' : 'cursor-pointer'
)}
>
<input
class="sr-only"
type="radio"
name={`download-mode-${archive.name}`}
value={option.value}
checked={downloadMode === option.value}
disabled={downloadingSubDocumentId !== null}
onchange={() => {
downloadMode = option.value as ExportMode;
}}
/>
{option.label}
</label>
{/each}
</div>
{/if}
<Button
class="bg-primary"
type="button"
disabled={(looseAttachments.length === 0 && subDocuments.length === 0) || isExportActive}
onclick={downloadArchive}
>
{isMerging ? 'PDF wird erstellt' : 'PDF herunterladen'}
</Button>
</div>
</header>
{#if mergeError}
<div class="rounded-container bg-red-50 px-4 py-3 text-[14px] font-medium text-red-900">
{mergeError}
</div>
{/if}
{#if groupingMode}
<div
class="border-primary-200 bg-primary-50 text-primary-900 flex flex-wrap items-center gap-3 rounded-xl border px-4 py-2 text-[13px] font-medium"
>
<span aria-live="polite">
{selectedCount} Datei{selectedCount === 1 ? '' : 'en'} ausgewählt
</span>
<Button type="button" disabled={selectedCount === 0} onclick={createGroupFromSelection}>
Teildokument erstellen
</Button>
{#if hasSubDocuments}
<select
class="h-8 cursor-pointer rounded-[16px] border-2 border-[#E6E7EA] bg-white px-3 text-[13px] font-bold text-primary disabled:cursor-not-allowed disabled:text-[#0B1F4366]"
aria-label="Auswahl in Teildokument verschieben"
disabled={selectedCount === 0}
onchange={(event) => {
const select = event.currentTarget as HTMLSelectElement;
const targetId = select.value;
select.value = '';
moveSelectionToTarget(targetId);
}}
>
<option value="">In Teildokument verschieben…</option>
{#each subDocuments as subDocument (subDocument.id)}
<option value={subDocument.id}>{subDocument.name}</option>
{/each}
</select>
{/if}
<div class="ml-auto flex items-center gap-2">
<Button type="button" variant="tertiary" onclick={selectAllAttachments}>
Alle auswählen
</Button>
<Button
type="button"
variant="tertiary"
disabled={selectedCount === 0}
onclick={selectNoAttachments}
>
Auswahl aufheben
</Button>
</div>
</div>
{/if}
<ul class="flex gap-3 overflow-x-auto pb-1">
{#each looseAttachments as attachment, index (attachment.path)}
<li
class={cn(
'flex shrink-0 flex-col items-stretch gap-2 rounded-xl border bg-accent p-2 text-primary transition',
dropIndex === index ? 'border-primary-400 ring-primary-200 ring-1' : 'border-primary-100',
dragIndex === index ? 'opacity-60' : '',
selectedPaths.has(attachment.path) ? 'border-primary-500 ring-primary-300 ring-2' : ''
)}
style={`width: ${thumbnailWidth}px;`}
draggable={groupingMode ? 'false' : 'true'}
ondragstart={(event) => handleLooseDragStart(index, event)}
ondragover={(event) => handleLooseDragOver(index, event)}
ondrop={(event) => handleLooseDrop(index, event)}
ondragend={handleLooseDragEnd}
>
{#if groupingMode}
<label class="flex cursor-pointer flex-col gap-2">
<div class="text-primary-900 flex items-center gap-2 px-1 text-[13px] font-medium">
<input
class="accent-primary-700 h-4 w-4"
type="checkbox"
checked={selectedPaths.has(attachment.path)}
onchange={() => toggleSelection(attachment.path)}
/>
<span class="truncate"
>{selectedPaths.has(attachment.path) ? 'Ausgewählt' : 'Auswählen'}</span
>
</div>
<div
class="text-primary-900 truncate text-[13px] leading-tight font-medium"
title={attachment.name}
>
{attachment.name}
</div>
<AttachmentPreview {attachment} />
</label>
{:else}
<div class="flex min-w-0 flex-1 flex-row items-center gap-1 py-1">
<button
class="border-primary-200 text-primary-700 w-fit cursor-grab rounded-md border bg-white px-2 py-1 text-sm leading-none font-semibold active:cursor-grabbing"
draggable="false"
type="button"
aria-label="Datei verschieben"
>
::
</button>
<div
class="text-primary-900 min-w-0 flex-1 truncate text-[13px] leading-tight font-medium"
title={attachment.name}
>
{attachment.name}
</div>
<AttachmentMenu
{attachment}
moveTargets={subDocumentTargets}
canSplit={attachment.kind === 'pdf'}
onMoveToTarget={(targetId) => moveAttachmentToTarget(attachment.path, targetId)}
onSplit={() => (splitSource = attachment)}
onRemove={() => (removeCandidate = attachment)}
/>
</div>
<AttachmentPreview {attachment} />
{/if}
</li>
{/each}
</ul>
{#if looseAttachments.length === 0 && subDocuments.length === 0}
<div
class="border-primary-200 bg-primary-50 text-primary-700 rounded-md border border-dashed px-4 py-4 text-[13px]"
>
Keine Dateien in diesem Archiv. Lade ein anderes ZIP, um weiterzuarbeiten.
</div>
{/if}
{#if hasSubDocuments}
<div class="flex flex-col gap-3">
{#each subDocuments as subDocument, blockIndex (subDocument.id)}
<SubDocumentEditor
{subDocument}
{blockIndex}
{thumbnailWidth}
isBlockDragSource={blockDragIndex === blockIndex}
isBlockDropTarget={blockDropIndex === blockIndex && blockDragIndex !== blockIndex}
{groupingMode}
{selectedPaths}
onToggleSelection={toggleSelection}
autoFocusName={subDocument.id === focusSubDocumentId}
moveTargets={subDocumentTargets.filter((target) => target.id !== subDocument.id)}
onMoveToTarget={(path, targetId) => moveAttachmentToTarget(path, targetId)}
onMoveToLoose={moveAttachmentToLoose}
onSplit={(attachment) => (splitSource = attachment)}
onRemoveAttachment={(attachment) => (removeCandidate = attachment)}
onSubDocumentChange={handleSubDocumentChange}
onRemove={() => dissolveSubDocumentBlock(subDocument.id)}
onAttachmentMove={handleAttachmentMove}
onBlockDragStart={handleBlockDragStart}
onBlockDragOver={handleBlockDragOver}
onBlockDrop={handleBlockDrop}
onBlockDragEnd={handleBlockDragEnd}
onDownload={() => downloadSubDocument(subDocument)}
isDownloading={downloadingSubDocumentId === subDocument.id}
downloadDisabled={isExportActive}
/>
{/each}
</div>
{/if}
</section>
{#if splitSource}
<PdfSplitDialog
source={splitSource}
onApply={handleSplitApply}
onClose={() => (splitSource = null)}
/>
{/if}
<AlertDialog.Root
open={removeCandidate !== null}
onOpenChange={(open) => {
if (!open) removeCandidate = null;
}}
>
<AlertDialog.Content class="max-w-sm">
<AlertDialog.Title class="text-primary-900 text-base font-bold">
Datei entfernen?
</AlertDialog.Title>
<AlertDialog.Description>
{removeCandidate?.name ?? ''} wird aus dem Archiv entfernt. Bereits heruntergeladene PDFs bleiben
erhalten.
</AlertDialog.Description>
<AlertDialog.Footer>
<AlertDialog.Cancel>Abbrechen</AlertDialog.Cancel>
<AlertDialog.Action onclick={confirmRemoveAttachment}>Entfernen</AlertDialog.Action>
</AlertDialog.Footer>
</AlertDialog.Content>
</AlertDialog.Root>
+92
View File
@@ -0,0 +1,92 @@
<script lang="ts">
import { pwaInfo } from 'virtual:pwa-info';
import Button from '$lib/components/ui/button.svelte';
/**
* Service-worker registration and the update hint for the PWA.
*
* Rendered only in the browser: the plugin's virtual modules are no-ops during SSR and in
* `pnpm dev` (PWA `devOptions` are disabled), so `pwaInfo` is `undefined` there and nothing is
* registered.
*
* Updates are never applied automatically (registerType `prompt`): a waiting worker is only
* activated when the user confirms, so an in-progress archive is never interrupted.
*/
let needRefresh = $state(false);
let offlineReady = $state(false);
let updateServiceWorker = $state<(reloadPage?: boolean) => Promise<void>>(async () => {});
let offlineReadyTimer: ReturnType<typeof setTimeout> | undefined;
$effect(() => {
if (!pwaInfo) return;
let cancelled = false;
(async () => {
const { registerSW } = await import('virtual:pwa-register');
if (cancelled) return;
updateServiceWorker = registerSW({
immediate: true,
onNeedRefresh() {
offlineReady = false;
needRefresh = true;
},
onOfflineReady() {
offlineReady = true;
offlineReadyTimer = setTimeout(close, 8000);
},
onRegisterError(error: unknown) {
console.error('Service Worker konnte nicht registriert werden.', error);
}
});
})();
return () => {
cancelled = true;
if (offlineReadyTimer) clearTimeout(offlineReadyTimer);
};
});
function close() {
needRefresh = false;
offlineReady = false;
if (offlineReadyTimer) {
clearTimeout(offlineReadyTimer);
offlineReadyTimer = undefined;
}
}
async function reload() {
await updateServiceWorker(true);
}
</script>
{#if needRefresh || offlineReady}
<div
role="status"
aria-live="polite"
class="fixed inset-x-4 bottom-4 z-50 flex flex-col gap-3 rounded-2xl border border-border bg-white p-4 shadow-lg sm:left-auto sm:w-[min(24rem,calc(100vw-2rem))]"
>
<p class="text-sm font-bold text-primary">
{needRefresh
? 'Neue Version von beA-Edit verfügbar.'
: 'beA-Edit funktioniert jetzt offline.'}
</p>
{#if needRefresh}
<p class="text-sm text-muted-foreground">
Die neue Version wird geladen, sobald Sie neu laden. Ein laufender Vorgang wird dabei nicht
unterbrochen.
</p>
{/if}
<div class="flex flex-row flex-wrap gap-3">
{#if needRefresh}
<Button variant="primary" size="standard" onclick={reload}>Neu laden</Button>
{/if}
<Button variant="bordered" size="standard" onclick={close}>Später</Button>
</div>
</div>
{/if}
@@ -0,0 +1,23 @@
<script lang="ts">
import { AlertTriangle } from '@lucide/svelte';
import { isRasterizationTooLarge, shouldWarnAboutRasterization } from '$lib/rasterization-limits';
type Props = { file: File | null };
let { file }: Props = $props();
</script>
{#if shouldWarnAboutRasterization(file)}
<div
class="flex gap-3 rounded-lg border p-4 text-sm {isRasterizationTooLarge(file)
? 'border-red-200 bg-red-50 text-red-800'
: 'border-amber-200 bg-amber-50 text-amber-800'}"
role={isRasterizationTooLarge(file) ? 'alert' : undefined}
>
<AlertTriangle class="mt-0.5 h-5 w-5 shrink-0" />
<p>
{isRasterizationTooLarge(file)
? 'Diese Datei ist größer als 100 MB und kann aus Speichergründen nicht im Browser gerastert werden.'
: 'Diese große Datei benötigt beim Rasterisieren viel Arbeitsspeicher. Schließen Sie andere speicherintensive Tabs.'}
</p>
</div>
{/if}
+73
View File
@@ -0,0 +1,73 @@
<script lang="ts">
import type { Snippet } from 'svelte';
import FileDropzone from '$lib/components/FileDropzone.svelte';
import ToolError from '$lib/components/ToolError.svelte';
type Props = {
title: string;
description: string;
file: File | null;
/** `0` while no PDF is loaded; switches the shell to the bare dropzone. */
pageCount: number;
loadingError: string | null;
error?: string | null;
onFilesSelected: (files: File[]) => void;
/** Extra toolbar buttons next to the file name. */
actions?: Snippet;
/** Main content, e.g. the page grid. */
children: Snippet;
/** Status line plus primary action, laid out `space-between`. */
footer: Snippet;
};
let {
title,
description,
file,
pageCount,
loadingError,
error,
onFilesSelected,
actions,
children,
footer
}: Props = $props();
</script>
<div class="mx-auto max-w-4xl space-y-6">
<div>
<h1 class="text-primary-900 text-2xl font-bold">{title}</h1>
<p class="text-primary-700 mt-2 text-sm">{description}</p>
</div>
{#if pageCount === 0}
<FileDropzone {onFilesSelected} />
<ToolError message={loadingError} class="mt-4" />
{:else}
<div class="space-y-4">
<FileDropzone
{onFilesSelected}
label="Anderes PDF ablegen"
sublabel={file?.name ?? ''}
class="py-4"
/>
<div class="flex flex-wrap items-center justify-between gap-3">
<h2 class="text-primary-900 text-lg font-semibold">
{file?.name} — {pageCount} Seite{pageCount === 1 ? '' : 'n'}
</h2>
<div class="flex flex-wrap gap-2">
{@render actions?.()}
</div>
</div>
{@render children()}
<ToolError message={error} />
<div class="flex items-center justify-between">
{@render footer()}
</div>
</div>
{/if}
</div>
+377
View File
@@ -0,0 +1,377 @@
<script lang="ts">
import AttachmentMenu, { type AttachmentMoveTarget } from '$lib/components/AttachmentMenu.svelte';
import AttachmentPreview from '$lib/components/AttachmentPreview.svelte';
import Button from '$lib/components/ui/button.svelte';
import type { AttachmentLocation, SubDocument, ZipAttachment } from '$lib/zip-processing';
import { cn } from '$lib/utils';
type Props = {
subDocument: SubDocument;
blockIndex: number;
thumbnailWidth: number;
isBlockDragSource?: boolean;
isBlockDropTarget?: boolean;
groupingMode?: boolean;
selectedPaths?: Set<string>;
onToggleSelection?: (path: string) => void;
autoFocusName?: boolean;
moveTargets?: AttachmentMoveTarget[];
onMoveToTarget?: (path: string, targetId: string) => void;
onMoveToLoose?: (path: string) => void;
onSplit?: (attachment: ZipAttachment) => void;
onRemoveAttachment?: (attachment: ZipAttachment) => void;
onSubDocumentChange?: (next: SubDocument) => void;
onRemove?: () => void;
onAttachmentMove?: (source: AttachmentLocation, target: AttachmentLocation) => void;
onBlockDragStart?: (blockIndex: number, event: DragEvent) => void;
onBlockDragOver?: (blockIndex: number, event: DragEvent) => void;
onBlockDrop?: (blockIndex: number, event: DragEvent) => void;
onBlockDragEnd?: () => void;
onDownload?: () => void;
isDownloading?: boolean;
downloadDisabled?: boolean;
};
let {
subDocument,
blockIndex,
thumbnailWidth = 200,
isBlockDragSource = false,
isBlockDropTarget = false,
groupingMode = false,
selectedPaths = new Set<string>(),
onToggleSelection = () => {},
autoFocusName = false,
moveTargets = [],
onMoveToTarget = () => {},
onMoveToLoose = () => {},
onSplit = () => {},
onRemoveAttachment = () => {},
onSubDocumentChange = () => {},
onRemove = () => {},
onAttachmentMove = () => {},
onBlockDragStart = () => {},
onBlockDragOver = () => {},
onBlockDrop = () => {},
onBlockDragEnd = () => {},
onDownload,
isDownloading = false,
downloadDisabled = false
}: Props = $props();
let nameInput = $state<HTMLInputElement | null>(null);
let subDocumentName = $state('');
let dragIndex = $state<number | null>(null);
let dropIndex = $state<number | null>(null);
$effect(() => {
subDocumentName = subDocument.name;
});
$effect(() => {
if (autoFocusName && nameInput) {
nameInput.focus();
nameInput.select();
nameInput.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
}
});
const emitChange = (overrides: Partial<SubDocument> = {}) => {
onSubDocumentChange({
...subDocument,
name: subDocumentName,
attachments: subDocument.attachments.map((attachment) => ({ ...attachment })),
...overrides
});
};
const ATTACHMENT_MIME = 'application/x-juri-merger-attachment';
const BLOCK_MIME = 'application/x-juri-merger-block';
const parseAttachmentPayload = (event: DragEvent): AttachmentLocation | null => {
const raw = event.dataTransfer?.getData(ATTACHMENT_MIME);
if (!raw) {
return null;
}
try {
const parsed = JSON.parse(raw) as AttachmentLocation;
if (parsed.kind === 'loose' || parsed.kind === 'subDocument') {
return parsed;
}
return null;
} catch {
return null;
}
};
const handleCardDragStart = (index: number, event: DragEvent) => {
if (groupingMode) {
event.preventDefault();
return;
}
dragIndex = index;
dropIndex = index;
const source: AttachmentLocation = {
kind: 'subDocument',
id: subDocument.id,
index
};
if (event.dataTransfer) {
event.dataTransfer.effectAllowed = 'move';
event.dataTransfer.setData(ATTACHMENT_MIME, JSON.stringify(source));
// keep a text/plain fallback so the drag is not silently cancelled
event.dataTransfer.setData('text/plain', String(index));
}
};
const handleCardDragOver = (index: number, event: DragEvent) => {
if (event.dataTransfer?.types.includes(ATTACHMENT_MIME)) {
event.preventDefault();
dropIndex = index;
}
};
const handleCardDrop = (index: number, event: DragEvent) => {
const source = parseAttachmentPayload(event);
if (!source) {
return;
}
event.preventDefault();
const target: AttachmentLocation = {
kind: 'subDocument',
id: subDocument.id,
index
};
onAttachmentMove(source, target);
dragIndex = null;
dropIndex = null;
};
const handleCardDragEnd = () => {
dragIndex = null;
dropIndex = null;
};
const handleNameInput = (event: Event) => {
subDocumentName = (event.currentTarget as HTMLInputElement).value;
emitChange();
};
const handleHeaderDragStart = (event: DragEvent) => {
// do not start a block drag when the user grabbed the remove button or name input
if (event.defaultPrevented) {
return;
}
if (event.dataTransfer) {
event.dataTransfer.effectAllowed = 'move';
event.dataTransfer.setData(BLOCK_MIME, String(blockIndex));
event.dataTransfer.setData('text/plain', String(blockIndex));
}
onBlockDragStart(blockIndex, event);
};
const handleHeaderDragOver = (event: DragEvent) => {
if (event.dataTransfer?.types.includes(BLOCK_MIME)) {
event.preventDefault();
onBlockDragOver(blockIndex, event);
}
};
const handleHeaderDrop = (event: DragEvent) => {
const raw = event.dataTransfer?.getData(BLOCK_MIME);
if (raw === undefined || raw === '') {
return;
}
event.preventDefault();
onBlockDrop(blockIndex, event);
};
const attachments: ZipAttachment[] = $derived(subDocument.attachments);
</script>
<section
class={cn(
'flex flex-col gap-3 rounded-xl border bg-primary-50/40 px-3 py-3 transition',
isBlockDropTarget ? 'border-primary-400 ring-1 ring-primary-200' : 'border-primary-100',
isBlockDragSource ? 'opacity-60' : ''
)}
>
<header class="flex flex-wrap items-center gap-2 text-primary">
<button
class="w-fit cursor-grab rounded-md border border-primary-200 bg-white px-2 py-1 text-sm font-semibold leading-none text-primary-700 active:cursor-grabbing"
draggable="true"
type="button"
aria-label="Teildokument verschieben"
ondragstart={handleHeaderDragStart}
ondragover={handleHeaderDragOver}
ondrop={handleHeaderDrop}
ondragend={onBlockDragEnd}
>
::
</button>
<label class="block min-w-0 flex-1">
<span class="sr-only">Teildokumentname</span>
<input
bind:this={nameInput}
class="w-full max-w-md rounded-md border border-primary-200 bg-white px-3 py-1.5 text-[15px] font-semibold text-primary-900 outline-none transition focus:border-primary-400 focus:ring-2 focus:ring-primary-200"
type="text"
value={subDocumentName}
oninput={handleNameInput}
draggable="false"
/>
</label>
<span class="text-[13px] text-primary-700">
{attachments.length} Datei{attachments.length === 1 ? '' : 'en'}
</span>
{#if onDownload}
<Button
type="button"
variant="bordered"
disabled={downloadDisabled || isDownloading || attachments.length === 0}
onclick={onDownload}
draggable="false"
aria-label={`PDF für ${subDocumentName} herunterladen`}
>
{isDownloading ? 'PDF wird erstellt' : 'PDF herunterladen'}
</Button>
{/if}
<button
class="rounded-md border border-primary-200 bg-white px-2 py-1 text-[13px] font-medium text-primary-700 transition hover:border-red-300 hover:bg-red-50 hover:text-red-700"
type="button"
onclick={onRemove}
draggable="false"
ondragstart={(event) => event.preventDefault()}
>
Auflösen
</button>
</header>
{#if attachments.length === 0}
<ul
class="flex items-center justify-center rounded-md border border-dashed border-primary-200 bg-white/60 px-4 py-6 text-[13px] text-primary-600"
ondragover={(event) => {
if (event.dataTransfer?.types.includes(ATTACHMENT_MIME)) {
event.preventDefault();
}
}}
ondrop={(event) => {
const source = parseAttachmentPayload(event);
if (!source) {
return;
}
event.preventDefault();
const target: AttachmentLocation = {
kind: 'subDocument',
id: subDocument.id,
index: 0
};
onAttachmentMove(source, target);
dragIndex = null;
dropIndex = null;
}}
>
<li class="w-full text-center">
Dieses Teildokument ist leer. Ziehe Dateien hierher, um sie hinzuzufügen.
</li>
</ul>
{:else}
<ul class="flex gap-3 overflow-x-auto pb-1">
{#each attachments as attachment, index (attachment.path)}
<li
class={cn(
'flex flex-col shrink-0 items-stretch gap-2 rounded-xl border bg-accent p-2 text-primary',
dropIndex === index
? 'border-primary-400 ring-1 ring-primary-200'
: 'border-primary-100',
dragIndex === index ? 'opacity-60' : '',
groupingMode && selectedPaths.has(attachment.path)
? 'border-primary-500 ring-2 ring-primary-300'
: ''
)}
style={`width: ${thumbnailWidth}px;`}
draggable={groupingMode ? 'false' : 'true'}
ondragstart={(event) => handleCardDragStart(index, event)}
ondragover={(event) => handleCardDragOver(index, event)}
ondrop={(event) => handleCardDrop(index, event)}
ondragend={handleCardDragEnd}
>
{#if groupingMode}
<label class="flex cursor-pointer flex-col gap-2">
<div class="flex items-center gap-2 px-1 text-[13px] font-medium text-primary-900">
<input
class="h-4 w-4 accent-primary-700"
type="checkbox"
checked={selectedPaths.has(attachment.path)}
onchange={() => onToggleSelection(attachment.path)}
/>
<span class="truncate"
>{selectedPaths.has(attachment.path) ? 'Ausgewählt' : 'Auswählen'}</span
>
</div>
<div
class="truncate text-[13px] font-medium leading-tight text-primary-900"
title={attachment.name}
>
{attachment.name}
</div>
<AttachmentPreview {attachment} />
</label>
{:else}
<div class="flex min-w-0 flex-1 flex-row items-center gap-1 py-1">
<button
class="w-fit cursor-grab rounded-md border border-primary-200 bg-white px-2 py-1 text-sm font-semibold leading-none text-primary-700 active:cursor-grabbing"
draggable="false"
type="button"
aria-label="Datei verschieben"
>
::
</button>
<div
class="min-w-0 flex-1 truncate text-[13px] font-medium leading-tight text-primary-900"
title={attachment.name}
>
{attachment.name}
</div>
<AttachmentMenu
{attachment}
{moveTargets}
canSplit={attachment.kind === 'pdf'}
onMoveToTarget={(targetId) => onMoveToTarget(attachment.path, targetId)}
onMoveToLoose={() => onMoveToLoose(attachment.path)}
onSplit={() => onSplit(attachment)}
onRemove={() => onRemoveAttachment(attachment)}
/>
</div>
<AttachmentPreview {attachment} />
{/if}
</li>
{/each}
</ul>
{/if}
</section>
+20
View File
@@ -0,0 +1,20 @@
<script lang="ts">
import { cn } from '$lib/utils';
type Props = {
/** German message; nothing is rendered when the value is empty. */
message?: string | null;
class?: string;
};
let { message, class: className = '' }: Props = $props();
</script>
{#if message}
<div
class={cn('rounded-lg border border-red-200 bg-red-50 p-4 text-sm text-red-800', className)}
role="alert"
>
{message}
</div>
{/if}
+12 -68
View File
@@ -1,77 +1,21 @@
<script lang="ts">
import FileDropzone from '$lib/components/FileDropzone.svelte';
import { WORKSPACE_ACCEPT_TYPES } from '$lib/zip-selection';
type Props = {
onFilesSelected: (files: File[]) => void;
class?: string;
};
let { onFilesSelected, class: className = "" }: Props = $props();
let fileInput: HTMLInputElement | null = null;
let isDragging = $state(false);
const processFiles = (files: FileList | null | undefined) => {
if (!files) return;
const filtered = Array.from(files).filter((file) => {
const name = file.name.toLowerCase();
return (
name.endsWith('.zip') ||
file.type === 'application/zip' ||
file.type === 'application/x-zip-compressed'
);
});
if (filtered.length > 0) {
onFilesSelected(filtered);
}
};
const openFileDialog = () => fileInput?.click();
const handleFileSelection = (event: Event) => {
const input = event.currentTarget as HTMLInputElement;
processFiles(input.files);
input.value = ''; // Reset so same file can be uploaded twice
};
const handleDragOver = (event: DragEvent) => {
event.preventDefault();
isDragging = true;
};
const handleDragLeave = (event: DragEvent) => {
event.preventDefault();
isDragging = false;
};
const handleDrop = (event: DragEvent) => {
event.preventDefault();
isDragging = false;
processFiles(event.dataTransfer?.files);
};
let { onFilesSelected, class: className = '' }: Props = $props();
</script>
<input
bind:this={fileInput}
class="hidden"
type="file"
accept=".zip,application/zip,application/x-zip-compressed"
multiple
onchange={handleFileSelection}
<FileDropzone
{onFilesSelected}
accept={WORKSPACE_ACCEPT_TYPES}
multiple={true}
ariaLabel="beA-ZIP- oder PDF-Dateien hinzufügen"
label="Dateien hinzufügen"
sublabel="beA-ZIP oder PDF hier ablegen oder zum Auswählen klicken"
class="min-h-[calc(-180px+100vh)] border-0 bg-transparent {className}"
/>
<button
type="button"
class="flex min-h-[calc(-180px+100vh)] w-full grow cursor-pointer flex-col justify-center border-0 bg-transparent p-0 text-center text-inherit appearance-none focus:outline-none transition-colors rounded-2xl {isDragging ? 'bg-gray-100 border-2 border-dashed border-primary-500' : ''} {className}"
aria-label="ZIP-Dateien hinzufügen"
onclick={openFileDialog}
ondragover={handleDragOver}
ondragleave={handleDragLeave}
ondrop={handleDrop}
>
<div class="flex flex-row items-baseline justify-center gap-2">
<!-- Changed h1 to span for better semantic HTML inside a button -->
<span class="text-[20px] font-bold text-primary-900">Dateien hinzufügen</span>
<span class="text-[20px] font-extrabold text-primary-900">+</span>
</div>
</button>
+62
View File
@@ -0,0 +1,62 @@
<script lang="ts">
import Button from '$lib/components/ui/button.svelte';
import type { ButtonVariant } from '$lib/components/ui/button.svelte';
import { isAcceptedWorkspaceFile, WORKSPACE_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(isAcceptedWorkspaceFile);
// 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={WORKSPACE_ACCEPT_TYPES}
{multiple}
data-zip-picker={pickerId}
onchange={handleFileSelection}
/>
<Button
{variant}
{disabled}
aria-label={ariaLabel}
class={className}
onclick={() => fileInput?.click()}
>
{@render children()}
</Button>
+18
View File
@@ -0,0 +1,18 @@
<script>
</script>
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
d="M10.5618 6.94745L14.1001 3.31245C15.0133 2.37424 16.5206 2.37424 17.4338 3.31245L19.0544 4.97737C19.9334 5.88045 19.9334 7.31929 19.0544 8.22237L14.3368 13.069C13.4235 14.0072 11.9162 14.0072 11.003 13.069L10.5537 12.6075"
stroke="currentColor"
stroke-width="1.9"
stroke-linecap="round"
stroke-linejoin="round"
/>
<path
d="M12.8717 16.4126L7.66659 21.76L4.37911 18.3826C3.50007 17.4796 3.50008 16.0407 4.37911 15.1376L9.09675 10.291C10.01 9.35276 11.5173 9.35276 12.4305 10.291L12.8798 10.7525"
stroke="currentColor"
stroke-width="1.9"
stroke-linecap="round"
/>
</svg>
+30
View File
@@ -0,0 +1,30 @@
<script>
</script>
<svg width="23" height="24" viewBox="0 0 23 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M18.6875 19.7575C18.6875 21.0807 17.6148 22.1533 16.2917 22.1533H4.3125V5.99287C4.3125 5.39212 4.54755 4.81522 4.96738 4.38552L7.17097 2.13015C7.60374 1.68721 8.19683 1.4375 8.81609 1.4375H16.3875C17.6578 1.4375 18.6875 2.46725 18.6875 3.7375V19.7575Z"
stroke="currentColor"
stroke-width="1.86875"
/>
<path
d="M7.06787 12.5889H15.6929"
stroke="currentColor"
stroke-width="1.725"
stroke-linecap="round"
/>
<path
d="M7.06787 8.71875H15.6929"
stroke="currentColor"
stroke-width="1.725"
stroke-linecap="round"
/>
<path
d="M7.06787 16.464H12.8179"
stroke="currentColor"
stroke-width="1.725"
stroke-linecap="round"
/>
</svg>
+23
View File
@@ -0,0 +1,23 @@
<script>
</script>
<svg width="21" height="15" viewBox="0 0 21 15" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M0 13.2188C0 12.5457 0.545653 12 1.21875 12H16.8256C17.339 12 17.6536 12.5629 17.3845 13.0002L16.5 14.4375H1.21875C0.545653 14.4375 0 13.8918 0 13.2188Z"
fill="currentColor"
/>
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M0 7.21875C0 6.54565 0.545653 6 1.21875 6H18.2738C18.7979 6 19.1106 6.58416 18.8198 7.02027L17.875 8.4375H1.21875C0.545653 8.4375 0 7.89185 0 7.21875Z"
fill="currentColor"
/>
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M0 1.21875C0 0.545653 0.545653 0 1.21875 0H19.721C20.2555 0 20.5658 0.60479 20.2541 1.03898L19.25 2.4375H1.21875C0.545653 2.4375 0 1.89185 0 1.21875Z"
fill="currentColor"
/>
</svg>
@@ -0,0 +1,15 @@
<script>
</script>
<svg width="23" height="24" viewBox="0 0 23 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
d="M20.4129 7.29267L14.1203 1C14.1203 1 11.8437 8.76535 4.14746 10.9744L11.7731 18.6C13.9805 10.9022 21.7475 8.62562 21.7475 8.62562L15.4548 2.33295"
stroke="currentColor"
stroke-width="2"
stroke-linejoin="round"
/>
<path
d="M7.28204 14.2833L8.8 15.8656L1.51796 23.0833L1.36219 22.921C0.606308 22.1331 0.622023 20.8845 1.3975 20.1159L7.28204 14.2833Z"
fill="currentColor"
/>
</svg>
@@ -0,0 +1,24 @@
<script>
</script>
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M12.9914 12.6763V15.4458V20.4H7.98481H3.6001V16.046V4.27311C3.6001 3.32208 4.49438 2.6246 5.41678 2.85624L11.8863 4.48089C12.5359 4.64402 12.9914 5.228 12.9914 5.89777V12.6763Z"
stroke="currentColor"
stroke-width="1.8"
stroke-linecap="round"
/>
<path
d="M13.6491 8.40002L20.5221 10.2682C21.1583 10.4411 21.5998 11.0186 21.5998 11.6779V12.6424V15.424V18.9392C21.5998 19.746 20.9457 20.4 20.1389 20.4H17.4409H12.2085"
stroke="currentColor"
stroke-width="1.8"
stroke-linecap="round"
/>
<path d="M4.5 8.3H7.5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" />
<path d="M4.5 12.1H7.5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" />
<path d="M4.5 15.9H7.5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" />
<path d="M14 13.1H16" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" />
<path d="M14 16.9H16" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" />
</svg>
@@ -0,0 +1,9 @@
<svg width="16" height="22" viewBox="0 0 16 22" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
d="M9.27193 9.97498L13.4818 11.6926C14.3848 12.0611 14.9751 12.9394 14.9751 13.9148V15.4118V18.475C14.9751 19.8557 13.8558 20.975 12.4751 20.975H7.51156H0.975098V16.0858V13.8946C0.975098 12.9294 1.55327 12.0581 2.44261 11.6831L6.49358 9.97498"
stroke="currentColor"
stroke-width="1.95"
stroke-linecap="round"
/>
<circle cx="7.4751" cy="5.47498" r="4.5" stroke="currentColor" stroke-width="1.95" />
</svg>

After

Width:  |  Height:  |  Size: 518 B

@@ -0,0 +1,20 @@
<script>
</script>
<svg width="22" height="23" viewBox="0 0 22 23" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
d="M21 17.5834C21.5523 17.5834 22 18.0311 22 18.5834V19.5834H1C0.447716 19.5834 0 19.1357 0 18.5834C0 18.0311 0.447715 17.5834 1 17.5834H21Z"
fill="#122F62"
/>
<path
d="M21 10.5834C21.5523 10.5834 22 11.0311 22 11.5834V12.5834H1C0.447716 12.5834 0 12.1357 0 11.5834C0 11.0311 0.447715 10.5834 1 10.5834H21Z"
fill="#122F62"
/>
<path
d="M21 3.08337C21.5523 3.08337 22 3.53109 22 4.08337V5.08337H1C0.447716 5.08337 0 4.63566 0 4.08337C0 3.53109 0.447715 3.08337 1 3.08337H21Z"
fill="#122F62"
/>
<circle cx="6.5" cy="3.75" r="2.75" fill="white" stroke="#122F62" stroke-width="2" />
<circle cx="15.5835" cy="11.0834" r="2.75" fill="white" stroke="#122F62" stroke-width="2" />
<circle cx="6.5" cy="18.4167" r="2.75" fill="white" stroke="#122F62" stroke-width="2" />
</svg>
+10
View File
@@ -0,0 +1,10 @@
<svg width="24" height="23" viewBox="0 0 24 23" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M10.4142 5.48509C10.7066 5.19265 11.101 5.0248 11.5145 5.01676L17.3775 4.90268C18.2946 4.88483 19.039 5.64016 19.0077 6.55694L18.8111 12.3183C18.7973 12.7235 18.6301 13.1084 18.3434 13.3951L10.5219 21.2166C9.89704 21.8414 8.88398 21.8414 8.25914 21.2166L1.47091 14.4284L10.4142 5.48509Z"
stroke="currentColor"
stroke-width="2.08"
/>
<circle cx="13.7192" cy="9.90002" r="1.5" fill="currentColor" />
</svg>

After

Width:  |  Height:  |  Size: 563 B

+12
View File
@@ -0,0 +1,12 @@
<script>
</script>
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M18 7V19C18 20.1046 17.1046 21 16 21H6V7" stroke="currentColor" stroke-width="2" />
<path
d="M8 4C8 3.44772 8.44772 3 9 3H15C15.5523 3 16 3.44772 16 4V7H8V4Z"
stroke="currentColor"
stroke-width="1.8"
/>
<path d="M3 7.5H21" stroke="currentColor" stroke-width="2" stroke-linecap="round" />
</svg>
@@ -0,0 +1,11 @@
<script>
</script>
<svg width="19" height="19" viewBox="0 0 19 19" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
d="M5.49023 1H12.6572C13.5748 1 14.375 1.62449 14.5977 2.51465L17.0977 12.5146C17.4132 13.7769 16.4584 15 15.1572 15H1.20508L3.52441 2.63184C3.70178 1.68589 4.5278 1 5.49023 1Z"
stroke="currentColor"
stroke-width="2"
/>
<rect x="5" y="17" width="8" height="2" rx="1" fill="currentColor" />
</svg>
@@ -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
};
+93
View File
@@ -0,0 +1,93 @@
<script lang="ts" module>
import { ripple } from '$lib/actions/ripple';
import { cn, type WithElementRef } from '$lib/utils.js';
import type { HTMLAnchorAttributes, HTMLButtonAttributes } from 'svelte/elements';
import { type VariantProps, tv } from 'tailwind-variants';
import type { Component } from 'svelte';
export const buttonVariants = tv({
base: 'relative overflow-hidden font-bold w-fit min-w-28 hover:not-disabled:cursor-pointer disabled:bg-[#E6E7EA] disabled:text-[#0B1F4366] inline-flex items-center justify-center gap-4',
variants: {
variant: {
primary: 'bg-primary text-primary-foreground',
secondary: 'bg-linear-to-r from-secondary to-[#FF6F6D] text-primary-foreground',
bordered: 'text-primary border-[#E6E7EA] border-2 bg-white hover:bg-[#122F621A]',
tertiary: 'text-primary hover:bg-[#122F621A]',
success: 'text-white bg-[#0FAD80]'
},
size: {
standard: 'px-6 h-8 rounded-[16px]',
large: 'px-8 h-12 rounded-[24px]'
}
},
defaultVariants: {
variant: 'primary',
size: 'standard'
}
});
export type ButtonVariant = VariantProps<typeof buttonVariants>['variant'];
export type ButtonSize = VariantProps<typeof buttonVariants>['size'];
export type ButtonProps = WithElementRef<HTMLButtonAttributes> &
WithElementRef<HTMLAnchorAttributes> & {
variant?: ButtonVariant;
size?: ButtonSize;
icon?: Component;
};
</script>
<script lang="ts">
let {
class: className,
variant = 'primary',
size = 'standard',
icon,
ref = $bindable(null),
href = undefined,
type = 'button',
disabled,
children,
...restProps
}: ButtonProps = $props();
</script>
{#if href}
<a
use:ripple
bind:this={ref}
data-slot="button"
class={cn('', buttonVariants({ variant, size }), className)}
href={disabled ? undefined : href}
aria-disabled={disabled}
role={disabled ? 'link' : undefined}
tabindex={disabled ? -1 : undefined}
{...restProps}
>
{@render children?.()}
{#if icon}
{@const Icon = icon}
<Icon />
{/if}
</a>
{:else}
<button
use:ripple
bind:this={ref}
data-slot="button"
class={cn(
'inline-flex items-center justify-center gap-4',
buttonVariants({ variant, size }),
className
)}
{type}
{disabled}
{...restProps}
>
{@render children?.()}
{#if icon}
{@const Icon = icon}
<Icon />
{/if}
</button>
{/if}
@@ -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="card-action"
class={cn(
'cn-card-action col-start-2 row-span-2 row-start-1 self-start justify-self-end',
className
)}
{...restProps}
>
{@render children?.()}
</div>
@@ -0,0 +1,20 @@
<script lang="ts">
import type { HTMLAttributes } from 'svelte/elements';
import { cn, type WithElementRef } from '$lib/utils.js';
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
</script>
<div
bind:this={ref}
data-slot="card-content"
class={cn('px-4 group-data-[size=sm]/card:px-3', className)}
{...restProps}
>
{@render children?.()}
</div>
@@ -0,0 +1,20 @@
<script lang="ts">
import type { HTMLAttributes } from 'svelte/elements';
import { cn, type WithElementRef } from '$lib/utils.js';
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLParagraphElement>> = $props();
</script>
<p
bind:this={ref}
data-slot="card-description"
class={cn('text-muted-foreground text-sm', className)}
{...restProps}
>
{@render children?.()}
</p>
@@ -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="card-footer"
class={cn(
'bg-muted/50 rounded-b-xl border-t p-4 group-data-[size=sm]/card:p-3 flex items-center',
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="card-header"
class={cn(
'gap-1 rounded-t-xl px-4 group-data-[size=sm]/card:px-3 [.border-b]:pb-4 group-data-[size=sm]/card:[.border-b]:pb-3 group/card-header @container/card-header grid auto-rows-min items-start has-data-[slot=card-action]:grid-cols-[1fr_auto] has-data-[slot=card-description]:grid-rows-[auto_auto]',
className
)}
{...restProps}
>
{@render children?.()}
</div>
@@ -0,0 +1,20 @@
<script lang="ts">
import type { HTMLAttributes } from 'svelte/elements';
import { cn, type WithElementRef } from '$lib/utils.js';
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
</script>
<div
bind:this={ref}
data-slot="card-title"
class={cn('text-base leading-snug font-medium group-data-[size=sm]/card:text-sm', className)}
{...restProps}
>
{@render children?.()}
</div>
+25
View File
@@ -0,0 +1,25 @@
<script lang="ts">
import type { HTMLAttributes } from 'svelte/elements';
import { cn, type WithElementRef } from '$lib/utils.js';
let {
ref = $bindable(null),
class: className,
children,
size = 'default',
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> & { size?: 'default' | 'sm' } = $props();
</script>
<div
bind:this={ref}
data-slot="card"
data-size={size}
class={cn(
'ring-foreground/10 bg-card text-card-foreground gap-4 overflow-hidden rounded-xl py-4 text-sm ring-1 has-data-[slot=card-footer]:pb-0 has-[>img:first-child]:pt-0 data-[size=sm]:gap-3 data-[size=sm]:py-3 data-[size=sm]:has-data-[slot=card-footer]:pb-0 *:[img:first-child]:rounded-t-xl *:[img:last-child]:rounded-b-xl group/card flex flex-col',
className
)}
{...restProps}
>
{@render children?.()}
</div>
+25
View File
@@ -0,0 +1,25 @@
import Root from './card.svelte';
import Content from './card-content.svelte';
import Description from './card-description.svelte';
import Footer from './card-footer.svelte';
import Header from './card-header.svelte';
import Title from './card-title.svelte';
import Action from './card-action.svelte';
export {
Root,
Content,
Description,
Footer,
Header,
Title,
Action,
//
Root as Card,
Content as CardContent,
Description as CardDescription,
Footer as CardFooter,
Header as CardHeader,
Title as CardTitle,
Action as CardAction
};
@@ -0,0 +1,16 @@
<script lang="ts">
import { DropdownMenu as DropdownMenuPrimitive } from 'bits-ui';
let {
ref = $bindable(null),
value = $bindable([]),
...restProps
}: DropdownMenuPrimitive.CheckboxGroupProps = $props();
</script>
<DropdownMenuPrimitive.CheckboxGroup
bind:ref
bind:value
data-slot="dropdown-menu-checkbox-group"
{...restProps}
/>
@@ -0,0 +1,46 @@
<script lang="ts">
import { DropdownMenu as DropdownMenuPrimitive } from 'bits-ui';
import MinusIcon from '@lucide/svelte/icons/minus';
import CheckIcon from '@lucide/svelte/icons/check';
import { cn, type WithoutChildrenOrChild } from '$lib/utils.js';
import type { Snippet } from 'svelte';
let {
ref = $bindable(null),
checked = $bindable(false),
indeterminate = $bindable(false),
class: className,
children: childrenProp,
closeOnSelect = false,
...restProps
}: WithoutChildrenOrChild<DropdownMenuPrimitive.CheckboxItemProps> & {
children?: Snippet;
} = $props();
</script>
<DropdownMenuPrimitive.CheckboxItem
bind:ref
{closeOnSelect}
bind:checked
bind:indeterminate
data-slot="dropdown-menu-checkbox-item"
class={cn(
"focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm data-inset:pl-7 [&_svg:not([class*='size-'])]:size-4 relative flex cursor-default items-center outline-hidden select-none data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0",
className
)}
{...restProps}
>
{#snippet children({ checked, indeterminate })}
<span
class="absolute right-2 flex items-center justify-center pointer-events-none"
data-slot="dropdown-menu-checkbox-item-indicator"
>
{#if indeterminate}
<MinusIcon />
{:else if checked}
<CheckIcon />
{/if}
</span>
{@render childrenProp?.()}
{/snippet}
</DropdownMenuPrimitive.CheckboxItem>
@@ -0,0 +1,31 @@
<script lang="ts">
import { DropdownMenu as DropdownMenuPrimitive } from 'bits-ui';
import { cn, type WithoutChildrenOrChild } from '$lib/utils.js';
import DropdownMenuPortal from './dropdown-menu-portal.svelte';
import type { ComponentProps } from 'svelte';
let {
ref = $bindable(null),
sideOffset = 4,
align = 'start',
portalProps,
class: className,
...restProps
}: DropdownMenuPrimitive.ContentProps & {
portalProps?: WithoutChildrenOrChild<ComponentProps<typeof DropdownMenuPortal>>;
} = $props();
</script>
<DropdownMenuPortal {...portalProps}>
<DropdownMenuPrimitive.Content
bind:ref
data-slot="dropdown-menu-content"
{sideOffset}
{align}
class={cn(
'data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-open:zoom-in-95 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 ring-foreground/10 bg-popover text-popover-foreground min-w-32 rounded-lg p-1 shadow-md ring-1 duration-100 z-50 max-h-(--bits-dropdown-menu-content-available-height) w-(--bits-dropdown-menu-anchor-width) origin-(--bits-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto data-closed:overflow-hidden',
className
)}
{...restProps}
/>
</DropdownMenuPortal>
@@ -0,0 +1,22 @@
<script lang="ts">
import { DropdownMenu as DropdownMenuPrimitive } from 'bits-ui';
import { cn } from '$lib/utils.js';
import type { ComponentProps } from 'svelte';
let {
ref = $bindable(null),
class: className,
inset,
...restProps
}: ComponentProps<typeof DropdownMenuPrimitive.GroupHeading> & {
inset?: boolean;
} = $props();
</script>
<DropdownMenuPrimitive.GroupHeading
bind:ref
data-slot="dropdown-menu-group-heading"
data-inset={inset}
class={cn('text-muted-foreground px-1.5 py-1 text-xs font-medium data-inset:pl-7', className)}
{...restProps}
/>
@@ -0,0 +1,7 @@
<script lang="ts">
import { DropdownMenu as DropdownMenuPrimitive } from 'bits-ui';
let { ref = $bindable(null), ...restProps }: DropdownMenuPrimitive.GroupProps = $props();
</script>
<DropdownMenuPrimitive.Group bind:ref data-slot="dropdown-menu-group" {...restProps} />
@@ -0,0 +1,27 @@
<script lang="ts">
import { DropdownMenu as DropdownMenuPrimitive } from 'bits-ui';
import { cn } from '$lib/utils.js';
let {
ref = $bindable(null),
class: className,
inset,
variant = 'default',
...restProps
}: DropdownMenuPrimitive.ItemProps & {
inset?: boolean;
variant?: 'default' | 'destructive';
} = $props();
</script>
<DropdownMenuPrimitive.Item
bind:ref
data-slot="dropdown-menu-item"
data-inset={inset}
data-variant={variant}
class={cn(
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:text-destructive not-data-[variant=destructive]:focus:**:text-accent-foreground gap-1.5 rounded-md px-1.5 py-1 text-sm data-inset:pl-7 [&_svg:not([class*='size-'])]:size-4 group/dropdown-menu-item relative flex cursor-default items-center outline-hidden select-none data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0",
className
)}
{...restProps}
/>
@@ -0,0 +1,24 @@
<script lang="ts">
import { cn, type WithElementRef } from '$lib/utils.js';
import type { HTMLAttributes } from 'svelte/elements';
let {
ref = $bindable(null),
class: className,
inset,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> & {
inset?: boolean;
} = $props();
</script>
<div
bind:this={ref}
data-slot="dropdown-menu-label"
data-inset={inset}
class={cn('text-muted-foreground px-1.5 py-1 text-xs font-medium data-inset:pl-7', className)}
{...restProps}
>
{@render children?.()}
</div>
@@ -0,0 +1,7 @@
<script lang="ts">
import { DropdownMenu as DropdownMenuPrimitive } from 'bits-ui';
let { ...restProps }: DropdownMenuPrimitive.PortalProps = $props();
</script>
<DropdownMenuPrimitive.Portal {...restProps} />
@@ -0,0 +1,16 @@
<script lang="ts">
import { DropdownMenu as DropdownMenuPrimitive } from 'bits-ui';
let {
ref = $bindable(null),
value = $bindable(),
...restProps
}: DropdownMenuPrimitive.RadioGroupProps = $props();
</script>
<DropdownMenuPrimitive.RadioGroup
bind:ref
bind:value
data-slot="dropdown-menu-radio-group"
{...restProps}
/>
@@ -0,0 +1,36 @@
<script lang="ts">
import { DropdownMenu as DropdownMenuPrimitive } from 'bits-ui';
import CheckIcon from '@lucide/svelte/icons/check';
import { cn, type WithoutChild } from '$lib/utils.js';
let {
ref = $bindable(null),
class: className,
children: childrenProp,
closeOnSelect = false,
...restProps
}: WithoutChild<DropdownMenuPrimitive.RadioItemProps> = $props();
</script>
<DropdownMenuPrimitive.RadioItem
bind:ref
{closeOnSelect}
data-slot="dropdown-menu-radio-item"
class={cn(
"focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm data-inset:pl-7 [&_svg:not([class*='size-'])]:size-4 relative flex cursor-default items-center outline-hidden select-none data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0",
className
)}
{...restProps}
>
{#snippet children({ checked })}
<span
class="absolute right-2 flex items-center justify-center pointer-events-none"
data-slot="dropdown-menu-radio-item-indicator"
>
{#if checked}
<CheckIcon />
{/if}
</span>
{@render childrenProp?.({ checked })}
{/snippet}
</DropdownMenuPrimitive.RadioItem>
@@ -0,0 +1,17 @@
<script lang="ts">
import { DropdownMenu as DropdownMenuPrimitive } from 'bits-ui';
import { cn } from '$lib/utils.js';
let {
ref = $bindable(null),
class: className,
...restProps
}: DropdownMenuPrimitive.SeparatorProps = $props();
</script>
<DropdownMenuPrimitive.Separator
bind:ref
data-slot="dropdown-menu-separator"
class={cn('bg-border -mx-1 my-1 h-px', 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<HTMLSpanElement>> = $props();
</script>
<span
bind:this={ref}
data-slot="dropdown-menu-shortcut"
class={cn(
'text-muted-foreground group-focus/dropdown-menu-item:text-accent-foreground ml-auto text-xs tracking-widest',
className
)}
{...restProps}
>
{@render children?.()}
</span>
@@ -0,0 +1,31 @@
<script lang="ts">
import { DropdownMenu as DropdownMenuPrimitive } from 'bits-ui';
import { cn, type WithoutChildrenOrChild } from '$lib/utils.js';
import DropdownMenuPortal from './dropdown-menu-portal.svelte';
import type { ComponentProps } from 'svelte';
let {
ref = $bindable(null),
class: className,
align = 'start',
alignOffset = -3,
portalProps,
...restProps
}: DropdownMenuPrimitive.SubContentProps & {
portalProps?: WithoutChildrenOrChild<ComponentProps<typeof DropdownMenuPortal>>;
} = $props();
</script>
<DropdownMenuPortal {...portalProps}>
<DropdownMenuPrimitive.SubContent
bind:ref
data-slot="dropdown-menu-sub-content"
{align}
{alignOffset}
class={cn(
'data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-open:zoom-in-95 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 ring-foreground/10 bg-popover text-popover-foreground min-w-[96px] rounded-lg p-1 shadow-lg ring-1 duration-100 z-50 origin-(--bits-dropdown-menu-content-transform-origin) overflow-hidden',
className
)}
{...restProps}
/>
</DropdownMenuPortal>
@@ -0,0 +1,29 @@
<script lang="ts">
import { DropdownMenu as DropdownMenuPrimitive } from 'bits-ui';
import ChevronRightIcon from '@lucide/svelte/icons/chevron-right';
import { cn } from '$lib/utils.js';
let {
ref = $bindable(null),
class: className,
inset,
children,
...restProps
}: DropdownMenuPrimitive.SubTriggerProps & {
inset?: boolean;
} = $props();
</script>
<DropdownMenuPrimitive.SubTrigger
bind:ref
data-slot="dropdown-menu-sub-trigger"
data-inset={inset}
class={cn(
"focus:bg-accent focus:text-accent-foreground data-open:bg-accent data-open:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground gap-1.5 rounded-md px-1.5 py-1 text-sm data-inset:pl-7 [&_svg:not([class*='size-'])]:size-4 flex cursor-default items-center outline-hidden select-none [&_svg]:pointer-events-none [&_svg]:shrink-0",
className
)}
{...restProps}
>
{@render children?.()}
<ChevronRightIcon class="cn-rtl-flip ml-auto" />
</DropdownMenuPrimitive.SubTrigger>
@@ -0,0 +1,7 @@
<script lang="ts">
import { DropdownMenu as DropdownMenuPrimitive } from 'bits-ui';
let { open = $bindable(false), ...restProps }: DropdownMenuPrimitive.SubProps = $props();
</script>
<DropdownMenuPrimitive.Sub bind:open {...restProps} />
@@ -0,0 +1,7 @@
<script lang="ts">
import { DropdownMenu as DropdownMenuPrimitive } from 'bits-ui';
let { ref = $bindable(null), ...restProps }: DropdownMenuPrimitive.TriggerProps = $props();
</script>
<DropdownMenuPrimitive.Trigger bind:ref data-slot="dropdown-menu-trigger" {...restProps} />
@@ -0,0 +1,7 @@
<script lang="ts">
import { DropdownMenu as DropdownMenuPrimitive } from 'bits-ui';
let { open = $bindable(false), ...restProps }: DropdownMenuPrimitive.RootProps = $props();
</script>
<DropdownMenuPrimitive.Root bind:open {...restProps} />
Loaded 100 of 185 files, more files were not shown because too many files have changed in this diff. Show more