Compare commits
32
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b7969b4c9e | ||
|
|
d71b25f834 | ||
|
|
09cc5c06fb | ||
|
|
ea21fc000f | ||
|
|
57b38fe2c7 | ||
|
|
367f34229c | ||
|
|
dea6a1ba77 | ||
|
|
365c57847c | ||
|
|
2b22fcb0e8 | ||
|
|
037050e1c1 | ||
|
|
1a2673f708 | ||
|
|
adaae41e44 | ||
|
|
891026ccbb | ||
|
|
a450c6f5dd | ||
|
|
47ed80bd24 | ||
|
|
44ca534cfa | ||
|
|
0f7bb4b6eb | ||
|
|
52ca2c24f4 | ||
|
|
0b032b2cd8 | ||
|
|
9785973e8b | ||
|
|
1c6d9cce0f | ||
|
|
8a5ee9fe20 | ||
|
|
ef96f523e3 | ||
|
|
e3518eaed0 | ||
|
|
0629ca88c9 | ||
|
|
b400bf2f7c | ||
|
|
33f0804c53 | ||
|
|
1a9f0e876c | ||
|
|
0cbbc6f46d | ||
|
|
d00a7e2783 | ||
|
|
15a3fc5877 | ||
|
|
187f7b3a6a |
@@ -0,0 +1,11 @@
|
||||
.git
|
||||
.gitignore
|
||||
.idea
|
||||
.vscode
|
||||
.svelte-kit
|
||||
build
|
||||
node_modules
|
||||
npm-debug.log*
|
||||
pnpm-debug.log*
|
||||
.env
|
||||
.env.*
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -7,6 +7,8 @@ node_modules
|
||||
.wrangler
|
||||
/.svelte-kit
|
||||
/build
|
||||
/playwright-report
|
||||
/test-results
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
|
||||
Generated
+2615
File diff suppressed because it is too large
Load Diff
Generated
+106
-19
@@ -1,46 +1,133 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="AutoImportSettings">
|
||||
<option name="autoReloadType" value="SELECTIVE" />
|
||||
</component>
|
||||
<component name="CMakeSettings">
|
||||
<configurations />
|
||||
</component>
|
||||
<component name="ChangeListManager">
|
||||
<list default="true" id="89f9fc3a-63ad-41cb-8d25-f07df352eb98" name="Changes" comment="">
|
||||
<change beforePath="$PROJECT_DIR$/.idea/workspace.xml" beforeDir="false" afterPath="$PROJECT_DIR$/.idea/workspace.xml" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/src/routes/+page.svelte" beforeDir="false" afterPath="$PROJECT_DIR$/src/routes/+page.svelte" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/src/routes/+layout.svelte" beforeDir="false" afterPath="$PROJECT_DIR$/src/routes/+layout.svelte" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/src/routes/datenschutz/+page.svelte" beforeDir="false" afterPath="$PROJECT_DIR$/src/routes/datenschutz/+page.svelte" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/src/routes/impressum/+page.svelte" beforeDir="false" afterPath="$PROJECT_DIR$/src/routes/impressum/+page.svelte" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/uat.yml" beforeDir="false" afterPath="$PROJECT_DIR$/.gitea/workflows/uat.yml" afterDir="false" />
|
||||
</list>
|
||||
<option name="SHOW_DIALOG" value="false" />
|
||||
<option name="HIGHLIGHT_CONFLICTS" value="true" />
|
||||
<option name="HIGHLIGHT_NON_ACTIVE_CHANGELIST" value="false" />
|
||||
<option name="LAST_RESOLUTION" value="IGNORE" />
|
||||
</component>
|
||||
<component name="FileTemplateManagerImpl">
|
||||
<option name="RECENT_TEMPLATES">
|
||||
<list>
|
||||
<option value="+page.svelte" />
|
||||
</list>
|
||||
</option>
|
||||
</component>
|
||||
<component name="Git.Settings">
|
||||
<option name="RECENT_BRANCH_BY_REPOSITORY">
|
||||
<map>
|
||||
<entry key="$PROJECT_DIR$" value="features/shadcn" />
|
||||
</map>
|
||||
</option>
|
||||
<option name="RECENT_GIT_ROOT_PATH" value="$PROJECT_DIR$" />
|
||||
</component>
|
||||
<component name="McpProjectServerCommands">
|
||||
<commands />
|
||||
<urls />
|
||||
</component>
|
||||
<component name="MetaFilesCheckinStateConfiguration" checkMetaFiles="true" />
|
||||
<component name="NextEditCompletionFeaturesState">
|
||||
<decayedCancelled>
|
||||
<entry key="MS100" value="1.0000000193915048" />
|
||||
<entry key="MS500" value="1.028676250370633" />
|
||||
<entry key="S2" value="1.4194868298714414" />
|
||||
<entry key="S5" value="1.8458418253662012" />
|
||||
<entry key="S10" value="2.2177989694912164" />
|
||||
<entry key="S30" value="2.6766437274014185" />
|
||||
<entry key="S60" value="2.959677574191515" />
|
||||
<entry key="M2" value="3.4366229865704883" />
|
||||
<entry key="M5" value="4.462442318201847" />
|
||||
<entry key="M10" value="5.916664349028232" />
|
||||
<entry key="M15" value="6.940237970746977" />
|
||||
<entry key="M30" value="8.521362268212844" />
|
||||
<entry key="H1" value="9.62023487151045" />
|
||||
<entry key="H2" value="10.270683493430917" />
|
||||
<entry key="H4" value="10.62488416631901" />
|
||||
<entry key="D1" value="10.935974404270617" />
|
||||
<entry key="W1" value="10.990815874256214" />
|
||||
</decayedCancelled>
|
||||
<decayedSelected>
|
||||
<entry key="MS100" value="0.0" />
|
||||
<entry key="MS500" value="0.0" />
|
||||
<entry key="S2" value="0.0" />
|
||||
<entry key="S5" value="0.0" />
|
||||
<entry key="S10" value="0.0" />
|
||||
<entry key="S30" value="0.0" />
|
||||
<entry key="S60" value="0.0" />
|
||||
<entry key="M2" value="0.0" />
|
||||
<entry key="M5" value="0.0" />
|
||||
<entry key="M10" value="0.0" />
|
||||
<entry key="M15" value="0.0" />
|
||||
<entry key="M30" value="0.0" />
|
||||
<entry key="H1" value="0.0" />
|
||||
<entry key="H2" value="0.0" />
|
||||
<entry key="H4" value="0.0" />
|
||||
<entry key="D1" value="0.0" />
|
||||
<entry key="W1" value="0.0" />
|
||||
</decayedSelected>
|
||||
<decayedShown>
|
||||
<entry key="MS100" value="0.15496346249522763" />
|
||||
<entry key="MS500" value="0.6936352317970043" />
|
||||
<entry key="S2" value="1.1789457258652456" />
|
||||
<entry key="S5" value="1.6521017306909382" />
|
||||
<entry key="S10" value="2.0660150480217916" />
|
||||
<entry key="S30" value="2.60082115522875" />
|
||||
<entry key="S60" value="2.9160446418159838" />
|
||||
<entry key="M2" value="3.4117712141940064" />
|
||||
<entry key="M5" value="4.4508993349183115" />
|
||||
<entry key="M10" value="5.910338911687691" />
|
||||
<entry key="M15" value="6.935829221677054" />
|
||||
<entry key="M30" value="8.519029212377378" />
|
||||
<entry key="H1" value="9.619027375474417" />
|
||||
<entry key="H2" value="10.270068085857725" />
|
||||
<entry key="H4" value="10.624573345818598" />
|
||||
<entry key="D1" value="10.935922149562563" />
|
||||
<entry key="W1" value="10.990808397996608" />
|
||||
</decayedShown>
|
||||
</component>
|
||||
<component name="ProjectColorInfo">{
|
||||
"associatedIndex": 0,
|
||||
"fromUser": false
|
||||
}</component>
|
||||
<component name="ProjectId" id="3Ez6dWfFxFKqFN56n3L3nruEC06" />
|
||||
<component name="ProjectLevelVcsManager">
|
||||
<ConfirmationsSetting value="1" id="Add" />
|
||||
</component>
|
||||
<component name="ProjectViewState">
|
||||
<option name="hideEmptyMiddlePackages" value="true" />
|
||||
<option name="showLibraryContents" value="true" />
|
||||
</component>
|
||||
<component name="PropertiesComponent"><![CDATA[{
|
||||
"keyToString": {
|
||||
"ModuleVcsDetector.initialDetectionPerformed": "true",
|
||||
"RunOnceActivity.ShowReadmeOnStart": "true",
|
||||
"RunOnceActivity.TerminalTabsStorage.copyFrom.TerminalArrangementManager.252": "true",
|
||||
"RunOnceActivity.cidr.known.project.marker": "true",
|
||||
"RunOnceActivity.readMode.enableVisualFormatting": "true",
|
||||
"RunOnceActivity.typescript.service.memoryLimit.init": "true",
|
||||
"cidr.known.project.marker": "true",
|
||||
"codeWithMe.voiceChat.enabledByDefault": "false",
|
||||
"git-widget-placeholder": "main",
|
||||
"last_opened_file_path": "/home/rehlert/repos/juri-merger",
|
||||
"nodejs_package_manager_path": "pnpm",
|
||||
"settings.editor.selected.configurable": "preferences.pluginManager",
|
||||
"ts.external.directory.path": "/home/rehlert/repos/juri-merger/node_modules/typescript/lib",
|
||||
"vue.rearranger.settings.migration": "true"
|
||||
<component name="PropertiesComponent">{
|
||||
"keyToString": {
|
||||
"ModuleVcsDetector.initialDetectionPerformed": "true",
|
||||
"RunOnceActivity.ShowReadmeOnStart": "true",
|
||||
"RunOnceActivity.TerminalTabsStorage.copyFrom.TerminalArrangementManager.252": "true",
|
||||
"RunOnceActivity.cidr.known.project.marker": "true",
|
||||
"RunOnceActivity.readMode.enableVisualFormatting": "true",
|
||||
"RunOnceActivity.typescript.service.memoryLimit.init": "true",
|
||||
"cidr.known.project.marker": "true",
|
||||
"codeWithMe.voiceChat.enabledByDefault": "false",
|
||||
"git-widget-placeholder": "develop",
|
||||
"last_opened_file_path": "/home/rehlert/repos/juri-merger",
|
||||
"node.js.selected.package.tslint": "(autodetect)",
|
||||
"nodejs_package_manager_path": "pnpm",
|
||||
"settings.editor.selected.configurable": "preferences.pluginManager",
|
||||
"ts.external.directory.path": "/home/rehlert/repos/juri-merger/node_modules/typescript/lib",
|
||||
"vue.rearranger.settings.migration": "true"
|
||||
}
|
||||
}]]></component>
|
||||
}</component>
|
||||
<component name="TaskManager">
|
||||
<task active="true" id="Default" summary="Default task">
|
||||
<changelist id="89f9fc3a-63ad-41cb-8d25-f07df352eb98" name="Changes" comment="" />
|
||||
|
||||
+29
@@ -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"]
|
||||
@@ -0,0 +1,307 @@
|
||||
# Project Agent Guide
|
||||
|
||||
## Project overview
|
||||
|
||||
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 imprint and privacy-policy routes.
|
||||
|
||||
## Technology stack
|
||||
|
||||
- SvelteKit 2 and Svelte 5, with runes mode forced for project source files
|
||||
- TypeScript in strict mode
|
||||
- Vite 8
|
||||
- Tailwind CSS 4 through `@tailwindcss/vite`
|
||||
- shadcn-svelte 1 with the `nova` style and CSS-variable theme tokens
|
||||
- bits-ui, tailwind-variants, clsx, and tailwind-merge
|
||||
- Lucide Svelte icons plus project-specific SVG icon components
|
||||
- fflate for ZIP extraction and image-export ZIP creation
|
||||
- pdf-lib for PDF assembly, `@cantoo/pdf-lib` for encryption, and pdfjs-dist for rendering
|
||||
- `@sveltejs/adapter-node`
|
||||
- pnpm with a committed lockfile
|
||||
- Prettier with Svelte and Tailwind plugins
|
||||
|
||||
Use **pnpm**, not npm or yarn. The production container uses Node 24 and pnpm 11.15.1.
|
||||
|
||||
## Commands
|
||||
|
||||
```sh
|
||||
pnpm install # Install dependencies
|
||||
pnpm dev # Start the development server
|
||||
pnpm build # Create the adapter-node production build
|
||||
pnpm preview # Preview the production build
|
||||
pnpm check # Run Svelte and TypeScript diagnostics
|
||||
pnpm check:watch # Run diagnostics in watch mode
|
||||
pnpm test:e2e # Run the Playwright toolbox tests in Chromium
|
||||
pnpm lint # Check repository formatting with Prettier
|
||||
pnpm format # Format the repository with Prettier
|
||||
```
|
||||
|
||||
For code changes, run at least:
|
||||
|
||||
```sh
|
||||
pnpm check
|
||||
pnpm test:e2e
|
||||
pnpm lint
|
||||
pnpm build
|
||||
```
|
||||
|
||||
For documentation-only changes, a targeted Prettier check is sufficient. If pre-existing warnings
|
||||
or formatting failures remain outside changed files, report them instead of making unrelated edits.
|
||||
|
||||
The Docker image can be built from the repository root. It installs with `--frozen-lockfile`, builds
|
||||
and prunes dependencies, then runs `node build/index.js` as a non-root user on port 3000.
|
||||
|
||||
## Repository structure
|
||||
|
||||
```text
|
||||
src/
|
||||
├── app.html # German HTML shell
|
||||
├── app.d.ts # SvelteKit application declarations
|
||||
├── lib/
|
||||
│ ├── actions/ripple.ts # Button ripple Svelte action
|
||||
│ ├── assets/ # Favicon and local Nunito fonts
|
||||
│ ├── components/
|
||||
│ │ ├── AttachmentPreview.svelte # Browser-rendered PDF/image thumbnails
|
||||
│ │ ├── BatchFileList.svelte # Shared batch rows: status chips, downloads, retry
|
||||
│ │ ├── BeaArchiveProcessing.svelte
|
||||
│ │ ├── 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/
|
||||
├── +layout.svelte # Shared logo header and branded footer
|
||||
├── +page.svelte # Toolbox hub and global beA ZIP drop target
|
||||
├── layout.css # Tailwind, shadcn, theme, fonts, global CSS
|
||||
├── tools/
|
||||
│ ├── +layout.svelte # Shared back-navigation shell
|
||||
│ ├── bea/+page.svelte # Existing ZIP archive workflow
|
||||
│ └── {merge,separate,stamp,watermark,encrypt,decrypt,compress,convert,rotate,metadata}/
|
||||
├── datenschutz/+page.svelte # Privacy policy
|
||||
└── impressum/+page.svelte # Imprint
|
||||
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.
|
||||
|
||||
## 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
|
||||
|
||||
- Use Svelte 5 runes and current event syntax: `$props()`, `$state`, `$derived`, `$effect`, snippets,
|
||||
`{@render ...}`, and handlers such as `onclick` rather than legacy APIs.
|
||||
- Keep component scripts typed with `<script lang="ts">`.
|
||||
- Define a local `Props` type for non-trivial component props.
|
||||
- Use callback props for child-to-parent updates; do not mutate prop objects in place.
|
||||
- Use `$lib/...` aliases for application imports instead of long relative paths.
|
||||
- Name reusable components in PascalCase and route files according to SvelteKit conventions.
|
||||
- Preserve semantic HTML, accessible names, visible focus states, and keyboard alternatives. Do not
|
||||
make drag-and-drop the only way to perform a critical action.
|
||||
- Clean up global listeners, object URLs, PDF loading tasks, and other browser resources.
|
||||
|
||||
The Vite configuration forces runes mode for project files, so do not introduce legacy Svelte
|
||||
component patterns.
|
||||
|
||||
## shadcn-svelte and component conventions
|
||||
|
||||
`components.json` configures the official shadcn-svelte registry, the `nova` style, Lucide icons, and
|
||||
these aliases:
|
||||
|
||||
- UI components: `$lib/components/ui`
|
||||
- General components: `$lib/components`
|
||||
- Utilities: `$lib/utils`
|
||||
|
||||
Use existing local UI components before creating replacements. Follow the exports actually present
|
||||
on disk: the card is composed through `import * as Card from '$lib/components/ui/card'`, while the
|
||||
current button is a default import from `$lib/components/ui/button.svelte`.
|
||||
|
||||
When adding a registry component, use the project runner, inspect every generated file, and preserve
|
||||
local customizations:
|
||||
|
||||
```sh
|
||||
pnpm dlx shadcn-svelte@latest add <component>
|
||||
```
|
||||
|
||||
Do not overwrite or bulk-update customized components without explicit approval. Use `cn()` for
|
||||
conditional class merging and existing variants before adding one-off component styling.
|
||||
|
||||
## Styling conventions
|
||||
|
||||
Use Tailwind utilities in Svelte markup and semantic theme tokens from `src/routes/layout.css`.
|
||||
|
||||
- Prefer `bg-background`, `text-foreground`, `bg-primary`, `text-muted-foreground`, `border-border`,
|
||||
and other configured semantic utilities over raw brand colors.
|
||||
- Preserve the established Nominandum visual language: pale blue-gray background, white cards, dark
|
||||
blue primary text, red/coral secondary actions, rounded containers, and Nunito typography.
|
||||
- Use `flex`/`grid` with `gap-*`, responsive variants, and `size-*` when width and height are equal.
|
||||
- Use `cn()` rather than constructing partial Tailwind class names. Keep complete class names visible
|
||||
to Tailwind's scanner.
|
||||
- Prefer component variants and theme variables over overriding UI-component colors in call sites.
|
||||
- Add global CSS only for imports, tokens, font faces, animations, or behavior that utilities cannot
|
||||
reasonably express.
|
||||
- Tailwind 4 is configured through Vite and `layout.css`; there is no `tailwind.config` file.
|
||||
- Maintain contrast, responsive layouts, drag-state feedback, and visible focus treatment.
|
||||
|
||||
Theme-level changes belong in `src/routes/layout.css`. Do not reintroduce the removed Skeleton theme
|
||||
or refer to the old `nom-theme.css` architecture.
|
||||
|
||||
## Formatting
|
||||
|
||||
Follow the checked-in Prettier configuration:
|
||||
|
||||
- Tabs for indentation
|
||||
- Single quotes in scripts and styles where supported
|
||||
- No trailing commas
|
||||
- 100-character print width
|
||||
- Svelte-aware and Tailwind-aware formatting
|
||||
|
||||
Use `pnpm exec prettier --write <changed-files>` for targeted formatting. Avoid formatting unrelated
|
||||
files solely to clean up existing differences.
|
||||
|
||||
## Product and content constraints
|
||||
|
||||
- User-facing copy is German unless a task explicitly requests another language.
|
||||
- Preserve legal text in `datenschutz/+page.svelte` and `impressum/+page.svelte` unless explicitly
|
||||
asked to change it.
|
||||
- Keep the Nominandum logo, footer artwork, and theme consistent across routes.
|
||||
- ZIP selection accepts multiple files matching `.zip`, `application/zip`, or
|
||||
`application/x-zip-compressed`.
|
||||
- Resetting the file input after selection is intentional so the same archive can be selected again.
|
||||
- Do not add uploads, network requests, server processing, analytics, or case-data persistence without
|
||||
an explicit requirement and privacy review.
|
||||
|
||||
## Change discipline
|
||||
|
||||
- Keep changes scoped and preserve unrelated work, including untracked files.
|
||||
- Inspect existing components, archive helpers, and theme tokens before adding abstractions.
|
||||
- Keep browser processing behavior and archive-order invariants covered when editing shared helpers.
|
||||
- Do not hand-edit generated output or lockfile contents. Update the lockfile only through pnpm when
|
||||
dependency changes are required.
|
||||
- Avoid dependencies when the task can be completed with Svelte, browser APIs, Tailwind, or existing
|
||||
packages.
|
||||
- After implementation, review changed files, run relevant checks, and mention remaining warnings or
|
||||
limitations.
|
||||
@@ -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
@@ -10,27 +10,41 @@
|
||||
"prepare": "svelte-kit sync || echo ''",
|
||||
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
||||
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
|
||||
"test:e2e": "playwright test",
|
||||
"lint": "prettier --check .",
|
||||
"format": "prettier --write ."
|
||||
},
|
||||
"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/vite-plugin-svelte": "^7.1.2",
|
||||
"@tailwindcss/forms": "^0.5.11",
|
||||
"@tailwindcss/typography": "^0.5.19",
|
||||
"@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-plugin-svelte": "^4.1.0",
|
||||
"prettier-plugin-tailwindcss": "^0.8.0",
|
||||
"shadcn-svelte": "1.3.0",
|
||||
"svelte": "^5.56.1",
|
||||
"svelte-check": "^4.6.0",
|
||||
"tailwind-merge": "3.6.0",
|
||||
"tailwind-variants": "^3.3.1",
|
||||
"tailwindcss": "^4.3.0",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"typescript": "^6.0.3",
|
||||
"vite": "^8.0.16"
|
||||
},
|
||||
"dependencies": {
|
||||
"@skeletonlabs/skeleton": "^4.15.2",
|
||||
"@skeletonlabs/skeleton-svelte": "^4.15.2"
|
||||
"@cantoo/pdf-lib": "^2.9.1",
|
||||
"fflate": "^0.8.3",
|
||||
"pdf-lib": "^1.17.1",
|
||||
"pdfjs-dist": "^6.0.227"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
});
|
||||
Generated
+929
-562
File diff suppressed because it is too large
Load Diff
@@ -4,6 +4,7 @@
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="text-scale" content="scale" />
|
||||
<link rel="icon" href="%sveltekit.assets%/favicon.svg" type="image/svg+xml" />
|
||||
%sveltekit.head%
|
||||
</head>
|
||||
<body data-sveltekit-preload-data="hover">
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -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`;
|
||||
};
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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}
|
||||
@@ -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}
|
||||
@@ -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>
|
||||
@@ -1,77 +1,21 @@
|
||||
<script lang="ts">
|
||||
type Props = {
|
||||
onFilesSelected: (files: File[]) => void;
|
||||
class?: string;
|
||||
};
|
||||
import FileDropzone from '$lib/components/FileDropzone.svelte';
|
||||
import { ZIP_ACCEPT_TYPES } from '$lib/zip-selection';
|
||||
|
||||
let { onFilesSelected, class: className = "" }: Props = $props();
|
||||
type Props = {
|
||||
onFilesSelected: (files: File[]) => void;
|
||||
class?: string;
|
||||
};
|
||||
|
||||
let fileInput: HTMLInputElement | null = null;
|
||||
let isDragging = $state(false);
|
||||
|
||||
const processFiles = (files: FileList | null | undefined) => {
|
||||
if (!files) return;
|
||||
|
||||
const filtered = Array.from(files).filter((file) => {
|
||||
const name = file.name.toLowerCase();
|
||||
return (
|
||||
name.endsWith('.zip') ||
|
||||
file.type === 'application/zip' ||
|
||||
file.type === 'application/x-zip-compressed'
|
||||
);
|
||||
});
|
||||
|
||||
if (filtered.length > 0) {
|
||||
onFilesSelected(filtered);
|
||||
}
|
||||
};
|
||||
|
||||
const openFileDialog = () => fileInput?.click();
|
||||
|
||||
const handleFileSelection = (event: Event) => {
|
||||
const input = event.currentTarget as HTMLInputElement;
|
||||
processFiles(input.files);
|
||||
input.value = ''; // Reset so same file can be uploaded twice
|
||||
};
|
||||
|
||||
const handleDragOver = (event: DragEvent) => {
|
||||
event.preventDefault();
|
||||
isDragging = true;
|
||||
};
|
||||
|
||||
const handleDragLeave = (event: DragEvent) => {
|
||||
event.preventDefault();
|
||||
isDragging = false;
|
||||
};
|
||||
|
||||
const handleDrop = (event: DragEvent) => {
|
||||
event.preventDefault();
|
||||
isDragging = false;
|
||||
processFiles(event.dataTransfer?.files);
|
||||
};
|
||||
let { onFilesSelected, class: className = '' }: Props = $props();
|
||||
</script>
|
||||
|
||||
<input
|
||||
bind:this={fileInput}
|
||||
class="hidden"
|
||||
type="file"
|
||||
accept=".zip,application/zip,application/x-zip-compressed"
|
||||
multiple
|
||||
onchange={handleFileSelection}
|
||||
<FileDropzone
|
||||
{onFilesSelected}
|
||||
accept={ZIP_ACCEPT_TYPES}
|
||||
multiple={true}
|
||||
ariaLabel="beA-ZIP-Dateien hinzufügen"
|
||||
label="Dateien hinzufügen"
|
||||
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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 |
@@ -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
|
||||
};
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -0,0 +1,25 @@
|
||||
import Root from './card.svelte';
|
||||
import Content from './card-content.svelte';
|
||||
import Description from './card-description.svelte';
|
||||
import Footer from './card-footer.svelte';
|
||||
import Header from './card-header.svelte';
|
||||
import Title from './card-title.svelte';
|
||||
import Action from './card-action.svelte';
|
||||
|
||||
export {
|
||||
Root,
|
||||
Content,
|
||||
Description,
|
||||
Footer,
|
||||
Header,
|
||||
Title,
|
||||
Action,
|
||||
//
|
||||
Root as Card,
|
||||
Content as CardContent,
|
||||
Description as CardDescription,
|
||||
Footer as CardFooter,
|
||||
Header as CardHeader,
|
||||
Title as CardTitle,
|
||||
Action as CardAction
|
||||
};
|
||||
@@ -0,0 +1,28 @@
|
||||
import Close from './popover-close.svelte';
|
||||
import Content from './popover-content.svelte';
|
||||
import Description from './popover-description.svelte';
|
||||
import Header from './popover-header.svelte';
|
||||
import Portal from './popover-portal.svelte';
|
||||
import Title from './popover-title.svelte';
|
||||
import Trigger from './popover-trigger.svelte';
|
||||
import Root from './popover.svelte';
|
||||
|
||||
export {
|
||||
Root,
|
||||
Content,
|
||||
Description,
|
||||
Header,
|
||||
Title,
|
||||
Trigger,
|
||||
Close,
|
||||
Portal,
|
||||
//
|
||||
Root as Popover,
|
||||
Content as PopoverContent,
|
||||
Description as PopoverDescription,
|
||||
Header as PopoverHeader,
|
||||
Title as PopoverTitle,
|
||||
Trigger as PopoverTrigger,
|
||||
Close as PopoverClose,
|
||||
Portal as PopoverPortal
|
||||
};
|
||||
@@ -0,0 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { Popover as PopoverPrimitive } from 'bits-ui';
|
||||
|
||||
let { ref = $bindable(null), ...restProps }: PopoverPrimitive.CloseProps = $props();
|
||||
</script>
|
||||
|
||||
<PopoverPrimitive.Close bind:ref data-slot="popover-close" {...restProps} />
|
||||
@@ -0,0 +1,31 @@
|
||||
<script lang="ts">
|
||||
import { Popover as PopoverPrimitive } from 'bits-ui';
|
||||
import { cn, type WithoutChildrenOrChild } from '$lib/utils.js';
|
||||
import PopoverPortal from './popover-portal.svelte';
|
||||
import type { ComponentProps } from 'svelte';
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
sideOffset = 4,
|
||||
align = 'center',
|
||||
portalProps,
|
||||
...restProps
|
||||
}: PopoverPrimitive.ContentProps & {
|
||||
portalProps?: WithoutChildrenOrChild<ComponentProps<typeof PopoverPortal>>;
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
<PopoverPortal {...portalProps}>
|
||||
<PopoverPrimitive.Content
|
||||
bind:ref
|
||||
data-slot="popover-content"
|
||||
{sideOffset}
|
||||
{align}
|
||||
class={cn(
|
||||
'flex flex-col gap-2.5 rounded-lg bg-popover p-2.5 text-sm text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 z-50 w-72 origin-(--transform-origin) outline-hidden',
|
||||
className
|
||||
)}
|
||||
{...restProps}
|
||||
/>
|
||||
</PopoverPortal>
|
||||
@@ -0,0 +1,20 @@
|
||||
<script lang="ts">
|
||||
import { cn, type WithElementRef } from '$lib/utils.js';
|
||||
import type { HTMLAttributes } from 'svelte/elements';
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
children,
|
||||
...restProps
|
||||
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
|
||||
</script>
|
||||
|
||||
<div
|
||||
bind:this={ref}
|
||||
data-slot="popover-description"
|
||||
class={cn('text-muted-foreground', className)}
|
||||
{...restProps}
|
||||
>
|
||||
{@render children?.()}
|
||||
</div>
|
||||
@@ -0,0 +1,20 @@
|
||||
<script lang="ts">
|
||||
import { cn, type WithElementRef } from '$lib/utils.js';
|
||||
import type { HTMLAttributes } from 'svelte/elements';
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
children,
|
||||
...restProps
|
||||
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
|
||||
</script>
|
||||
|
||||
<div
|
||||
bind:this={ref}
|
||||
data-slot="popover-header"
|
||||
class={cn('flex flex-col gap-0.5 text-sm', className)}
|
||||
{...restProps}
|
||||
>
|
||||
{@render children?.()}
|
||||
</div>
|
||||
@@ -0,0 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { Popover as PopoverPrimitive } from 'bits-ui';
|
||||
|
||||
let { ...restProps }: PopoverPrimitive.PortalProps = $props();
|
||||
</script>
|
||||
|
||||
<PopoverPrimitive.Portal {...restProps} />
|
||||
@@ -0,0 +1,15 @@
|
||||
<script lang="ts">
|
||||
import { cn, type WithElementRef } from '$lib/utils.js';
|
||||
import type { HTMLAttributes } from 'svelte/elements';
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
children,
|
||||
...restProps
|
||||
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
|
||||
</script>
|
||||
|
||||
<div bind:this={ref} data-slot="popover-title" class={cn('font-medium', className)} {...restProps}>
|
||||
{@render children?.()}
|
||||
</div>
|
||||
@@ -0,0 +1,17 @@
|
||||
<script lang="ts">
|
||||
import { Popover as PopoverPrimitive } from 'bits-ui';
|
||||
import { cn } from '$lib/utils.js';
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
...restProps
|
||||
}: PopoverPrimitive.TriggerProps = $props();
|
||||
</script>
|
||||
|
||||
<PopoverPrimitive.Trigger
|
||||
bind:ref
|
||||
data-slot="popover-trigger"
|
||||
class={cn('', className)}
|
||||
{...restProps}
|
||||
/>
|
||||
@@ -0,0 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { Popover as PopoverPrimitive } from 'bits-ui';
|
||||
|
||||
let { open = $bindable(false), ...restProps }: PopoverPrimitive.RootProps = $props();
|
||||
</script>
|
||||
|
||||
<PopoverPrimitive.Root bind:open {...restProps} />
|
||||
@@ -0,0 +1,7 @@
|
||||
import Root from './separator.svelte';
|
||||
|
||||
export {
|
||||
Root,
|
||||
//
|
||||
Root as Separator
|
||||
};
|
||||
@@ -0,0 +1,23 @@
|
||||
<script lang="ts">
|
||||
import { Separator as SeparatorPrimitive } from 'bits-ui';
|
||||
import { cn } from '$lib/utils.js';
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
'data-slot': dataSlot = 'separator',
|
||||
...restProps
|
||||
}: SeparatorPrimitive.RootProps = $props();
|
||||
</script>
|
||||
|
||||
<SeparatorPrimitive.Root
|
||||
bind:ref
|
||||
data-slot={dataSlot}
|
||||
class={cn(
|
||||
'shrink-0 bg-border data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:w-px',
|
||||
// this is different in shadcn/ui but self-stretch breaks things for us
|
||||
'data-[orientation=vertical]:h-full',
|
||||
className
|
||||
)}
|
||||
{...restProps}
|
||||
/>
|
||||
@@ -0,0 +1,7 @@
|
||||
import Root from './slider.svelte';
|
||||
|
||||
export {
|
||||
Root,
|
||||
//
|
||||
Root as Slider
|
||||
};
|
||||
@@ -0,0 +1,57 @@
|
||||
<script lang="ts">
|
||||
import { Slider as SliderPrimitive } from 'bits-ui';
|
||||
import { cn, type WithoutChildrenOrChild } from '$lib/utils.js';
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
value = $bindable(),
|
||||
orientation = 'horizontal',
|
||||
thumbId = undefined,
|
||||
thumbAriaLabel = undefined,
|
||||
class: className,
|
||||
...restProps
|
||||
}: WithoutChildrenOrChild<SliderPrimitive.RootProps> & {
|
||||
thumbId?: string | undefined;
|
||||
thumbAriaLabel?: string | undefined;
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
<!--
|
||||
Discriminated Unions + Destructing (required for bindable) do not
|
||||
get along, so we shut typescript up by casting `value` to `never`.
|
||||
-->
|
||||
<SliderPrimitive.Root
|
||||
bind:ref
|
||||
bind:value={value as never}
|
||||
data-slot="slider"
|
||||
{orientation}
|
||||
class={cn(
|
||||
'data-vertical:min-h-40 relative flex w-full touch-none items-center select-none data-disabled:opacity-50 data-vertical:h-full data-vertical:w-auto data-vertical:flex-col',
|
||||
className
|
||||
)}
|
||||
{...restProps}
|
||||
>
|
||||
{#snippet children({ thumbItems })}
|
||||
<span
|
||||
data-slot="slider-track"
|
||||
data-orientation={orientation}
|
||||
class={cn(
|
||||
'rounded-full bg-muted data-horizontal:h-1 data-horizontal:w-full data-vertical:h-full data-vertical:w-1 relative grow overflow-hidden bg-muted data-horizontal:w-full data-vertical:h-full'
|
||||
)}
|
||||
>
|
||||
<SliderPrimitive.Range
|
||||
data-slot="slider-range"
|
||||
class={cn('bg-primary absolute select-none data-horizontal:h-full data-vertical:w-full')}
|
||||
/>
|
||||
</span>
|
||||
{#each thumbItems as thumb (thumb.index)}
|
||||
<SliderPrimitive.Thumb
|
||||
data-slot="slider-thumb"
|
||||
index={thumb.index}
|
||||
id={thumbId}
|
||||
aria-label={thumbAriaLabel}
|
||||
class="relative size-3 rounded-full border border-ring bg-white ring-ring/50 transition-[color,box-shadow] after:absolute after:-inset-2 hover:ring-3 focus-visible:ring-3 focus-visible:outline-hidden active:ring-3 block shrink-0 select-none disabled:pointer-events-none disabled:opacity-50"
|
||||
/>
|
||||
{/each}
|
||||
{/snippet}
|
||||
</SliderPrimitive.Root>
|
||||
@@ -0,0 +1,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();
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
};
|
||||
@@ -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();
|
||||
};
|
||||
@@ -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
|
||||
);
|
||||
};
|
||||
@@ -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();
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
let pendingZipFiles: File[] = [];
|
||||
|
||||
export const queueZipFiles = (files: File[]) => {
|
||||
pendingZipFiles = [...pendingZipFiles, ...files];
|
||||
};
|
||||
|
||||
export const takeQueuedZipFiles = () => {
|
||||
const files = pendingZipFiles;
|
||||
pendingZipFiles = [];
|
||||
return files;
|
||||
};
|
||||
@@ -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;
|
||||
@@ -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
|
||||
};
|
||||
};
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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'
|
||||
}
|
||||
];
|
||||
@@ -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 };
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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());
|
||||
};
|
||||
+57
-43
@@ -1,51 +1,65 @@
|
||||
<script lang="ts">
|
||||
import './layout.css';
|
||||
import favicon from '$lib/assets/favicon.svg';
|
||||
import './layout.css';
|
||||
import favicon from '$lib/assets/favicon.svg';
|
||||
|
||||
let {children} = $props();
|
||||
let { children } = $props();
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<link rel="icon" href={favicon}/>
|
||||
<link rel="icon" href={favicon} />
|
||||
</svelte:head>
|
||||
<header style="height:72px" class="bg-white shadow grid place-content-center">
|
||||
<a href="/">
|
||||
<img height="33px" src="/logo.svg" alt="logo"/>
|
||||
</a>
|
||||
</header>
|
||||
{@render children()}
|
||||
<div class="flex min-h-dvh flex-col">
|
||||
<header style="height:72px" class="grid shrink-0 place-content-center bg-white shadow">
|
||||
<a href="/">
|
||||
<img height="33px" src="/logo.svg" alt="logo" />
|
||||
</a>
|
||||
</header>
|
||||
|
||||
<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"
|
||||
style="background-image: url('/footer.svg');background-position: top right; background-size: cover; background-repeat: no-repeat">
|
||||
<div class="w-full max-w-360 mx-auto">
|
||||
<h2 class="font-bold mb-4">Nominandum GmbH</h2>
|
||||
<div class="flex-1">
|
||||
{@render children()}
|
||||
</div>
|
||||
|
||||
<div class="flex flex-row flex-wrap gap-15 text-[14px] leading-5 mx-auto">
|
||||
<div class="not-sm:hidden">
|
||||
Holstenwall 1<br/>
|
||||
20355 Hamburg<br/>
|
||||
<br/>
|
||||
info[at]nom.legal
|
||||
</div>
|
||||
<div class="not-sm:hidden">
|
||||
Geschäftsführer Andreas Damke <br/>
|
||||
AG Hamburg <br/>
|
||||
HRB 161778<br/>
|
||||
UstId. DE321802543
|
||||
</div>
|
||||
<div>
|
||||
<a class="underline" href="">Impressum</a><br/>
|
||||
<a class="underline" href="">Datenschutzerklärung</a><br/>
|
||||
<a class="underline" href="">Nutzungsbedingungen</a><br/>
|
||||
</div>
|
||||
<div class="grow">
|
||||
<a class="underline" href="">Instagram</a><br/>
|
||||
<a class="underline" href="">LinkedIn</a><br/>
|
||||
</div>
|
||||
<div class="self-end text-gray-400">
|
||||
© 2018-{new Date().getFullYear()} all rights reserved.<br/>
|
||||
Nominandum is a protected trademark.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
<footer
|
||||
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"
|
||||
style="background-image: url('/footer.svg');background-position: top right; background-size: cover; background-repeat: no-repeat"
|
||||
>
|
||||
<div class="mx-auto w-full max-w-360">
|
||||
<h2 class="mb-4 hidden font-bold">Nominandum GmbH</h2>
|
||||
|
||||
<div class="mx-auto flex flex-row flex-wrap gap-15 text-[14px] leading-5">
|
||||
<div>
|
||||
<a class="underline" href="/impressum">Impressum</a><br />
|
||||
<a class="underline" href="/datenschutz">Datenschutzerklärung</a><br />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mx-auto hidden flex-row flex-wrap gap-15 text-[14px] leading-5">
|
||||
<div class="not-sm:hidden">
|
||||
Holstenwall 1<br />
|
||||
20355 Hamburg<br />
|
||||
<br />
|
||||
info[at]nom.legal
|
||||
</div>
|
||||
<div class="not-sm:hidden">
|
||||
Geschäftsführer Andreas Damke <br />
|
||||
AG Hamburg <br />
|
||||
HRB 161778<br />
|
||||
UstId. DE321802543
|
||||
</div>
|
||||
<div>
|
||||
<a class="underline" href="/impressum">Impressum</a><br />
|
||||
<a class="underline" href="/datenschutz">Datenschutzerklärung</a><br />
|
||||
<a class="underline" href="">Nutzungsbedingungen</a><br />
|
||||
</div>
|
||||
<div class="grow">
|
||||
<a class="underline" href="">Instagram</a><br />
|
||||
<a class="underline" href="">LinkedIn</a><br />
|
||||
</div>
|
||||
<div class="self-end text-gray-400">
|
||||
© 2018-{new Date().getFullYear()} all rights reserved.<br />
|
||||
Nominandum is a protected trademark.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
|
||||
+113
-28
@@ -1,35 +1,120 @@
|
||||
<script lang="ts">
|
||||
import Card from "$lib/components/Card.svelte";
|
||||
import ZipDropzone from "$lib/components/ZipDropzone.svelte";
|
||||
import {
|
||||
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[]) => {
|
||||
selectedZipFiles = [...selectedZipFiles, ...files];
|
||||
};
|
||||
const isZipFile = (file: File) =>
|
||||
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>
|
||||
|
||||
{#snippet content()}
|
||||
{#if selectedZipFiles.length === 0}
|
||||
<ZipDropzone onFilesSelected={handleFilesSelected} />
|
||||
{:else}
|
||||
<div class="flex min-h-[calc(-180px+100vh)] w-full grow flex-col justify-center gap-4 px-4 py-6">
|
||||
<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>
|
||||
<svelte:head>
|
||||
<title>beA-Edit — Werkzeugkasten für das besondere elektronische Anwaltspostfach</title>
|
||||
</svelte:head>
|
||||
|
||||
<ul class="mx-auto w-full max-w-3xl space-y-2">
|
||||
{#each selectedZipFiles as file}
|
||||
<li class="rounded-container bg-primary-50 px-4 py-3 text-[14px] font-medium text-primary-900">
|
||||
{file.name}
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</div>
|
||||
{/if}
|
||||
{/snippet}
|
||||
<svelte:window ondragover={handleWindowDragOver} ondrop={handleWindowDrop} />
|
||||
|
||||
<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>
|
||||
<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>
|
||||
|
||||
<!-- ZIP entry point -->
|
||||
<div class="mx-auto w-full max-w-2xl">
|
||||
<FileDropzone
|
||||
onFilesSelected={openBeaFiles}
|
||||
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}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,414 @@
|
||||
<script>
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Datenschutzerklärung | Nominandum</title>
|
||||
<meta
|
||||
name="description"
|
||||
content="Informationen zum Datenschutz und zur Verarbeitung personenbezogener Daten bei Nominandum."
|
||||
/>
|
||||
</svelte:head>
|
||||
|
||||
<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 bg-[#244272]">
|
||||
<div class="mx-auto max-w-7xl px-4 py-12 sm:px-8 sm:py-16 lg:py-20">
|
||||
<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"
|
||||
href="/"
|
||||
aria-label="Zurück zur Startseite"
|
||||
>
|
||||
<span aria-hidden="true">←</span>
|
||||
Startseite
|
||||
</a>
|
||||
<p class="mb-3 text-xs font-extrabold tracking-[0.16em] text-tertiary-300 uppercase">
|
||||
Rechtliches · Datenschutz
|
||||
</p>
|
||||
<h1
|
||||
id="page-title"
|
||||
class="max-w-4xl text-4xl leading-none font-extrabold tracking-tight text-white sm:text-6xl lg:text-7xl"
|
||||
>
|
||||
Datenschutz­erklärung
|
||||
</h1>
|
||||
<p class="mt-6 max-w-2xl text-lg leading-relaxed text-primary-100 sm:text-xl">
|
||||
Was wir erfassen, warum wir es verarbeiten und welche Rechte Sie dabei haben.
|
||||
</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div
|
||||
class="mx-auto grid max-w-7xl gap-8 px-4 py-8 sm:px-8 sm:py-12 lg:grid-cols-[14rem_minmax(0,1fr)] lg:gap-12 lg:py-16"
|
||||
>
|
||||
<aside class="hidden lg:block" aria-label="Inhaltsverzeichnis">
|
||||
<div class="sticky top-8">
|
||||
<p class="mb-4 text-xs font-extrabold tracking-[0.16em] text-primary-700 uppercase">
|
||||
Inhalt
|
||||
</p>
|
||||
<nav
|
||||
class="flex flex-col border-l border-primary-200 [&_a]:grid [&_a]:grid-cols-[2rem_1fr] [&_a]:gap-2 [&_a]:border-l-2 [&_a]:border-transparent [&_a]:px-4 [&_a]:py-2.5 [&_a]:text-sm [&_a]:font-bold [&_a]:text-surface-600 [&_a]:transition-colors [&_a:hover]:border-tertiary-500 [&_a:hover]:text-primary-900 [&_span]:font-extrabold [&_span]:text-primary-400"
|
||||
>
|
||||
<a href="#ueberblick"><span>01</span>Auf einen Blick</a>
|
||||
<a href="#hosting"><span>02</span>Hosting</a>
|
||||
<a href="#pflichtinformationen"><span>03</span>Pflichtinformationen</a>
|
||||
<a href="#datenerfassung"><span>04</span>Datenerfassung</a>
|
||||
</nav>
|
||||
<p class="mt-6 text-xs leading-relaxed text-surface-500">Vier Kapitel</p>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<article aria-labelledby="page-title">
|
||||
<div
|
||||
class="[&_address]:my-6 [&_address]:w-fit [&_address]:rounded-r-xl [&_address]:border-l-4 [&_address]:border-tertiary-400 [&_address]:bg-primary-50 [&_address]:px-5 [&_address]:py-4 [&_address]:not-italic [&_address]:leading-relaxed [&_h2]:mb-8 [&_h2]:text-2xl [&_h2]:leading-tight [&_h2]:font-extrabold [&_h2]:tracking-tight [&_h2]:text-primary-900 sm:[&_h2]:text-3xl [&_h3]:mt-10 [&_h3]:mb-3 [&_h3]:text-xl [&_h3]:leading-snug [&_h3]:font-extrabold [&_h3]:text-primary-800 [&_h4]:mt-6 [&_h4]:mb-2 [&_h4]:font-extrabold [&_h4]:text-surface-900 [&_li]:leading-relaxed [&_p]:leading-[1.75] [&_p+p]:mt-4 [&_section]:scroll-mt-6 [&_section]:rounded-container [&_section]:border [&_section]:border-primary-100 [&_section]:bg-white [&_section]:p-6 [&_section]:shadow-sm sm:[&_section]:p-10 lg:[&_section]:p-14 [&_section+section]:mt-6 [&_ul]:my-5 [&_ul]:grid [&_ul]:list-disc [&_ul]:gap-2 [&_ul]:pl-6 [&_li::marker]:text-tertiary-600 [&_.legal-notice]:my-6 [&_.legal-notice]:rounded-xl [&_.legal-notice]:border [&_.legal-notice]:border-primary-200 [&_.legal-notice]:bg-primary-50 [&_.legal-notice]:p-5 sm:[&_.legal-notice]:p-7 [&_.legal-notice_p]:text-sm [&_.legal-notice_p]:font-bold [&_.legal-notice_p]:tracking-wide [&_.legal-notice_p]:text-primary-950 [&_.source]:mt-8 [&_.source]:flex [&_.source]:items-center [&_.source]:gap-3 [&_.source]:rounded-container [&_.source]:border [&_.source]:border-primary-100 [&_.source]:bg-white [&_.source]:px-6 [&_.source]:py-4 [&_.source]:text-sm [&_.source]:text-surface-500 [&_.source_span]:font-extrabold [&_.source_span]:tracking-wider [&_.source_span]:text-primary-700 [&_.source_span]:uppercase [&_.source_a]:font-bold [&_.source_a]:text-primary-700 [&_.source_a]:underline [&_.source_a]:underline-offset-4"
|
||||
>
|
||||
<section id="ueberblick" data-section="01">
|
||||
<h2>1. Datenschutz auf einen Blick</h2>
|
||||
<h3>Allgemeine Hinweise</h3>
|
||||
<p>
|
||||
Die folgenden Hinweise geben einen einfachen Überblick darüber, was mit Ihren
|
||||
personenbezogenen Daten passiert, wenn Sie diese Website besuchen. Personenbezogene
|
||||
Daten sind alle Daten, mit denen Sie persönlich identifiziert werden können.
|
||||
Ausführliche Informationen zum Thema Datenschutz entnehmen Sie unserer unter diesem Text
|
||||
aufgeführten Datenschutzerklärung.
|
||||
</p>
|
||||
|
||||
<h3>Datenerfassung auf dieser Website</h3>
|
||||
<h4>Wer ist verantwortlich für die Datenerfassung auf dieser Website?</h4>
|
||||
<p>
|
||||
Die Datenverarbeitung auf dieser Website erfolgt durch den Websitebetreiber. Dessen
|
||||
Kontaktdaten können Sie dem Abschnitt „Hinweis zur Verantwortlichen Stelle“ in dieser
|
||||
Datenschutzerklärung entnehmen.
|
||||
</p>
|
||||
|
||||
<h4>Wie erfassen wir Ihre Daten?</h4>
|
||||
<p>
|
||||
Ihre Daten werden zum einen dadurch erhoben, dass Sie uns diese mitteilen. Hierbei kann
|
||||
es sich z. B. um Daten handeln, die Sie in ein Kontaktformular eingeben.
|
||||
</p>
|
||||
<p>
|
||||
Andere Daten werden automatisch oder nach Ihrer Einwilligung beim Besuch der Website
|
||||
durch unsere IT-Systeme erfasst. Das sind vor allem technische Daten (z. B.
|
||||
Internetbrowser, Betriebssystem oder Uhrzeit des Seitenaufrufs). Die Erfassung dieser
|
||||
Daten erfolgt automatisch, sobald Sie diese Website betreten.
|
||||
</p>
|
||||
|
||||
<h4>Wofür nutzen wir Ihre Daten?</h4>
|
||||
<p>
|
||||
Ein Teil der Daten wird erhoben, um eine fehlerfreie Bereitstellung der Website zu
|
||||
gewährleisten. Andere Daten können zur Analyse Ihres Nutzerverhaltens verwendet werden.
|
||||
Sofern über die Website Verträge geschlossen oder angebahnt werden können, werden die
|
||||
übermittelten Daten auch für Vertragsangebote, Bestellungen oder sonstige
|
||||
Auftragsanfragen verarbeitet.
|
||||
</p>
|
||||
|
||||
<h4>Welche Rechte haben Sie bezüglich Ihrer Daten?</h4>
|
||||
<p>
|
||||
Sie haben jederzeit das Recht, unentgeltlich Auskunft über Herkunft, Empfänger und Zweck
|
||||
Ihrer gespeicherten personenbezogenen Daten zu erhalten. Sie haben außerdem ein Recht,
|
||||
die Berichtigung oder Löschung dieser Daten zu verlangen. Wenn Sie eine Einwilligung zur
|
||||
Datenverarbeitung erteilt haben, können Sie diese Einwilligung jederzeit für die Zukunft
|
||||
widerrufen. Außerdem haben Sie das Recht, unter bestimmten Umständen die Einschränkung
|
||||
der Verarbeitung Ihrer personenbezogenen Daten zu verlangen. Des Weiteren steht Ihnen
|
||||
ein Beschwerderecht bei der zuständigen Aufsichtsbehörde zu.
|
||||
</p>
|
||||
<p>
|
||||
Hierzu sowie zu weiteren Fragen zum Thema Datenschutz können Sie sich jederzeit an uns
|
||||
wenden.
|
||||
</p>
|
||||
|
||||
<h3>Analyse-Tools und Tools von Drittanbietern</h3>
|
||||
<p>
|
||||
Beim Besuch dieser Website kann Ihr Surf-Verhalten statistisch ausgewertet werden. Das
|
||||
geschieht vor allem mit sogenannten Analyseprogrammen.
|
||||
</p>
|
||||
<p>
|
||||
Detaillierte Informationen zu diesen Analyseprogrammen finden Sie in der folgenden
|
||||
Datenschutzerklärung.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section id="hosting" data-section="02">
|
||||
<h2>2. Hosting</h2>
|
||||
<p>Wir hosten die Inhalte unserer Website bei folgendem Anbieter:</p>
|
||||
|
||||
<h3>Externes Hosting</h3>
|
||||
<p>
|
||||
Diese Website wird extern gehostet. Die personenbezogenen Daten, die auf dieser Website
|
||||
erfasst werden, werden auf den Servern des Hosters / der Hoster gespeichert. Hierbei
|
||||
kann es sich v. a. um IP-Adressen, Kontaktanfragen, Meta- und Kommunikationsdaten,
|
||||
Vertragsdaten, Kontaktdaten, Namen, Websitezugriffe und sonstige Daten, die über eine
|
||||
Website generiert werden, handeln.
|
||||
</p>
|
||||
<p>
|
||||
Das externe Hosting erfolgt zum Zwecke der Vertragserfüllung gegenüber unseren
|
||||
potenziellen und bestehenden Kunden (Art. 6 Abs. 1 lit. b DSGVO) und im Interesse einer
|
||||
sicheren, schnellen und effizienten Bereitstellung unseres Online-Angebots durch einen
|
||||
professionellen Anbieter (Art. 6 Abs. 1 lit. f DSGVO). Sofern eine entsprechende
|
||||
Einwilligung abgefragt wurde, erfolgt die Verarbeitung ausschließlich auf Grundlage von
|
||||
Art. 6 Abs. 1 lit. a DSGVO und § 25 Abs. 1 TDDDG, soweit die Einwilligung die
|
||||
Speicherung von Cookies oder den Zugriff auf Informationen im Endgerät des Nutzers (z.
|
||||
B. Device-Fingerprinting) im Sinne des TDDDG umfasst. Die Einwilligung ist jederzeit
|
||||
widerrufbar.
|
||||
</p>
|
||||
<p>
|
||||
Unser(e) Hoster wird bzw. werden Ihre Daten nur insoweit verarbeiten, wie dies zur
|
||||
Erfüllung seiner Leistungspflichten erforderlich ist und unsere Weisungen in Bezug auf
|
||||
diese Daten befolgen.
|
||||
</p>
|
||||
<p>Wir setzen folgende(n) Hoster ein:</p>
|
||||
|
||||
<address>
|
||||
netcup GmbH<br />
|
||||
Emmy-Noether-Str. 10<br />
|
||||
76131 Karlsruhe
|
||||
</address>
|
||||
|
||||
<h3>Auftragsverarbeitung</h3>
|
||||
<p>
|
||||
Wir haben einen Vertrag über Auftragsverarbeitung (AVV) zur Nutzung des oben genannten
|
||||
Dienstes geschlossen. Hierbei handelt es sich um einen datenschutzrechtlich
|
||||
vorgeschriebenen Vertrag, der gewährleistet, dass dieser die personenbezogenen Daten
|
||||
unserer Websitebesucher nur nach unseren Weisungen und unter Einhaltung der DSGVO
|
||||
verarbeitet.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section id="pflichtinformationen" data-section="03">
|
||||
<h2>3. Allgemeine Hinweise und Pflichtinformationen</h2>
|
||||
<h3>Datenschutz</h3>
|
||||
<p>
|
||||
Die Betreiber dieser Seiten nehmen den Schutz Ihrer persönlichen Daten sehr ernst. Wir
|
||||
behandeln Ihre personenbezogenen Daten vertraulich und entsprechend den gesetzlichen
|
||||
Datenschutzvorschriften sowie dieser Datenschutzerklärung.
|
||||
</p>
|
||||
<p>
|
||||
Wenn Sie diese Website benutzen, werden verschiedene personenbezogene Daten erhoben.
|
||||
Personenbezogene Daten sind Daten, mit denen Sie persönlich identifiziert werden können.
|
||||
Die vorliegende Datenschutzerklärung erläutert, welche Daten wir erheben und wofür wir
|
||||
sie nutzen. Sie erläutert auch, wie und zu welchem Zweck das geschieht.
|
||||
</p>
|
||||
<p>
|
||||
Wir weisen darauf hin, dass die Datenübertragung im Internet (z. B. bei der
|
||||
Kommunikation per E-Mail) Sicherheitslücken aufweisen kann. Ein lückenloser Schutz der
|
||||
Daten vor dem Zugriff durch Dritte ist nicht möglich.
|
||||
</p>
|
||||
|
||||
<h3>Hinweis zur verantwortlichen Stelle</h3>
|
||||
<p>Die verantwortliche Stelle für die Datenverarbeitung auf dieser Website ist:</p>
|
||||
|
||||
<address>
|
||||
Robin Ehlert<br />
|
||||
Am Frohnacker 3<br />
|
||||
76829 Landau in der Pfalz<br /><br />
|
||||
Telefon: +49 (0) 1782877207<br />
|
||||
E-Mail: post@rehlert.de
|
||||
</address>
|
||||
|
||||
<p>
|
||||
Verantwortliche Stelle ist die natürliche oder juristische Person, die allein oder
|
||||
gemeinsam mit anderen über die Zwecke und Mittel der Verarbeitung von personenbezogenen
|
||||
Daten (z. B. Namen, E-Mail-Adressen o. Ä.) entscheidet.
|
||||
</p>
|
||||
|
||||
<h3>Speicherdauer</h3>
|
||||
<p>
|
||||
Soweit innerhalb dieser Datenschutzerklärung keine speziellere Speicherdauer genannt
|
||||
wurde, verbleiben Ihre personenbezogenen Daten bei uns, bis der Zweck für die
|
||||
Datenverarbeitung entfällt. Wenn Sie ein berechtigtes Löschersuchen geltend machen oder
|
||||
eine Einwilligung zur Datenverarbeitung widerrufen, werden Ihre Daten gelöscht, sofern
|
||||
wir keine anderen rechtlich zulässigen Gründe für die Speicherung Ihrer
|
||||
personenbezogenen Daten haben (z. B. steuer- oder handelsrechtliche
|
||||
Aufbewahrungsfristen); im letztgenannten Fall erfolgt die Löschung nach Fortfall dieser
|
||||
Gründe.
|
||||
</p>
|
||||
|
||||
<h3>
|
||||
Allgemeine Hinweise zu den Rechtsgrundlagen der Datenverarbeitung auf dieser Website
|
||||
</h3>
|
||||
<p>
|
||||
Sofern Sie in die Datenverarbeitung eingewilligt haben, verarbeiten wir Ihre
|
||||
personenbezogenen Daten auf Grundlage von Art. 6 Abs. 1 lit. a DSGVO bzw. Art. 9 Abs. 2
|
||||
lit. a DSGVO, sofern besondere Datenkategorien nach Art. 9 Abs. 1 DSGVO verarbeitet
|
||||
werden. Im Falle einer ausdrücklichen Einwilligung in die Übertragung personenbezogener
|
||||
Daten in Drittstaaten erfolgt die Datenverarbeitung außerdem auf Grundlage von Art. 49
|
||||
Abs. 1 lit. a DSGVO. Sofern Sie in die Speicherung von Cookies oder in den Zugriff auf
|
||||
Informationen in Ihr Endgerät (z. B. via Device-Fingerprinting) eingewilligt haben,
|
||||
erfolgt die Datenverarbeitung zusätzlich auf Grundlage von § 25 Abs. 1 TDDDG. Die
|
||||
Einwilligung ist jederzeit widerrufbar. Sind Ihre Daten zur Vertragserfüllung oder zur
|
||||
Durchführung vorvertraglicher Maßnahmen erforderlich, verarbeiten wir Ihre Daten auf
|
||||
Grundlage des Art. 6 Abs. 1 lit. b DSGVO. Des Weiteren verarbeiten wir Ihre Daten,
|
||||
sofern diese zur Erfüllung einer rechtlichen Verpflichtung erforderlich sind auf
|
||||
Grundlage von Art. 6 Abs. 1 lit. c DSGVO. Die Datenverarbeitung kann ferner auf
|
||||
Grundlage unseres berechtigten Interesses nach Art. 6 Abs. 1 lit. f DSGVO erfolgen. Über
|
||||
die jeweils im Einzelfall einschlägigen Rechtsgrundlagen wird in den folgenden Absätzen
|
||||
dieser Datenschutzerklärung informiert.
|
||||
</p>
|
||||
|
||||
<h3>Empfänger von personenbezogenen Daten</h3>
|
||||
<p>
|
||||
Im Rahmen unserer Geschäftstätigkeit arbeiten wir mit verschiedenen externen Stellen
|
||||
zusammen. Dabei ist teilweise auch eine Übermittlung von personenbezogenen Daten an
|
||||
diese externen Stellen erforderlich. Wir geben personenbezogene Daten nur dann an
|
||||
externe Stellen weiter, wenn dies im Rahmen einer Vertragserfüllung erforderlich ist,
|
||||
wenn wir gesetzlich hierzu verpflichtet sind (z. B. Weitergabe von Daten an
|
||||
Steuerbehörden), wenn wir ein berechtigtes Interesse nach Art. 6 Abs. 1 lit. f DSGVO an
|
||||
der Weitergabe haben oder wenn eine sonstige Rechtsgrundlage die Datenweitergabe
|
||||
erlaubt. Beim Einsatz von Auftragsverarbeitern geben wir personenbezogene Daten unserer
|
||||
Kunden nur auf Grundlage eines gültigen Vertrags über Auftragsverarbeitung weiter. Im
|
||||
Falle einer gemeinsamen Verarbeitung wird ein Vertrag über gemeinsame Verarbeitung
|
||||
geschlossen.
|
||||
</p>
|
||||
|
||||
<h3>Widerruf Ihrer Einwilligung zur Datenverarbeitung</h3>
|
||||
<p>
|
||||
Viele Datenverarbeitungsvorgänge sind nur mit Ihrer ausdrücklichen Einwilligung möglich.
|
||||
Sie können eine bereits erteilte Einwilligung jederzeit widerrufen. Die Rechtmäßigkeit
|
||||
der bis zum Widerruf erfolgten Datenverarbeitung bleibt vom Widerruf unberührt.
|
||||
</p>
|
||||
|
||||
<h3>
|
||||
Widerspruchsrecht gegen die Datenerhebung in besonderen Fällen sowie gegen Direktwerbung
|
||||
(Art. 21 DSGVO)
|
||||
</h3>
|
||||
<div class="legal-notice">
|
||||
<p>
|
||||
WENN DIE DATENVERARBEITUNG AUF GRUNDLAGE VON ART. 6 ABS. 1 LIT. E ODER F DSGVO
|
||||
ERFOLGT, HABEN SIE JEDERZEIT DAS RECHT, AUS GRÜNDEN, DIE SICH AUS IHRER BESONDEREN
|
||||
SITUATION ERGEBEN, GEGEN DIE VERARBEITUNG IHRER PERSONENBEZOGENEN DATEN WIDERSPRUCH
|
||||
EINZULEGEN; DIES GILT AUCH FÜR EIN AUF DIESE BESTIMMUNGEN GESTÜTZTES PROFILING. DIE
|
||||
JEWEILIGE RECHTSGRUNDLAGE, AUF DENEN EINE VERARBEITUNG BERUHT, ENTNEHMEN SIE DIESER
|
||||
DATENSCHUTZERKLÄRUNG. WENN SIE WIDERSPRUCH EINLEGEN, WERDEN WIR IHRE BETROFFENEN
|
||||
PERSONENBEZOGENEN DATEN NICHT MEHR VERARBEITEN, ES SEI DENN, WIR KÖNNEN ZWINGENDE
|
||||
SCHUTZWÜRDIGE GRÜNDE FÜR DIE VERARBEITUNG NACHWEISEN, DIE IHRE INTERESSEN, RECHTE UND
|
||||
FREIHEITEN ÜBERWIEGEN ODER DIE VERARBEITUNG DIENT DER GELTENDMACHUNG, AUSÜBUNG ODER
|
||||
VERTEIDIGUNG VON RECHTSANSPRÜCHEN (WIDERSPRUCH NACH ART. 21 ABS. 1 DSGVO).
|
||||
</p>
|
||||
<p>
|
||||
WERDEN IHRE PERSONENBEZOGENEN DATEN VERARBEITET, UM DIREKTWERBUNG ZU BETREIBEN, SO
|
||||
HABEN SIE DAS RECHT, JEDERZEIT WIDERSPRUCH GEGEN DIE VERARBEITUNG SIE BETREFFENDER
|
||||
PERSONENBEZOGENER DATEN ZUM ZWECKE DERARTIGER WERBUNG EINZULEGEN; DIES GILT AUCH FÜR
|
||||
DAS PROFILING, SOWEIT ES MIT SOLCHER DIREKTWERBUNG IN VERBINDUNG STEHT. WENN SIE
|
||||
WIDERSPRECHEN, WERDEN IHRE PERSONENBEZOGENEN DATEN ANSCHLIESSEND NICHT MEHR ZUM ZWECKE
|
||||
DER DIREKTWERBUNG VERWENDET (WIDERSPRUCH NACH ART. 21 ABS. 2 DSGVO).
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<h3>Beschwerderecht bei der zuständigen Aufsichtsbehörde</h3>
|
||||
<p>
|
||||
Im Falle von Verstößen gegen die DSGVO steht den Betroffenen ein Beschwerderecht bei
|
||||
einer Aufsichtsbehörde, insbesondere in dem Mitgliedstaat ihres gewöhnlichen
|
||||
Aufenthalts, ihres Arbeitsplatzes oder des Orts des mutmaßlichen Verstoßes zu. Das
|
||||
Beschwerderecht besteht unbeschadet anderweitiger verwaltungsrechtlicher oder
|
||||
gerichtlicher Rechtsbehelfe.
|
||||
</p>
|
||||
|
||||
<h3>Recht auf Datenübertragbarkeit</h3>
|
||||
<p>
|
||||
Sie haben das Recht, Daten, die wir auf Grundlage Ihrer Einwilligung oder in Erfüllung
|
||||
eines Vertrags automatisiert verarbeiten, an sich oder an einen Dritten in einem
|
||||
gängigen, maschinenlesbaren Format aushändigen zu lassen. Sofern Sie die direkte
|
||||
Übertragung der Daten an einen anderen Verantwortlichen verlangen, erfolgt dies nur,
|
||||
soweit es technisch machbar ist.
|
||||
</p>
|
||||
|
||||
<h3>Auskunft, Berichtigung und Löschung</h3>
|
||||
<p>
|
||||
Sie haben im Rahmen der geltenden gesetzlichen Bestimmungen jederzeit das Recht auf
|
||||
unentgeltliche Auskunft über Ihre gespeicherten personenbezogenen Daten, deren Herkunft
|
||||
und Empfänger und den Zweck der Datenverarbeitung und ggf. ein Recht auf Berichtigung
|
||||
oder Löschung dieser Daten. Hierzu sowie zu weiteren Fragen zum Thema personenbezogene
|
||||
Daten können Sie sich jederzeit an uns wenden.
|
||||
</p>
|
||||
|
||||
<h3>Recht auf Einschränkung der Verarbeitung</h3>
|
||||
<p>
|
||||
Sie haben das Recht, die Einschränkung der Verarbeitung Ihrer personenbezogenen Daten zu
|
||||
verlangen. Hierzu können Sie sich jederzeit an uns wenden. Das Recht auf Einschränkung
|
||||
der Verarbeitung besteht in folgenden Fällen:
|
||||
</p>
|
||||
<ul>
|
||||
<li>
|
||||
Wenn Sie die Richtigkeit Ihrer bei uns gespeicherten personenbezogenen Daten
|
||||
bestreiten, benötigen wir in der Regel Zeit, um dies zu überprüfen. Für die Dauer der
|
||||
Prüfung haben Sie das Recht, die Einschränkung der Verarbeitung Ihrer
|
||||
personenbezogenen Daten zu verlangen.
|
||||
</li>
|
||||
<li>
|
||||
Wenn die Verarbeitung Ihrer personenbezogenen Daten unrechtmäßig geschah/geschieht,
|
||||
können Sie statt der Löschung die Einschränkung der Datenverarbeitung verlangen.
|
||||
</li>
|
||||
<li>
|
||||
Wenn wir Ihre personenbezogenen Daten nicht mehr benötigen, Sie sie jedoch zur
|
||||
Ausübung, Verteidigung oder Geltendmachung von Rechtsansprüchen benötigen, haben Sie
|
||||
das Recht, statt der Löschung die Einschränkung der Verarbeitung Ihrer
|
||||
personenbezogenen Daten zu verlangen.
|
||||
</li>
|
||||
<li>
|
||||
Wenn Sie einen Widerspruch nach Art. 21 Abs. 1 DSGVO eingelegt haben, muss eine
|
||||
Abwägung zwischen Ihren und unseren Interessen vorgenommen werden. Solange noch nicht
|
||||
feststeht, wessen Interessen überwiegen, haben Sie das Recht, die Einschränkung der
|
||||
Verarbeitung Ihrer personenbezogenen Daten zu verlangen.
|
||||
</li>
|
||||
</ul>
|
||||
<p>
|
||||
Wenn Sie die Verarbeitung Ihrer personenbezogenen Daten eingeschränkt haben, dürfen
|
||||
diese Daten – von ihrer Speicherung abgesehen – nur mit Ihrer Einwilligung oder zur
|
||||
Geltendmachung, Ausübung oder Verteidigung von Rechtsansprüchen oder zum Schutz der
|
||||
Rechte einer anderen natürlichen oder juristischen Person oder aus Gründen eines
|
||||
wichtigen öffentlichen Interesses der Europäischen Union oder eines Mitgliedstaats
|
||||
verarbeitet werden.
|
||||
</p>
|
||||
|
||||
<h3>SSL- bzw. TLS-Verschlüsselung</h3>
|
||||
<p>
|
||||
Diese Seite nutzt aus Sicherheitsgründen und zum Schutz der Übertragung vertraulicher
|
||||
Inhalte, wie zum Beispiel Bestellungen oder Anfragen, die Sie an uns als Seitenbetreiber
|
||||
senden, eine SSL- bzw. TLS-Verschlüsselung. Eine verschlüsselte Verbindung erkennen Sie
|
||||
daran, dass die Adresszeile des Browsers von „http://“ auf „https://“ wechselt und an
|
||||
dem Schloss-Symbol in Ihrer Browserzeile.
|
||||
</p>
|
||||
<p>
|
||||
Wenn die SSL- bzw. TLS-Verschlüsselung aktiviert ist, können die Daten, die Sie an uns
|
||||
übermitteln, nicht von Dritten mitgelesen werden.
|
||||
</p>
|
||||
|
||||
<h3>Widerspruch gegen Werbe-E-Mails</h3>
|
||||
<p>
|
||||
Der Nutzung von im Rahmen der Impressumspflicht veröffentlichten Kontaktdaten zur
|
||||
Übersendung von nicht ausdrücklich angeforderter Werbung und Informationsmaterialien
|
||||
wird hiermit widersprochen. Die Betreiber der Seiten behalten sich ausdrücklich
|
||||
rechtliche Schritte im Falle der unverlangten Zusendung von Werbeinformationen, etwa
|
||||
durch Spam-E-Mails, vor.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section id="datenerfassung" data-section="04">
|
||||
<h2>4. Datenerfassung auf dieser Website</h2>
|
||||
<h3>Server-Log-Dateien</h3>
|
||||
<p>
|
||||
Der Provider der Seiten erhebt und speichert automatisch Informationen in so genannten
|
||||
Server-Log-Dateien, die Ihr Browser automatisch an uns übermittelt. Dies sind:
|
||||
</p>
|
||||
<ul>
|
||||
<li>Browsertyp und Browserversion</li>
|
||||
<li>verwendetes Betriebssystem</li>
|
||||
<li>Referrer URL</li>
|
||||
<li>Hostname des zugreifenden Rechners</li>
|
||||
<li>Uhrzeit der Serveranfrage</li>
|
||||
<li>IP-Adresse</li>
|
||||
</ul>
|
||||
<p>Eine Zusammenführung dieser Daten mit anderen Datenquellen wird nicht vorgenommen.</p>
|
||||
<p>
|
||||
Die Erfassung dieser Daten erfolgt auf Grundlage von Art. 6 Abs. 1 lit. f DSGVO. Der
|
||||
Websitebetreiber hat ein berechtigtes Interesse an der technisch fehlerfreien
|
||||
Darstellung und der Optimierung seiner Website – hierzu müssen die Server-Log-Files
|
||||
erfasst werden.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<p class="source">
|
||||
<span>Quelle</span>
|
||||
<a href="https://www.e-recht24.de">e-recht24.de</a>
|
||||
</p>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
</main>
|
||||
@@ -0,0 +1,97 @@
|
||||
<svelte:head>
|
||||
<title>Impressum | Nominandum</title>
|
||||
<meta name="description" content="Anbieterkennzeichnung und Kontaktdaten von Nominandum." />
|
||||
</svelte:head>
|
||||
|
||||
<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 bg-[#244272]">
|
||||
<div class="mx-auto max-w-7xl px-4 py-12 sm:px-8 sm:py-16 lg:py-20">
|
||||
<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"
|
||||
href="/"
|
||||
aria-label="Zurück zur Startseite"
|
||||
>
|
||||
<span aria-hidden="true">←</span>
|
||||
Startseite
|
||||
</a>
|
||||
<p class="mb-3 text-xs font-extrabold tracking-[0.16em] text-tertiary-300 uppercase">
|
||||
Rechtliches · Anbieterkennzeichnung
|
||||
</p>
|
||||
<h1
|
||||
id="page-title"
|
||||
class="text-4xl leading-none font-extrabold tracking-tight text-white sm:text-6xl lg:text-7xl"
|
||||
>
|
||||
Impressum
|
||||
</h1>
|
||||
<p class="mt-6 max-w-2xl text-lg leading-relaxed text-primary-100 sm:text-xl">
|
||||
Verantwortliche Person, ladungsfähige Anschrift und direkter Kontakt.
|
||||
</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="mx-auto max-w-7xl px-4 py-8 sm:px-8 sm:py-12 lg:py-16">
|
||||
<article
|
||||
class="relative grid overflow-hidden rounded-container border border-primary-100 bg-white shadow-sm md:grid-cols-2"
|
||||
aria-labelledby="page-title"
|
||||
>
|
||||
<div
|
||||
class="absolute top-0 right-6 rounded-b-lg bg-tertiary-400 px-3 py-2 text-xs font-extrabold tracking-[0.14em] text-primary-950"
|
||||
aria-hidden="true"
|
||||
>
|
||||
IMP
|
||||
</div>
|
||||
|
||||
<section class="p-7 sm:p-10 lg:p-14" aria-labelledby="address-heading">
|
||||
<p class="mb-8 text-sm font-extrabold text-primary-400">01</p>
|
||||
<h2
|
||||
id="address-heading"
|
||||
class="text-2xl font-extrabold tracking-tight text-primary-900 sm:text-3xl"
|
||||
>
|
||||
Anschrift
|
||||
</h2>
|
||||
<address class="mt-8 flex flex-col gap-1 text-base leading-relaxed not-italic sm:text-lg">
|
||||
<strong class="mb-2 font-extrabold text-primary-900">Robin Ehlert</strong>
|
||||
<span>Am Frohnacker 3</span>
|
||||
<span>76829 Landau in der Pfalz</span>
|
||||
</address>
|
||||
</section>
|
||||
|
||||
<section
|
||||
class="border-t border-primary-100 bg-primary-50/60 p-7 sm:p-10 md:border-t-0 md:border-l lg:p-14"
|
||||
aria-labelledby="contact-heading"
|
||||
>
|
||||
<p class="mb-8 text-sm font-extrabold text-primary-400">02</p>
|
||||
<h2
|
||||
id="contact-heading"
|
||||
class="text-2xl font-extrabold tracking-tight text-primary-900 sm:text-3xl"
|
||||
>
|
||||
Kontakt
|
||||
</h2>
|
||||
<dl class="mt-8 grid gap-6">
|
||||
<div>
|
||||
<dt class="mb-1 text-xs font-extrabold tracking-wider text-surface-500 uppercase">
|
||||
Telefon
|
||||
</dt>
|
||||
<dd>
|
||||
<a
|
||||
class="rounded-sm text-lg font-bold text-primary-800 underline decoration-primary-300 underline-offset-4 hover:text-primary-600 focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-primary-700"
|
||||
href="tel:+491782877207">+49 (0) 178 2877207</a
|
||||
>
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="mb-1 text-xs font-extrabold tracking-wider text-surface-500 uppercase">
|
||||
E-Mail
|
||||
</dt>
|
||||
<dd>
|
||||
<a
|
||||
class="rounded-sm text-lg font-bold text-primary-800 underline decoration-primary-300 underline-offset-4 hover:text-primary-600 focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-primary-700"
|
||||
href="mailto:jm@rehlert.de">jm@rehlert.de</a
|
||||
>
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</section>
|
||||
</article>
|
||||
</div>
|
||||
</main>
|
||||
+156
-10
@@ -1,16 +1,162 @@
|
||||
@import 'tailwindcss';
|
||||
@import '@skeletonlabs/skeleton/themes/cerberus';
|
||||
@import '@skeletonlabs/skeleton';
|
||||
@import '@skeletonlabs/skeleton-svelte';
|
||||
@import '../lib/nom-theme.css';
|
||||
/*@import '../globals.css';*/
|
||||
@import 'tw-animate-css';
|
||||
@import 'shadcn-svelte/tailwind.css';
|
||||
@import '@fontsource-variable/geist';
|
||||
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
|
||||
@plugin '@tailwindcss/forms';
|
||||
@plugin '@tailwindcss/typography';
|
||||
|
||||
@font-face {
|
||||
font-family: 'Nunito';
|
||||
font-style: normal;
|
||||
font-weight: 100 900;
|
||||
src: url('$lib/assets/Nunito-VariableFont_wght.ttf') format('truetype');
|
||||
}
|
||||
|
||||
@font-face{
|
||||
font-family: 'Nunito';
|
||||
font-style: normal;
|
||||
font-weight: 100 900;
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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
Reference in New Issue
Block a user