31 Commits
Author SHA1 Message Date
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 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
121 changed files with 12515 additions and 1009 deletions
+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 .wrangler
/.svelte-kit /.svelte-kit
/build /build
/playwright-report
/test-results
# OS # OS
.DS_Store .DS_Store
+2615
View File
File diff suppressed because it is too large Load Diff
+55 -53
View File
@@ -8,8 +8,10 @@
</component> </component>
<component name="ChangeListManager"> <component name="ChangeListManager">
<list default="true" id="89f9fc3a-63ad-41cb-8d25-f07df352eb98" name="Changes" comment=""> <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/+layout.svelte" beforeDir="false" afterPath="$PROJECT_DIR$/src/routes/+layout.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> </list>
<option name="SHOW_DIALOG" value="false" /> <option name="SHOW_DIALOG" value="false" />
<option name="HIGHLIGHT_CONFLICTS" value="true" /> <option name="HIGHLIGHT_CONFLICTS" value="true" />
@@ -38,23 +40,23 @@
<component name="MetaFilesCheckinStateConfiguration" checkMetaFiles="true" /> <component name="MetaFilesCheckinStateConfiguration" checkMetaFiles="true" />
<component name="NextEditCompletionFeaturesState"> <component name="NextEditCompletionFeaturesState">
<decayedCancelled> <decayedCancelled>
<entry key="MS100" value="1.0" /> <entry key="MS100" value="1.0000000193915048" />
<entry key="MS500" value="1.000030264792808" /> <entry key="MS500" value="1.028676250370633" />
<entry key="S2" value="1.0741710498535333" /> <entry key="S2" value="1.4194868298714414" />
<entry key="S5" value="1.353259435419701" /> <entry key="S5" value="1.8458418253662012" />
<entry key="S10" value="1.5943563202488056" /> <entry key="S10" value="2.2177989694912164" />
<entry key="S30" value="1.8407798506380604" /> <entry key="S30" value="2.6766437274014185" />
<entry key="S60" value="1.916982485499306" /> <entry key="S60" value="2.959677574191515" />
<entry key="M2" value="1.9733993920394513" /> <entry key="M2" value="3.4366229865704883" />
<entry key="M5" value="2.54046111637059" /> <entry key="M5" value="4.462442318201847" />
<entry key="M10" value="3.820331613615542" /> <entry key="M10" value="5.916664349028232" />
<entry key="M15" value="4.711774035901717" /> <entry key="M15" value="6.940237970746977" />
<entry key="M30" value="6.035032387334216" /> <entry key="M30" value="8.521362268212844" />
<entry key="H1" value="6.920684152516748" /> <entry key="H1" value="9.62023487151045" />
<entry key="H2" value="7.4336791591637175" /> <entry key="H2" value="10.270683493430917" />
<entry key="H4" value="7.709839735759553" /> <entry key="H4" value="10.62488416631901" />
<entry key="D1" value="7.950638029700117" /> <entry key="D1" value="10.935974404270617" />
<entry key="W1" value="7.992923355805816" /> <entry key="W1" value="10.990815874256214" />
</decayedCancelled> </decayedCancelled>
<decayedSelected> <decayedSelected>
<entry key="MS100" value="0.0" /> <entry key="MS100" value="0.0" />
@@ -76,23 +78,23 @@
<entry key="W1" value="0.0" /> <entry key="W1" value="0.0" />
</decayedSelected> </decayedSelected>
<decayedShown> <decayedShown>
<entry key="MS100" value="0.004910208494225893" /> <entry key="MS100" value="0.15496346249522763" />
<entry key="MS500" value="0.3453286512092814" /> <entry key="MS500" value="0.6936352317970043" />
<entry key="S2" value="0.8228647326727687" /> <entry key="S2" value="1.1789457258652456" />
<entry key="S5" value="1.215480411189489" /> <entry key="S5" value="1.6521017306909382" />
<entry key="S10" value="1.5106753869300364" /> <entry key="S10" value="2.0660150480217916" />
<entry key="S30" value="1.8078926104734718" /> <entry key="S30" value="2.60082115522875" />
<entry key="S60" value="1.8997675156293707" /> <entry key="S60" value="2.9160446418159838" />
<entry key="M2" value="1.9645730736920846" /> <entry key="M2" value="3.4117712141940064" />
<entry key="M5" value="2.5367044075651064" /> <entry key="M5" value="4.4508993349183115" />
<entry key="M10" value="3.818241287421449" /> <entry key="M10" value="5.910338911687691" />
<entry key="M15" value="4.710284232071638" /> <entry key="M15" value="6.935829221677054" />
<entry key="M30" value="6.034216617353951" /> <entry key="M30" value="8.519029212377378" />
<entry key="H1" value="6.9202526638434145" /> <entry key="H1" value="9.619027375474417" />
<entry key="H2" value="7.433456593321955" /> <entry key="H2" value="10.270068085857725" />
<entry key="H4" value="7.709726618573428" /> <entry key="H4" value="10.624573345818598" />
<entry key="D1" value="7.9506189104308005" /> <entry key="D1" value="10.935922149562563" />
<entry key="W1" value="7.992920617800578" /> <entry key="W1" value="10.990808397996608" />
</decayedShown> </decayedShown>
</component> </component>
<component name="ProjectColorInfo">{ <component name="ProjectColorInfo">{
@@ -107,25 +109,25 @@
<option name="hideEmptyMiddlePackages" value="true" /> <option name="hideEmptyMiddlePackages" value="true" />
<option name="showLibraryContents" value="true" /> <option name="showLibraryContents" value="true" />
</component> </component>
<component name="PropertiesComponent"><![CDATA[{ <component name="PropertiesComponent">{
"keyToString": { &quot;keyToString&quot;: {
"ModuleVcsDetector.initialDetectionPerformed": "true", &quot;ModuleVcsDetector.initialDetectionPerformed&quot;: &quot;true&quot;,
"RunOnceActivity.ShowReadmeOnStart": "true", &quot;RunOnceActivity.ShowReadmeOnStart&quot;: &quot;true&quot;,
"RunOnceActivity.TerminalTabsStorage.copyFrom.TerminalArrangementManager.252": "true", &quot;RunOnceActivity.TerminalTabsStorage.copyFrom.TerminalArrangementManager.252&quot;: &quot;true&quot;,
"RunOnceActivity.cidr.known.project.marker": "true", &quot;RunOnceActivity.cidr.known.project.marker&quot;: &quot;true&quot;,
"RunOnceActivity.readMode.enableVisualFormatting": "true", &quot;RunOnceActivity.readMode.enableVisualFormatting&quot;: &quot;true&quot;,
"RunOnceActivity.typescript.service.memoryLimit.init": "true", &quot;RunOnceActivity.typescript.service.memoryLimit.init&quot;: &quot;true&quot;,
"cidr.known.project.marker": "true", &quot;cidr.known.project.marker&quot;: &quot;true&quot;,
"codeWithMe.voiceChat.enabledByDefault": "false", &quot;codeWithMe.voiceChat.enabledByDefault&quot;: &quot;false&quot;,
"git-widget-placeholder": "develop", &quot;git-widget-placeholder&quot;: &quot;develop&quot;,
"last_opened_file_path": "/home/rehlert/repos/juri-merger", &quot;last_opened_file_path&quot;: &quot;/home/rehlert/repos/juri-merger&quot;,
"node.js.selected.package.tslint": "(autodetect)", &quot;node.js.selected.package.tslint&quot;: &quot;(autodetect)&quot;,
"nodejs_package_manager_path": "pnpm", &quot;nodejs_package_manager_path&quot;: &quot;pnpm&quot;,
"settings.editor.selected.configurable": "preferences.pluginManager", &quot;settings.editor.selected.configurable&quot;: &quot;preferences.pluginManager&quot;,
"ts.external.directory.path": "/home/rehlert/repos/juri-merger/node_modules/typescript/lib", &quot;ts.external.directory.path&quot;: &quot;/home/rehlert/repos/juri-merger/node_modules/typescript/lib&quot;,
"vue.rearranger.settings.migration": "true" &quot;vue.rearranger.settings.migration&quot;: &quot;true&quot;
} }
}]]></component> }</component>
<component name="TaskManager"> <component name="TaskManager">
<task active="true" id="Default" summary="Default task"> <task active="true" id="Default" summary="Default task">
<changelist id="89f9fc3a-63ad-41cb-8d25-f07df352eb98" name="Changes" comment="" /> <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"]
+236 -55
View File
@@ -2,99 +2,272 @@
## Project overview ## Project overview
This repository contains **juri-merger**, a German-language SvelteKit application whose current UI is branded as **beA-Edit**. The main route lets users select or drag ZIP files in the browser. It currently displays the selected filenames; no server-side upload or archive-processing flow is present in this version. This repository contains **juri-merger**, a German-language SvelteKit application branded as
**beA-Edit**. Users add one or more ZIP archives in the browser, inspect and reorder supported
attachments, optionally group attachments into named sub-documents, 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 legal pages for the imprint and privacy policy. The application also contains German imprint and privacy-policy routes.
## Technology stack ## Technology stack
- SvelteKit 2 - SvelteKit 2 and Svelte 5, with runes mode forced for project source files
- Svelte 5 with runes mode forced for project source files - TypeScript in strict mode
- TypeScript with strict mode
- Vite 8 - Vite 8
- Tailwind CSS 4 through `@tailwindcss/vite` - Tailwind CSS 4 through `@tailwindcss/vite`
- Skeleton 4 with a custom `nominandum` theme - shadcn-svelte 1 with the `nova` style and CSS-variable theme tokens
- pnpm and `pnpm-lock.yaml` - bits-ui, tailwind-variants, clsx, and tailwind-merge
- `@sveltejs/adapter-auto` - Lucide Svelte icons plus project-specific SVG icon components
- Prettier with the Svelte and Tailwind plugins - 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`
- pnpm with a committed lockfile
- Prettier with Svelte and Tailwind plugins
Use **pnpm**, not npm or yarn, when installing packages or running project scripts. Use **pnpm**, not npm or yarn. The production container uses Node 24 and pnpm 11.15.1.
## Commands ## Commands
```sh ```sh
pnpm install # Install dependencies pnpm install # Install dependencies
pnpm dev # Start the development server pnpm dev # Start the development server
pnpm build # Create a production build pnpm build # Create the adapter-node production build
pnpm preview # Preview the production build pnpm preview # Preview the production build
pnpm check # Run Svelte and TypeScript diagnostics pnpm check # Run Svelte and TypeScript diagnostics
pnpm check:watch # Run diagnostics in watch mode pnpm check:watch # Run diagnostics in watch mode
pnpm lint # Check formatting with Prettier pnpm test:e2e # Run the Playwright toolbox tests in Chromium
pnpm lint # Check repository formatting with Prettier
pnpm format # Format the repository with Prettier pnpm format # Format the repository with Prettier
``` ```
There is currently no automated unit or end-to-end test suite. For code changes, run at least: For code changes, run at least:
```sh ```sh
pnpm check pnpm check
pnpm test:e2e
pnpm lint pnpm lint
pnpm build pnpm build
``` ```
If pre-existing warnings remain outside the files being changed, report them rather than making unrelated edits. 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 ## Repository structure
```text ```text
src/ src/
├── app.html # German document shell and Skeleton theme selection ├── app.html # German HTML shell
├── app.d.ts # SvelteKit application type declarations ├── app.d.ts # SvelteKit application declarations
├── lib/ ├── lib/
│ ├── assets/ # Bundled favicon and Nunito font files │ ├── actions/ripple.ts # Button ripple Svelte action
│ ├── components/ # Reusable Svelte components │ ├── assets/ # Favicon and local Nunito fonts
│ ├── Button.svelte │ ├── components/
│ │ ├── Card.svelte │ │ ├── AttachmentPreview.svelte # Browser-rendered PDF/image thumbnails
│ │ ── ZipDropzone.svelte │ │ ── BatchFileList.svelte # Shared batch rows: status chips, downloads, retry
│ ├── index.ts # Public `$lib` exports │ ├── BeaArchiveProcessing.svelte
└── nom-theme.css # Custom Skeleton theme tokens │ ├── BeaWorkspaceControls.svelte # beA workspace toolbar, settings popover, dialogs
│ │ ├── ProcessedZipArchiveEditor.svelte
│ │ ├── SubDocumentEditor.svelte
│ │ ├── 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
│ │ ├── 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
│ ├── pdf-compression.ts # Shared compression presets and rasterize pipeline
│ ├── pdf-metadata.ts # PDF metadata inspection and cleaning (pdf-lib only)
│ ├── pdf-overlay.ts # Shared PDF overlay positions and color parsing
│ ├── pdf-processing.ts # Reusable PDF merge helper
│ ├── pdf-thumbnails.ts # Managed pdfjs thumbnail loading and cleanup
│ ├── utils.ts # cn() and shared component utility types
│ └── zip-processing.ts # Archive model, transforms, and PDF export
└── routes/ └── routes/
├── +layout.svelte # Shared logo header and branded footer ├── +layout.svelte # Shared logo header and branded footer
├── +page.svelte # Main ZIP-selection page ├── +page.svelte # Toolbox hub and global beA ZIP drop target
├── layout.css # Tailwind/Skeleton imports and global font setup ├── layout.css # Tailwind, shadcn, theme, fonts, global CSS
├── tools/
│ ├── +layout.svelte # Shared back-navigation shell
│ ├── bea/+page.svelte # Existing ZIP archive workflow
│ └── {merge,separate,stamp,watermark,encrypt,decrypt,compress,convert,rotate,metadata}/
├── datenschutz/+page.svelte # Privacy policy ├── datenschutz/+page.svelte # Privacy policy
└── impressum/+page.svelte # Imprint └── impressum/+page.svelte # Imprint
static/ # Files served unchanged from the site root static/ # Public logo, footer art, and robots.txt
components.json # shadcn-svelte registry configuration
Dockerfile # Node adapter production image
plan/ # Design and implementation notes
``` ```
Generated directories such as `.svelte-kit/`, `build/`, and `node_modules/` must not be edited or committed. Generated directories such as `.svelte-kit/`, `build/`, and `node_modules/` must not be edited or
committed.
## Application flow and state
- `/` is the toolbox hub. A ZIP dropped anywhere on that route is queued in memory and forwarded to
`/tools/bea`; the file is not uploaded or persisted.
- `/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 ZIP bearbeiten”** (replace) opens a ZIP picker and, after the user confirms the
replacement dialog, calls `replaceWithZipFiles(files)` which resets the workspace and enqueues the
new files against the incremented generation. Files picked for replacement stay in temporary
component state until confirmation; cancelling the picker or the dialog must never mutate the
current workspace.
- **“Weitere ZIP hinzufügen”** (append) lives in the settings popover, uses the plain append path
(`handleFilesSelected`), and never clears current archives. Keep the two labels distinct:
replace is “Neue ZIP bearbeiten”, append is “Weitere ZIP hinzufügen”.
- `BeaWorkspaceControls.svelte` owns only ephemeral UI state (popover/dialog open state and pending
replacement files). The route remains the source of truth for files, jobs, archives, and the
persisted thumbnail width.
- `resetWorkspace()` is the single teardown helper: it increments `archiveGeneration` and clears
selected files, processed archives, and jobs. Never clear those arrays without going through it.
- Accepted ZIP types and the file filter for the dropzone and both compact pickers are centralized in
`src/lib/zip-selection.ts`; `ZipFilePicker.svelte` resets its input after every selection so the
same file can be chosen again.
- ZIP files are processed concurrently. `archiveGeneration` prevents results from an old batch from
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.
- Selection mode creates sub-documents from loose attachments. Exiting with **Fertig** or Escape
clears the pending 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.
- 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.
- 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.
- `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, encrypt, …) are being retrofitted to
accept multiple files. Follow the established compress retrofit when adding the pattern to another
tool; see `plan/batch-processing-plan.md` for the full design.
- `src/lib/batch.ts` owns the engine: `createBatchItem(file)`, `runBatch(items, process, update,
options?)` and `formatBytes`. `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 — and consults the optional `shouldProcess` predicate so rows removed mid-run are
skipped.
- `src/lib/components/BatchFileList.svelte` renders the rows: name, size, status chip
(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.
- Per-tool ZIP base names: compress uses `komprimiert`, watermark `wasserzeichen`, 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 ## Svelte conventions
- Use Svelte 5 runes and current event syntax. Prefer `$props()`, `$state`, `$derived`, snippets, `{@render ...}`, and handlers such as `onclick` over legacy APIs. - 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">`. - Keep component scripts typed with `<script lang="ts">`.
- Define a local `Props` type for non-trivial component props. - Define a local `Props` type for non-trivial component props.
- Use callback props for child-to-parent communication, as `ZipDropzone.svelte` does with `onFilesSelected`. - Use callback props for child-to-parent updates; do not mutate prop objects in place.
- Use `$lib/...` for imports from `src/lib` rather than long relative paths. - Use `$lib/...` aliases for application imports instead of long relative paths.
- Name reusable component files in PascalCase and route files according to SvelteKit conventions. - Name reusable components in PascalCase and route files according to SvelteKit conventions.
- Keep browser-only APIs such as `File`, `FileList`, and drag-and-drop handling in components that execute in the browser. Do not introduce server-side upload behavior unless requested. - Preserve semantic HTML, accessible names, visible focus states, and keyboard alternatives. Do not
- Preserve semantic HTML. Interactive drop zones must remain keyboard-operable buttons with an accessible name. 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 application files, so do not add legacy Svelte component patterns. 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 ## Styling conventions
Use **Tailwind utility classes in Svelte markup** for component and route styling. Orient new work around the existing classes in `+layout.svelte`, `+page.svelte`, `Card.svelte`, and `ZipDropzone.svelte`. Use Tailwind utilities in Svelte markup and semantic theme tokens from `src/routes/layout.css`.
- Prefer existing theme utilities such as `bg-primary-50`, `text-primary-900`, `border-primary-500`, and `rounded-container`. - Prefer `bg-background`, `text-foreground`, `bg-primary`, `text-muted-foreground`, `border-border`,
- Use the custom `primary`, `secondary`, `tertiary`, and `surface` scales defined in `src/lib/nom-theme.css` instead of adding arbitrary brand colors. and other configured semantic utilities over raw brand colors.
- Keep the established visual language: cool blue-gray page background, white cards, dark blue headings, subtle shadows, rounded containers, and the Nunito typeface. - Preserve the established Nominandum visual language: pale blue-gray background, white cards, dark
- Use responsive Tailwind variants and ensure layouts work on narrow screens. blue primary text, red/coral secondary actions, rounded containers, and Nunito typography.
- Prefer utilities over new component-level `<style>` blocks or inline styles. Add global CSS only when defining shared theme primitives, imports, font faces, or behavior that cannot reasonably be expressed with utilities. - Use `flex`/`grid` with `gap-*`, responsive variants, and `size-*` when width and height are equal.
- Tailwind 4 is configured through Vite and `src/routes/layout.css`; there is no `tailwind.config` file. - Use `cn()` rather than constructing partial Tailwind class names. Keep complete class names visible
- Keep complete utility class names visible to Tailwind. For variants, map each state to complete class strings rather than constructing fragments such as `text-${color}`. to Tailwind's scanner.
- Preserve clear focus states, adequate contrast, and visible drag-and-drop feedback. - 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.
`src/app.html` applies `data-theme="nominandum"`. Theme-level changes belong in `src/lib/nom-theme.css`; global Tailwind and Skeleton imports belong in `src/routes/layout.css`. 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 ## Formatting
@@ -106,21 +279,29 @@ Follow the checked-in Prettier configuration:
- 100-character print width - 100-character print width
- Svelte-aware and Tailwind-aware formatting - 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. Use `pnpm exec prettier --write <changed-files>` for targeted formatting. Avoid formatting unrelated
files solely to clean up existing differences.
## Product and content constraints ## Product and content constraints
- User-facing copy is German unless a task explicitly requests another language. - User-facing copy is German unless a task explicitly requests another language.
- Preserve legal text in `datenschutz/+page.svelte` and `impressum/+page.svelte` unless the request explicitly asks for content changes. - Preserve legal text in `datenschutz/+page.svelte` and `impressum/+page.svelte` unless explicitly
- Keep the shared Nominandum logo, footer artwork, and theme consistent across routes. asked to change it.
- ZIP selection is client-side and accepts `.zip`, `application/zip`, and `application/x-zip-compressed` files. - Keep the Nominandum logo, footer artwork, and theme consistent across routes.
- Resetting the file input after selection is intentional so the same archive can be selected twice. - ZIP selection accepts multiple files matching `.zip`, `application/zip`, or
- Do not add network requests, file uploads, analytics, or persistence without an explicit requirement. `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 ## Change discipline
- Keep changes scoped to the request and preserve unrelated work, including untracked files. - Keep changes scoped and preserve unrelated work, including untracked files.
- Inspect existing components and theme tokens before introducing new abstractions. - Inspect existing components, archive helpers, and theme tokens before adding abstractions.
- Do not hand-edit generated output or lockfile contents. Update the lockfile only through pnpm when dependency changes are required. - Keep browser processing behavior and archive-order invariants covered when editing shared helpers.
- Avoid adding dependencies when the task can be completed with Svelte, browser APIs, Tailwind, or existing Skeleton packages. - Do not hand-edit generated output or lockfile contents. Update the lockfile only through pnpm when
- After implementation, review the changed files, run the relevant checks, and mention any remaining warnings or limitations. 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"
}
+17 -3
View File
@@ -10,27 +10,41 @@
"prepare": "svelte-kit sync || echo ''", "prepare": "svelte-kit sync || echo ''",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch", "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
"test:e2e": "playwright test",
"lint": "prettier --check .", "lint": "prettier --check .",
"format": "prettier --write ." "format": "prettier --write ."
}, },
"devDependencies": { "devDependencies": {
"@sveltejs/adapter-auto": "^7.0.1", "@fontsource-variable/geist": "^5.2.9",
"@lucide/svelte": "^1.18.0",
"@playwright/test": "^1.62.1",
"@sveltejs/adapter-node": "^5.5.7",
"@sveltejs/kit": "^2.63.0", "@sveltejs/kit": "^2.63.0",
"@sveltejs/vite-plugin-svelte": "^7.1.2", "@sveltejs/vite-plugin-svelte": "^7.1.2",
"@tailwindcss/forms": "^0.5.11", "@tailwindcss/forms": "^0.5.11",
"@tailwindcss/typography": "^0.5.19", "@tailwindcss/typography": "^0.5.19",
"@tailwindcss/vite": "^4.3.0", "@tailwindcss/vite": "^4.3.0",
"@types/node": "^26.3.0",
"bits-ui": "2.18.1",
"class-variance-authority": "0.7.1",
"clsx": "2.1.1",
"prettier": "^3.8.3", "prettier": "^3.8.3",
"prettier-plugin-svelte": "^4.1.0", "prettier-plugin-svelte": "^4.1.0",
"prettier-plugin-tailwindcss": "^0.8.0", "prettier-plugin-tailwindcss": "^0.8.0",
"shadcn-svelte": "1.3.0",
"svelte": "^5.56.1", "svelte": "^5.56.1",
"svelte-check": "^4.6.0", "svelte-check": "^4.6.0",
"tailwind-merge": "3.6.0",
"tailwind-variants": "^3.3.1",
"tailwindcss": "^4.3.0", "tailwindcss": "^4.3.0",
"tw-animate-css": "^1.4.0",
"typescript": "^6.0.3", "typescript": "^6.0.3",
"vite": "^8.0.16" "vite": "^8.0.16"
}, },
"dependencies": { "dependencies": {
"@skeletonlabs/skeleton": "^4.15.2", "@cantoo/pdf-lib": "^2.9.1",
"@skeletonlabs/skeleton-svelte": "^4.15.2" "fflate": "^0.8.3",
"pdf-lib": "^1.17.1",
"pdfjs-dist": "^6.0.227"
} }
} }
+30
View File
@@ -0,0 +1,30 @@
/// <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({
testDir: './tests/e2e',
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
}
});
+929 -562
View File
File diff suppressed because it is too large Load Diff
+1
View File
@@ -4,6 +4,7 @@
<meta charset="utf-8" /> <meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="text-scale" content="scale" /> <meta name="text-scale" content="scale" />
<link rel="icon" href="%sveltekit.assets%/favicon.svg" type="image/svg+xml" />
%sveltekit.head% %sveltekit.head%
</head> </head>
<body data-sveltekit-preload-data="hover"> <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);
}
};
}
+71
View File
@@ -0,0 +1,71 @@
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. */
errorMessage?: 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.
*/
export const runBatch = async (
items: BatchItem[],
process: (file: File) => 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);
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
});
}
}
};
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`;
};
+129
View File
@@ -0,0 +1,129 @@
<script lang="ts">
import { onMount } from "svelte";
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 = "PDF-Vorschau konnte nicht geladen werden.";
}
};
void loadPreview();
})();
return () => {
cancelled = true;
revokeObjectUrl();
};
});
</script>
<div class={className}>
{#if previewError}
<div class="flex h-full min-h-0 items-center justify-center rounded-md border border-dashed border-primary-200 bg-primary-50 px-3 py-4 text-center text-[12px] text-primary-800">
{previewError}
</div>
{:else if imageUrl}
<img
alt={attachment.name}
class="block rounded-md border border-primary-100 bg-white object-contain"
src={imageUrl}
/>
{:else}
<div class="flex items-center justify-center rounded-md border border-dashed border-primary-200 bg-primary-50 px-3 py-4 text-center text-[12px] text-primary-800">
Vorschau wird erstellt
</div>
{/if}
</div>
+176
View File
@@ -0,0 +1,176 @@
<script lang="ts">
import { Download, RotateCcw, Trash2 } from '@lucide/svelte';
import { zipSync } from 'fflate';
import Button from '$lib/components/ui/button.svelte';
import { downloadBlob } from '$lib/download';
import { formatBytes, type BatchItem } from '$lib/batch';
import { RASTERIZATION_WARNING_BYTES } from '$lib/rasterization-limits';
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(
items.length > 20 || items.some((item) => item.file.size > RASTERIZATION_WARNING_BYTES)
);
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;
};
const downloadZip = () => {
const entries: Record<string, Uint8Array> = {};
const usedNames = new Set<string>();
for (const item of doneItems) {
if (!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');
};
const statusChipClass = (status: BatchItem['status']) => {
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';
}
};
const statusLabel = (status: BatchItem['status']) => {
switch (status) {
case 'processing':
return 'Verarbeite…';
case 'done':
return 'Fertig ✓';
case 'error':
return 'Fehler';
default:
return 'Wartet';
}
};
</script>
<div class="rounded-2xl border border-border bg-card p-4 space-y-3">
<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 {statusChipClass(
item.status
)}"
>
{statusLabel(item.status)}
</span>
<div class="flex shrink-0 items-center gap-1">
{#if item.status === 'done' && item.resultBytes && item.resultName}
<button
type="button"
class="rounded-lg p-2 text-primary transition hover:bg-accent focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-400"
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="rounded-lg p-2 text-primary transition hover:bg-accent focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-400"
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="rounded-lg p-2 text-primary transition hover:bg-accent focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-400 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 zipText = `${selectedZipCount} ${selectedZipCount === 1 ? 'ZIP' : 'ZIPs'}`;
const archiveText = `${processedArchiveCount} ${
processedArchiveCount === 1 ? 'Archiv' : 'Archive'
} geöffnet`;
const pendingText =
pendingZipCount > 0
? ` · ${pendingZipCount} ${pendingZipCount === 1 ? 'wird' : 'werden'} geöffnet`
: '';
return `${zipText} · ${archiveText}${pendingText}`;
});
const commit = async (action: () => void | Promise<void>) => {
isCommitting = true;
try {
await action();
} finally {
isCommitting = false;
}
};
const confirmReplacement = () => {
const files = pendingReplacementFiles;
pendingReplacementFiles = null;
if (!files || files.length === 0) return;
void commit(() => onRequestReplacement(files));
};
const confirmClear = () => {
clearDialogOpen = false;
void commit(onClear);
};
const exportAll = async () => {
isExporting = true;
try {
await onExportAll();
} finally {
isExporting = false;
}
};
</script>
<div
class="flex w-full flex-col gap-3 border-b border-primary-100 pb-4 sm:flex-row sm:items-center sm:justify-between"
>
<p class="text-sm font-bold text-primary-700" aria-live="polite">
{statusText}
</p>
<div class="flex flex-wrap items-center gap-2">
<ZipFilePicker
pickerId="replace-zip"
ariaLabel="Neue ZIP bearbeiten"
disabled={isCommitting}
onFilesSelected={(files) => (pendingReplacementFiles = files)}
>
<FilePlus2 class="size-4" aria-hidden="true" />
Neue ZIP bearbeiten
</ZipFilePicker>
<Popover.Root>
<Popover.Trigger aria-label="Einstellungen">
{#snippet child({ props })}
<Button variant="bordered" {...props}>
<Settings2 class="size-4" aria-hidden="true" />
<span class="hidden sm:inline">Einstellungen</span>
</Button>
{/snippet}
</Popover.Trigger>
<Popover.Content
align="end"
sideOffset={8}
class="w-[min(22rem,calc(100vw-2rem))] gap-4 rounded-2xl border border-primary-100 p-4 shadow-lg ring-0"
>
<span class="sr-only">Einstellungen</span>
<div class="flex flex-col gap-3">
<span class="text-[12px] font-bold uppercase tracking-wide text-primary-700">
Ansicht
</span>
<div class="flex items-center justify-between gap-3 text-sm font-bold text-primary-900">
<span>Vorschaugröße</span>
<span class="tabular-nums text-primary-700">{thumbnailWidth} px</span>
</div>
<Slider
type="single"
thumbAriaLabel="Vorschaugröße"
min={100}
max={400}
step={1}
value={thumbnailWidth}
onValueChange={(value: number | number[]) => {
if (typeof value === 'number') onThumbnailWidthChange(value);
}}
/>
<div
class="flex justify-between text-[12px] font-medium text-primary-700"
aria-hidden="true"
>
<span>Klein</span>
<span>Groß</span>
</div>
</div>
<div class="flex flex-col gap-2">
<span class="text-[12px] font-bold uppercase tracking-wide text-primary-700">
Archiv-Aktionen
</span>
<ZipFilePicker
pickerId="append-zip"
ariaLabel="Weitere ZIP hinzufügen"
variant="bordered"
disabled={isCommitting}
class="w-full justify-start"
onFilesSelected={onAppendFiles}
>
<Plus class="size-4" aria-hidden="true" />
Weitere ZIP hinzufügen
</ZipFilePicker>
{#if processedArchiveCount >= 2}
<Button
variant="bordered"
disabled={isExporting || isCommitting}
class="w-full justify-start"
onclick={() => void exportAll()}
>
<Download class="size-4" aria-hidden="true" />
Alle Archive als PDF herunterladen
</Button>
{/if}
<Separator class="my-1" />
<Button
variant="tertiary"
disabled={isCommitting}
class="w-full justify-start text-destructive hover:bg-destructive/10 hover:text-destructive"
onclick={() => (clearDialogOpen = true)}
>
<Trash2 class="size-4" aria-hidden="true" />
Aktuelle Bearbeitung leeren
</Button>
</div>
</Popover.Content>
</Popover.Root>
</div>
</div>
<AlertDialog.Root
open={pendingReplacementFiles !== null}
onOpenChange={(open) => {
if (!open) pendingReplacementFiles = null;
}}
>
<AlertDialog.Content class="max-w-sm">
<AlertDialog.Title class="text-base font-bold text-primary-900">
Aktuelle Bearbeitung ersetzen?
</AlertDialog.Title>
<AlertDialog.Description>
Archive und nicht heruntergeladene Änderungen in dieser Ansicht werden entfernt. Bereits
heruntergeladene PDFs bleiben erhalten.
</AlertDialog.Description>
<AlertDialog.Footer>
<AlertDialog.Cancel>Abbrechen</AlertDialog.Cancel>
<AlertDialog.Action onclick={confirmReplacement}>Neue ZIP öffnen</AlertDialog.Action>
</AlertDialog.Footer>
</AlertDialog.Content>
</AlertDialog.Root>
<AlertDialog.Root bind:open={clearDialogOpen}>
<AlertDialog.Content class="max-w-sm">
<AlertDialog.Title class="text-base font-bold text-primary-900">
Aktuelle Bearbeitung leeren?
</AlertDialog.Title>
<AlertDialog.Description>
Alle Archive und nicht heruntergeladene Änderungen in dieser Ansicht werden entfernt. Bereits
heruntergeladene PDFs bleiben erhalten.
</AlertDialog.Description>
<AlertDialog.Footer>
<AlertDialog.Cancel>Abbrechen</AlertDialog.Cancel>
<AlertDialog.Action onclick={confirmClear}>Bearbeitung leeren</AlertDialog.Action>
</AlertDialog.Footer>
</AlertDialog.Content>
</AlertDialog.Root>
-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>
+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}
+109
View File
@@ -0,0 +1,109 @@
<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}
</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>
@@ -0,0 +1,537 @@
<script lang="ts">
import AttachmentPreview from '$lib/components/AttachmentPreview.svelte';
import Button from '$lib/components/ui/button.svelte';
import SubDocumentEditor from '$lib/components/SubDocumentEditor.svelte';
import {
buildArchiveExport,
buildSubDocumentExport,
createSubDocument,
dissolveSubDocument,
moveAttachmentIntoSubDocument,
moveSubDocument,
type AttachmentLocation,
type ExportMode,
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 selectionMode = $state(false);
let selectedPaths = $state<Set<string>>(new Set());
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);
$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 hasSubDocuments = $derived(subDocuments.length > 0);
const toggleSelection = (path: string) => {
const next = new Set(selectedPaths);
if (next.has(path)) {
next.delete(path);
} else {
next.add(path);
}
selectedPaths = next;
};
const toggleSelectionMode = () => {
selectionMode = !selectionMode;
selectedPaths = new Set();
};
const exitSelectionMode = () => {
selectionMode = false;
selectedPaths = new Set();
};
const handleWindowKeydown = (event: KeyboardEvent) => {
if (event.key === 'Escape' && selectionMode) {
exitSelectionMode();
}
};
const createSubDocumentFromSelection = () => {
if (selectedPaths.size === 0) {
return;
}
const selectedAttachments = looseAttachments.filter((attachment) =>
selectedPaths.has(attachment.path)
);
const remainingAttachments = looseAttachments.filter(
(attachment) => !selectedPaths.has(attachment.path)
);
if (selectedAttachments.length === 0) {
return;
}
const subDocumentNumber = subDocuments.length + 1;
const nextSubDocument = createSubDocument(
`Teildokument ${subDocumentNumber}`,
selectedAttachments
);
looseAttachments = remainingAttachments;
subDocuments = [...subDocuments, nextSubDocument];
selectedPaths = new Set();
commit();
};
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 (selectionMode) {
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 = '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 = `Teildokument „${subDocument.name}“ konnte nicht als PDF erstellt werden.`;
} finally {
downloadingSubDocumentId = null;
}
};
</script>
<svelte:window onkeydown={handleWindowKeydown} />
<section
class="flex flex-col gap-4 rounded-2xl border border-primary-100 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="w-full max-w-xl rounded-md border border-primary-200 bg-white px-3 py-2 text-[16px] font-bold text-primary-900 outline-none transition focus:border-primary-400 focus:ring-2 focus:ring-primary-200"
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">
<Button
type="button"
variant={selectionMode ? 'secondary' : 'bordered'}
onclick={toggleSelectionMode}
>
{selectionMode ? 'Fertig' : 'Auswählen'}
</Button>
{#if hasSubDocuments}
<div
class={cn(
'inline-flex items-center rounded-[16px] border border-primary-200 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 selectionMode && selectedPaths.size > 0}
<div
class="flex flex-wrap items-center gap-3 rounded-xl border border-primary-200 bg-primary-50 px-4 py-2 text-[13px] font-medium text-primary-900"
>
<span>{selectedPaths.size} Datei{selectedPaths.size === 1 ? '' : 'en'} ausgewählt</span>
<Button type="button" onclick={createSubDocumentFromSelection}>Teildokument erstellen</Button>
</div>
{/if}
<ul class="flex gap-3 overflow-x-auto pb-1">
{#each looseAttachments 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 transition',
dropIndex === index ? 'border-primary-400 ring-1 ring-primary-200' : 'border-primary-100',
dragIndex === index ? 'opacity-60' : '',
selectedPaths.has(attachment.path) ? 'border-primary-500 ring-2 ring-primary-300' : ''
)}
style={`width: ${thumbnailWidth}px;`}
draggable={selectionMode ? 'false' : 'true'}
ondragstart={(event) => handleLooseDragStart(index, event)}
ondragover={(event) => handleLooseDragOver(index, event)}
ondrop={(event) => handleLooseDrop(index, event)}
ondragend={handleLooseDragEnd}
>
{#if selectionMode}
<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={() => toggleSelection(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-baseline gap-2 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="truncate text-[13px] font-medium leading-tight text-primary-900"
title={attachment.name}
>
{attachment.name}
</div>
</div>
<AttachmentPreview {attachment} />
{/if}
</li>
{/each}
</ul>
{#if looseAttachments.length === 0 && subDocuments.length === 0}
<div
class="rounded-md border border-dashed border-primary-200 bg-primary-50 px-4 py-4 text-[13px] text-primary-700"
>
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}
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>
@@ -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}
+306
View File
@@ -0,0 +1,306 @@
<script lang="ts">
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;
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,
onSubDocumentChange = () => {},
onRemove = () => {},
onAttachmentMove = () => {},
onBlockDragStart = () => {},
onBlockDragOver = () => {},
onBlockDrop = () => {},
onBlockDragEnd = () => {},
onDownload,
isDownloading = false,
downloadDisabled = false
}: Props = $props();
let subDocumentName = $state('');
let dragIndex = $state<number | null>(null);
let dropIndex = $state<number | null>(null);
$effect(() => {
subDocumentName = subDocument.name;
});
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) => {
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
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' : ''
)}
style={`width: ${thumbnailWidth}px;`}
draggable="true"
ondragstart={(event) => handleCardDragStart(index, event)}
ondragover={(event) => handleCardDragOver(index, event)}
ondrop={(event) => handleCardDrop(index, event)}
ondragend={handleCardDragEnd}
>
<div class="flex min-w-0 flex-1 flex-row items-baseline gap-2 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="truncate text-[13px] font-medium leading-tight text-primary-900"
title={attachment.name}
>
{attachment.name}
</div>
</div>
<AttachmentPreview {attachment} />
</li>
{/each}
</ul>
{/if}
</section>
+12 -68
View File
@@ -1,77 +1,21 @@
<script lang="ts"> <script lang="ts">
import FileDropzone from '$lib/components/FileDropzone.svelte';
import { ZIP_ACCEPT_TYPES } from '$lib/zip-selection';
type Props = { type Props = {
onFilesSelected: (files: File[]) => void; onFilesSelected: (files: File[]) => void;
class?: string; class?: string;
}; };
let { onFilesSelected, class: className = "" }: Props = $props(); 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);
};
</script> </script>
<input <FileDropzone
bind:this={fileInput} {onFilesSelected}
class="hidden" accept={ZIP_ACCEPT_TYPES}
type="file" multiple={true}
accept=".zip,application/zip,application/x-zip-compressed" ariaLabel="beA-ZIP-Dateien hinzufügen"
multiple label="Dateien hinzufügen"
onchange={handleFileSelection} sublabel="beA-ZIP-Dateien 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 { isAcceptedZipFile, ZIP_ACCEPT_TYPES } from '$lib/zip-selection';
import type { Snippet } from 'svelte';
type Props = {
/** Identifies the hidden input for tests, e.g. `replace-zip` or `append-zip`. */
pickerId: string;
ariaLabel: string;
onFilesSelected: (files: File[]) => void;
children: Snippet;
multiple?: boolean;
disabled?: boolean;
variant?: ButtonVariant;
class?: string;
};
let {
pickerId,
ariaLabel,
onFilesSelected,
children,
multiple = true,
disabled = false,
variant = 'primary',
class: className = ''
}: Props = $props();
let fileInput = $state<HTMLInputElement | null>(null);
const handleFileSelection = (event: Event) => {
const input = event.currentTarget as HTMLInputElement;
const files = Array.from(input.files ?? []).filter(isAcceptedZipFile);
// Reset first so selecting the same file again still fires `change`.
input.value = '';
if (files.length === 0) return;
onFilesSelected(multiple ? files : files.slice(0, 1));
};
</script>
<input
bind:this={fileInput}
class="hidden"
type="file"
accept={ZIP_ACCEPT_TYPES}
{multiple}
data-zip-picker={pickerId}
onchange={handleFileSelection}
/>
<Button
{variant}
{disabled}
aria-label={ariaLabel}
class={className}
onclick={() => fileInput?.click()}
>
{@render children()}
</Button>
@@ -0,0 +1,8 @@
<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>
+10
View File
@@ -0,0 +1,10 @@
<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>
+10
View File
@@ -0,0 +1,10 @@
<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,9 @@
<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,15 @@
<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,4 @@
<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: 513 B

