Compare commits
22
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
afe5476175 | ||
|
|
3b04039aa9 | ||
|
|
b5279434b2 | ||
|
|
39da8dac4d | ||
|
|
f7fb00e52c | ||
|
|
ca06ca2050 | ||
|
|
f09384d2e6 | ||
|
|
b7969b4c9e | ||
|
|
d71b25f834 | ||
|
|
09cc5c06fb | ||
|
|
ea21fc000f | ||
|
|
57b38fe2c7 | ||
|
|
367f34229c | ||
|
|
dea6a1ba77 | ||
|
|
365c57847c | ||
|
|
2b22fcb0e8 | ||
|
|
037050e1c1 | ||
|
|
1a2673f708 | ||
|
|
adaae41e44 | ||
|
|
891026ccbb | ||
|
|
a450c6f5dd | ||
|
|
47ed80bd24 |
No files matched your search
File renamed without changes.
@@ -7,6 +7,8 @@ node_modules
|
||||
.wrangler
|
||||
/.svelte-kit
|
||||
/build
|
||||
/playwright-report
|
||||
/test-results
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
|
||||
Generated
+65
-216
@@ -3,40 +3,34 @@
|
||||
<component name="AutoImportSettings">
|
||||
<option name="autoReloadType" value="SELECTIVE" />
|
||||
</component>
|
||||
<component name="CMakeProjectFlavorService">
|
||||
<option name="flavorId" value="CMakePlainProjectFlavor" />
|
||||
</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$/agents.md" beforeDir="false" afterPath="$PROJECT_DIR$/agents.md" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/package.json" beforeDir="false" afterPath="$PROJECT_DIR$/package.json" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/pnpm-lock.yaml" beforeDir="false" afterPath="$PROJECT_DIR$/pnpm-lock.yaml" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/src/lib/components/ProcessedZipArchiveEditor.svelte" beforeDir="false" afterPath="$PROJECT_DIR$/src/lib/components/ProcessedZipArchiveEditor.svelte" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/src/lib/zip-processing.ts" beforeDir="false" afterPath="$PROJECT_DIR$/src/lib/zip-processing.ts" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/vite.config.ts" beforeDir="false" afterPath="$PROJECT_DIR$/vite.config.ts" 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="EmbeddingIndexingInfo">
|
||||
<option name="cachedIndexableFilesCount" value="169" />
|
||||
</component>
|
||||
<component name="FileTemplateManagerImpl">
|
||||
<option name="RECENT_TEMPLATES">
|
||||
<list>
|
||||
<option value="CSS File" />
|
||||
<option value="TypeScript File" />
|
||||
<option value="IconSettings" />
|
||||
<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">
|
||||
@@ -46,23 +40,23 @@
|
||||
<component name="MetaFilesCheckinStateConfiguration" checkMetaFiles="true" />
|
||||
<component name="NextEditCompletionFeaturesState">
|
||||
<decayedCancelled>
|
||||
<entry key="MS100" value="1.7534270927402278" />
|
||||
<entry key="MS500" value="3.946749409858449" />
|
||||
<entry key="S2" value="10.803582735333821" />
|
||||
<entry key="S5" value="15.959801828989763" />
|
||||
<entry key="S10" value="18.60984028553002" />
|
||||
<entry key="S30" value="20.774011757305356" />
|
||||
<entry key="S60" value="21.423894438791837" />
|
||||
<entry key="M2" value="22.530930014590602" />
|
||||
<entry key="M5" value="32.256903842215344" />
|
||||
<entry key="M10" value="49.10264974172939" />
|
||||
<entry key="M15" value="59.99418378616175" />
|
||||
<entry key="M30" value="75.77225940331346" />
|
||||
<entry key="H1" value="86.24194464495858" />
|
||||
<entry key="H2" value="92.30215756709606" />
|
||||
<entry key="H4" value="95.56680916983069" />
|
||||
<entry key="D1" value="98.41568550867962" />
|
||||
<entry key="W1" value="98.91622423777348" />
|
||||
<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" />
|
||||
@@ -84,23 +78,23 @@
|
||||
<entry key="W1" value="0.0" />
|
||||
</decayedSelected>
|
||||
<decayedShown>
|
||||
<entry key="MS100" value="1.572917306599136" />
|
||||
<entry key="MS500" value="3.853231737204187" />
|
||||
<entry key="S2" value="10.722306786321894" />
|
||||
<entry key="S5" value="15.90871853935142" />
|
||||
<entry key="S10" value="18.579462450925384" />
|
||||
<entry key="S30" value="20.762563658810205" />
|
||||
<entry key="S60" value="21.41797857399968" />
|
||||
<entry key="M2" value="22.5278009383226" />
|
||||
<entry key="M5" value="32.254976079390616" />
|
||||
<entry key="M10" value="49.101123585535056" />
|
||||
<entry key="M15" value="59.99293373937006" />
|
||||
<entry key="M30" value="75.77147145663449" />
|
||||
<entry key="H1" value="86.24149819888382" />
|
||||
<entry key="H2" value="92.30191941653102" />
|
||||
<entry key="H4" value="95.56668611084393" />
|
||||
<entry key="D1" value="98.41566442286562" />
|
||||
<entry key="W1" value="98.91622121110338" />
|
||||
<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">{
|
||||
@@ -115,31 +109,25 @@
|
||||
<option name="hideEmptyMiddlePackages" value="true" />
|
||||
<option name="showLibraryContents" value="true" />
|
||||
</component>
|
||||
<component name="PropertiesComponent"><![CDATA[{
|
||||
"keyToString": {
|
||||
"ModuleVcsDetector.initialDetectionPerformed": "true",
|
||||
"RunOnceActivity.MCP Project settings loaded": "true",
|
||||
"RunOnceActivity.ShowReadmeOnStart": "true",
|
||||
"RunOnceActivity.TerminalTabsStorage.copyFrom.TerminalArrangementManager.252": "true",
|
||||
"RunOnceActivity.cidr.known.project.marker": "true",
|
||||
"RunOnceActivity.git.unshallow": "true",
|
||||
"RunOnceActivity.readMode.enableVisualFormatting": "true",
|
||||
"RunOnceActivity.typescript.service.memoryLimit.init": "true",
|
||||
"cidr.known.project.marker": "true",
|
||||
"codeWithMe.voiceChat.enabledByDefault": "false",
|
||||
"git-widget-placeholder": "features/shadcn",
|
||||
"last_opened_file_path": "/home/rehlert/repos/pulley",
|
||||
"list.type.of.created.stylesheet": "CSS",
|
||||
"node.js.detected.package.eslint": "true",
|
||||
"node.js.detected.package.tslint": "true",
|
||||
"node.js.selected.package.eslint": "(autodetect)",
|
||||
"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 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="" />
|
||||
@@ -148,18 +136,7 @@
|
||||
<option name="presentableId" value="Default" />
|
||||
<updated>1781169139290</updated>
|
||||
<workItem from="1781169141138" duration="309000" />
|
||||
<workItem from="1781251694220" duration="22281000" />
|
||||
<workItem from="1781349431536" duration="3602000" />
|
||||
<workItem from="1781378743498" duration="306000" />
|
||||
<workItem from="1781534483559" duration="2103000" />
|
||||
<workItem from="1781597500009" duration="2733000" />
|
||||
<workItem from="1781621908149" duration="4463000" />
|
||||
<workItem from="1781626468843" duration="1890000" />
|
||||
<workItem from="1781673504756" duration="1513000" />
|
||||
<workItem from="1781675346348" duration="3860000" />
|
||||
<workItem from="1781797582572" duration="437000" />
|
||||
<workItem from="1781844676215" duration="5049000" />
|
||||
<workItem from="1782804485415" duration="4386000" />
|
||||
<workItem from="1781251694220" duration="5262000" />
|
||||
</task>
|
||||
<task id="LOCAL-00001" summary="adapts styling of rvg.legal">
|
||||
<option name="closed" value="true" />
|
||||
@@ -169,111 +146,7 @@
|
||||
<option name="project" value="LOCAL" />
|
||||
<updated>1781256112218</updated>
|
||||
</task>
|
||||
<task id="LOCAL-00002" summary="adds dropzone to add zips">
|
||||
<option name="closed" value="true" />
|
||||
<created>1781257473070</created>
|
||||
<option name="number" value="00002" />
|
||||
<option name="presentableId" value="LOCAL-00002" />
|
||||
<option name="project" value="LOCAL" />
|
||||
<updated>1781257473070</updated>
|
||||
</task>
|
||||
<task id="LOCAL-00003" summary="zips are getting extracted">
|
||||
<option name="closed" value="true" />
|
||||
<created>1781258303003</created>
|
||||
<option name="number" value="00003" />
|
||||
<option name="presentableId" value="LOCAL-00003" />
|
||||
<option name="project" value="LOCAL" />
|
||||
<updated>1781258303003</updated>
|
||||
</task>
|
||||
<task id="LOCAL-00004" summary="parses xjustiz and orders attachments">
|
||||
<option name="closed" value="true" />
|
||||
<created>1781259433444</created>
|
||||
<option name="number" value="00004" />
|
||||
<option name="presentableId" value="LOCAL-00004" />
|
||||
<option name="project" value="LOCAL" />
|
||||
<updated>1781259433444</updated>
|
||||
</task>
|
||||
<task id="LOCAL-00005" summary="renders preview of zips">
|
||||
<option name="closed" value="true" />
|
||||
<created>1781260304085</created>
|
||||
<option name="number" value="00005" />
|
||||
<option name="presentableId" value="LOCAL-00005" />
|
||||
<option name="project" value="LOCAL" />
|
||||
<updated>1781260304085</updated>
|
||||
</task>
|
||||
<task id="LOCAL-00006" summary="design anpassungen">
|
||||
<option name="closed" value="true" />
|
||||
<created>1781261986439</created>
|
||||
<option name="number" value="00006" />
|
||||
<option name="presentableId" value="LOCAL-00006" />
|
||||
<option name="project" value="LOCAL" />
|
||||
<updated>1781261986439</updated>
|
||||
</task>
|
||||
<task id="LOCAL-00007" summary="renders thumbnails">
|
||||
<option name="closed" value="true" />
|
||||
<created>1781263980882</created>
|
||||
<option name="number" value="00007" />
|
||||
<option name="presentableId" value="LOCAL-00007" />
|
||||
<option name="project" value="LOCAL" />
|
||||
<updated>1781263980882</updated>
|
||||
</task>
|
||||
<task id="LOCAL-00008" summary="ignores mac special folders">
|
||||
<option name="closed" value="true" />
|
||||
<created>1781264183729</created>
|
||||
<option name="number" value="00008" />
|
||||
<option name="presentableId" value="LOCAL-00008" />
|
||||
<option name="project" value="LOCAL" />
|
||||
<updated>1781264183729</updated>
|
||||
</task>
|
||||
<task id="LOCAL-00009" summary="handles zips in zip correctly">
|
||||
<option name="closed" value="true" />
|
||||
<created>1781264568851</created>
|
||||
<option name="number" value="00009" />
|
||||
<option name="presentableId" value="LOCAL-00009" />
|
||||
<option name="project" value="LOCAL" />
|
||||
<updated>1781264568851</updated>
|
||||
</task>
|
||||
<task id="LOCAL-00010" summary="range settings">
|
||||
<option name="closed" value="true" />
|
||||
<created>1781267754325</created>
|
||||
<option name="number" value="00010" />
|
||||
<option name="presentableId" value="LOCAL-00010" />
|
||||
<option name="project" value="LOCAL" />
|
||||
<updated>1781267754325</updated>
|
||||
</task>
|
||||
<task id="LOCAL-00011" summary="name generation improvement">
|
||||
<option name="closed" value="true" />
|
||||
<created>1781268046478</created>
|
||||
<option name="number" value="00011" />
|
||||
<option name="presentableId" value="LOCAL-00011" />
|
||||
<option name="project" value="LOCAL" />
|
||||
<updated>1781268046478</updated>
|
||||
</task>
|
||||
<task id="LOCAL-00012" summary="The git changes introduce functionality to merge attachments from a processed ZIP archive into a single PDF file. Here is a summary of the changes: ### New Features - PDF Merging: Added the ability to merge multiple attachments (PDFs and images) from a processed ZIP archive into a single PDF document. - Image Conversion: Implemented automatic conversion of non-PNG images (like JPGs) to PNG format before embedding them into the merged PDF. ### File Changes - Dependencies: Added pdf-lib to the project's dependencies. - src/lib/zip-processing.ts: - Added mergeProcessedZipArchive to handle the core logic of creating a merged PDF. - Implemented helpers to embed PDF pages and convert/embed images. - Added toArrayBuffer and convertImageBytesToPng utilities for handling binary data and canvas-based image processing. - src/lib/components/ProcessedZipArchiveEditor.svelte: - Added a "PDF herunterladen" (Download PDF) button. - Added UI states to handle the merging process (loading state, error messages). - Integrated the mergeProcessedZipArchive function with the UI. - src/lib/components/AttachmentPreview.svelte: - Added a fix to ensure attachment.data is treated as a Uint8Array when loading PDF documents.">
|
||||
<option name="closed" value="true" />
|
||||
<created>1781350375505</created>
|
||||
<option name="number" value="00012" />
|
||||
<option name="presentableId" value="LOCAL-00012" />
|
||||
<option name="project" value="LOCAL" />
|
||||
<updated>1781350375505</updated>
|
||||
</task>
|
||||
<task id="LOCAL-00013" summary="adds shadcn, removes skeleton, implements button after styleguide">
|
||||
<option name="closed" value="true" />
|
||||
<created>1781626078662</created>
|
||||
<option name="number" value="00013" />
|
||||
<option name="presentableId" value="LOCAL-00013" />
|
||||
<option name="project" value="LOCAL" />
|
||||
<updated>1781626078662</updated>
|
||||
</task>
|
||||
<task id="LOCAL-00014" summary="adds icons">
|
||||
<option name="closed" value="true" />
|
||||
<created>1781845554523</created>
|
||||
<option name="number" value="00014" />
|
||||
<option name="presentableId" value="LOCAL-00014" />
|
||||
<option name="project" value="LOCAL" />
|
||||
<updated>1781845554523</updated>
|
||||
</task>
|
||||
<option name="localTasksCounter" value="15" />
|
||||
<option name="localTasksCounter" value="2" />
|
||||
<servers />
|
||||
</component>
|
||||
<component name="TypeScriptGeneratedFilesManager">
|
||||
@@ -281,34 +154,10 @@
|
||||
</component>
|
||||
<component name="UnityCheckinConfiguration" checkUnsavedScenes="true" />
|
||||
<component name="UnityProjectConfiguration" hasMinimizedUI="false" />
|
||||
<component name="Vcs.Log.Tabs.Properties">
|
||||
<option name="TAB_STATES">
|
||||
<map>
|
||||
<entry key="MAIN">
|
||||
<value>
|
||||
<State />
|
||||
</value>
|
||||
</entry>
|
||||
</map>
|
||||
</option>
|
||||
</component>
|
||||
<component name="VcsManagerConfiguration">
|
||||
<option name="CLEAR_INITIAL_COMMIT_MESSAGE" value="true" />
|
||||
<MESSAGE value="adapts styling of rvg.legal" />
|
||||
<MESSAGE value="adds dropzone to add zips" />
|
||||
<MESSAGE value="zips are getting extracted" />
|
||||
<MESSAGE value="parses xjustiz and orders attachments" />
|
||||
<MESSAGE value="renders preview of zips" />
|
||||
<MESSAGE value="design anpassungen" />
|
||||
<MESSAGE value="renders thumbnails" />
|
||||
<MESSAGE value="ignores mac special folders" />
|
||||
<MESSAGE value="handles zips in zip correctly" />
|
||||
<MESSAGE value="range settings" />
|
||||
<MESSAGE value="name generation improvement" />
|
||||
<MESSAGE value="The git changes introduce functionality to merge attachments from a processed ZIP archive into a single PDF file. Here is a summary of the changes: ### New Features - PDF Merging: Added the ability to merge multiple attachments (PDFs and images) from a processed ZIP archive into a single PDF document. - Image Conversion: Implemented automatic conversion of non-PNG images (like JPGs) to PNG format before embedding them into the merged PDF. ### File Changes - Dependencies: Added pdf-lib to the project's dependencies. - src/lib/zip-processing.ts: - Added mergeProcessedZipArchive to handle the core logic of creating a merged PDF. - Implemented helpers to embed PDF pages and convert/embed images. - Added toArrayBuffer and convertImageBytesToPng utilities for handling binary data and canvas-based image processing. - src/lib/components/ProcessedZipArchiveEditor.svelte: - Added a "PDF herunterladen" (Download PDF) button. - Added UI states to handle the merging process (loading state, error messages). - Integrated the mergeProcessedZipArchive function with the UI. - src/lib/components/AttachmentPreview.svelte: - Added a fix to ensure attachment.data is treated as a Uint8Array when loading PDF documents." />
|
||||
<MESSAGE value="adds shadcn, removes skeleton, implements button after styleguide" />
|
||||
<MESSAGE value="adds icons" />
|
||||
<option name="LAST_COMMIT_MESSAGE" value="adds icons" />
|
||||
<option name="LAST_COMMIT_MESSAGE" value="adapts styling of rvg.legal" />
|
||||
</component>
|
||||
<component name="XDebuggerManager">
|
||||
<breakpoint-manager>
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
# PWA / Offline-Fähigkeit (plan/pwa-plan.md)
|
||||
|
||||
- [x] Add `@vite-pwa/sveltekit` dev dependency (pnpm, plus explicit `vite-plugin-pwa`/`workbox-window` for pnpm strict resolution)
|
||||
- [x] Generate PWA icons (192 / 512 / maskable / apple-touch) from `static/favicon.svg`
|
||||
- [x] Configure `SvelteKitPWA` in `vite.config.ts` (manifest, workbox glob patterns incl. pdfjs `.mjs` worker, navigateFallback)
|
||||
- [x] Enable prerendering so route documents are precached (`src/routes/+layout.ts`)
|
||||
- [x] Add PWA client type references (`src/app.d.ts`)
|
||||
- [x] Update toast component + `virtual:pwa-info` manifest link in root layout
|
||||
- [x] Add `theme-color` meta + apple-touch-icon
|
||||
- [x] Production-build verification: SW registers, offline document/tool navigation, offline ZIP + merge chain, update toast (defer + activate) — `pnpm test:pwa`
|
||||
- [x] Run `pnpm check` (0 errors), `pnpm build`
|
||||
- [x] Update `agents.md`
|
||||
- [x] `pnpm lint`: 19 formatting failures fixed via `pnpm format` (shadcn-generated
|
||||
`src/lib/components/ui/` and `src/lib/components/icons/`, plus `AttachmentPreview.svelte`;
|
||||
pure reformatting, no logic changes)
|
||||
- [ ] `datenschutz` wording: draft added to section 4 (“Offline-Nutzung als App (Progressive Web
|
||||
App)”) in `src/routes/datenschutz/+page.svelte` — pending user review/approval (D3)
|
||||
@@ -1,93 +1,434 @@
|
||||
# Project notes for agents
|
||||
# Project Agent Guide
|
||||
|
||||
## Project snapshot
|
||||
- **Repo name:** `juri-merger`
|
||||
- **App name/branding:** `beA-Edit`, published by Nominandum GmbH.
|
||||
- **Tech stack:** SvelteKit + Svelte 5 (runes) + TypeScript + Vite + Tailwind v4 (CSS-first).
|
||||
- **UI system:** shadcn-svelte (style `nova`, lucide icons) layered on a custom "nom" theme via CSS variables in `src/routes/layout.css`.
|
||||
- **Core purpose:** upload one or more ZIP files, extract relevant PDFs/images, preview them, optionally reorder attachments, and download a merged PDF.
|
||||
- **Active branch:** `features/shadcn` (shadcn-svelte integration in progress).
|
||||
## Project overview
|
||||
|
||||
## Main user flow
|
||||
1. User adds ZIP files via drag/drop or file picker (`ZipDropzone`).
|
||||
2. `+page.svelte` starts async extraction for each file (tracked via `archiveGeneration` so stale in-flight extractions are discarded on bulk delete).
|
||||
3. Each ZIP is processed into one or more `ProcessedZipArchive` entries.
|
||||
4. `ProcessedZipArchiveEditor` shows thumbnails, allows reordering, and can download the merged PDF.
|
||||
5. `AttachmentPreview` renders image previews directly and PDF previews via `pdfjs-dist`.
|
||||
6. A fixed bottom-right settings drawer (`<details>`) exposes **export all** and **delete all** archive actions plus the thumbnail-width slider.
|
||||
This repository contains **juri-merger**, a German-language SvelteKit application branded as
|
||||
**beA-Edit**. Users add one or more beA ZIP archives or PDF files in the browser, inspect and
|
||||
reorder supported attachments, group attachments into named sub-documents (including page-level
|
||||
PDF splitting), and export merged PDFs.
|
||||
Nested ZIP archives and XJustiz metadata are handled client-side; there is no upload, server-side
|
||||
archive processing, database, analytics, or persistence of case data.
|
||||
|
||||
## Important source files
|
||||
- `src/routes/+page.svelte` — top-level page state, file selection, localStorage-backed thumbnail width, async ZIP processing, bulk export/delete, settings drawer.
|
||||
- `src/routes/+layout.svelte` — app shell: header with logo and footer with Nominandum GmbH legal/imprint block.
|
||||
- `src/routes/layout.css` — Tailwind v4 entrypoint; imports `tw-animate-css`, `shadcn-svelte/tailwind.css`, Geist + Nunito fonts; defines the semantic token layer (`--background`, `--primary`, `--secondary`, `--accent`, `--destructive`, `--radius`, etc.) mapping shadcn tokens onto the nom palette, including a `.dark` override.
|
||||
- `src/lib/zip-processing.ts` — archive extraction, recursive nested ZIP handling, metadata ordering, PDF merge/export, image conversion.
|
||||
- `src/lib/services/zip-inflating.service.ts` — thin wrapper around `fflate` unzip.
|
||||
- `src/lib/services/xml-reading.service.ts` — parses XJustiz XML metadata (`sender`, `receiver`, `documentNames`).
|
||||
- `src/lib/components/ZipDropzone.svelte` — file picker + drag/drop ZIP intake.
|
||||
- `src/lib/components/ProcessedZipArchiveEditor.svelte` — archive editor (name input, drag reordering, per-archive PDF download, selection mode + sub-document management, export-mode switch).
|
||||
- `src/lib/components/SubDocumentEditor.svelte` — sub-document block editor (editable name, count, dissolve action, draggable header for block reorder, internal horizontal reorderable attachment list).
|
||||
- `src/lib/components/AttachmentPreview.svelte` — preview generation for images/PDFs.
|
||||
- `src/lib/components/ui/button.svelte` — shadcn-backed button atom (see "Button system" below).
|
||||
- `src/lib/components/ui/card/` — shadcn card composition (`Root/Content/Header/Title/Description/Footer/Action`, re-exported as `Card.*`).
|
||||
- `src/lib/actions/ripple.ts` — Svelte action that renders a click ripple animation on an element.
|
||||
- `src/lib/components/icons/` — hand-rolled SVG icon components (`IconSettings`, `IconFile`, `IconMenu`, `IconNeedle`, `IconOffice`, `IconPerson`, `IconTag`, `IconTrash`, `IconChain`, `IconUnknown`). If an icon is missing, report it, and we see if we can create
|
||||
- `src/lib/utils.ts` — shared `cn()` helper (clsx + tailwind-merge) and `WithElementRef`/`WithoutChild` typing helpers used by shadcn components.
|
||||
The application also contains German imprint and privacy-policy routes.
|
||||
|
||||
## Button system
|
||||
`src/lib/components/ui/button.svelte` is a shadcn-svelte–style primitive adapted to the nom theme:
|
||||
- Built with `tailwind-variants` (`buttonVariants`) and the shared `cn()` helper.
|
||||
- Variants: `primary`, `secondary` (gradient `secondary → --secondary-end`), `bordered`, `tertiary`, `success`.
|
||||
- Sizes: `standard` (h-8, rounded-16px), `large` (h-12, rounded-24px).
|
||||
- Renders either a `<button>` or an `<a>` (when `href` is set); both apply the `use:ripple` action.
|
||||
- Accepts an optional `icon` prop (a Svelte component) rendered after `children`.
|
||||
- `ButtonProps` merges `HTMLButtonAttributes` + `HTMLAnchorAttributes` with `WithElementRef` and `variant`/`size`/`icon`.
|
||||
- Used across the editor, settings drawer, and the throwaway test buttons on the home `<section>` (still present on the branch).
|
||||
## Technology stack
|
||||
|
||||
## ZIP/PDF processing details
|
||||
- ZIP entries are filtered to ignore macOS metadata (`__MACOSX`, `.DS_Store`, `._*`).
|
||||
- Relevant attachments are PDFs and common image formats.
|
||||
- Nested ZIP archives are processed recursively.
|
||||
- `xjustiz_nachricht.xml` is used to derive archive naming and ordering.
|
||||
- Ordering prefers document names from metadata before falling back to original attachment order.
|
||||
- PDF merging uses `pdf-lib`; images are embedded as PNG/JPG, with browser-side canvas conversion for other image types.
|
||||
- **Sub-documents:** a `ProcessedZipArchive` now has `attachments` ("loose") plus an ordered `subDocuments: SubDocument[]` list. Each `SubDocument` is a named, ordered subset of `ZipAttachment`s that live alongside the loose ones. Sub-documents are created on demand by the user (selection mode in the editor) — when none exist, the archive behaves exactly as before.
|
||||
- The merge loop is shared via `mergeAttachments(attachments)`; `mergeProcessedZipArchive` flattens the archive via `flattenArchiveForMerge` (sub-documents first, then loose) so backwards compatibility holds.
|
||||
- Pure helpers in `zip-processing.ts`: `createSubDocument`, `moveSubDocument`, `moveAttachmentInPlace`, `moveAttachmentIntoSubDocument`, `removeAttachmentFromSubDocument`, `dissolveSubDocument`, `flattenArchiveForMerge`, `buildArchiveExport(archive, 'single' | 'separate')`. Cross-list moves preserve the total attachment count invariant.
|
||||
- **Export modes:** `single` flips the whole archive into one PDF (sub-docs flattened first); `separate` produces one PDF per non-empty sub-document (`{archiveName} - {subName}.pdf`) plus one for any residual loose attachments.
|
||||
- SvelteKit 2 and Svelte 5, with runes mode forced for project source files
|
||||
- TypeScript in strict mode
|
||||
- Vite 8
|
||||
- Tailwind CSS 4 through `@tailwindcss/vite`
|
||||
- shadcn-svelte 1 with the `nova` style and CSS-variable theme tokens
|
||||
- bits-ui, tailwind-variants, clsx, and tailwind-merge
|
||||
- Lucide Svelte icons plus project-specific SVG icon components
|
||||
- fflate for ZIP extraction and image-export ZIP creation
|
||||
- pdf-lib for PDF assembly, `@cantoo/pdf-lib` for encryption, and pdfjs-dist for rendering
|
||||
- `@sveltejs/adapter-node`
|
||||
- PWA/offline support through `@vite-pwa/sveltekit` (Workbox precache, generated web manifest)
|
||||
- pnpm with a committed lockfile
|
||||
- Prettier with Svelte and Tailwind plugins
|
||||
|
||||
## UI/state details
|
||||
- Thumbnail width is stored in `localStorage` under `thumbnailWidth` (key constant `THUMBNAIL_WIDTH_STORAGE_KEY`) and clamped to `100..400`; hydration is gated by a `thumbnailWidthHydrated` flag so the initial render does not fight `onMount`.
|
||||
- Processing progress is shown as a count of pending ZIP files (`pendingZipCount`).
|
||||
- The editor supports manual attachment reordering via drag-and-drop.
|
||||
- The settings drawer contains: "Alle ZIP-Archive als PDF exportieren", "Alle ZIP-Archive löschen", and the thumbnail-width range input.
|
||||
- Bulk delete bumps `archiveGeneration` so otherwise-orphaned async extractions no-op instead of re-populating state.
|
||||
- **Sub-document editing (`ProcessedZipArchiveEditor.svelte`):**
|
||||
- An "Auswählen" / "Fertig" toggle in the header puts the editor into selection mode. Only then are checkboxes rendered on each loose thumbnail (wrapped in a `<label>` for keyboard operability — Tab + Space); in normal mode the editor looks identical to today. `Esc` exits selection mode and clears the selection. Drag-to-reorder is suspended while in selection mode.
|
||||
- An inline action bar with "Teildokument erstellen" appears whenever `selectionMode && selectedPaths.size > 0`. Creating a sub-document removes the selected attachments from the loose list, appends a new `SubDocument` block (named "Teildokument N"), clears the selection, and keeps selection mode on.
|
||||
- Sub-documents render below the loose list as a vertical stack of `SubDocumentEditor` blocks. Each block has an editable name, a count, an "Auflösen" action (returns its attachments to the end of the loose list), a draggable header (`::`) to reorder the block, and its own horizontal reorderable attachment list.
|
||||
- Drag-and-drop is generalised: HTML5 DnD payloads use MIME type `application/x-juri-merger-attachment` (carrying a `AttachmentLocation` of `{ kind: 'loose', index }` or `{ kind: 'subDocument', id, index }`) for attachment moves, and `application/x-juri-merger-block` for reordering whole sub-document blocks. Supported moves: loose↔loose, loose→sub, sub→loose, sub attachment↔sub attachment (same or different sub), and sub-document block↔sub-document block.
|
||||
- Export control: with no sub-documents, the existing single "PDF herunterladen" button behaves unchanged. With sub-documents present, a segmented control ("Eine PDF" / "Getrennt") lets the user choose the export mode; "Eine PDF" downloads one flattened PDF, "Getrennt" downloads one PDF per sub-document sequentially.
|
||||
- **`SubDocumentEditor.svelte`** is the new sub-document block component. It emits `onSubDocumentChange`, `onRemove`, `onAttachmentMove`, and block-level drag callbacks up to the parent; the parent stays the source of truth via `onArchiveChange`.
|
||||
Use **pnpm**, not npm or yarn. The production container uses Node 24 and pnpm 11.15.1.
|
||||
|
||||
## Dependencies worth noting
|
||||
- `fflate` for ZIP extraction
|
||||
- `pdf-lib` for PDF generation/merging
|
||||
- `pdfjs-dist` for PDF thumbnail previews
|
||||
- `bits-ui`, `shadcn-svelte` (CLI/runtime), `clsx`, `tailwind-merge`, `class-variance-authority`, `tailwind-variants`, `tw-animate-css` for the shadcn-svelte UI layer
|
||||
- `@lucide/svelte` and a set of local SVG icon components for iconography
|
||||
- `@fontsource-variable/geist` font; custom `Nunito` variable font bundled under `src/lib/assets/`
|
||||
- `@tailwindcss/vite`, `@tailwindcss/forms`, `@tailwindcss/typography` for styling
|
||||
## Commands
|
||||
|
||||
## shadcn-svelte configuration
|
||||
- `components.json`: style `nova`, `iconLibrary` `lucide`, `baseColor` `neutral`, Tailwind CSS path `src/routes/layout.css`.
|
||||
- Aliases: `components → $lib/components`, `ui → $lib/components/ui`, `utils → $lib/utils`, `hooks → $lib/hooks`, `lib → $lib`.
|
||||
- `src/lib/hooks/` exists but is currently empty.
|
||||
- Generated components live under `src/lib/components/ui/`; `button.svelte` is the canonical adapted example (custom variants + ripple + icon prop).
|
||||
```sh
|
||||
pnpm install # Install dependencies
|
||||
pnpm dev # Start the development server
|
||||
pnpm build # Create the adapter-node production build
|
||||
pnpm preview # Preview the production build
|
||||
pnpm check # Run Svelte and TypeScript diagnostics
|
||||
pnpm check:watch # Run diagnostics in watch mode
|
||||
pnpm test:e2e # Run the toolbox specs (tests/e2e) and the unit specs (tests/unit)
|
||||
pnpm test:pwa # PWA offline/update tests against the production build (requires pnpm build)
|
||||
pnpm lint # Check repository formatting with Prettier
|
||||
pnpm format # Format the repository with Prettier
|
||||
```
|
||||
|
||||
## Current working tree observations
|
||||
- Branch is `features/shadcn`.
|
||||
- `agents.md` is being updated to match the post-shadcn-integration state.
|
||||
- `plan/shadcn-svelte-plan.md` tracks the in-progress migration (only `button` + `card` generated so far; broader UI replacement is still optional/incremental).
|
||||
- Untracked: `plan/`, and several icons under `src/lib/components/icons/` (`IconChain`, `IconFile`, `IconMenu`, `IconNeedle`, `IconOffice`, `IconPerson`, `IconTag`, `IconTrash`, `IconUnknown`).
|
||||
- Modified (uncommitted): `agents.md`, `src/lib/components/icons/IconSettings.svelte`, `src/lib/components/ui/button.svelte`, `src/routes/+page.svelte`, `.idea/workspace.xml`.
|
||||
- `src/lib/services/` is now committed (was previously untracked).
|
||||
For code changes, run at least:
|
||||
|
||||
```sh
|
||||
pnpm check
|
||||
pnpm test:e2e
|
||||
pnpm lint
|
||||
pnpm build
|
||||
```
|
||||
|
||||
For documentation-only changes, a targeted Prettier check is sufficient. If pre-existing warnings
|
||||
or formatting failures remain outside changed files, report them instead of making unrelated edits.
|
||||
|
||||
The Docker image can be built from the repository root. It installs with `--frozen-lockfile`, builds
|
||||
and prunes dependencies, then runs `node build/index.js` as a non-root user on port 3000.
|
||||
|
||||
## Repository structure
|
||||
|
||||
```text
|
||||
src/
|
||||
├── app.html # German HTML shell
|
||||
├── app.d.ts # SvelteKit application declarations
|
||||
├── lib/
|
||||
│ ├── actions/ripple.ts # Button ripple Svelte action
|
||||
│ ├── assets/ # Favicon and local Nunito fonts
|
||||
│ ├── components/
|
||||
│ │ ├── AttachmentPreview.svelte # Browser-rendered PDF/image thumbnails
|
||||
│ │ ├── AttachmentMenu.svelte # Per-attachment dropdown menu (move, split, remove)
|
||||
│ │ ├── BatchFileList.svelte # Shared batch rows: status chips, downloads, retry
|
||||
│ │ ├── BeaArchiveProcessing.svelte
|
||||
│ │ ├── BeaWorkspaceControls.svelte # beA workspace toolbar, settings popover, dialogs
|
||||
│ │ ├── ExhibitFileList.svelte # Reorderable Anlagen list: labels, renames, results
|
||||
│ │ ├── ProcessedZipArchiveEditor.svelte
|
||||
│ │ ├── PaginationPreview.svelte # Rendered page preview incl. shrink hints and page nav
|
||||
│ │ ├── PdfSplitDialog.svelte # Page-level PDF splitting into Teildokumente
|
||||
│ │ ├── SinglePdfTool.svelte # Shell for one-PDF tools (header, dropzone, slots)
|
||||
│ │ ├── SubDocumentEditor.svelte
|
||||
│ │ ├── ToolError.svelte # Shared error banner (role="alert")
|
||||
│ │ ├── FileDropzone.svelte # Shared PDF/image/ZIP file picker and drop target
|
||||
│ │ ├── ZipFilePicker.svelte # Compact ZIP picker button with hidden resettable input
|
||||
│ │ ├── PdfPageGrid.svelte # Accessible PDF thumbnail selection grid
|
||||
│ │ ├── PwaUpdatePrompt.svelte # Service-worker registration and update/offline toast
|
||||
│ │ ├── RasterizationWarning.svelte
|
||||
│ │ ├── ZipDropzone.svelte # beA-specific wrapper around FileDropzone
|
||||
│ │ ├── icons/ # Project-specific SVG components
|
||||
│ │ └── ui/ # Local shadcn-svelte component source
|
||||
│ ├── services/
|
||||
│ │ ├── xml-reading.service.ts # Namespace-tolerant XJustiz parsing
|
||||
│ │ └── zip-inflating.service.ts # Async fflate wrapper
|
||||
│ ├── batch.ts # Sequential batch engine for multi-file tools
|
||||
│ ├── download.ts # Blob/download helpers (pdfBytesToBlob, downloadBlobFile)
|
||||
│ ├── exhibit-naming.ts # Anlagen label, start number and file-name helpers
|
||||
│ ├── latest-task.ts # "Only the latest async call wins" tokens
|
||||
│ ├── pdf-compression.ts # Shared compression presets and rasterize pipeline
|
||||
│ ├── pdf-errors.ts # Shared PDF error classification and German messages
|
||||
│ ├── pdf-metadata.ts # PDF metadata inspection and cleaning (pdf-lib only)
|
||||
│ ├── pdf-overlay.ts # Shared overlay positions, preview CSS and color parsing
|
||||
│ ├── pdf-pagination.ts # Sheet-number labels and the pagination pipeline
|
||||
│ ├── pdf-preview-text.ts # Canvas text measurement for HTML previews
|
||||
│ ├── pdf-processing.ts # Reusable PDF merge helper
|
||||
│ ├── pdf-session.svelte.ts # Shared single-PDF lifecycle (file, bytes, handle, errors)
|
||||
│ ├── pdf-text-overlay.ts # Shared framed/rotated text and image overlay drawing
|
||||
│ ├── pdf-thumbnails.ts # Managed pdfjs thumbnails, on-demand page previews, cleanup
|
||||
│ ├── utils.ts # cn() and shared component utility types
|
||||
│ └── zip-processing.ts # Archive model, transforms, and PDF export
|
||||
└── routes/
|
||||
├── +layout.svelte # Shared logo header and branded footer
|
||||
├── +layout.ts # `prerender = true` for all routes (PWA offline documents)
|
||||
├── +page.svelte # Toolbox hub and global beA ZIP drop target
|
||||
├── layout.css # Tailwind, shadcn, theme, fonts, global CSS
|
||||
├── tools/
|
||||
│ ├── +layout.svelte # Shared back-navigation shell
|
||||
│ ├── bea/+page.svelte # Existing ZIP archive workflow
|
||||
│ └── {exhibits,pagination,merge,separate,stamp,watermark,encrypt,decrypt,compress,convert,rotate,metadata}/
|
||||
├── datenschutz/+page.svelte # Privacy policy
|
||||
└── impressum/+page.svelte # Imprint
|
||||
static/ # Public logo, footer art, PWA icons, and robots.txt
|
||||
components.json # shadcn-svelte registry configuration
|
||||
Dockerfile # Node adapter production image
|
||||
playwright.pwa.config.ts # Playwright config for `pnpm test:pwa`
|
||||
tests/e2e/ # Playwright toolbox specs
|
||||
tests/unit/ # Pure-function specs (run by the same Playwright config)
|
||||
plan/ # Design and implementation notes
|
||||
```
|
||||
|
||||
Generated directories such as `.svelte-kit/`, `build/`, and `node_modules/` must not be edited or
|
||||
committed.
|
||||
|
||||
## PWA / offline mode
|
||||
|
||||
- `SvelteKitPWA` is configured in `vite.config.ts` with `registerType: 'prompt'`: a waiting service
|
||||
worker never activates by itself. `PwaUpdatePrompt.svelte` registers the worker in the browser and
|
||||
shows a toast (“Neue Version verfügbar” with “Neu laden”/“Später”, plus a one-time hint when the
|
||||
app is ready offline). Never force a reload — an in-progress archive must survive a declined
|
||||
update.
|
||||
- All routes are prerendered (`src/routes/+layout.ts`), so route documents land in the precache and
|
||||
offline navigation works for every page, including a direct deep link. `navigateFallback: '/'`
|
||||
covers unknown paths.
|
||||
- Precache contents come from `workbox.globPatterns`: the plugin's client/prerendered defaults plus
|
||||
`mjs` (the pdfjs worker chunk, required for offline rendering) and the local fonts; the unused
|
||||
`static/Nunito/**` font drop is excluded via `globIgnores`. Precache size is ~4.5 MB.
|
||||
- PWA icons in `static/icons/` (192/512, maskable, apple-touch) were generated once from
|
||||
`static/favicon.svg`; regenerate them with ImageMagick/rsvg if the logo changes. The manifest link
|
||||
is injected client-side from `virtual:pwa-info` in the root layout, `theme-color` and the
|
||||
apple-touch-icon live in `app.html`.
|
||||
- `devOptions.enabled` stays `false`: `pnpm dev` never registers a service worker, so development
|
||||
caches stay clean. Verify PWA behaviour only against a production build (`pnpm build` and
|
||||
`pnpm preview`, or `pnpm test:pwa`, which also covers the offline processing chain and the update
|
||||
toast).
|
||||
- Simulating a deployment in tests requires restarting `node build/index.js`: adapter-node's static
|
||||
handler (sirv, non-dev mode) walks the build directory once at startup and caches the ETags, so
|
||||
swapped files are invisible to a running process. Browsers revalidate `/sw.js` via its ETag on
|
||||
navigation and `registration.update()`, so the normal container restart on deploy is sufficient.
|
||||
|
||||
## Application flow and state
|
||||
|
||||
- `/` is the toolbox hub. A beA ZIP or PDF dropped anywhere on that route is queued in memory and
|
||||
forwarded to `/tools/bea`; the file is not uploaded or persisted. The same `queuePendingFiles` /
|
||||
`takePendingFiles` handoff carries the merged PDF from `/tools/merge` to `/tools/pagination`.
|
||||
- `/tools/pagination` is a single-PDF tool: it renders one page at a time through
|
||||
`loadPdfPageRenderer` (never the all-pages thumbnail array), composes every label with
|
||||
`buildPaginationLabel` — the single source for both the live preview and the export — and stamps
|
||||
all pages through `drawTextOverlay`. The page number is always on every page; the optional free
|
||||
text (Aktenzeichen) follows its own “every page” / “first page only” scope.
|
||||
- `/tools/exhibits` is the Anlagen tool and the only batch tool with an order. One dropped PDF is
|
||||
one Anlage: `src/lib/exhibit-naming.ts` owns the label (`buildExhibitLabel`, no padded numbers in
|
||||
the stamp) and the optional file name (`buildExhibitFileName`, `{prefixSlug}_{paddedNumber}_…`,
|
||||
padding from `getExhibitNumberWidth`). Numbers are frozen per row when the run starts
|
||||
(`assignments` map), so reordering or changing the start number never rewrites a created Anlage —
|
||||
the list keeps showing the frozen label while pending rows follow the live order. The stamp is
|
||||
`drawTextOverlay` with `framed: true`, `compensatePageRotation: true`, first page by default.
|
||||
- `/tools/metadata` is a single-PDF inspection tool. `src/lib/pdf-metadata.ts` reads document
|
||||
properties with pdf-lib only (no rasterization), returns a discriminated `{ status: 'encrypted' }`
|
||||
result for password-protected files, and detects embedded-file markers by scanning raw bytes.
|
||||
Cleaning neutralizes the standard Info dictionary fields and re-saves through pdf-lib; embedded
|
||||
files and XMP streams are out of scope and are disclosed in the UI instead.
|
||||
- `tools/bea/+page.svelte` owns selected files, processed archives, pending work, and the
|
||||
thumbnail-width preference. Thumbnail width is the only persisted UI setting and uses localStorage.
|
||||
- The loaded workspace renders `BeaWorkspaceControls.svelte`, a top-aligned toolbar with the
|
||||
workspace status and two archive lifecycle actions:
|
||||
- **“Neue Datei bearbeiten”** (replace) opens a file picker and, after the user confirms the
|
||||
replacement dialog, calls `replaceWithZipFiles(files)` which resets the workspace and enqueues the
|
||||
new files against the incremented generation. Files picked for replacement stay in temporary
|
||||
component state until confirmation; cancelling the picker or the dialog must never mutate the
|
||||
current workspace.
|
||||
- **“Weitere Datei hinzufügen”** (append) lives in the settings popover, uses the plain append path
|
||||
(`handleFilesSelected`), and never clears current archives. Keep the two labels distinct:
|
||||
replace is “Neue Datei bearbeiten”, append is “Weitere Datei hinzufügen”.
|
||||
- `BeaWorkspaceControls.svelte` owns only ephemeral UI state (popover/dialog open state and pending
|
||||
replacement files). The route remains the source of truth for files, jobs, archives, and the
|
||||
persisted thumbnail width.
|
||||
- `resetWorkspace()` is the single teardown helper: it increments `archiveGeneration` and clears
|
||||
selected files, processed archives, and jobs. Never clear those arrays without going through it.
|
||||
- Accepted file types for the workspace (beA ZIP **and** PDF) are centralized in
|
||||
`src/lib/zip-selection.ts` and shared by the dropzone, the start-page drop target, and both
|
||||
compact pickers; `ZipFilePicker.svelte` resets its input after every selection so the
|
||||
same file can be chosen again.
|
||||
- Workspace files are processed concurrently. PDF files become single-attachment archives via
|
||||
`pdfFileToArchive`; ZIP files go through `extractZipArchives`. `archiveGeneration` prevents
|
||||
results from an old batch from reappearing after the user deletes all archives **or** confirms a
|
||||
replacement.
|
||||
- `ProcessedZipArchiveEditor.svelte` edits one archive and emits immutable replacements through
|
||||
`onArchiveChange`; the route-level archive array remains the source of truth.
|
||||
- Attachments may remain loose or belong to exactly one sub-document. Drag-and-drop supports
|
||||
reordering and moving attachments between lists, while sub-document headers can be reordered.
|
||||
- **Gruppieren** (grouping mode) offers archive-wide multi-select over loose and sub-document
|
||||
attachments: create a new Teildokument from the selection, move the selection into an existing
|
||||
Teildokument, select all/none. Exiting with **Fertig** or Escape clears the pending selection.
|
||||
New Teildokumente are numbered via `nextSubDocumentNumber` (max existing trailing number, not
|
||||
list length), and the created block's name input is focused and scrolled into view.
|
||||
- Each attachment card has an **“⋯” menu** (`AttachmentMenu.svelte`) as the keyboard/touch path:
|
||||
move into an existing or new Teildokument, move back to the loose list, split PDFs, and remove
|
||||
(with confirmation). It must never become the only way to perform an action that drag-and-drop
|
||||
also covers.
|
||||
- **PDF splitting**: `PdfSplitDialog.svelte` opens per PDF attachment, renders pages through
|
||||
`loadPdfDocument` + `PdfPageGrid`, and assigns page sets to named groups (manual selection,
|
||||
“Jede Seite einzeln”, or “Alle N Seiten”). Cut is the default; “Seiten im Original behalten”
|
||||
switches to copy. `applyPdfSplit` replaces the source with the remaining pages (or removes it),
|
||||
inserts the new Teildokumente after the source's parent block, and the synthetic attachments
|
||||
get unique `#seiten-…` path suffixes because `path` is the identity for keyed lists, drag
|
||||
payloads, and selection.
|
||||
- Per-archive export supports one flattened PDF or separate PDFs for sub-documents. Each
|
||||
sub-document also has its own direct download action that exports only that sub-document's
|
||||
attachments. Bulk export from the page always creates one flattened PDF per archive.
|
||||
|
||||
When changing archive transforms, preserve these invariants:
|
||||
|
||||
- An attachment move must not duplicate or discard data. Splitting cuts pages by default and only
|
||||
duplicates them when the user explicitly chooses copy mode.
|
||||
- Sub-document IDs remain stable across edits and reordering.
|
||||
- Single-PDF order is all sub-documents in order, each attachment in order, followed by loose
|
||||
attachments in order.
|
||||
- Dissolving a sub-document appends its attachments to the loose list.
|
||||
- Splitting inserts the new Teildokumente directly after the source's parent block (loose source:
|
||||
appended at the end) so the reading order is preserved.
|
||||
- State helpers in `zip-processing.ts` should return new archive/list objects rather than mutating
|
||||
component-owned state.
|
||||
|
||||
## ZIP, XML, and PDF processing
|
||||
|
||||
All case-file processing is intentionally local to the browser.
|
||||
|
||||
- `extractZipArchives` recursively inflates nested ZIP files.
|
||||
- Ignore macOS metadata (`__MACOSX`, `.DS_Store`, and `._*`) and unsupported entries.
|
||||
- `xjustiz_nachricht.xml` is metadata, not an export attachment. Its sender, receiver, and declared
|
||||
document names determine the generated PDF name and preferred attachment order.
|
||||
- XJustiz element matching must remain namespace tolerant. Malformed or absent metadata falls back
|
||||
safely instead of preventing extraction.
|
||||
- Supported attachments are PDFs and the image extensions listed in `zip-processing.ts`. Update the
|
||||
extension classification and downstream preview/export behavior together when adding formats.
|
||||
- PDF attachments are copied with pdf-lib. JPEG and PNG files can be embedded directly; other image
|
||||
formats are decoded through browser image/canvas APIs and converted to PNG first.
|
||||
- Page-set copies (separate/extract tools, PDF splitting) share
|
||||
`copyPagesToNewPdf(sourceBytes, pageIndexes)` in `src/lib/pdf-processing.ts` so the copy
|
||||
semantics cannot drift apart.
|
||||
- `src/lib/pdf-text-overlay.ts` owns text fitting, the rotated-box placement and the optional
|
||||
`/Rotate` compensation. Stamp keeps `compensatePageRotation` off so its output does not change;
|
||||
pagination turns it on so numbers stay at the visual position on rotated scans. `drawImageOverlay`
|
||||
is the image counterpart. Share both instead of re-implementing overlay maths in a route, and take
|
||||
the margin/padding presets (`TEXT_OVERLAY_MARGIN`, `TEXT_OVERLAY_PADDING`,
|
||||
`MIN_TEXT_OVERLAY_SIZE`) from the module rather than redefining them locally.
|
||||
- `src/lib/pdf-errors.ts` classifies PDF failures (`classifyPdfError`, `TextOverlayError`,
|
||||
`isEncryptedPdfError`) and owns the German copy (`pdfLoadErrorMessage`, `pdfProcessingErrorMessage`,
|
||||
both with a tool-specific fallback). All tools use it; never match on pdf-lib/pdfjs error text in a
|
||||
route, and never copy the password hint locally.
|
||||
- `src/lib/pdf-session.svelte.ts` (`createPdfSession`) is the shared single-PDF lifecycle for rotate,
|
||||
separate and pagination: it reads the bytes once, builds the tool handle, maps load errors and
|
||||
invalidates stale loads. Keep new single-PDF tools on it instead of re-declaring
|
||||
`file`/`bytes`/`handle`/`isLoading`/`loadingError` and a generation counter.
|
||||
- `src/lib/latest-task.ts` (`createLatestToken`) is the guard for "only the newest async call may
|
||||
publish its result" (file loads, preview renders). Do not add another hand-rolled counter.
|
||||
- Markup is shared the same way: `ToolError.svelte` owns the red `role="alert"` banner (never
|
||||
inline the classes in a route), `SinglePdfTool.svelte` owns the header + dropzone + toolbar +
|
||||
footer layout for one-PDF tools (rotate, separate — keep new ones on it), and
|
||||
`PaginationPreview.svelte` owns the page preview with its shrink hints. Tool-specific copy is
|
||||
passed in as props; keep it out of the shared components.
|
||||
- `src/lib/pending-files.ts` hands files between routes. Queue with an explicit target
|
||||
(`queuePendingFiles(files, 'bea' | 'pagination')`) and consume with `takePendingFiles(target)` so a
|
||||
tool cannot silently swallow a file queued for another one.
|
||||
- `src/lib/pdf-thumbnails.ts` offers two loaders: `loadPdfDocument` (all thumbnails, for pickers and
|
||||
page grids) and `loadPdfPageRenderer` (one page at a time, for long documents). Both share
|
||||
`PdfPageImage` and one rendering helper, revoke object URLs and tear down the pdfjs loading task —
|
||||
including when the initial `getDocument` promise rejects. Superseded renders throw
|
||||
`PreviewRenderCancelledError`, which callers swallow instead of logging.
|
||||
- `AttachmentPreview.svelte` dynamically loads pdfjs-dist in `onMount`, copies PDF bytes before
|
||||
rendering, and revokes object URLs during cleanup. Preserve those memory and data-integrity
|
||||
safeguards.
|
||||
- Empty archives/export groups should continue to fail explicitly rather than creating invalid PDFs.
|
||||
|
||||
Browser-only APIs used by this flow include `File`, `DOMParser`, `Image`, canvas, `Blob`, `URL`,
|
||||
localStorage, and `crypto.randomUUID()`. Do not invoke browser-only work during SSR or at module
|
||||
initialization. Keep it in event handlers, `onMount`, effects guarded by hydration, or functions only
|
||||
called in the browser.
|
||||
|
||||
## Batch processing pattern for single-PDF tools
|
||||
|
||||
Tools that transform one PDF per run (compress, watermark, stamp, encrypt, decrypt) accept multiple
|
||||
files. Follow the established compress retrofit when adding the pattern to another tool; see
|
||||
`plan/done/batch-processing-plan.md` for the full design.
|
||||
|
||||
- `src/lib/batch.ts` owns the engine: `createBatchItem(file)`, `runBatch(items, process, update,
|
||||
options?)`, `formatBytes`, `batchStatusLabel`/`batchStatusClass` and
|
||||
`shouldWarnAboutBatchMemory`. `process` is `(file, item) => Promise<{ bytes, name }>`: the item is
|
||||
passed so a tool can resolve per-row data frozen at run start (the Anlagen tool resolves its
|
||||
exhibit number that way; `(file) => …` callbacks stay valid). `runBatch` processes strictly
|
||||
sequentially (one PDF in flight for memory safety), never throws — per-item failures are caught
|
||||
and written to the item as a German error message (a string, or a formatter that can inspect the
|
||||
thrown error) — and consults the optional `shouldProcess` predicate so rows removed mid-run are
|
||||
skipped.
|
||||
- `src/lib/batch.ts` also owns the bulk export: `uniqueEntryName` (duplicate suffixes) and
|
||||
`downloadBatchZip(items, zipBaseName)` → `{toolname}_{date}.zip`. Keep new batch tools on it
|
||||
instead of building ZIPs inside a component.
|
||||
- `src/lib/components/BatchFileList.svelte` renders the rows: name, size, status chip (`batchStatusClass`/`batchStatusLabel`)
|
||||
(Wartet / Verarbeite… / Fertig ✓ / Fehler), per-row download, remove, and retry buttons with
|
||||
accessible names including the file name, a progress line (“Datei 3 von 7 wird verarbeitet…”), a
|
||||
total result-size summary, a memory warning (>20 files or any file >25 MB), and the bulk
|
||||
“Alle herunterladen” ZIP action (via fflate, named `{toolname}_{date}.zip`) shown once ≥2 results
|
||||
exist. Duplicate result names inside the ZIP are suffixed instead of overwriting.
|
||||
- Page wiring: keep `items = $state<BatchItem[]>([])`, append newly dropped files (do not replace;
|
||||
deduplicate by name + size), pass a `process(file)` callback that reads the current option state
|
||||
so option changes mid-run affect only subsequent files, and gate the run button on pending items.
|
||||
Files too large to rasterize (>100 MB) are marked as error rows immediately on selection instead
|
||||
of blocking the batch. Keep thumbnails, previews, and page-specific selection to a single dropped
|
||||
file; multi-file runs apply the tool to every page of every file.
|
||||
- `src/lib/components/ExhibitFileList.svelte` is the other batch list, for tools where order and a
|
||||
derived per-row label matter (currently `/tools/exhibits`): drag handle plus up/down buttons that
|
||||
both call one `onMove(from, to)`, position badge, the resolved label, the renamed file name, and
|
||||
the same status/download/retry/remove/ZIP surface as `BatchFileList`. Reordering and preview
|
||||
selection are disabled while a run is active.
|
||||
- Per-tool ZIP base names: compress uses `komprimiert`, watermark `wasserzeichen`, stamp `stempel`,
|
||||
exhibits `anlagen`, encrypt `geschuetzt`, decrypt `entsperrt`. Choose an analogous German name
|
||||
per tool and cover the
|
||||
batch flow with Playwright tests (multi-file ZIP contents, failure continuation, retry, removal).
|
||||
|
||||
## Svelte conventions
|
||||
|
||||
- Use Svelte 5 runes and current event syntax: `$props()`, `$state`, `$derived`, `$effect`, snippets,
|
||||
`{@render ...}`, and handlers such as `onclick` rather than legacy APIs.
|
||||
- Keep component scripts typed with `<script lang="ts">`.
|
||||
- Define a local `Props` type for non-trivial component props.
|
||||
- Use callback props for child-to-parent updates; do not mutate prop objects in place.
|
||||
- Use `$lib/...` aliases for application imports instead of long relative paths.
|
||||
- Name reusable components in PascalCase and route files according to SvelteKit conventions.
|
||||
- Preserve semantic HTML, accessible names, visible focus states, and keyboard alternatives. Do not
|
||||
make drag-and-drop the only way to perform a critical action.
|
||||
- Clean up global listeners, object URLs, PDF loading tasks, and other browser resources.
|
||||
|
||||
The Vite configuration forces runes mode for project files, so do not introduce legacy Svelte
|
||||
component patterns.
|
||||
|
||||
## shadcn-svelte and component conventions
|
||||
|
||||
`components.json` configures the official shadcn-svelte registry, the `nova` style, Lucide icons, and
|
||||
these aliases:
|
||||
|
||||
- UI components: `$lib/components/ui`
|
||||
- General components: `$lib/components`
|
||||
- Utilities: `$lib/utils`
|
||||
|
||||
Use existing local UI components before creating replacements. Follow the exports actually present
|
||||
on disk: the card is composed through `import * as Card from '$lib/components/ui/card'`, while the
|
||||
current button is a default import from `$lib/components/ui/button.svelte`.
|
||||
|
||||
When adding a registry component, use the project runner, inspect every generated file, and preserve
|
||||
local customizations:
|
||||
|
||||
```sh
|
||||
pnpm dlx shadcn-svelte@latest add <component>
|
||||
```
|
||||
|
||||
Do not overwrite or bulk-update customized components without explicit approval. Use `cn()` for
|
||||
conditional class merging and existing variants before adding one-off component styling.
|
||||
|
||||
## Styling conventions
|
||||
|
||||
Use Tailwind utilities in Svelte markup and semantic theme tokens from `src/routes/layout.css`.
|
||||
|
||||
- Prefer `bg-background`, `text-foreground`, `bg-primary`, `text-muted-foreground`, `border-border`,
|
||||
and other configured semantic utilities over raw brand colors.
|
||||
- Preserve the established Nominandum visual language: pale blue-gray background, white cards, dark
|
||||
blue primary text, red/coral secondary actions, rounded containers, and Nunito typography.
|
||||
- Use `flex`/`grid` with `gap-*`, responsive variants, and `size-*` when width and height are equal.
|
||||
- Use `cn()` rather than constructing partial Tailwind class names. Keep complete class names visible
|
||||
to Tailwind's scanner.
|
||||
- Prefer component variants and theme variables over overriding UI-component colors in call sites.
|
||||
- Add global CSS only for imports, tokens, font faces, animations, or behavior that utilities cannot
|
||||
reasonably express.
|
||||
- Tailwind 4 is configured through Vite and `layout.css`; there is no `tailwind.config` file.
|
||||
- Maintain contrast, responsive layouts, drag-state feedback, and visible focus treatment.
|
||||
|
||||
Theme-level changes belong in `src/routes/layout.css`. Do not reintroduce the removed Skeleton theme
|
||||
or refer to the old `nom-theme.css` architecture.
|
||||
|
||||
## Formatting
|
||||
|
||||
Follow the checked-in Prettier configuration:
|
||||
|
||||
- Tabs for indentation
|
||||
- Single quotes in scripts and styles where supported
|
||||
- No trailing commas
|
||||
- 100-character print width
|
||||
- Svelte-aware and Tailwind-aware formatting
|
||||
|
||||
Use `pnpm exec prettier --write <changed-files>` for targeted formatting. Avoid formatting unrelated
|
||||
files solely to clean up existing differences.
|
||||
|
||||
## Product and content constraints
|
||||
|
||||
- User-facing copy is German unless a task explicitly requests another language.
|
||||
- Preserve legal text in `datenschutz/+page.svelte` and `impressum/+page.svelte` unless explicitly
|
||||
asked to change it.
|
||||
- Keep the Nominandum logo, footer artwork, and theme consistent across routes.
|
||||
- ZIP selection accepts multiple files matching `.zip`, `application/zip`, or
|
||||
`application/x-zip-compressed`.
|
||||
- Resetting the file input after selection is intentional so the same archive can be selected again.
|
||||
- Do not add uploads, network requests, server processing, analytics, or case-data persistence without
|
||||
an explicit requirement and privacy review.
|
||||
|
||||
## Change discipline
|
||||
|
||||
- Keep changes scoped and preserve unrelated work, including untracked files.
|
||||
- Inspect existing components, archive helpers, and theme tokens before adding abstractions.
|
||||
- Keep browser processing behavior and archive-order invariants covered when editing shared helpers.
|
||||
- Do not hand-edit generated output or lockfile contents. Update the lockfile only through pnpm when
|
||||
dependency changes are required.
|
||||
- Avoid dependencies when the task can be completed with Svelte, browser APIs, Tailwind, or existing
|
||||
packages.
|
||||
- After implementation, review changed files, run relevant checks, and mention remaining warnings or
|
||||
limitations.
|
||||
+24
-16
@@ -10,37 +10,45 @@
|
||||
"prepare": "svelte-kit sync || echo ''",
|
||||
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
||||
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
|
||||
"test:e2e": "playwright test",
|
||||
"lint": "prettier --check .",
|
||||
"format": "prettier --write ."
|
||||
"format": "prettier --write .",
|
||||
"test:pwa": "playwright test -c playwright.pwa.config.ts"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@fontsource-variable/geist": "^5.2.9",
|
||||
"@lucide/svelte": "^1.18.0",
|
||||
"@fontsource-variable/geist": "^5.3.0",
|
||||
"@lucide/svelte": "^1.61.0",
|
||||
"@playwright/test": "^1.64.1",
|
||||
"@sveltejs/adapter-node": "^5.5.7",
|
||||
"@sveltejs/kit": "^2.63.0",
|
||||
"@sveltejs/vite-plugin-svelte": "^7.1.2",
|
||||
"@sveltejs/kit": "^2.70.3",
|
||||
"@sveltejs/vite-plugin-svelte": "^7.3.1",
|
||||
"@tailwindcss/forms": "^0.5.11",
|
||||
"@tailwindcss/typography": "^0.5.19",
|
||||
"@tailwindcss/vite": "^4.3.0",
|
||||
"@tailwindcss/typography": "^0.5.20",
|
||||
"@tailwindcss/vite": "^4.3.3",
|
||||
"@types/node": "^26.6.5",
|
||||
"@vite-pwa/sveltekit": "^1.1.0",
|
||||
"bits-ui": "2.18.1",
|
||||
"class-variance-authority": "0.7.1",
|
||||
"clsx": "2.1.1",
|
||||
"prettier": "^3.8.3",
|
||||
"prettier-plugin-svelte": "^4.1.0",
|
||||
"prettier-plugin-tailwindcss": "^0.8.0",
|
||||
"prettier": "^3.9.10",
|
||||
"prettier-plugin-svelte": "^4.1.1",
|
||||
"prettier-plugin-tailwindcss": "^0.8.1",
|
||||
"shadcn-svelte": "1.3.0",
|
||||
"svelte": "^5.56.1",
|
||||
"svelte-check": "^4.6.0",
|
||||
"svelte": "^5.57.2",
|
||||
"svelte-check": "^4.7.7",
|
||||
"tailwind-merge": "3.6.0",
|
||||
"tailwind-variants": "^3.2.2",
|
||||
"tailwindcss": "^4.3.0",
|
||||
"tailwind-variants": "^3.3.1",
|
||||
"tailwindcss": "^4.3.3",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"typescript": "^6.0.3",
|
||||
"vite": "^8.0.16"
|
||||
"vite": "^8.3.5",
|
||||
"vite-plugin-pwa": "^1.3.0",
|
||||
"workbox-window": "^7.4.1"
|
||||
},
|
||||
"dependencies": {
|
||||
"@cantoo/pdf-lib": "^2.11.1",
|
||||
"fflate": "^0.8.3",
|
||||
"pdf-lib": "^1.17.1",
|
||||
"pdfjs-dist": "^6.0.227"
|
||||
"pdfjs-dist": "^6.4.299"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
# Plan: Batch-Verarbeitung in den Einzel-PDF-Tools
|
||||
|
||||
## Goal
|
||||
|
||||
Several tools process exactly one PDF at a time, but real legal work is repetitive: compress ten
|
||||
scans, watermark five drafts, encrypt three exhibits. This plan generalizes the single-file
|
||||
tools to accept **multiple files**, process them sequentially with per-file status, and download
|
||||
results individually or as one ZIP.
|
||||
|
||||
---
|
||||
|
||||
## Open decisions (defaults marked ✅)
|
||||
|
||||
### D1 — Rollout scope
|
||||
|
||||
- ✅ **A. Phased rollout, starting with compress.** Compress is the highest-repetition tool and
|
||||
already has the cleanest input→output shape. Phase 2: watermark (needs option application per
|
||||
file), stamp/page-numbers (same pattern). Phase 3: encrypt/decrypt (decrypt needs a password
|
||||
for all files — one shared password input).
|
||||
- B. All tools at once. Too much surface; the shared pattern should prove itself first.
|
||||
|
||||
### D2 — UX model
|
||||
|
||||
- ✅ **A. Always multi-file.** The dropzone accepts multiple PDFs from day one; a single dropped
|
||||
file simply renders a one-row list. No mode switch. Download: one button per finished row
|
||||
plus "Alle als ZIP", which appears once ≥2 files are done.
|
||||
- B. Explicit "Batch-Modus" toggle. Extra clicks and duplicated code paths.
|
||||
|
||||
### D3 — Failure behavior
|
||||
|
||||
- ✅ **Continue on error.** A failing file is marked with a German error message in its row; the
|
||||
queue keeps processing the rest. "Erneut versuchen" per failed row. This matches how lawyers
|
||||
will use it (one broken scan must not kill the run).
|
||||
|
||||
---
|
||||
|
||||
## UX
|
||||
|
||||
Per retrofitted tool page:
|
||||
|
||||
1. `PdfDropzone` with `multiple` enabled; dropping more files appends to the current list
|
||||
(does not replace it) — matches how the start page queues ZIPs.
|
||||
2. **File list** below the options card: one row per file —
|
||||
name, size, status chip (Wartet / Verarbeite… / Fertig ✓ mit neuer Größe / Fehler),
|
||||
per-row download button (enabled when done), per-row remove, "Erneut versuchen" on error.
|
||||
3. Progress line during a run: "Datei 3 von 7 wird verarbeitet…".
|
||||
4. Footer actions: **"Alle herunterladen"** (ZIP via fflate, named
|
||||
`{toolname}_{date}.zip`, e.g. `komprimiert_2026-02-12.zip`) visible when ≥2 results exist.
|
||||
5. Options (preset, watermark settings, password…) apply to **all** files in the run; changing
|
||||
options while a run is active affects only subsequent files (documented in helper text).
|
||||
|
||||
## Technical approach
|
||||
|
||||
### Shared batch engine (`src/lib/components/BatchFileList.svelte` + `src/lib/batch.ts`)
|
||||
|
||||
```ts
|
||||
// src/lib/batch.ts
|
||||
export type BatchItemStatus = 'pending' | 'processing' | 'done' | 'error';
|
||||
export type BatchItem = {
|
||||
id: string; // crypto.randomUUID()
|
||||
file: File;
|
||||
status: BatchItemStatus;
|
||||
resultBytes?: Uint8Array;
|
||||
resultName?: string;
|
||||
errorMessage?: string | null;
|
||||
};
|
||||
|
||||
export const runBatch = async (
|
||||
items: BatchItem[],
|
||||
process: (file: File) => Promise<{ bytes: Uint8Array; name: string }>,
|
||||
update: (id: string, patch: Partial<BatchItem>) => void
|
||||
): Promise<void>;
|
||||
```
|
||||
|
||||
- `runBatch` iterates **sequentially** (memory protection: only one PDF in flight), calls
|
||||
`update` per lifecycle step so the component re-renders status chips, and never throws —
|
||||
per-item errors are caught and written to the item.
|
||||
- `BatchFileList.svelte` renders rows, emits callbacks (`onDownload(item)`, `onRemove(id)`,
|
||||
`onRetry(id)`); row buttons are real buttons with accessible labels including the file name.
|
||||
- Result bytes stay in memory until downloaded or removed; revocation concerns don't apply
|
||||
(downloads use the existing `downloadBlob` object-URL pattern with timeout revocation). A
|
||||
summary line shows total result size so users notice runaway memory situations.
|
||||
- Very large runs: warn (>20 files or >25 MB per file in rasterizing tools) with a German note;
|
||||
do not hard-block.
|
||||
|
||||
### Retrofit pilot: compress
|
||||
|
||||
- Extract per-file compression into `compressWithPreset` (already planned in
|
||||
`bea-size-limit-plan.md` — coordinate: whichever ships first creates `pdf-compression.ts`).
|
||||
- Page state becomes `items = $state<BatchItem[]>([])` + options; the old single-result UI
|
||||
(`compressedBytes` etc.) is replaced by the list. Zielgröße mode + batch: each file runs the
|
||||
preset loop independently.
|
||||
- Rasterization guards apply per file: files >100 MB are immediately marked as error rows with
|
||||
the rasterization message instead of blocking the whole batch.
|
||||
|
||||
### Rollout pattern for later tools
|
||||
|
||||
- Watermark/stamp/page-numbers: identical wiring; the pdf-lib overlay loop is already per-page
|
||||
and per-file load is isolated.
|
||||
- Encrypt: one password for all files (password inputs stay at the top); per-file failure shows
|
||||
which file broke.
|
||||
- Decrypt: one shared password input + rasterization caveat once, per-file status as above.
|
||||
|
||||
## Files changed
|
||||
|
||||
- `src/lib/batch.ts` — new batch engine.
|
||||
- `src/lib/components/BatchFileList.svelte` — new shared list component.
|
||||
- `src/routes/tools/compress/+page.svelte` — pilot retrofit.
|
||||
- (Phase 2/3) watermark/stamp/page-numbers/encrypt/decrypt pages.
|
||||
- `tests/e2e/toolbox.spec.ts` — spec blocks per retrofitted tool.
|
||||
- `agents.md` — document the batch pattern for future tools.
|
||||
|
||||
## Implementation phases
|
||||
|
||||
1. `batch.ts` + `BatchFileList.svelte` (build against a mock process function first).
|
||||
2. Compress pilot retrofit (depends on `pdf-compression.ts` extraction).
|
||||
3. E2E for compress batch: 2 PDFs → both done, ZIP contains 2 entries; one corrupt file →
|
||||
error row, other still succeeds; retry works.
|
||||
4. Phase 2 tools, Phase 3 tools (separate PRs).
|
||||
5. Polish: copy, memory summary line, warning thresholds.
|
||||
|
||||
## Verification
|
||||
|
||||
- Single file dropped → identical outcome to today's single-file flow (same output bytes for the
|
||||
same preset).
|
||||
- Mixed success/failure batch: statuses correct, ZIP includes only successful results.
|
||||
- Remove a finished row → its bytes freed (no download offered afterwards).
|
||||
- Option change mid-run affects only following files.
|
||||
- `pnpm check`, `pnpm lint`, `pnpm build`, `pnpm test:e2e` pass.
|
||||
|
||||
## Risks
|
||||
|
||||
- **Memory with many results held simultaneously**: 30 compressed PDFs × several MB can strain
|
||||
low-end machines. Mitigations: per-row remove, total-size summary, and a follow-up "download
|
||||
and clear" convenience. Do not stream to disk (browser constraint).
|
||||
- **Sequential processing is slow for many large files** — inherent to memory safety; progress
|
||||
line sets expectations.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- Parallel processing.
|
||||
- Cross-tool pipelines (compress → watermark in one run) — separate roadmap item.
|
||||
- Per-file option overrides (v1 applies one option set to the whole batch).
|
||||
@@ -0,0 +1,315 @@
|
||||
# Plan: beA settings menu and “next ZIP” workflow
|
||||
|
||||
**Status: Done**
|
||||
|
||||
## Current review
|
||||
|
||||
The loaded-archive state in `src/routes/tools/bea/+page.svelte` uses a native `<details>` menu fixed
|
||||
in the lower-right corner. I reviewed it at 1440 × 900 and 390 × 844 with a real one-PDF ZIP.
|
||||
|
||||
### What is not working
|
||||
|
||||
- The floating gear is detached from the archive it controls and competes with the footer/content
|
||||
edge. On mobile, the open panel overlaps the archive card.
|
||||
- Two long actions are forced side by side into a 288 px panel. Their labels visibly clip to
|
||||
“Archive als PDF” and “Archive”, so the difference between export and delete is unclear.
|
||||
- The emoji gear does not match the app's Lucide icon language. Its accessible name is the English
|
||||
word “Settings” in an otherwise German interface.
|
||||
- View configuration, bulk export, and destructive session deletion are mixed without hierarchy.
|
||||
“Alle ZIP-Archive löschen” receives nearly the same visual weight as export and has no
|
||||
confirmation.
|
||||
- “Thumbnail Breite” is an implementation-oriented, partly English label. The control should be
|
||||
described as the user-visible result: “Vorschaugröße”.
|
||||
- Native `<details>` does not provide the expected menu behavior here: no reliable outside-click
|
||||
dismissal, weak focus management, and no deliberate focus return.
|
||||
- Once any ZIP has loaded, the dropzone disappears. Although the route can append files internally,
|
||||
the user has no control to open another ZIP or begin a clean next task.
|
||||
- The loaded archive is vertically centered in a viewport-height container. This creates a large
|
||||
gap below “beA-Edit” on both desktop and mobile and makes the fixed settings control feel even more
|
||||
disconnected.
|
||||
|
||||
## Product decision
|
||||
|
||||
Use two explicit archive lifecycle actions:
|
||||
|
||||
1. **“Neue ZIP bearbeiten”** is the prominent next-task action. It replaces the current workspace
|
||||
only after the user has selected a valid ZIP and confirmed the replacement. This directly serves
|
||||
the requested “I am done; give me the next ZIP” workflow.
|
||||
2. **“Weitere ZIP hinzufügen”** remains available inside the actions panel for users intentionally
|
||||
working with multiple ZIPs at once. It appends to the current workspace, preserving the behavior
|
||||
already supported by `handleFilesSelected`.
|
||||
|
||||
The replacement confirmation is shown _after_ file selection. Cancelling either the system picker or
|
||||
confirmation must leave the current work untouched. The confirmation says that the current view and
|
||||
unexported changes will be removed, while files already downloaded remain on the device.
|
||||
|
||||
Do not try to infer that a user is “done” from a download click. A download does not prove that every
|
||||
wanted output was saved, and the browser cannot reliably verify what happened to the file afterward.
|
||||
|
||||
## UX direction
|
||||
|
||||
### Desktop
|
||||
|
||||
```text
|
||||
← Zurück
|
||||
|
||||
beA-Edit 1 ZIP · 1 Archiv geöffnet [+ Neue ZIP bearbeiten] [⚙ Einstellungen]
|
||||
──────────────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
┌ Current archive editor ────────────────────────────────────────────────────────────────┐
|
||||
│ archive name [Auswählen] [PDF herunterladen] │
|
||||
│ attachment previews … │
|
||||
└────────────────────────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
Settings popover, aligned to the right edge of the toolbar:
|
||||
|
||||
```text
|
||||
┌ Einstellungen ─────────────────────────────┐
|
||||
│ ANSICHT │
|
||||
│ Vorschaugröße 200 px │
|
||||
│ Klein ─────────●────────────── Groß │
|
||||
│ │
|
||||
│ ARCHIV-AKTIONEN │
|
||||
│ [+ Weitere ZIP hinzufügen] │
|
||||
│ [⇩ Alle Archive als PDF herunterladen] │ shown only when useful
|
||||
│ ───────────────────────────────────────── │
|
||||
│ [🗑 Aktuelle Bearbeitung leeren] │ quiet destructive action
|
||||
└───────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### Mobile
|
||||
|
||||
The toolbar becomes a two-row block immediately below the title and remains in normal document flow.
|
||||
The status occupies the first row; the two buttons share the second row. The popover uses
|
||||
`width: min(22rem, calc(100vw - 2rem))`, stacks every action vertically, and relies on collision-aware
|
||||
positioning rather than fixed bottom/right coordinates. No control or panel may cover the archive
|
||||
header.
|
||||
|
||||
The loaded workspace becomes top-aligned (`justify-start`) instead of viewport-centered, removing the
|
||||
large empty band between the page heading and first archive.
|
||||
|
||||
## Visual system
|
||||
|
||||
Preserve the existing Nominandum identity rather than introducing a new theme:
|
||||
|
||||
- **Ink** `#122F62`: headings, primary actions, and active controls.
|
||||
- **Workspace** `#EEF4F6`: page background.
|
||||
- **Paper** `#FFFFFF`: editor and popover surfaces.
|
||||
- **Handoff coral** `#EA544B`: restrained accent only; do not use it to make deletion look primary.
|
||||
- **Success** `#4E8A72`: completed processing state.
|
||||
- Typography remains **Nunito** for interface copy; use the existing Geist utility face only where
|
||||
numeric/file metadata already uses it.
|
||||
- Use existing radii and semantic CSS variables from `src/routes/layout.css`; no theme-level CSS
|
||||
changes should be necessary.
|
||||
|
||||
The distinctive element is the **archive handoff row**: current-work status on the left and “Neue ZIP
|
||||
bearbeiten” on the right. It expresses the actual legal-document rhythm—finish one beA export, hand
|
||||
it off, open the next—rather than presenting a generic floating settings cog.
|
||||
|
||||
Self-critique: a card full of settings tabs would be a generic dashboard solution and is unnecessary
|
||||
for one view preference. The revised direction removes the floating action, keeps the next-case task
|
||||
visible, and spends visual emphasis only on the archive handoff.
|
||||
|
||||
## Interaction details
|
||||
|
||||
### Toolbar
|
||||
|
||||
- Status copy derives from state:
|
||||
- `1 ZIP · 1 Archiv geöffnet`
|
||||
- `2 ZIPs · 3 Archive geöffnet`
|
||||
- while work is pending, append `· 1 wird geöffnet` in an `aria-live="polite"` status.
|
||||
- “Neue ZIP bearbeiten” uses Lucide `FilePlus2` and remains available after the first archive or an
|
||||
error. It opens a ZIP-only file picker.
|
||||
- “Einstellungen” uses Lucide `Settings2`, has a visible German label at desktop sizes, and retains
|
||||
an explicit accessible name at all widths.
|
||||
- Do not make the toolbar fixed or sticky in v1. Normal flow avoids covering previews and footer
|
||||
content; sticky behavior can be reconsidered after testing long archives.
|
||||
|
||||
### New ZIP replacement
|
||||
|
||||
1. User activates “Neue ZIP bearbeiten”.
|
||||
2. The system file picker opens and accepts the same ZIP types as `ZipDropzone`.
|
||||
3. If the picker is cancelled, nothing changes.
|
||||
4. If valid files are selected, show an `AlertDialog`:
|
||||
- title: **“Aktuelle Bearbeitung ersetzen?”**
|
||||
- description: **“Archive und nicht heruntergeladene Änderungen in dieser Ansicht werden
|
||||
entfernt. Bereits heruntergeladene PDFs bleiben erhalten.”**
|
||||
- cancel: **“Abbrechen”**
|
||||
- confirm: **“Neue ZIP öffnen”**
|
||||
5. On confirm, increment `archiveGeneration`, clear selected files, processed archives, and jobs, then
|
||||
enqueue the newly selected files against the new generation.
|
||||
6. If extraction fails, show the existing processing error state for the new ZIP and allow retry or
|
||||
removal; do not restore the old workspace implicitly.
|
||||
|
||||
Keep the selected replacement files in temporary component state until confirmation. This prevents a
|
||||
cancelled dialog from mutating or clearing the current workspace. Reset the underlying file input
|
||||
after every selection so choosing the same ZIP again still fires `change`.
|
||||
|
||||
### Append, export, and clear
|
||||
|
||||
- “Weitere ZIP hinzufügen” uses the existing append path and never clears current archives.
|
||||
- Show bulk export only when there are at least two processed archives; one archive already has its
|
||||
own clearer “PDF herunterladen” action.
|
||||
- Rename bulk export to **“Alle Archive als PDF herunterladen”**.
|
||||
- Rename deletion to **“Aktuelle Bearbeitung leeren”** and place it after a separator as a quiet
|
||||
destructive menu item with `Trash2`.
|
||||
- Clearing requires its own `AlertDialog`; confirmation calls the existing reset path and returns the
|
||||
user to the initial ZIP dropzone.
|
||||
- Disable replacement, append, export, and clear while a destructive transition is being committed,
|
||||
but allow a new selection while archive extraction is in progress; generation guarding prevents
|
||||
stale results from returning.
|
||||
|
||||
### View setting
|
||||
|
||||
- Replace the raw range with the shadcn `Slider` labeled **“Vorschaugröße”**.
|
||||
- Keep the current `100–400 px` range, `1 px` step, clamp function, and `thumbnailWidth`
|
||||
localStorage key for backward compatibility.
|
||||
- Display the current value as tabular numerals and include “Klein”/“Groß” endpoint hints.
|
||||
- The label and current value must be associated with the slider for assistive technology. Arrow-key
|
||||
adjustment remains available.
|
||||
|
||||
## Component and state design
|
||||
|
||||
### New `BeaWorkspaceControls.svelte`
|
||||
|
||||
Create `src/lib/components/BeaWorkspaceControls.svelte` to keep the already state-heavy route from
|
||||
absorbing menu markup. Suggested props:
|
||||
|
||||
```ts
|
||||
type Props = {
|
||||
selectedZipCount: number;
|
||||
processedArchiveCount: number;
|
||||
pendingZipCount: number;
|
||||
thumbnailWidth: number;
|
||||
onThumbnailWidthChange: (value: number) => void;
|
||||
onRequestReplacement: () => void;
|
||||
onAppendFiles: (files: File[]) => void;
|
||||
onExportAll: () => void;
|
||||
onClear: () => void;
|
||||
};
|
||||
```
|
||||
|
||||
The component owns only ephemeral UI state (popover/dialog open state and temporarily selected
|
||||
replacement files). The route remains the source of truth for files, jobs, archives, and the
|
||||
persisted thumbnail width.
|
||||
|
||||
Centralize the ZIP accept string and validation used by the initial dropzone and both compact pickers.
|
||||
Prefer extracting a small reusable ZIP picker/input rather than duplicating MIME and extension checks
|
||||
inside the controls component.
|
||||
|
||||
### Route changes
|
||||
|
||||
In `src/routes/tools/bea/+page.svelte`:
|
||||
|
||||
- Replace `deleteAllZipArchives` with a single `resetWorkspace()` helper that increments
|
||||
`archiveGeneration` and clears all three state arrays.
|
||||
- Add `replaceWithZipFiles(files)` that calls `resetWorkspace()` and then
|
||||
`handleFilesSelected(files)`; the new jobs must capture the incremented generation.
|
||||
- Keep `handleFilesSelected` as the append operation.
|
||||
- Render `BeaWorkspaceControls` whenever at least one selected file/job/archive exists.
|
||||
- Remove the fixed `<details>` block.
|
||||
- Top-align the loaded-workspace container and render status/controls before processing and editor
|
||||
cards.
|
||||
- Preserve `thumbnailWidth` hydration and persistence exactly as today.
|
||||
|
||||
### shadcn-svelte pieces
|
||||
|
||||
Add only the required official components with the pnpm CLI, reviewing generated files and imports:
|
||||
|
||||
- `popover`
|
||||
- `slider`
|
||||
- `separator`
|
||||
- `alert-dialog`
|
||||
|
||||
Do not overwrite the customized button component. The project currently imports
|
||||
`$lib/components/ui/button.svelte` directly, so adapt generated imports to the local export shape if
|
||||
the registry assumes a barrel. Use Lucide Svelte icons (`FilePlus2`, `Settings2`, `Download`,
|
||||
`Trash2`) with `data-icon` inside buttons.
|
||||
|
||||
## Files
|
||||
|
||||
- `src/routes/tools/bea/+page.svelte` — workspace lifecycle, reset/replace handlers, and layout.
|
||||
- `src/lib/components/BeaWorkspaceControls.svelte` — new toolbar, popover, dialogs, and picker
|
||||
coordination.
|
||||
- `src/lib/components/ZipDropzone.svelte` and/or a new small ZIP picker helper — shared accept and
|
||||
selection behavior.
|
||||
- `src/lib/components/ui/{popover,slider,separator,alert-dialog}/` — official shadcn-svelte source.
|
||||
- `tests/e2e/toolbox.spec.ts` — archive handoff, menu, keyboard, and responsive coverage.
|
||||
- `agents.md` — document append versus replace semantics and the shared reset invariant.
|
||||
|
||||
No changes are expected in `zip-processing.ts`, archive transforms, export ordering, or server code.
|
||||
|
||||
## Implementation phases
|
||||
|
||||
1. **Shared ZIP selection and state helpers**
|
||||
- Centralize accepted ZIP types.
|
||||
- Introduce `resetWorkspace` and `replaceWithZipFiles`.
|
||||
- Verify generation guarding when replacement occurs during extraction.
|
||||
2. **Workspace toolbar and layout**
|
||||
- Add the top-aligned status/action row.
|
||||
- Implement the replacement picker and confirmation without removing current work on cancel.
|
||||
- Remove the fixed gear and viewport-centering.
|
||||
3. **Settings popover**
|
||||
- Add the responsive popover, slider, append, conditional bulk export, and confirmed clear.
|
||||
- Verify keyboard focus, Escape/outside-click dismissal, and focus return.
|
||||
4. **Tests and polish**
|
||||
- Add e2e coverage below.
|
||||
- Run desktop/mobile browser review and remove any clipped or overlapping labels.
|
||||
- Update `agents.md` and run the full validation set.
|
||||
|
||||
## Verification
|
||||
|
||||
### Automated
|
||||
|
||||
Add Playwright cases for:
|
||||
|
||||
- Load ZIP A → “Neue ZIP bearbeiten” appears → select ZIP B → cancel confirmation → ZIP A remains.
|
||||
- Repeat replacement → confirm → only ZIP B is shown and no late result from ZIP A can reappear.
|
||||
- Cancel the system file picker → workspace is unchanged.
|
||||
- Select the same ZIP twice through replacement → the second `change` is processed.
|
||||
- “Weitere ZIP hinzufügen” appends ZIP B and both archive editors remain visible.
|
||||
- Bulk export is absent for one processed archive and available for two or more.
|
||||
- “Aktuelle Bearbeitung leeren” cancel preserves state; confirm returns to the initial dropzone.
|
||||
- Slider changes preview width, works with arrow keys, and survives a reload through localStorage.
|
||||
- Settings opens with a German accessible name, closes on Escape/outside click, and returns focus to
|
||||
its trigger.
|
||||
- At 390 × 844, toolbar actions and every popover label fit without horizontal clipping; the panel
|
||||
stays inside the viewport and does not cover the archive header.
|
||||
|
||||
Then run:
|
||||
|
||||
```sh
|
||||
pnpm check
|
||||
pnpm test:e2e
|
||||
pnpm lint
|
||||
pnpm build
|
||||
```
|
||||
|
||||
### Manual
|
||||
|
||||
- Review 1440 × 900 and 390 × 844 with one archive, multiple archives, an extraction in progress,
|
||||
and an extraction error.
|
||||
- Confirm file processing remains entirely local and no case data is persisted.
|
||||
- Confirm tab order is title/status → new ZIP → settings → popover controls → archive controls.
|
||||
- Confirm reduced-motion mode introduces no menu animation requirement.
|
||||
|
||||
## Risks and mitigations
|
||||
|
||||
- **Accidental loss during replacement:** current state is not cleared until valid files are selected
|
||||
and the user confirms.
|
||||
- **Stale async extraction:** every reset increments `archiveGeneration`; old jobs cannot append
|
||||
results afterward.
|
||||
- **Duplicate picker logic:** centralize accept/filter/reset behavior before adding compact actions.
|
||||
- **Component bloat for a small menu:** use one popover and shared alert-dialog primitives; do not add
|
||||
tabs, a permanent sidebar, or a settings route.
|
||||
- **Ambiguous “new” versus “additional”:** keep the labels distinct and stable everywhere:
|
||||
“Neue ZIP bearbeiten” replaces; “Weitere ZIP hinzufügen” appends.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- Persisting archives or edits across reloads.
|
||||
- Detecting whether the operating system completed a download.
|
||||
- Undoing a confirmed workspace replacement.
|
||||
- Changing PDF composition, attachment ordering, or sub-document behavior.
|
||||
- A global application settings page.
|
||||
@@ -0,0 +1,237 @@
|
||||
# Plan: Anlagen-Bezeichnung — Anlagen nummerieren und benennen
|
||||
|
||||
## Goal
|
||||
|
||||
Add a new tool **„Anlagen nummerieren“** (`/tools/exhibits`): users drop any number of PDFs, sort
|
||||
them, choose an exhibit label („Anlage“, „Anlage K“, „Anlage B“, „Anlage XYZ“ or free text), a start
|
||||
number and the page scope, and receive each PDF stamped with its exhibit number — optionally renamed
|
||||
to `Anlage_K_06_Originalname.pdf` for court submission.
|
||||
|
||||
Context: mail thread with Nikolai Klute (26.09.2026 / 06.10.2026). The requirement is explicitly
|
||||
_one PDF = one Anlage = one number_, with sortable order, configurable label and counter start, and
|
||||
optional filename prefixing. Per-page counters are the separate pagination plan.
|
||||
|
||||
Related: `plan/done/page-numbers-plan.md` (shared overlay/renderer), `plan/done/stamp-plan.md`,
|
||||
`plan/done/batch-processing-plan.md`.
|
||||
|
||||
---
|
||||
|
||||
## Open decisions (defaults marked ✅)
|
||||
|
||||
### D1 — Numbering unit
|
||||
|
||||
- ✅ **A. One uploaded PDF = one Anlage = one number.** An „Anlage K 6“ may have many pages; the
|
||||
stamp identifies the document, not the sheet.
|
||||
- B. Group several PDFs into one Anlage. Not requested; deferred (a group would need its own reorder
|
||||
UI inside a group).
|
||||
|
||||
### D2 — Stamped pages
|
||||
|
||||
- ✅ **A. First page by default,** checkbox „Auf jeder Seite stempeln“ for every page of that
|
||||
exhibit.
|
||||
- B. Every page by default. Real exhibit stamps usually mark the first page; the stamp tool already
|
||||
covers „everywhere“.
|
||||
|
||||
### D3 — Look
|
||||
|
||||
- ✅ **A. Framed exhibit stamp:** `Anlage K 6` in title case, HelveticaBold 14 pt (slider 10–24),
|
||||
black `#1a1a1a` (color input included, same control as the stamp tool), 2 pt border, 6 pt padding,
|
||||
no rotation, no date, default position **oben rechts**, 20 pt margin; the six other positions are
|
||||
selectable.
|
||||
- B. Reuse the red authority stamp look. Wrong signal: an exhibit label is a designation, not an
|
||||
endorsement.
|
||||
|
||||
### D4 — Label and presets
|
||||
|
||||
- ✅ **A. Preset chips + editable text.** Chips `Anlage`, `Anlage K`, `Anlage B`, `Anlage XYZ`
|
||||
(`maxlength="40"`, default `Anlage`). The stamp text is `${prefix} ${number}` with a single space —
|
||||
no leading zeros in the stamp (`Anlage K 6`, not `Anlage K 06`).
|
||||
- B. Fixed list only. Firms use their own exhibit letters.
|
||||
|
||||
### D5 — Filename renaming
|
||||
|
||||
- ✅ **A. Optional, default on.** Pattern `{prefixSlug}_{paddedNumber}_{originalBase}.pdf`, e.g.
|
||||
`Anlage_K_06_Klageschrift.pdf`. Prefix slug: trim, collapse whitespace to `_`, strip
|
||||
`/\:*?"<>|` and control characters, keep German letters. Padding = `max(2, digits of highest
|
||||
assigned number)`, so 1…99 → `06`, 1…100 → `006`.
|
||||
Renaming off keeps the original file name exactly (ZIP duplicate names are still deduplicated by
|
||||
the batch ZIP helper).
|
||||
- B. Always rename. Users sometimes want to control file names themselves.
|
||||
|
||||
### D6 — Ordering
|
||||
|
||||
- ✅ **A. Drag-and-drop with a visible handle, plus up/down buttons.** Buttons are the keyboard and
|
||||
Playwright path; both call the same `moveItem(from, to)`. Reordering is disabled while a run is
|
||||
active.
|
||||
- B. Up/down only. Too slow for 30 exhibits.
|
||||
|
||||
### D7 — Number freezing
|
||||
|
||||
- ✅ **A. Numbers are frozen when a run starts** (`itemId → start + index`). Results keep the number
|
||||
they were stamped with even if the user reorders afterwards; the list shows
|
||||
„Bereits erstellte Anlagen behalten ihre Nummer.“ Retry uses the frozen number. A clean renumber
|
||||
means removing results and running again.
|
||||
- B. Live renumbering after processing. Would silently invalidate downloaded files and produce
|
||||
duplicate numbers.
|
||||
|
||||
### D8 — Deliverables
|
||||
|
||||
- ✅ **A. Per-row downloads + one ZIP „Alle herunterladen“** via the existing batch ZIP engine
|
||||
(`anlagen_2026-10-06.zip`). A merged „Anlagenkonvolut“ stays out of scope — „PDFs zusammenfügen“
|
||||
exists.
|
||||
- B. Always one merged PDF. Not requested and destroys per-exhibit files.
|
||||
|
||||
### D9 — Preview
|
||||
|
||||
- ✅ **A. Preview only for the selected row**, page 1 rendered on demand (shared
|
||||
`loadPdfPageRenderer` from the pagination plan), overlay scaled like the stamp preview; selecting
|
||||
another row disposes the previous handle.
|
||||
- B. Thumbnail per row. Renders every dropped PDF completely — memory trap.
|
||||
|
||||
---
|
||||
|
||||
## UX
|
||||
|
||||
Route `/tools/exhibits`, title „Anlagen nummerieren“, registry entry next to `stamp`:
|
||||
|
||||
```ts
|
||||
{
|
||||
slug: 'exhibits',
|
||||
title: 'Anlagen nummerieren',
|
||||
description: 'Nummerieren und benennen Sie Anlagen für die Einreichung — mit Anlagenstempel.',
|
||||
icon: 'files'
|
||||
}
|
||||
```
|
||||
|
||||
- **Header/description:** „Versehen Sie mehrere PDFs mit fortlaufenden Anlagen-Bezeichnungen und
|
||||
benennen Sie die Dateien passend um.“
|
||||
- **FileDropzone** (multiple) — „PDFs als Anlagen hinzufügen“; appending keeps the current list
|
||||
(same dedupe by name + size as the stamp tool).
|
||||
- **Numbered list** (`ExhibitFileList.svelte`, not `BatchFileList` — it needs reordering and number
|
||||
labels):
|
||||
- per row: drag handle, order badge, computed label („Anlage K 6“), file name, renamed preview
|
||||
(`→ Anlage_K_06_Klageschrift.pdf`), status chip, per-row download / retry / remove, and a
|
||||
„Vorschau“ selection.
|
||||
- live label updates for pending rows when order, prefix or start number changes; done rows show
|
||||
the frozen label.
|
||||
- **Options card**
|
||||
- Bezeichnung: preset chips + text input (`maxlength="40"`), live example „Anlage K 6“.
|
||||
- Startnummer (number input, default 1, min 0) + hint „3 Dateien → Anlage K 6, 7, 8“.
|
||||
- „Auf jeder Seite stempeln“ checkbox (default off).
|
||||
- Position (default oben rechts), Schriftgröße slider 10–24 (default 14), Farbe (default
|
||||
`#1a1a1a`).
|
||||
- „Dateien umbenennen“ checkbox (default on) with the filename pattern as helper text.
|
||||
- **Preview card:** selected row, page 1, overlay of the row's label at the chosen position; page
|
||||
count of the selected file shown (requires loading it — the renderer reports both).
|
||||
- **Primary action** „Anlagen erstellen“ → `runBatch` over all pending rows; then per-row downloads
|
||||
and the ZIP action. While running: „Datei 3 von 7 wird verarbeitet…“ (batch pattern) and
|
||||
reordering disabled.
|
||||
- Errors: encrypted PDFs become per-row error rows with the shared decryption hint; an empty prefix
|
||||
blocks the run with a German validation hint; too-long labels use the overlay's fit/error path.
|
||||
|
||||
## Technical approach
|
||||
|
||||
### Shared overlay and errors
|
||||
|
||||
- Uses `drawTextOverlay` from `src/lib/pdf-text-overlay.ts` (introduced by
|
||||
`plan/done/page-numbers-plan.md`) with `framed: true`, bold font, `compensatePageRotation: true`.
|
||||
- Uses `src/lib/pdf-errors.ts` for the encrypted-PDF message.
|
||||
- If the exhibit plan ships first, it creates both modules and the pagination plan reuses them.
|
||||
|
||||
### Batch engine
|
||||
|
||||
- Extend `runBatch`'s callback to `(file: File, item: BatchItem) => Promise<BatchProcessResult>` so
|
||||
the tool can resolve the frozen number per row (existing `(file) => …` callbacks remain valid).
|
||||
- Keep `shouldProcess`, per-row error formatting and the sequential loop unchanged.
|
||||
- Extract the ZIP/status logic that currently lives inline in `BatchFileList.svelte` into
|
||||
`src/lib/batch.ts` (`downloadBatchZip(items, zipBaseName)`, `batchStatusLabel`,
|
||||
`batchStatusClass`) and reuse it from `BatchFileList` and `ExhibitFileList` without behaviour
|
||||
changes.
|
||||
|
||||
### Naming helper (`src/lib/exhibit-naming.ts`)
|
||||
|
||||
```ts
|
||||
export const buildExhibitLabel = (prefix: string, number: number) => `${prefix.trim()} ${number}`;
|
||||
|
||||
export const buildExhibitFileName = (options: {
|
||||
prefix: string;
|
||||
number: number;
|
||||
highestNumber: number;
|
||||
originalName: string;
|
||||
}): string => {
|
||||
const base = options.originalName.replace(/\.pdf$/i, '');
|
||||
const slug = options.prefix
|
||||
.trim()
|
||||
.replace(/\s+/g, '_')
|
||||
.replace(/[/\\:*?"<>|\u0000-\u001f]/g, '');
|
||||
const width = Math.max(2, String(options.highestNumber).length);
|
||||
return `${slug}_${String(options.number).padStart(width, '0')}_${base}.pdf`;
|
||||
};
|
||||
```
|
||||
|
||||
### Stamping
|
||||
|
||||
- Load the PDF with pdf-lib, embed `StandardFonts.HelveticaBold`, draw the label on page 0 (or all
|
||||
pages), save.
|
||||
- Result name from the naming helper (or the original name when renaming is off). The batch result
|
||||
keeps the bytes in memory for download/ZIP.
|
||||
- Numbering is frozen at run start; `process` resolves `numbers.get(item.id) ?? freeze(item)`.
|
||||
|
||||
## Files changed
|
||||
|
||||
- `src/routes/tools/exhibits/+page.svelte` — new route.
|
||||
- `src/lib/components/ExhibitFileList.svelte` — reorderable list with status/downloads.
|
||||
- `src/lib/exhibit-naming.ts` — label + filename helpers.
|
||||
- `src/lib/batch.ts`, `src/lib/components/BatchFileList.svelte` — `runBatch` item context, shared
|
||||
ZIP/status helpers.
|
||||
- `src/lib/pdf-text-overlay.ts`, `src/lib/pdf-errors.ts`, `src/lib/pdf-thumbnails.ts` — shared
|
||||
helpers (whichever plan ships first creates them).
|
||||
- `src/lib/tools.ts`, `src/routes/+page.svelte` — registry + icon map (`Files`).
|
||||
- `tests/e2e/exhibits.spec.ts` — new spec.
|
||||
- `agents.md` — route, batch and shared-helper documentation.
|
||||
|
||||
## Implementation phases
|
||||
|
||||
1. Shared helpers (`pdf-text-overlay`, `pdf-errors`, page renderer) + stamp/compress regression.
|
||||
2. Batch refactor: `runBatch` item context, ZIP/status extraction, `BatchFileList` unchanged in
|
||||
behaviour.
|
||||
3. Naming helper + list component with reorder, live labels and selection.
|
||||
4. Route: options, stamping, per-row results, ZIP, error rows.
|
||||
5. Preview panel.
|
||||
6. Registry/icon/docs.
|
||||
7. Playwright coverage + command gauntlet.
|
||||
|
||||
## Verification
|
||||
|
||||
- Three PDFs (1, 2, 3 pages), reorder the second to the front, prefix `Anlage K`, start 6, renaming
|
||||
on → labels „Anlage K 6/7/8“, downloads named `Anlage_K_06_…`, `Anlage_K_07_…`, `Anlage_K_08_…`;
|
||||
page 1 of each contains the label, page 2 is untouched (`getPageContentStreamCount` unchanged).
|
||||
- Start 98 with three files → padding switches to three digits (`098`, `099`, `100`).
|
||||
- Renaming off → downloads keep the original names; ZIP entries deduplicate on collisions.
|
||||
- „Auf jeder Seite stempeln“ → every page of every exhibit contains the label.
|
||||
- Reorder after a run → done rows keep their label and the hint is visible.
|
||||
- Prefix validation (empty), encrypted PDF (row error, queue continues), 30-file ZIP.
|
||||
- Single-page PDFs do not break the run.
|
||||
- `pnpm check`, targeted `pnpm test:e2e`, `pnpm build`, Prettier on touched files.
|
||||
|
||||
## Risks
|
||||
|
||||
- **Number drift after edits:** mitigated by frozen numbers + hint; no silent renumber.
|
||||
- **Drag-and-drop accessibility:** buttons remain the primary path and the tested one; DnD must have
|
||||
a visible handle and keyboard alternative.
|
||||
- **Memory:** results stay in memory like every batch tool; the existing warning (>20 files or
|
||||
> 25 MB) applies. The preview renders only one page — never a thumbnail array.
|
||||
- **Filename edge cases:** slugs from unusual prefixes (`Anlage K/1`) must not produce path
|
||||
separators or empty names; validate and fall back to `Anlage`.
|
||||
- **Rotated scans:** the shared overlay compensates page rotation for the new tools; stamp output
|
||||
must stay unchanged.
|
||||
- **Already-prefixed input files:** re-running the tool on its own output stacks prefixes. Document
|
||||
it; no auto-detection in v1.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- Grouping multiple PDFs into one Anlage.
|
||||
- Anlagenverzeichnis / index page (a merged table of contents is a follow-up candidate).
|
||||
- One merged „Anlagenkonvolut“ export.
|
||||
- Per-page counters (pagination plan).
|
||||
- Persisted prefixes or exhibit libraries.
|
||||
@@ -0,0 +1,139 @@
|
||||
# Plan: PDF-Metadaten — anzeigen und bereinigen
|
||||
|
||||
## Goal
|
||||
|
||||
Add a new tool **"Metadaten"** (`/tools/metadata`) with two jobs:
|
||||
|
||||
1. **Inspect** a PDF: show document metadata (title, author, subject, keywords, creator,
|
||||
producer, creation/modification dates), page count, file size, and encryption state —
|
||||
lawyers need to know what a document reveals before forwarding it.
|
||||
2. **Clean** a PDF: strip the metadata fields and re-save, so the cleaned file can be shared
|
||||
without leaking software, author names, or edit history.
|
||||
|
||||
No rasterization is involved — this tool works directly with pdf-lib and preserves the document
|
||||
fully (vector text stays selectable). No new dependencies.
|
||||
|
||||
---
|
||||
|
||||
## Open decisions (defaults marked ✅)
|
||||
|
||||
### D1 — Cleaning strategy
|
||||
|
||||
- ✅ **A. Neutralize fields + re-save with pdf-lib.** Set title/author/subject/keywords to empty
|
||||
strings, creator/producer to neutral values, reset creation/modification dates to the cleaning
|
||||
moment. Saving through pdf-lib rebuilds the file without unreferenced objects. Simple and
|
||||
dependency-free.
|
||||
- B. Also attempt to remove embedded files/attachments. pdf-lib has no reliable public API to
|
||||
enumerate or remove all embedded-file entries; defer to D2.
|
||||
|
||||
### D2 — Embedded-file detection
|
||||
|
||||
- ✅ **A. Best-effort heuristic warning.** Scan the raw bytes for `/EmbeddedFile` and
|
||||
`/EmbeddedFiles` markers; if found, show "Die Datei enthält möglicherweise eingebettete
|
||||
Anlagen. Diese werden beim Bereinigen nicht entfernt." No removal in v1.
|
||||
- B. No detection at all. Rejected: silent ignorance is worse than an honest warning.
|
||||
|
||||
### D3 — Encrypted PDFs
|
||||
|
||||
- ✅ **A. Friendly rejection.** pdf-lib cannot load encrypted files; show a German hint pointing
|
||||
to `tools/decrypt` first. No silent failure.
|
||||
|
||||
---
|
||||
|
||||
## UX
|
||||
|
||||
- Route `/tools/metadata`, new entry in `src/lib/tools.ts`:
|
||||
`{ slug: 'metadata', title: 'Metadaten', description: 'Zeigen Sie Metadaten eines PDFs an und entfernen Sie sie.', icon: 'info' }`
|
||||
- icon mapping on the start page.
|
||||
- Flow: `PdfDropzone` (single PDF) → results card with:
|
||||
- **Info table** (label/value rows, empty values shown as "–"): Titel, Autor, Betreff,
|
||||
Schlüsselwörter, Erstellt mit (creator), PDF-Erzeuger (producer), Erstellt am, Geändert am,
|
||||
Seiten, Größe, Verschlüsselung (Ja/Nein — from load success/failure is insufficient; use
|
||||
pdf-lib's load error for encrypted detection and show it as a state, or report "Unbekannt"
|
||||
when not determinable).
|
||||
- **Embedded-file warning block** when the heuristic fires (amber styling consistent with
|
||||
existing warning patterns).
|
||||
- **Quality note**: "Beim Bereinigen bleibt der Inhalt vollständig erhalten (kein Qualitätsverlust)."
|
||||
- Actions:
|
||||
- **"Bereinigt herunterladen"** (primary, disabled for encrypted files) → cleaned PDF named
|
||||
`{baseName}_bereinigt.pdf`.
|
||||
- Secondary: "Original herunterladen" is _not_ offered (the user already has the original).
|
||||
- German inline error area for corrupt/encrypted files, matching existing tool pages.
|
||||
|
||||
## Technical approach
|
||||
|
||||
New route `src/routes/tools/metadata/+page.svelte` plus a small lib module
|
||||
`src/lib/pdf-metadata.ts`:
|
||||
|
||||
```ts
|
||||
export type PdfMetadataInfo = {
|
||||
title: string;
|
||||
author: string;
|
||||
subject: string;
|
||||
keywords: string[];
|
||||
creator: string;
|
||||
producer: string;
|
||||
creationDate: Date | null;
|
||||
modificationDate: Date | null;
|
||||
pageCount: number;
|
||||
embeddedFileSuspected: boolean;
|
||||
};
|
||||
|
||||
export const readPdfMetadata = async (bytes: Uint8Array): Promise<PdfMetadataInfo>;
|
||||
export const stripPdfMetadata = async (bytes: Uint8Array): Promise<Uint8Array>;
|
||||
```
|
||||
|
||||
- `readPdfMetadata`: `PDFDocument.load(bytes)` (with `{ ignoreEncryption: false }` default so
|
||||
encrypted files throw), then the `getTitle()`/`getAuthor()`/`getSubject()`/`getKeywords()`/
|
||||
`getCreator()`/`getProducer()`/`getCreationDate()`/`getModificationDate()`/`getPageCount()`
|
||||
accessors. Encryption detection: catch the pdf-lib `EncryptedPDFError` and return a distinct
|
||||
`{ encrypted: true }` result shape (discriminated union) so the UI can branch.
|
||||
- `stripPdfMetadata`: load, apply setters (`setTitle('')`, `setAuthor('')`, `setSubject('')`,
|
||||
`setKeywords([])`, `setCreator('')`, `setProducer('beA-Edit')`, `setCreationDate(new Date())`,
|
||||
`setModificationDate(new Date())`), `save()`.
|
||||
- Embedded-file heuristic: decode bytes with `TextDecoder` once and search for the two marker
|
||||
strings; cheap, runs before pdf-lib load.
|
||||
- Reading metadata does **not** need pdfjs or thumbnails — no rasterization limits apply; large
|
||||
files are fine because pdf-lib parses lazily enough for this purpose.
|
||||
|
||||
## Files changed
|
||||
|
||||
- `src/lib/pdf-metadata.ts` — new.
|
||||
- `src/routes/tools/metadata/+page.svelte` — new route.
|
||||
- `src/lib/tools.ts`, `src/routes/+page.svelte` — registry entry + icon map.
|
||||
- `tests/e2e/toolbox.spec.ts` — new spec block.
|
||||
- `agents.md` — structure update.
|
||||
|
||||
## Implementation phases
|
||||
|
||||
1. `pdf-metadata.ts` with read + strip + heuristic.
|
||||
2. Route UI: info table, warning block, clean action, encrypted-file branch.
|
||||
3. Registry entry + icon.
|
||||
4. Playwright tests: PDF created with `createPdf` + set metadata fields → fields displayed;
|
||||
clean download loads with pdf-lib and has empty author/title; encrypted-PDF rejection hint
|
||||
(reuse the encrypt tool to produce a fixture inline).
|
||||
5. Polish, German copy review, `pnpm check`/`lint`/`build`/`test:e2e`.
|
||||
|
||||
## Verification
|
||||
|
||||
- Metadata of a known PDF renders completely; empty fields show "–".
|
||||
- Cleaned PDF: open in viewer, metadata fields empty; content and page count identical; file
|
||||
still selectable/searchable (no rasterization happened).
|
||||
- Encrypted input shows the decrypt hint instead of crashing.
|
||||
- `/EmbeddedFile`-containing fixture triggers the amber warning.
|
||||
- Standard command gauntlet passes.
|
||||
|
||||
## Risks
|
||||
|
||||
- **XMP metadata**: pdf-lib's setters touch the Info dict; a separate XMP metadata stream can
|
||||
survive. Mitigation: note in the UI copy that cleaning covers standard document properties.
|
||||
(Full XMP removal could be a follow-up by deleting the metadata stream object.)
|
||||
- **Producer fingerprint**: setting `beA-Edit` as producer is itself a small fingerprint; neutral
|
||||
but acceptable — alternative is an empty string; pick empty string if reviewers prefer.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- Removing embedded files/attachments (see D2).
|
||||
- XMP stream removal (follow-up).
|
||||
- Metadata editing (rename title, etc.) — inspection + removal only.
|
||||
- Hooking metadata cleaning into the beA export pipeline.
|
||||
@@ -0,0 +1,250 @@
|
||||
# Plan: Paginierung — fortlaufende Blattnummern mit Aktenzeichen
|
||||
|
||||
## Goal
|
||||
|
||||
Add a new tool **„Paginieren“** (`/tools/pagination`) that stamps every page of one PDF with an
|
||||
ascending sheet number — the way authorities paginate a case file („Bl. 26“) — optionally combined
|
||||
with a static free text (e.g. an Aktenzeichen `123 Ja 60/26`) that repeats on every page (default) or
|
||||
only on the first page. The same operation is offered as a follow-up action after **„PDFs
|
||||
zusammenfügen“**.
|
||||
|
||||
Context: mail thread with Nikolai Klute (26.09.2026 / 06.10.2026). Two clarifications shape this
|
||||
plan: pagination needs a _simple_ free text next to the tool-generated number (no `{0}/{1}` template
|
||||
language), and that text should repeat „auf der ersten oder auf jeder Seite (Default)“.
|
||||
|
||||
Related: `plan/done/stamp-plan.md` already references this plan as `page-numbers-plan`.
|
||||
|
||||
---
|
||||
|
||||
## Open decisions (defaults marked ✅)
|
||||
|
||||
### D1 — Free-text model
|
||||
|
||||
- ✅ **A. Static free text + tool-generated number.** No placeholder syntax: the number is always
|
||||
produced by the tool, the free text is an optional label rendered with it. Directly matches
|
||||
Nikolai's answer („Einfach nur ein Text im Zusammenhang mit der Seitenzahl“).
|
||||
- B. Template field (`Seite {n} von {total}`). Rejected for v1: more validation and support surface
|
||||
than the use case needs; `{total}` is not requested.
|
||||
|
||||
### D2 — What repeats, what increments
|
||||
|
||||
- ✅ **A. Two independent scopes.** Page numbers always on every page (that is the point of
|
||||
pagination). The free text has its own scope: „Auf jeder Seite“ (default) or „Nur auf der ersten
|
||||
Seite“ — on page 2+ only the number remains.
|
||||
- B. One shared scope (text scope also gates numbering). Rejected: a first-page-only pagination is
|
||||
not a thing.
|
||||
|
||||
### D3 — Number format
|
||||
|
||||
- ✅ **A. Select with four fixed formats** — `Bl. {n}` (default), `Seite {n}`, `S. {n}`, `{n}`.
|
||||
Internally a prefix/suffix pair, so no user-visible template language, but „Bl.“ as the
|
||||
German-authority default. Free text is prepended as `„{Text}, “`.
|
||||
- B. Free prefix/suffix inputs. More knobs, more invalid combinations.
|
||||
- C. One free format string. Reopened D1.
|
||||
|
||||
### D4 — Look and placement
|
||||
|
||||
- ✅ **A. Restrained authority style:** Helvetica, 11 pt (slider 8–18), black `#1a1a1a`, no frame,
|
||||
no rotation, default position **unten mittig** with 28 pt margin; the six other positions are
|
||||
selectable via the existing `getPositionCoords` presets.
|
||||
- B. Reuse the stamp tool's framed/bold look. Wrong semantics: a page number is a neutral mark, not
|
||||
an authority stamp.
|
||||
|
||||
### D5 — Files per run
|
||||
|
||||
- ✅ **A. Exactly one PDF.** A Gesamtdokument is paginated in one pass; multi-document cases are
|
||||
handled by „PDFs zusammenfügen“ + handoff. Continuous numbering across several dropped files is a
|
||||
different (and silently wrong) semantic.
|
||||
- B. Batch with the same start number per file. Ambiguous for users; deferred until requested.
|
||||
|
||||
### D6 — Integration into „PDF Zusammenfügen“
|
||||
|
||||
- ✅ **A. Handoff to the pagination tool.** Merge writes the merged bytes into a `File`, queues it via
|
||||
the existing in-memory file handoff, and navigates to `/tools/pagination`, where options and live
|
||||
preview live. One implementation of pagination, preview included.
|
||||
- B. Inline pagination options on the merge page. Duplicated UI and no preview.
|
||||
|
||||
### D7 — Preview renderer
|
||||
|
||||
- ✅ **A. On-demand single-page renderer.** A paginated case file can have hundreds of pages;
|
||||
`loadPdfDocument` rasterises _every_ page up front. A new
|
||||
`loadPdfPageRenderer(bytes)` renders only the visible page and disposes the previous one.
|
||||
- B. Reuse `loadPdfDocument` (all thumbnails). Unacceptable for long documents.
|
||||
|
||||
---
|
||||
|
||||
## UX
|
||||
|
||||
Route `/tools/pagination`, title „Paginieren“, registry entry after `stamp`:
|
||||
|
||||
```ts
|
||||
{
|
||||
slug: 'pagination',
|
||||
title: 'Paginieren',
|
||||
description: 'Versehen Sie alle Seiten mit fortlaufenden Blattnummern und optional einem Aktenzeichen.',
|
||||
icon: 'list-ordered'
|
||||
}
|
||||
```
|
||||
|
||||
Page layout (stamp tool as the pattern: options left, preview right):
|
||||
|
||||
- **FileDropzone** (single PDF) — „PDF zum Paginieren hinzufügen“; shows the file name and page count
|
||||
after loading.
|
||||
- **Options card**
|
||||
- **Startnummer** (number input, default 1, min 1, max 99999) with hint „Seite 1 erhält diese
|
||||
Nummer.“
|
||||
- **Zusatztext (optional)** — e.g. Aktenzeichen, `maxlength="60"`, placeholder
|
||||
„z. B. 123 Ja 60/26“, plus radio/checkbox „Auf jeder Seite“ (default) / „Nur auf der ersten
|
||||
Seite“.
|
||||
- **Zahlenformat** select: `Bl. {n}`, `Seite {n}`, `S. {n}`, `{n}`.
|
||||
- **Position** select (default „Unten mittig“), **Schriftgröße** slider 8–18 pt (default 11).
|
||||
- Live example line: `123 Ja 60/26, Bl. 26`.
|
||||
- **Preview card** — the current page rendered at reading size with an absolutely positioned overlay
|
||||
(same technique as the stamp preview), page counter „Seite 3 von 42“, prev/next buttons and a
|
||||
„Springe zu Seite“ number input (long documents). The overlay shows the number that page will
|
||||
actually receive (`start + pageIndex`). Label it „Annäherung an die Ausgabe im PDF“.
|
||||
- **Primary action** „Paginieren & herunterladen“ → `{base}_paginiert.pdf`. Options are read at click
|
||||
time, so the button can be pressed again after changing settings.
|
||||
- Errors: encrypted PDF → the shared German hint pointing to „Passwort entfernen“; text too long for
|
||||
a page → German error instead of silent clipping.
|
||||
|
||||
## Technical approach
|
||||
|
||||
### Shared text overlay (`src/lib/pdf-text-overlay.ts`)
|
||||
|
||||
Extract the reusable parts of `src/routes/tools/stamp/+page.svelte` into a module used by stamp,
|
||||
pagination and exhibit numbering:
|
||||
|
||||
```ts
|
||||
export type TextOverlayOptions = {
|
||||
font: PDFFont;
|
||||
text: string;
|
||||
size: number;
|
||||
color: RGB;
|
||||
position: OverlayPosition;
|
||||
margin?: number;
|
||||
padding?: number;
|
||||
framed?: boolean;
|
||||
borderWidth?: number;
|
||||
rotation?: number; // degrees
|
||||
minSize?: number; // shrink floor before throwing
|
||||
compensatePageRotation?: boolean; // default false; new tools set true
|
||||
};
|
||||
|
||||
export const drawTextOverlay = (page: PDFPage, options: TextOverlayOptions) => void;
|
||||
```
|
||||
|
||||
- Owns fit-to-page shrinking (loop down to `minSize`), the rotated placement maths already present in
|
||||
the stamp route (`getRotatedStampPlacement`), optional border, and the text draw.
|
||||
- The stamp route is rewired to call it with `framed: true`, `rotation: value` and its bold font;
|
||||
behaviour must stay identical (guard: `tests/e2e/stamp.spec.ts`).
|
||||
- `compensatePageRotation` maps bottom-center on a `/Rotate 90` scan to the correct user-space anchor
|
||||
and counter-rotates the text. Stamp keeps `false` so its output does not change.
|
||||
- Move `isEncryptedPdfError` / the encrypted-PDF message into `src/lib/pdf-errors.ts` so all three
|
||||
tools share one wording.
|
||||
|
||||
### Pagination
|
||||
|
||||
```ts
|
||||
const numberText = `${formatPrefix}${startNumber + pageIndex}${formatSuffix}`;
|
||||
const textVisible = textScope === 'all' || pageIndex === 0;
|
||||
const line = textVisible && freeText.trim() ? `${freeText.trim()}, ${numberText}` : numberText;
|
||||
|
||||
for (const [pageIndex, page] of pdfDoc.getPages().entries()) {
|
||||
drawTextOverlay(page, {
|
||||
font,
|
||||
text: line,
|
||||
size,
|
||||
color: parseHexColor('#1a1a1a'),
|
||||
position,
|
||||
margin: 28,
|
||||
compensatePageRotation: true
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
- Embed one `StandardFonts.Helvetica` per document, save, name `${base}_paginiert.pdf`.
|
||||
- Page count comes from pdfjs (`numPages`) for the preview and from pdf-lib for processing; both are
|
||||
cheap, no full rasterisation.
|
||||
- **Renderer:** extend `src/lib/pdf-thumbnails.ts` with
|
||||
`loadPdfPageRenderer(bytes): Promise<{ pageCount, render(pageNumber), destroy() }>`; `render`
|
||||
builds the canvas from the requested page's own viewport (mixed page sizes stay correct), revokes
|
||||
the previous object URL, and `destroy` tears down the pdfjs loading task. Reused by the exhibit
|
||||
tool's page-1 preview.
|
||||
|
||||
### Merge integration
|
||||
|
||||
- Generalise `src/lib/pending-files.ts`: `queueZipFiles` / `takeQueuedZipFiles` become
|
||||
`queuePendingFiles` / `takePendingFiles` (same behaviour; update `src/routes/+page.svelte` and
|
||||
`src/routes/tools/bea/+page.svelte`).
|
||||
- `src/routes/tools/merge/+page.svelte`: keep „PDFs zusammenfügen“ (direct download) and add
|
||||
„Zusammenfügen & paginieren“, which merges, wraps the bytes in
|
||||
`new File([bytes], 'zusammengefuegt.pdf', { type: 'application/pdf' })`, queues it and calls
|
||||
`goto('/tools/pagination')`. Keep the change minimal — the page still uses older styling tokens;
|
||||
do not restyle it in this task.
|
||||
- The pagination route consumes `takePendingFiles()` in `onMount`, accepts the first PDF and ignores
|
||||
anything else defensively.
|
||||
|
||||
### Registry and docs
|
||||
|
||||
- `src/lib/tools.ts` + `iconMap` in `src/routes/+page.svelte` (`ListOrdered`).
|
||||
- `agents.md`: new route, shared overlay/error helpers, renderer, pending-file naming, merge handoff.
|
||||
|
||||
## Files changed
|
||||
|
||||
- `src/routes/tools/pagination/+page.svelte` — new route.
|
||||
- `src/lib/pdf-text-overlay.ts`, `src/lib/pdf-errors.ts` — extracted shared helpers.
|
||||
- `src/routes/tools/stamp/+page.svelte` — use the shared helpers (no behaviour change).
|
||||
- `src/lib/pdf-thumbnails.ts` — add `loadPdfPageRenderer`.
|
||||
- `src/lib/pending-files.ts`, `src/routes/+page.svelte`, `src/routes/tools/bea/+page.svelte` —
|
||||
generic pending-file naming.
|
||||
- `src/routes/tools/merge/+page.svelte` — handoff button.
|
||||
- `src/lib/tools.ts`, `src/routes/+page.svelte` — registry + icon map.
|
||||
- `tests/e2e/pagination.spec.ts`, `tests/e2e/merge.spec.ts` — new/updated specs.
|
||||
- `agents.md` — structure and flow documentation.
|
||||
|
||||
## Implementation phases
|
||||
|
||||
1. Extract `pdf-text-overlay.ts` + `pdf-errors.ts`, rewire stamp, keep `stamp.spec.ts` green.
|
||||
2. Add `loadPdfPageRenderer` and use it for the preview.
|
||||
3. Build the pagination route (options, live preview, export, errors).
|
||||
4. Merge handoff + generic pending files.
|
||||
5. Registry/icon/docs.
|
||||
6. Playwright coverage + command gauntlet.
|
||||
|
||||
## Verification
|
||||
|
||||
- 3-page PDF, start 26, text `123 Ja 60/26`, all pages → each page's content stream contains
|
||||
`123 Ja 60/26, Bl. 26/27/28`; page count and page order preserved; download named
|
||||
`akte_paginiert.pdf`.
|
||||
- First-page-only text → page 1 contains the Aktenzeichen, page 2 contains `Bl. 2` but not the
|
||||
Aktenzeichen; the original page content is preserved on every page.
|
||||
- Formats `Seite {n}` / `{n}` and empty free text → expected strings.
|
||||
- Preview: navigating to page 2 shows `Bl. 27` (start 26); changing the start number updates the
|
||||
preview before export.
|
||||
- Rotated input (`createPreRotatedPdf`) → number appears at the visual bottom-center (test asserts
|
||||
the draw coordinates in the content stream or compares stream text plus position).
|
||||
- Merge: two PDFs (1 + 2 pages) → „Zusammenfügen & paginieren“ lands on `/tools/pagination` with a
|
||||
3-page document; running produces continuous `Bl. 1…3`.
|
||||
- Encrypted PDF → German error, no download.
|
||||
- `pnpm check`, targeted `pnpm test:e2e`, `pnpm build`, Prettier on touched files.
|
||||
|
||||
## Risks
|
||||
|
||||
- **Long documents:** never call `loadPdfDocument` here; one page in memory at a time. The preview
|
||||
navigation must not leak object URLs or pdfjs tasks.
|
||||
- **Text length on narrow pages:** fit-to-page shrinks to `minSize`; below that, fail with a German
|
||||
message rather than clipping.
|
||||
- **Unsupported characters:** StandardFonts use WinAnsi; validate the free text (e.g. no emoji) and
|
||||
show a German hint instead of a pdf-lib save error.
|
||||
- **Rotated pages:** compensation is new logic; if the mapping turns out unreliable, ship v1 with a
|
||||
visible warning for rotated pages instead of silently misplaced numbers.
|
||||
- **Preview fidelity:** browser font metrics differ slightly from pdf-lib; the panel is labelled as
|
||||
an approximation.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- Placeholder templates (`{total}`, Roman numerals, ranges) and numbering only selected pages.
|
||||
- Multiple files per run with continuous numbering (use merge first).
|
||||
- Persisted pagination settings.
|
||||
- Automatic pagination inside the beA archive export.
|
||||
@@ -0,0 +1,140 @@
|
||||
# Plan: Seiten drehen
|
||||
|
||||
## Goal
|
||||
|
||||
Add a new tool **"Seiten drehen"** (`/tools/rotate`) that fixes page orientation — the classic
|
||||
case being scanned court mail with individual pages rotated 90°/180°. Users rotate pages
|
||||
individually or all at once; the result is saved via the standard PDF `/Rotate` mechanism, so the
|
||||
operation is **lossless and instant** (no rasterization, no re-encoding).
|
||||
|
||||
No new dependencies.
|
||||
|
||||
---
|
||||
|
||||
## Open decisions (defaults marked ✅)
|
||||
|
||||
### D1 — How rotation is applied
|
||||
|
||||
- ✅ **A. pdf-lib `setRotation` (document-level `/Rotate`).** `page.setRotation(degrees(angle))`
|
||||
writes the page's rotation entry; every viewer honors it. Lossless, fast, file size unchanged.
|
||||
Existing rotation of a page (from scanners that already set `/Rotate`) is read via
|
||||
`page.getRotation().angle` and added to, so the user's delta is always applied on top of the
|
||||
current state.
|
||||
- B. Rasterize-and-rotate pixels. Unnecessary quality loss and memory cost; rejected.
|
||||
|
||||
### D2 — Grid capabilities
|
||||
|
||||
- ✅ **Extend `PdfPageGrid` with an optional action snippet.** The current grid renders fixed
|
||||
cards. Add an optional snippet prop (e.g. `pageActions`) rendered below each thumbnail; existing
|
||||
call sites (separate, watermark, extract) pass nothing and look unchanged. The rotate page uses
|
||||
it for per-page rotate buttons and a rotation badge.
|
||||
- B. Fork the grid inside the rotate page. Rejected: duplicates the accessible grid work.
|
||||
|
||||
---
|
||||
|
||||
## UX
|
||||
|
||||
- Route `/tools/rotate`, new `src/lib/tools.ts` entry:
|
||||
`{ slug: 'rotate', title: 'Seiten drehen', description: 'Drehen Sie einzelne oder alle Seiten eines PDFs.', icon: 'rotate-cw' }`
|
||||
- icon map entry.
|
||||
- After load (`loadPdfDocument` + thumbnails like separate):
|
||||
- Quick-action bar: **"Alle 90° rechts"**, **"Alle 90° links"**, **"Alle 180°"**,
|
||||
**"Zurücksetzen"** (clears all deltas).
|
||||
- Page grid: each card shows the thumbnail with a CSS rotation preview
|
||||
(`transform: rotate({total}deg)` on the `<img>`, contained in a square-ish frame so rotated
|
||||
pages still fit), a small badge when the total rotation is non-zero (e.g. "90°"), and two
|
||||
icon buttons (rotate left / rotate right) provided through the new `pageActions` snippet.
|
||||
Buttons are real `<button>` elements with `aria-label="Seite 3 um 90° nach rechts drehen"`.
|
||||
- Count label: "4 von 12 Seiten gedreht".
|
||||
- Primary button **"Drehen & herunterladen"** → `{baseName}_gedreht.pdf`. Disabled when no page
|
||||
has a non-zero total rotation change.
|
||||
- Keyboard operability is inherent (all buttons); no drag interactions required at all.
|
||||
|
||||
## Technical approach
|
||||
|
||||
### State
|
||||
|
||||
```ts
|
||||
let rotations = $state(new Map<number, number>()); // pageNumber → delta degrees (0/90/180/270)
|
||||
```
|
||||
|
||||
- `rotatePage(pageNumber, delta)`: `next = ((current + delta) % 360 + 360) % 360`; store; delete
|
||||
entry when back to 0 (keeps "gedreht" count honest).
|
||||
- Preview angle: `originalRotation(page) + delta` — the thumbnail renderer cannot know the
|
||||
original `/Rotate` without pdf-lib; simplest correct-enough approach: apply only the delta to
|
||||
the preview (pdfjs already renders pages honoring `/Rotate`, so the preview matches what the
|
||||
user sees plus their delta). No need to read pdf-lib during preview.
|
||||
|
||||
### Export
|
||||
|
||||
```ts
|
||||
const pdfDoc = await PDFDocument.load(bytes);
|
||||
for (const page of pdfDoc.getPages()) {
|
||||
const delta = rotations.get(pageIndexPlusOne);
|
||||
if (!delta) continue;
|
||||
const current = page.getRotation().angle; // respects existing /Rotate
|
||||
page.setRotation(degrees((((current + delta) % 360) + 360) % 360));
|
||||
}
|
||||
downloadBlob(await pdfDoc.save(), `${baseName}_gedreht.pdf`);
|
||||
```
|
||||
|
||||
- Instant for any file size — no rasterization limits needed, but keep the shared load error
|
||||
handling (encrypted files: pdf-lib load fails → German error; optionally hint to decrypt like
|
||||
the metadata plan does).
|
||||
|
||||
### `PdfPageGrid` extension (backward compatible)
|
||||
|
||||
```ts
|
||||
type Props = {
|
||||
thumbnails: PdfThumbnail[];
|
||||
selectedPages?: Set<number>;
|
||||
onPageClick?: (pageNumber: number) => void;
|
||||
thumbnailWidth?: number;
|
||||
class?: string;
|
||||
pageActions?: Snippet<[pageNumber: number]>; // NEW, optional
|
||||
cardClass?: (pageNumber: number) => string; // NEW, optional — rotate preview transform hook
|
||||
};
|
||||
```
|
||||
|
||||
- When `pageActions` is provided, render `{@render pageActions(thumb.pageNumber)}` inside the
|
||||
card. When `cardClass` is provided, merge it into the image wrapper via `cn()`. Existing pages
|
||||
need no changes.
|
||||
|
||||
## Files changed
|
||||
|
||||
- `src/lib/components/PdfPageGrid.svelte` — optional snippet/class props.
|
||||
- `src/routes/tools/rotate/+page.svelte` — new route.
|
||||
- `src/lib/tools.ts`, `src/routes/+page.svelte` — registry + icon map.
|
||||
- `tests/e2e/toolbox.spec.ts` — spec block.
|
||||
- `agents.md` — structure update.
|
||||
|
||||
## Implementation phases
|
||||
|
||||
1. `PdfPageGrid` extension; run existing e2e to prove separate/watermark visuals unchanged.
|
||||
2. Rotate route: state, quick actions, grid with badges/buttons, CSS preview rotation.
|
||||
3. Export via `setRotation`.
|
||||
4. Registry + icon.
|
||||
5. Playwright: rotate page 2 by 90° twice via its buttons, download, load output with pdf-lib,
|
||||
assert `getPage(1).getRotation().angle === 180`; "Zurücksetzen" disables the download button.
|
||||
6. Polish + `pnpm check`/`lint`/`build`/`test:e2e`.
|
||||
|
||||
## Verification
|
||||
|
||||
- Single page rotate, all-page rotate, reset flows.
|
||||
- A PDF whose pages already carry `/Rotate=90` gains a correct cumulative rotation (assert in e2e
|
||||
by creating such a fixture with pdf-lib).
|
||||
- Output opens with correct orientation in a viewer; file size ~unchanged.
|
||||
- Grid cards stay within layout bounds when rotated (object-contain + fixed aspect frame).
|
||||
|
||||
## Risks
|
||||
|
||||
- **CSS preview vs. real rotation mismatch** for pages that already carry `/Rotate`: pdfjs
|
||||
thumbnails render the page as displayed; the preview adds the delta visually — acceptable.
|
||||
- Rotated landscape images inside the grid may look small in portrait cards; use `object-contain`
|
||||
and a square min-height frame.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- Auto-detection of upside-down scans (needs heuristics/OCR).
|
||||
- Rotating arbitrary selections only via a separate tool (quick actions + per-page buttons cover
|
||||
it).
|
||||
@@ -0,0 +1,139 @@
|
||||
# Plan: Stempeln — Textstempel auf PDFs
|
||||
|
||||
## Goal
|
||||
|
||||
Add a new tool **"Stempeln"** (`/tools/stamp`) that places opaque, framed text stamps on PDF
|
||||
pages — "BEGLAUBIGT", "EILT", "ENTWURF", "KOPIE", "AUSFERTIGUNG", plus free text with an optional
|
||||
date, or an image. This is a staple of German legal/case-file work and is deliberately separate from the
|
||||
watermark tool:
|
||||
|
||||
- Watermark = semi-transparent, decorative/repetitive, many positions.
|
||||
- Stamp = fully opaque, framed, authoritative marking, usually one per page.
|
||||
|
||||
Pure pdf-lib drawing (text + rectangle border): lossless, instant. No new dependencies.
|
||||
|
||||
---
|
||||
|
||||
## Open decisions (defaults marked ✅)
|
||||
|
||||
### D1 — Stamp look
|
||||
|
||||
- ✅ **A. Uppercase bold text inside a plain rectangle border** (2 pt border, padding 8 pt,
|
||||
default color red `#cc1a1a`, matching the existing brand red used by the watermark default).
|
||||
pdf-lib has no rounded-rectangle primitive; plain rectangle is the honest option.
|
||||
- B. Borderless colored text. Possible but reads as a watermark, not a stamp.
|
||||
|
||||
### D2 — Presets
|
||||
|
||||
- ✅ **Editable preset chips + free text.** Chips: BEGLAUBIGT, AUSFERTIGUNG, KOPIE, EILT,
|
||||
ENTWURF, ERLEDIGT. Selecting a chip fills the text input; the input stays editable. Optional
|
||||
checkbox "Datum anhängen" appends `dd.mm.yyyy` (German format).
|
||||
- B. Fixed presets only. Rejected: courts/firms love their own wording.
|
||||
|
||||
### D3 — Relation to the watermark tool
|
||||
|
||||
- ✅ **Separate tool**, sharing extracted helpers from `src/lib/pdf-overlay.ts`
|
||||
(position coordinate computation + hex color parsing — see page-numbers-plan; whichever plan is
|
||||
implemented first creates the module). Keeps discovery simple (one tile per job) and avoids
|
||||
overloading the watermark UI with a mode switch.
|
||||
|
||||
---
|
||||
|
||||
## UX
|
||||
|
||||
- Route `/tools/stamp`, new `src/lib/tools.ts` entry:
|
||||
`{ slug: 'stamp', title: 'Stempeln', description: 'Versehen Sie Seiten mit Textstempeln wie „Beglaubigt“ oder „Eilt“.', icon: 'stamp' }`
|
||||
(`stamp` is a real Lucide icon name) + icon map entry.
|
||||
- After load (`loadPdfDocument` for thumbnails):
|
||||
- **Options card**:
|
||||
- Preset chip row (buttons; selected chip highlighted).
|
||||
- Text input (bound, default "BEGLAUBIGT"), "Datum anhängen" checkbox.
|
||||
- Schriftgröße slider (12–48 pt, default 24).
|
||||
- Farbe color input (default `#cc1a1a`).
|
||||
- Position select — same six positions + center as watermark
|
||||
(reuses shared position helper; no "diagonal" for stamps).
|
||||
- Rotation slider (−45°…45°, default 0) — stamps are often applied slightly tilted.
|
||||
- Apply-to: "Auf alle Seiten anwenden" checkbox + `PdfPageGrid` selection when off
|
||||
(watermark pattern).
|
||||
- **Live preview**: rendered current page (pdfjs) with an absolutely-positioned overlay div
|
||||
styled like the stamp (border, uppercase, color, rotation) at the chosen position; page
|
||||
navigation. Approximation is fine, same approach as page-numbers-plan.
|
||||
- Primary button **"Stempel anwenden & herunterladen"** → `{baseName}_stempel.pdf`.
|
||||
|
||||
## Technical approach
|
||||
|
||||
### Export (pdf-lib)
|
||||
|
||||
```ts
|
||||
const font = await pdfDoc.embedFont(StandardFonts.HelveticaBold);
|
||||
const stampText = `${textInput.trim().toUpperCase()}${withDate ? ` ${formatGermanDate(new Date())}` : ''}`;
|
||||
const textWidth = font.widthOfTextAtSize(stampText, size);
|
||||
const boxWidth = textWidth + 2 * padding;
|
||||
const boxHeight = font.heightAtSize(size) + 2 * padding;
|
||||
|
||||
for (const pageIndex of targetPages) {
|
||||
const { width, height } = pages[pageIndex].getSize();
|
||||
const { x, y } = getPositionCoords(width, height, boxWidth, boxHeight, position); // shared helper
|
||||
pages[pageIndex].drawRectangle({
|
||||
x, y, width: boxWidth, height: boxHeight,
|
||||
borderColor: parseHexColor(colorValue), borderWidth: 2
|
||||
});
|
||||
pages[pageIndex].drawText(stampText, {
|
||||
x: x + padding, y: y + padding + /* baseline adjust */, size, font,
|
||||
color: parseHexColor(colorValue), rotate: degrees(rotationValue)
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
Details:
|
||||
|
||||
- Rotation is applied to the text; the border rectangle stays axis-aligned for v1 (rotated
|
||||
rectangles need manual path drawing — follow-up if users care).
|
||||
- Baseline adjustment: use `font.heightAtSize(size) * 0.2` descent approximation; verify visually.
|
||||
- Uppercasing happens in JS (`toUpperCase()` — German umlauts are fine in WinAnsi: ä/ö/ü/ß are
|
||||
supported except ẞ; uppercase ß → SS via `toLocaleUpperCase('de')` acceptable).
|
||||
- Encrypted input → German error + decrypt hint (shared pattern).
|
||||
|
||||
### Shared helpers
|
||||
|
||||
Depends on `src/lib/pdf-overlay.ts` (position presets + `parseHexColor`). If page-numbers or
|
||||
stamp ships first, it creates the module and rewires watermark; the second consumer just imports.
|
||||
|
||||
## Files changed
|
||||
|
||||
- `src/routes/tools/stamp/+page.svelte` — new route.
|
||||
- `src/lib/pdf-overlay.ts` — shared helpers (created here if not already present).
|
||||
- `src/lib/tools.ts`, `src/routes/+page.svelte` — registry + icon map.
|
||||
- `tests/e2e/toolbox.spec.ts` — spec block.
|
||||
- `agents.md` — structure update.
|
||||
|
||||
## Implementation phases
|
||||
|
||||
1. Shared overlay helpers (or import if they exist) — watermark regression must stay green.
|
||||
2. Route UI: presets, inputs, preview overlay, page selection.
|
||||
3. Export + download.
|
||||
4. Registry + icon.
|
||||
5. Playwright: preset "EILT" on all pages of a 3-page PDF → output valid, 3 pages; custom text
|
||||
- date checkbox adds text; selection-only mode leaves other pages unmodified (compare page
|
||||
content-stream sizes via pdf-lib if practical, else file-level validity + count).
|
||||
6. Polish + command gauntlet.
|
||||
|
||||
## Verification
|
||||
|
||||
- Every preset chip fills the input; the stamp renders uppercase in preview and output.
|
||||
- Date uses German `dd.mm.yyyy` and updates on the export day (not cached).
|
||||
- Rotation visually matches preview within tolerance.
|
||||
- Watermark tool unchanged (regression).
|
||||
|
||||
## Risks
|
||||
|
||||
- **Text width overflow** on small pages with long custom text + huge font: clamp computed box to
|
||||
page size minus margins and shrink font if needed (simple guard loop), with a German hint.
|
||||
- Preview overlay approximation may differ a few points from export — acceptable, labeled.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- Image stamps (logo stamps) — users can route through the watermark image mode for that.
|
||||
- Rotated border rectangles, multi-stamps per page in one run (run the tool twice; output can be
|
||||
re-loaded).
|
||||
- Stamp "libraries" persisted across sessions.
|
||||
@@ -0,0 +1,126 @@
|
||||
# Plan: Start page — make the beA context obvious
|
||||
|
||||
**Status: Done**
|
||||
|
||||
## Problem
|
||||
|
||||
The start page (`src/routes/+page.svelte`) presents itself as a generic "PDF-Werkzeuge" site:
|
||||
|
||||
- Hero headline: **"PDF-Werkzeuge"**
|
||||
- ZIP dropzone: label **"ZIP-Archiv bearbeiten"**, sublabel "ZIP-Datei hier ablegen oder zum
|
||||
Auswählen klicken", aria label "ZIP-Archiv bearbeiten"
|
||||
- Drag overlay: **"ZIP-Archiv hier ablegen"**
|
||||
- `<title>`: `juri-merger — PDF-Werkzeuge`
|
||||
|
||||
The ZIP the user is supposed to drop here is, in reality, an archive exported from the
|
||||
**besondere elektronische Anwaltspostfach (beA)** — that's what `/tools/bea` processes and what
|
||||
the whole product ("beA-Edit", see `agents.md`) exists for. Nothing on the start page says so.
|
||||
A user seeing any random ZIP has no hint which one belongs in this slot.
|
||||
|
||||
Goal: keep the toolbox-hub structure (hero + beA entry point + tool tiles), but reframe the copy
|
||||
so that (a) the beA origin of the expected ZIP is unmistakable and (b) the overall positioning
|
||||
shifts from generic "PDF tools" to "workbench for beA exports (+ extra PDF tools)".
|
||||
|
||||
## Target copy (proposal)
|
||||
|
||||
### 1. Hero / page identity
|
||||
|
||||
- `<title>`: from `juri-merger — PDF-Werkzeuge` to
|
||||
`beA-Edit — Werkzeugkasten für das besondere elektronische Anwaltspostfach` (long form) or
|
||||
`… für beA-Archive` (short form, see D1).
|
||||
- H1: from `PDF-Werkzeuge` to **`Werkzeugkasten für beA`**.
|
||||
- Subline: currently only the generic privacy line. Replace with a lead sentence explaining beA:
|
||||
„Bereiten Sie ZIP-Archive aus dem besonderen elektronischen Anwaltspostfach (beA) auf — plus
|
||||
praktische PDF-Werkzeuge.“ — then keep the existing privacy line as a second sentence.
|
||||
|
||||
This keeps "PDF-Werkzeuge" discoverable for anyone who knew the old wording but leads with beA.
|
||||
|
||||
### 2. ZIP entry point (the core fix)
|
||||
|
||||
- Label: **„beA-ZIP öffnen"** instead of "ZIP-Archiv bearbeiten".
|
||||
- Sublabel: „ZIP aus dem besonderen elektronischen Anwaltspostfach (beA) hier ablegen oder zum
|
||||
Auswählen klicken".
|
||||
- Add a small example/hint line inside or under the dropzone:
|
||||
„Tipp: In beA exportierte Archive enden meist auf `.zip` und enthalten Anlagen als PDF."
|
||||
(exact wording see D2 — including whether to show typical archive contents)
|
||||
- Icon inside the dropzone next to the "+" (e.g. Lucide `Mail`/`Postbox`-like or `Archive`)
|
||||
to visually distinguish it from the plain-PDF tool tiles below.
|
||||
- Dropzone keeps `accept=".zip,…"`. No functional change to filtering — dropping any ZIP still
|
||||
routes to `/tools/bea`.
|
||||
|
||||
### 3. Full-window drag overlay
|
||||
|
||||
- Text: **„beA-ZIP hier ablegen"** (currently "ZIP-Archiv hier ablegen"), optionally with the
|
||||
subline „Archive aus dem besonderen elektronischen Anwaltspostfach".
|
||||
|
||||
### 4. Tool grid section heading
|
||||
|
||||
- Currently: „Werkzeuge". Change to **„Weitere PDF-Werkzeuge"** — this is where the words
|
||||
"PDF-Werkzeuge" live on after the hero shift, clarifying these tiles are the _secondary_,
|
||||
general-purpose part while beA sits above.
|
||||
|
||||
## Code touchpoints
|
||||
|
||||
All copy lives in exactly three files; no component API changes required:
|
||||
|
||||
1. `src/routes/+page.svelte`
|
||||
|
||||
- `<title>`, H1 + sublines in the Hero block
|
||||
- props passed to `FileDropzone`: `ariaLabel`, `label`, `sublabel` (add a hint element below
|
||||
the dropzone if D2 decides for it)
|
||||
- drag overlay text
|
||||
- "Werkzeuge" → "Weitere PDF-Werkzeuge"
|
||||
|
||||
1. `src/routes/+layout.svelte` — only if global header/footer copy mentions the product role
|
||||
(check during implementation; likely unchanged).
|
||||
2. `tests/e2e/toolbox.spec.ts`
|
||||
- Line ~88 asserts the heading name `'PDF-Werkzeuge'` → update to the new H1
|
||||
(`'Werkzeugkasten für beA'`).
|
||||
- Any other selectors keyed on the old labels must be swept (search tests for
|
||||
"ZIP-Archiv" / "PDF-Werkzeuge").
|
||||
|
||||
Accessibility notes:
|
||||
|
||||
- The new sublabel contains the full term + abbreviation once per page; use `abbr`-free plain
|
||||
text (screen readers handle "beA" fine as-is). Keep `aria-label` identical to the visible
|
||||
label to avoid divergence.
|
||||
|
||||
## Open decisions (defaults marked ✅)
|
||||
|
||||
### D1 — How far to push the rebrand?
|
||||
|
||||
- ✅ **A. Lead with beA, keep toolbox**: H1 "Werkzeugkasten für beA", tool grid becomes
|
||||
"Weitere PDF-Werkzeuge". Minimal risk, preserves both audiences.
|
||||
- B. Full rename to "beA-Editor": hero only about beA, tools demoted into an opt-in section
|
||||
further down. Stronger positioning, hides the newer tools behind scroll/click.
|
||||
|
||||
### D2 — Where does the explainer text live?
|
||||
|
||||
- ✅ **A. Short hint line inside/below the dropzone** (one sentence naming "besonderes
|
||||
elektronisches Anwaltspostfach"). Cheap, always visible where it matters.
|
||||
- B. Info popover/question-mark icon expanding details (typical file names, how to export from
|
||||
beA). More room, more moving parts.
|
||||
- C. Both A and B if users actually need export instructions (needs user feedback).
|
||||
|
||||
### D3 — Also update other beA-named strings for consistency?
|
||||
|
||||
- ✅ **A. Yes, sweep for consistency**: e.g. documents/README references and `ZipDropzone.svelte`
|
||||
sublabels inside `/tools/bea` should also say "beA-ZIP" so terminology matches across pages.
|
||||
Copy-only sweep, no behavior change.
|
||||
- B. Only the start page; leave inner pages untouched.
|
||||
|
||||
## Implementation phases
|
||||
|
||||
Single phase — this is copy + one test file.
|
||||
|
||||
1. Apply copy changes in `src/routes/+page.svelte` (+ optional consistency sweep per D3).
|
||||
2. Update `tests/e2e/toolbox.spec.ts` expectations.
|
||||
3. Run `pnpm check`, `pnpm lint`, `pnpm build`, and the e2e suite.
|
||||
4. Manual browser pass: hero, dropzone, drag-overlay, keyboard focus order unchanged;
|
||||
verify window-wide drag of a ZIP still lands on `/tools/bea`.
|
||||
5. Update `agents.md` / README wording if D3 = A touches those files.
|
||||
|
||||
## Risks
|
||||
|
||||
- Very low. Pure copy change; only test breakage possible (heading/label assertions), covered
|
||||
by running the spec suite. SSR-safe: no new logic, all changes static markup.
|
||||
@@ -0,0 +1,259 @@
|
||||
# Plan: Start-page redesign — PDF toolbox
|
||||
|
||||
## Goal
|
||||
|
||||
Today the start page (`src/routes/+page.svelte`) offers exactly one entry point: the ZIP dropzone
|
||||
that starts the beA archive workflow. This redesign turns the start page into a **toolbox hub**:
|
||||
|
||||
1. The existing **ZIP upload** (beA workflow) stays available — visually prominent as the
|
||||
"hero" action of the product.
|
||||
2. Eight additional tools become reachable as tiles from the start page:
|
||||
- **Wasserzeichen** — add a watermark to a PDF
|
||||
- **Signieren** — sign a PDF
|
||||
- **Passwort setzen** — encrypt a PDF with a password
|
||||
- **Passwort entfernen** — unlock a password-protected PDF
|
||||
- **Konvertieren** — convert a PDF
|
||||
- **Komprimieren** — compress a PDF
|
||||
- **Seiten entfernen** — remove pages from (separate) a PDF
|
||||
- **PDFs zusammenfügen** — merge PDFs
|
||||
|
||||
All processing remains **100 % client-side** (no uploads, no network requests, no persistence),
|
||||
in line with the project's privacy constraints.
|
||||
|
||||
---
|
||||
|
||||
## Target UX
|
||||
|
||||
### Start page (`/`)
|
||||
|
||||
- The Nominandum header/footer and overall visual language stay untouched.
|
||||
- A compact hero area with the product name and a one-line value proposition.
|
||||
- A **large primary tile** for the beA workflow ("ZIP-Archiv bearbeiten") — it keeps the
|
||||
full-height dropzone feel: dragging a ZIP anywhere on the start page still starts the workflow.
|
||||
- A **grid of secondary tool tiles** (2–3 columns responsive, `grid` + `gap-*`, white cards on the
|
||||
pale blue-gray background, Lucide icon + German title + one-line description, visible focus and
|
||||
keyboard operability — tiles are real links/buttons, not divs).
|
||||
- German copy throughout (project constraint).
|
||||
|
||||
### Tool pages
|
||||
|
||||
- Each tool gets its **own route** under `src/routes/tools/…` (see D1):
|
||||
`tools/bea`, `tools/merge`, `tools/separate`, `tools/watermark`, `tools/sign`,
|
||||
`tools/encrypt`, `tools/decrypt`, `tools/convert`, `tools/compress`.
|
||||
- A shared layout (`tools/+layout.svelte`) provides a consistent shell: back-link to `/`,
|
||||
tool title, and the file-pick area.
|
||||
- The existing ZIP workflow component tree (`ZipDropzone`, `ProcessedZipArchiveEditor`,
|
||||
`SubDocumentEditor`, `AttachmentPreview`) moves to `tools/bea` essentially unchanged.
|
||||
|
||||
---
|
||||
|
||||
## Technical approach per tool
|
||||
|
||||
| Tool | Route | Input | Processing | Output |
|
||||
| ----------------------- | ----------------- | ------------------------ | ------------------------------------ | ---------------------- |
|
||||
| beA workflow | `tools/bea` | `.zip` (multiple) | unchanged existing pipeline | merged/separate PDFs |
|
||||
| Merge | `tools/merge` | multiple PDFs | pdf-lib copy of pages in order | one PDF |
|
||||
| Separate / remove pages | `tools/separate` | one PDF | pdf-lib copy with page selection | one PDF |
|
||||
| Watermark | `tools/watermark` | one PDF | pdf-lib overlay on every page | one PDF |
|
||||
| Sign | `tools/sign` | one PDF | pdf-lib places signature image | one PDF |
|
||||
| Encrypt | `tools/encrypt` | one PDF | encryption library (see D5) | password-protected PDF |
|
||||
| Unlock | `tools/decrypt` | protected PDF + password | pdfjs (password) → rebuild (see D6) | unprotected PDF |
|
||||
| Compress | `tools/compress` | one PDF | pdfjs rasterize + re-encode (see D7) | smaller PDF |
|
||||
| Convert | `tools/convert` | one PDF | pdfjs render pages (see D4) | images or PDF variant |
|
||||
|
||||
### 1. Merge PDFs
|
||||
|
||||
- Load each selected PDF with pdf-lib, copy pages in the (reorderable) list order into a new
|
||||
document. This is a generalization of the existing `mergeProcessedZipArchive` logic — extract a
|
||||
reusable `mergePdfFiles(files: File[]): Promise<Uint8Array>` helper.
|
||||
|
||||
### 2. Separate / remove pages
|
||||
|
||||
- Render page thumbnails (reuse the pdfjs pattern from `AttachmentPreview.svelte`), show a page
|
||||
grid with selection; the user deselects/removes pages and downloads the remaining document.
|
||||
- Page selection must also work with keyboard focus (project accessibility rule).
|
||||
|
||||
### 3. Watermark
|
||||
|
||||
- Options: text (or uploaded PNG/JPG image), position (center/diagonal/corners), opacity, font
|
||||
size, page range (all / selected). Drawn with pdf-lib `drawText`/`drawImage` on each selected page.
|
||||
- No new dependency.
|
||||
|
||||
### 4. Sign
|
||||
|
||||
- Default proposal (see D3): **visual signature**. The user draws a signature in a canvas element
|
||||
(mouse/touch/pen), or types a name in a handwriting-like font, or uploads a signature image.
|
||||
The signature is exported as a transparent PNG and placed by clicking/tapping the desired
|
||||
position on a rendered page (pdfjs preview + pdf-lib placement).
|
||||
- PAdES/cryptographic signing is **not** part of the default scope.
|
||||
|
||||
### 5. Set password (encrypt)
|
||||
|
||||
- pdf-lib does **not** support encryption → a new dependency is unavoidable (see D5).
|
||||
Primary candidate: `@cantoo/pdf-lib` (drop-in fork of pdf-lib with AES encryption support),
|
||||
alternative: `qpdf-wasm`.
|
||||
- UI: password + confirmation field, optional permissions flags (print/copy allowed?).
|
||||
|
||||
### 6. Unlock (remove password)
|
||||
|
||||
- pdfjs-dist can open password-protected PDFs when given the password; pdf-lib cannot load them.
|
||||
- Default approach (see D6): open with pdfjs using the password, then **rasterize** each page to a
|
||||
canvas and rebuild a PDF via pdf-lib. Known tradeoff: text becomes images (no selectable text,
|
||||
larger file, no vector quality). This is documented to the user in the UI.
|
||||
- Failing passwords must surface a clear German error message, not a console-only error.
|
||||
|
||||
### 7. Compress
|
||||
|
||||
- Browser-only compression is realistically rasterization-based: render each page with pdfjs at a
|
||||
chosen DPI/scale, encode as JPEG at a chosen quality, rebuild the PDF.
|
||||
- UI presents target presets (e.g. "Starke Komprimierung" / "Ausgewogen" / "Leicht") and shows the
|
||||
resulting size estimate before download. Same vector→raster caveat as unlock, shown in the UI.
|
||||
|
||||
### 8. Convert
|
||||
|
||||
- Default scope (see D4): **PDF → images** (PNG or JPEG, one file per page, downloaded as separate
|
||||
files or a ZIP via fflate — fflate is already a dependency).
|
||||
- Other directions (images/Office → PDF) already exist in the beA flow for images; extending
|
||||
convert beyond PDF→images is out of scope for v1.
|
||||
|
||||
---
|
||||
|
||||
## Shared infrastructure (new `src/lib` pieces)
|
||||
|
||||
- **`PdfDropzone` component** — generalization of `ZipDropzone` with configurable `accept` filter
|
||||
(`.pdf` vs `.zip`) and drag state. `ZipDropzone` is refactored on top of it (or replaced).
|
||||
- **`usePdfThumbnails` helper / `PdfPageGrid` component** — shared page-thumbnail rendering for
|
||||
separate/sign/watermark/convert/compress, modeled on `AttachmentPreview.svelte`'s memory rules
|
||||
(dynamic pdfjs import, byte copies before render, revoked object URLs, destroyed loading tasks).
|
||||
- **`downloadBlob()` helper** — the current inline `downloadPdfBytes` in `+page.svelte` moves to a
|
||||
shared `src/lib/download.ts` and gains a generic filename/MIME signature.
|
||||
- **Tool registry** (`src/lib/tools.ts`) — typed list of `{ slug, title, description, icon }`
|
||||
consumed by the start page tile grid, so adding a tool later means one entry + one route.
|
||||
- All browser-only work stays in event handlers / `onMount` (SSR-safe, per `agents.md`).
|
||||
- File-size/memory guard: cap or warn on very large PDFs before rasterization-heavy tools
|
||||
(unlock/compress/convert).
|
||||
|
||||
## Dependencies
|
||||
|
||||
- **New runtime dependency** (only for encrypt): `@cantoo/pdf-lib` **or** `qpdf-wasm` — decision D5.
|
||||
All other tools run on the existing stack (pdf-lib, pdfjs-dist, fflate).
|
||||
|
||||
---
|
||||
|
||||
## Implementation phases
|
||||
|
||||
Each phase ends with `pnpm check`, `pnpm lint`, `pnpm build` and a manual browser pass
|
||||
(prettier formatting on changed files).
|
||||
|
||||
### Phase 0 — Groundwork
|
||||
|
||||
1. Create `tools/+layout.svelte` shell and `src/lib/tools.ts` registry.
|
||||
2. Move the existing ZIP workflow from `+page.svelte` to `tools/bea/+page.svelte` unchanged
|
||||
(keep the settings popover, thumbnail width localStorage, bulk export).
|
||||
3. Build the new start page: hero + beA tile + (initially) tool tiles that link to stub routes
|
||||
with a "coming soon" state? — no: see D8; default is to ship phases together and leave
|
||||
tiles hidden until their tool exists.
|
||||
4. Update `agents.md` repository structure and flow sections.
|
||||
|
||||
### Phase 1 — No-new-dependency tools
|
||||
|
||||
1. `PdfDropzone`, `download.ts`, `PdfPageGrid`/thumbnail helper.
|
||||
2. `tools/merge` (reusable `mergePdfFiles` helper).
|
||||
3. `tools/separate` (page grid, selection, export).
|
||||
4. `tools/watermark`.
|
||||
|
||||
### Phase 2 — Signature
|
||||
|
||||
1. `tools/sign`: canvas signature capture (draw/type/upload), placement preview, export.
|
||||
|
||||
### Phase 3 — Password tools
|
||||
|
||||
1. Add the chosen encryption dependency; `tools/encrypt`.
|
||||
2. `tools/decrypt` (pdfjs password + rasterized rebuild) with explicit quality warning in UI.
|
||||
|
||||
### Phase 4 — Rasterization tools
|
||||
|
||||
1. `tools/compress` (DPI/quality presets, size preview).
|
||||
2. `tools/convert` (PDF → PNG/JPEG, separate files or ZIP).
|
||||
|
||||
### Phase 5 — Polish
|
||||
|
||||
1. Start-page tile copy and icons review (German, Nominandum visual language).
|
||||
2. Final `check`/`lint`/`build`, update `agents.md`, README feature list.
|
||||
|
||||
---
|
||||
|
||||
## Open decisions (please confirm before implementation)
|
||||
|
||||
Defaults are marked ✅.
|
||||
|
||||
### D1 — One page per tool vs. single-page app
|
||||
|
||||
- ✅ **A. Separate routes** (`/tools/<slug>`). Better deep-linking/bookmarking, per-tool state is
|
||||
isolated, back-navigation is natural, and SvelteKit gives us the structure for free.
|
||||
- B. Single start page where each tile opens an in-place panel/modal. Keeps everything on `/` but
|
||||
concentrates a lot of state in one component and hurts URL shareability.
|
||||
|
||||
### D2 — Where does the ZIP workflow live?
|
||||
|
||||
- ✅ **A. `/tools/bea`, start page shows it as the large primary tile.** Drag-a-ZIP-anywhere on the
|
||||
start page still routes into the workflow, preserving today's fastest path.
|
||||
- B. ZIP workflow stays on `/` and tools branch off it. Rejected: makes `/` a mixed
|
||||
dropzone+dashboard and complicates the page.
|
||||
|
||||
### D3 — What does "sign" mean?
|
||||
|
||||
- ✅ **A. Visual signature** (drawn/typed/uploaded image placed on the page). No crypto, no
|
||||
certificates, no PAdES.
|
||||
- B. Cryptographic/PAdES signing. Large scope (certificate handling, new heavy dependencies,
|
||||
legal implications) — recommend a separate project if needed.
|
||||
|
||||
### D4 — What does "convert" mean (v1)?
|
||||
|
||||
- ✅ **A. PDF → PNG/JPEG** (per-page images, optionally bundled into a ZIP with fflate).
|
||||
- B. PDF → PDF/A. There is no solid browser-only PDF/A converter; would need a new heavy
|
||||
dependency or a server.
|
||||
- C. Images/Office → PDF. Image→PDF already exists inside the beA flow; Office formats are
|
||||
out of scope.
|
||||
|
||||
### D5 — Encryption dependency
|
||||
|
||||
- ✅ **A. `@cantoo/pdf-lib`** — API-compatible fork of pdf-lib with encryption; smallest migration
|
||||
risk (drop-in swap of the pdf-lib import in the encrypt tool only).
|
||||
- B. `qpdf-wasm` — battle-tested engine, but WASM bundle size and wiring cost are significant.
|
||||
- C. Server-side encryption — violates the no-upload constraint; rejected.
|
||||
|
||||
### D6 — Unlock strategy
|
||||
|
||||
- ✅ **A. pdfjs (with password) → rasterize pages → rebuild with pdf-lib.** Works fully in the
|
||||
browser; document the vector→raster quality loss in the UI before the user commits.
|
||||
- B. Await/patch pdf-lib fork decryption support — currently not available for browser use.
|
||||
- C. Server-side qpdf — violates the no-upload constraint; rejected.
|
||||
|
||||
### D7 — Compress strategy
|
||||
|
||||
- ✅ **Rasterize-and-re-encode** (pdfjs render + JPEG at chosen quality). Predictable, browser-only;
|
||||
shows the same quality caveat as unlock.
|
||||
- B. "Structure-only" optimization (remove metadata, deduplicate objects). pdf-lib offers little
|
||||
here; savings are usually marginal. Could be added as a light pre-step within the same tool.
|
||||
|
||||
### D8 — Release strategy for tiles
|
||||
|
||||
- ✅ **A. Ship it whole / hide unfinished tiles.** Only tiles whose route works are rendered
|
||||
(driven by the tool registry), so phases can be merged without dead links.
|
||||
- B. Show all tiles immediately with "in Kürze verfügbar" badges — acceptable only if the
|
||||
redesign itself must go live before all tools are done.
|
||||
|
||||
---
|
||||
|
||||
## Risks and mitigations
|
||||
|
||||
- **Rasterization quality/memory** (unlock, compress, convert): large PDFs can exhaust browser
|
||||
memory when rendering many pages at high DPI. Mitigate with sequential page processing, DPI
|
||||
caps, and an upfront file-size warning.
|
||||
- **`@cantoo/pdf-lib` maturity**: it is a fork; keep the pdf-lib import isolated to the encrypt
|
||||
tool so a swap back (or to qpdf-wasm) stays local.
|
||||
- **Scope creep on "sign"/"convert"**: the open decisions above pin v1 scope deliberately; crypto
|
||||
signing and PDF/A conversion are explicitly out.
|
||||
- **Regression risk for the existing workflow**: Phase 0 is a pure move of the current page;
|
||||
verify sub-document editing, XJustiz naming, and bulk export still work after relocation.
|
||||
@@ -0,0 +1,188 @@
|
||||
# Plan: Download an individual sub-document
|
||||
|
||||
**Status: Done**
|
||||
|
||||
## Goal
|
||||
|
||||
Each sub-document displayed inside a processed ZIP archive shall have its own download action. The
|
||||
action creates and downloads one PDF containing only that sub-document's attachments, in their
|
||||
current order.
|
||||
|
||||
This is an additional interaction. The existing archive-level export modes remain available:
|
||||
|
||||
- **Eine PDF** continues to flatten all sub-documents and loose attachments into one PDF.
|
||||
- **Getrennt** continues to download every non-empty sub-document plus the loose-attachment PDF.
|
||||
- The new sub-document action downloads only the selected sub-document and does not include loose
|
||||
attachments or sibling sub-documents.
|
||||
|
||||
## Current implementation
|
||||
|
||||
- `ProcessedZipArchiveEditor.svelte` owns archive-level download state and the browser download helper.
|
||||
- `SubDocumentEditor.svelte` renders each sub-document but currently exposes only edit, drag/drop, and
|
||||
dissolve callbacks.
|
||||
- `mergeAttachments()` in `src/lib/zip-processing.ts` already creates a PDF from an ordered attachment
|
||||
list.
|
||||
- `buildArchiveExport(archive, 'separate')` already defines the desired sub-document naming convention:
|
||||
`{archiveName} - {subDocumentName}.pdf`. It currently creates exports for every sub-document, so it
|
||||
should not be called for an individual download.
|
||||
|
||||
## UX and behavior
|
||||
|
||||
Add a **PDF herunterladen** button to each sub-document header, before **Auflösen**.
|
||||
|
||||
- Clicking it downloads one PDF for that sub-document.
|
||||
- The PDF page order matches `subDocument.attachments` exactly.
|
||||
- The filename remains consistent with separate archive export:
|
||||
`{archiveName without .pdf} - {subDocumentName without .pdf}.pdf`.
|
||||
- The action is disabled when the sub-document has no attachments.
|
||||
- While an individual PDF is being generated, its button reads **PDF wird erstellt** and cannot be
|
||||
triggered again.
|
||||
- Only one PDF-generation operation may run in an archive editor at a time. While an individual
|
||||
download is active, archive-level and sibling download actions are disabled; while archive-level
|
||||
export is active, individual actions are disabled.
|
||||
- A failure leaves the archive and sub-document unchanged and shows the existing inline error area
|
||||
with a sub-document-specific German message.
|
||||
- Existing drag/drop, rename, selection, dissolve, and archive export behavior must remain unchanged.
|
||||
|
||||
## Implementation
|
||||
|
||||
### 1. Add a focused export helper
|
||||
|
||||
Update `src/lib/zip-processing.ts` with an exported helper:
|
||||
|
||||
```ts
|
||||
export const buildSubDocumentExport = async (
|
||||
archiveName: string,
|
||||
subDocument: SubDocument
|
||||
): Promise<ExportUnit>
|
||||
```
|
||||
|
||||
Behavior:
|
||||
|
||||
1. Reject an empty attachment list with the same explicit empty-export behavior used by
|
||||
`mergeAttachments()`.
|
||||
2. Merge `subDocument.attachments` by calling `mergeAttachments()`.
|
||||
3. Return one `ExportUnit` using the existing archive/sub-document filename convention.
|
||||
4. Keep `.pdf` normalization centralized through the existing `stripPdfExtension()` helper.
|
||||
|
||||
Refactor the `mode === 'separate'` branch of `buildArchiveExport()` to call
|
||||
`buildSubDocumentExport()` for each non-empty sub-document. This ensures bulk-separate and
|
||||
individual exports cannot drift in PDF contents or naming. Preserve sequential generation and the
|
||||
existing loose-attachment export behavior.
|
||||
|
||||
Do not construct a temporary `ProcessedZipArchive` and do not invoke `buildArchiveExport(...,
|
||||
'separate')` for one item: both approaches encode the wrong scope and risk exporting unrelated
|
||||
attachments later.
|
||||
|
||||
### 2. Add individual download orchestration to the archive editor
|
||||
|
||||
Update `src/lib/components/ProcessedZipArchiveEditor.svelte`:
|
||||
|
||||
- Import `buildSubDocumentExport`.
|
||||
- Add `downloadingSubDocumentId: string | null` state alongside `isMerging` and `mergeError`.
|
||||
- Derive whether any export is active from `isMerging || downloadingSubDocumentId !== null`.
|
||||
- Add `downloadSubDocument(subDocument: SubDocument)` that:
|
||||
1. Returns when another export is active or the sub-document is empty.
|
||||
2. Stores the selected sub-document ID and clears `mergeError`.
|
||||
3. Calls `buildSubDocumentExport(archiveName, subDocument)`.
|
||||
4. Passes the returned bytes and name to the existing `downloadPdfBytes()` helper.
|
||||
5. On failure, logs context including the archive and sub-document and sets
|
||||
`Teildokument „{name}“ konnte nicht als PDF erstellt werden.`.
|
||||
6. Clears the active ID in `finally`.
|
||||
- Update `downloadArchive()` to guard against any active export, not only `isMerging`.
|
||||
- Disable the archive-level download button and export-mode controls while an individual export is
|
||||
active. The mode controls should remain visible and retain their selected value.
|
||||
- Pass these props to every `SubDocumentEditor`:
|
||||
- `onDownload={() => downloadSubDocument(subDocument)}`
|
||||
- `isDownloading={downloadingSubDocumentId === subDocument.id}`
|
||||
- `downloadDisabled={isMerging || downloadingSubDocumentId !== null}`
|
||||
|
||||
The browser download implementation stays in the parent so Blob/object-URL creation, filename
|
||||
normalization, loading coordination, and errors remain centralized.
|
||||
|
||||
### 3. Add the sub-document header action
|
||||
|
||||
Update `src/lib/components/SubDocumentEditor.svelte`:
|
||||
|
||||
- Import the existing local `Button` component.
|
||||
- Extend `Props` with:
|
||||
- `onDownload?: () => void`
|
||||
- `isDownloading?: boolean`
|
||||
- `downloadDisabled?: boolean`
|
||||
- Give all new props safe defaults.
|
||||
- Render a bordered button before **Auflösen**:
|
||||
- Normal label: **PDF herunterladen**
|
||||
- Active label: **PDF wird erstellt**
|
||||
- Disabled when `downloadDisabled`, `isDownloading`, or `attachments.length === 0`
|
||||
- Keep the control as a native button through the shared `Button` component, with a clear accessible
|
||||
label that includes the sub-document name if the visible label alone is ambiguous in assistive
|
||||
technology.
|
||||
- Prevent the button from participating in drag initiation in the same way as the existing name and
|
||||
dissolve controls.
|
||||
- Keep **Auflösen** visually distinct as the destructive action; do not change its behavior.
|
||||
|
||||
Use existing button variants and semantic theme utilities. Do not introduce a new UI dependency or a
|
||||
second download utility in the child component.
|
||||
|
||||
## Files changed
|
||||
|
||||
- `src/lib/zip-processing.ts`
|
||||
- Add `buildSubDocumentExport()`.
|
||||
- Reuse it from separate archive export.
|
||||
- `src/lib/components/ProcessedZipArchiveEditor.svelte`
|
||||
- Coordinate individual export state, errors, and browser download.
|
||||
- `src/lib/components/SubDocumentEditor.svelte`
|
||||
- Add the individual download button and callback props.
|
||||
- `agents.md`
|
||||
- Clarify that per-archive separate export and direct per-sub-document export are both supported.
|
||||
|
||||
No route, server endpoint, persistence, package, lockfile, or Docker changes are required.
|
||||
|
||||
## Verification
|
||||
|
||||
### Automated checks
|
||||
|
||||
Run:
|
||||
|
||||
```sh
|
||||
pnpm exec prettier --write \
|
||||
src/lib/zip-processing.ts \
|
||||
src/lib/components/ProcessedZipArchiveEditor.svelte \
|
||||
src/lib/components/SubDocumentEditor.svelte \
|
||||
agents.md
|
||||
pnpm check
|
||||
pnpm lint
|
||||
pnpm build
|
||||
```
|
||||
|
||||
There is no test framework in the repository. Do not add one solely for this feature. If a test
|
||||
framework is introduced separately, cover `buildSubDocumentExport()` with non-empty, empty, ordering,
|
||||
and filename cases.
|
||||
|
||||
### Manual scenarios
|
||||
|
||||
1. Create an archive with two sub-documents and at least one loose attachment.
|
||||
2. Download the first sub-document and verify exactly one PDF is triggered.
|
||||
3. Confirm the PDF contains only the first sub-document's attachments and preserves their displayed
|
||||
order.
|
||||
4. Confirm the filename is `{archive} - {sub-document}.pdf` without duplicate `.pdf` suffixes.
|
||||
5. Download the second sub-document and verify it has independent contents and naming.
|
||||
6. Confirm an empty sub-document's download action is disabled.
|
||||
7. Start an individual export and verify duplicate, sibling, and archive-level export actions cannot
|
||||
start until it finishes.
|
||||
8. Force an invalid PDF/image merge and verify a German inline error appears, loading state clears,
|
||||
and editor state is unchanged.
|
||||
9. Verify **Eine PDF** still exports all sub-documents followed by loose attachments.
|
||||
10. Verify **Getrennt** still exports every non-empty sub-document and the loose-attachment PDF.
|
||||
11. Verify rename, reorder, move, selection, Escape, and **Auflösen** interactions still work.
|
||||
12. Verify the new control remains usable on a narrow viewport and by keyboard.
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- Every non-empty sub-document has a directly accessible download action.
|
||||
- The action downloads one PDF containing only that sub-document's ordered attachments.
|
||||
- Individual and archive-level exports use the same filename convention and merge implementation.
|
||||
- Empty sub-documents cannot be exported.
|
||||
- Loading and error states are visible, duplicate operations are prevented, and state always resets.
|
||||
- Existing archive-level export behavior and all editor interactions are preserved.
|
||||
- `pnpm check`, `pnpm lint`, and `pnpm build` pass without new issues.
|
||||
@@ -0,0 +1,330 @@
|
||||
# Plan: Teildokumente besser aufteilen (PDF & beA-ZIP)
|
||||
|
||||
## Goal
|
||||
|
||||
Splitting an archive's content into multiple Teildokumente is a core workflow of the beA editor,
|
||||
but the current UX handles only the easy half of it. This plan closes two gaps:
|
||||
|
||||
1. **PDFs split into sub-documents.** A single PDF attachment (e.g. one scan containing several
|
||||
documents) cannot be divided at page level today. The user has to leave the app, split the PDF
|
||||
with an external tool, and re-zip the results — the beA route only accepts ZIPs.
|
||||
2. **Grouping UX.** Creating and re-arranging Teildokumente relies on a modal-ish selection mode
|
||||
and HTML5 drag-and-drop only. It is hard to discover, hard to use on touch devices (relevant
|
||||
since the app is an installable PWA), and offers no path for regrouping attachments that are
|
||||
already inside a Teildokument except dissolving the whole block.
|
||||
|
||||
All features stay 100 % client-side, German UI copy, no new dependencies.
|
||||
|
||||
---
|
||||
|
||||
## Current-state analysis (UX pain points)
|
||||
|
||||
| # | Pain point | Where |
|
||||
| --- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------- |
|
||||
| P1 | A multi-document PDF attachment cannot be split into several Teildokumente at all. | `ProcessedZipArchiveEditor.svelte` — attachments are atomic |
|
||||
| P2 | PDFs cannot enter the beA workspace; only ZIPs are accepted. Splitting a PDF means zipping it first. | `zip-selection.ts`, `ZipDropzone.svelte` |
|
||||
| P3 | Grouping is hidden behind an unlabeled-purpose "Auswählen" mode toggle; the action bar with "Teildokument erstellen" only appears after the first checkbox, so the goal of the mode is unclear. | `ProcessedZipArchiveEditor.svelte` |
|
||||
| P4 | Selection mode only covers loose attachments. To regroup attachments that already sit in a Teildokument, the user must dissolve the whole block (all-or-nothing) or drag each file individually. | `ProcessedZipArchiveEditor.svelte`, `SubDocumentEditor.svelte` |
|
||||
| P5 | Moving attachments relies exclusively on HTML5 drag-and-drop, which does not work with touch input and has no keyboard alternative. | both editors |
|
||||
| P6 | New Teildokumente get the name `Teildokument ${subDocuments.length + 1}` — dissolving and re-creating produces duplicate names — and the name input is not focused, so the rename step is easy to miss. | `createSubDocumentFromSelection` |
|
||||
| P7 | The newly created Teildokument renders far below the loose list; there is no scroll-into-view or other confirmation. | `ProcessedZipArchiveEditor.svelte` |
|
||||
| P8 | No quick actions for the most common grouping patterns: "every attachment becomes its own Teildokument", "create empty Teildokument and fill it". | — |
|
||||
| P9 | `tests/e2e/bea.spec.ts` has zero coverage of the sub-document flow (no grouping, no drag, no export-mode tests). | `tests/e2e/bea.spec.ts` |
|
||||
|
||||
---
|
||||
|
||||
## Open decisions (defaults marked ✅)
|
||||
|
||||
### D1 — Entry point for PDFs
|
||||
|
||||
- ✅ **A. Accept PDFs in the beA workspace.** Extend the beA dropzone/pickers to also accept PDF
|
||||
files. A PDF is turned into a `ProcessedZipArchive` with `name: {fileName}.pdf`,
|
||||
`attachments: [ {kind: 'pdf', data, path: fileName, name: fileName} ]`, `subDocuments: []`.
|
||||
The archive editor then offers the full splitting workflow on it.
|
||||
- B. Keep beA ZIP-only; route PDF splitting to the planned standalone `/tools/extract`
|
||||
(extract-split-plan.md). Rejected: its output is a ZIP of PDFs, which the user would then have
|
||||
to re-import — a disconnected round trip.
|
||||
|
||||
> Naming note: the route heading and dropzone copy should mention PDFs
|
||||
> ("beA-ZIP oder PDF auswählen") so the entry point is discoverable.
|
||||
|
||||
### D2 — Page-level split interaction
|
||||
|
||||
- ✅ **A. Split dialog per PDF attachment.** A "Teilen" action on a PDF attachment opens a dialog
|
||||
that reuses `loadPdfDocument` + `PdfPageGrid` (same pattern as `tools/separate`). The user
|
||||
selects a page set, clicks "Als Teildokument übernehmen", the dialog stays open with the
|
||||
remaining pages and a fresh empty selection for the next Teildokument, until "Fertig".
|
||||
This guided loop mirrors how documents are actually separated (page range by page range).
|
||||
- B. Inline page expansion in the archive editor (expand thumbnails for every attachment).
|
||||
Rejected: doubles memory (pdfjs documents for every PDF stay alive) and clutters the editor.
|
||||
|
||||
### D3 — What happens to the source PDF
|
||||
|
||||
- ✅ **A. "Ausschneiden" (cut) by default.** The selected pages move into the new Teildokument;
|
||||
the source attachment keeps the remaining pages. If all pages are consumed, the source
|
||||
attachment is removed. A checkbox "Seiten im Original behalten" (copy mode) is available for
|
||||
the rare case where pages should appear in two Teildokumente.
|
||||
- B. Copy-only. Rejected: the feature's purpose is _splitting_; copy-only leaves the user with
|
||||
manual cleanup every time.
|
||||
|
||||
### D4 — Grouping mode redesign
|
||||
|
||||
- ✅ **A. Keep a temporary grouping mode, but make it self-explanatory and archive-wide.**
|
||||
Rename the toggle to **"Gruppieren"** (with a layers/group icon). While active:
|
||||
- the action bar is always visible (disabled at 0 selected), showing the count and the
|
||||
"Teildokument erstellen" button,
|
||||
- checkboxes appear on loose **and** sub-document attachments, so regrouping no longer
|
||||
requires dissolving,
|
||||
- the bar offers **"In Teildokument verschieben"** (a select of existing Teildokumente) next
|
||||
to "Teildokument erstellen", covering both create-new and move-into-existing in one place,
|
||||
- "Alle auswählen" / "Auswahl aufheben" buttons are added (parity with the separate tool).
|
||||
- B. Remove the mode entirely in favour of per-attachment menus only. Rejected: multi-select is
|
||||
the fastest way to group many attachments; menus are one-file-at-a-time.
|
||||
|
||||
### D5 — Touch/keyboard-friendly moves
|
||||
|
||||
- ✅ **A. Per-attachment action menu.** Each attachment card gets a small "⋯" menu (visible on
|
||||
hover/focus, always rendered for screen readers and touch) with:
|
||||
- "In Teildokument verschieben →" (existing Teildokumente + "Neues Teildokument"),
|
||||
- "Teilen…" (PDFs only, opens the D2 dialog),
|
||||
- "Entfernen" (remove attachment from the archive, with confirm).
|
||||
Drag-and-drop stays as the fast path for pointer users.
|
||||
- B. Up/down move buttons on every card. Rejected: six buttons per card is visual noise, and it
|
||||
cannot express "into another Teildokument" without a target chooser anyway.
|
||||
|
||||
### D6 — Bulk split modes in the dialog
|
||||
|
||||
- ✅ **A. Offer "Jede Seite einzeln" and "Alle N Seiten"** alongside the manual page selection
|
||||
(same modes as extract-split-plan.md D2). Each chunk becomes one Teildokument named
|
||||
`Seiten {first}–{last}`; the source attachment is removed (D3 cut semantics).
|
||||
- B. Manual ranges only for v1. Kept as the fallback if the chunking logic proves fiddly.
|
||||
|
||||
### D7 — Naming of new Teildokumente
|
||||
|
||||
- ✅ **A.** Keep the editable inline name, but number with `Math.max` of existing numbers + 1
|
||||
(parse trailing digits of names starting with "Teildokument") so dissolve/re-create cycles no
|
||||
longer produce duplicates, and **focus + select the name input** of the freshly created block,
|
||||
then scroll it into view. The split dialog (D2) pre-fills the name from the page range
|
||||
("Seiten 1–3") and lets the user edit it inline per iteration.
|
||||
|
||||
---
|
||||
|
||||
## UX concept
|
||||
|
||||
### Workstream A — Grouping UX (beA-ZIP → Teildokumente)
|
||||
|
||||
1. **"Gruppieren" toggle** in the archive header (replaces "Auswählen"/"Fertig"). Archive-wide
|
||||
multi-select with an always-visible action bar:
|
||||
`N Dateien ausgewählt` · "Teildokument erstellen" · "In Teildokument verschieben ▾" ·
|
||||
"Alle auswählen" / "Auswahl aufheben". Escape/"Fertig" exits and clears.
|
||||
2. **Per-attachment "⋯" menu** (D5) for single-file moves and splitting without entering any mode.
|
||||
3. **Empty Teildokument**: new header button "Neues Teildokument" creates an empty block (the
|
||||
existing empty-drop-zone state in `SubDocumentEditor` is already the right affordance).
|
||||
4. **Quick action**: "Jede Datei als Teildokument" in the header menu converts all loose
|
||||
attachments into one Teildokument each, in current order.
|
||||
5. **Feedback**: newly created blocks are scrolled into view; their name input is focused.
|
||||
|
||||
### Workstream B — Page-level splitting (PDF → Teildokumente)
|
||||
|
||||
1. **PDF acceptance** in the beA workspace (D1): dropzone, file picker, and the start-page
|
||||
drop-forward accept `.pdf` alongside ZIPs; a dropped PDF opens the same workspace.
|
||||
2. **"Teilen…" dialog** (D6/D3): page grid via `PdfPageGrid`, selection via click, live label
|
||||
("3 von 12 Seiten ausgewählt"), "Als Teildokument übernehmen" applies the cut and re-opens
|
||||
for the next range; bulk radio "Jede Seite einzeln" / "Alle N Seiten" with a file-count preview
|
||||
("Es werden 4 Teildokumente erstellt."). "Fertig" closes.
|
||||
3. The generated chunks are regular PDF attachments (kind `'pdf'`), so export, reordering, and
|
||||
previews need no special cases.
|
||||
|
||||
---
|
||||
|
||||
## Technical approach
|
||||
|
||||
### Data model (`src/lib/zip-processing.ts`)
|
||||
|
||||
`SubDocument` and `ProcessedZipArchive` stay unchanged. Splitting produces **synthetic
|
||||
attachments** (implemented helpers; page _sets_ instead of plain ranges so arbitrary grid
|
||||
selections work — a range is the special case used by the bulk modes):
|
||||
|
||||
```ts
|
||||
export type PdfSplitResult = {
|
||||
groups: Array<{ name: string; attachment: ZipAttachment }>;
|
||||
source: ZipAttachment | null; // remaining pages; null removes the source
|
||||
};
|
||||
|
||||
// New helpers (pure, immutable — same style as moveAttachmentIntoSubDocument):
|
||||
createAttachmentFromPages(source, pages: number[], name?) // copy a 1-based page set into a new attachment
|
||||
removePagesFromAttachment(source, pages: number[]) // complement; null when empty
|
||||
applyPdfSplit(archive, sourcePath, result) // replace source, insert new sub-documents after its parent block
|
||||
pdfFileToArchive(file) // D1: PDF → single-attachment archive
|
||||
nextSubDocumentNumber(subDocuments) // max trailing "Teildokument N" number + 1
|
||||
createSubDocumentFromPaths(archive, paths) // group any selection into a new Teildokument
|
||||
moveAttachmentsByPathsToSubDocument(archive, paths, id) // move selection into an existing Teildokument
|
||||
groupEachAttachmentAsSubDocument(archive) // quick action: one Teildokument per loose file
|
||||
removeAttachmentFromArchive(archive, path) // menu "Entfernen"
|
||||
```
|
||||
|
||||
- **Unique `path`s are mandatory**: `path` is the `{#each}` key and the drag/selection identity
|
||||
(`selectedPaths`). The `#seiten-…-uuid` suffix guarantees uniqueness even when the same range
|
||||
is split twice (copy mode).
|
||||
- Splitting an attachment **inside a Teildokument** inserts the new Teildokument **directly
|
||||
after the source's parent block** and applies the cut to the copy inside that block, so the
|
||||
user's reading order is preserved.
|
||||
|
||||
### Shared page extraction
|
||||
|
||||
Implement `copyPagesToNewPdf(sourceBytes, pageNumberList): Promise<Uint8Array>` in
|
||||
`src/lib/pdf-processing.ts` exactly as specified in extract-split-plan.md D3, and rewire
|
||||
`tools/separate` to use it. If `/tools/extract` is built first, reuse the helper — the two plans
|
||||
must not fork the code.
|
||||
|
||||
### Components
|
||||
|
||||
- **New `src/lib/components/PdfSplitDialog.svelte`**: props `{ attachment, onConfirm }`.
|
||||
Loads thumbnails through `loadPdfDocument` and destroys the handle on close (same memory
|
||||
safeguards as `AttachmentPreview`); rendering happens only while open. Uses the existing
|
||||
shadcn dialog primitives.
|
||||
- **New `src/lib/components/AttachmentMenu.svelte`**: the "⋯" menu (D5), built on the existing
|
||||
shadcn dropdown-menu primitives; used by both editors.
|
||||
- **`ProcessedZipArchiveEditor.svelte`**: grouping-mode rework (D4), quick actions, "Neues
|
||||
Teildokument" button, split-dialog wiring, scroll-into-view + name focus after creation.
|
||||
- **`SubDocumentEditor.svelte`**: render the "⋯" menu per attachment; grouping-mode checkboxes
|
||||
forward selection to the parent (selection stays owned by the archive editor).
|
||||
|
||||
### PDF acceptance (D1)
|
||||
|
||||
- `zip-selection.ts`: add `PDF_ACCEPT_TYPES`/`isAcceptedPdfFile` and a combined
|
||||
`isAcceptedWorkspaceFile`.
|
||||
- `ZipDropzone.svelte`, both compact pickers, and the start-page drop-forward: accept PDFs; the
|
||||
beA page routes PDF files to a `pdfFileToArchive(file)` wrapper that produces the synthetic
|
||||
single-attachment archive.
|
||||
- Jobs list: a PDF job shows the same processing row; it cannot fail like a nested-ZIP extraction
|
||||
can (only a pdf-lib load error, surfaced with the existing German error copy).
|
||||
|
||||
### Invariants (from agents.md, restated as acceptance criteria)
|
||||
|
||||
- No move/split operation may duplicate or discard attachment data (cut moves bytes, copy
|
||||
duplicates them deliberately and only on explicit user request).
|
||||
- Teildokument IDs stay stable across edits and reordering.
|
||||
- Single-PDF export order stays: Teildokumente in order, each attachment in order, loose
|
||||
attachments last.
|
||||
- Dissolving a Teildokument appends its attachments to the loose list (unchanged).
|
||||
- All state helpers return new archive objects; the route remains the source of truth.
|
||||
|
||||
---
|
||||
|
||||
## Files changed
|
||||
|
||||
| File | Change |
|
||||
| ------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------- |
|
||||
| `src/lib/pdf-processing.ts` | `copyPagesToNewPdf` (shared with extract-split-plan) |
|
||||
| `src/lib/zip-processing.ts` | page-range helpers, `createSubDocumentPerAttachment`, `nextSubDocumentNumber`, `pdfFileToArchive` |
|
||||
| `src/lib/components/PdfSplitDialog.svelte` | new — page-selection dialog with manual + bulk modes |
|
||||
| `src/lib/components/AttachmentMenu.svelte` | new — per-attachment actions |
|
||||
| `src/lib/components/ProcessedZipArchiveEditor.svelte` | grouping-mode rework, quick actions, split wiring |
|
||||
| `src/lib/components/SubDocumentEditor.svelte` | attachment menu, grouping-mode checkboxes |
|
||||
| `src/lib/zip-selection.ts`, `ZipDropzone.svelte`, `ZipFilePicker.svelte` | PDF acceptance |
|
||||
| `src/routes/tools/bea/+page.svelte`, `src/routes/+page.svelte` | PDF → archive wrapper, drop-forward |
|
||||
| `tests/e2e/bea.spec.ts` (+ fixtures) | sub-document, grouping, splitting coverage |
|
||||
| `agents.md` | flow + invariants updates |
|
||||
|
||||
---
|
||||
|
||||
## Implementation phases
|
||||
|
||||
### Phase 1 — Shared extraction helper + pure helpers
|
||||
|
||||
`copyPagesToNewPdf`, page-range/splitting helpers, `nextSubDocumentNumber`,
|
||||
`pdfFileToArchive`. Unit-verifiable pure functions, no UI. Rewire `tools/separate` to the shared
|
||||
helper.
|
||||
|
||||
### Phase 2 — Grouping UX rework
|
||||
|
||||
"Gruppieren" mode with always-visible action bar, archive-wide selection, move-into-existing
|
||||
select, select all/none, naming/numbering fix, focus + scroll-into-view feedback.
|
||||
|
||||
### Phase 3 — Attachment menu + quick actions
|
||||
|
||||
`AttachmentMenu.svelte` in both editors (touch/keyboard path), "Neues Teildokument",
|
||||
"Jede Datei als Teildokument".
|
||||
|
||||
### Phase 4 — Split dialog
|
||||
|
||||
`PdfSplitDialog.svelte` with manual cut/copy loop and bulk modes; wire "Teilen…" from the
|
||||
attachment menu; insertion after the source block.
|
||||
|
||||
### Phase 5 — PDF acceptance in the beA workspace
|
||||
|
||||
Selection types, dropzone/picker/start-page plumbing, synthetic archive, job error copy.
|
||||
|
||||
### Phase 6 — Polish, agents.md, tests
|
||||
|
||||
Copy pass, empty/edge states, `agents.md` update, full e2e suite.
|
||||
|
||||
Phases 2–5 are independent enough to land separately (each behind the Phase 1 helpers where it
|
||||
needs them).
|
||||
|
||||
---
|
||||
|
||||
## Verification
|
||||
|
||||
- `pnpm check`, `pnpm lint`, `pnpm build` — no new errors.
|
||||
- `pnpm test:e2e` with new cases in `bea.spec.ts` (current suite has no sub-document coverage):
|
||||
- group loose attachments via "Gruppieren" → names/counts correct, order preserved;
|
||||
- select attachments inside existing Teildokumente and move them into another Teildokument
|
||||
without dissolving;
|
||||
- regroup without drag-and-drop (menu path — this is also the touch-equivalent path);
|
||||
- split a PDF attachment: cut three pages into a new Teildokument, remainder stays as source,
|
||||
both export correctly via "Eine PDF" and "Getrennt";
|
||||
- bulk mode "Jede Seite einzeln" produces one Teildokument per page, source removed;
|
||||
- drop a bare PDF onto the beA dropzone and onto the start page → workspace with one
|
||||
attachment; split and export;
|
||||
- duplicate-name regression: dissolve + re-create → "Teildokument 2", not a second
|
||||
"Teildokument 1".
|
||||
- Manual: run the split dialog on a 100+ page scan (thumbnail memory, dialog close cleanup);
|
||||
touch device pass for menu-based moves.
|
||||
- `pnpm test:pwa` still passes (PDF acceptance changes the dropzone precached flow inputs only).
|
||||
|
||||
---
|
||||
|
||||
## Risks
|
||||
|
||||
- **Memory**: the split dialog keeps a pdfjs document + thumbnails alive while open. Mitigated by
|
||||
the dialog lifecycle (load on open, `destroy()` on close/discard) and the existing managed
|
||||
thumbnail service.
|
||||
- **Path collisions**: synthetic paths must be globally unique per archive; the uuid suffix
|
||||
makes this safe, but the grouping/selection code must never assume `path` is a real file-system
|
||||
path (it already only uses it as identity).
|
||||
- **Forking with extract-split-plan**: both plans need `copyPagesToNewPdf`. Whichever lands first
|
||||
owns the helper; the other must reuse it.
|
||||
- **Copy-mode confusion**: cut vs copy must be visually explicit in the dialog (default checked
|
||||
= cut), otherwise users may assume data loss.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- Undo/redo for archive edits (separate plan; this plan's helpers are immutable, which is the
|
||||
prerequisite for it).
|
||||
- ZIP export of the edited structure (export-zip-plan.md).
|
||||
- Free-form range input ("1-3, 5, 8-10") in the split dialog — follow-up after v1.
|
||||
- Splitting image attachments into pages (images are single-page by definition).
|
||||
- XJustiz-aware auto-grouping (e.g. pre-filling Teildokument names from document names) —
|
||||
candidate for a later plan once the metadata panel (xjustiz-metadata-panel-plan.md) exists.
|
||||
|
||||
---
|
||||
|
||||
## Status: Implemented
|
||||
|
||||
Deviations from the original text, made during implementation:
|
||||
|
||||
- Page helpers work on **1-based page sets** (`createAttachmentFromPages`,
|
||||
`removePagesFromAttachment`) instead of `PageRange` only, because the manual dialog mode selects
|
||||
arbitrary grid pages; the bulk modes pass contiguous sets.
|
||||
- The split dialog applies the cut **once on "Teilen"** (accumulating groups with "Als Teildokument
|
||||
übernehmen" first) rather than re-encoding the source after every assignment; the semantics are
|
||||
identical and the grid shows assigned pages with a "✓ Zugewiesen" badge.
|
||||
- Split-chunk attachments use the source-based default names (`vertrag_Seiten_1-2.pdf`); the
|
||||
user-facing names live on the Teildokumente.
|
||||
- Toolbar labels became file-neutral ("Neue Datei bearbeiten", "Weitere Datei hinzufügen") because
|
||||
the same pickers now accept PDFs (D1); `agents.md` and the e2e helpers were updated accordingly.
|
||||
- `PdfPageGrid.svelte` renders the `pageActions` snippet in its clickable branch too (previously
|
||||
only the static branch), needed for the assigned-page badges.
|
||||
- The workspace status counts files ("1 Datei · 1 Archiv geöffnet") instead of ZIPs.
|
||||
@@ -1,11 +1,13 @@
|
||||
# Plan: Sub-documents within a ZIP archive
|
||||
|
||||
**Status: Done**
|
||||
|
||||
## Goal
|
||||
|
||||
For a processed ZIP archive the user can already **reorder** attachments and **export** them as a single merged PDF. This feature adds the ability to:
|
||||
|
||||
1. **Select** some of the archive's attachments and **group them into a "sub-document"** (a named, ordered subset of attachments).
|
||||
2. **Reorder** attachments *within* a sub-document — and **reorder** the sub-documents themselves — exactly like the parent archive today.
|
||||
2. **Reorder** attachments _within_ a sub-document — and **reorder** the sub-documents themselves — exactly like the parent archive today.
|
||||
3. **Choose an export mode** for the sub-documents:
|
||||
- **Separate files** → each sub-document becomes its own downloaded PDF.
|
||||
- **One PDF** → all sub-documents are merged into a single downloaded PDF (the existing top-level "PDF herunterladen" behaviour, now scoped to sub-documents when they exist).
|
||||
@@ -19,19 +21,23 @@ The existing flow (no sub-documents created → one merged PDF for the whole arc
|
||||
These affect the data model and UI. Default proposals are marked ✅.
|
||||
|
||||
### D1 — Partition model
|
||||
- ✅ **A. Optional grouping.** Attachments can be "loose" (not part of any sub-document) *or* members of a sub-document. Sub-documents are created on demand by selecting attachments.
|
||||
|
||||
- ✅ **A. Optional grouping.** Attachments can be "loose" (not part of any sub-document) _or_ members of a sub-document. Sub-documents are created on demand by selecting attachments.
|
||||
- B. **Exclusive partition.** Once the user starts creating sub-documents, every attachment must belong to exactly one.
|
||||
- C. **Shared/multi-membership.** An attachment can appear in multiple sub-documents (references, not moves).
|
||||
|
||||
> Recommendation: **A**. It is the least disruptive, matches the current "flat list" mental model, and degrades gracefully when the user does nothing.
|
||||
|
||||
### D2 — Selection lifecycle
|
||||
|
||||
- ✅ **Selection mode toggle.** A small "Auswählen" button in the archive header switches the editor into selection mode. Only then are checkboxes rendered on each thumbnail; in normal mode the editor looks exactly like today (no permanent checkbox clutter). While in selection mode the user toggles attachments, then clicks "Teildokument erstellen" to consume the selection (which clears the selection but leaves selection mode on, so they can immediately create another sub-document). Exiting selection mode via a "Fertig" button (or Esc) clears any pending selection.
|
||||
- A sub-document is created from the *current* selection. Afterwards each attachment can be moved into / out of / between sub-documents via drag-drop or a context action.
|
||||
- A sub-document is created from the _current_ selection. Afterwards each attachment can be moved into / out of / between sub-documents via drag-drop or a context action.
|
||||
- Alternative considered: persistent always-visible checkboxes — rejected as visually noisy for the common case where the user only wants to reorder.
|
||||
|
||||
### D3 — Download scope when sub-documents exist
|
||||
|
||||
The existing single archive-level "PDF herunterladen" button behaviour needs clarification. Proposed:
|
||||
|
||||
- ✅ When **no** sub-documents exist → today's behaviour (merge all attachments into one PDF).
|
||||
- When sub-documents **do** exist → show a **download mode toggle** offered to the user at export time:
|
||||
- **Getrennt** (separate): one PDF per sub-document (loose attachments are downloaded individually, matching their kind — PDF passthrough, image as-is or as a tiny PDF — TBD in D4).
|
||||
@@ -39,6 +45,7 @@ The existing single archive-level "PDF herunterladen" button behaviour needs cla
|
||||
- ✅ "Separate" downloads are triggered sequentially (same pattern as today's `exportAllZipArchivesAsPdf`).
|
||||
|
||||
### D4 — Format of "separate" downloads
|
||||
|
||||
- ✅ Each sub-document is downloaded as a **PDF** (merge its attachments with the existing `mergeProcessedZipArchive` logic, scoped to the sub-document's attachments). Naming: `{archiveName} - {subDocumentName}.pdf`.
|
||||
- B. Each sub-document is downloaded as a **ZIP** containing its original files.
|
||||
- C. Mixed: keep each attachment's original format (PDFs passthrough, images as image files).
|
||||
@@ -46,6 +53,7 @@ The existing single archive-level "PDF herunterladen" button behaviour needs cla
|
||||
> Recommendation: **A** for consistency with the existing PDF-first export. We can revisit if the actual need is "preserve original files."
|
||||
|
||||
### D5 — Treatment of loose (ungrouped) attachments at export
|
||||
|
||||
- ✅ When exporting **separate**: loose attachments are downloaded each as their own PDF (or skipped — needs a quick confirm).
|
||||
- ✅ When exporting **one PDF**: loose attachments are appended at the end (in their existing order) — or skipped.
|
||||
- B. Ignore loose attachments entirely during export.
|
||||
@@ -53,9 +61,11 @@ The existing single archive-level "PDF herunterladen" button behaviour needs cla
|
||||
> Recommendation: **include loose attachments** at the end of both modes, so nothing is silently lost. Confirm with user.
|
||||
|
||||
### D6 — Naming of sub-documents
|
||||
|
||||
- ✅ A sub-document has a user-editable name (default e.g. `Teildokument 1`, `Teildokument 2`, …) shown next to its attachments, mirroring the archive name input.
|
||||
|
||||
### D7 — Persistence
|
||||
|
||||
- ✅ Sub-document structure lives only in component state (same as today's reordering) — no localStorage persistence required for v1. (We already do not persist reordering.)
|
||||
|
||||
---
|
||||
@@ -79,6 +89,7 @@ export type ProcessedZipArchive = {
|
||||
```
|
||||
|
||||
Notes:
|
||||
|
||||
- Attachments inside `SubDocument` reuse the existing `ZipAttachment` type unchanged.
|
||||
- `extractZipEntries` populates `subDocuments: []`. All existing call sites continue to work.
|
||||
- A sub-document's `id` is generated with `crypto.randomUUID()` (available in browser + modern Node) — never re-derived from index, so reordering stays stable.
|
||||
@@ -87,10 +98,11 @@ Notes:
|
||||
|
||||
```ts
|
||||
// Create a sub-document from a list of attachments.
|
||||
export const createSubDocument = (
|
||||
name: string,
|
||||
attachments: ZipAttachment[]
|
||||
): SubDocument => ({ id: crypto.randomUUID(), name, attachments });
|
||||
export const createSubDocument = (name: string, attachments: ZipAttachment[]): SubDocument => ({
|
||||
id: crypto.randomUUID(),
|
||||
name,
|
||||
attachments
|
||||
});
|
||||
|
||||
// Move an attachment from the loose list (or another sub-document) into a sub-document.
|
||||
export const moveAttachmentIntoSubDocument = (
|
||||
@@ -98,20 +110,22 @@ export const moveAttachmentIntoSubDocument = (
|
||||
attachmentPath: string,
|
||||
source: { kind: 'loose' } | { kind: 'subDocument'; id: string },
|
||||
target: { kind: 'loose'; index: number } | { kind: 'subDocument'; id: string; index: number }
|
||||
): ProcessedZipArchive => { /* immutable update */ };
|
||||
): ProcessedZipArchive => {
|
||||
/* immutable update */
|
||||
};
|
||||
|
||||
// Reorder sub-documents (same shape as the existing moveAttachment helper).
|
||||
export const moveSubDocument = (
|
||||
archive: ProcessedZipArchive,
|
||||
fromIndex: number,
|
||||
toIndex: number
|
||||
): ProcessedZipArchive => { /* ... */ };
|
||||
): ProcessedZipArchive => {
|
||||
/* ... */
|
||||
};
|
||||
|
||||
// Flatten the archive into an ordered list of attachments for "one PDF" export.
|
||||
// sub-document order is preserved; loose attachments appended at the end (per D5).
|
||||
export const flattenArchiveForMerge = (
|
||||
archive: ProcessedZipArchive
|
||||
): ZipAttachment[] => {
|
||||
export const flattenArchiveForMerge = (archive: ProcessedZipArchive): ZipAttachment[] => {
|
||||
const ordered: ZipAttachment[] = [];
|
||||
for (const sub of archive.subDocuments) ordered.push(...sub.attachments);
|
||||
ordered.push(...archive.attachments);
|
||||
@@ -125,7 +139,9 @@ export type ExportUnit = { name: string; bytes: Uint8Array };
|
||||
export const buildArchiveExport = async (
|
||||
archive: ProcessedZipArchive,
|
||||
mode: 'single' | 'separate'
|
||||
): Promise<ExportUnit[]> => { /* ... */ };
|
||||
): Promise<ExportUnit[]> => {
|
||||
/* ... */
|
||||
};
|
||||
```
|
||||
|
||||
`mergeProcessedZipArchive` is refactored to delegate to a new `mergeAttachments(attachments): Promise<Uint8Array>` (a thin rename of the existing inner loop) so both "single" and "separate" share the same merging code.
|
||||
@@ -137,6 +153,7 @@ export const buildArchiveExport = async (
|
||||
### `ProcessedZipArchiveEditor.svelte` (main work)
|
||||
|
||||
State additions:
|
||||
|
||||
- `subDocuments = $state<SubDocument[]>([])` synced from `archive.subDocuments` in the existing `$effect`.
|
||||
- `selectionMode = $state<boolean>(false)` — whether selection checkboxes are currently shown on thumbnails.
|
||||
- `selectedPaths = $state<Set<string>>([])` — currently selected attachment paths (keyed by `attachment.path`, which is unique within an archive after extraction). Only meaningful while `selectionMode` is on.
|
||||
@@ -149,6 +166,7 @@ State additions:
|
||||
- Drag-and-drop is disabled while in selection mode (so a click toggles selection rather than starting a drag).
|
||||
|
||||
Layout:
|
||||
|
||||
- Keep the existing horizontal `<ul>` for **loose** attachments.
|
||||
- In the header, add a small toggle button labelled **"Auswählen"** (icon: `IconMenu` or a checkmark lucide icon). When active it reads **"Fertig"** (and selection mode is on). In normal mode thumbnails have no checkbox at all — the editor looks identical to today.
|
||||
- While in selection mode, each thumbnail card shows a **checkbox** in the top-left corner and the whole card becomes a click target that toggles its membership in `selectedPaths`. Drag-to-reorder is suspended in this mode to avoid click/drag ambiguity.
|
||||
@@ -163,6 +181,7 @@ Layout:
|
||||
- Sub-document blocks are draggable as a whole via their header (HTML5 DnD, mirroring the existing pattern in `handleDragStart/Drop`).
|
||||
|
||||
Export controls (header area):
|
||||
|
||||
- If `subDocuments.length === 0` → existing **"PDF herunterladen"** button + behaviour (unchanged).
|
||||
- If `subDocuments.length > 0` → replace with a **segmented control** (`Eine PDF` / `Getrennt`) plus the download button labeled accordingly:
|
||||
- `Eine PDF` → `buildArchiveExport(archive, 'single')`, one `downloadPdfBytes` call.
|
||||
@@ -171,12 +190,14 @@ Export controls (header area):
|
||||
All state changes flow through `onArchiveChange` (already plumbed to `updateProcessedZipFile` in `+page.svelte`) so the top-level array stays the source of truth.
|
||||
|
||||
### `+page.svelte`
|
||||
|
||||
- `exportAllZipArchivesAsPdf` needs to honour per-archive export mode. Two options:
|
||||
- ✅ Keep it simple: bulk export uses the existing **"single"** mode for every archive (sub-documents flattened). Add a note in the tooltip.
|
||||
- B. Read each archive's last-chosen mode (requires propagating `downloadMode` up). Defer to a follow-up if desired.
|
||||
- The settings-drawer bulk export button label stays; behaviour unchanged for v1.
|
||||
|
||||
### No changes required
|
||||
|
||||
- `ZipDropzone`, `AttachmentPreview`, services, `xml-reading.service`, `zip-inflating.service`, layout.
|
||||
|
||||
---
|
||||
@@ -184,6 +205,7 @@ All state changes flow through `onArchiveChange` (already plumbed to `updateProc
|
||||
## Implementation phases
|
||||
|
||||
### Phase 1 — Data model + pure helpers
|
||||
|
||||
- Edit `src/lib/zip-processing.ts`:
|
||||
- Add `SubDocument` type and extend `ProcessedZipArchive` with `subDocuments: SubDocument[]` (default `[]`).
|
||||
- Set `subDocuments: []` in `extractZipEntries`.
|
||||
@@ -192,6 +214,7 @@ All state changes flow through `onArchiveChange` (already plumbed to `updateProc
|
||||
- Verification: TypeScript compiles (`pnpm check` / `svelte-check`); existing tests (if any) still pass; existing UI still renders unchanged.
|
||||
|
||||
### Phase 2 — Sub-document block component
|
||||
|
||||
- New `src/lib/components/SubDocumentEditor.svelte`:
|
||||
- Props: `subDocument`, `thumbnailWidth`, `onSubDocumentChange`, `onRemove`, `drag handlers` (or pass a generic reorder callback).
|
||||
- Renders the editable name, count, remove button, draggable header, horizontal attachment list (reusing `AttachmentPreview` + the same DnD styling).
|
||||
@@ -199,6 +222,7 @@ All state changes flow through `onArchiveChange` (already plumbed to `updateProc
|
||||
- Verify: can be unit-rendered in isolation (storybook-equivalent or a throwaway page snippet).
|
||||
|
||||
### Phase 3 — Selection mode + sub-document creation in `ProcessedZipArchiveEditor`
|
||||
|
||||
- Add `selectionMode` and `selectedPaths` state and the "Auswählen" / "Fertig" header toggle.
|
||||
- While `selectionMode` is on, render a checkbox on each loose thumbnail and make the whole card a click-to-toggle target; suspend drag-to-reorder to avoid click/drag ambiguity.
|
||||
- Add the inline action bar with **"Teildokument erstellen"**, visible when a selection exists.
|
||||
@@ -208,11 +232,13 @@ All state changes flow through `onArchiveChange` (already plumbed to `updateProc
|
||||
- Verify: toggling selection mode shows/hides checkboxes; in normal mode the editor looks identical to today; creating, populating, and removing sub-documents works end-to-end with reordering still working for loose attachments.
|
||||
|
||||
### Phase 4 — Sub-document drag interactions
|
||||
|
||||
- Generalise the existing HTML5 DnD handlers to support the three move scenarios listed above. Use the `dataTransfer` payload to encode `{ source: 'loose' |`sub:${id}`, index }`.
|
||||
- Reorder sub-document blocks via header drag.
|
||||
- Verify: items can be moved loose↔sub and sub↔sub; sub-document order can be changed; nothing is duplicated or lost (count invariant: `loose.length + Σ sub.attachments.length === total attachments`).
|
||||
|
||||
### Phase 5 — Export modes
|
||||
|
||||
- Replace header download button with the segmented control + button when sub-documents exist.
|
||||
- Wire `buildArchiveExport(archive, mode)` to `downloadPdfBytes` (single) and the sequential loop (separate).
|
||||
- `+page.svelte`: keep `exportAllZipArchivesAsPdf` in single mode; optionally surface a small hint when an archive has sub-documents.
|
||||
@@ -222,6 +248,7 @@ All state changes flow through `onArchiveChange` (already plumbed to `updateProc
|
||||
- With sub-documents, "Getrennt" downloads N files named `{archiveName} - {subName}.pdf`.
|
||||
|
||||
### Phase 6 — Polish
|
||||
|
||||
- Empty states (e.g. empty sub-document block, no loose attachments).
|
||||
- Disable the create-sub-document button when selection is empty.
|
||||
- Accessible labels + keyboard operability for checkboxes / segmented control (`bits-ui` SegmentedControl if available, otherwise native radio group).
|
||||
@@ -232,18 +259,18 @@ All state changes flow through `onArchiveChange` (already plumbed to `updateProc
|
||||
|
||||
## Verification checklist (definition of done)
|
||||
|
||||
- [ ] `pnpm check` (or `svelte-check`) passes with no new errors.
|
||||
- [ ] `pnpm build` succeeds.
|
||||
- [ ] Loading a ZIP without creating sub-documents produces the exact same merged PDF as today.
|
||||
- [ ] Toggling "Auswählen" shows checkboxes on thumbnails; toggling "Fertig" (or pressing Esc) hides them and clears the selection. In normal mode the editor looks identical to today (no checkboxes).
|
||||
- [ ] Selecting ≥1 attachments and clicking "Teildokument erstellen" removes them from the loose list and adds a named sub-document block, then clears the selection while staying in selection mode for the next group.
|
||||
- [ ] Drag-reordering works for: loose attachments, attachments inside a sub-document, sub-document blocks themselves, and moving an attachment loose↔sub.
|
||||
- [ ] With sub-documents present:
|
||||
- [x] `pnpm check` (or `svelte-check`) passes with no new errors.
|
||||
- [x] `pnpm build` succeeds.
|
||||
- [x] Loading a ZIP without creating sub-documents produces the exact same merged PDF as today.
|
||||
- [x] Toggling "Auswählen" shows checkboxes on thumbnails; toggling "Fertig" (or pressing Esc) hides them and clears the selection. In normal mode the editor looks identical to today (no checkboxes).
|
||||
- [x] Selecting ≥1 attachments and clicking "Teildokument erstellen" removes them from the loose list and adds a named sub-document block, then clears the selection while staying in selection mode for the next group.
|
||||
- [x] Drag-reordering works for: loose attachments, attachments inside a sub-document, sub-document blocks themselves, and moving an attachment loose↔sub.
|
||||
- [x] With sub-documents present:
|
||||
- "Eine PDF" downloads a single PDF containing every attachment (sub-documents first, then loose).
|
||||
- "Getrennt" downloads one PDF per sub-document, each correctly named.
|
||||
- [ ] Removing a sub-document returns its attachments to the loose list; total attachment count is invariant.
|
||||
- [ ] Bulk "Alle ZIP-Archive als PDF exportieren" still works (single mode).
|
||||
- [ ] `agents.md` updated.
|
||||
- [x] Removing a sub-document returns its attachments to the loose list; total attachment count is invariant.
|
||||
- [x] Bulk "Alle ZIP-Archive als PDF exportieren" still works (single mode).
|
||||
- [x] `agents.md` updated.
|
||||
|
||||
---
|
||||
|
||||
@@ -253,4 +280,4 @@ All state changes flow through `onArchiveChange` (already plumbed to `updateProc
|
||||
- Renaming attachments themselves (only sub-document names are editable in v1).
|
||||
- Exporting "separate" as ZIPs or original-format files (see D4-C).
|
||||
- Per-archive export mode remembered by the bulk exporter (D3 follow-up).
|
||||
- Drag-and-drop between *different archives* in `+page.svelte` (today each editor is self-contained).
|
||||
- Drag-and-drop between _different archives_ in `+page.svelte` (today each editor is self-contained).
|
||||
@@ -0,0 +1,146 @@
|
||||
# Plan: PWA / Offline-Fähigkeit
|
||||
|
||||
## Goal
|
||||
|
||||
The app already processes everything locally — but the browser still has to fetch the app shell
|
||||
from the server on every load. Making **beA-Edit an installable, offline-capable PWA** reinforces
|
||||
the privacy story ("works without internet after first load"), enables home-screen/desktop
|
||||
installation, and is essentially free given there are no network calls to cache-around.
|
||||
|
||||
---
|
||||
|
||||
## Open decisions (defaults marked ✅)
|
||||
|
||||
### D1 — Implementation approach
|
||||
|
||||
- ✅ **A. `@vite-pwa/sveltekit`** (new **dev** dependency). Mature integration: precaching of the
|
||||
built assets via Workbox, generated manifest, virtual `RegisterSW` module, works with
|
||||
`adapter-node` builds. The dependency is justified (agents.md allows deps when the task cannot
|
||||
reasonably be done without) — hand-rolling a correct service worker + update flow is
|
||||
error-prone.
|
||||
- B. Hand-written minimal `sw.js` in `static/` + manual manifest. Zero deps but custom update
|
||||
logic, manual asset hashing, more long-term maintenance.
|
||||
|
||||
### D2 — Update UX
|
||||
|
||||
- ✅ **A. Auto-update with a toast.** New versions activate on next navigation; a small toast
|
||||
"Neue Version verfügbar – Seite neu laden" appears when an update is waiting (Workbox
|
||||
`onNeedRefresh`/`onOfflineReady` hooks). Never force-reload mid-work (users may have unsaved
|
||||
archive state).
|
||||
- B. Silent auto-reload. Rejected: could interrupt an in-progress merge.
|
||||
|
||||
### D3 — Legal pages
|
||||
|
||||
- ✅ **Review required, not auto-edited.** A service worker + install prompt is client-side
|
||||
storage/behavior; `datenschutz/+page.svelte` should likely gain a sentence. Flag for the user;
|
||||
do not rewrite legal copy unilaterally (project constraint).
|
||||
|
||||
---
|
||||
|
||||
## Technical approach
|
||||
|
||||
### Vite config
|
||||
|
||||
```ts
|
||||
// vite.config.ts
|
||||
import { SvelteKitPWA } from '@vite-pwa/sveltekit';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [
|
||||
sveltekit(),
|
||||
tailwindcss(),
|
||||
SvelteKitPWA({
|
||||
registerType: 'prompt',
|
||||
manifest: {
|
||||
name: 'beA-Edit',
|
||||
short_name: 'beA-Edit',
|
||||
description: 'PDF-Werkzeuge für die beA-Kanzlei – 100 % lokal im Browser.',
|
||||
lang: 'de',
|
||||
start_url: '/',
|
||||
display: 'standalone',
|
||||
background_color: '#f2f5f8', // align with theme background token
|
||||
theme_color: '#…', // align with primary token
|
||||
icons: [
|
||||
/* 192 + 512 + maskable, derived from static logo */
|
||||
]
|
||||
},
|
||||
workbox: {
|
||||
globPatterns: ['**/*.{js,css,html,svg,woff2}'],
|
||||
navigateFallback: '/' // SPA-style offline fallback for app routes
|
||||
},
|
||||
devOptions: { enabled: false }
|
||||
})
|
||||
]
|
||||
});
|
||||
```
|
||||
|
||||
Notes:
|
||||
|
||||
- pdfjs worker chunk (`pdf.worker.mjs?url`) is part of the built assets → precached by glob.
|
||||
- Legal routes (`/impressum`, `/datenschutz`) precache like any route; offline navigation works
|
||||
via the fallback.
|
||||
- No runtime caching strategies needed: the app makes **no** network requests by design.
|
||||
|
||||
### Registration UI
|
||||
|
||||
- Root `+layout.svelte`: import the virtual `virtual:pwa-register` module (SSR-guarded), wire
|
||||
`onNeedRefresh` → toast state; render a small dismissible toast component (build with existing
|
||||
card/button styling; no new UI dep) with "Neu laden" / "Später".
|
||||
- `onOfflineReady` → optional one-time hint "beA-Edit funktioniert jetzt offline." (keep subtle).
|
||||
- Install prompt: rely on the browser's native install affordance (manifest makes it appear);
|
||||
optionally an "Installieren" hint on the start page later — out of scope for v1.
|
||||
|
||||
### Icons
|
||||
|
||||
- Generate 192/512/maskable PNGs from the existing logo (`static/` logo asset) once, committed
|
||||
to `static/icons/`. Use an offline tool/imagemagick during implementation; no runtime dep.
|
||||
|
||||
### Docker / adapter-node
|
||||
|
||||
- Service worker + manifest are static build output → served by `node build/index.js` without
|
||||
changes. Verify `Cache-Control` headers don't block SW updates (adapter-node defaults are fine;
|
||||
the SW itself must not be cached aggressively — Vite PWA emits the right filenames).
|
||||
|
||||
## Files changed
|
||||
|
||||
- `vite.config.ts` — SvelteKitPWA plugin + manifest.
|
||||
- `package.json` / lockfile — `@vite-pwa/sveltekit` dev dependency (via pnpm only).
|
||||
- `src/routes/+layout.svelte` — registration + update toast.
|
||||
- `static/icons/*` — generated icons; `static/manifest.webmanifest` handled by plugin.
|
||||
- `src/app.html` — `theme-color` meta if plugin doesn't inject.
|
||||
- `agents.md` — document PWA setup + update behavior.
|
||||
- `datenschutz/+page.svelte` — only after explicit user approval (D3).
|
||||
|
||||
## Implementation phases
|
||||
|
||||
1. Add dependency, configure plugin + manifest + icons.
|
||||
2. Registration + update toast in root layout.
|
||||
3. Build + `pnpm preview`: verify `/` loads, then devtools → Application → service worker
|
||||
registered; toggle "Offline" → reload → app still works.
|
||||
4. Production image smoke test (Docker build, offline reload).
|
||||
5. Update `agents.md`; flag datenschutz review.
|
||||
|
||||
## Verification
|
||||
|
||||
- Fresh load registers the SW (devtools), `onOfflineReady` path reachable.
|
||||
- Offline reload after precache: start page + all tool routes render; dropping a ZIP still works
|
||||
fully (entire processing chain offline).
|
||||
- New build deployed → toast offers reload; choosing it loads the new version; declining does
|
||||
not interrupt current state.
|
||||
- No console errors on iOS Safari (manifest parsing quirks) — best-effort check.
|
||||
- `pnpm check`, `pnpm lint`, `pnpm build` pass.
|
||||
|
||||
## Risks
|
||||
|
||||
- **Stale cache confusion** during development: keep `devOptions.enabled: false`; document in
|
||||
agents.md that preview/production test the SW, not dev mode.
|
||||
- **Large precache manifest**: the pdfjs worker + fonts add a few MB to the first precache —
|
||||
acceptable for an installable tool; monitor build output stats.
|
||||
- **SW + SvelteKit hydration edge cases** on first offline visit are known upstream; pin a
|
||||
current stable plugin version and test the exact offline-reload sequence.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- Background sync (nothing to sync — no network by design).
|
||||
- App-store packaging.
|
||||
- Custom install button UI beyond the native affordance.
|
||||
@@ -0,0 +1,31 @@
|
||||
/// <reference types="node" />
|
||||
|
||||
import { defineConfig } from '@playwright/test';
|
||||
import { existsSync } from 'node:fs';
|
||||
|
||||
const chromiumExecutable =
|
||||
process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH ??
|
||||
(existsSync('/usr/bin/chromium') ? '/usr/bin/chromium' : undefined);
|
||||
|
||||
export default defineConfig({
|
||||
// `tests/unit` holds pure-function specs (no browser); Playwright runs both.
|
||||
testDir: './tests',
|
||||
fullyParallel: false,
|
||||
retries: 0,
|
||||
reporter: 'line',
|
||||
use: {
|
||||
baseURL: 'http://127.0.0.1:4174',
|
||||
trace: 'retain-on-failure',
|
||||
video: 'retain-on-failure',
|
||||
launchOptions: {
|
||||
executablePath: chromiumExecutable,
|
||||
args: ['--no-sandbox']
|
||||
}
|
||||
},
|
||||
webServer: {
|
||||
command: 'pnpm dev --host 127.0.0.1 --port 4174 --strictPort',
|
||||
url: 'http://127.0.0.1:4174',
|
||||
reuseExistingServer: true,
|
||||
timeout: 120_000
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,29 @@
|
||||
/// <reference types="node" />
|
||||
|
||||
// TEMPORARY: verification harness for the PWA offline/update behaviour.
|
||||
// The spec starts the production `node build/index.js` server itself, because a "deployment"
|
||||
// has to restart the process (adapter-node caches build-file stats at startup).
|
||||
import { defineConfig } from '@playwright/test';
|
||||
import { existsSync } from 'node:fs';
|
||||
|
||||
const chromiumExecutable =
|
||||
process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH ??
|
||||
(existsSync('/usr/bin/chromium') ? '/usr/bin/chromium' : undefined);
|
||||
|
||||
export default defineConfig({
|
||||
testDir: './tests/e2e',
|
||||
testMatch: 'pwa.spec.ts',
|
||||
fullyParallel: false,
|
||||
workers: 1,
|
||||
retries: 0,
|
||||
reporter: 'list',
|
||||
timeout: 420_000,
|
||||
use: {
|
||||
baseURL: 'http://127.0.0.1:4179',
|
||||
trace: 'retain-on-failure',
|
||||
launchOptions: {
|
||||
executablePath: chromiumExecutable,
|
||||
args: ['--no-sandbox']
|
||||
}
|
||||
}
|
||||
});
|
||||
Generated
+3790
-560
File diff suppressed because it is too large.
Load diff
Vendored
+3
@@ -1,3 +1,6 @@
|
||||
/// <reference types="vite-plugin-pwa/client" />
|
||||
/// <reference types="vite-plugin-pwa/info" />
|
||||
|
||||
// See https://svelte.dev/docs/kit/types#app.d.ts
|
||||
// for information about these interfaces
|
||||
declare global {
|
||||
|
||||
@@ -3,7 +3,10 @@
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="theme-color" content="#122f62" />
|
||||
<meta name="text-scale" content="scale" />
|
||||
<link rel="icon" href="%sveltekit.assets%/favicon.svg" type="image/svg+xml" />
|
||||
<link rel="apple-touch-icon" href="%sveltekit.assets%/icons/apple-touch-icon.png" />
|
||||
%sveltekit.head%
|
||||
</head>
|
||||
<body data-sveltekit-preload-data="hover">
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
export function ripple(node: HTMLElement) {
|
||||
function handleClick(event: MouseEvent) {
|
||||
const circle = document.createElement("span");
|
||||
const circle = document.createElement('span');
|
||||
|
||||
const rect = node.getBoundingClientRect();
|
||||
const size = Math.max(rect.width, rect.height);
|
||||
@@ -11,21 +11,20 @@ export function ripple(node: HTMLElement) {
|
||||
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";
|
||||
circle.className = 'absolute rounded-full bg-white/30 animate-ripple pointer-events-none';
|
||||
|
||||
node.appendChild(circle);
|
||||
|
||||
circle.addEventListener("animationend", () => {
|
||||
circle.addEventListener('animationend', () => {
|
||||
circle.remove();
|
||||
});
|
||||
}
|
||||
|
||||
node.addEventListener("click", handleClick);
|
||||
node.addEventListener('click', handleClick);
|
||||
|
||||
return {
|
||||
destroy() {
|
||||
node.removeEventListener("click", handleClick);
|
||||
node.removeEventListener('click', handleClick);
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
import { zipSync } from 'fflate';
|
||||
import { downloadBlob } from './download';
|
||||
|
||||
export type BatchItemStatus = 'pending' | 'processing' | 'done' | 'error';
|
||||
|
||||
export type BatchItem = {
|
||||
id: string;
|
||||
file: File;
|
||||
status: BatchItemStatus;
|
||||
resultBytes?: Uint8Array;
|
||||
resultName?: string;
|
||||
errorMessage?: string | null;
|
||||
};
|
||||
|
||||
export type BatchProcessResult = { bytes: Uint8Array; name: string };
|
||||
|
||||
export type BatchRunOptions = {
|
||||
/**
|
||||
* Error message written to a row when `process` throws for it. A function
|
||||
* receives the thrown error so tools can surface a specific German message.
|
||||
*/
|
||||
errorMessage?: string | ((error: unknown, file: File) => string);
|
||||
/**
|
||||
* Optional predicate consulted before each item; when it returns `false`
|
||||
* (for example because the row was removed mid-run), the item is skipped.
|
||||
*/
|
||||
shouldProcess?: (id: string) => boolean;
|
||||
};
|
||||
|
||||
export const createBatchItem = (file: File): BatchItem => ({
|
||||
id: crypto.randomUUID(),
|
||||
file,
|
||||
status: 'pending'
|
||||
});
|
||||
|
||||
/**
|
||||
* Processes the given items strictly one after another so only one PDF is in
|
||||
* flight at a time. Never throws: a failing item is marked as an error row and
|
||||
* the queue continues with the remaining files.
|
||||
*
|
||||
* `process` receives the item as well, so tools can resolve per-row data (for
|
||||
* example the exhibit number frozen when the run started).
|
||||
*/
|
||||
export const runBatch = async (
|
||||
items: BatchItem[],
|
||||
process: (file: File, item: BatchItem) => Promise<BatchProcessResult>,
|
||||
update: (id: string, patch: Partial<BatchItem>) => void,
|
||||
options: BatchRunOptions = {}
|
||||
): Promise<void> => {
|
||||
const { errorMessage = 'Die Datei konnte nicht verarbeitet werden.', shouldProcess } = options;
|
||||
|
||||
for (const item of items) {
|
||||
if (shouldProcess && !shouldProcess(item.id)) continue;
|
||||
|
||||
update(item.id, { status: 'processing', errorMessage: null });
|
||||
try {
|
||||
const result = await process(item.file, item);
|
||||
update(item.id, {
|
||||
status: 'done',
|
||||
resultBytes: result.bytes,
|
||||
resultName: result.name,
|
||||
errorMessage: null
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(`Batch processing failed for ${item.file.name}:`, error);
|
||||
update(item.id, {
|
||||
status: 'error',
|
||||
resultBytes: undefined,
|
||||
resultName: undefined,
|
||||
errorMessage:
|
||||
typeof errorMessage === 'function' ? errorMessage(error, item.file) : errorMessage
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export const formatBytes = (bytes: number) => {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||
};
|
||||
|
||||
/** Badge colours for a batch row, shared by every batch list component. */
|
||||
export const batchStatusClass = (status: BatchItemStatus) => {
|
||||
switch (status) {
|
||||
case 'processing':
|
||||
return 'bg-amber-100 text-amber-800';
|
||||
case 'done':
|
||||
return 'bg-emerald-100 text-emerald-800';
|
||||
case 'error':
|
||||
return 'bg-red-100 text-red-800';
|
||||
default:
|
||||
return 'bg-accent text-primary';
|
||||
}
|
||||
};
|
||||
|
||||
/** German badge label for a batch row. */
|
||||
export const batchStatusLabel = (status: BatchItemStatus) => {
|
||||
switch (status) {
|
||||
case 'processing':
|
||||
return 'Verarbeite…';
|
||||
case 'done':
|
||||
return 'Fertig ✓';
|
||||
case 'error':
|
||||
return 'Fehler';
|
||||
default:
|
||||
return 'Wartet';
|
||||
}
|
||||
};
|
||||
|
||||
/** Above this many files (or one file above the threshold) memory gets tight. */
|
||||
const BATCH_MEMORY_FILE_COUNT = 20;
|
||||
const BATCH_MEMORY_FILE_BYTES = 25 * 1024 * 1024;
|
||||
|
||||
/**
|
||||
* Shared memory hint for batch tools: very many or very large files keep every
|
||||
* result in memory until the page is closed.
|
||||
*/
|
||||
export const shouldWarnAboutBatchMemory = (items: BatchItem[]) =>
|
||||
items.length > BATCH_MEMORY_FILE_COUNT ||
|
||||
items.some((item) => item.file.size > BATCH_MEMORY_FILE_BYTES);
|
||||
|
||||
/** Appends `_2`, `_3`, … when a name is already taken inside the archive. */
|
||||
export const uniqueEntryName = (name: string, usedNames: Set<string>) => {
|
||||
if (!usedNames.has(name)) {
|
||||
usedNames.add(name);
|
||||
return name;
|
||||
}
|
||||
|
||||
const dotIndex = name.lastIndexOf('.');
|
||||
const base = dotIndex > 0 ? name.slice(0, dotIndex) : name;
|
||||
const extension = dotIndex > 0 ? name.slice(dotIndex) : '';
|
||||
let counter = 2;
|
||||
let candidate = `${base}_2${extension}`;
|
||||
|
||||
while (usedNames.has(candidate)) {
|
||||
counter += 1;
|
||||
candidate = `${base}_${counter}${extension}`;
|
||||
}
|
||||
|
||||
usedNames.add(candidate);
|
||||
return candidate;
|
||||
};
|
||||
|
||||
/**
|
||||
* Bundles every finished row into one `{zipBaseName}_{YYYY-MM-DD}.zip`. Kept out
|
||||
* of the component so the next batch tool (exhibits) reuses the naming and the
|
||||
* duplicate handling instead of copying them.
|
||||
*/
|
||||
export const downloadBatchZip = (items: BatchItem[], zipBaseName: string) => {
|
||||
const entries: Record<string, Uint8Array> = {};
|
||||
const usedNames = new Set<string>();
|
||||
|
||||
for (const item of items) {
|
||||
if (item.status !== 'done' || !item.resultBytes || !item.resultName) continue;
|
||||
entries[uniqueEntryName(item.resultName, usedNames)] = item.resultBytes;
|
||||
}
|
||||
|
||||
if (Object.keys(entries).length === 0) return;
|
||||
|
||||
const dateStamp = new Date().toISOString().slice(0, 10);
|
||||
downloadBlob(zipSync(entries), `${zipBaseName}_${dateStamp}.zip`, 'application/zip');
|
||||
};
|
||||
@@ -0,0 +1,100 @@
|
||||
<script lang="ts">
|
||||
import { File, Folder, FolderPlus, MoreHorizontal, Scissors, Trash2 } from '@lucide/svelte';
|
||||
import * as DropdownMenu from '$lib/components/ui/dropdown-menu/index.js';
|
||||
import type { ZipAttachment } from '$lib/zip-processing';
|
||||
|
||||
export type AttachmentMoveTarget = {
|
||||
id: string;
|
||||
name: string;
|
||||
};
|
||||
|
||||
type Props = {
|
||||
attachment: ZipAttachment;
|
||||
/** Existing sub-document move targets (excluding the attachment's own). */
|
||||
moveTargets: AttachmentMoveTarget[];
|
||||
canSplit?: boolean;
|
||||
onMoveToTarget: (targetId: string) => void;
|
||||
onMoveToLoose?: () => void;
|
||||
onSplit?: () => void;
|
||||
onRemove?: () => void;
|
||||
};
|
||||
|
||||
let {
|
||||
attachment,
|
||||
moveTargets,
|
||||
canSplit = false,
|
||||
onMoveToTarget,
|
||||
onMoveToLoose,
|
||||
onSplit,
|
||||
onRemove
|
||||
}: Props = $props();
|
||||
</script>
|
||||
|
||||
<DropdownMenu.Root>
|
||||
<DropdownMenu.Trigger aria-label={`Aktionen für ${attachment.name}`}>
|
||||
{#snippet child({ props })}
|
||||
<button
|
||||
{...props}
|
||||
type="button"
|
||||
class="flex h-6 w-6 shrink-0 cursor-pointer items-center justify-center rounded-md border border-primary-200 bg-white text-primary-700 transition hover:border-primary-400 hover:bg-primary-50 focus-visible:ring-2 focus-visible:ring-primary-300"
|
||||
aria-label={`Aktionen für ${attachment.name}`}
|
||||
data-attachment-menu={attachment.path}
|
||||
draggable="false"
|
||||
ondragstart={(event) => event.preventDefault()}
|
||||
>
|
||||
<MoreHorizontal class="size-3.5" aria-hidden="true" />
|
||||
</button>
|
||||
{/snippet}
|
||||
</DropdownMenu.Trigger>
|
||||
|
||||
<DropdownMenu.Content align="end" side="top" sideOffset={6} class="w-56">
|
||||
<DropdownMenu.Group>
|
||||
<DropdownMenu.GroupHeading>Verschieben</DropdownMenu.GroupHeading>
|
||||
|
||||
{#each moveTargets as target (target.id)}
|
||||
<DropdownMenu.Item
|
||||
aria-label={`Nach „${target.name}“ verschieben`}
|
||||
onclick={() => onMoveToTarget(target.id)}
|
||||
>
|
||||
<Folder aria-hidden="true" />
|
||||
<span class="truncate">{target.name}</span>
|
||||
</DropdownMenu.Item>
|
||||
{/each}
|
||||
|
||||
<DropdownMenu.Item
|
||||
aria-label="In neues Teildokument verschieben"
|
||||
onclick={() => onMoveToTarget('new')}
|
||||
>
|
||||
<FolderPlus aria-hidden="true" />
|
||||
<span class="truncate">Neues Teildokument</span>
|
||||
</DropdownMenu.Item>
|
||||
|
||||
{#if onMoveToLoose}
|
||||
<DropdownMenu.Item aria-label="Zu den losen Dateien verschieben" onclick={onMoveToLoose}>
|
||||
<File aria-hidden="true" />
|
||||
<span class="truncate">Zu den losen Dateien</span>
|
||||
</DropdownMenu.Item>
|
||||
{/if}
|
||||
</DropdownMenu.Group>
|
||||
|
||||
{#if canSplit && onSplit}
|
||||
<DropdownMenu.Separator />
|
||||
<DropdownMenu.Item aria-label={`Teilen: ${attachment.name}`} onclick={onSplit}>
|
||||
<Scissors aria-hidden="true" />
|
||||
<span class="truncate">Teilen…</span>
|
||||
</DropdownMenu.Item>
|
||||
{/if}
|
||||
|
||||
{#if onRemove}
|
||||
<DropdownMenu.Separator />
|
||||
<DropdownMenu.Item
|
||||
variant="destructive"
|
||||
onclick={onRemove}
|
||||
aria-label={`Entfernen: ${attachment.name}`}
|
||||
>
|
||||
<Trash2 aria-hidden="true" />
|
||||
<span class="truncate">Entfernen</span>
|
||||
</DropdownMenu.Item>
|
||||
{/if}
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Root>
|
||||
@@ -1,14 +1,15 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from "svelte";
|
||||
import { onMount } from 'svelte';
|
||||
import { pdfLoadErrorMessage } from '$lib/pdf-errors';
|
||||
|
||||
import type {ZipAttachment} from "$lib/zip-processing";
|
||||
import type { ZipAttachment } from '$lib/zip-processing';
|
||||
|
||||
type Props = {
|
||||
attachment: ZipAttachment;
|
||||
class?: string;
|
||||
};
|
||||
|
||||
let { attachment, class: className = "" }: Props = $props();
|
||||
let { attachment, class: className = '' }: Props = $props();
|
||||
|
||||
let imageUrl = $state<string | null>(null);
|
||||
let previewError = $state<string | null>(null);
|
||||
@@ -31,19 +32,21 @@
|
||||
};
|
||||
|
||||
(async () => {
|
||||
const pdfjsLib = await import("pdfjs-dist");
|
||||
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);
|
||||
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") {
|
||||
if (attachment.kind === 'image') {
|
||||
const imageBuffer = attachment.data.buffer.slice(
|
||||
attachment.data.byteOffset,
|
||||
attachment.data.byteOffset + attachment.data.byteLength,
|
||||
attachment.data.byteOffset + attachment.data.byteLength
|
||||
) as ArrayBuffer;
|
||||
setBlobImage(new Blob([imageBuffer]));
|
||||
return;
|
||||
@@ -62,15 +65,15 @@
|
||||
}
|
||||
|
||||
const viewport = page.getViewport({ scale: 1 });
|
||||
const canvasElement = document.createElement("canvas");
|
||||
const canvasElement = document.createElement('canvas');
|
||||
|
||||
canvasElement.width = viewport.width;
|
||||
canvasElement.height = viewport.height;
|
||||
|
||||
const canvasContext = canvasElement.getContext("2d");
|
||||
const canvasContext = canvasElement.getContext('2d');
|
||||
|
||||
if (!canvasContext) {
|
||||
throw new Error("Could not create thumbnail canvas context.");
|
||||
throw new Error('Could not create thumbnail canvas context.');
|
||||
}
|
||||
|
||||
await page.render({ canvas: canvasElement, canvasContext, viewport }).promise;
|
||||
@@ -87,8 +90,8 @@
|
||||
return;
|
||||
}
|
||||
|
||||
reject(new Error("Could not create thumbnail blob."));
|
||||
}, "image/png");
|
||||
reject(new Error('Could not create thumbnail blob.'));
|
||||
}, 'image/png');
|
||||
});
|
||||
|
||||
setBlobImage(blob);
|
||||
@@ -96,7 +99,7 @@
|
||||
await loadingTask.destroy();
|
||||
} catch (error) {
|
||||
console.error(`Failed to render preview for ${attachment.path}`, error);
|
||||
previewError = "PDF-Vorschau konnte nicht geladen werden.";
|
||||
previewError = pdfLoadErrorMessage(error, 'PDF-Vorschau konnte nicht geladen werden.');
|
||||
}
|
||||
};
|
||||
|
||||
@@ -112,17 +115,21 @@
|
||||
|
||||
<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">
|
||||
<div
|
||||
class="border-primary-200 bg-primary-50 text-primary-800 flex h-full min-h-0 items-center justify-center rounded-md border border-dashed px-3 py-4 text-center text-[12px]"
|
||||
>
|
||||
{previewError}
|
||||
</div>
|
||||
{:else if imageUrl}
|
||||
<img
|
||||
alt={attachment.name}
|
||||
class="block rounded-md border border-primary-100 bg-white object-contain"
|
||||
class="border-primary-100 block rounded-md border 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">
|
||||
<div
|
||||
class="border-primary-200 bg-primary-50 text-primary-800 flex items-center justify-center rounded-md border border-dashed px-3 py-4 text-center text-[12px]"
|
||||
>
|
||||
Vorschau wird erstellt
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
<script lang="ts">
|
||||
import { Download, RotateCcw, Trash2 } from '@lucide/svelte';
|
||||
import Button from '$lib/components/ui/button.svelte';
|
||||
import {
|
||||
batchStatusClass,
|
||||
batchStatusLabel,
|
||||
downloadBatchZip,
|
||||
formatBytes,
|
||||
shouldWarnAboutBatchMemory,
|
||||
type BatchItem
|
||||
} from '$lib/batch';
|
||||
|
||||
type Props = {
|
||||
items: BatchItem[];
|
||||
/** Base file name of the bulk ZIP, e.g. `komprimiert` → `komprimiert_2026-02-12.zip`. */
|
||||
zipBaseName: string;
|
||||
onDownload: (item: BatchItem) => void;
|
||||
onRemove: (id: string) => void;
|
||||
onRetry: (id: string) => void;
|
||||
};
|
||||
|
||||
let { items, zipBaseName, onDownload, onRemove, onRetry }: Props = $props();
|
||||
|
||||
const doneItems = $derived(items.filter((item) => item.status === 'done'));
|
||||
const totalResultBytes = $derived(
|
||||
doneItems.reduce((sum, item) => sum + (item.resultBytes?.byteLength ?? 0), 0)
|
||||
);
|
||||
const processingIndex = $derived(items.findIndex((item) => item.status === 'processing'));
|
||||
const showMemoryWarning = $derived(shouldWarnAboutBatchMemory(items));
|
||||
|
||||
const downloadZip = () => downloadBatchZip(doneItems, zipBaseName);
|
||||
</script>
|
||||
|
||||
<div class="space-y-3 rounded-2xl border border-border bg-card p-4">
|
||||
<div class="flex flex-wrap items-center justify-between gap-2">
|
||||
<h2 class="text-lg font-semibold text-foreground">Dateien ({items.length})</h2>
|
||||
{#if doneItems.length > 0}
|
||||
<p class="text-sm text-primary">
|
||||
Ergebnisse gesamt: <span class="font-semibold">{formatBytes(totalResultBytes)}</span>
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if processingIndex !== -1}
|
||||
<p class="text-sm text-primary" role="status">
|
||||
Datei {processingIndex + 1} von {items.length} wird verarbeitet…
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
{#if showMemoryWarning}
|
||||
<div class="rounded-lg border border-amber-200 bg-amber-50 p-3 text-sm text-amber-800">
|
||||
Bei sehr vielen oder sehr großen Dateien kann der Arbeitsspeicher knapp werden. Laden Sie
|
||||
fertige Ergebnisse früh herunter und entfernen Sie sie aus der Liste.
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<ul class="divide-y divide-border">
|
||||
{#each items as item (item.id)}
|
||||
<li class="flex flex-wrap items-center gap-3 py-3" data-batch-item={item.file.name}>
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="truncate text-sm font-medium text-foreground">{item.file.name}</p>
|
||||
<p class="text-xs text-primary">
|
||||
{formatBytes(item.file.size)}
|
||||
{#if item.status === 'done' && item.resultBytes}
|
||||
→ {formatBytes(item.resultBytes.length)}
|
||||
{/if}
|
||||
</p>
|
||||
{#if item.status === 'error' && item.errorMessage}
|
||||
<p class="mt-0.5 text-xs text-red-700" role="alert">{item.errorMessage}</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<span
|
||||
class="shrink-0 rounded-full px-2.5 py-0.5 text-xs font-semibold {batchStatusClass(
|
||||
item.status
|
||||
)}"
|
||||
>
|
||||
{batchStatusLabel(item.status)}
|
||||
</span>
|
||||
|
||||
<div class="flex shrink-0 items-center gap-1">
|
||||
{#if item.status === 'done' && item.resultBytes && item.resultName}
|
||||
<button
|
||||
type="button"
|
||||
class="focus-visible:ring-primary-400 rounded-lg p-2 text-primary transition hover:bg-accent focus:outline-none focus-visible:ring-2"
|
||||
aria-label="{item.file.name} herunterladen"
|
||||
title="{item.file.name} herunterladen"
|
||||
onclick={() => onDownload(item)}
|
||||
>
|
||||
<Download class="h-4 w-4" />
|
||||
</button>
|
||||
{/if}
|
||||
{#if item.status === 'error'}
|
||||
<button
|
||||
type="button"
|
||||
class="focus-visible:ring-primary-400 rounded-lg p-2 text-primary transition hover:bg-accent focus:outline-none focus-visible:ring-2"
|
||||
aria-label="{item.file.name} erneut versuchen"
|
||||
title="{item.file.name} erneut versuchen"
|
||||
onclick={() => onRetry(item.id)}
|
||||
>
|
||||
<RotateCcw class="h-4 w-4" />
|
||||
</button>
|
||||
{/if}
|
||||
<button
|
||||
type="button"
|
||||
class="focus-visible:ring-primary-400 rounded-lg p-2 text-primary transition hover:bg-accent focus:outline-none focus-visible:ring-2 disabled:cursor-not-allowed disabled:opacity-40"
|
||||
aria-label="{item.file.name} entfernen"
|
||||
title="{item.file.name} entfernen"
|
||||
disabled={item.status === 'processing'}
|
||||
onclick={() => onRemove(item.id)}
|
||||
>
|
||||
<Trash2 class="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
|
||||
{#if doneItems.length >= 2}
|
||||
<div class="flex justify-end pt-1">
|
||||
<Button onclick={downloadZip} icon={Download}>Alle herunterladen</Button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -0,0 +1,332 @@
|
||||
<script lang="ts" module>
|
||||
export type ZipProcessingJobStatus = 'processing' | 'complete' | 'error';
|
||||
|
||||
export type ZipProcessingJob = {
|
||||
id: string;
|
||||
file: File;
|
||||
status: ZipProcessingJobStatus;
|
||||
archiveCount?: number;
|
||||
error?: string;
|
||||
};
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
import { Check, CircleDashed, TriangleAlert } from '@lucide/svelte';
|
||||
import Button from '$lib/components/ui/button.svelte';
|
||||
import * as Card from '$lib/components/ui/card/index';
|
||||
|
||||
type Props = {
|
||||
jobs: ZipProcessingJob[];
|
||||
compact?: boolean;
|
||||
onRetry: (jobId: string) => void;
|
||||
onRemove: (jobId: string) => void;
|
||||
};
|
||||
|
||||
let { jobs, compact = false, onRetry, onRemove }: Props = $props();
|
||||
|
||||
let processingCount = $derived(jobs.filter((job) => job.status === 'processing').length);
|
||||
let completedCount = $derived(jobs.filter((job) => job.status === 'complete').length);
|
||||
let failedJobs = $derived(jobs.filter((job) => job.status === 'error'));
|
||||
let completionPercent = $derived(jobs.length === 0 ? 0 : (completedCount / jobs.length) * 100);
|
||||
|
||||
const formatFileSize = (size: number) => {
|
||||
if (size < 1024 * 1024) return `${Math.max(1, Math.round(size / 1024))} KB`;
|
||||
return `${(size / (1024 * 1024)).toFixed(1)} MB`;
|
||||
};
|
||||
|
||||
const archiveCountLabel = (count: number | undefined) => {
|
||||
if (count === undefined) return 'Bereit';
|
||||
return `${count} Archiv${count === 1 ? '' : 'e'} bereit`;
|
||||
};
|
||||
</script>
|
||||
|
||||
{#if compact}
|
||||
<Card.Root size="sm" class="border-l-4 border-l-secondary">
|
||||
<p class="sr-only" role="status" aria-live="polite">
|
||||
{completedCount} von {jobs.length} Dateien bereit, {processingCount} in Bearbeitung,
|
||||
{failedJobs.length} fehlgeschlagen.
|
||||
</p>
|
||||
<Card.Content class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div class="flex min-w-0 items-center gap-3">
|
||||
<div class="relative grid size-10 shrink-0 place-items-center" aria-hidden="true">
|
||||
<div class="compact-folder"></div>
|
||||
{#if processingCount > 0}
|
||||
<CircleDashed
|
||||
class="absolute -right-0.5 -bottom-0.5 size-4 animate-spin text-secondary"
|
||||
/>
|
||||
{:else}
|
||||
<TriangleAlert class="absolute -right-0.5 -bottom-0.5 size-4 text-destructive" />
|
||||
{/if}
|
||||
</div>
|
||||
<div class="min-w-0">
|
||||
<p class="font-bold text-primary-900">
|
||||
{#if processingCount > 0}
|
||||
{processingCount} ZIP-Datei{processingCount === 1 ? '' : 'en'}
|
||||
{processingCount === 1 ? ' wird' : ' werden'} noch geöffnet
|
||||
{:else}
|
||||
{failedJobs.length} ZIP-Datei{failedJobs.length === 1 ? '' : 'en'} konnte{failedJobs.length ===
|
||||
1
|
||||
? ''
|
||||
: 'n'} nicht geöffnet werden
|
||||
{/if}
|
||||
</p>
|
||||
<p class="truncate text-[13px] text-primary-700">
|
||||
{jobs
|
||||
.filter((job) =>
|
||||
processingCount > 0 ? job.status === 'processing' : job.status === 'error'
|
||||
)
|
||||
.map((job) => job.file.name)
|
||||
.join(', ')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<p class="shrink-0 text-[12px] font-medium text-primary-700">
|
||||
Verarbeitung nur in Ihrem Browser
|
||||
</p>
|
||||
</Card.Content>
|
||||
|
||||
{#if failedJobs.length > 0}
|
||||
<Card.Footer class="flex flex-col items-stretch gap-2">
|
||||
{#each failedJobs as job (job.id)}
|
||||
<div class="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||||
<p class="min-w-0 text-[13px] font-medium text-destructive">
|
||||
<span class="font-bold">{job.file.name}</span> konnte nicht geöffnet werden.
|
||||
</p>
|
||||
<div class="flex shrink-0 gap-1">
|
||||
<Button variant="tertiary" class="h-8 min-w-0 px-3" onclick={() => onRetry(job.id)}
|
||||
>Erneut versuchen</Button
|
||||
>
|
||||
<Button variant="tertiary" class="h-8 min-w-0 px-3" onclick={() => onRemove(job.id)}
|
||||
>Entfernen</Button
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</Card.Footer>
|
||||
{/if}
|
||||
</Card.Root>
|
||||
{:else}
|
||||
<Card.Root class="mx-auto w-full max-w-2xl shadow-sm">
|
||||
<p class="sr-only" role="status" aria-live="polite">
|
||||
{completedCount} von {jobs.length} Dateien bereit, {processingCount} in Bearbeitung,
|
||||
{failedJobs.length} fehlgeschlagen.
|
||||
</p>
|
||||
<Card.Header class="place-items-center px-6 pt-4 text-center sm:px-10">
|
||||
<div class="case-intake" aria-hidden="true">
|
||||
<div class="folder-back"></div>
|
||||
<div class="document-sheet">
|
||||
<span></span>
|
||||
<span></span>
|
||||
<span></span>
|
||||
</div>
|
||||
<div class="folder-front"></div>
|
||||
</div>
|
||||
|
||||
<Card.Title class="mt-3 text-[22px] font-extrabold text-primary-900">
|
||||
{#if processingCount > 0}
|
||||
beA-Archive werden vorbereitet
|
||||
{:else if failedJobs.length > 0 && completedCount === 0}
|
||||
Archive konnten nicht vorbereitet werden
|
||||
{:else}
|
||||
Archive sind bereit
|
||||
{/if}
|
||||
</Card.Title>
|
||||
<Card.Description class="max-w-md font-medium text-primary-700">
|
||||
Die Dateien werden ausschließlich in Ihrem Browser geöffnet und nicht hochgeladen.
|
||||
</Card.Description>
|
||||
</Card.Header>
|
||||
|
||||
<Card.Content class="flex flex-col gap-4 px-6 sm:px-10">
|
||||
<div class="flex flex-col gap-2">
|
||||
<div class="flex items-center justify-between gap-4 text-[13px] font-bold text-primary-900">
|
||||
<span>{completedCount} von {jobs.length} Dateien bereit</span>
|
||||
{#if processingCount > 0}
|
||||
<span class="font-medium text-primary-700">Bitte kurz warten</span>
|
||||
{/if}
|
||||
</div>
|
||||
<div
|
||||
class="h-1.5 overflow-hidden rounded-full bg-primary-100"
|
||||
role="progressbar"
|
||||
aria-label="Vorbereitete ZIP-Dateien"
|
||||
aria-valuemin="0"
|
||||
aria-valuemax={jobs.length}
|
||||
aria-valuenow={completedCount}
|
||||
>
|
||||
<div
|
||||
class="h-full rounded-full bg-success transition-[width] duration-500"
|
||||
style:width={`${completionPercent}%`}
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ul class="flex flex-col gap-2" aria-label="Status der ZIP-Dateien">
|
||||
{#each jobs as job (job.id)}
|
||||
<li
|
||||
class="grid grid-cols-[auto_minmax(0,1fr)] items-center gap-3 rounded-xl border bg-background/55 px-3 py-3 sm:grid-cols-[auto_minmax(0,1fr)_auto]"
|
||||
>
|
||||
<div class="grid size-8 place-items-center rounded-full bg-card" aria-hidden="true">
|
||||
{#if job.status === 'processing'}
|
||||
<CircleDashed class="size-5 animate-spin text-secondary" />
|
||||
{:else if job.status === 'complete'}
|
||||
<Check class="size-5 text-success" />
|
||||
{:else}
|
||||
<TriangleAlert class="size-5 text-destructive" />
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="min-w-0">
|
||||
<p class="truncate text-[14px] font-bold text-primary-900">{job.file.name}</p>
|
||||
<p class="job-meta text-[12px] text-primary-700">{formatFileSize(job.file.size)}</p>
|
||||
{#if job.status === 'error'}
|
||||
<p class="mt-1 text-[12px] leading-snug text-destructive">
|
||||
<span class="font-bold">Konnte nicht geöffnet werden.</span>
|
||||
<span class="block">
|
||||
{job.error ?? 'Prüfen Sie, ob die Datei ein gültiges beA-ZIP-Archiv ist.'}
|
||||
</span>
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="col-start-2 flex flex-wrap items-center gap-1 sm:col-start-3 sm:row-start-1"
|
||||
>
|
||||
{#if job.status === 'processing'}
|
||||
<span class="text-[12px] font-bold text-secondary">Wird geöffnet</span>
|
||||
{:else if job.status === 'complete'}
|
||||
<span class="text-[12px] font-bold text-success">
|
||||
{archiveCountLabel(job.archiveCount)}
|
||||
</span>
|
||||
{:else}
|
||||
<Button variant="tertiary" class="h-8 min-w-0 px-3" onclick={() => onRetry(job.id)}
|
||||
>Erneut versuchen</Button
|
||||
>
|
||||
<Button variant="tertiary" class="h-8 min-w-0 px-3" onclick={() => onRemove(job.id)}
|
||||
>Entfernen</Button
|
||||
>
|
||||
{/if}
|
||||
</div>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.job-meta {
|
||||
font-family: 'Geist Variable', ui-monospace, monospace;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.case-intake {
|
||||
position: relative;
|
||||
width: 7rem;
|
||||
height: 5.5rem;
|
||||
}
|
||||
|
||||
.folder-back,
|
||||
.folder-front,
|
||||
.compact-folder {
|
||||
position: absolute;
|
||||
background: color-mix(in srgb, var(--primary) 12%, white);
|
||||
border: 2px solid var(--primary);
|
||||
}
|
||||
|
||||
.folder-back {
|
||||
inset: 1.55rem 0 0;
|
||||
border-radius: 0.65rem 0.65rem 0.8rem 0.8rem;
|
||||
}
|
||||
|
||||
.folder-back::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: -2px;
|
||||
top: -0.8rem;
|
||||
width: 3.4rem;
|
||||
height: 1rem;
|
||||
border: 2px solid var(--primary);
|
||||
border-bottom: 0;
|
||||
border-radius: 0.55rem 0.55rem 0 0;
|
||||
background: color-mix(in srgb, var(--primary) 12%, white);
|
||||
}
|
||||
|
||||
.document-sheet {
|
||||
position: absolute;
|
||||
z-index: 1;
|
||||
left: 50%;
|
||||
top: 0.2rem;
|
||||
display: flex;
|
||||
width: 3.7rem;
|
||||
height: 4.6rem;
|
||||
transform: translateX(-50%);
|
||||
flex-direction: column;
|
||||
gap: 0.42rem;
|
||||
border: 2px solid var(--primary);
|
||||
border-radius: 0.35rem;
|
||||
background: var(--card);
|
||||
padding: 0.8rem 0.65rem;
|
||||
box-shadow: 0 0.4rem 1rem rgb(18 47 98 / 12%);
|
||||
animation: file-intake 1.8s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.document-sheet span {
|
||||
display: block;
|
||||
height: 0.18rem;
|
||||
border-radius: 999px;
|
||||
background: color-mix(in srgb, var(--primary) 30%, transparent);
|
||||
}
|
||||
|
||||
.document-sheet span:nth-child(2) {
|
||||
width: 72%;
|
||||
}
|
||||
|
||||
.document-sheet span:nth-child(3) {
|
||||
width: 88%;
|
||||
background: var(--secondary);
|
||||
}
|
||||
|
||||
.folder-front {
|
||||
z-index: 2;
|
||||
inset: 2.7rem 0 0;
|
||||
border-radius: 0.45rem 0.45rem 0.8rem 0.8rem;
|
||||
background: color-mix(in srgb, var(--primary) 18%, white);
|
||||
transform: perspective(8rem) rotateX(-5deg);
|
||||
transform-origin: bottom;
|
||||
}
|
||||
|
||||
.compact-folder {
|
||||
inset: 0.65rem 0.2rem 0.25rem;
|
||||
border-radius: 0.35rem;
|
||||
}
|
||||
|
||||
.compact-folder::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: -2px;
|
||||
top: -0.4rem;
|
||||
width: 1rem;
|
||||
height: 0.5rem;
|
||||
border: 2px solid var(--primary);
|
||||
border-bottom: 0;
|
||||
border-radius: 0.25rem 0.25rem 0 0;
|
||||
background: color-mix(in srgb, var(--primary) 12%, white);
|
||||
}
|
||||
|
||||
@keyframes file-intake {
|
||||
0%,
|
||||
18% {
|
||||
transform: translate(-50%, -0.45rem);
|
||||
}
|
||||
55%,
|
||||
100% {
|
||||
transform: translate(-50%, 0.65rem);
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.document-sheet,
|
||||
:global(.animate-spin) {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,230 @@
|
||||
<script lang="ts">
|
||||
import { Download, FilePlus2, Plus, Settings2, Trash2 } from '@lucide/svelte';
|
||||
import Button from '$lib/components/ui/button.svelte';
|
||||
import * as AlertDialog from '$lib/components/ui/alert-dialog/index.js';
|
||||
import * as Popover from '$lib/components/ui/popover/index.js';
|
||||
import { Slider } from '$lib/components/ui/slider/index.js';
|
||||
import { Separator } from '$lib/components/ui/separator/index.js';
|
||||
import ZipFilePicker from '$lib/components/ZipFilePicker.svelte';
|
||||
|
||||
type Props = {
|
||||
selectedZipCount: number;
|
||||
processedArchiveCount: number;
|
||||
pendingZipCount: number;
|
||||
thumbnailWidth: number;
|
||||
onThumbnailWidthChange: (value: number) => void;
|
||||
/** Replaces the current workspace with the confirmed files. */
|
||||
onRequestReplacement: (files: File[]) => void | Promise<void>;
|
||||
/** Appends the files to the current workspace. */
|
||||
onAppendFiles: (files: File[]) => void;
|
||||
onExportAll: () => void | Promise<void>;
|
||||
onClear: () => void | Promise<void>;
|
||||
};
|
||||
|
||||
let {
|
||||
selectedZipCount,
|
||||
processedArchiveCount,
|
||||
pendingZipCount,
|
||||
thumbnailWidth,
|
||||
onThumbnailWidthChange,
|
||||
onRequestReplacement,
|
||||
onAppendFiles,
|
||||
onExportAll,
|
||||
onClear
|
||||
}: Props = $props();
|
||||
|
||||
// Replacement files stay in temporary component state until the user confirms;
|
||||
// cancelling the confirmation must never touch the current workspace.
|
||||
let pendingReplacementFiles: File[] | null = $state(null);
|
||||
let clearDialogOpen = $state(false);
|
||||
let isCommitting = $state(false);
|
||||
let isExporting = $state(false);
|
||||
|
||||
const statusText = $derived.by(() => {
|
||||
const fileText = `${selectedZipCount} ${selectedZipCount === 1 ? 'Datei' : 'Dateien'}`;
|
||||
const archiveText = `${processedArchiveCount} ${
|
||||
processedArchiveCount === 1 ? 'Archiv' : 'Archive'
|
||||
} geöffnet`;
|
||||
const pendingText =
|
||||
pendingZipCount > 0
|
||||
? ` · ${pendingZipCount} ${pendingZipCount === 1 ? 'wird' : 'werden'} geöffnet`
|
||||
: '';
|
||||
return `${fileText} · ${archiveText}${pendingText}`;
|
||||
});
|
||||
|
||||
const commit = async (action: () => void | Promise<void>) => {
|
||||
isCommitting = true;
|
||||
try {
|
||||
await action();
|
||||
} finally {
|
||||
isCommitting = false;
|
||||
}
|
||||
};
|
||||
|
||||
const confirmReplacement = () => {
|
||||
const files = pendingReplacementFiles;
|
||||
pendingReplacementFiles = null;
|
||||
if (!files || files.length === 0) return;
|
||||
void commit(() => onRequestReplacement(files));
|
||||
};
|
||||
|
||||
const confirmClear = () => {
|
||||
clearDialogOpen = false;
|
||||
void commit(onClear);
|
||||
};
|
||||
|
||||
const exportAll = async () => {
|
||||
isExporting = true;
|
||||
try {
|
||||
await onExportAll();
|
||||
} finally {
|
||||
isExporting = false;
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="flex w-full flex-col gap-3 border-b border-primary-100 pb-4 sm:flex-row sm:items-center sm:justify-between"
|
||||
>
|
||||
<p class="text-sm font-bold text-primary-700" aria-live="polite">
|
||||
{statusText}
|
||||
</p>
|
||||
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<ZipFilePicker
|
||||
pickerId="replace-zip"
|
||||
ariaLabel="Neue Datei bearbeiten"
|
||||
disabled={isCommitting}
|
||||
onFilesSelected={(files) => (pendingReplacementFiles = files)}
|
||||
>
|
||||
<FilePlus2 class="size-4" aria-hidden="true" />
|
||||
Neue Datei bearbeiten
|
||||
</ZipFilePicker>
|
||||
|
||||
<Popover.Root>
|
||||
<Popover.Trigger aria-label="Einstellungen">
|
||||
{#snippet child({ props })}
|
||||
<Button variant="bordered" {...props}>
|
||||
<Settings2 class="size-4" aria-hidden="true" />
|
||||
<span class="hidden sm:inline">Einstellungen</span>
|
||||
</Button>
|
||||
{/snippet}
|
||||
</Popover.Trigger>
|
||||
|
||||
<Popover.Content
|
||||
align="end"
|
||||
sideOffset={8}
|
||||
class="w-[min(22rem,calc(100vw-2rem))] gap-4 rounded-2xl border border-primary-100 p-4 shadow-lg ring-0"
|
||||
>
|
||||
<span class="sr-only">Einstellungen</span>
|
||||
|
||||
<div class="flex flex-col gap-3">
|
||||
<span class="text-[12px] font-bold uppercase tracking-wide text-primary-700">
|
||||
Ansicht
|
||||
</span>
|
||||
<div class="flex items-center justify-between gap-3 text-sm font-bold text-primary-900">
|
||||
<span>Vorschaugröße</span>
|
||||
<span class="tabular-nums text-primary-700">{thumbnailWidth} px</span>
|
||||
</div>
|
||||
<Slider
|
||||
type="single"
|
||||
thumbAriaLabel="Vorschaugröße"
|
||||
min={100}
|
||||
max={400}
|
||||
step={1}
|
||||
value={thumbnailWidth}
|
||||
onValueChange={(value: number | number[]) => {
|
||||
if (typeof value === 'number') onThumbnailWidthChange(value);
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
class="flex justify-between text-[12px] font-medium text-primary-700"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<span>Klein</span>
|
||||
<span>Groß</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-2">
|
||||
<span class="text-[12px] font-bold uppercase tracking-wide text-primary-700">
|
||||
Archiv-Aktionen
|
||||
</span>
|
||||
|
||||
<ZipFilePicker
|
||||
pickerId="append-zip"
|
||||
ariaLabel="Weitere Datei hinzufügen"
|
||||
variant="bordered"
|
||||
disabled={isCommitting}
|
||||
class="w-full justify-start"
|
||||
onFilesSelected={onAppendFiles}
|
||||
>
|
||||
<Plus class="size-4" aria-hidden="true" />
|
||||
Weitere Datei hinzufügen
|
||||
</ZipFilePicker>
|
||||
|
||||
{#if processedArchiveCount >= 2}
|
||||
<Button
|
||||
variant="bordered"
|
||||
disabled={isExporting || isCommitting}
|
||||
class="w-full justify-start"
|
||||
onclick={() => void exportAll()}
|
||||
>
|
||||
<Download class="size-4" aria-hidden="true" />
|
||||
Alle Archive als PDF herunterladen
|
||||
</Button>
|
||||
{/if}
|
||||
|
||||
<Separator class="my-1" />
|
||||
|
||||
<Button
|
||||
variant="tertiary"
|
||||
disabled={isCommitting}
|
||||
class="w-full justify-start text-destructive hover:bg-destructive/10 hover:text-destructive"
|
||||
onclick={() => (clearDialogOpen = true)}
|
||||
>
|
||||
<Trash2 class="size-4" aria-hidden="true" />
|
||||
Aktuelle Bearbeitung leeren
|
||||
</Button>
|
||||
</div>
|
||||
</Popover.Content>
|
||||
</Popover.Root>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<AlertDialog.Root
|
||||
open={pendingReplacementFiles !== null}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) pendingReplacementFiles = null;
|
||||
}}
|
||||
>
|
||||
<AlertDialog.Content class="max-w-sm">
|
||||
<AlertDialog.Title class="text-base font-bold text-primary-900">
|
||||
Aktuelle Bearbeitung ersetzen?
|
||||
</AlertDialog.Title>
|
||||
<AlertDialog.Description>
|
||||
Archive und nicht heruntergeladene Änderungen in dieser Ansicht werden entfernt. Bereits
|
||||
heruntergeladene PDFs bleiben erhalten.
|
||||
</AlertDialog.Description>
|
||||
<AlertDialog.Footer>
|
||||
<AlertDialog.Cancel>Abbrechen</AlertDialog.Cancel>
|
||||
<AlertDialog.Action onclick={confirmReplacement}>Neue Datei öffnen</AlertDialog.Action>
|
||||
</AlertDialog.Footer>
|
||||
</AlertDialog.Content>
|
||||
</AlertDialog.Root>
|
||||
|
||||
<AlertDialog.Root bind:open={clearDialogOpen}>
|
||||
<AlertDialog.Content class="max-w-sm">
|
||||
<AlertDialog.Title class="text-base font-bold text-primary-900">
|
||||
Aktuelle Bearbeitung leeren?
|
||||
</AlertDialog.Title>
|
||||
<AlertDialog.Description>
|
||||
Alle Archive und nicht heruntergeladene Änderungen in dieser Ansicht werden entfernt. Bereits
|
||||
heruntergeladene PDFs bleiben erhalten.
|
||||
</AlertDialog.Description>
|
||||
<AlertDialog.Footer>
|
||||
<AlertDialog.Cancel>Abbrechen</AlertDialog.Cancel>
|
||||
<AlertDialog.Action onclick={confirmClear}>Bearbeitung leeren</AlertDialog.Action>
|
||||
</AlertDialog.Footer>
|
||||
</AlertDialog.Content>
|
||||
</AlertDialog.Root>
|
||||
@@ -0,0 +1,269 @@
|
||||
<script lang="ts">
|
||||
import { Download, Eye, GripVertical, RotateCcw, Trash2 } from '@lucide/svelte';
|
||||
import Button from '$lib/components/ui/button.svelte';
|
||||
import {
|
||||
batchStatusClass,
|
||||
batchStatusLabel,
|
||||
downloadBatchZip,
|
||||
formatBytes,
|
||||
shouldWarnAboutBatchMemory,
|
||||
type BatchItem
|
||||
} from '$lib/batch';
|
||||
|
||||
type Props = {
|
||||
items: BatchItem[];
|
||||
/** Resolved stamp label per row; frozen once the row was created. */
|
||||
labels: Record<string, string>;
|
||||
/** Renamed file per row, or `null` when renaming is switched off. */
|
||||
fileNames: Record<string, string | null>;
|
||||
selectedId: string | null;
|
||||
/** Base file name of the bulk ZIP, e.g. `anlagen` → `anlagen_2026-10-06.zip`. */
|
||||
zipBaseName: string;
|
||||
/** Reordering and preview selection are blocked while a run is active. */
|
||||
disabled?: boolean;
|
||||
onSelect: (id: string) => void;
|
||||
onMove: (from: number, to: number) => void;
|
||||
onDownload: (item: BatchItem) => void;
|
||||
onRemove: (id: string) => void;
|
||||
onRetry: (id: string) => void;
|
||||
};
|
||||
|
||||
let {
|
||||
items,
|
||||
labels,
|
||||
fileNames,
|
||||
selectedId,
|
||||
zipBaseName,
|
||||
disabled = false,
|
||||
onSelect,
|
||||
onMove,
|
||||
onDownload,
|
||||
onRemove,
|
||||
onRetry
|
||||
}: Props = $props();
|
||||
|
||||
let draggingId = $state<string | null>(null);
|
||||
let dragOverId = $state<string | null>(null);
|
||||
|
||||
const doneItems = $derived(items.filter((item) => item.status === 'done'));
|
||||
const totalResultBytes = $derived(
|
||||
doneItems.reduce((sum, item) => sum + (item.resultBytes?.byteLength ?? 0), 0)
|
||||
);
|
||||
const processingIndex = $derived(items.findIndex((item) => item.status === 'processing'));
|
||||
const showMemoryWarning = $derived(shouldWarnAboutBatchMemory(items));
|
||||
|
||||
const downloadZip = () => downloadBatchZip(doneItems, zipBaseName);
|
||||
|
||||
const moveUp = (index: number) => {
|
||||
if (disabled || index === 0) return;
|
||||
onMove(index, index - 1);
|
||||
};
|
||||
|
||||
const moveDown = (index: number) => {
|
||||
if (disabled || index === items.length - 1) return;
|
||||
onMove(index, index + 1);
|
||||
};
|
||||
|
||||
const handleDragStart = (event: DragEvent, item: BatchItem) => {
|
||||
if (disabled) return;
|
||||
draggingId = item.id;
|
||||
event.dataTransfer?.setData('text/plain', item.id);
|
||||
if (event.dataTransfer) event.dataTransfer.effectAllowed = 'move';
|
||||
};
|
||||
|
||||
const handleDragOver = (event: DragEvent, item: BatchItem) => {
|
||||
if (disabled || !draggingId || draggingId === item.id) return;
|
||||
event.preventDefault();
|
||||
dragOverId = item.id;
|
||||
};
|
||||
|
||||
const handleDrop = (event: DragEvent, item: BatchItem) => {
|
||||
event.preventDefault();
|
||||
const sourceId = draggingId;
|
||||
draggingId = null;
|
||||
dragOverId = null;
|
||||
if (disabled || !sourceId || sourceId === item.id) return;
|
||||
|
||||
const from = items.findIndex((entry) => entry.id === sourceId);
|
||||
const to = items.findIndex((entry) => entry.id === item.id);
|
||||
if (from === -1 || to === -1) return;
|
||||
onMove(from, to);
|
||||
};
|
||||
|
||||
const handleDragEnd = () => {
|
||||
draggingId = null;
|
||||
dragOverId = null;
|
||||
};
|
||||
</script>
|
||||
|
||||
<div class="space-y-3 rounded-2xl border border-border bg-card p-4">
|
||||
<div class="flex flex-wrap items-center justify-between gap-2">
|
||||
<h2 class="text-lg font-semibold text-foreground">Anlagen ({items.length})</h2>
|
||||
{#if doneItems.length > 0}
|
||||
<p class="text-sm text-primary">
|
||||
Ergebnisse gesamt: <span class="font-semibold">{formatBytes(totalResultBytes)}</span>
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if processingIndex !== -1}
|
||||
<p class="text-sm text-primary" role="status">
|
||||
Datei {processingIndex + 1} von {items.length} wird verarbeitet…
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
{#if showMemoryWarning}
|
||||
<div class="rounded-lg border border-amber-200 bg-amber-50 p-3 text-sm text-amber-800">
|
||||
Bei sehr vielen oder sehr großen Dateien kann der Arbeitsspeicher knapp werden. Laden Sie
|
||||
fertige Ergebnisse früh herunter und entfernen Sie sie aus der Liste.
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<ul class="divide-y divide-border">
|
||||
{#each items as item, index (item.id)}
|
||||
{@const label = labels[item.id] ?? ''}
|
||||
{@const renamed = fileNames[item.id] ?? null}
|
||||
<li
|
||||
class="flex flex-wrap items-center gap-3 py-3 {dragOverId === item.id
|
||||
? 'bg-accent/40'
|
||||
: ''} {draggingId === item.id ? 'opacity-60' : ''}"
|
||||
data-exhibit-item={item.file.name}
|
||||
data-exhibit-index={index + 1}
|
||||
data-exhibit-label={label}
|
||||
draggable={!disabled}
|
||||
ondragstart={(event) => handleDragStart(event, item)}
|
||||
ondragover={(event) => handleDragOver(event, item)}
|
||||
ondrop={(event) => handleDrop(event, item)}
|
||||
ondragend={handleDragEnd}
|
||||
>
|
||||
<div class="flex shrink-0 items-center gap-1">
|
||||
<span
|
||||
class="cursor-grab p-1 text-muted-foreground {disabled ? 'opacity-40' : ''}"
|
||||
aria-hidden="true"
|
||||
title="Ziehen zum Verschieben"
|
||||
>
|
||||
<GripVertical class="h-4 w-4" />
|
||||
</span>
|
||||
<span
|
||||
class="flex h-6 w-6 items-center justify-center rounded-full bg-accent text-xs font-bold text-primary"
|
||||
title="Position in der Liste"
|
||||
>
|
||||
{index + 1}
|
||||
</span>
|
||||
<div class="flex flex-col">
|
||||
<button
|
||||
type="button"
|
||||
class="rounded p-0.5 text-primary transition hover:bg-accent focus:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-40"
|
||||
aria-label="{item.file.name} nach oben verschieben"
|
||||
title="Nach oben"
|
||||
disabled={disabled || index === 0}
|
||||
onclick={() => moveUp(index)}
|
||||
>
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
class="h-3 w-3"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="3"
|
||||
aria-hidden="true"><path d="M6 15l6-6 6 6" /></svg
|
||||
>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="rounded p-0.5 text-primary transition hover:bg-accent focus:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-40"
|
||||
aria-label="{item.file.name} nach unten verschieben"
|
||||
title="Nach unten"
|
||||
disabled={disabled || index === items.length - 1}
|
||||
onclick={() => moveDown(index)}
|
||||
>
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
class="h-3 w-3"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="3"
|
||||
aria-hidden="true"><path d="M6 9l6 6 6-6" /></svg
|
||||
>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="truncate text-sm font-semibold text-foreground">
|
||||
{label}
|
||||
</p>
|
||||
<p class="truncate text-xs text-primary">{item.file.name}</p>
|
||||
{#if renamed}
|
||||
<p class="truncate text-xs text-muted-foreground" data-exhibit-filename>
|
||||
→ {renamed}
|
||||
</p>
|
||||
{/if}
|
||||
{#if item.status === 'error' && item.errorMessage}
|
||||
<p class="mt-0.5 text-xs text-red-700" role="alert">{item.errorMessage}</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<span
|
||||
class="shrink-0 rounded-full px-2.5 py-0.5 text-xs font-semibold {batchStatusClass(
|
||||
item.status
|
||||
)}"
|
||||
>
|
||||
{batchStatusLabel(item.status)}
|
||||
</span>
|
||||
|
||||
<div class="flex shrink-0 items-center gap-1">
|
||||
<button
|
||||
type="button"
|
||||
class="focus-visible:ring-primary-400 rounded-lg p-2 text-primary transition hover:bg-accent focus:outline-none focus-visible:ring-2 disabled:cursor-not-allowed disabled:opacity-40 aria-pressed:bg-accent"
|
||||
aria-label="Vorschau für {item.file.name}"
|
||||
title="Vorschau für {item.file.name}"
|
||||
aria-pressed={selectedId === item.id}
|
||||
{disabled}
|
||||
onclick={() => onSelect(item.id)}
|
||||
>
|
||||
<Eye class="h-4 w-4" />
|
||||
</button>
|
||||
{#if item.status === 'done' && item.resultBytes && item.resultName}
|
||||
<button
|
||||
type="button"
|
||||
class="focus-visible:ring-primary-400 rounded-lg p-2 text-primary transition hover:bg-accent focus:outline-none focus-visible:ring-2"
|
||||
aria-label="{item.file.name} herunterladen"
|
||||
title="{item.file.name} herunterladen"
|
||||
onclick={() => onDownload(item)}
|
||||
>
|
||||
<Download class="h-4 w-4" />
|
||||
</button>
|
||||
{/if}
|
||||
{#if item.status === 'error'}
|
||||
<button
|
||||
type="button"
|
||||
class="focus-visible:ring-primary-400 rounded-lg p-2 text-primary transition hover:bg-accent focus:outline-none focus-visible:ring-2 disabled:cursor-not-allowed disabled:opacity-40"
|
||||
aria-label="{item.file.name} erneut versuchen"
|
||||
title="{item.file.name} erneut versuchen"
|
||||
{disabled}
|
||||
onclick={() => onRetry(item.id)}
|
||||
>
|
||||
<RotateCcw class="h-4 w-4" />
|
||||
</button>
|
||||
{/if}
|
||||
<button
|
||||
type="button"
|
||||
class="focus-visible:ring-primary-400 rounded-lg p-2 text-primary transition hover:bg-accent focus:outline-none focus-visible:ring-2 disabled:cursor-not-allowed disabled:opacity-40"
|
||||
aria-label="{item.file.name} entfernen"
|
||||
title="{item.file.name} entfernen"
|
||||
disabled={item.status === 'processing'}
|
||||
onclick={() => onRemove(item.id)}
|
||||
>
|
||||
<Trash2 class="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
|
||||
{#if doneItems.length >= 2}
|
||||
<div class="flex justify-end pt-1">
|
||||
<Button onclick={downloadZip} icon={Download}>Alle herunterladen</Button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -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,164 @@
|
||||
<script lang="ts">
|
||||
import { ChevronLeft, ChevronRight } from '@lucide/svelte';
|
||||
import Button from '$lib/components/ui/button.svelte';
|
||||
import { MIN_FONT_SIZE, PAGE_NUMBER_COLOR, PAGE_NUMBER_MARGIN } from '$lib/pdf-pagination';
|
||||
import { getPreviewPositionStyle, type OverlayPosition } from '$lib/pdf-overlay';
|
||||
import { getTextFitScale } from '$lib/pdf-preview-text';
|
||||
import { PDF_PREVIEW_SCALE, type PdfPageImage } from '$lib/pdf-thumbnails';
|
||||
|
||||
type Props = {
|
||||
/** Rendered page bitmap, or `null` while the first render is pending. */
|
||||
preview: PdfPageImage | null;
|
||||
/** Message shown instead of the bitmap when rendering failed. */
|
||||
error: string | null;
|
||||
/** Label the export will stamp on this page. */
|
||||
label: string;
|
||||
fontSize: number;
|
||||
position: OverlayPosition;
|
||||
currentPage: number;
|
||||
pageCount: number;
|
||||
onRetry: () => void;
|
||||
onShowPage: (pageNumber: number) => void;
|
||||
};
|
||||
|
||||
let {
|
||||
preview,
|
||||
error,
|
||||
label,
|
||||
fontSize,
|
||||
position,
|
||||
currentPage,
|
||||
pageCount,
|
||||
onRetry,
|
||||
onShowPage
|
||||
}: Props = $props();
|
||||
|
||||
const pageSize = $derived(
|
||||
preview
|
||||
? { width: preview.width / PDF_PREVIEW_SCALE, height: preview.height / PDF_PREVIEW_SCALE }
|
||||
: null
|
||||
);
|
||||
|
||||
/**
|
||||
* The export shrinks a label that does not fit (down to `MIN_FONT_SIZE`), so
|
||||
* the preview applies the same factor instead of clipping.
|
||||
*/
|
||||
const fit = $derived.by(() => {
|
||||
if (!pageSize) return { scale: 1, shrunk: false, tooLong: false };
|
||||
|
||||
const raw = getTextFitScale(
|
||||
label,
|
||||
fontSize,
|
||||
pageSize.width - PAGE_NUMBER_MARGIN * 2,
|
||||
pageSize.height - PAGE_NUMBER_MARGIN * 2
|
||||
);
|
||||
const minScale = MIN_FONT_SIZE / fontSize;
|
||||
|
||||
return { scale: Math.max(raw, minScale), shrunk: raw < 0.999, tooLong: raw < minScale };
|
||||
});
|
||||
|
||||
const previewFontSize = $derived(
|
||||
preview ? (fontSize * fit.scale * PDF_PREVIEW_SCALE * 100) / preview.width : 0
|
||||
);
|
||||
const shrunkFontSize = $derived(Math.max(MIN_FONT_SIZE, Math.round(fontSize * fit.scale)));
|
||||
</script>
|
||||
|
||||
<section
|
||||
class="flex flex-col gap-4 rounded-2xl border border-border bg-card p-5"
|
||||
aria-labelledby="pagination-preview-title"
|
||||
>
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<h2 id="pagination-preview-title" class="text-lg font-semibold text-foreground">Vorschau</h2>
|
||||
<p class="mt-1 text-xs text-muted-foreground">Annäherung an die Ausgabe im PDF</p>
|
||||
</div>
|
||||
<span class="text-xs font-semibold text-primary">Seite {currentPage} von {pageCount}</span>
|
||||
</div>
|
||||
|
||||
{#if error}
|
||||
<div
|
||||
class="flex flex-col items-center gap-3 rounded-lg border border-border bg-muted/30 p-6 text-center"
|
||||
role="alert"
|
||||
>
|
||||
<p class="text-sm text-foreground">{error}</p>
|
||||
<Button variant="tertiary" onclick={onRetry}>Erneut versuchen</Button>
|
||||
</div>
|
||||
{:else if preview}
|
||||
<div
|
||||
class="relative mx-auto w-full max-w-lg overflow-hidden rounded-lg border border-border bg-white shadow-sm"
|
||||
style="container-type: inline-size; aspect-ratio: {preview.width} / {preview.height};"
|
||||
>
|
||||
<img
|
||||
src={preview.imageUrl}
|
||||
alt="Vorschau von Seite {currentPage}"
|
||||
class="block h-full w-full"
|
||||
/>
|
||||
<div
|
||||
class="absolute max-w-[92%] leading-tight whitespace-nowrap"
|
||||
style="{getPreviewPositionStyle(
|
||||
position
|
||||
)} font-size: {previewFontSize}cqw; color: {PAGE_NUMBER_COLOR};"
|
||||
data-testid="pagination-preview"
|
||||
data-position={position}
|
||||
>
|
||||
{label}
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<div
|
||||
class="flex aspect-[1/1.414] items-center justify-center rounded-lg border border-border bg-muted/30 text-xs text-muted-foreground"
|
||||
>
|
||||
Vorschau wird geladen …
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if fit.tooLong}
|
||||
<p class="text-xs text-destructive" role="status">
|
||||
Der Text ist für diese Seite zu lang. Kürzen Sie den Zusatztext oder wählen Sie ein kürzeres
|
||||
Zahlenformat.
|
||||
</p>
|
||||
{:else if fit.shrunk}
|
||||
<p class="text-xs text-muted-foreground" role="status">
|
||||
Auf dieser Seite wird der Text auf {shrunkFontSize} pt verkleinert.
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
{#if pageCount > 1}
|
||||
<div class="flex flex-wrap items-center justify-center gap-3">
|
||||
<Button
|
||||
variant="tertiary"
|
||||
class="min-w-0 px-3"
|
||||
onclick={() => onShowPage(currentPage - 1)}
|
||||
disabled={currentPage === 1}
|
||||
aria-label="Vorherige Seite"
|
||||
>
|
||||
<ChevronLeft aria-hidden="true" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="tertiary"
|
||||
class="min-w-0 px-3"
|
||||
onclick={() => onShowPage(currentPage + 1)}
|
||||
disabled={currentPage === pageCount}
|
||||
aria-label="Nächste Seite"
|
||||
>
|
||||
<ChevronRight aria-hidden="true" />
|
||||
</Button>
|
||||
<label class="flex items-center gap-2 text-sm text-foreground" for="pagination-page">
|
||||
<span class="text-xs text-muted-foreground">Seite</span>
|
||||
<input
|
||||
id="pagination-page"
|
||||
type="number"
|
||||
inputmode="numeric"
|
||||
step="1"
|
||||
min="1"
|
||||
max={pageCount}
|
||||
value={currentPage}
|
||||
oninput={(event) => onShowPage(Number(event.currentTarget.value))}
|
||||
onblur={(event) => (event.currentTarget.value = String(currentPage))}
|
||||
aria-label="Springe zu Seite"
|
||||
class="w-20 rounded-lg border border-input bg-background px-2 py-1 text-sm"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
{/if}
|
||||
</section>
|
||||
@@ -0,0 +1,112 @@
|
||||
<script lang="ts">
|
||||
import type { Snippet } from 'svelte';
|
||||
import type { PdfThumbnail } from '$lib/pdf-thumbnails';
|
||||
import { cn } from '$lib/utils';
|
||||
|
||||
type Props = {
|
||||
thumbnails: PdfThumbnail[];
|
||||
selectedPages?: Set<number>;
|
||||
onPageClick?: (pageNumber: number) => void;
|
||||
thumbnailWidth?: number;
|
||||
class?: string;
|
||||
pageActions?: Snippet<[pageNumber: number]>;
|
||||
cardClass?: (pageNumber: number) => string;
|
||||
};
|
||||
|
||||
let {
|
||||
thumbnails,
|
||||
selectedPages = new Set<number>(),
|
||||
onPageClick,
|
||||
thumbnailWidth = 120,
|
||||
class: className = '',
|
||||
pageActions,
|
||||
cardClass
|
||||
}: Props = $props();
|
||||
</script>
|
||||
|
||||
<div
|
||||
class={cn('grid gap-3', className)}
|
||||
style="grid-template-columns: repeat(auto-fill, minmax({thumbnailWidth}px, 1fr));"
|
||||
>
|
||||
{#each thumbnails as thumb}
|
||||
{@const isSelected = selectedPages.has(thumb.pageNumber)}
|
||||
{#if onPageClick}
|
||||
<button
|
||||
type="button"
|
||||
class={cn(
|
||||
'group relative flex flex-col items-center gap-2 rounded-xl border-2 p-2 transition focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-400',
|
||||
isSelected
|
||||
? 'border-primary bg-primary/5'
|
||||
: 'border-transparent hover:border-primary-200 hover:bg-primary-50/50'
|
||||
)}
|
||||
onclick={() => onPageClick(thumb.pageNumber)}
|
||||
>
|
||||
{#if cardClass}
|
||||
<div
|
||||
class={cn(
|
||||
'flex aspect-square w-full items-center justify-center',
|
||||
cardClass(thumb.pageNumber)
|
||||
)}
|
||||
>
|
||||
<img
|
||||
src={thumb.imageUrl}
|
||||
alt="Seite {thumb.pageNumber}"
|
||||
class="max-h-full max-w-full rounded border border-primary-100 bg-white object-contain"
|
||||
/>
|
||||
</div>
|
||||
{:else}
|
||||
<img
|
||||
src={thumb.imageUrl}
|
||||
alt="Seite {thumb.pageNumber}"
|
||||
class="w-full rounded border border-primary-100 bg-white object-contain"
|
||||
style="aspect-ratio: {thumb.width} / {thumb.height};"
|
||||
/>
|
||||
{/if}
|
||||
<span class="text-[12px] font-medium text-primary-700">Seite {thumb.pageNumber}</span>
|
||||
{#if isSelected}
|
||||
<div
|
||||
class="absolute top-1 right-1 flex h-5 w-5 items-center justify-center rounded-full bg-primary text-[10px] font-bold text-white"
|
||||
>
|
||||
✓
|
||||
</div>
|
||||
{/if}
|
||||
{#if pageActions}
|
||||
{@render pageActions(thumb.pageNumber)}
|
||||
{/if}
|
||||
</button>
|
||||
{:else}
|
||||
<div
|
||||
class={cn(
|
||||
'group relative flex flex-col items-center gap-2 rounded-xl border-2 border-transparent p-2',
|
||||
isSelected && 'border-primary bg-primary/5'
|
||||
)}
|
||||
>
|
||||
{#if cardClass}
|
||||
<div
|
||||
class={cn(
|
||||
'flex aspect-square w-full items-center justify-center',
|
||||
cardClass(thumb.pageNumber)
|
||||
)}
|
||||
>
|
||||
<img
|
||||
src={thumb.imageUrl}
|
||||
alt="Seite {thumb.pageNumber}"
|
||||
class="max-h-full max-w-full rounded border border-primary-100 bg-white object-contain"
|
||||
/>
|
||||
</div>
|
||||
{:else}
|
||||
<img
|
||||
src={thumb.imageUrl}
|
||||
alt="Seite {thumb.pageNumber}"
|
||||
class="w-full rounded border border-primary-100 bg-white object-contain"
|
||||
style="aspect-ratio: {thumb.width} / {thumb.height};"
|
||||
/>
|
||||
{/if}
|
||||
<span class="text-[12px] font-medium text-primary-700">Seite {thumb.pageNumber}</span>
|
||||
{#if pageActions}
|
||||
{@render pageActions(thumb.pageNumber)}
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
@@ -0,0 +1,439 @@
|
||||
<script lang="ts">
|
||||
import { onDestroy, onMount } from 'svelte';
|
||||
import { X } from '@lucide/svelte';
|
||||
import Button from '$lib/components/ui/button.svelte';
|
||||
import PdfPageGrid from '$lib/components/PdfPageGrid.svelte';
|
||||
import { pdfLoadErrorMessage, pdfProcessingErrorMessage } from '$lib/pdf-errors';
|
||||
import { loadPdfDocument, type PdfDocumentHandle } from '$lib/pdf-thumbnails';
|
||||
import {
|
||||
createAttachmentFromPages,
|
||||
removePagesFromAttachment,
|
||||
type PdfSplitResult,
|
||||
type ZipAttachment
|
||||
} from '$lib/zip-processing';
|
||||
import { cn } from '$lib/utils';
|
||||
|
||||
type SplitMode = 'manual' | 'perPage' | 'everyN';
|
||||
|
||||
type AssignedGroup = {
|
||||
name: string;
|
||||
pages: number[];
|
||||
};
|
||||
|
||||
type Props = {
|
||||
source: ZipAttachment;
|
||||
onApply: (result: PdfSplitResult) => void;
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
let { source, onApply, onClose }: Props = $props();
|
||||
|
||||
let panel = $state<HTMLDivElement | null>(null);
|
||||
let pdfHandle = $state<PdfDocumentHandle | null>(null);
|
||||
let loadError = $state<string | null>(null);
|
||||
let splitError = $state<string | null>(null);
|
||||
let isProcessing = $state(false);
|
||||
let mode = $state<SplitMode>('manual');
|
||||
let selectedPages = $state(new Set<number>());
|
||||
let assignedGroups = $state<AssignedGroup[]>([]);
|
||||
let groupName = $state('');
|
||||
let keepSource = $state(false);
|
||||
let chunkSize = $state(2);
|
||||
|
||||
let cancelled = false;
|
||||
|
||||
const pageCount = $derived(pdfHandle?.pageCount ?? 0);
|
||||
const assignedPages = $derived(new Set(assignedGroups.flatMap((group) => group.pages)));
|
||||
|
||||
const modes: Array<{ value: SplitMode; label: string }> = [
|
||||
{ value: 'manual', label: 'Seiten auswählen' },
|
||||
{ value: 'perPage', label: 'Jede Seite einzeln' },
|
||||
{ value: 'everyN', label: 'Alle N Seiten' }
|
||||
];
|
||||
|
||||
const isContiguous = (pages: number[]) => {
|
||||
const sorted = [...pages].sort((a, b) => a - b);
|
||||
return sorted.every((page, index) => index === 0 || page === sorted[index - 1] + 1);
|
||||
};
|
||||
|
||||
const describePages = (pages: number[]) => {
|
||||
const sorted = [...pages].sort((a, b) => a - b);
|
||||
|
||||
if (sorted.length === 1) {
|
||||
return `Seite ${sorted[0]}`;
|
||||
}
|
||||
|
||||
if (isContiguous(sorted)) {
|
||||
return `Seiten ${sorted[0]}–${sorted[sorted.length - 1]}`;
|
||||
}
|
||||
|
||||
return `Seiten ${sorted.join(', ')}`;
|
||||
};
|
||||
|
||||
const defaultGroupName = (pages: number[]) =>
|
||||
pages.length === 1 ? `Seite ${pages[0]}` : describePages(pages);
|
||||
|
||||
const setMode = (nextMode: SplitMode) => {
|
||||
mode = nextMode;
|
||||
selectedPages = new Set();
|
||||
};
|
||||
|
||||
const togglePage = (pageNumber: number) => {
|
||||
if (assignedPages.has(pageNumber)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const next = new Set(selectedPages);
|
||||
|
||||
if (next.has(pageNumber)) {
|
||||
next.delete(pageNumber);
|
||||
} else {
|
||||
next.add(pageNumber);
|
||||
}
|
||||
|
||||
selectedPages = next;
|
||||
groupName = defaultGroupName([...next]);
|
||||
};
|
||||
|
||||
const assignSelection = () => {
|
||||
if (selectedPages.size === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const pages = [...selectedPages].sort((a, b) => a - b);
|
||||
const name = groupName.trim() || defaultGroupName(pages);
|
||||
|
||||
assignedGroups = [...assignedGroups, { name, pages }];
|
||||
selectedPages = new Set();
|
||||
groupName = '';
|
||||
};
|
||||
|
||||
const removeGroup = (index: number) => {
|
||||
assignedGroups = assignedGroups.filter((_, groupIndex) => groupIndex !== index);
|
||||
};
|
||||
|
||||
const bulkChunks = $derived.by(() => {
|
||||
if (mode !== 'perPage' && mode !== 'everyN') {
|
||||
return [];
|
||||
}
|
||||
|
||||
const chunks: number[][] = [];
|
||||
|
||||
if (mode === 'perPage') {
|
||||
for (let pageNumber = 1; pageNumber <= pageCount; pageNumber += 1) {
|
||||
chunks.push([pageNumber]);
|
||||
}
|
||||
|
||||
return chunks;
|
||||
}
|
||||
|
||||
const size = Math.max(2, Number.isFinite(chunkSize) ? Math.floor(chunkSize) : 2);
|
||||
|
||||
for (let first = 1; first <= pageCount; first += size) {
|
||||
const chunk: number[] = [];
|
||||
|
||||
for (let page = first; page < Math.min(first + size, pageCount + 1); page += 1) {
|
||||
chunk.push(page);
|
||||
}
|
||||
|
||||
if (chunk.length > 0) {
|
||||
chunks.push(chunk);
|
||||
}
|
||||
}
|
||||
|
||||
return chunks;
|
||||
});
|
||||
|
||||
const buildResult = async (groups: AssignedGroup[]): Promise<PdfSplitResult> => {
|
||||
const splitGroups = [];
|
||||
|
||||
for (const group of groups) {
|
||||
const attachment = await createAttachmentFromPages(source, group.pages);
|
||||
|
||||
splitGroups.push({ name: group.name, attachment });
|
||||
}
|
||||
|
||||
const assignedAllPages = groups.flatMap((group) => group.pages);
|
||||
const remainingSource = keepSource
|
||||
? source
|
||||
: await removePagesFromAttachment(source, assignedAllPages);
|
||||
|
||||
return { groups: splitGroups, source: remainingSource };
|
||||
};
|
||||
|
||||
const applyGroups = async (groups: AssignedGroup[]) => {
|
||||
if (groups.length === 0 || isProcessing) {
|
||||
return;
|
||||
}
|
||||
|
||||
isProcessing = true;
|
||||
splitError = null;
|
||||
|
||||
try {
|
||||
onApply(await buildResult(groups));
|
||||
} catch (error) {
|
||||
console.error(`Failed to split PDF attachment ${source.path}`, error);
|
||||
splitError = pdfProcessingErrorMessage(error, 'Das PDF konnte nicht geteilt werden.');
|
||||
} finally {
|
||||
isProcessing = false;
|
||||
}
|
||||
};
|
||||
|
||||
const applyBulk = () => {
|
||||
void applyGroups(bulkChunks.map((pages) => ({ name: defaultGroupName(pages), pages })));
|
||||
};
|
||||
|
||||
const applyManual = () => {
|
||||
void applyGroups(assignedGroups);
|
||||
};
|
||||
|
||||
const handleKeydown = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape') {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
onMount(() => {
|
||||
panel?.focus();
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
const loaded = await loadPdfDocument(new Uint8Array(source.data));
|
||||
|
||||
if (cancelled) {
|
||||
loaded.destroy();
|
||||
return;
|
||||
}
|
||||
|
||||
pdfHandle = loaded;
|
||||
} catch (error) {
|
||||
console.error(`Failed to load PDF attachment ${source.path}`, error);
|
||||
|
||||
if (!cancelled) {
|
||||
loadError = pdfLoadErrorMessage(error);
|
||||
}
|
||||
}
|
||||
})();
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
cancelled = true;
|
||||
pdfHandle?.destroy();
|
||||
pdfHandle = null;
|
||||
});
|
||||
</script>
|
||||
|
||||
<svelte:window onkeydown={handleKeydown} />
|
||||
|
||||
<div
|
||||
class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4"
|
||||
role="presentation"
|
||||
>
|
||||
<div
|
||||
bind:this={panel}
|
||||
class="flex max-h-[90vh] w-full max-w-3xl flex-col overflow-hidden rounded-2xl bg-white shadow-xl outline-none"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="PDF teilen"
|
||||
data-split-dialog
|
||||
tabindex="-1"
|
||||
>
|
||||
<header class="border-primary-100 flex items-center justify-between gap-3 border-b px-5 py-4">
|
||||
<div class="min-w-0">
|
||||
<h2 class="text-primary-900 text-[16px] font-bold">PDF teilen</h2>
|
||||
<p class="text-primary-700 truncate text-[13px]" title={source.name}>
|
||||
{source.name} — {pageCount} Seite{pageCount === 1 ? '' : 'n'}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="border-primary-200 text-primary-700 hover:border-primary-400 hover:bg-primary-50 flex h-8 w-8 shrink-0 cursor-pointer items-center justify-center rounded-full border bg-white transition"
|
||||
aria-label="Teilen abbrechen"
|
||||
onclick={onClose}
|
||||
>
|
||||
<X class="size-4" aria-hidden="true" />
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<div class="flex-1 space-y-4 overflow-y-auto px-5 py-4">
|
||||
<div
|
||||
class="border-primary-200 inline-flex flex-wrap items-center rounded-[16px] border bg-white p-0.5"
|
||||
role="radiogroup"
|
||||
aria-label="Art des Teilens"
|
||||
>
|
||||
{#each modes as modeOption (modeOption.value)}
|
||||
<label
|
||||
class={cn(
|
||||
'cursor-pointer rounded-[14px] px-3 py-1 text-[13px] font-semibold transition',
|
||||
mode === modeOption.value
|
||||
? 'bg-primary text-primary-foreground'
|
||||
: 'text-primary-700 hover:bg-primary-50'
|
||||
)}
|
||||
>
|
||||
<input
|
||||
class="sr-only"
|
||||
type="radio"
|
||||
name="split-mode"
|
||||
value={modeOption.value}
|
||||
checked={mode === modeOption.value}
|
||||
onchange={() => setMode(modeOption.value)}
|
||||
/>
|
||||
{modeOption.label}
|
||||
</label>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
{#if mode === 'everyN'}
|
||||
<label class="text-primary-900 flex flex-wrap items-center gap-2 text-[13px] font-semibold">
|
||||
Seiten pro Teildokument
|
||||
<input
|
||||
class="border-primary-200 text-primary-900 focus:border-primary-400 focus:ring-primary-200 w-20 rounded-md border bg-white px-2 py-1 text-[13px] font-medium transition outline-none focus:ring-2"
|
||||
type="number"
|
||||
min={2}
|
||||
value={chunkSize}
|
||||
oninput={(event) => {
|
||||
chunkSize = Number((event.currentTarget as HTMLInputElement).value);
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
{/if}
|
||||
|
||||
{#if mode !== 'manual' && pageCount > 0}
|
||||
<p class="text-primary-700 text-[13px] font-medium" aria-live="polite">
|
||||
Es werden {bulkChunks.length}
|
||||
Teildokument{bulkChunks.length === 1 ? '' : 'e'} erstellt.
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
{#if loadError}
|
||||
<div class="rounded-container bg-red-50 px-4 py-3 text-[14px] font-medium text-red-900">
|
||||
{loadError}
|
||||
</div>
|
||||
{:else if pdfHandle}
|
||||
<PdfPageGrid
|
||||
thumbnails={pdfHandle.thumbnails}
|
||||
{selectedPages}
|
||||
onPageClick={mode === 'manual' ? togglePage : undefined}
|
||||
class={mode === 'manual' ? '' : 'pointer-events-none'}
|
||||
cardClass={(pageNumber) =>
|
||||
assignedPages.has(pageNumber) ? 'bg-primary/10 rounded-md p-1' : ''}
|
||||
>
|
||||
{#snippet pageActions(pageNumber)}
|
||||
{#if assignedPages.has(pageNumber)}
|
||||
<span
|
||||
class="mt-1 rounded-full bg-primary px-2 py-0.5 text-[11px] font-bold text-primary-foreground"
|
||||
>
|
||||
✓ Zugewiesen
|
||||
</span>
|
||||
{/if}
|
||||
{/snippet}
|
||||
</PdfPageGrid>
|
||||
{:else}
|
||||
<div
|
||||
class="border-primary-200 bg-primary-50 text-primary-700 flex items-center justify-center rounded-md border border-dashed px-4 py-10 text-[13px]"
|
||||
>
|
||||
Vorschau wird erstellt
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if mode === 'manual' && pdfHandle}
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<label class="flex min-w-0 flex-1 items-center gap-2">
|
||||
<span class="sr-only">Name des Teildokuments</span>
|
||||
<input
|
||||
class="border-primary-200 text-primary-900 focus:border-primary-400 focus:ring-primary-200 w-full min-w-40 rounded-md border bg-white px-3 py-1.5 text-[13px] font-semibold transition outline-none focus:ring-2"
|
||||
type="text"
|
||||
placeholder={selectedPages.size > 0
|
||||
? defaultGroupName([...selectedPages])
|
||||
: 'Name des Teildokuments'}
|
||||
value={groupName}
|
||||
oninput={(event) => {
|
||||
groupName = (event.currentTarget as HTMLInputElement).value;
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
<Button
|
||||
type="button"
|
||||
disabled={selectedPages.size === 0 || isProcessing}
|
||||
onclick={assignSelection}
|
||||
>
|
||||
Als Teildokument übernehmen
|
||||
</Button>
|
||||
</div>
|
||||
<p class="text-primary-700 text-[13px]" aria-live="polite">
|
||||
{selectedPages.size} von {pageCount} Seiten ausgewählt
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
{#if assignedGroups.length > 0}
|
||||
<ul class="flex flex-col gap-2">
|
||||
{#each assignedGroups as group, index (index)}
|
||||
<li
|
||||
class="border-primary-200 bg-primary-50 flex flex-wrap items-center gap-2 rounded-xl border px-3 py-1.5"
|
||||
>
|
||||
<span class="text-primary-900 min-w-0 flex-1 truncate text-[13px] font-semibold">
|
||||
{group.name}
|
||||
</span>
|
||||
<span class="text-primary-700 text-[12px] font-medium">
|
||||
{describePages(group.pages)}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
class="text-primary-600 flex h-6 w-6 cursor-pointer items-center justify-center rounded-full transition hover:bg-white hover:text-red-700"
|
||||
aria-label={`Zuordnung „${group.name}“ aufheben`}
|
||||
onclick={() => removeGroup(index)}
|
||||
>
|
||||
<X class="size-3.5" aria-hidden="true" />
|
||||
</button>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
|
||||
{#if splitError}
|
||||
<div class="rounded-container bg-red-50 px-4 py-3 text-[14px] font-medium text-red-900">
|
||||
{splitError}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<label class="text-primary-900 flex items-center gap-2 text-[13px] font-medium">
|
||||
<input
|
||||
class="accent-primary-700 h-4 w-4"
|
||||
type="checkbox"
|
||||
checked={keepSource}
|
||||
onchange={(event) => {
|
||||
keepSource = (event.currentTarget as HTMLInputElement).checked;
|
||||
}}
|
||||
/>
|
||||
Seiten im Original behalten (kopieren statt ausschneiden)
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<footer
|
||||
class="border-primary-100 flex flex-wrap items-center justify-end gap-2 border-t px-5 py-4"
|
||||
>
|
||||
<Button type="button" variant="bordered" disabled={isProcessing} onclick={onClose}>
|
||||
Abbrechen
|
||||
</Button>
|
||||
{#if mode === 'manual'}
|
||||
<Button
|
||||
type="button"
|
||||
disabled={assignedGroups.length === 0 || isProcessing}
|
||||
onclick={applyManual}
|
||||
data-split-apply
|
||||
>
|
||||
{isProcessing ? 'Teildokumente werden erstellt' : 'Teilen'}
|
||||
</Button>
|
||||
{:else}
|
||||
<Button
|
||||
type="button"
|
||||
disabled={bulkChunks.length === 0 || isProcessing}
|
||||
onclick={applyBulk}
|
||||
data-split-apply
|
||||
>
|
||||
{isProcessing ? 'Teildokumente werden erstellt' : 'Teilen'}
|
||||
</Button>
|
||||
{/if}
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,15 +1,29 @@
|
||||
<script lang="ts">
|
||||
import { FilePlus2, Layers } from '@lucide/svelte';
|
||||
import AttachmentMenu from '$lib/components/AttachmentMenu.svelte';
|
||||
import AttachmentPreview from '$lib/components/AttachmentPreview.svelte';
|
||||
import Button from '$lib/components/ui/button.svelte';
|
||||
import * as AlertDialog from '$lib/components/ui/alert-dialog/index.js';
|
||||
import PdfSplitDialog from '$lib/components/PdfSplitDialog.svelte';
|
||||
import { pdfProcessingErrorMessage } from '$lib/pdf-errors';
|
||||
import SubDocumentEditor from '$lib/components/SubDocumentEditor.svelte';
|
||||
import {
|
||||
applyPdfSplit,
|
||||
buildArchiveExport,
|
||||
buildSubDocumentExport,
|
||||
createSubDocument,
|
||||
createSubDocumentFromPaths,
|
||||
dissolveSubDocument,
|
||||
groupEachAttachmentAsSubDocument,
|
||||
moveAttachmentIntoSubDocument,
|
||||
moveAttachmentsByPathsToSubDocument,
|
||||
moveSubDocument,
|
||||
nextSubDocumentNumber,
|
||||
removeAttachmentFromArchive,
|
||||
removeAttachmentFromSubDocument,
|
||||
type AttachmentLocation,
|
||||
type ExportMode,
|
||||
type PdfSplitResult,
|
||||
type ProcessedZipArchive,
|
||||
type SubDocument,
|
||||
type ZipAttachment
|
||||
@@ -30,8 +44,11 @@
|
||||
let looseAttachments = $state<ZipAttachment[]>([]);
|
||||
let subDocuments = $state<SubDocument[]>([]);
|
||||
|
||||
let selectionMode = $state(false);
|
||||
let groupingMode = $state(false);
|
||||
let selectedPaths = $state<Set<string>>(new Set());
|
||||
let focusSubDocumentId = $state<string | null>(null);
|
||||
let splitSource = $state<ZipAttachment | null>(null);
|
||||
let removeCandidate = $state<ZipAttachment | null>(null);
|
||||
|
||||
let dragIndex = $state<number | null>(null);
|
||||
let dropIndex = $state<number | null>(null);
|
||||
@@ -41,8 +58,36 @@
|
||||
|
||||
let downloadMode = $state<ExportMode>('single');
|
||||
let isMerging = $state(false);
|
||||
let downloadingSubDocumentId = $state<string | null>(null);
|
||||
let mergeError = $state<string | null>(null);
|
||||
|
||||
const isExportActive = $derived(isMerging || downloadingSubDocumentId !== null);
|
||||
const hasSubDocuments = $derived(subDocuments.length > 0);
|
||||
|
||||
const subDocumentTargets = $derived(
|
||||
subDocuments.map((subDocument) => ({ id: subDocument.id, name: subDocument.name }))
|
||||
);
|
||||
|
||||
const allAttachmentPaths = $derived.by(() => {
|
||||
const paths = new Set<string>();
|
||||
|
||||
for (const subDocument of subDocuments) {
|
||||
for (const attachment of subDocument.attachments) {
|
||||
paths.add(attachment.path);
|
||||
}
|
||||
}
|
||||
|
||||
for (const attachment of looseAttachments) {
|
||||
paths.add(attachment.path);
|
||||
}
|
||||
|
||||
return paths;
|
||||
});
|
||||
|
||||
const selectedCount = $derived(
|
||||
[...selectedPaths].filter((path) => allAttachmentPaths.has(path)).length
|
||||
);
|
||||
|
||||
$effect(() => {
|
||||
archiveName = archive.name;
|
||||
looseAttachments = archive.attachments.map((attachment) => ({ ...attachment }));
|
||||
@@ -90,8 +135,6 @@
|
||||
onArchiveChange(composedArchive());
|
||||
};
|
||||
|
||||
const hasSubDocuments = $derived(subDocuments.length > 0);
|
||||
|
||||
const toggleSelection = (path: string) => {
|
||||
const next = new Set(selectedPaths);
|
||||
|
||||
@@ -104,48 +147,145 @@
|
||||
selectedPaths = next;
|
||||
};
|
||||
|
||||
const toggleSelectionMode = () => {
|
||||
selectionMode = !selectionMode;
|
||||
const toggleGroupingMode = () => {
|
||||
groupingMode = !groupingMode;
|
||||
selectedPaths = new Set();
|
||||
};
|
||||
|
||||
const exitSelectionMode = () => {
|
||||
selectionMode = false;
|
||||
const exitGroupingMode = () => {
|
||||
groupingMode = false;
|
||||
selectedPaths = new Set();
|
||||
};
|
||||
|
||||
const selectAllAttachments = () => {
|
||||
selectedPaths = new Set(allAttachmentPaths);
|
||||
};
|
||||
|
||||
const selectNoAttachments = () => {
|
||||
selectedPaths = new Set();
|
||||
};
|
||||
|
||||
const handleWindowKeydown = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape' && selectionMode) {
|
||||
exitSelectionMode();
|
||||
if (event.key === 'Escape' && groupingMode && !splitSource && !removeCandidate) {
|
||||
exitGroupingMode();
|
||||
}
|
||||
};
|
||||
|
||||
const createSubDocumentFromSelection = () => {
|
||||
if (selectedPaths.size === 0) {
|
||||
const focusLastSubDocument = (nextArchive: ProcessedZipArchive) => {
|
||||
focusSubDocumentId = nextArchive.subDocuments[nextArchive.subDocuments.length - 1]?.id ?? null;
|
||||
};
|
||||
|
||||
const createGroupFromSelection = () => {
|
||||
if (selectedCount === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const selectedAttachments = looseAttachments.filter((attachment) =>
|
||||
selectedPaths.has(attachment.path)
|
||||
);
|
||||
const remainingAttachments = looseAttachments.filter(
|
||||
(attachment) => !selectedPaths.has(attachment.path)
|
||||
);
|
||||
const nextArchive = createSubDocumentFromPaths(composedArchive(), selectedPaths);
|
||||
|
||||
if (selectedAttachments.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const subDocumentNumber = subDocuments.length + 1;
|
||||
const nextSubDocument = createSubDocument(
|
||||
`Teildokument ${subDocumentNumber}`,
|
||||
selectedAttachments
|
||||
);
|
||||
|
||||
looseAttachments = remainingAttachments;
|
||||
subDocuments = [...subDocuments, nextSubDocument];
|
||||
emitArchiveChange(nextArchive);
|
||||
focusLastSubDocument(nextArchive);
|
||||
selectedPaths = new Set();
|
||||
commit();
|
||||
};
|
||||
|
||||
const moveSelectionToTarget = (targetId: string) => {
|
||||
if (targetId === '' || selectedCount === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
emitArchiveChange(
|
||||
moveAttachmentsByPathsToSubDocument(composedArchive(), selectedPaths, targetId)
|
||||
);
|
||||
selectedPaths = new Set();
|
||||
};
|
||||
|
||||
const createEmptySubDocument = () => {
|
||||
const nextSubDocument = createSubDocument(
|
||||
`Teildokument ${nextSubDocumentNumber(subDocuments)}`,
|
||||
[]
|
||||
);
|
||||
const nextArchive = {
|
||||
...composedArchive(),
|
||||
subDocuments: [...subDocuments, nextSubDocument]
|
||||
};
|
||||
|
||||
emitArchiveChange(nextArchive);
|
||||
focusSubDocumentId = nextSubDocument.id;
|
||||
};
|
||||
|
||||
const groupEachAttachment = () => {
|
||||
if (looseAttachments.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
emitArchiveChange(groupEachAttachmentAsSubDocument(composedArchive()));
|
||||
focusSubDocumentId = null;
|
||||
};
|
||||
|
||||
const locateAttachment = (
|
||||
sourceArchive: ProcessedZipArchive,
|
||||
path: string
|
||||
): AttachmentLocation | null => {
|
||||
const looseIndex = sourceArchive.attachments.findIndex(
|
||||
(attachment) => attachment.path === path
|
||||
);
|
||||
|
||||
if (looseIndex !== -1) {
|
||||
return { kind: 'loose', index: looseIndex };
|
||||
}
|
||||
|
||||
for (const subDocument of sourceArchive.subDocuments) {
|
||||
const index = subDocument.attachments.findIndex((attachment) => attachment.path === path);
|
||||
|
||||
if (index !== -1) {
|
||||
return { kind: 'subDocument', id: subDocument.id, index };
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const moveAttachmentToTarget = (path: string, targetId: string) => {
|
||||
if (targetId === 'new') {
|
||||
const nextArchive = createSubDocumentFromPaths(composedArchive(), new Set([path]));
|
||||
|
||||
emitArchiveChange(nextArchive);
|
||||
focusLastSubDocument(nextArchive);
|
||||
return;
|
||||
}
|
||||
|
||||
emitArchiveChange(
|
||||
moveAttachmentsByPathsToSubDocument(composedArchive(), new Set([path]), targetId)
|
||||
);
|
||||
};
|
||||
|
||||
const moveAttachmentToLoose = (path: string) => {
|
||||
const location = locateAttachment(composedArchive(), path);
|
||||
|
||||
if (!location || location.kind !== 'subDocument') {
|
||||
return;
|
||||
}
|
||||
|
||||
emitArchiveChange(
|
||||
removeAttachmentFromSubDocument(composedArchive(), location.id, location.index)
|
||||
);
|
||||
};
|
||||
|
||||
const confirmRemoveAttachment = () => {
|
||||
if (!removeCandidate) {
|
||||
return;
|
||||
}
|
||||
|
||||
emitArchiveChange(removeAttachmentFromArchive(composedArchive(), removeCandidate.path));
|
||||
removeCandidate = null;
|
||||
};
|
||||
|
||||
const handleSplitApply = (result: PdfSplitResult) => {
|
||||
if (!splitSource) {
|
||||
return;
|
||||
}
|
||||
|
||||
emitArchiveChange(applyPdfSplit(composedArchive(), splitSource.path, result));
|
||||
splitSource = null;
|
||||
};
|
||||
|
||||
const dissolveSubDocumentBlock = (subDocumentId: string) => {
|
||||
@@ -215,7 +355,7 @@
|
||||
};
|
||||
|
||||
const handleLooseDragStart = (index: number, event: DragEvent) => {
|
||||
if (selectionMode) {
|
||||
if (groupingMode) {
|
||||
event.preventDefault();
|
||||
return;
|
||||
}
|
||||
@@ -293,7 +433,7 @@
|
||||
return;
|
||||
}
|
||||
|
||||
if (isMerging) {
|
||||
if (isExportActive) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -308,24 +448,50 @@
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Failed to export archive ${archive.name}`, error);
|
||||
mergeError = 'PDF konnte nicht erstellt werden.';
|
||||
mergeError = pdfProcessingErrorMessage(error, 'PDF konnte nicht erstellt werden.');
|
||||
} finally {
|
||||
isMerging = false;
|
||||
}
|
||||
};
|
||||
|
||||
const downloadSubDocument = async (subDocument: SubDocument) => {
|
||||
if (isExportActive || subDocument.attachments.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
downloadingSubDocumentId = subDocument.id;
|
||||
mergeError = null;
|
||||
|
||||
try {
|
||||
const exportUnit = await buildSubDocumentExport(archiveName, subDocument);
|
||||
|
||||
downloadPdfBytes(exportUnit.bytes, exportUnit.name);
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`Failed to export sub-document "${subDocument.name}" from archive ${archive.name}`,
|
||||
error
|
||||
);
|
||||
mergeError = pdfProcessingErrorMessage(
|
||||
error,
|
||||
`Teildokument „${subDocument.name}“ konnte nicht als PDF erstellt werden.`
|
||||
);
|
||||
} finally {
|
||||
downloadingSubDocumentId = null;
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<svelte:window onkeydown={handleWindowKeydown} />
|
||||
|
||||
<section
|
||||
class="flex flex-col gap-4 rounded-2xl border border-primary-100 bg-white px-4 py-4 shadow-sm"
|
||||
class="border-primary-100 flex flex-col gap-4 rounded-2xl border bg-white px-4 py-4 shadow-sm"
|
||||
>
|
||||
<header class="flex flex-wrap items-baseline justify-between gap-2 text-primary">
|
||||
<div class="min-w-0 flex-1">
|
||||
<label class="block min-w-0">
|
||||
<span class="sr-only">Archivname</span>
|
||||
<input
|
||||
class="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"
|
||||
class="border-primary-200 text-primary-900 focus:border-primary-400 focus:ring-primary-200 w-full max-w-xl rounded-md border bg-white px-3 py-2 text-[16px] font-bold transition outline-none focus:ring-2"
|
||||
type="text"
|
||||
value={archiveName}
|
||||
draggable="false"
|
||||
@@ -344,27 +510,46 @@
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
{#if !groupingMode}
|
||||
<Button type="button" variant="bordered" onclick={createEmptySubDocument}>
|
||||
<FilePlus2 class="size-4" aria-hidden="true" />
|
||||
Neues Teildokument
|
||||
</Button>
|
||||
|
||||
{#if looseAttachments.length >= 2}
|
||||
<Button type="button" variant="bordered" onclick={groupEachAttachment}>
|
||||
<Layers class="size-4" aria-hidden="true" />
|
||||
Jede Datei als Teildokument
|
||||
</Button>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
variant={selectionMode ? 'secondary' : 'bordered'}
|
||||
onclick={toggleSelectionMode}
|
||||
variant={groupingMode ? 'secondary' : 'bordered'}
|
||||
onclick={toggleGroupingMode}
|
||||
>
|
||||
{selectionMode ? 'Fertig' : 'Auswählen'}
|
||||
<Layers class="size-4" aria-hidden="true" />
|
||||
{groupingMode ? 'Fertig' : 'Gruppieren'}
|
||||
</Button>
|
||||
|
||||
{#if hasSubDocuments}
|
||||
<div
|
||||
class="inline-flex items-center rounded-[16px] border border-primary-200 bg-white p-0.5"
|
||||
class={cn(
|
||||
'border-primary-200 inline-flex items-center rounded-[16px] border bg-white p-0.5',
|
||||
downloadingSubDocumentId !== null ? 'opacity-60' : ''
|
||||
)}
|
||||
role="radiogroup"
|
||||
aria-label="Export-Modus"
|
||||
>
|
||||
{#each [{ value: 'single', label: 'Eine PDF' }, { value: 'separate', label: 'Getrennt' }] as option (option.value)}
|
||||
<label
|
||||
class={cn(
|
||||
'cursor-pointer rounded-[14px] px-3 py-1 text-[13px] font-semibold transition',
|
||||
'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'
|
||||
: 'text-primary-700 hover:bg-primary-50',
|
||||
downloadingSubDocumentId !== null ? 'cursor-not-allowed' : 'cursor-pointer'
|
||||
)}
|
||||
>
|
||||
<input
|
||||
@@ -373,6 +558,7 @@
|
||||
name={`download-mode-${archive.name}`}
|
||||
value={option.value}
|
||||
checked={downloadMode === option.value}
|
||||
disabled={downloadingSubDocumentId !== null}
|
||||
onchange={() => {
|
||||
downloadMode = option.value as ExportMode;
|
||||
}}
|
||||
@@ -386,7 +572,7 @@
|
||||
<Button
|
||||
class="bg-primary"
|
||||
type="button"
|
||||
disabled={(looseAttachments.length === 0 && subDocuments.length === 0) || isMerging}
|
||||
disabled={(looseAttachments.length === 0 && subDocuments.length === 0) || isExportActive}
|
||||
onclick={downloadArchive}
|
||||
>
|
||||
{isMerging ? 'PDF wird erstellt' : 'PDF herunterladen'}
|
||||
@@ -400,12 +586,51 @@
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if selectionMode && selectedPaths.size > 0}
|
||||
{#if groupingMode}
|
||||
<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"
|
||||
class="border-primary-200 bg-primary-50 text-primary-900 flex flex-wrap items-center gap-3 rounded-xl border px-4 py-2 text-[13px] font-medium"
|
||||
>
|
||||
<span>{selectedPaths.size} Datei{selectedPaths.size === 1 ? '' : 'en'} ausgewählt</span>
|
||||
<Button type="button" onclick={createSubDocumentFromSelection}>Teildokument erstellen</Button>
|
||||
<span aria-live="polite">
|
||||
{selectedCount} Datei{selectedCount === 1 ? '' : 'en'} ausgewählt
|
||||
</span>
|
||||
|
||||
<Button type="button" disabled={selectedCount === 0} onclick={createGroupFromSelection}>
|
||||
Teildokument erstellen
|
||||
</Button>
|
||||
|
||||
{#if hasSubDocuments}
|
||||
<select
|
||||
class="h-8 cursor-pointer rounded-[16px] border-2 border-[#E6E7EA] bg-white px-3 text-[13px] font-bold text-primary disabled:cursor-not-allowed disabled:text-[#0B1F4366]"
|
||||
aria-label="Auswahl in Teildokument verschieben"
|
||||
disabled={selectedCount === 0}
|
||||
onchange={(event) => {
|
||||
const select = event.currentTarget as HTMLSelectElement;
|
||||
const targetId = select.value;
|
||||
|
||||
select.value = '';
|
||||
moveSelectionToTarget(targetId);
|
||||
}}
|
||||
>
|
||||
<option value="">In Teildokument verschieben…</option>
|
||||
{#each subDocuments as subDocument (subDocument.id)}
|
||||
<option value={subDocument.id}>{subDocument.name}</option>
|
||||
{/each}
|
||||
</select>
|
||||
{/if}
|
||||
|
||||
<div class="ml-auto flex items-center gap-2">
|
||||
<Button type="button" variant="tertiary" onclick={selectAllAttachments}>
|
||||
Alle auswählen
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="tertiary"
|
||||
disabled={selectedCount === 0}
|
||||
onclick={selectNoAttachments}
|
||||
>
|
||||
Auswahl aufheben
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -413,23 +638,23 @@
|
||||
{#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',
|
||||
'flex shrink-0 flex-col items-stretch gap-2 rounded-xl border bg-accent p-2 text-primary transition',
|
||||
dropIndex === index ? 'border-primary-400 ring-primary-200 ring-1' : 'border-primary-100',
|
||||
dragIndex === index ? 'opacity-60' : '',
|
||||
selectedPaths.has(attachment.path) ? 'border-primary-500 ring-2 ring-primary-300' : ''
|
||||
selectedPaths.has(attachment.path) ? 'border-primary-500 ring-primary-300 ring-2' : ''
|
||||
)}
|
||||
style={`width: ${thumbnailWidth}px;`}
|
||||
draggable={selectionMode ? 'false' : 'true'}
|
||||
draggable={groupingMode ? 'false' : 'true'}
|
||||
ondragstart={(event) => handleLooseDragStart(index, event)}
|
||||
ondragover={(event) => handleLooseDragOver(index, event)}
|
||||
ondrop={(event) => handleLooseDrop(index, event)}
|
||||
ondragend={handleLooseDragEnd}
|
||||
>
|
||||
{#if selectionMode}
|
||||
{#if groupingMode}
|
||||
<label class="flex cursor-pointer flex-col gap-2">
|
||||
<div class="flex items-center gap-2 px-1 text-[13px] font-medium text-primary-900">
|
||||
<div class="text-primary-900 flex items-center gap-2 px-1 text-[13px] font-medium">
|
||||
<input
|
||||
class="h-4 w-4 accent-primary-700"
|
||||
class="accent-primary-700 h-4 w-4"
|
||||
type="checkbox"
|
||||
checked={selectedPaths.has(attachment.path)}
|
||||
onchange={() => toggleSelection(attachment.path)}
|
||||
@@ -440,7 +665,7 @@
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="truncate text-[13px] font-medium leading-tight text-primary-900"
|
||||
class="text-primary-900 truncate text-[13px] leading-tight font-medium"
|
||||
title={attachment.name}
|
||||
>
|
||||
{attachment.name}
|
||||
@@ -448,9 +673,9 @@
|
||||
<AttachmentPreview {attachment} />
|
||||
</label>
|
||||
{:else}
|
||||
<div class="flex min-w-0 flex-1 flex-row items-baseline gap-2 py-1">
|
||||
<div class="flex min-w-0 flex-1 flex-row items-center gap-1 py-1">
|
||||
<button
|
||||
class="w-fit cursor-grab rounded-md border border-primary-200 bg-white px-2 py-1 text-sm font-semibold leading-none text-primary-700 active:cursor-grabbing"
|
||||
class="border-primary-200 text-primary-700 w-fit cursor-grab rounded-md border bg-white px-2 py-1 text-sm leading-none font-semibold active:cursor-grabbing"
|
||||
draggable="false"
|
||||
type="button"
|
||||
aria-label="Datei verschieben"
|
||||
@@ -459,11 +684,20 @@
|
||||
</button>
|
||||
|
||||
<div
|
||||
class="truncate text-[13px] font-medium leading-tight text-primary-900"
|
||||
class="text-primary-900 min-w-0 flex-1 truncate text-[13px] leading-tight font-medium"
|
||||
title={attachment.name}
|
||||
>
|
||||
{attachment.name}
|
||||
</div>
|
||||
|
||||
<AttachmentMenu
|
||||
{attachment}
|
||||
moveTargets={subDocumentTargets}
|
||||
canSplit={attachment.kind === 'pdf'}
|
||||
onMoveToTarget={(targetId) => moveAttachmentToTarget(attachment.path, targetId)}
|
||||
onSplit={() => (splitSource = attachment)}
|
||||
onRemove={() => (removeCandidate = attachment)}
|
||||
/>
|
||||
</div>
|
||||
<AttachmentPreview {attachment} />
|
||||
{/if}
|
||||
@@ -473,7 +707,7 @@
|
||||
|
||||
{#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"
|
||||
class="border-primary-200 bg-primary-50 text-primary-700 rounded-md border border-dashed px-4 py-4 text-[13px]"
|
||||
>
|
||||
Keine Dateien in diesem Archiv. Lade ein anderes ZIP, um weiterzuarbeiten.
|
||||
</div>
|
||||
@@ -488,6 +722,15 @@
|
||||
{thumbnailWidth}
|
||||
isBlockDragSource={blockDragIndex === blockIndex}
|
||||
isBlockDropTarget={blockDropIndex === blockIndex && blockDragIndex !== blockIndex}
|
||||
{groupingMode}
|
||||
{selectedPaths}
|
||||
onToggleSelection={toggleSelection}
|
||||
autoFocusName={subDocument.id === focusSubDocumentId}
|
||||
moveTargets={subDocumentTargets.filter((target) => target.id !== subDocument.id)}
|
||||
onMoveToTarget={(path, targetId) => moveAttachmentToTarget(path, targetId)}
|
||||
onMoveToLoose={moveAttachmentToLoose}
|
||||
onSplit={(attachment) => (splitSource = attachment)}
|
||||
onRemoveAttachment={(attachment) => (removeCandidate = attachment)}
|
||||
onSubDocumentChange={handleSubDocumentChange}
|
||||
onRemove={() => dissolveSubDocumentBlock(subDocument.id)}
|
||||
onAttachmentMove={handleAttachmentMove}
|
||||
@@ -495,8 +738,40 @@
|
||||
onBlockDragOver={handleBlockDragOver}
|
||||
onBlockDrop={handleBlockDrop}
|
||||
onBlockDragEnd={handleBlockDragEnd}
|
||||
onDownload={() => downloadSubDocument(subDocument)}
|
||||
isDownloading={downloadingSubDocumentId === subDocument.id}
|
||||
downloadDisabled={isExportActive}
|
||||
/>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
{#if splitSource}
|
||||
<PdfSplitDialog
|
||||
source={splitSource}
|
||||
onApply={handleSplitApply}
|
||||
onClose={() => (splitSource = null)}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<AlertDialog.Root
|
||||
open={removeCandidate !== null}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) removeCandidate = null;
|
||||
}}
|
||||
>
|
||||
<AlertDialog.Content class="max-w-sm">
|
||||
<AlertDialog.Title class="text-primary-900 text-base font-bold">
|
||||
Datei entfernen?
|
||||
</AlertDialog.Title>
|
||||
<AlertDialog.Description>
|
||||
{removeCandidate?.name ?? ''} wird aus dem Archiv entfernt. Bereits heruntergeladene PDFs bleiben
|
||||
erhalten.
|
||||
</AlertDialog.Description>
|
||||
<AlertDialog.Footer>
|
||||
<AlertDialog.Cancel>Abbrechen</AlertDialog.Cancel>
|
||||
<AlertDialog.Action onclick={confirmRemoveAttachment}>Entfernen</AlertDialog.Action>
|
||||
</AlertDialog.Footer>
|
||||
</AlertDialog.Content>
|
||||
</AlertDialog.Root>
|
||||
@@ -0,0 +1,92 @@
|
||||
<script lang="ts">
|
||||
import { pwaInfo } from 'virtual:pwa-info';
|
||||
|
||||
import Button from '$lib/components/ui/button.svelte';
|
||||
|
||||
/**
|
||||
* Service-worker registration and the update hint for the PWA.
|
||||
*
|
||||
* Rendered only in the browser: the plugin's virtual modules are no-ops during SSR and in
|
||||
* `pnpm dev` (PWA `devOptions` are disabled), so `pwaInfo` is `undefined` there and nothing is
|
||||
* registered.
|
||||
*
|
||||
* Updates are never applied automatically (registerType `prompt`): a waiting worker is only
|
||||
* activated when the user confirms, so an in-progress archive is never interrupted.
|
||||
*/
|
||||
let needRefresh = $state(false);
|
||||
let offlineReady = $state(false);
|
||||
let updateServiceWorker = $state<(reloadPage?: boolean) => Promise<void>>(async () => {});
|
||||
|
||||
let offlineReadyTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
|
||||
$effect(() => {
|
||||
if (!pwaInfo) return;
|
||||
|
||||
let cancelled = false;
|
||||
|
||||
(async () => {
|
||||
const { registerSW } = await import('virtual:pwa-register');
|
||||
if (cancelled) return;
|
||||
|
||||
updateServiceWorker = registerSW({
|
||||
immediate: true,
|
||||
onNeedRefresh() {
|
||||
offlineReady = false;
|
||||
needRefresh = true;
|
||||
},
|
||||
onOfflineReady() {
|
||||
offlineReady = true;
|
||||
offlineReadyTimer = setTimeout(close, 8000);
|
||||
},
|
||||
onRegisterError(error: unknown) {
|
||||
console.error('Service Worker konnte nicht registriert werden.', error);
|
||||
}
|
||||
});
|
||||
})();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
if (offlineReadyTimer) clearTimeout(offlineReadyTimer);
|
||||
};
|
||||
});
|
||||
|
||||
function close() {
|
||||
needRefresh = false;
|
||||
offlineReady = false;
|
||||
if (offlineReadyTimer) {
|
||||
clearTimeout(offlineReadyTimer);
|
||||
offlineReadyTimer = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
async function reload() {
|
||||
await updateServiceWorker(true);
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if needRefresh || offlineReady}
|
||||
<div
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
class="fixed inset-x-4 bottom-4 z-50 flex flex-col gap-3 rounded-2xl border border-border bg-white p-4 shadow-lg sm:left-auto sm:w-[min(24rem,calc(100vw-2rem))]"
|
||||
>
|
||||
<p class="text-sm font-bold text-primary">
|
||||
{needRefresh
|
||||
? 'Neue Version von beA-Edit verfügbar.'
|
||||
: 'beA-Edit funktioniert jetzt offline.'}
|
||||
</p>
|
||||
{#if needRefresh}
|
||||
<p class="text-sm text-muted-foreground">
|
||||
Die neue Version wird geladen, sobald Sie neu laden. Ein laufender Vorgang wird dabei nicht
|
||||
unterbrochen.
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
<div class="flex flex-row flex-wrap gap-3">
|
||||
{#if needRefresh}
|
||||
<Button variant="primary" size="standard" onclick={reload}>Neu laden</Button>
|
||||
{/if}
|
||||
<Button variant="bordered" size="standard" onclick={close}>Später</Button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -0,0 +1,23 @@
|
||||
<script lang="ts">
|
||||
import { AlertTriangle } from '@lucide/svelte';
|
||||
import { isRasterizationTooLarge, shouldWarnAboutRasterization } from '$lib/rasterization-limits';
|
||||
|
||||
type Props = { file: File | null };
|
||||
let { file }: Props = $props();
|
||||
</script>
|
||||
|
||||
{#if shouldWarnAboutRasterization(file)}
|
||||
<div
|
||||
class="flex gap-3 rounded-lg border p-4 text-sm {isRasterizationTooLarge(file)
|
||||
? 'border-red-200 bg-red-50 text-red-800'
|
||||
: 'border-amber-200 bg-amber-50 text-amber-800'}"
|
||||
role={isRasterizationTooLarge(file) ? 'alert' : undefined}
|
||||
>
|
||||
<AlertTriangle class="mt-0.5 h-5 w-5 shrink-0" />
|
||||
<p>
|
||||
{isRasterizationTooLarge(file)
|
||||
? 'Diese Datei ist größer als 100 MB und kann aus Speichergründen nicht im Browser gerastert werden.'
|
||||
: 'Diese große Datei benötigt beim Rasterisieren viel Arbeitsspeicher. Schließen Sie andere speicherintensive Tabs.'}
|
||||
</p>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -0,0 +1,73 @@
|
||||
<script lang="ts">
|
||||
import type { Snippet } from 'svelte';
|
||||
import FileDropzone from '$lib/components/FileDropzone.svelte';
|
||||
import ToolError from '$lib/components/ToolError.svelte';
|
||||
|
||||
type Props = {
|
||||
title: string;
|
||||
description: string;
|
||||
file: File | null;
|
||||
/** `0` while no PDF is loaded; switches the shell to the bare dropzone. */
|
||||
pageCount: number;
|
||||
loadingError: string | null;
|
||||
error?: string | null;
|
||||
onFilesSelected: (files: File[]) => void;
|
||||
/** Extra toolbar buttons next to the file name. */
|
||||
actions?: Snippet;
|
||||
/** Main content, e.g. the page grid. */
|
||||
children: Snippet;
|
||||
/** Status line plus primary action, laid out `space-between`. */
|
||||
footer: Snippet;
|
||||
};
|
||||
|
||||
let {
|
||||
title,
|
||||
description,
|
||||
file,
|
||||
pageCount,
|
||||
loadingError,
|
||||
error,
|
||||
onFilesSelected,
|
||||
actions,
|
||||
children,
|
||||
footer
|
||||
}: Props = $props();
|
||||
</script>
|
||||
|
||||
<div class="mx-auto max-w-4xl space-y-6">
|
||||
<div>
|
||||
<h1 class="text-primary-900 text-2xl font-bold">{title}</h1>
|
||||
<p class="text-primary-700 mt-2 text-sm">{description}</p>
|
||||
</div>
|
||||
|
||||
{#if pageCount === 0}
|
||||
<FileDropzone {onFilesSelected} />
|
||||
<ToolError message={loadingError} class="mt-4" />
|
||||
{:else}
|
||||
<div class="space-y-4">
|
||||
<FileDropzone
|
||||
{onFilesSelected}
|
||||
label="Anderes PDF ablegen"
|
||||
sublabel={file?.name ?? ''}
|
||||
class="py-4"
|
||||
/>
|
||||
|
||||
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||
<h2 class="text-primary-900 text-lg font-semibold">
|
||||
{file?.name} — {pageCount} Seite{pageCount === 1 ? '' : 'n'}
|
||||
</h2>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
{@render actions?.()}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{@render children()}
|
||||
|
||||
<ToolError message={error} />
|
||||
|
||||
<div class="flex items-center justify-between">
|
||||
{@render footer()}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -1,5 +1,7 @@
|
||||
<script lang="ts">
|
||||
import AttachmentMenu, { type AttachmentMoveTarget } from '$lib/components/AttachmentMenu.svelte';
|
||||
import AttachmentPreview from '$lib/components/AttachmentPreview.svelte';
|
||||
import Button from '$lib/components/ui/button.svelte';
|
||||
import type { AttachmentLocation, SubDocument, ZipAttachment } from '$lib/zip-processing';
|
||||
import { cn } from '$lib/utils';
|
||||
|
||||
@@ -9,6 +11,15 @@
|
||||
thumbnailWidth: number;
|
||||
isBlockDragSource?: boolean;
|
||||
isBlockDropTarget?: boolean;
|
||||
groupingMode?: boolean;
|
||||
selectedPaths?: Set<string>;
|
||||
onToggleSelection?: (path: string) => void;
|
||||
autoFocusName?: boolean;
|
||||
moveTargets?: AttachmentMoveTarget[];
|
||||
onMoveToTarget?: (path: string, targetId: string) => void;
|
||||
onMoveToLoose?: (path: string) => void;
|
||||
onSplit?: (attachment: ZipAttachment) => void;
|
||||
onRemoveAttachment?: (attachment: ZipAttachment) => void;
|
||||
onSubDocumentChange?: (next: SubDocument) => void;
|
||||
onRemove?: () => void;
|
||||
onAttachmentMove?: (source: AttachmentLocation, target: AttachmentLocation) => void;
|
||||
@@ -16,6 +27,9 @@
|
||||
onBlockDragOver?: (blockIndex: number, event: DragEvent) => void;
|
||||
onBlockDrop?: (blockIndex: number, event: DragEvent) => void;
|
||||
onBlockDragEnd?: () => void;
|
||||
onDownload?: () => void;
|
||||
isDownloading?: boolean;
|
||||
downloadDisabled?: boolean;
|
||||
};
|
||||
|
||||
let {
|
||||
@@ -24,15 +38,28 @@
|
||||
thumbnailWidth = 200,
|
||||
isBlockDragSource = false,
|
||||
isBlockDropTarget = false,
|
||||
groupingMode = false,
|
||||
selectedPaths = new Set<string>(),
|
||||
onToggleSelection = () => {},
|
||||
autoFocusName = false,
|
||||
moveTargets = [],
|
||||
onMoveToTarget = () => {},
|
||||
onMoveToLoose = () => {},
|
||||
onSplit = () => {},
|
||||
onRemoveAttachment = () => {},
|
||||
onSubDocumentChange = () => {},
|
||||
onRemove = () => {},
|
||||
onAttachmentMove = () => {},
|
||||
onBlockDragStart = () => {},
|
||||
onBlockDragOver = () => {},
|
||||
onBlockDrop = () => {},
|
||||
onBlockDragEnd = () => {}
|
||||
onBlockDragEnd = () => {},
|
||||
onDownload,
|
||||
isDownloading = false,
|
||||
downloadDisabled = false
|
||||
}: Props = $props();
|
||||
|
||||
let nameInput = $state<HTMLInputElement | null>(null);
|
||||
let subDocumentName = $state('');
|
||||
let dragIndex = $state<number | null>(null);
|
||||
let dropIndex = $state<number | null>(null);
|
||||
@@ -41,6 +68,14 @@
|
||||
subDocumentName = subDocument.name;
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (autoFocusName && nameInput) {
|
||||
nameInput.focus();
|
||||
nameInput.select();
|
||||
nameInput.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
|
||||
}
|
||||
});
|
||||
|
||||
const emitChange = (overrides: Partial<SubDocument> = {}) => {
|
||||
onSubDocumentChange({
|
||||
...subDocument,
|
||||
@@ -74,6 +109,11 @@
|
||||
};
|
||||
|
||||
const handleCardDragStart = (index: number, event: DragEvent) => {
|
||||
if (groupingMode) {
|
||||
event.preventDefault();
|
||||
return;
|
||||
}
|
||||
|
||||
dragIndex = index;
|
||||
dropIndex = index;
|
||||
|
||||
@@ -188,6 +228,7 @@
|
||||
<label class="block min-w-0 flex-1">
|
||||
<span class="sr-only">Teildokumentname</span>
|
||||
<input
|
||||
bind:this={nameInput}
|
||||
class="w-full max-w-md rounded-md border border-primary-200 bg-white px-3 py-1.5 text-[15px] font-semibold text-primary-900 outline-none transition focus:border-primary-400 focus:ring-2 focus:ring-primary-200"
|
||||
type="text"
|
||||
value={subDocumentName}
|
||||
@@ -200,6 +241,19 @@
|
||||
{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"
|
||||
@@ -252,16 +306,42 @@
|
||||
dropIndex === index
|
||||
? 'border-primary-400 ring-1 ring-primary-200'
|
||||
: 'border-primary-100',
|
||||
dragIndex === index ? 'opacity-60' : ''
|
||||
dragIndex === index ? 'opacity-60' : '',
|
||||
groupingMode && selectedPaths.has(attachment.path)
|
||||
? 'border-primary-500 ring-2 ring-primary-300'
|
||||
: ''
|
||||
)}
|
||||
style={`width: ${thumbnailWidth}px;`}
|
||||
draggable="true"
|
||||
draggable={groupingMode ? 'false' : '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">
|
||||
{#if groupingMode}
|
||||
<label class="flex cursor-pointer flex-col gap-2">
|
||||
<div class="flex items-center gap-2 px-1 text-[13px] font-medium text-primary-900">
|
||||
<input
|
||||
class="h-4 w-4 accent-primary-700"
|
||||
type="checkbox"
|
||||
checked={selectedPaths.has(attachment.path)}
|
||||
onchange={() => onToggleSelection(attachment.path)}
|
||||
/>
|
||||
<span class="truncate"
|
||||
>{selectedPaths.has(attachment.path) ? 'Ausgewählt' : 'Auswählen'}</span
|
||||
>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="truncate text-[13px] font-medium leading-tight text-primary-900"
|
||||
title={attachment.name}
|
||||
>
|
||||
{attachment.name}
|
||||
</div>
|
||||
<AttachmentPreview {attachment} />
|
||||
</label>
|
||||
{:else}
|
||||
<div class="flex min-w-0 flex-1 flex-row items-center gap-1 py-1">
|
||||
<button
|
||||
class="w-fit cursor-grab rounded-md border border-primary-200 bg-white px-2 py-1 text-sm font-semibold leading-none text-primary-700 active:cursor-grabbing"
|
||||
draggable="false"
|
||||
@@ -272,13 +352,24 @@
|
||||
</button>
|
||||
|
||||
<div
|
||||
class="truncate text-[13px] font-medium leading-tight text-primary-900"
|
||||
class="min-w-0 flex-1 truncate text-[13px] font-medium leading-tight text-primary-900"
|
||||
title={attachment.name}
|
||||
>
|
||||
{attachment.name}
|
||||
</div>
|
||||
|
||||
<AttachmentMenu
|
||||
{attachment}
|
||||
{moveTargets}
|
||||
canSplit={attachment.kind === 'pdf'}
|
||||
onMoveToTarget={(targetId) => onMoveToTarget(attachment.path, targetId)}
|
||||
onMoveToLoose={() => onMoveToLoose(attachment.path)}
|
||||
onSplit={() => onSplit(attachment)}
|
||||
onRemove={() => onRemoveAttachment(attachment)}
|
||||
/>
|
||||
</div>
|
||||
<AttachmentPreview {attachment} />
|
||||
{/if}
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
<script lang="ts">
|
||||
import { cn } from '$lib/utils';
|
||||
|
||||
type Props = {
|
||||
/** German message; nothing is rendered when the value is empty. */
|
||||
message?: string | null;
|
||||
class?: string;
|
||||
};
|
||||
|
||||
let { message, class: className = '' }: Props = $props();
|
||||
</script>
|
||||
|
||||
{#if message}
|
||||
<div
|
||||
class={cn('rounded-lg border border-red-200 bg-red-50 p-4 text-sm text-red-800', className)}
|
||||
role="alert"
|
||||
>
|
||||
{message}
|
||||
</div>
|
||||
{/if}
|
||||
@@ -1,76 +1,21 @@
|
||||
<script lang="ts">
|
||||
import FileDropzone from '$lib/components/FileDropzone.svelte';
|
||||
import { WORKSPACE_ACCEPT_TYPES } from '$lib/zip-selection';
|
||||
|
||||
type Props = {
|
||||
onFilesSelected: (files: File[]) => void;
|
||||
class?: string;
|
||||
};
|
||||
|
||||
let { onFilesSelected, class: className = "" }: Props = $props();
|
||||
|
||||
let fileInput: HTMLInputElement | null = null;
|
||||
let isDragging = $state(false);
|
||||
|
||||
const processFiles = (files: FileList | null | undefined) => {
|
||||
if (!files) return;
|
||||
|
||||
const filtered = Array.from(files).filter((file) => {
|
||||
const name = file.name.toLowerCase();
|
||||
return (
|
||||
name.endsWith('.zip') ||
|
||||
file.type === 'application/zip' ||
|
||||
file.type === 'application/x-zip-compressed'
|
||||
);
|
||||
});
|
||||
|
||||
if (filtered.length > 0) {
|
||||
onFilesSelected(filtered);
|
||||
}
|
||||
};
|
||||
|
||||
const openFileDialog = () => fileInput?.click();
|
||||
|
||||
const handleFileSelection = (event: Event) => {
|
||||
const input = event.currentTarget as HTMLInputElement;
|
||||
processFiles(input.files);
|
||||
input.value = ''; // Reset so same file can be uploaded twice
|
||||
};
|
||||
|
||||
const handleDragOver = (event: DragEvent) => {
|
||||
event.preventDefault();
|
||||
isDragging = true;
|
||||
};
|
||||
|
||||
const handleDragLeave = (event: DragEvent) => {
|
||||
event.preventDefault();
|
||||
isDragging = false;
|
||||
};
|
||||
|
||||
const handleDrop = (event: DragEvent) => {
|
||||
event.preventDefault();
|
||||
isDragging = false;
|
||||
processFiles(event.dataTransfer?.files);
|
||||
};
|
||||
let { onFilesSelected, class: className = '' }: Props = $props();
|
||||
</script>
|
||||
|
||||
<input
|
||||
bind:this={fileInput}
|
||||
class="hidden"
|
||||
type="file"
|
||||
accept=".zip,application/zip,application/x-zip-compressed"
|
||||
multiple
|
||||
onchange={handleFileSelection}
|
||||
<FileDropzone
|
||||
{onFilesSelected}
|
||||
accept={WORKSPACE_ACCEPT_TYPES}
|
||||
multiple={true}
|
||||
ariaLabel="beA-ZIP- oder PDF-Dateien hinzufügen"
|
||||
label="Dateien hinzufügen"
|
||||
sublabel="beA-ZIP oder PDF hier ablegen oder zum Auswählen klicken"
|
||||
class="min-h-[calc(-180px+100vh)] border-0 bg-transparent {className}"
|
||||
/>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="flex min-h-[calc(-180px+100vh)] w-full grow cursor-pointer flex-col justify-center border-0 text-primary bg-transparent p-0 text-center 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">
|
||||
<span class="text-[20px] font-bold">Dateien hinzufügen</span>
|
||||
<span class="text-[20px] font-extrabold">+</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 { isAcceptedWorkspaceFile, WORKSPACE_ACCEPT_TYPES } from '$lib/zip-selection';
|
||||
import type { Snippet } from 'svelte';
|
||||
|
||||
type Props = {
|
||||
/** Identifies the hidden input for tests, e.g. `replace-zip` or `append-zip`. */
|
||||
pickerId: string;
|
||||
ariaLabel: string;
|
||||
onFilesSelected: (files: File[]) => void;
|
||||
children: Snippet;
|
||||
multiple?: boolean;
|
||||
disabled?: boolean;
|
||||
variant?: ButtonVariant;
|
||||
class?: string;
|
||||
};
|
||||
|
||||
let {
|
||||
pickerId,
|
||||
ariaLabel,
|
||||
onFilesSelected,
|
||||
children,
|
||||
multiple = true,
|
||||
disabled = false,
|
||||
variant = 'primary',
|
||||
class: className = ''
|
||||
}: Props = $props();
|
||||
|
||||
let fileInput = $state<HTMLInputElement | null>(null);
|
||||
|
||||
const handleFileSelection = (event: Event) => {
|
||||
const input = event.currentTarget as HTMLInputElement;
|
||||
const files = Array.from(input.files ?? []).filter(isAcceptedWorkspaceFile);
|
||||
|
||||
// Reset first so selecting the same file again still fires `change`.
|
||||
input.value = '';
|
||||
|
||||
if (files.length === 0) return;
|
||||
onFilesSelected(multiple ? files : files.slice(0, 1));
|
||||
};
|
||||
</script>
|
||||
|
||||
<input
|
||||
bind:this={fileInput}
|
||||
class="hidden"
|
||||
type="file"
|
||||
accept={WORKSPACE_ACCEPT_TYPES}
|
||||
{multiple}
|
||||
data-zip-picker={pickerId}
|
||||
onchange={handleFileSelection}
|
||||
/>
|
||||
|
||||
<Button
|
||||
{variant}
|
||||
{disabled}
|
||||
aria-label={ariaLabel}
|
||||
class={className}
|
||||
onclick={() => fileInput?.click()}
|
||||
>
|
||||
{@render children()}
|
||||
</Button>
|
||||
@@ -2,7 +2,17 @@
|
||||
</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"/>
|
||||
<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>
|
||||
|
||||
@@ -2,9 +2,29 @@
|
||||
</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"/>
|
||||
<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>
|
||||
|
||||
@@ -2,9 +2,22 @@
|
||||
</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"/>
|
||||
<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>
|
||||
|
||||
|
||||
@@ -2,8 +2,14 @@
|
||||
</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"/>
|
||||
<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>
|
||||
|
||||
|
||||
@@ -2,14 +2,23 @@
|
||||
</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
|
||||
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>
|
||||
|
||||
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
<svg width="16" height="22" viewBox="0 0 16 22" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M9.27193 9.97498L13.4818 11.6926C14.3848 12.0611 14.9751 12.9394 14.9751 13.9148V15.4118V18.475C14.9751 19.8557 13.8558 20.975 12.4751 20.975H7.51156H0.975098V16.0858V13.8946C0.975098 12.9294 1.55327 12.0581 2.44261 11.6831L6.49358 9.97498" stroke="currentColor" stroke-width="1.95" stroke-linecap="round"/>
|
||||
<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>
|
||||
|
Before Width: | Height: | Size: 513 B After Width: | Height: | Size: 518 B |
@@ -2,11 +2,19 @@
|
||||
</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"/>
|
||||
<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>
|
||||
|
||||
@@ -1,4 +1,10 @@
|
||||
<svg width="24" height="23" viewBox="0 0 24 23" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M10.4142 5.48509C10.7066 5.19265 11.101 5.0248 11.5145 5.01676L17.3775 4.90268C18.2946 4.88483 19.039 5.64016 19.0077 6.55694L18.8111 12.3183C18.7973 12.7235 18.6301 13.1084 18.3434 13.3951L10.5219 21.2166C9.89704 21.8414 8.88398 21.8414 8.25914 21.2166L1.47091 14.4284L10.4142 5.48509Z" stroke="currentColor" stroke-width="2.08"/>
|
||||
<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>
|
||||
|
Before Width: | Height: | Size: 556 B After Width: | Height: | Size: 563 B |
@@ -3,8 +3,10 @@
|
||||
|
||||
<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="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>
|
||||
|
||||
|
||||
@@ -2,6 +2,10 @@
|
||||
</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"/>
|
||||
<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
|
||||
};
|
||||
@@ -1,33 +1,33 @@
|
||||
<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";
|
||||
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",
|
||||
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]"
|
||||
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]"
|
||||
},
|
||||
standard: 'px-6 h-8 rounded-[16px]',
|
||||
large: 'px-8 h-12 rounded-[24px]'
|
||||
}
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "primary",
|
||||
size: "standard",
|
||||
},
|
||||
variant: 'primary',
|
||||
size: 'standard'
|
||||
}
|
||||
});
|
||||
|
||||
export type ButtonVariant = VariantProps<typeof buttonVariants>["variant"];
|
||||
export type ButtonSize = VariantProps<typeof buttonVariants>["size"];
|
||||
export type ButtonVariant = VariantProps<typeof buttonVariants>['variant'];
|
||||
export type ButtonSize = VariantProps<typeof buttonVariants>['size'];
|
||||
|
||||
export type ButtonProps = WithElementRef<HTMLButtonAttributes> &
|
||||
WithElementRef<HTMLAnchorAttributes> & {
|
||||
@@ -40,12 +40,12 @@
|
||||
<script lang="ts">
|
||||
let {
|
||||
class: className,
|
||||
variant = "primary",
|
||||
size = "standard",
|
||||
variant = 'primary',
|
||||
size = 'standard',
|
||||
icon,
|
||||
ref = $bindable(null),
|
||||
href = undefined,
|
||||
type = "button",
|
||||
type = 'button',
|
||||
disabled,
|
||||
children,
|
||||
...restProps
|
||||
@@ -57,10 +57,10 @@
|
||||
use:ripple
|
||||
bind:this={ref}
|
||||
data-slot="button"
|
||||
class={cn("", buttonVariants({ variant, size }), className)}
|
||||
class={cn('', buttonVariants({ variant, size }), className)}
|
||||
href={disabled ? undefined : href}
|
||||
aria-disabled={disabled}
|
||||
role={disabled ? "link" : undefined}
|
||||
role={disabled ? 'link' : undefined}
|
||||
tabindex={disabled ? -1 : undefined}
|
||||
{...restProps}
|
||||
>
|
||||
@@ -75,7 +75,11 @@
|
||||
use:ripple
|
||||
bind:this={ref}
|
||||
data-slot="button"
|
||||
class={cn("inline-flex items-center justify-center gap-4", buttonVariants({ variant, size }), className)}
|
||||
class={cn(
|
||||
'inline-flex items-center justify-center gap-4',
|
||||
buttonVariants({ variant, size }),
|
||||
className
|
||||
)}
|
||||
{type}
|
||||
{disabled}
|
||||
{...restProps}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { cn, type WithElementRef } from "$lib/utils.js";
|
||||
import type { HTMLAttributes } from "svelte/elements";
|
||||
import { cn, type WithElementRef } from '$lib/utils.js';
|
||||
import type { HTMLAttributes } from 'svelte/elements';
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
@@ -14,7 +14,7 @@
|
||||
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",
|
||||
'cn-card-action col-start-2 row-span-2 row-start-1 self-start justify-self-end',
|
||||
className
|
||||
)}
|
||||
{...restProps}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script lang="ts">
|
||||
import type { HTMLAttributes } from "svelte/elements";
|
||||
import { cn, type WithElementRef } from "$lib/utils.js";
|
||||
import type { HTMLAttributes } from 'svelte/elements';
|
||||
import { cn, type WithElementRef } from '$lib/utils.js';
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
@@ -13,7 +13,7 @@
|
||||
<div
|
||||
bind:this={ref}
|
||||
data-slot="card-content"
|
||||
class={cn("px-4 group-data-[size=sm]/card:px-3", className)}
|
||||
class={cn('px-4 group-data-[size=sm]/card:px-3', className)}
|
||||
{...restProps}
|
||||
>
|
||||
{@render children?.()}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script lang="ts">
|
||||
import type { HTMLAttributes } from "svelte/elements";
|
||||
import { cn, type WithElementRef } from "$lib/utils.js";
|
||||
import type { HTMLAttributes } from 'svelte/elements';
|
||||
import { cn, type WithElementRef } from '$lib/utils.js';
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
@@ -13,7 +13,7 @@
|
||||
<p
|
||||
bind:this={ref}
|
||||
data-slot="card-description"
|
||||
class={cn("text-muted-foreground text-sm", className)}
|
||||
class={cn('text-muted-foreground text-sm', className)}
|
||||
{...restProps}
|
||||
>
|
||||
{@render children?.()}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { cn, type WithElementRef } from "$lib/utils.js";
|
||||
import type { HTMLAttributes } from "svelte/elements";
|
||||
import { cn, type WithElementRef } from '$lib/utils.js';
|
||||
import type { HTMLAttributes } from 'svelte/elements';
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
@@ -13,7 +13,10 @@
|
||||
<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)}
|
||||
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?.()}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { cn, type WithElementRef } from "$lib/utils.js";
|
||||
import type { HTMLAttributes } from "svelte/elements";
|
||||
import { cn, type WithElementRef } from '$lib/utils.js';
|
||||
import type { HTMLAttributes } from 'svelte/elements';
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
@@ -14,7 +14,7 @@
|
||||
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]",
|
||||
'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}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script lang="ts">
|
||||
import type { HTMLAttributes } from "svelte/elements";
|
||||
import { cn, type WithElementRef } from "$lib/utils.js";
|
||||
import type { HTMLAttributes } from 'svelte/elements';
|
||||
import { cn, type WithElementRef } from '$lib/utils.js';
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
@@ -13,7 +13,7 @@
|
||||
<div
|
||||
bind:this={ref}
|
||||
data-slot="card-title"
|
||||
class={cn("text-base leading-snug font-medium group-data-[size=sm]/card:text-sm", className)}
|
||||
class={cn('text-base leading-snug font-medium group-data-[size=sm]/card:text-sm', className)}
|
||||
{...restProps}
|
||||
>
|
||||
{@render children?.()}
|
||||
|
||||
@@ -1,21 +1,24 @@
|
||||
<script lang="ts">
|
||||
import type { HTMLAttributes } from "svelte/elements";
|
||||
import { cn, type WithElementRef } from "$lib/utils.js";
|
||||
import type { HTMLAttributes } from 'svelte/elements';
|
||||
import { cn, type WithElementRef } from '$lib/utils.js';
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
children,
|
||||
size = "default",
|
||||
size = 'default',
|
||||
...restProps
|
||||
}: WithElementRef<HTMLAttributes<HTMLDivElement>> & { size?: "default" | "sm" } = $props();
|
||||
}: 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)}
|
||||
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?.()}
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
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";
|
||||
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,
|
||||
@@ -21,5 +21,5 @@ export {
|
||||
Footer as CardFooter,
|
||||
Header as CardHeader,
|
||||
Title as CardTitle,
|
||||
Action as CardAction,
|
||||
Action as CardAction
|
||||
};
|
||||
@@ -0,0 +1,16 @@
|
||||
<script lang="ts">
|
||||
import { DropdownMenu as DropdownMenuPrimitive } from 'bits-ui';
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
value = $bindable([]),
|
||||
...restProps
|
||||
}: DropdownMenuPrimitive.CheckboxGroupProps = $props();
|
||||
</script>
|
||||
|
||||
<DropdownMenuPrimitive.CheckboxGroup
|
||||
bind:ref
|
||||
bind:value
|
||||
data-slot="dropdown-menu-checkbox-group"
|
||||
{...restProps}
|
||||
/>
|
||||
@@ -0,0 +1,46 @@
|
||||
<script lang="ts">
|
||||
import { DropdownMenu as DropdownMenuPrimitive } from 'bits-ui';
|
||||
import MinusIcon from '@lucide/svelte/icons/minus';
|
||||
import CheckIcon from '@lucide/svelte/icons/check';
|
||||
import { cn, type WithoutChildrenOrChild } from '$lib/utils.js';
|
||||
import type { Snippet } from 'svelte';
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
checked = $bindable(false),
|
||||
indeterminate = $bindable(false),
|
||||
class: className,
|
||||
children: childrenProp,
|
||||
closeOnSelect = false,
|
||||
...restProps
|
||||
}: WithoutChildrenOrChild<DropdownMenuPrimitive.CheckboxItemProps> & {
|
||||
children?: Snippet;
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
<DropdownMenuPrimitive.CheckboxItem
|
||||
bind:ref
|
||||
{closeOnSelect}
|
||||
bind:checked
|
||||
bind:indeterminate
|
||||
data-slot="dropdown-menu-checkbox-item"
|
||||
class={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm data-inset:pl-7 [&_svg:not([class*='size-'])]:size-4 relative flex cursor-default items-center outline-hidden select-none data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0",
|
||||
className
|
||||
)}
|
||||
{...restProps}
|
||||
>
|
||||
{#snippet children({ checked, indeterminate })}
|
||||
<span
|
||||
class="absolute right-2 flex items-center justify-center pointer-events-none"
|
||||
data-slot="dropdown-menu-checkbox-item-indicator"
|
||||
>
|
||||
{#if indeterminate}
|
||||
<MinusIcon />
|
||||
{:else if checked}
|
||||
<CheckIcon />
|
||||
{/if}
|
||||
</span>
|
||||
{@render childrenProp?.()}
|
||||
{/snippet}
|
||||
</DropdownMenuPrimitive.CheckboxItem>
|
||||
@@ -0,0 +1,31 @@
|
||||
<script lang="ts">
|
||||
import { DropdownMenu as DropdownMenuPrimitive } from 'bits-ui';
|
||||
import { cn, type WithoutChildrenOrChild } from '$lib/utils.js';
|
||||
import DropdownMenuPortal from './dropdown-menu-portal.svelte';
|
||||
import type { ComponentProps } from 'svelte';
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
sideOffset = 4,
|
||||
align = 'start',
|
||||
portalProps,
|
||||
class: className,
|
||||
...restProps
|
||||
}: DropdownMenuPrimitive.ContentProps & {
|
||||
portalProps?: WithoutChildrenOrChild<ComponentProps<typeof DropdownMenuPortal>>;
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
<DropdownMenuPortal {...portalProps}>
|
||||
<DropdownMenuPrimitive.Content
|
||||
bind:ref
|
||||
data-slot="dropdown-menu-content"
|
||||
{sideOffset}
|
||||
{align}
|
||||
class={cn(
|
||||
'data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-open:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 ring-foreground/10 bg-popover text-popover-foreground min-w-32 rounded-lg p-1 shadow-md ring-1 duration-100 z-50 max-h-(--bits-dropdown-menu-content-available-height) w-(--bits-dropdown-menu-anchor-width) origin-(--bits-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto data-closed:overflow-hidden',
|
||||
className
|
||||
)}
|
||||
{...restProps}
|
||||
/>
|
||||
</DropdownMenuPortal>
|
||||
@@ -0,0 +1,22 @@
|
||||
<script lang="ts">
|
||||
import { DropdownMenu as DropdownMenuPrimitive } from 'bits-ui';
|
||||
import { cn } from '$lib/utils.js';
|
||||
import type { ComponentProps } from 'svelte';
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
inset,
|
||||
...restProps
|
||||
}: ComponentProps<typeof DropdownMenuPrimitive.GroupHeading> & {
|
||||
inset?: boolean;
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
<DropdownMenuPrimitive.GroupHeading
|
||||
bind:ref
|
||||
data-slot="dropdown-menu-group-heading"
|
||||
data-inset={inset}
|
||||
class={cn('text-muted-foreground px-1.5 py-1 text-xs font-medium data-inset:pl-7', className)}
|
||||
{...restProps}
|
||||
/>
|
||||
@@ -0,0 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { DropdownMenu as DropdownMenuPrimitive } from 'bits-ui';
|
||||
|
||||
let { ref = $bindable(null), ...restProps }: DropdownMenuPrimitive.GroupProps = $props();
|
||||
</script>
|
||||
|
||||
<DropdownMenuPrimitive.Group bind:ref data-slot="dropdown-menu-group" {...restProps} />
|
||||
@@ -0,0 +1,27 @@
|
||||
<script lang="ts">
|
||||
import { DropdownMenu as DropdownMenuPrimitive } from 'bits-ui';
|
||||
import { cn } from '$lib/utils.js';
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
inset,
|
||||
variant = 'default',
|
||||
...restProps
|
||||
}: DropdownMenuPrimitive.ItemProps & {
|
||||
inset?: boolean;
|
||||
variant?: 'default' | 'destructive';
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
<DropdownMenuPrimitive.Item
|
||||
bind:ref
|
||||
data-slot="dropdown-menu-item"
|
||||
data-inset={inset}
|
||||
data-variant={variant}
|
||||
class={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:text-destructive not-data-[variant=destructive]:focus:**:text-accent-foreground gap-1.5 rounded-md px-1.5 py-1 text-sm data-inset:pl-7 [&_svg:not([class*='size-'])]:size-4 group/dropdown-menu-item relative flex cursor-default items-center outline-hidden select-none data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0",
|
||||
className
|
||||
)}
|
||||
{...restProps}
|
||||
/>
|
||||
@@ -0,0 +1,24 @@
|
||||
<script lang="ts">
|
||||
import { cn, type WithElementRef } from '$lib/utils.js';
|
||||
import type { HTMLAttributes } from 'svelte/elements';
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
inset,
|
||||
children,
|
||||
...restProps
|
||||
}: WithElementRef<HTMLAttributes<HTMLDivElement>> & {
|
||||
inset?: boolean;
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
<div
|
||||
bind:this={ref}
|
||||
data-slot="dropdown-menu-label"
|
||||
data-inset={inset}
|
||||
class={cn('text-muted-foreground px-1.5 py-1 text-xs font-medium data-inset:pl-7', className)}
|
||||
{...restProps}
|
||||
>
|
||||
{@render children?.()}
|
||||
</div>
|
||||
@@ -0,0 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { DropdownMenu as DropdownMenuPrimitive } from 'bits-ui';
|
||||
|
||||
let { ...restProps }: DropdownMenuPrimitive.PortalProps = $props();
|
||||
</script>
|
||||
|
||||
<DropdownMenuPrimitive.Portal {...restProps} />
|
||||
@@ -0,0 +1,16 @@
|
||||
<script lang="ts">
|
||||
import { DropdownMenu as DropdownMenuPrimitive } from 'bits-ui';
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
value = $bindable(),
|
||||
...restProps
|
||||
}: DropdownMenuPrimitive.RadioGroupProps = $props();
|
||||
</script>
|
||||
|
||||
<DropdownMenuPrimitive.RadioGroup
|
||||
bind:ref
|
||||
bind:value
|
||||
data-slot="dropdown-menu-radio-group"
|
||||
{...restProps}
|
||||
/>
|
||||
@@ -0,0 +1,36 @@
|
||||
<script lang="ts">
|
||||
import { DropdownMenu as DropdownMenuPrimitive } from 'bits-ui';
|
||||
import CheckIcon from '@lucide/svelte/icons/check';
|
||||
import { cn, type WithoutChild } from '$lib/utils.js';
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
children: childrenProp,
|
||||
closeOnSelect = false,
|
||||
...restProps
|
||||
}: WithoutChild<DropdownMenuPrimitive.RadioItemProps> = $props();
|
||||
</script>
|
||||
|
||||
<DropdownMenuPrimitive.RadioItem
|
||||
bind:ref
|
||||
{closeOnSelect}
|
||||
data-slot="dropdown-menu-radio-item"
|
||||
class={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm data-inset:pl-7 [&_svg:not([class*='size-'])]:size-4 relative flex cursor-default items-center outline-hidden select-none data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0",
|
||||
className
|
||||
)}
|
||||
{...restProps}
|
||||
>
|
||||
{#snippet children({ checked })}
|
||||
<span
|
||||
class="absolute right-2 flex items-center justify-center pointer-events-none"
|
||||
data-slot="dropdown-menu-radio-item-indicator"
|
||||
>
|
||||
{#if checked}
|
||||
<CheckIcon />
|
||||
{/if}
|
||||
</span>
|
||||
{@render childrenProp?.({ checked })}
|
||||
{/snippet}
|
||||
</DropdownMenuPrimitive.RadioItem>
|
||||
@@ -0,0 +1,17 @@
|
||||
<script lang="ts">
|
||||
import { DropdownMenu as DropdownMenuPrimitive } from 'bits-ui';
|
||||
import { cn } from '$lib/utils.js';
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
...restProps
|
||||
}: DropdownMenuPrimitive.SeparatorProps = $props();
|
||||
</script>
|
||||
|
||||
<DropdownMenuPrimitive.Separator
|
||||
bind:ref
|
||||
data-slot="dropdown-menu-separator"
|
||||
class={cn('bg-border -mx-1 my-1 h-px', className)}
|
||||
{...restProps}
|
||||
/>
|
||||
@@ -0,0 +1,23 @@
|
||||
<script lang="ts">
|
||||
import { cn, type WithElementRef } from '$lib/utils.js';
|
||||
import type { HTMLAttributes } from 'svelte/elements';
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
children,
|
||||
...restProps
|
||||
}: WithElementRef<HTMLAttributes<HTMLSpanElement>> = $props();
|
||||
</script>
|
||||
|
||||
<span
|
||||
bind:this={ref}
|
||||
data-slot="dropdown-menu-shortcut"
|
||||
class={cn(
|
||||
'text-muted-foreground group-focus/dropdown-menu-item:text-accent-foreground ml-auto text-xs tracking-widest',
|
||||
className
|
||||
)}
|
||||
{...restProps}
|
||||
>
|
||||
{@render children?.()}
|
||||
</span>
|
||||
@@ -0,0 +1,31 @@
|
||||
<script lang="ts">
|
||||
import { DropdownMenu as DropdownMenuPrimitive } from 'bits-ui';
|
||||
import { cn, type WithoutChildrenOrChild } from '$lib/utils.js';
|
||||
import DropdownMenuPortal from './dropdown-menu-portal.svelte';
|
||||
import type { ComponentProps } from 'svelte';
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
align = 'start',
|
||||
alignOffset = -3,
|
||||
portalProps,
|
||||
...restProps
|
||||
}: DropdownMenuPrimitive.SubContentProps & {
|
||||
portalProps?: WithoutChildrenOrChild<ComponentProps<typeof DropdownMenuPortal>>;
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
<DropdownMenuPortal {...portalProps}>
|
||||
<DropdownMenuPrimitive.SubContent
|
||||
bind:ref
|
||||
data-slot="dropdown-menu-sub-content"
|
||||
{align}
|
||||
{alignOffset}
|
||||
class={cn(
|
||||
'data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-open:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 ring-foreground/10 bg-popover text-popover-foreground min-w-[96px] rounded-lg p-1 shadow-lg ring-1 duration-100 z-50 origin-(--bits-dropdown-menu-content-transform-origin) overflow-hidden',
|
||||
className
|
||||
)}
|
||||
{...restProps}
|
||||
/>
|
||||
</DropdownMenuPortal>
|
||||
@@ -0,0 +1,29 @@
|
||||
<script lang="ts">
|
||||
import { DropdownMenu as DropdownMenuPrimitive } from 'bits-ui';
|
||||
import ChevronRightIcon from '@lucide/svelte/icons/chevron-right';
|
||||
import { cn } from '$lib/utils.js';
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
inset,
|
||||
children,
|
||||
...restProps
|
||||
}: DropdownMenuPrimitive.SubTriggerProps & {
|
||||
inset?: boolean;
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
<DropdownMenuPrimitive.SubTrigger
|
||||
bind:ref
|
||||
data-slot="dropdown-menu-sub-trigger"
|
||||
data-inset={inset}
|
||||
class={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground data-open:bg-accent data-open:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground gap-1.5 rounded-md px-1.5 py-1 text-sm data-inset:pl-7 [&_svg:not([class*='size-'])]:size-4 flex cursor-default items-center outline-hidden select-none [&_svg]:pointer-events-none [&_svg]:shrink-0",
|
||||
className
|
||||
)}
|
||||
{...restProps}
|
||||
>
|
||||
{@render children?.()}
|
||||
<ChevronRightIcon class="cn-rtl-flip ml-auto" />
|
||||
</DropdownMenuPrimitive.SubTrigger>
|
||||
@@ -0,0 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { DropdownMenu as DropdownMenuPrimitive } from 'bits-ui';
|
||||
|
||||
let { open = $bindable(false), ...restProps }: DropdownMenuPrimitive.SubProps = $props();
|
||||
</script>
|
||||
|
||||
<DropdownMenuPrimitive.Sub bind:open {...restProps} />
|
||||
@@ -0,0 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { DropdownMenu as DropdownMenuPrimitive } from 'bits-ui';
|
||||
|
||||
let { ref = $bindable(null), ...restProps }: DropdownMenuPrimitive.TriggerProps = $props();
|
||||
</script>
|
||||
|
||||
<DropdownMenuPrimitive.Trigger bind:ref data-slot="dropdown-menu-trigger" {...restProps} />
|
||||
@@ -0,0 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { DropdownMenu as DropdownMenuPrimitive } from 'bits-ui';
|
||||
|
||||
let { open = $bindable(false), ...restProps }: DropdownMenuPrimitive.RootProps = $props();
|
||||
</script>
|
||||
|
||||
<DropdownMenuPrimitive.Root bind:open {...restProps} />
|
||||
@@ -0,0 +1,54 @@
|
||||
import CheckboxGroup from './dropdown-menu-checkbox-group.svelte';
|
||||
import CheckboxItem from './dropdown-menu-checkbox-item.svelte';
|
||||
import Content from './dropdown-menu-content.svelte';
|
||||
import GroupHeading from './dropdown-menu-group-heading.svelte';
|
||||
import Group from './dropdown-menu-group.svelte';
|
||||
import Item from './dropdown-menu-item.svelte';
|
||||
import Label from './dropdown-menu-label.svelte';
|
||||
import Portal from './dropdown-menu-portal.svelte';
|
||||
import RadioGroup from './dropdown-menu-radio-group.svelte';
|
||||
import RadioItem from './dropdown-menu-radio-item.svelte';
|
||||
import Separator from './dropdown-menu-separator.svelte';
|
||||
import Shortcut from './dropdown-menu-shortcut.svelte';
|
||||
import SubContent from './dropdown-menu-sub-content.svelte';
|
||||
import SubTrigger from './dropdown-menu-sub-trigger.svelte';
|
||||
import Sub from './dropdown-menu-sub.svelte';
|
||||
import Trigger from './dropdown-menu-trigger.svelte';
|
||||
import Root from './dropdown-menu.svelte';
|
||||
|
||||
export {
|
||||
CheckboxGroup,
|
||||
CheckboxItem,
|
||||
Content,
|
||||
Portal,
|
||||
Root as DropdownMenu,
|
||||
CheckboxGroup as DropdownMenuCheckboxGroup,
|
||||
CheckboxItem as DropdownMenuCheckboxItem,
|
||||
Content as DropdownMenuContent,
|
||||
Portal as DropdownMenuPortal,
|
||||
Group as DropdownMenuGroup,
|
||||
Item as DropdownMenuItem,
|
||||
Label as DropdownMenuLabel,
|
||||
RadioGroup as DropdownMenuRadioGroup,
|
||||
RadioItem as DropdownMenuRadioItem,
|
||||
Separator as DropdownMenuSeparator,
|
||||
Shortcut as DropdownMenuShortcut,
|
||||
Sub as DropdownMenuSub,
|
||||
SubContent as DropdownMenuSubContent,
|
||||
SubTrigger as DropdownMenuSubTrigger,
|
||||
Trigger as DropdownMenuTrigger,
|
||||
GroupHeading as DropdownMenuGroupHeading,
|
||||
Group,
|
||||
GroupHeading,
|
||||
Item,
|
||||
Label,
|
||||
RadioGroup,
|
||||
RadioItem,
|
||||
Root,
|
||||
Separator,
|
||||
Shortcut,
|
||||
Sub,
|
||||
SubContent,
|
||||
SubTrigger,
|
||||
Trigger
|
||||
};
|
||||
@@ -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} />
|
||||
Loaded 100 of 175 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user