@@ -0,0 +1,12 @@
<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>
+4
View File
@@ -0,0 +1,4 @@
<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: 556 B

+10
View File
@@ -0,0 +1,10 @@
<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,7 @@
<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
};
+89
View File
@@ -0,0 +1,89 @@
<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,20 @@
<script lang="ts">
import { cn, type WithElementRef } from "$lib/utils.js";
import type { HTMLAttributes } from "svelte/elements";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
</script>
<div
bind:this={ref}
data-slot="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>
+22
View File
@@ -0,0 +1,22 @@
<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
};
+28
View File
@@ -0,0 +1,28 @@
import Close from './popover-close.svelte';
import Content from './popover-content.svelte';
import Description from './popover-description.svelte';
import Header from './popover-header.svelte';
import Portal from './popover-portal.svelte';
import Title from './popover-title.svelte';
import Trigger from './popover-trigger.svelte';
import Root from './popover.svelte';
export {
Root,
Content,
Description,
Header,
Title,
Trigger,
Close,
Portal,
//
Root as Popover,
Content as PopoverContent,
Description as PopoverDescription,
Header as PopoverHeader,
Title as PopoverTitle,
Trigger as PopoverTrigger,
Close as PopoverClose,
Portal as PopoverPortal
};
@@ -0,0 +1,7 @@
<script lang="ts">
import { Popover as PopoverPrimitive } from 'bits-ui';
let { ref = $bindable(null), ...restProps }: PopoverPrimitive.CloseProps = $props();
</script>
<PopoverPrimitive.Close bind:ref data-slot="popover-close" {...restProps} />
@@ -0,0 +1,31 @@
<script lang="ts">
import { Popover as PopoverPrimitive } from 'bits-ui';
import { cn, type WithoutChildrenOrChild } from '$lib/utils.js';
import PopoverPortal from './popover-portal.svelte';
import type { ComponentProps } from 'svelte';
let {
ref = $bindable(null),
class: className,
sideOffset = 4,
align = 'center',
portalProps,
...restProps
}: PopoverPrimitive.ContentProps & {
portalProps?: WithoutChildrenOrChild<ComponentProps<typeof PopoverPortal>>;
} = $props();
</script>
<PopoverPortal {...portalProps}>
<PopoverPrimitive.Content
bind:ref
data-slot="popover-content"
{sideOffset}
{align}
class={cn(
'flex flex-col gap-2.5 rounded-lg bg-popover p-2.5 text-sm text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 z-50 w-72 origin-(--transform-origin) outline-hidden',
className
)}
{...restProps}
/>
</PopoverPortal>
@@ -0,0 +1,20 @@
<script lang="ts">
import { cn, type WithElementRef } from '$lib/utils.js';
import type { HTMLAttributes } from 'svelte/elements';
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
</script>
<div
bind:this={ref}
data-slot="popover-description"
class={cn('text-muted-foreground', className)}
{...restProps}
>
{@render children?.()}
</div>
@@ -0,0 +1,20 @@
<script lang="ts">
import { cn, type WithElementRef } from '$lib/utils.js';
import type { HTMLAttributes } from 'svelte/elements';
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
</script>
<div
bind:this={ref}
data-slot="popover-header"
class={cn('flex flex-col gap-0.5 text-sm', className)}
{...restProps}
>
{@render children?.()}
</div>
@@ -0,0 +1,7 @@
<script lang="ts">
import { Popover as PopoverPrimitive } from 'bits-ui';
let { ...restProps }: PopoverPrimitive.PortalProps = $props();
</script>
<PopoverPrimitive.Portal {...restProps} />
@@ -0,0 +1,15 @@
<script lang="ts">
import { cn, type WithElementRef } from '$lib/utils.js';
import type { HTMLAttributes } from 'svelte/elements';
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
</script>
<div bind:this={ref} data-slot="popover-title" class={cn('font-medium', className)} {...restProps}>
{@render children?.()}
</div>
@@ -0,0 +1,17 @@
<script lang="ts">
import { Popover as PopoverPrimitive } from 'bits-ui';
import { cn } from '$lib/utils.js';
let {
ref = $bindable(null),
class: className,
...restProps
}: PopoverPrimitive.TriggerProps = $props();
</script>
<PopoverPrimitive.Trigger
bind:ref
data-slot="popover-trigger"
class={cn('', className)}
{...restProps}
/>
@@ -0,0 +1,7 @@
<script lang="ts">
import { Popover as PopoverPrimitive } from 'bits-ui';
let { open = $bindable(false), ...restProps }: PopoverPrimitive.RootProps = $props();
</script>
<PopoverPrimitive.Root bind:open {...restProps} />
+7
View File
@@ -0,0 +1,7 @@
import Root from './separator.svelte';
export {
Root,
//
Root as Separator
};
@@ -0,0 +1,23 @@
<script lang="ts">
import { Separator as SeparatorPrimitive } from 'bits-ui';
import { cn } from '$lib/utils.js';
let {
ref = $bindable(null),
class: className,
'data-slot': dataSlot = 'separator',
...restProps
}: SeparatorPrimitive.RootProps = $props();
</script>
<SeparatorPrimitive.Root
bind:ref
data-slot={dataSlot}
class={cn(
'shrink-0 bg-border data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:w-px',
// this is different in shadcn/ui but self-stretch breaks things for us
'data-[orientation=vertical]:h-full',
className
)}
{...restProps}
/>
+7
View File
@@ -0,0 +1,7 @@
import Root from './slider.svelte';
export {
Root,
//
Root as Slider
};
@@ -0,0 +1,57 @@
<script lang="ts">
import { Slider as SliderPrimitive } from 'bits-ui';
import { cn, type WithoutChildrenOrChild } from '$lib/utils.js';
let {
ref = $bindable(null),
value = $bindable(),
orientation = 'horizontal',
thumbId = undefined,
thumbAriaLabel = undefined,
class: className,
...restProps
}: WithoutChildrenOrChild<SliderPrimitive.RootProps> & {
thumbId?: string | undefined;
thumbAriaLabel?: string | undefined;
} = $props();
</script>
<!--
Discriminated Unions + Destructing (required for bindable) do not
get along, so we shut typescript up by casting `value` to `never`.
-->
<SliderPrimitive.Root
bind:ref
bind:value={value as never}
data-slot="slider"
{orientation}
class={cn(
'data-vertical:min-h-40 relative flex w-full touch-none items-center select-none data-disabled:opacity-50 data-vertical:h-full data-vertical:w-auto data-vertical:flex-col',
className
)}
{...restProps}
>
{#snippet children({ thumbItems })}
<span
data-slot="slider-track"
data-orientation={orientation}
class={cn(
'rounded-full bg-muted data-horizontal:h-1 data-horizontal:w-full data-vertical:h-full data-vertical:w-1 relative grow overflow-hidden bg-muted data-horizontal:w-full data-vertical:h-full'
)}
>
<SliderPrimitive.Range
data-slot="slider-range"
class={cn('bg-primary absolute select-none data-horizontal:h-full data-vertical:w-full')}
/>
</span>
{#each thumbItems as thumb (thumb.index)}
<SliderPrimitive.Thumb
data-slot="slider-thumb"
index={thumb.index}
id={thumbId}
aria-label={thumbAriaLabel}
class="relative size-3 rounded-full border border-ring bg-white ring-ring/50 transition-[color,box-shadow] after:absolute after:-inset-2 hover:ring-3 focus-visible:ring-3 focus-visible:outline-hidden active:ring-3 block shrink-0 select-none disabled:pointer-events-none disabled:opacity-50"
/>
{/each}
{/snippet}
</SliderPrimitive.Root>
+25
View File
@@ -0,0 +1,25 @@
export const downloadBlob = (
bytes: Uint8Array,
fileName: string,
mimeType: string = 'application/pdf'
) => {
const buffer = new Uint8Array(bytes.byteLength);
buffer.set(bytes);
const blob = new Blob([buffer.buffer as ArrayBuffer], { type: mimeType });
const objectUrl = URL.createObjectURL(blob);
const downloadLink = document.createElement('a');
downloadLink.href = objectUrl;
downloadLink.download = fileName;
downloadLink.rel = 'noopener';
downloadLink.click();
window.setTimeout(() => URL.revokeObjectURL(objectUrl), 1000);
};
export const downloadBlobUrl = (blobUrl: string, fileName: string) => {
const downloadLink = document.createElement('a');
downloadLink.href = blobUrl;
downloadLink.download = fileName;
downloadLink.rel = 'noopener';
downloadLink.click();
};
-108
View File
@@ -1,108 +0,0 @@
[data-theme='nominandum'] {
--text-scaling: 1.067;
--base-font-color: var(--color-surface-900);
--base-font-color-dark: var(--color-surface-50);
--base-font-family: Nunito, Helvetica Neue, sans-serif;
--base-font-size: inherit;
--base-line-height: 1.5;
--base-font-weight: 400;
--heading-font-color: var(--color-primary-900);
--heading-font-color-dark: var(--color-primary-900);
--heading-font-family: Nunito, Helvetica Neue, sans-serif;
--heading-font-weight: 400;
--anchor-font-color: var(--color-primary-600);
--anchor-font-color-dark: var(--color-primary-300);
--anchor-text-decoration: none;
--anchor-text-decoration-hover: underline;
--spacing: 0.25rem;
--radius-base: 0.5rem;
--radius-container: 24px;
--default-border-width: 1px;
--default-divide-width: 1px;
--default-ring-width: 2px;
--body-background-color: #EEF4F6;
--body-background-color-dark: #0f172a;
/* Primary: Material Blue */
/* Very Light / Ghostly */
--color-primary-50: oklch(96.8% 0.010 253deg);
--color-primary-100: oklch(92.8% 0.020 253deg);
--color-primary-200: oklch(86.5% 0.040 253deg);
--color-primary-300: oklch(77.5% 0.060 253deg);
--color-primary-400: oklch(68.5% 0.080 253deg);
/* Mid-range / Muted Tones */
--color-primary-500: oklch(59.5% 0.090 253deg);
--color-primary-600: oklch(52.5% 0.095 253deg);
--color-primary-700: oklch(45.5% 0.095 253deg);
/* Dark Range (Matching your base color) */
--color-primary-800: oklch(38.5% 0.090 253deg);
--color-primary-900: oklch(31.5% 0.090 253deg); /* YOUR BASE COLOR */
--color-primary-950: oklch(23.5% 0.070 253deg);
--color-primary-contrast-dark: var(--color-primary-950);
--color-primary-contrast-light: white;
--color-primary-contrast-50: var(--color-primary-contrast-dark);
--color-primary-contrast-100: var(--color-primary-contrast-dark);
--color-primary-contrast-200: var(--color-primary-contrast-dark);
--color-primary-contrast-300: var(--color-primary-contrast-dark);
--color-primary-contrast-400: white;
--color-primary-contrast-500: white;
--color-primary-contrast-600: white;
--color-primary-contrast-700: white;
--color-primary-contrast-800: white;
--color-primary-contrast-900: white;
--color-primary-contrast-950: white;
/* Secondary: dezentes Blau-Grau */
--color-secondary-50: oklch(97% 0.01 250deg);
--color-secondary-100: oklch(93.5% 0.015 250deg);
--color-secondary-200: oklch(88.5% 0.022 250deg);
--color-secondary-300: oklch(80% 0.035 250deg);
--color-secondary-400: oklch(68% 0.045 250deg);
--color-secondary-500: oklch(56% 0.055 250deg);
--color-secondary-600: oklch(48% 0.05 250deg);
--color-secondary-700: oklch(40% 0.045 250deg);
--color-secondary-800: oklch(32% 0.04 250deg);
--color-secondary-900: oklch(24% 0.035 250deg);
--color-secondary-950: oklch(17% 0.03 250deg);
--color-secondary-contrast-dark: var(--color-secondary-950);
--color-secondary-contrast-light: white;
/* Tertiary: Akzent Cyan */
--color-tertiary-50: oklch(97% 0.025 220deg);
--color-tertiary-100: oklch(93% 0.045 220deg);
--color-tertiary-200: oklch(87% 0.07 220deg);
--color-tertiary-300: oklch(78% 0.105 220deg);
--color-tertiary-400: oklch(69% 0.135 220deg);
--color-tertiary-500: oklch(60% 0.15 220deg);
--color-tertiary-600: oklch(52% 0.135 220deg);
--color-tertiary-700: oklch(44% 0.115 220deg);
--color-tertiary-800: oklch(36% 0.09 220deg);
--color-tertiary-900: oklch(29% 0.065 220deg);
--color-tertiary-950: oklch(21% 0.045 220deg);
/* Surface: Material-nahe kühle Grautöne */
--color-surface-50: oklch(98.5% 0.004 255deg);
--color-surface-100: oklch(96.5% 0.006 255deg);
--color-surface-200: oklch(92.5% 0.01 255deg);
--color-surface-300: oklch(86.5% 0.015 255deg);
--color-surface-400: oklch(74% 0.02 255deg);
--color-surface-500: oklch(62% 0.025 255deg);
--color-surface-600: oklch(50% 0.025 255deg);
--color-surface-700: oklch(39% 0.025 255deg);
--color-surface-800: oklch(29% 0.025 255deg);
--color-surface-900: oklch(21% 0.025 255deg);
--color-surface-950: oklch(14% 0.025 255deg);
--color-surface-contrast-dark: var(--color-surface-950);
--color-surface-contrast-light: white;
}
+96
View File
@@ -0,0 +1,96 @@
import { PDFDocument } from 'pdf-lib';
export type CompressionPreset = {
id: string;
label: string;
description: string;
scale: number;
quality: number;
};
export const compressionPresets: CompressionPreset[] = [
{
id: 'strong',
label: 'Starke Komprimierung',
description: '~72 DPI, JPEG 50%',
scale: 1,
quality: 0.5
},
{
id: 'balanced',
label: 'Ausgewogen',
description: '~108 DPI, JPEG 70%',
scale: 1.5,
quality: 0.7
},
{ id: 'light', label: 'Leicht', description: '~144 DPI, JPEG 85%', scale: 2, quality: 0.85 }
];
export const defaultCompressionPreset = compressionPresets[1];
const loadPdfjs = async () => {
const pdfjsLib = await import('pdfjs-dist');
const { getDocument, GlobalWorkerOptions } = pdfjsLib;
GlobalWorkerOptions.workerSrc = await import('pdfjs-dist/build/pdf.worker.mjs?url').then(
(module) => module.default
);
return { getDocument };
};
/**
* Rasterizes every page of `file` with the given preset and rebuilds a
* JPEG-compressed PDF from the rendered pages. Throws when the file cannot be
* processed; callers are responsible for user-facing error messages.
*/
export const compressWithPreset = async (
file: File,
preset: CompressionPreset
): Promise<Uint8Array> => {
const { getDocument } = await loadPdfjs();
const bytes = new Uint8Array(await file.arrayBuffer());
const loadingTask = getDocument({ data: bytes });
try {
const pdfjsDoc = await loadingTask.promise;
const outputPdf = await PDFDocument.create();
for (let pageNumber = 1; pageNumber <= pdfjsDoc.numPages; pageNumber += 1) {
const page = await pdfjsDoc.getPage(pageNumber);
const viewport = page.getViewport({ scale: preset.scale });
const canvas = document.createElement('canvas');
canvas.width = Math.ceil(viewport.width);
canvas.height = Math.ceil(viewport.height);
const canvasContext = canvas.getContext('2d');
if (!canvasContext) throw new Error('Could not create canvas context');
await page.render({
canvas,
canvasContext,
viewport,
background: '#ffffff'
}).promise;
const jpegBlob = await new Promise<Blob>((resolve, reject) => {
canvas.toBlob(
(result) => (result ? resolve(result) : reject(new Error('JPEG encode failed'))),
'image/jpeg',
preset.quality
);
});
const jpegImage = await outputPdf.embedJpg(new Uint8Array(await jpegBlob.arrayBuffer()));
const originalViewport = page.getViewport({ scale: 1 });
const pdfPage = outputPdf.addPage([originalViewport.width, originalViewport.height]);
pdfPage.drawImage(jpegImage, {
x: 0,
y: 0,
width: pdfPage.getWidth(),
height: pdfPage.getHeight()
});
}
return await outputPdf.save();
} finally {
await loadingTask.destroy();
}
};
+108
View File
@@ -0,0 +1,108 @@
import { PDFDocument } from 'pdf-lib';
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 type PdfMetadataResult =
| { status: 'ok'; metadata: PdfMetadataInfo }
| { status: 'encrypted' };
// The pdf-lib error class for encrypted documents is not exported from the
// package root, so we identify it by its message instead.
const isEncryptedPdfError = (error: unknown) =>
error instanceof Error && error.message.includes('is encrypted');
const safeRead = <T>(read: () => T | undefined): T | undefined => {
try {
return read();
} catch {
return undefined;
}
};
const toDateOrNull = (value: Date | undefined): Date | null =>
value && !Number.isNaN(value.getTime()) ? value : null;
// Finds an ASCII marker in raw bytes without decoding the whole file. The
// search for '/EmbeddedFile' also matches '/EmbeddedFiles' as a prefix.
const containsAsciiMarker = (bytes: Uint8Array, marker: string): boolean => {
const firstByte = marker.charCodeAt(0);
for (let index = 0; index <= bytes.length - marker.length; index += 1) {
if (bytes[index] !== firstByte) continue;
let matched = true;
for (let offset = 1; offset < marker.length; offset += 1) {
if (bytes[index + offset] !== marker.charCodeAt(offset)) {
matched = false;
break;
}
}
if (matched) return true;
}
return false;
};
export const hasEmbeddedFileMarkers = (bytes: Uint8Array): boolean =>
containsAsciiMarker(bytes, '/EmbeddedFile');
export const readPdfMetadata = async (bytes: Uint8Array): Promise<PdfMetadataResult> => {
const embeddedFileSuspected = hasEmbeddedFileMarkers(bytes);
let document: PDFDocument;
try {
document = await PDFDocument.load(bytes, { updateMetadata: false });
} catch (error) {
if (isEncryptedPdfError(error)) return { status: 'encrypted' };
throw error;
}
const keywords = safeRead(() => document.getKeywords());
return {
status: 'ok',
metadata: {
title: safeRead(() => document.getTitle()) ?? '',
author: safeRead(() => document.getAuthor()) ?? '',
subject: safeRead(() => document.getSubject()) ?? '',
// PDF keyword lists have no canonical separator; split only on
// explicit punctuation so multi-word keywords survive.
keywords: keywords
? keywords
.split(/[;,]/)
.map((keyword) => keyword.trim())
.filter(Boolean)
: [],
creator: safeRead(() => document.getCreator()) ?? '',
producer: safeRead(() => document.getProducer()) ?? '',
creationDate: toDateOrNull(safeRead(() => document.getCreationDate())),
modificationDate: toDateOrNull(safeRead(() => document.getModificationDate())),
pageCount: document.getPageCount(),
embeddedFileSuspected
}
};
};
export const stripPdfMetadata = async (bytes: Uint8Array): Promise<Uint8Array> => {
const document = await PDFDocument.load(bytes, { updateMetadata: false });
const now = new Date();
document.setTitle('');
document.setAuthor('');
document.setSubject('');
document.setKeywords([]);
document.setCreator('');
document.setProducer('beA-Edit');
document.setCreationDate(now);
document.setModificationDate(now);
return document.save();
};
+52
View File
@@ -0,0 +1,52 @@
import { rgb, type RGB } from 'pdf-lib';
export type OverlayPosition =
| 'center'
| 'top-left'
| 'top-center'
| 'top-right'
| 'bottom-left'
| 'bottom-center'
| 'bottom-right';
export type WatermarkPosition = OverlayPosition | 'diagonal';
export const getPositionCoords = (
pageWidth: number,
pageHeight: number,
markWidth: number,
markHeight: number,
position: WatermarkPosition,
margin = 20
): { x: number; y: number } => {
const horizontallyCentered = (pageWidth - markWidth) / 2;
switch (position) {
case 'center':
case 'diagonal':
return { x: horizontallyCentered, y: (pageHeight - markHeight) / 2 };
case 'top-left':
return { x: margin, y: pageHeight - markHeight - margin };
case 'top-center':
return { x: horizontallyCentered, y: pageHeight - markHeight - margin };
case 'top-right':
return { x: pageWidth - markWidth - margin, y: pageHeight - markHeight - margin };
case 'bottom-left':
return { x: margin, y: margin };
case 'bottom-center':
return { x: horizontallyCentered, y: margin };
case 'bottom-right':
return { x: pageWidth - markWidth - margin, y: margin };
}
};
export const parseHexColor = (hexColor: string): RGB => {
const match = /^#([\da-f]{2})([\da-f]{2})([\da-f]{2})$/i.exec(hexColor);
if (!match) return rgb(0.8, 0.1, 0.1);
return rgb(
Number.parseInt(match[1], 16) / 255,
Number.parseInt(match[2], 16) / 255,
Number.parseInt(match[3], 16) / 255
);
};
+17
View File
@@ -0,0 +1,17 @@
import { PDFDocument } from 'pdf-lib';
export const mergePdfFiles = async (files: File[]): Promise<Uint8Array> => {
if (files.length < 2) throw new Error('At least two PDF files are required.');
const mergedDocument = await PDFDocument.create();
for (const file of files) {
const sourceDocument = await PDFDocument.load(new Uint8Array(await file.arrayBuffer()));
const copiedPages = await mergedDocument.copyPages(
sourceDocument,
sourceDocument.getPageIndices()
);
for (const page of copiedPages) mergedDocument.addPage(page);
}
return mergedDocument.save();
};
+71
View File
@@ -0,0 +1,71 @@
export type PdfThumbnail = {
pageNumber: number;
imageUrl: string;
width: number;
height: number;
};
export type PdfDocumentHandle = {
pageCount: number;
thumbnails: PdfThumbnail[];
destroy: () => void;
};
export async function loadPdfDocument(data: Uint8Array): Promise<PdfDocumentHandle> {
const pdfjsLib = await import('pdfjs-dist');
const { getDocument, GlobalWorkerOptions } = pdfjsLib;
GlobalWorkerOptions.workerSrc = await import('pdfjs-dist/build/pdf.worker.mjs?url').then(
(module) => module.default
);
const loadingTask = getDocument({ data: new Uint8Array(data) });
const thumbnails: PdfThumbnail[] = [];
try {
const pdfDocument = await loadingTask.promise;
for (let pageNumber = 1; pageNumber <= pdfDocument.numPages; pageNumber += 1) {
const page = await pdfDocument.getPage(pageNumber);
const viewport = page.getViewport({ scale: 0.5 });
const canvas = document.createElement('canvas');
canvas.width = Math.ceil(viewport.width);
canvas.height = Math.ceil(viewport.height);
const canvasContext = canvas.getContext('2d');
if (!canvasContext) throw new Error('Could not create thumbnail canvas context.');
await page.render({ canvas, canvasContext, viewport }).promise;
const blob = await new Promise<Blob>((resolve, reject) => {
canvas.toBlob(
(result) =>
result ? resolve(result) : reject(new Error('Could not encode PDF thumbnail.')),
'image/png'
);
});
thumbnails.push({
pageNumber,
imageUrl: URL.createObjectURL(blob),
width: viewport.width,
height: viewport.height
});
}
let destroyed = false;
return {
pageCount: pdfDocument.numPages,
thumbnails,
destroy: () => {
if (destroyed) return;
destroyed = true;
for (const thumbnail of thumbnails) URL.revokeObjectURL(thumbnail.imageUrl);
void loadingTask.destroy();
}
};
} catch (error) {
for (const thumbnail of thumbnails) URL.revokeObjectURL(thumbnail.imageUrl);
await loadingTask.destroy();
throw error;
}
}
+11
View File
@@ -0,0 +1,11 @@
let pendingZipFiles: File[] = [];
export const queueZipFiles = (files: File[]) => {
pendingZipFiles = [...pendingZipFiles, ...files];
};
export const takeQueuedZipFiles = () => {
const files = pendingZipFiles;
pendingZipFiles = [];
return files;
};
+8
View File
@@ -0,0 +1,8 @@
export const RASTERIZATION_WARNING_BYTES = 25 * 1024 * 1024;
export const RASTERIZATION_MAX_BYTES = 100 * 1024 * 1024;
export const isRasterizationTooLarge = (file: File | null) =>
file !== null && file.size > RASTERIZATION_MAX_BYTES;
export const shouldWarnAboutRasterization = (file: File | null) =>
file !== null && file.size > RASTERIZATION_WARNING_BYTES;
+93
View File
@@ -0,0 +1,93 @@
export type XJustizMetadata = {
sender: string;
receiver: string;
documentNames: string[];
};
const XML_TEXT_DECODER = new TextDecoder();
const matchesElementName = (element: Element, requestedName: string) => {
const requestedLocalName = requestedName.split('.').pop() ?? requestedName;
return (
element.localName === requestedName ||
element.localName === requestedLocalName ||
element.tagName === requestedName ||
element.tagName === requestedLocalName ||
element.tagName.endsWith(`:${requestedLocalName}`)
);
};
const getDirectChildByLocalName = (element: Element, localName: string) =>
Array.from(element.children).find((child) => matchesElementName(child, localName)) ?? null;
const getFirstDescendantByLocalName = (root: Document | Element, localName: string) =>
Array.from(root.querySelectorAll('*')).find((element) =>
matchesElementName(element, localName)
) ?? null;
const getTextContentByPath = (root: Element, path: string[]) => {
let current: Element | null = root;
for (const localName of path) {
current = current ? getDirectChildByLocalName(current, localName) : null;
if (!current) {
return null;
}
}
const textContent = current.textContent?.trim();
return textContent ? textContent : null;
};
const getTextContentByLocalName = (root: Document | Element, localName: string) =>
getFirstDescendantByLocalName(root, localName)?.textContent?.trim() ?? null;
const parseXmlDocument = (xmlBytes: Uint8Array) => {
const parser = new DOMParser();
const document = parser.parseFromString(XML_TEXT_DECODER.decode(xmlBytes), 'application/xml');
if (document.querySelector('parsererror')) {
return null;
}
return document;
};
export const parseXJustizMetadata = (xmlBytes: Uint8Array): XJustizMetadata | null => {
const document = parseXmlDocument(xmlBytes);
if (!document) {
return null;
}
const sender = getTextContentByLocalName(document, 'aktenzeichen.absender') ?? '';
const receiver = getTextContentByLocalName(document, 'aktenzeichen.empfaenger') ?? '';
const schriftgutobjekte = getFirstDescendantByLocalName(document, 'schriftgutobjekte');
if (!schriftgutobjekte) {
return {
sender,
receiver,
documentNames: []
};
}
const dokumentNodes = Array.from(schriftgutobjekte.children).filter((child) =>
matchesElementName(child, 'dokument')
);
const documentNames = dokumentNodes
.map((dokument) =>
getTextContentByPath(dokument, ['xjustiz.fachspezifischeDaten', 'datei', 'dateiname'])
)
.filter((name): name is string => Boolean(name));
return {
sender,
receiver,
documentNames
};
};
+13
View File
@@ -0,0 +1,13 @@
import { unzip } from 'fflate';
export const unzipArchive = (data: Uint8Array) =>
new Promise<Record<string, Uint8Array>>((resolve, reject) => {
unzip(data, (error, files) => {
if (error) {
reject(error);
return;
}
resolve(files);
});
});
+69
View File
@@ -0,0 +1,69 @@
export type Tool = {
slug: string;
title: string;
description: string;
icon: string;
};
export const tools: Tool[] = [
{
slug: 'stamp',
title: 'Stempeln',
description: 'Versehen Sie Seiten mit Textstempeln wie „Beglaubigt“ oder „Eilt“.',
icon: 'stamp'
},
{
slug: 'watermark',
title: 'Wasserzeichen',
description: 'Fügen Sie ein Wasserzeichen zu Ihrem PDF hinzu.',
icon: 'droplets'
},
{
slug: 'encrypt',
title: 'Passwort setzen',
description: 'Verschlüsseln Sie Ihr PDF mit einem Passwort.',
icon: 'lock'
},
{
slug: 'decrypt',
title: 'Passwort entfernen',
description: 'Entsperren Sie ein passwortgeschütztes PDF.',
icon: 'unlock'
},
{
slug: 'convert',
title: 'Konvertieren',
description: 'Konvertieren Sie ein PDF in Bilder.',
icon: 'image'
},
{
slug: 'compress',
title: 'Komprimieren',
description: 'Komprimieren Sie ein PDF für kleinere Dateigröße.',
icon: 'minimize'
},
{
slug: 'separate',
title: 'Seiten entfernen',
description: 'Entfernen Sie Seiten aus einem PDF.',
icon: 'scissors'
},
{
slug: 'rotate',
title: 'Seiten drehen',
description: 'Drehen Sie einzelne oder alle Seiten eines PDFs.',
icon: 'rotate-cw'
},
{
slug: 'merge',
title: 'PDFs zusammenfügen',
description: 'Fügen Sie mehrere PDFs zu einer Datei zusammen.',
icon: 'merge'
},
{
slug: 'metadata',
title: 'Metadaten',
description: 'Zeigen Sie Metadaten eines PDFs an und entfernen Sie sie.',
icon: 'info'
}
];
+13
View File
@@ -0,0 +1,13 @@
import { clsx, type ClassValue } from 'clsx';
import { twMerge } from 'tailwind-merge';
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export type WithoutChild<T> = T extends { child?: any } ? Omit<T, 'child'> : T;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export type WithoutChildren<T> = T extends { children?: any } ? Omit<T, 'children'> : T;
export type WithoutChildrenOrChild<T> = WithoutChildren<WithoutChild<T>>;
export type WithElementRef<T, U extends HTMLElement = HTMLElement> = T & { ref?: U | null };
+644
View File
@@ -0,0 +1,644 @@
import { PDFDocument } from 'pdf-lib';
import { parseXJustizMetadata, type XJustizMetadata } from './services/xml-reading.service';
import { unzipArchive } from './services/zip-inflating.service';
export type ZipAttachmentKind = 'pdf' | 'image';
export type ZipAttachment = {
name: string;
path: string;
kind: ZipAttachmentKind;
data: Uint8Array;
};
export type SubDocument = {
id: string;
name: string;
attachments: ZipAttachment[];
};
export type ProcessedZipArchive = {
name: string;
attachments: ZipAttachment[];
subDocuments: SubDocument[];
};
export type ExportMode = 'single' | 'separate';
export type ExportUnit = {
name: string;
bytes: Uint8Array;
};
/**
* Drag source/target descriptors shared between the archive editor and the
* sub-document editor so HTML5 `dataTransfer` payloads stay consistent.
*/
export type AttachmentLocation =
| { kind: 'loose'; index: number }
| { kind: 'subDocument'; id: string; index: number };
const IMAGE_EXTENSIONS = new Set([
'.avif',
'.bmp',
'.gif',
'.heic',
'.jpeg',
'.jpg',
'.jp2',
'.png',
'.tif',
'.tiff',
'.webp'
]);
const ZIP_META_FILE_NAME = 'xjustiz_nachricht.xml';
const getBaseName = (path: string) => path.split('/').pop() ?? path;
const getCurrentDateString = (date: Date) =>
`${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`;
const createArchiveName = (date: Date, metadata: XJustizMetadata | null) => {
const datePrefix = getCurrentDateString(date);
if (!metadata) {
return `${datePrefix}_unbekannt_unbekannt.pdf`;
}
return `${datePrefix}_${metadata.sender}_${metadata.receiver}.pdf`;
};
const getExtension = (path: string) => {
const baseName = getBaseName(path).toLowerCase();
const lastDotIndex = baseName.lastIndexOf('.');
return lastDotIndex === -1 ? '' : baseName.slice(lastDotIndex);
};
const isPdf = (path: string) => getExtension(path) === '.pdf';
const isImage = (path: string) => IMAGE_EXTENSIONS.has(getExtension(path));
const isZipArchive = (path: string) => getExtension(path) === '.zip';
const normalizeKey = (value: string) => value.replaceAll('\\', '/').toLowerCase().trim();
const isMacMetadataEntry = (path: string) => {
const normalizedPath = normalizeKey(path);
const baseName = getBaseName(normalizedPath);
return (
normalizedPath.startsWith('__macosx/') ||
normalizedPath.includes('/__macosx/') ||
baseName === '.ds_store' ||
baseName.startsWith('._')
);
};
const orderAttachmentsByDocumentNames = (attachments: ZipAttachment[], documentNames: string[]) => {
const buckets = new Map<string, ZipAttachment[]>();
for (const attachment of attachments) {
const keys = new Set([normalizeKey(attachment.path), normalizeKey(attachment.name)]);
for (const key of keys) {
const bucket = buckets.get(key) ?? [];
bucket.push(attachment);
buckets.set(key, bucket);
}
}
const usedAttachments = new Set<ZipAttachment>();
const orderedAttachments: ZipAttachment[] = [];
const takeAttachment = (key: string) => {
const bucket = buckets.get(key);
if (!bucket) {
return null;
}
const nextAttachment = bucket.find((attachment) => !usedAttachments.has(attachment)) ?? null;
if (nextAttachment) {
usedAttachments.add(nextAttachment);
}
return nextAttachment;
};
for (const documentName of documentNames) {
const normalizedName = normalizeKey(documentName);
const normalizedBaseName = normalizeKey(getBaseName(documentName));
const matchedAttachment = takeAttachment(normalizedName) ?? takeAttachment(normalizedBaseName);
if (matchedAttachment) {
orderedAttachments.push(matchedAttachment);
}
}
for (const attachment of attachments) {
if (!usedAttachments.has(attachment)) {
orderedAttachments.push(attachment);
}
}
return orderedAttachments;
};
const shouldSkipZipEntry = (path: string) => isMacMetadataEntry(path);
const isMetadataFile = (path: string) => getBaseName(path).toLowerCase() === ZIP_META_FILE_NAME;
const createAttachment = (path: string, data: Uint8Array): ZipAttachment => ({
name: getBaseName(path),
path,
kind: isPdf(path) ? 'pdf' : 'image',
data
});
const extractZipEntries = async (archiveBytes: Uint8Array): Promise<ProcessedZipArchive[]> => {
const files = await unzipArchive(archiveBytes);
const attachments: ZipAttachment[] = [];
const nestedArchives: ProcessedZipArchive[] = [];
let xjustizNachrichtXml: Uint8Array | null = null;
for (const [path, data] of Object.entries(files)) {
if (shouldSkipZipEntry(path)) {
continue;
}
if (isMetadataFile(path)) {
xjustizNachrichtXml = data;
continue;
}
if (isZipArchive(path)) {
const childArchives = await extractZipEntries(data);
nestedArchives.push(...childArchives);
continue;
}
if (isPdf(path) || isImage(path)) {
attachments.push(createAttachment(path, data));
}
}
const metadata = xjustizNachrichtXml ? parseXJustizMetadata(xjustizNachrichtXml) : null;
const documentNames = metadata?.documentNames ?? [];
const orderedAttachments = orderAttachmentsByDocumentNames(attachments, documentNames);
const archiveName = createArchiveName(new Date(), metadata);
const archives: ProcessedZipArchive[] = [];
if (orderedAttachments.length > 0) {
archives.push({
name: archiveName,
attachments: orderedAttachments,
subDocuments: []
});
}
return [...archives, ...nestedArchives];
};
export const extractZipArchives = async (file: File): Promise<ProcessedZipArchive[]> => {
const archiveBytes = new Uint8Array(await file.arrayBuffer());
return extractZipEntries(archiveBytes);
};
const loadImageElement = (src: string) =>
new Promise<HTMLImageElement>((resolve, reject) => {
const image = new Image();
image.decoding = 'async';
image.onload = () => resolve(image);
image.onerror = () => reject(new Error(`Unable to decode image asset: ${src}`));
image.src = src;
});
const toArrayBuffer = (bytes: Uint8Array) =>
(() => {
const copy = new Uint8Array(bytes.byteLength);
copy.set(bytes);
return copy.buffer;
})();
const convertImageBytesToPng = async (imageBytes: Uint8Array) => {
if (typeof document === 'undefined') {
throw new Error('Image conversion requires a browser environment.');
}
const objectUrl = URL.createObjectURL(new Blob([toArrayBuffer(imageBytes)]));
try {
const image = await loadImageElement(objectUrl);
const canvas = document.createElement('canvas');
canvas.width = image.naturalWidth || image.width;
canvas.height = image.naturalHeight || image.height;
const context = canvas.getContext('2d');
if (!context) {
throw new Error('Could not create a canvas context for image conversion.');
}
context.drawImage(image, 0, 0);
const pngBlob = await new Promise<Blob>((resolve, reject) => {
canvas.toBlob((blob) => {
if (blob) {
resolve(blob);
return;
}
reject(new Error('Could not encode image asset as PNG.'));
}, 'image/png');
});
return new Uint8Array(await pngBlob.arrayBuffer());
} finally {
URL.revokeObjectURL(objectUrl);
}
};
const embedAttachmentImage = async (pdfDocument: PDFDocument, attachment: ZipAttachment) => {
const extension = getExtension(attachment.path);
const imageData = attachment.data;
if (extension === '.jpg' || extension === '.jpeg') {
return pdfDocument.embedJpg(imageData);
}
if (extension === '.png') {
return pdfDocument.embedPng(imageData);
}
const pngBytes = await convertImageBytesToPng(imageData);
return pdfDocument.embedPng(pngBytes);
};
const mergeAttachmentIntoPdf = async (mergedPdf: PDFDocument, attachment: ZipAttachment) => {
if (attachment.kind === 'pdf') {
const sourcePdf = await PDFDocument.load(attachment.data, { ignoreEncryption: true });
const copiedPages = await mergedPdf.copyPages(sourcePdf, sourcePdf.getPageIndices());
for (const page of copiedPages) {
mergedPdf.addPage(page);
}
return;
}
try {
const embeddedImage = await embedAttachmentImage(mergedPdf, attachment);
const page = mergedPdf.addPage([embeddedImage.width, embeddedImage.height]);
page.drawImage(embeddedImage, {
x: 0,
y: 0,
width: page.getWidth(),
height: page.getHeight()
});
} catch (error) {
throw error;
}
};
export const mergeAttachments = async (attachments: ZipAttachment[]): Promise<Uint8Array> => {
if (attachments.length === 0) {
throw new Error('Cannot create a PDF from an empty attachment list.');
}
const mergedPdf = await PDFDocument.create();
for (const attachment of attachments) {
await mergeAttachmentIntoPdf(mergedPdf, attachment);
}
return mergedPdf.save();
};
export const mergeProcessedZipArchive = async (archive: ProcessedZipArchive) => {
if (archive.attachments.length === 0 && archive.subDocuments.length === 0) {
throw new Error('Cannot create a PDF from an empty archive.');
}
return mergeAttachments(flattenArchiveForMerge(archive));
};
/**
* Flatten the archive into a single ordered attachment list, preserving
* sub-document order first and appending loose attachments at the end.
*/
export const flattenArchiveForMerge = (archive: ProcessedZipArchive): ZipAttachment[] => {
const ordered: ZipAttachment[] = [];
for (const subDocument of archive.subDocuments) {
ordered.push(...subDocument.attachments);
}
ordered.push(...archive.attachments);
return ordered;
};
/**
* Create a new sub-document with a stable id from a list of attachments.
*/
export const createSubDocument = (name: string, attachments: ZipAttachment[]): SubDocument => ({
id: crypto.randomUUID(),
name,
attachments: attachments.map((attachment) => ({ ...attachment }))
});
/**
* Reorder sub-documents inside an archive. Matches the semantics of a single
* `splice(fromIndex, 1)` -> `splice(toIndex, 0, moved)` move.
*/
export const moveSubDocument = (
archive: ProcessedZipArchive,
fromIndex: number,
toIndex: number
): ProcessedZipArchive => {
if (
fromIndex === toIndex ||
fromIndex < 0 ||
toIndex < 0 ||
fromIndex >= archive.subDocuments.length ||
toIndex >= archive.subDocuments.length
) {
return archive;
}
const nextSubDocuments = [...archive.subDocuments];
const [movedSubDocument] = nextSubDocuments.splice(fromIndex, 1);
nextSubDocuments.splice(toIndex, 0, movedSubDocument);
return { ...archive, subDocuments: nextSubDocuments };
};
const removeAt = <T>(list: T[], index: number): T | null => {
if (index < 0 || index >= list.length) {
return null;
}
const [item] = list.splice(index, 1);
return item ?? null;
};
const insertAt = <T>(list: T[], index: number, item: T) => {
const clampedIndex = Math.max(0, Math.min(index, list.length));
list.splice(clampedIndex, 0, item);
};
/**
* Reorder an attachment within its current location (loose list or a single
* sub-document's attachment list). Used by the per-list drag handlers.
*/
export const moveAttachmentInPlace = (
archive: ProcessedZipArchive,
location: AttachmentLocation,
toIndex: number
): ProcessedZipArchive => {
if (location.kind === 'loose') {
const nextAttachments = [...archive.attachments];
const moved = removeAt(nextAttachments, location.index);
if (!moved) {
return archive;
}
insertAt(nextAttachments, toIndex, moved);
return { ...archive, attachments: nextAttachments };
}
let changed = false;
const nextSubDocuments = archive.subDocuments.map((subDocument) => {
if (subDocument.id !== location.id) {
return subDocument;
}
const nextAttachments = [...subDocument.attachments];
const moved = removeAt(nextAttachments, location.index);
if (!moved) {
return subDocument;
}
insertAt(nextAttachments, toIndex, moved);
changed = true;
return { ...subDocument, attachments: nextAttachments };
});
return changed ? { ...archive, subDocuments: nextSubDocuments } : archive;
};
/**
* Move an attachment from a source location to a target location. Either side
* may be the loose list or any sub-document. The attachment moves (not copies),
* so the total attachment count is invariant. When source and target point to
* the same list, this degenerates to an in-place reorder (explicitly delegated
* to `moveAttachmentInPlace` so the indices are normalized correctly).
*/
export const moveAttachmentIntoSubDocument = (
archive: ProcessedZipArchive,
source: AttachmentLocation,
target: AttachmentLocation
): ProcessedZipArchive => {
const sameList =
source.kind === 'loose' && target.kind === 'loose'
? true
: source.kind === 'subDocument' && target.kind === 'subDocument'
? source.id === target.id
: false;
if (sameList) {
return moveAttachmentInPlace(archive, source, target.index);
}
const nextSubDocuments = archive.subDocuments.map((subDocument) => ({
...subDocument,
attachments: [...subDocument.attachments]
}));
const looseAttachments = [...archive.attachments];
const removeFromList = (location: AttachmentLocation): ZipAttachment | null => {
if (location.kind === 'loose') {
return removeAt(looseAttachments, location.index);
}
const target_ = nextSubDocuments.find((item) => item.id === location.id);
return target_ ? removeAt(target_.attachments, location.index) : null;
};
const insertIntoList = (attachment: ZipAttachment, location: AttachmentLocation) => {
if (location.kind === 'loose') {
insertAt(looseAttachments, location.index, attachment);
return;
}
const target_ = nextSubDocuments.find((item) => item.id === location.id);
if (!target_) {
// fall back to loose list end if the referenced sub-document vanished
looseAttachments.push(attachment);
return;
}
insertAt(target_.attachments, location.index, attachment);
};
const movedAttachment = removeFromList(source);
if (!movedAttachment) {
return archive;
}
insertIntoList(movedAttachment, target);
return {
...archive,
attachments: looseAttachments,
subDocuments: nextSubDocuments
};
};
/**
* Remove a single attachment from a sub-document and return it to the end of
* the loose list. Useful as a targeted "move back to loose" action.
*/
export const removeAttachmentFromSubDocument = (
archive: ProcessedZipArchive,
subDocumentId: string,
attachmentIndex: number
): ProcessedZipArchive => {
const nextSubDocuments = archive.subDocuments.map((subDocument) => ({
...subDocument,
attachments: [...subDocument.attachments]
}));
const nextLooseAttachments = [...archive.attachments];
const target_ = nextSubDocuments.find((item) => item.id === subDocumentId);
if (!target_) {
return archive;
}
const moved = removeAt(target_.attachments, attachmentIndex);
if (!moved) {
return archive;
}
nextLooseAttachments.push(moved);
return {
...archive,
attachments: nextLooseAttachments,
subDocuments: nextSubDocuments
};
};
/**
* Remove an entire sub-document and return its attachments to the end of the
* loose list. The sub-document ids of the remaining sub-documents are
* preserved.
*/
export const dissolveSubDocument = (
archive: ProcessedZipArchive,
subDocumentId: string
): ProcessedZipArchive => {
const dissolved = archive.subDocuments.find((item) => item.id === subDocumentId);
if (!dissolved) {
return archive;
}
return {
...archive,
attachments: [...archive.attachments, ...dissolved.attachments],
subDocuments: archive.subDocuments.filter((item) => item.id !== subDocumentId)
};
};
const stripPdfExtension = (name: string) =>
name.toLowerCase().endsWith('.pdf') ? name.slice(0, -'.pdf'.length) : name;
/**
* Build a single PDF export unit for one sub-document. The resulting filename
* follows the same convention as the separate archive export:
* `{archiveName} - {subDocumentName}` (without duplicate `.pdf` suffixes).
*/
export const buildSubDocumentExport = async (
archiveName: string,
subDocument: SubDocument
): Promise<ExportUnit> => {
if (subDocument.attachments.length === 0) {
throw new Error('Cannot create a PDF from an empty sub-document.');
}
return {
name: `${stripPdfExtension(archiveName)} - ${stripPdfExtension(subDocument.name)}`,
bytes: await mergeAttachments(subDocument.attachments)
};
};
/**
* Build one or more PDF export units from the archive.
*
* - `mode: 'single'` mirrors today's behaviour: one merged PDF covering
* sub-documents first, then loose attachments, named after the archive.
* - `mode: 'separate'` produces one PDF per sub-document (named
* `{archiveName} - {subDocumentName}.pdf`) plus a single merged PDF for any
* loose attachments (named after the archive). Empty groups are skipped.
*/
export const buildArchiveExport = async (
archive: ProcessedZipArchive,
mode: ExportMode
): Promise<ExportUnit[]> => {
if (mode === 'single') {
const attachments = flattenArchiveForMerge(archive);
if (attachments.length === 0) {
throw new Error('Cannot create a PDF from an empty archive.');
}
return [
{
name: stripPdfExtension(archive.name),
bytes: await mergeAttachments(attachments)
}
];
}
const separateUnits: ExportUnit[] = [];
for (const subDocument of archive.subDocuments) {
if (subDocument.attachments.length === 0) {
continue;
}
separateUnits.push(await buildSubDocumentExport(archive.name, subDocument));
}
if (archive.attachments.length > 0) {
separateUnits.push({
name: stripPdfExtension(archive.name),
bytes: await mergeAttachments(archive.attachments)
});
}
if (separateUnits.length === 0) {
throw new Error('Cannot create a PDF from an empty archive.');
}
return separateUnits;
};
+6
View File
@@ -0,0 +1,6 @@
export const ZIP_ACCEPT_TYPES = '.zip,application/zip,application/x-zip-compressed';
export const isAcceptedZipFile = (file: File): boolean => {
if (file.name.toLowerCase().endsWith('.zip')) return true;
return ['application/zip', 'application/x-zip-compressed'].includes(file.type.toLowerCase());
};
+14 -7
View File
@@ -8,26 +8,32 @@
<svelte:head> <svelte:head>
<link rel="icon" href={favicon} /> <link rel="icon" href={favicon} />
</svelte:head> </svelte:head>
<header style="height:72px" class="bg-white shadow grid place-content-center"> <div class="flex min-h-dvh flex-col">
<header style="height:72px" class="grid shrink-0 place-content-center bg-white shadow">
<a href="/"> <a href="/">
<img height="33px" src="/logo.svg" alt="logo" /> <img height="33px" src="/logo.svg" alt="logo" />
</a> </a>
</header> </header>
<div class="flex-1">
{@render children()} {@render children()}
</div>
<footer class="flex flex-col justify-end gap-15 min-h-78.5 h-96 px-4 mt-8.75 text-white py-8.75" <footer
style="background-image: url('/footer.svg');background-position: top right; background-size: cover; background-repeat: no-repeat"> class="mt-8.75 flex h-96 min-h-78.5 shrink-0 flex-col justify-end gap-15 px-4 py-8.75 text-white"
<div class="w-full max-w-360 mx-auto"> style="background-image: url('/footer.svg');background-position: top right; background-size: cover; background-repeat: no-repeat"
<h2 class="hidden font-bold mb-4">Nominandum GmbH</h2> >
<div class="mx-auto w-full max-w-360">
<h2 class="mb-4 hidden font-bold">Nominandum GmbH</h2>
<div class="flex flex-row flex-wrap gap-15 text-[14px] leading-5 mx-auto"> <div class="mx-auto flex flex-row flex-wrap gap-15 text-[14px] leading-5">
<div> <div>
<a class="underline" href="/impressum">Impressum</a><br /> <a class="underline" href="/impressum">Impressum</a><br />
<a class="underline" href="/datenschutz">Datenschutzerklärung</a><br /> <a class="underline" href="/datenschutz">Datenschutzerklärung</a><br />
</div> </div>
</div> </div>
<div class="hidden flex flex-row flex-wrap gap-15 text-[14px] leading-5 mx-auto"> <div class="mx-auto hidden flex-row flex-wrap gap-15 text-[14px] leading-5">
<div class="not-sm:hidden"> <div class="not-sm:hidden">
Holstenwall 1<br /> Holstenwall 1<br />
20355 Hamburg<br /> 20355 Hamburg<br />
@@ -56,3 +62,4 @@
</div> </div>
</div> </div>
</footer> </footer>
</div>
+110 -25
View File
@@ -1,35 +1,120 @@
<script lang="ts"> <script lang="ts">
import Card from "$lib/components/Card.svelte"; import {
import ZipDropzone from "$lib/components/ZipDropzone.svelte"; Archive,
Droplets,
Lock,
Unlock,
Image,
Minimize2,
Scissors,
Merge,
Stamp,
RotateCw,
Info,
FileText
} from '@lucide/svelte';
import { goto } from '$app/navigation';
import FileDropzone from '$lib/components/FileDropzone.svelte';
import * as Card from '$lib/components/ui/card/index';
import { queueZipFiles } from '$lib/pending-files';
import { tools } from '$lib/tools';
let selectedZipFiles: File[] = $state([]); const iconMap: Record<string, typeof FileText> = {
droplets: Droplets,
lock: Lock,
unlock: Unlock,
image: Image,
minimize: Minimize2,
scissors: Scissors,
merge: Merge,
stamp: Stamp,
'rotate-cw': RotateCw,
info: Info
};
const handleFilesSelected = (files: File[]) => { const isZipFile = (file: File) =>
selectedZipFiles = [...selectedZipFiles, ...files]; file.name.toLowerCase().endsWith('.zip') ||
file.type === 'application/zip' ||
file.type === 'application/x-zip-compressed';
const openBeaFiles = (files: File[]) => {
const zipFiles = files.filter(isZipFile);
if (zipFiles.length === 0) return;
queueZipFiles(zipFiles);
void goto('/tools/bea');
};
const handleWindowDragOver = (event: DragEvent) => {
if (!event.dataTransfer?.types.includes('Files')) return;
event.preventDefault();
};
const handleWindowDrop = (event: DragEvent) => {
if (!event.dataTransfer?.files.length) return;
event.preventDefault();
openBeaFiles(Array.from(event.dataTransfer.files));
}; };
</script> </script>
{#snippet content()} <svelte:head>
{#if selectedZipFiles.length === 0} <title>beA-Edit — Werkzeugkasten für das besondere elektronische Anwaltspostfach</title>
<ZipDropzone onFilesSelected={handleFilesSelected} /> </svelte:head>
{:else}
<div class="flex min-h-[calc(-180px+100vh)] w-full grow flex-col justify-center gap-4 px-4 py-6"> <svelte:window ondragover={handleWindowDragOver} ondrop={handleWindowDrop} />
<div class="flex flex-row items-baseline justify-center gap-2">
<h2 class="h1 text-[20px] font-bold text-primary-900">ZIP-Dateien geladen</h2> <div class="flex flex-col gap-10 px-4 py-10">
<!-- Hero -->
<div class="flex flex-col items-center gap-2 text-center">
<h1 class="text-[28px] font-bold text-primary-900">Werkzeugkasten für beA</h1>
<p class="max-w-lg text-[15px] font-medium text-primary-700">
Bereiten Sie ZIP-Archive aus dem besonderen elektronischen Anwaltspostfach (beA) auf — plus
praktische PDF-Werkzeuge.
</p>
<p class="max-w-lg text-[13px] text-primary-700">
Alle Werkzeuge laufen zu 100 % in Ihrem Browser — keine Dateien werden hochgeladen.
</p>
</div> </div>
<ul class="mx-auto w-full max-w-3xl space-y-2"> <!-- ZIP entry point -->
{#each selectedZipFiles as file} <div class="mx-auto w-full max-w-2xl">
<li class="rounded-container bg-primary-50 px-4 py-3 text-[14px] font-medium text-primary-900"> <FileDropzone
{file.name} onFilesSelected={openBeaFiles}
</li> accept=".zip,application/zip,application/x-zip-compressed"
multiple={true}
ariaLabel="beA-ZIP öffnen"
label="beA-ZIP öffnen"
sublabel="ZIP aus dem besonderen elektronischen Anwaltspostfach (beA) hier ablegen oder zum Auswählen klicken"
icon={Archive}
class="py-10"
/>
<p class="mt-2 text-center text-[12px] text-primary-600">
Tipp: In beA exportierte Archive enden auf .zip und enthalten Ihre Anlagen als PDF.
</p>
</div>
<!-- Tool grid -->
<div class="mx-auto w-full max-w-4xl">
<h2 class="mb-4 text-[18px] font-bold text-primary-900">Weitere PDF-Werkzeuge</h2>
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
{#each tools as tool}
{@const Icon = iconMap[tool.icon] ?? FileText}
<a href="/tools/{tool.slug}" class="block">
<Card.Root class="h-full transition hover:shadow-md cursor-pointer">
<Card.Content class="flex flex-col gap-3 py-6">
<div
class="flex h-10 w-10 items-center justify-center rounded-xl bg-primary/10 text-primary"
>
<Icon class="h-5 w-5" />
</div>
<div>
<h3 class="text-[15px] font-bold text-primary-900">{tool.title}</h3>
<p class="mt-1 text-[13px] leading-snug text-primary-700">{tool.description}</p>
</div>
</Card.Content>
</Card.Root>
</a>
{/each} {/each}
</ul>
</div> </div>
{/if} </div>
{/snippet} </div>
<section class="px-4">
<h1 class="h1 text-[20px] font-bold py-6 text-primary-900">beA-Edit</h1>
<Card {content} class="h-full"/>
</section>
+4 -1
View File
@@ -1,3 +1,6 @@
<script>
</script>
<svelte:head> <svelte:head>
<title>Datenschutzerklärung | Nominandum</title> <title>Datenschutzerklärung | Nominandum</title>
<meta <meta
@@ -7,7 +10,7 @@
</svelte:head> </svelte:head>
<main class="min-h-screen bg-primary-50/50 text-surface-800"> <main class="min-h-screen bg-primary-50/50 text-surface-800">
<header class="border-b-4 border-tertiary-400 bg-primary-900 text-white"> <header class="border-b-4 border-tertiary-400 bg-primary-900 text-white bg-[#244272]">
<div class="mx-auto max-w-7xl px-4 py-12 sm:px-8 sm:py-16 lg:py-20"> <div class="mx-auto max-w-7xl px-4 py-12 sm:px-8 sm:py-16 lg:py-20">
<a <a
class="mb-10 inline-flex items-center gap-2 rounded-sm text-sm font-bold text-primary-200 transition-colors hover:text-white focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-white" class="mb-10 inline-flex items-center gap-2 rounded-sm text-sm font-bold text-primary-200 transition-colors hover:text-white focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-white"
+1 -1
View File
@@ -4,7 +4,7 @@
</svelte:head> </svelte:head>
<main class="min-h-screen bg-primary-50/50 text-surface-800"> <main class="min-h-screen bg-primary-50/50 text-surface-800">
<header class="border-b-4 border-tertiary-400 bg-primary-900 text-white"> <header class="border-b-4 border-tertiary-400 bg-primary-900 text-white bg-[#244272]">
<div class="mx-auto max-w-7xl px-4 py-12 sm:px-8 sm:py-16 lg:py-20"> <div class="mx-auto max-w-7xl px-4 py-12 sm:px-8 sm:py-16 lg:py-20">
<a <a
class="mb-10 inline-flex items-center gap-2 rounded-sm text-sm font-bold text-primary-200 transition-colors hover:text-white focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-white" class="mb-10 inline-flex items-center gap-2 rounded-sm text-sm font-bold text-primary-200 transition-colors hover:text-white focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-white"
+152 -6
View File
@@ -1,16 +1,162 @@
@import 'tailwindcss'; @import 'tailwindcss';
@import '@skeletonlabs/skeleton/themes/cerberus'; /*@import '../globals.css';*/
@import '@skeletonlabs/skeleton'; @import 'tw-animate-css';
@import '@skeletonlabs/skeleton-svelte'; @import 'shadcn-svelte/tailwind.css';
@import '../lib/nom-theme.css'; @import '@fontsource-variable/geist';
@custom-variant dark (&:is(.dark *));
@plugin '@tailwindcss/forms'; @plugin '@tailwindcss/forms';
@plugin '@tailwindcss/typography'; @plugin '@tailwindcss/typography';
@font-face { @font-face {
font-family: 'Nunito'; font-family: 'Nunito';
font-style: normal; font-style: normal;
font-weight: 100 900; font-weight: 100 900;
src: url('$lib/assets/Nunito-VariableFont_wght.ttf') format("truetype"); src: url('$lib/assets/Nunito-VariableFont_wght.ttf') format('truetype');
}
:root {
--background: #eef4f6;
--foreground: #122f62;
--card: oklch(1 0 0);
--card-foreground: oklch(0.145 0 0);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.145 0 0);
--primary: #122f62;
--primary-foreground: oklch(0.985 0 0);
--secondary: #ea544b;
--secondary-end: #ff6f6d;
--secondary-foreground: oklch(0.205 0 0);
--muted: oklch(0.97 0 0);
--muted-foreground: oklch(0.556 0 0);
--accent: #d6dce5;
--accent-foreground: oklch(0.205 0 0);
--destructive: #ff0027;
--success: #4e8a72;
--border: oklch(0.922 0 0);
--input: oklch(0.922 0 0);
--ring: oklch(0.708 0 0);
--chart-1: oklch(0.87 0 0);
--chart-2: oklch(0.556 0 0);
--chart-3: oklch(0.439 0 0);
--chart-4: oklch(0.371 0 0);
--chart-5: oklch(0.269 0 0);
--radius: 16px;
--sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.145 0 0);
--sidebar-primary: oklch(0.205 0 0);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.97 0 0);
--sidebar-accent-foreground: oklch(0.205 0 0);
--sidebar-border: oklch(0.922 0 0);
--sidebar-ring: oklch(0.708 0 0);
}
.dark {
--background: #212121;
--foreground: #ffffff;
--card: oklch(0.205 0 0);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.205 0 0);
--popover-foreground: oklch(0.985 0 0);
--primary: #92b2eb;
--primary-foreground: oklch(0.205 0 0);
--secondary: linear-gradient(90deg, #ea544b 0%, #ff6f6d 100%);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.269 0 0);
--muted-foreground: oklch(0.708 0 0);
--accent: oklch(0.269 0 0);
--accent-foreground: oklch(0.985 0 0);
--destructive: #ff0027;
--success: #79c4a3;
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.556 0 0);
--chart-1: oklch(0.87 0 0);
--chart-2: oklch(0.556 0 0);
--chart-3: oklch(0.439 0 0);
--chart-4: oklch(0.371 0 0);
--chart-5: oklch(0.269 0 0);
--sidebar: oklch(0.205 0 0);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.269 0 0);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.556 0 0);
}
@theme inline {
--font-sans: 'Nunito', sans-serif;
--color-sidebar-ring: var(--sidebar-ring);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar: var(--sidebar);
--color-chart-5: var(--chart-5);
--color-chart-4: var(--chart-4);
--color-chart-3: var(--chart-3);
--color-chart-2: var(--chart-2);
--color-chart-1: var(--chart-1);
--color-ring: var(--ring);
--color-input: var(--input);
--color-border: var(--border);
--color-destructive: var(--destructive);
--color-success: var(--success);
--color-accent-foreground: var(--accent-foreground);
--color-accent: var(--accent);
--color-muted-foreground: var(--muted-foreground);
--color-muted: var(--muted);
--color-secondary-foreground: var(--secondary-foreground);
--color-secondary: var(--secondary);
--color-primary-foreground: var(--primary-foreground);
--color-primary: var(--primary);
--color-popover-foreground: var(--popover-foreground);
--color-popover: var(--popover);
--color-card-foreground: var(--card-foreground);
--color-card: var(--card);
--color-foreground: var(--foreground);
--color-background: var(--background);
--radius-sm: calc(var(--radius) * 0.6);
--radius-md: calc(var(--radius) * 0.8);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) * 1.4);
--radius-2xl: calc(var(--radius) * 1.8);
--radius-3xl: calc(var(--radius) * 2.2);
--radius-4xl: calc(var(--radius) * 2.6);
}
@layer base {
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground;
}
html {
@apply font-sans;
}
}
/* ripple */
@keyframes ripple {
from {
transform: scale(0);
opacity: 0.8;
}
to {
transform: scale(4);
opacity: 0;
}
}
.animate-ripple {
animation: ripple 600ms ease-out;
} }
+18
View File
@@ -0,0 +1,18 @@
<script lang="ts">
import { ArrowLeft } from '@lucide/svelte';
let { children } = $props();
</script>
<section class="px-4">
<div class="flex flex-wrap items-center gap-3 py-6">
<a
href="/"
class="flex items-center gap-2 text-[14px] font-medium text-primary-700 transition hover:text-primary-900"
>
<ArrowLeft class="h-4 w-4" />
Zurück
</a>
</div>
{@render children()}
</section>
+200
View File
@@ -0,0 +1,200 @@
<script lang="ts">
import { onMount } from 'svelte';
import BeaWorkspaceControls from '$lib/components/BeaWorkspaceControls.svelte';
import ProcessedZipArchiveEditor from '$lib/components/ProcessedZipArchiveEditor.svelte';
import ZipDropzone from '$lib/components/ZipDropzone.svelte';
import BeaArchiveProcessing, {
type ZipProcessingJob
} from '$lib/components/BeaArchiveProcessing.svelte';
import {
extractZipArchives,
mergeProcessedZipArchive,
type ProcessedZipArchive
} from '$lib/zip-processing';
import { downloadBlob } from '$lib/download';
import { takeQueuedZipFiles } from '$lib/pending-files';
import * as Card from '$lib/components/ui/card/index';
const THUMBNAIL_WIDTH_STORAGE_KEY = 'thumbnailWidth';
let selectedZipFiles: File[] = $state([]);
let processedZipFiles: ProcessedZipArchive[] = $state([]);
let zipJobs: ZipProcessingJob[] = $state([]);
let pendingZipCount = $derived(zipJobs.filter((job) => job.status === 'processing').length);
let failedZipCount = $derived(zipJobs.filter((job) => job.status === 'error').length);
let thumbnailWidth = $state(200);
let thumbnailWidthHydrated = $state(false);
let archiveGeneration = 0;
const clampThumbnailWidth = (value: number) => Math.min(400, Math.max(100, value));
onMount(() => {
const storedValue = localStorage.getItem(THUMBNAIL_WIDTH_STORAGE_KEY);
if (storedValue !== null) {
const parsedValue = Number(storedValue);
if (Number.isFinite(parsedValue)) {
thumbnailWidth = clampThumbnailWidth(parsedValue);
}
}
thumbnailWidthHydrated = true;
const queuedFiles = takeQueuedZipFiles();
if (queuedFiles.length > 0) handleFilesSelected(queuedFiles);
});
$effect(() => {
if (!thumbnailWidthHydrated) {
return;
}
localStorage.setItem(THUMBNAIL_WIDTH_STORAGE_KEY, String(thumbnailWidth));
});
const updateZipJob = (jobId: string, update: Partial<ZipProcessingJob>) => {
zipJobs = zipJobs.map((job) => (job.id === jobId ? { ...job, ...update } : job));
};
const handleFilesSelected = (files: File[]) => {
selectedZipFiles = [...selectedZipFiles, ...files];
const jobs = files.map((file) => ({
id: crypto.randomUUID(),
file,
status: 'processing' as const
}));
zipJobs = [...zipJobs, ...jobs];
for (const job of jobs) {
void processZipFile(job.id, job.file, archiveGeneration);
}
};
const processZipFile = async (jobId: string, file: File, generation: number) => {
updateZipJob(jobId, { status: 'processing', error: undefined, archiveCount: undefined });
try {
const processedZipArchives = await extractZipArchives(file);
if (generation !== archiveGeneration || !zipJobs.some((job) => job.id === jobId)) {
return;
}
processedZipFiles = [...processedZipFiles, ...processedZipArchives];
updateZipJob(jobId, {
status: 'complete',
archiveCount: processedZipArchives.length
});
} catch (error) {
console.error(`Failed to process ${file.name}`, error);
if (generation === archiveGeneration && zipJobs.some((job) => job.id === jobId)) {
updateZipJob(jobId, {
status: 'error',
error: 'Prüfen Sie, ob die Datei ein gültiges beA-ZIP-Archiv ist.'
});
}
}
};
const retryZipFile = (jobId: string) => {
const job = zipJobs.find((candidate) => candidate.id === jobId);
if (!job || job.status === 'processing') return;
void processZipFile(job.id, job.file, archiveGeneration);
};
const removeZipFile = (jobId: string) => {
const job = zipJobs.find((candidate) => candidate.id === jobId);
if (!job || job.status === 'processing') return;
zipJobs = zipJobs.filter((candidate) => candidate.id !== jobId);
selectedZipFiles = selectedZipFiles.filter((file) => file !== job.file);
};
const updateProcessedZipFile = (index: number, archive: ProcessedZipArchive) => {
processedZipFiles = processedZipFiles.map((existingArchive, existingIndex) =>
existingIndex === index ? archive : existingArchive
);
};
const exportAllZipArchivesAsPdf = async () => {
for (const archive of processedZipFiles) {
try {
const mergedBytes = await mergeProcessedZipArchive(archive);
downloadBlob(mergedBytes, archive.name);
} catch (error) {
console.error(`Failed to export archive ${archive.name}`, error);
}
}
};
const resetWorkspace = () => {
archiveGeneration += 1;
selectedZipFiles = [];
processedZipFiles = [];
zipJobs = [];
};
const replaceWithZipFiles = (files: File[]) => {
resetWorkspace();
// handleFilesSelected captures the incremented archiveGeneration, so results
// from the previous workspace can no longer append.
handleFilesSelected(files);
};
</script>
{#snippet content()}
{#if selectedZipFiles.length === 0}
<Card.Root>
<Card.Content>
<ZipDropzone onFilesSelected={handleFilesSelected} />
</Card.Content>
</Card.Root>
{:else if zipJobs.length > 0}
<div class="flex w-full grow flex-col gap-4 px-4 py-6">
<BeaWorkspaceControls
selectedZipCount={selectedZipFiles.length}
processedArchiveCount={processedZipFiles.length}
{pendingZipCount}
{thumbnailWidth}
onThumbnailWidthChange={(value) => (thumbnailWidth = value)}
onRequestReplacement={replaceWithZipFiles}
onAppendFiles={handleFilesSelected}
onExportAll={exportAllZipArchivesAsPdf}
onClear={resetWorkspace}
/>
<div class="mx-auto flex w-full flex-col gap-3">
{#if processedZipFiles.length === 0}
<BeaArchiveProcessing jobs={zipJobs} onRetry={retryZipFile} onRemove={removeZipFile} />
{:else if pendingZipCount > 0 || failedZipCount > 0}
<BeaArchiveProcessing
jobs={zipJobs}
compact
onRetry={retryZipFile}
onRemove={removeZipFile}
/>
{/if}
{#if processedZipFiles.length > 0}
<div class="flex flex-col gap-4">
{#each processedZipFiles as file, index}
<ProcessedZipArchiveEditor
archive={file}
{thumbnailWidth}
onArchiveChange={(updatedArchive) => updateProcessedZipFile(index, updatedArchive)}
/>
{/each}
</div>
{/if}
</div>
</div>
{/if}
{/snippet}
<div>
<h1 class="h1 text-[20px] font-bold text-primary-900">beA-Edit</h1>
{@render content()}
</div>
+171
View File
@@ -0,0 +1,171 @@
<script lang="ts">
import BatchFileList from '$lib/components/BatchFileList.svelte';
import FileDropzone from '$lib/components/FileDropzone.svelte';
import Button from '$lib/components/ui/button.svelte';
import { createBatchItem, runBatch, type BatchItem } from '$lib/batch';
import { downloadBlob } from '$lib/download';
import {
compressWithPreset,
compressionPresets,
defaultCompressionPreset
} from '$lib/pdf-compression';
import { isRasterizationTooLarge, RASTERIZATION_MAX_BYTES } from '$lib/rasterization-limits';
import { AlertTriangle } from '@lucide/svelte';
let items = $state<BatchItem[]>([]);
let selectedPreset = $state(defaultCompressionPreset.id);
let isRunning = $state(false);
const pendingCount = $derived(items.filter((item) => item.status === 'pending').length);
const canStart = $derived(pendingCount > 0 && !isRunning);
const handleFilesSelected = (files: File[]) => {
const knownKeys = new Set(items.map((item) => `${item.file.name}:${item.file.size}`));
const additions = files
.filter((file) => !knownKeys.has(`${file.name}:${file.size}`))
.map((file) => {
const item = createBatchItem(file);
if (isRasterizationTooLarge(file)) {
item.status = 'error';
item.errorMessage = `Diese Datei ist größer als ${RASTERIZATION_MAX_BYTES / (1024 * 1024)} MB und kann aus Speichergründen nicht im Browser gerastert werden.`;
}
return item;
});
items = [...items, ...additions];
};
const updateItem = (id: string, patch: Partial<BatchItem>) => {
items = items.map((item) => (item.id === id ? { ...item, ...patch } : item));
};
const processFile = async (file: File) => {
// The preset is read per file so an option change during a run only
// affects the still pending files.
const preset =
compressionPresets.find((entry) => entry.id === selectedPreset) ?? defaultCompressionPreset;
const bytes = await compressWithPreset(file, preset);
return { bytes, name: `${file.name.replace(/\.pdf$/i, '')}_komprimiert.pdf` };
};
const startBatch = async () => {
if (isRunning) return;
const queue = items.filter((item) => item.status === 'pending');
if (queue.length === 0) return;
isRunning = true;
try {
await runBatch(queue, processFile, updateItem, {
errorMessage: 'Diese PDF konnte nicht komprimiert werden.',
// Skip rows removed while they were still waiting in the queue.
shouldProcess: (id) => items.some((item) => item.id === id)
});
} finally {
isRunning = false;
}
};
const retryItem = async (id: string) => {
if (isRunning) return;
const item = items.find((entry) => entry.id === id);
if (!item) return;
isRunning = true;
try {
await runBatch([item], processFile, updateItem, {
errorMessage: 'Diese PDF konnte nicht komprimiert werden.'
});
} finally {
isRunning = false;
}
};
const removeItem = (id: string) => {
items = items.filter((item) => item.id !== id);
};
const downloadItem = (item: BatchItem) => {
if (item.resultBytes && item.resultName) downloadBlob(item.resultBytes, item.resultName);
};
</script>
<div class="mx-auto max-w-4xl space-y-6">
<div>
<h1 class="text-2xl font-bold text-foreground">Komprimieren</h1>
<p class="mt-2 text-sm text-primary">
Reduzieren Sie die Dateigröße Ihrer PDFs durch Komprimierung einzeln oder mehrere in einem
Durchlauf.
</p>
</div>
<div class="rounded-lg border border-amber-200 bg-amber-50 p-4 flex gap-3">
<AlertTriangle class="h-5 w-5 shrink-0 text-amber-600 mt-0.5" />
<div class="text-sm text-amber-800">
<p class="font-medium">Hinweis zur Qualität</p>
<p class="mt-1">
Das PDF wird neu gerendert, wodurch Text zu Bildern wird. Ausgewählter Text,
Durchsuchbarkeit und Vektorgrafiken gehen verloren.
</p>
</div>
</div>
<FileDropzone
onFilesSelected={handleFilesSelected}
multiple
label={items.length === 0 ? 'Dateien hinzufügen' : 'Weitere Dateien ablegen'}
sublabel={items.length === 0
? 'PDFs hier ablegen oder zum Auswählen klicken'
: 'Weitere PDFs hinzufügen bestehende Dateien bleiben erhalten'}
/>
{#if items.length > 0}
<div class="rounded-2xl border border-border bg-card p-4 space-y-4">
<h2 class="text-lg font-semibold text-foreground">Komprimierungsstufe</h2>
<p class="text-xs text-primary">Die Einstellung gilt für alle Dateien der Liste.</p>
<div class="space-y-2">
{#each compressionPresets as preset (preset.id)}
<label
class="flex items-start gap-3 rounded-lg border p-3 cursor-pointer transition {selectedPreset ===
preset.id
? 'border-primary bg-primary/5'
: 'border-border hover:border-primary-300'}"
>
<input
type="radio"
name="compression-preset"
value={preset.id}
bind:group={selectedPreset}
class="mt-0.5 h-4 w-4"
/>
<div>
<p class="text-sm font-medium text-foreground">{preset.label}</p>
<p class="text-xs text-primary">{preset.description}</p>
</div>
</label>
{/each}
</div>
{#if isRunning}
<p class="text-xs text-primary" role="note">
Änderungen an der Komprimierungsstufe gelten für die noch ausstehenden Dateien.
</p>
{/if}
</div>
{#if items.length > 0}
<BatchFileList
{items}
zipBaseName="komprimiert"
onDownload={downloadItem}
onRemove={removeItem}
onRetry={retryItem}
/>
{/if}
<div class="flex justify-end">
<Button onclick={startBatch} disabled={!canStart}>
{isRunning ? 'Wird komprimiert...' : 'Komprimieren'}
</Button>
</div>
{/if}
</div>
+192
View File
@@ -0,0 +1,192 @@
<script lang="ts">
import FileDropzone from '$lib/components/FileDropzone.svelte';
import RasterizationWarning from '$lib/components/RasterizationWarning.svelte';
import Button from '$lib/components/ui/button.svelte';
import { downloadBlob, downloadBlobUrl } from '$lib/download';
import { isRasterizationTooLarge } from '$lib/rasterization-limits';
import { zipSync } from 'fflate';
import { Image } from '@lucide/svelte';
type OutputFormat = 'png' | 'jpeg';
let pdfFile: File | null = $state(null);
let outputFormat = $state<OutputFormat>('png');
let scale = $state(2);
let isProcessing = $state(false);
let error = $state<string | null>(null);
let progress = $state(0);
let totalPages = $state(0);
const rasterizationBlocked = $derived(isRasterizationTooLarge(pdfFile));
const handleFileSelected = (files: File[]) => {
pdfFile = files[0] ?? null;
error = null;
progress = 0;
totalPages = 0;
};
const convertPdf = async () => {
if (!pdfFile) return;
isProcessing = true;
error = null;
progress = 0;
try {
const pdfjsLib = await import('pdfjs-dist');
const { getDocument, GlobalWorkerOptions } = pdfjsLib;
GlobalWorkerOptions.workerSrc = await import('pdfjs-dist/build/pdf.worker.mjs?url').then(
(module) => module.default
);
const loadingTask = getDocument({
data: new Uint8Array(await pdfFile.arrayBuffer())
});
try {
const pdfDocument = await loadingTask.promise;
const renderScale = Number(scale);
const baseName = pdfFile.name.replace(/\.pdf$/i, '');
const mimeType = outputFormat === 'png' ? 'image/png' : 'image/jpeg';
const extension = outputFormat === 'png' ? 'png' : 'jpg';
totalPages = pdfDocument.numPages;
const renderPage = async (pageNumber: number) => {
const page = await pdfDocument.getPage(pageNumber);
const viewport = page.getViewport({ scale: renderScale });
const canvas = document.createElement('canvas');
canvas.width = Math.ceil(viewport.width);
canvas.height = Math.ceil(viewport.height);
const canvasContext = canvas.getContext('2d');
if (!canvasContext) throw new Error('Could not create canvas context');
await page.render({ canvas, canvasContext, viewport, background: '#ffffff' }).promise;
return new Promise<Blob>((resolve, reject) => {
canvas.toBlob(
(result) => (result ? resolve(result) : reject(new Error('Image encode failed'))),
mimeType,
outputFormat === 'jpeg' ? 0.9 : undefined
);
});
};
if (pdfDocument.numPages === 1) {
const blob = await renderPage(1);
const objectUrl = URL.createObjectURL(blob);
downloadBlobUrl(objectUrl, `${baseName}.${extension}`);
window.setTimeout(() => URL.revokeObjectURL(objectUrl), 1000);
progress = 1;
} else {
const images: Record<string, Uint8Array> = {};
for (let pageNumber = 1; pageNumber <= pdfDocument.numPages; pageNumber += 1) {
const blob = await renderPage(pageNumber);
images[`seite_${String(pageNumber).padStart(3, '0')}.${extension}`] = new Uint8Array(
await blob.arrayBuffer()
);
progress = pageNumber;
}
downloadBlob(zipSync(images), `${baseName}_bilder.zip`, 'application/zip');
}
} finally {
await loadingTask.destroy();
}
} catch (conversionError) {
console.error('Conversion failed:', conversionError);
error = 'Fehler beim Konvertieren des PDFs.';
} finally {
isProcessing = false;
}
};
</script>
<div class="mx-auto max-w-4xl space-y-6">
<div>
<h1 class="text-2xl font-bold text-primary-900">Konvertieren</h1>
<p class="mt-2 text-sm text-primary-700">Konvertieren Sie ein PDF in Bilder (PNG oder JPEG).</p>
</div>
{#if !pdfFile}
<FileDropzone onFilesSelected={handleFileSelected} />
{:else}
<div class="space-y-6">
<FileDropzone
onFilesSelected={handleFileSelected}
label="Anderes PDF ablegen"
sublabel={pdfFile.name}
class="py-4"
/>
<RasterizationWarning file={pdfFile} />
<div class="rounded-xl border border-primary-200 bg-white p-4 space-y-4">
<div class="flex items-center gap-2 text-primary-900">
<Image class="h-5 w-5" />
<h2 class="text-lg font-semibold">{pdfFile.name}</h2>
</div>
<div>
<p class="text-sm font-medium text-primary-900 mb-2">Bildformat</p>
<div class="flex gap-3">
<label class="flex items-center gap-2 cursor-pointer">
<input type="radio" bind:group={outputFormat} value="png" class="h-4 w-4" />
<span class="text-sm text-primary-700">PNG (verlustfrei)</span>
</label>
<label class="flex items-center gap-2 cursor-pointer">
<input type="radio" bind:group={outputFormat} value="jpeg" class="h-4 w-4" />
<span class="text-sm text-primary-700">JPEG (kleiner)</span>
</label>
</div>
</div>
<div>
<label class="block text-sm font-medium text-primary-900 mb-1" for="render-scale">
Auflösung: {scale}x
</label>
<input
id="render-scale"
type="range"
min="1"
max="4"
step="0.5"
bind:value={scale}
class="w-full"
/>
<p class="text-xs text-primary-600 mt-1">
{scale === 1
? '~72 DPI'
: scale === 2
? '~150 DPI'
: scale === 3
? '~216 DPI'
: '~288 DPI'}
</p>
</div>
{#if isProcessing && totalPages > 0}
<div class="space-y-1">
<div class="flex justify-between text-xs text-primary-700">
<span>Wird konvertiert...</span>
<span>{progress} / {totalPages}</span>
</div>
<div class="h-2 w-full rounded-full bg-primary-100">
<div
class="h-2 rounded-full bg-primary transition-all"
style="width: {(progress / totalPages) * 100}%"
></div>
</div>
</div>
{/if}
</div>
{#if error}
<div class="rounded-lg border border-red-200 bg-red-50 p-4 text-sm text-red-800">
{error}
</div>
{/if}
<div class="flex justify-end gap-2">
<Button onclick={convertPdf} disabled={isProcessing || rasterizationBlocked}>
{isProcessing ? 'Wird konvertiert...' : 'Konvertieren & herunterladen'}
</Button>
</div>
</div>
{/if}
</div>
+215
View File
@@ -0,0 +1,215 @@
<script lang="ts">
import { PDFDocument } from 'pdf-lib';
import BatchFileList from '$lib/components/BatchFileList.svelte';
import FileDropzone from '$lib/components/FileDropzone.svelte';
import Button from '$lib/components/ui/button.svelte';
import { createBatchItem, runBatch, type BatchItem } from '$lib/batch';
import { downloadBlob } from '$lib/download';
import { isRasterizationTooLarge, RASTERIZATION_MAX_BYTES } from '$lib/rasterization-limits';
import { Unlock, AlertTriangle } from '@lucide/svelte';
let items = $state<BatchItem[]>([]);
let password = $state('');
let isRunning = $state(false);
let error = $state<string | null>(null);
const pendingCount = $derived(items.filter((item) => item.status === 'pending').length);
const canRun = $derived(!isRunning && pendingCount > 0 && password.length > 0);
const handleFilesSelected = (files: File[]) => {
const knownKeys = new Set(items.map((item) => `${item.file.name}:${item.file.size}`));
const additions = files
.filter((file) => !knownKeys.has(`${file.name}:${file.size}`))
.map((file) => {
const item = createBatchItem(file);
if (isRasterizationTooLarge(file)) {
item.status = 'error';
item.errorMessage = `Diese Datei ist größer als ${RASTERIZATION_MAX_BYTES / (1024 * 1024)} MB und kann aus Speichergründen nicht im Browser gerastert werden.`;
}
return item;
});
items = [...items, ...additions];
error = null;
};
const updateItem = (id: string, patch: Partial<BatchItem>) => {
items = items.map((item) => (item.id === id ? { ...item, ...patch } : item));
};
const processFile = async (file: File) => {
const pdfjsLib = await import('pdfjs-dist');
const { getDocument, GlobalWorkerOptions } = pdfjsLib;
GlobalWorkerOptions.workerSrc = await import('pdfjs-dist/build/pdf.worker.mjs?url').then(
(module) => module.default
);
const loadingTask = getDocument({
data: new Uint8Array(await file.arrayBuffer()),
password
});
try {
const pdfDocument = await loadingTask.promise;
const outputPdf = await PDFDocument.create();
for (let pageNumber = 1; pageNumber <= pdfDocument.numPages; pageNumber += 1) {
const page = await pdfDocument.getPage(pageNumber);
const viewport = page.getViewport({ scale: 2 });
const canvas = document.createElement('canvas');
canvas.width = Math.ceil(viewport.width);
canvas.height = Math.ceil(viewport.height);
const canvasContext = canvas.getContext('2d');
if (!canvasContext) throw new Error('Could not create canvas context');
await page.render({
canvas,
canvasContext,
viewport,
background: '#ffffff'
}).promise;
const pngBlob = await new Promise<Blob>((resolve, reject) => {
canvas.toBlob(
(result) => (result ? resolve(result) : reject(new Error('PNG encode failed'))),
'image/png'
);
});
const pngImage = await outputPdf.embedPng(new Uint8Array(await pngBlob.arrayBuffer()));
const originalViewport = page.getViewport({ scale: 1 });
const pdfPage = outputPdf.addPage([originalViewport.width, originalViewport.height]);
pdfPage.drawImage(pngImage, {
x: 0,
y: 0,
width: pdfPage.getWidth(),
height: pdfPage.getHeight()
});
}
const bytes = await outputPdf.save();
return { bytes, name: `${file.name.replace(/\.pdf$/i, '')}_entsperrt.pdf` };
} finally {
await loadingTask.destroy();
}
};
const startBatch = async () => {
if (isRunning) return;
const queue = items.filter((item) => item.status === 'pending');
if (queue.length === 0) return;
isRunning = true;
try {
await runBatch(queue, processFile, updateItem, {
errorMessage:
'Fehler beim Entsperren. Bitte prüfen Sie das Passwort und versuchen Sie es erneut.',
// Skip rows removed while they were still waiting in the queue.
shouldProcess: (id) => items.some((item) => item.id === id)
});
} finally {
isRunning = false;
}
};
const retryItem = async (id: string) => {
if (isRunning) return;
const item = items.find((entry) => entry.id === id);
if (!item) return;
isRunning = true;
try {
await runBatch([item], processFile, updateItem, {
errorMessage:
'Fehler beim Entsperren. Bitte prüfen Sie das Passwort und versuchen Sie es erneut.'
});
} finally {
isRunning = false;
}
};
const removeItem = (id: string) => {
items = items.filter((item) => item.id !== id);
};
const downloadItem = (item: BatchItem) => {
if (item.resultBytes && item.resultName) downloadBlob(item.resultBytes, item.resultName);
};
</script>
<div class="mx-auto max-w-4xl space-y-6">
<div>
<h1 class="text-2xl font-bold text-foreground">Passwort entfernen</h1>
<p class="mt-2 text-sm text-primary">
Entfernen Sie den Passwortschutz von PDF-Dokumenten einzeln oder mehrere in einem Durchlauf.
</p>
</div>
<div class="rounded-lg border border-amber-200 bg-amber-50 p-4 flex gap-3">
<AlertTriangle class="h-5 w-5 shrink-0 text-amber-600 mt-0.5" />
<div class="text-sm text-amber-800">
<p class="font-medium">Hinweis zur Qualität</p>
<p class="mt-1">
Das PDF wird neu gerendert, wodurch Text zu Bildern wird. Ausgewählter Text,
Durchsuchbarkeit und Vektorgrafiken gehen verloren. Die Dateigröße kann größer werden.
</p>
</div>
</div>
<FileDropzone
onFilesSelected={handleFilesSelected}
multiple
label={items.length === 0 ? 'Dateien hinzufügen' : 'Weitere Dateien ablegen'}
sublabel={items.length === 0
? 'PDFs hier ablegen oder zum Auswählen klicken'
: 'Weitere PDFs hinzufügen bestehende Dateien bleiben erhalten'}
/>
{#if items.length > 0}
<div class="rounded-2xl border border-border bg-card p-4 space-y-4">
<div class="flex items-center gap-2 text-foreground">
<Unlock class="h-5 w-5" />
<h2 class="text-lg font-semibold">Passwort</h2>
</div>
<p class="text-xs text-primary">Das Passwort gilt für alle Dateien der Liste.</p>
<div>
<label class="block text-sm font-medium text-foreground mb-1" for="decrypt-password">
Passwort
</label>
<input
id="decrypt-password"
type="password"
bind:value={password}
class="w-full rounded-lg border border-border px-3 py-2 text-sm focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary"
placeholder="Passwort der PDFs eingeben"
/>
</div>
{#if isRunning}
<p class="text-xs text-primary" role="note">
Änderungen am Passwort gelten für die noch ausstehenden Dateien.
</p>
{/if}
</div>
<BatchFileList
{items}
zipBaseName="entsperrt"
onDownload={downloadItem}
onRemove={removeItem}
onRetry={retryItem}
/>
{/if}
{#if error}
<div class="rounded-lg border border-red-200 bg-red-50 p-4 text-sm text-red-800">
{error}
</div>
{/if}
{#if items.length > 0}
<div class="flex justify-end">
<Button onclick={startBatch} disabled={!canRun}>
{isRunning ? 'Wird entsperrt...' : 'Passwort entfernen'}
</Button>
</div>
{/if}
</div>
+192
View File
@@ -0,0 +1,192 @@
<script lang="ts">
import { PDFDocument } from '@cantoo/pdf-lib';
import BatchFileList from '$lib/components/BatchFileList.svelte';
import FileDropzone from '$lib/components/FileDropzone.svelte';
import Button from '$lib/components/ui/button.svelte';
import { createBatchItem, runBatch, type BatchItem } from '$lib/batch';
import { downloadBlob } from '$lib/download';
import { Lock } from '@lucide/svelte';
let items = $state<BatchItem[]>([]);
let password = $state('');
let confirmPassword = $state('');
let allowPrinting = $state(true);
let allowCopying = $state(true);
let isRunning = $state(false);
let error = $state<string | null>(null);
const pendingCount = $derived(items.filter((item) => item.status === 'pending').length);
const canRun = $derived(
!isRunning && pendingCount > 0 && password.length > 0 && password === confirmPassword
);
const handleFilesSelected = (files: File[]) => {
const knownKeys = new Set(items.map((item) => `${item.file.name}:${item.file.size}`));
const additions = files
.filter((file) => !knownKeys.has(`${file.name}:${file.size}`))
.map((file) => createBatchItem(file));
items = [...items, ...additions];
error = null;
};
const updateItem = (id: string, patch: Partial<BatchItem>) => {
items = items.map((item) => (item.id === id ? { ...item, ...patch } : item));
};
const processFile = async (file: File) => {
// The password and permissions are read per file so a change during a
// run only affects the still pending files.
const bytes = new Uint8Array(await file.arrayBuffer());
const pdfDoc = await PDFDocument.load(bytes);
pdfDoc.encrypt({
userPassword: password,
ownerPassword: crypto.randomUUID(),
permissions: {
printing: allowPrinting,
copying: allowCopying
}
});
const encryptedBytes = await pdfDoc.save();
return { bytes: encryptedBytes, name: `${file.name.replace(/\.pdf$/i, '')}_geschuetzt.pdf` };
};
const startBatch = async () => {
if (isRunning) return;
const queue = items.filter((item) => item.status === 'pending');
if (queue.length === 0) return;
isRunning = true;
try {
await runBatch(queue, processFile, updateItem, {
errorMessage: 'Diese PDF konnte nicht verschlüsselt werden.',
// Skip rows removed while they were still waiting in the queue.
shouldProcess: (id) => items.some((item) => item.id === id)
});
} finally {
isRunning = false;
}
};
const retryItem = async (id: string) => {
if (isRunning) return;
const item = items.find((entry) => entry.id === id);
if (!item) return;
isRunning = true;
try {
await runBatch([item], processFile, updateItem, {
errorMessage: 'Diese PDF konnte nicht verschlüsselt werden.'
});
} finally {
isRunning = false;
}
};
const removeItem = (id: string) => {
items = items.filter((item) => item.id !== id);
};
const downloadItem = (item: BatchItem) => {
if (item.resultBytes && item.resultName) downloadBlob(item.resultBytes, item.resultName);
};
</script>
<div class="mx-auto max-w-4xl space-y-6">
<div>
<h1 class="text-2xl font-bold text-foreground">Passwort setzen</h1>
<p class="mt-2 text-sm text-primary">
Verschlüsseln Sie Ihre PDFs mit einem Passwort, um sie vor unbefugtem Zugriff zu schützen
einzeln oder mehrere in einem Durchlauf.
</p>
</div>
<FileDropzone
onFilesSelected={handleFilesSelected}
multiple
label={items.length === 0 ? 'Dateien hinzufügen' : 'Weitere Dateien ablegen'}
sublabel={items.length === 0
? 'PDFs hier ablegen oder zum Auswählen klicken'
: 'Weitere PDFs hinzufügen bestehende Dateien bleiben erhalten'}
/>
{#if items.length > 0}
<div class="rounded-2xl border border-border bg-card p-4 space-y-4">
<div class="flex items-center gap-2 text-foreground">
<Lock class="h-5 w-5" />
<h2 class="text-lg font-semibold">Passwort</h2>
</div>
<p class="text-xs text-primary">Das Passwort gilt für alle Dateien der Liste.</p>
<div>
<label class="block text-sm font-medium text-foreground mb-1" for="encrypt-password">
Passwort
</label>
<input
id="encrypt-password"
type="password"
bind:value={password}
class="w-full rounded-lg border border-border px-3 py-2 text-sm focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary"
placeholder="Passwort eingeben"
/>
</div>
<div>
<label
class="block text-sm font-medium text-foreground mb-1"
for="encrypt-password-confirmation"
>
Passwort bestätigen
</label>
<input
id="encrypt-password-confirmation"
type="password"
bind:value={confirmPassword}
class="w-full rounded-lg border border-border px-3 py-2 text-sm focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary"
placeholder="Passwort wiederholen"
/>
</div>
<div class="space-y-2">
<p class="text-sm font-medium text-foreground">Berechtigungen</p>
<div class="flex items-center gap-2">
<input type="checkbox" id="allow-printing" bind:checked={allowPrinting} class="h-4 w-4" />
<label for="allow-printing" class="text-sm text-primary">Drucken erlauben</label>
</div>
<div class="flex items-center gap-2">
<input type="checkbox" id="allow-copying" bind:checked={allowCopying} class="h-4 w-4" />
<label for="allow-copying" class="text-sm text-primary">Kopieren erlauben</label>
</div>
</div>
{#if isRunning}
<p class="text-xs text-primary" role="note">
Änderungen am Passwort gelten für die noch ausstehenden Dateien.
</p>
{/if}
</div>
<BatchFileList
{items}
zipBaseName="geschuetzt"
onDownload={downloadItem}
onRemove={removeItem}
onRetry={retryItem}
/>
{/if}
{#if error}
<div class="rounded-lg border border-red-200 bg-red-50 p-4 text-sm text-red-800">
{error}
</div>
{/if}
{#if items.length > 0}
<div class="flex justify-end">
<Button onclick={startBatch} disabled={!canRun}>
{isRunning ? 'Wird verschlüsselt...' : 'Passwort setzen'}
</Button>
</div>
{/if}
</div>
+108
View File
@@ -0,0 +1,108 @@
<script lang="ts">
import FileDropzone from '$lib/components/FileDropzone.svelte';
import Button from '$lib/components/ui/button.svelte';
import { downloadBlob } from '$lib/download';
import { mergePdfFiles } from '$lib/pdf-processing';
import { Trash2 } from '@lucide/svelte';
let pdfFiles: File[] = $state([]);
let isProcessing = $state(false);
let error = $state<string | null>(null);
const handleFilesSelected = (files: File[]) => {
pdfFiles = [...pdfFiles, ...files];
error = null;
};
const removeFile = (index: number) => {
pdfFiles = pdfFiles.filter((_, i) => i !== index);
};
const moveFile = (fromIndex: number, toIndex: number) => {
if (toIndex < 0 || toIndex >= pdfFiles.length) return;
const newFiles = [...pdfFiles];
const [moved] = newFiles.splice(fromIndex, 1);
newFiles.splice(toIndex, 0, moved);
pdfFiles = newFiles;
};
const mergePdfs = async () => {
if (pdfFiles.length < 2) return;
isProcessing = true;
error = null;
try {
downloadBlob(await mergePdfFiles(pdfFiles), 'zusammengefuegt.pdf');
} catch (err) {
console.error('Merge failed:', err);
error = 'Fehler beim Zusammenfügen der PDFs.';
} finally {
isProcessing = false;
}
};
</script>
<div class="mx-auto max-w-4xl space-y-6">
<div>
<h1 class="text-2xl font-bold text-primary-900">PDFs zusammenfügen</h1>
<p class="mt-2 text-sm text-primary-700">
Fügen Sie mehrere PDF-Dateien zu einem einzigen Dokument zusammen.
</p>
</div>
<FileDropzone onFilesSelected={handleFilesSelected} multiple={true} />
{#if pdfFiles.length > 0}
<div class="space-y-4">
<div class="flex items-center justify-between">
<h2 class="text-lg font-semibold text-primary-900">
{pdfFiles.length} Datei{pdfFiles.length === 1 ? '' : 'en'} ausgewählt
</h2>
<Button onclick={mergePdfs} disabled={isProcessing || pdfFiles.length < 2}>
{isProcessing ? 'Wird zusammengefügt...' : 'PDFs zusammenfügen'}
</Button>
</div>
{#if error}
<div class="rounded-lg border border-red-200 bg-red-50 p-4 text-sm text-red-800">
{error}
</div>
{/if}
<div class="space-y-2">
{#each pdfFiles as file, index}
<div class="flex items-center gap-3 rounded-lg border border-primary-200 bg-white p-3">
<span
class="flex h-8 w-8 items-center justify-center rounded-full bg-primary/10 text-sm font-semibold text-primary"
>
{index + 1}
</span>
<span class="flex-1 truncate text-sm text-primary-900">{file.name}</span>
<div class="flex gap-1">
<Button
variant="tertiary"
class="min-w-0 px-3"
onclick={() => moveFile(index, index - 1)}
disabled={index === 0}
>
</Button>
<Button
variant="tertiary"
class="min-w-0 px-3"
onclick={() => moveFile(index, index + 1)}
disabled={index === pdfFiles.length - 1}
>
</Button>
<Button variant="tertiary" class="min-w-0 px-3" onclick={() => removeFile(index)}>
<Trash2 class="h-4 w-4" />
</Button>
</div>
</div>
{/each}
</div>
</div>
{/if}
</div>

Some files were not shown because too many files have changed in this diff Show More