Compare commits
13
Commits
main
...
09cc5c06fb
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
09cc5c06fb | ||
|
|
ea21fc000f | ||
|
|
57b38fe2c7 | ||
|
|
367f34229c | ||
|
|
dea6a1ba77 | ||
|
|
365c57847c | ||
|
|
2b22fcb0e8 | ||
|
|
037050e1c1 | ||
|
|
1a2673f708 | ||
|
|
adaae41e44 | ||
|
|
891026ccbb | ||
|
|
a450c6f5dd | ||
|
|
47ed80bd24 |
@@ -7,6 +7,8 @@ node_modules
|
|||||||
.wrangler
|
.wrangler
|
||||||
/.svelte-kit
|
/.svelte-kit
|
||||||
/build
|
/build
|
||||||
|
/playwright-report
|
||||||
|
/test-results
|
||||||
|
|
||||||
# OS
|
# OS
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
|||||||
Generated
+65
-216
@@ -3,40 +3,34 @@
|
|||||||
<component name="AutoImportSettings">
|
<component name="AutoImportSettings">
|
||||||
<option name="autoReloadType" value="SELECTIVE" />
|
<option name="autoReloadType" value="SELECTIVE" />
|
||||||
</component>
|
</component>
|
||||||
<component name="CMakeProjectFlavorService">
|
|
||||||
<option name="flavorId" value="CMakePlainProjectFlavor" />
|
|
||||||
</component>
|
|
||||||
<component name="CMakeSettings">
|
<component name="CMakeSettings">
|
||||||
<configurations />
|
<configurations />
|
||||||
</component>
|
</component>
|
||||||
<component name="ChangeListManager">
|
<component name="ChangeListManager">
|
||||||
<list default="true" id="89f9fc3a-63ad-41cb-8d25-f07df352eb98" name="Changes" comment="">
|
<list default="true" id="89f9fc3a-63ad-41cb-8d25-f07df352eb98" name="Changes" comment="">
|
||||||
<change beforePath="$PROJECT_DIR$/.idea/workspace.xml" beforeDir="false" afterPath="$PROJECT_DIR$/.idea/workspace.xml" afterDir="false" />
|
<change beforePath="$PROJECT_DIR$/src/routes/+layout.svelte" beforeDir="false" afterPath="$PROJECT_DIR$/src/routes/+layout.svelte" afterDir="false" />
|
||||||
<change beforePath="$PROJECT_DIR$/agents.md" beforeDir="false" afterPath="$PROJECT_DIR$/agents.md" 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$/package.json" beforeDir="false" afterPath="$PROJECT_DIR$/package.json" 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$/pnpm-lock.yaml" beforeDir="false" afterPath="$PROJECT_DIR$/pnpm-lock.yaml" afterDir="false" />
|
<change beforePath="$PROJECT_DIR$/uat.yml" beforeDir="false" afterPath="$PROJECT_DIR$/.gitea/workflows/uat.yml" 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" />
|
|
||||||
</list>
|
</list>
|
||||||
<option name="SHOW_DIALOG" value="false" />
|
<option name="SHOW_DIALOG" value="false" />
|
||||||
<option name="HIGHLIGHT_CONFLICTS" value="true" />
|
<option name="HIGHLIGHT_CONFLICTS" value="true" />
|
||||||
<option name="HIGHLIGHT_NON_ACTIVE_CHANGELIST" value="false" />
|
<option name="HIGHLIGHT_NON_ACTIVE_CHANGELIST" value="false" />
|
||||||
<option name="LAST_RESOLUTION" value="IGNORE" />
|
<option name="LAST_RESOLUTION" value="IGNORE" />
|
||||||
</component>
|
</component>
|
||||||
<component name="EmbeddingIndexingInfo">
|
|
||||||
<option name="cachedIndexableFilesCount" value="169" />
|
|
||||||
</component>
|
|
||||||
<component name="FileTemplateManagerImpl">
|
<component name="FileTemplateManagerImpl">
|
||||||
<option name="RECENT_TEMPLATES">
|
<option name="RECENT_TEMPLATES">
|
||||||
<list>
|
<list>
|
||||||
<option value="CSS File" />
|
<option value="+page.svelte" />
|
||||||
<option value="TypeScript File" />
|
|
||||||
<option value="IconSettings" />
|
|
||||||
</list>
|
</list>
|
||||||
</option>
|
</option>
|
||||||
</component>
|
</component>
|
||||||
<component name="Git.Settings">
|
<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$" />
|
<option name="RECENT_GIT_ROOT_PATH" value="$PROJECT_DIR$" />
|
||||||
</component>
|
</component>
|
||||||
<component name="McpProjectServerCommands">
|
<component name="McpProjectServerCommands">
|
||||||
@@ -46,23 +40,23 @@
|
|||||||
<component name="MetaFilesCheckinStateConfiguration" checkMetaFiles="true" />
|
<component name="MetaFilesCheckinStateConfiguration" checkMetaFiles="true" />
|
||||||
<component name="NextEditCompletionFeaturesState">
|
<component name="NextEditCompletionFeaturesState">
|
||||||
<decayedCancelled>
|
<decayedCancelled>
|
||||||
<entry key="MS100" value="1.7534270927402278" />
|
<entry key="MS100" value="1.0000000193915048" />
|
||||||
<entry key="MS500" value="3.946749409858449" />
|
<entry key="MS500" value="1.028676250370633" />
|
||||||
<entry key="S2" value="10.803582735333821" />
|
<entry key="S2" value="1.4194868298714414" />
|
||||||
<entry key="S5" value="15.959801828989763" />
|
<entry key="S5" value="1.8458418253662012" />
|
||||||
<entry key="S10" value="18.60984028553002" />
|
<entry key="S10" value="2.2177989694912164" />
|
||||||
<entry key="S30" value="20.774011757305356" />
|
<entry key="S30" value="2.6766437274014185" />
|
||||||
<entry key="S60" value="21.423894438791837" />
|
<entry key="S60" value="2.959677574191515" />
|
||||||
<entry key="M2" value="22.530930014590602" />
|
<entry key="M2" value="3.4366229865704883" />
|
||||||
<entry key="M5" value="32.256903842215344" />
|
<entry key="M5" value="4.462442318201847" />
|
||||||
<entry key="M10" value="49.10264974172939" />
|
<entry key="M10" value="5.916664349028232" />
|
||||||
<entry key="M15" value="59.99418378616175" />
|
<entry key="M15" value="6.940237970746977" />
|
||||||
<entry key="M30" value="75.77225940331346" />
|
<entry key="M30" value="8.521362268212844" />
|
||||||
<entry key="H1" value="86.24194464495858" />
|
<entry key="H1" value="9.62023487151045" />
|
||||||
<entry key="H2" value="92.30215756709606" />
|
<entry key="H2" value="10.270683493430917" />
|
||||||
<entry key="H4" value="95.56680916983069" />
|
<entry key="H4" value="10.62488416631901" />
|
||||||
<entry key="D1" value="98.41568550867962" />
|
<entry key="D1" value="10.935974404270617" />
|
||||||
<entry key="W1" value="98.91622423777348" />
|
<entry key="W1" value="10.990815874256214" />
|
||||||
</decayedCancelled>
|
</decayedCancelled>
|
||||||
<decayedSelected>
|
<decayedSelected>
|
||||||
<entry key="MS100" value="0.0" />
|
<entry key="MS100" value="0.0" />
|
||||||
@@ -84,23 +78,23 @@
|
|||||||
<entry key="W1" value="0.0" />
|
<entry key="W1" value="0.0" />
|
||||||
</decayedSelected>
|
</decayedSelected>
|
||||||
<decayedShown>
|
<decayedShown>
|
||||||
<entry key="MS100" value="1.572917306599136" />
|
<entry key="MS100" value="0.15496346249522763" />
|
||||||
<entry key="MS500" value="3.853231737204187" />
|
<entry key="MS500" value="0.6936352317970043" />
|
||||||
<entry key="S2" value="10.722306786321894" />
|
<entry key="S2" value="1.1789457258652456" />
|
||||||
<entry key="S5" value="15.90871853935142" />
|
<entry key="S5" value="1.6521017306909382" />
|
||||||
<entry key="S10" value="18.579462450925384" />
|
<entry key="S10" value="2.0660150480217916" />
|
||||||
<entry key="S30" value="20.762563658810205" />
|
<entry key="S30" value="2.60082115522875" />
|
||||||
<entry key="S60" value="21.41797857399968" />
|
<entry key="S60" value="2.9160446418159838" />
|
||||||
<entry key="M2" value="22.5278009383226" />
|
<entry key="M2" value="3.4117712141940064" />
|
||||||
<entry key="M5" value="32.254976079390616" />
|
<entry key="M5" value="4.4508993349183115" />
|
||||||
<entry key="M10" value="49.101123585535056" />
|
<entry key="M10" value="5.910338911687691" />
|
||||||
<entry key="M15" value="59.99293373937006" />
|
<entry key="M15" value="6.935829221677054" />
|
||||||
<entry key="M30" value="75.77147145663449" />
|
<entry key="M30" value="8.519029212377378" />
|
||||||
<entry key="H1" value="86.24149819888382" />
|
<entry key="H1" value="9.619027375474417" />
|
||||||
<entry key="H2" value="92.30191941653102" />
|
<entry key="H2" value="10.270068085857725" />
|
||||||
<entry key="H4" value="95.56668611084393" />
|
<entry key="H4" value="10.624573345818598" />
|
||||||
<entry key="D1" value="98.41566442286562" />
|
<entry key="D1" value="10.935922149562563" />
|
||||||
<entry key="W1" value="98.91622121110338" />
|
<entry key="W1" value="10.990808397996608" />
|
||||||
</decayedShown>
|
</decayedShown>
|
||||||
</component>
|
</component>
|
||||||
<component name="ProjectColorInfo">{
|
<component name="ProjectColorInfo">{
|
||||||
@@ -115,31 +109,25 @@
|
|||||||
<option name="hideEmptyMiddlePackages" value="true" />
|
<option name="hideEmptyMiddlePackages" value="true" />
|
||||||
<option name="showLibraryContents" value="true" />
|
<option name="showLibraryContents" value="true" />
|
||||||
</component>
|
</component>
|
||||||
<component name="PropertiesComponent"><![CDATA[{
|
<component name="PropertiesComponent">{
|
||||||
"keyToString": {
|
"keyToString": {
|
||||||
"ModuleVcsDetector.initialDetectionPerformed": "true",
|
"ModuleVcsDetector.initialDetectionPerformed": "true",
|
||||||
"RunOnceActivity.MCP Project settings loaded": "true",
|
"RunOnceActivity.ShowReadmeOnStart": "true",
|
||||||
"RunOnceActivity.ShowReadmeOnStart": "true",
|
"RunOnceActivity.TerminalTabsStorage.copyFrom.TerminalArrangementManager.252": "true",
|
||||||
"RunOnceActivity.TerminalTabsStorage.copyFrom.TerminalArrangementManager.252": "true",
|
"RunOnceActivity.cidr.known.project.marker": "true",
|
||||||
"RunOnceActivity.cidr.known.project.marker": "true",
|
"RunOnceActivity.readMode.enableVisualFormatting": "true",
|
||||||
"RunOnceActivity.git.unshallow": "true",
|
"RunOnceActivity.typescript.service.memoryLimit.init": "true",
|
||||||
"RunOnceActivity.readMode.enableVisualFormatting": "true",
|
"cidr.known.project.marker": "true",
|
||||||
"RunOnceActivity.typescript.service.memoryLimit.init": "true",
|
"codeWithMe.voiceChat.enabledByDefault": "false",
|
||||||
"cidr.known.project.marker": "true",
|
"git-widget-placeholder": "develop",
|
||||||
"codeWithMe.voiceChat.enabledByDefault": "false",
|
"last_opened_file_path": "/home/rehlert/repos/juri-merger",
|
||||||
"git-widget-placeholder": "features/shadcn",
|
"node.js.selected.package.tslint": "(autodetect)",
|
||||||
"last_opened_file_path": "/home/rehlert/repos/pulley",
|
"nodejs_package_manager_path": "pnpm",
|
||||||
"list.type.of.created.stylesheet": "CSS",
|
"settings.editor.selected.configurable": "preferences.pluginManager",
|
||||||
"node.js.detected.package.eslint": "true",
|
"ts.external.directory.path": "/home/rehlert/repos/juri-merger/node_modules/typescript/lib",
|
||||||
"node.js.detected.package.tslint": "true",
|
"vue.rearranger.settings.migration": "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>
|
}</component>
|
||||||
<component name="TaskManager">
|
<component name="TaskManager">
|
||||||
<task active="true" id="Default" summary="Default task">
|
<task active="true" id="Default" summary="Default task">
|
||||||
<changelist id="89f9fc3a-63ad-41cb-8d25-f07df352eb98" name="Changes" comment="" />
|
<changelist id="89f9fc3a-63ad-41cb-8d25-f07df352eb98" name="Changes" comment="" />
|
||||||
@@ -148,18 +136,7 @@
|
|||||||
<option name="presentableId" value="Default" />
|
<option name="presentableId" value="Default" />
|
||||||
<updated>1781169139290</updated>
|
<updated>1781169139290</updated>
|
||||||
<workItem from="1781169141138" duration="309000" />
|
<workItem from="1781169141138" duration="309000" />
|
||||||
<workItem from="1781251694220" duration="22281000" />
|
<workItem from="1781251694220" duration="5262000" />
|
||||||
<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" />
|
|
||||||
</task>
|
</task>
|
||||||
<task id="LOCAL-00001" summary="adapts styling of rvg.legal">
|
<task id="LOCAL-00001" summary="adapts styling of rvg.legal">
|
||||||
<option name="closed" value="true" />
|
<option name="closed" value="true" />
|
||||||
@@ -169,111 +146,7 @@
|
|||||||
<option name="project" value="LOCAL" />
|
<option name="project" value="LOCAL" />
|
||||||
<updated>1781256112218</updated>
|
<updated>1781256112218</updated>
|
||||||
</task>
|
</task>
|
||||||
<task id="LOCAL-00002" summary="adds dropzone to add zips">
|
<option name="localTasksCounter" value="2" />
|
||||||
<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" />
|
|
||||||
<servers />
|
<servers />
|
||||||
</component>
|
</component>
|
||||||
<component name="TypeScriptGeneratedFilesManager">
|
<component name="TypeScriptGeneratedFilesManager">
|
||||||
@@ -281,34 +154,10 @@
|
|||||||
</component>
|
</component>
|
||||||
<component name="UnityCheckinConfiguration" checkUnsavedScenes="true" />
|
<component name="UnityCheckinConfiguration" checkUnsavedScenes="true" />
|
||||||
<component name="UnityProjectConfiguration" hasMinimizedUI="false" />
|
<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">
|
<component name="VcsManagerConfiguration">
|
||||||
<option name="CLEAR_INITIAL_COMMIT_MESSAGE" value="true" />
|
<option name="CLEAR_INITIAL_COMMIT_MESSAGE" value="true" />
|
||||||
<MESSAGE value="adapts styling of rvg.legal" />
|
<MESSAGE value="adapts styling of rvg.legal" />
|
||||||
<MESSAGE value="adds dropzone to add zips" />
|
<option name="LAST_COMMIT_MESSAGE" value="adapts styling of rvg.legal" />
|
||||||
<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" />
|
|
||||||
</component>
|
</component>
|
||||||
<component name="XDebuggerManager">
|
<component name="XDebuggerManager">
|
||||||
<breakpoint-manager>
|
<breakpoint-manager>
|
||||||
|
|||||||
@@ -1,93 +1,301 @@
|
|||||||
# Project notes for agents
|
# Project Agent Guide
|
||||||
|
|
||||||
## Project snapshot
|
## Project overview
|
||||||
- **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).
|
|
||||||
|
|
||||||
## Main user flow
|
This repository contains **juri-merger**, a German-language SvelteKit application branded as
|
||||||
1. User adds ZIP files via drag/drop or file picker (`ZipDropzone`).
|
**beA-Edit**. Users add one or more ZIP archives in the browser, inspect and reorder supported
|
||||||
2. `+page.svelte` starts async extraction for each file (tracked via `archiveGeneration` so stale in-flight extractions are discarded on bulk delete).
|
attachments, optionally group attachments into named sub-documents, and export merged PDFs.
|
||||||
3. Each ZIP is processed into one or more `ProcessedZipArchive` entries.
|
Nested ZIP archives and XJustiz metadata are handled client-side; there is no upload, server-side
|
||||||
4. `ProcessedZipArchiveEditor` shows thumbnails, allows reordering, and can download the merged PDF.
|
archive processing, database, analytics, or persistence of case data.
|
||||||
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.
|
|
||||||
|
|
||||||
## Important source files
|
The application also contains German imprint and privacy-policy routes.
|
||||||
- `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.
|
|
||||||
|
|
||||||
## Button system
|
## Technology stack
|
||||||
`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).
|
|
||||||
|
|
||||||
## ZIP/PDF processing details
|
- SvelteKit 2 and Svelte 5, with runes mode forced for project source files
|
||||||
- ZIP entries are filtered to ignore macOS metadata (`__MACOSX`, `.DS_Store`, `._*`).
|
- TypeScript in strict mode
|
||||||
- Relevant attachments are PDFs and common image formats.
|
- Vite 8
|
||||||
- Nested ZIP archives are processed recursively.
|
- Tailwind CSS 4 through `@tailwindcss/vite`
|
||||||
- `xjustiz_nachricht.xml` is used to derive archive naming and ordering.
|
- shadcn-svelte 1 with the `nova` style and CSS-variable theme tokens
|
||||||
- Ordering prefers document names from metadata before falling back to original attachment order.
|
- bits-ui, tailwind-variants, clsx, and tailwind-merge
|
||||||
- PDF merging uses `pdf-lib`; images are embedded as PNG/JPG, with browser-side canvas conversion for other image types.
|
- Lucide Svelte icons plus project-specific SVG icon components
|
||||||
- **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.
|
- fflate for ZIP extraction and image-export ZIP creation
|
||||||
- The merge loop is shared via `mergeAttachments(attachments)`; `mergeProcessedZipArchive` flattens the archive via `flattenArchiveForMerge` (sub-documents first, then loose) so backwards compatibility holds.
|
- pdf-lib for PDF assembly, `@cantoo/pdf-lib` for encryption, and pdfjs-dist for rendering
|
||||||
- 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.
|
- `@sveltejs/adapter-node`
|
||||||
- **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.
|
- pnpm with a committed lockfile
|
||||||
|
- Prettier with Svelte and Tailwind plugins
|
||||||
|
|
||||||
## UI/state details
|
Use **pnpm**, not npm or yarn. The production container uses Node 24 and pnpm 11.15.1.
|
||||||
- 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`.
|
|
||||||
|
|
||||||
## Dependencies worth noting
|
## Commands
|
||||||
- `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
|
|
||||||
|
|
||||||
## shadcn-svelte configuration
|
```sh
|
||||||
- `components.json`: style `nova`, `iconLibrary` `lucide`, `baseColor` `neutral`, Tailwind CSS path `src/routes/layout.css`.
|
pnpm install # Install dependencies
|
||||||
- Aliases: `components → $lib/components`, `ui → $lib/components/ui`, `utils → $lib/utils`, `hooks → $lib/hooks`, `lib → $lib`.
|
pnpm dev # Start the development server
|
||||||
- `src/lib/hooks/` exists but is currently empty.
|
pnpm build # Create the adapter-node production build
|
||||||
- Generated components live under `src/lib/components/ui/`; `button.svelte` is the canonical adapted example (custom variants + ripple + icon prop).
|
pnpm preview # Preview the production build
|
||||||
|
pnpm check # Run Svelte and TypeScript diagnostics
|
||||||
|
pnpm check:watch # Run diagnostics in watch mode
|
||||||
|
pnpm test:e2e # Run the Playwright toolbox tests in Chromium
|
||||||
|
pnpm lint # Check repository formatting with Prettier
|
||||||
|
pnpm format # Format the repository with Prettier
|
||||||
|
```
|
||||||
|
|
||||||
## Current working tree observations
|
For code changes, run at least:
|
||||||
- Branch is `features/shadcn`.
|
|
||||||
- `agents.md` is being updated to match the post-shadcn-integration state.
|
```sh
|
||||||
- `plan/shadcn-svelte-plan.md` tracks the in-progress migration (only `button` + `card` generated so far; broader UI replacement is still optional/incremental).
|
pnpm check
|
||||||
- Untracked: `plan/`, and several icons under `src/lib/components/icons/` (`IconChain`, `IconFile`, `IconMenu`, `IconNeedle`, `IconOffice`, `IconPerson`, `IconTag`, `IconTrash`, `IconUnknown`).
|
pnpm test:e2e
|
||||||
- Modified (uncommitted): `agents.md`, `src/lib/components/icons/IconSettings.svelte`, `src/lib/components/ui/button.svelte`, `src/routes/+page.svelte`, `.idea/workspace.xml`.
|
pnpm lint
|
||||||
- `src/lib/services/` is now committed (was previously untracked).
|
pnpm build
|
||||||
|
```
|
||||||
|
|
||||||
|
For documentation-only changes, a targeted Prettier check is sufficient. If pre-existing warnings
|
||||||
|
or formatting failures remain outside changed files, report them instead of making unrelated edits.
|
||||||
|
|
||||||
|
The Docker image can be built from the repository root. It installs with `--frozen-lockfile`, builds
|
||||||
|
and prunes dependencies, then runs `node build/index.js` as a non-root user on port 3000.
|
||||||
|
|
||||||
|
## Repository structure
|
||||||
|
|
||||||
|
```text
|
||||||
|
src/
|
||||||
|
├── app.html # German HTML shell
|
||||||
|
├── app.d.ts # SvelteKit application declarations
|
||||||
|
├── lib/
|
||||||
|
│ ├── actions/ripple.ts # Button ripple Svelte action
|
||||||
|
│ ├── assets/ # Favicon and local Nunito fonts
|
||||||
|
│ ├── components/
|
||||||
|
│ │ ├── AttachmentPreview.svelte # Browser-rendered PDF/image thumbnails
|
||||||
|
│ │ ├── BatchFileList.svelte # Shared batch rows: status chips, downloads, retry
|
||||||
|
│ │ ├── BeaArchiveProcessing.svelte
|
||||||
|
│ │ ├── BeaWorkspaceControls.svelte # beA workspace toolbar, settings popover, dialogs
|
||||||
|
│ │ ├── ProcessedZipArchiveEditor.svelte
|
||||||
|
│ │ ├── SubDocumentEditor.svelte
|
||||||
|
│ │ ├── FileDropzone.svelte # Shared PDF/image/ZIP file picker and drop target
|
||||||
|
│ │ ├── ZipFilePicker.svelte # Compact ZIP picker button with hidden resettable input
|
||||||
|
│ │ ├── PdfPageGrid.svelte # Accessible PDF thumbnail selection grid
|
||||||
|
│ │ ├── RasterizationWarning.svelte
|
||||||
|
│ │ ├── ZipDropzone.svelte # beA-specific wrapper around FileDropzone
|
||||||
|
│ │ ├── icons/ # Project-specific SVG components
|
||||||
|
│ │ └── ui/ # Local shadcn-svelte component source
|
||||||
|
│ ├── services/
|
||||||
|
│ │ ├── xml-reading.service.ts # Namespace-tolerant XJustiz parsing
|
||||||
|
│ │ └── zip-inflating.service.ts # Async fflate wrapper
|
||||||
|
│ ├── batch.ts # Sequential batch engine for multi-file tools
|
||||||
|
│ ├── pdf-compression.ts # Shared compression presets and rasterize pipeline
|
||||||
|
│ ├── pdf-overlay.ts # Shared PDF overlay positions and color parsing
|
||||||
|
│ ├── pdf-processing.ts # Reusable PDF merge helper
|
||||||
|
│ ├── pdf-thumbnails.ts # Managed pdfjs thumbnail loading and cleanup
|
||||||
|
│ ├── utils.ts # cn() and shared component utility types
|
||||||
|
│ └── zip-processing.ts # Archive model, transforms, and PDF export
|
||||||
|
└── routes/
|
||||||
|
├── +layout.svelte # Shared logo header and branded footer
|
||||||
|
├── +page.svelte # Toolbox hub and global beA ZIP drop target
|
||||||
|
├── layout.css # Tailwind, shadcn, theme, fonts, global CSS
|
||||||
|
├── tools/
|
||||||
|
│ ├── +layout.svelte # Shared back-navigation shell
|
||||||
|
│ ├── bea/+page.svelte # Existing ZIP archive workflow
|
||||||
|
│ └── {merge,separate,stamp,watermark,encrypt,decrypt,compress,convert,rotate}/
|
||||||
|
├── datenschutz/+page.svelte # Privacy policy
|
||||||
|
└── impressum/+page.svelte # Imprint
|
||||||
|
static/ # Public logo, footer art, and robots.txt
|
||||||
|
components.json # shadcn-svelte registry configuration
|
||||||
|
Dockerfile # Node adapter production image
|
||||||
|
plan/ # Design and implementation notes
|
||||||
|
```
|
||||||
|
|
||||||
|
Generated directories such as `.svelte-kit/`, `build/`, and `node_modules/` must not be edited or
|
||||||
|
committed.
|
||||||
|
|
||||||
|
## Application flow and state
|
||||||
|
|
||||||
|
- `/` is the toolbox hub. A ZIP dropped anywhere on that route is queued in memory and forwarded to
|
||||||
|
`/tools/bea`; the file is not uploaded or persisted.
|
||||||
|
- `tools/bea/+page.svelte` owns selected files, processed archives, pending work, and the
|
||||||
|
thumbnail-width preference. Thumbnail width is the only persisted UI setting and uses localStorage.
|
||||||
|
- The loaded workspace renders `BeaWorkspaceControls.svelte`, a top-aligned toolbar with the
|
||||||
|
workspace status and two archive lifecycle actions:
|
||||||
|
- **“Neue ZIP bearbeiten”** (replace) opens a ZIP picker and, after the user confirms the
|
||||||
|
replacement dialog, calls `replaceWithZipFiles(files)` which resets the workspace and enqueues the
|
||||||
|
new files against the incremented generation. Files picked for replacement stay in temporary
|
||||||
|
component state until confirmation; cancelling the picker or the dialog must never mutate the
|
||||||
|
current workspace.
|
||||||
|
- **“Weitere ZIP hinzufügen”** (append) lives in the settings popover, uses the plain append path
|
||||||
|
(`handleFilesSelected`), and never clears current archives. Keep the two labels distinct:
|
||||||
|
replace is “Neue ZIP bearbeiten”, append is “Weitere ZIP hinzufügen”.
|
||||||
|
- `BeaWorkspaceControls.svelte` owns only ephemeral UI state (popover/dialog open state and pending
|
||||||
|
replacement files). The route remains the source of truth for files, jobs, archives, and the
|
||||||
|
persisted thumbnail width.
|
||||||
|
- `resetWorkspace()` is the single teardown helper: it increments `archiveGeneration` and clears
|
||||||
|
selected files, processed archives, and jobs. Never clear those arrays without going through it.
|
||||||
|
- Accepted ZIP types and the file filter for the dropzone and both compact pickers are centralized in
|
||||||
|
`src/lib/zip-selection.ts`; `ZipFilePicker.svelte` resets its input after every selection so the
|
||||||
|
same file can be chosen again.
|
||||||
|
- ZIP files are processed concurrently. `archiveGeneration` prevents results from an old batch from
|
||||||
|
reappearing after the user deletes all archives **or** confirms a replacement.
|
||||||
|
- `ProcessedZipArchiveEditor.svelte` edits one archive and emits immutable replacements through
|
||||||
|
`onArchiveChange`; the route-level archive array remains the source of truth.
|
||||||
|
- Attachments may remain loose or belong to exactly one sub-document. Drag-and-drop supports
|
||||||
|
reordering and moving attachments between lists, while sub-document headers can be reordered.
|
||||||
|
- Selection mode creates sub-documents from loose attachments. Exiting with **Fertig** or Escape
|
||||||
|
clears the pending selection.
|
||||||
|
- Per-archive export supports one flattened PDF or separate PDFs for sub-documents. Each
|
||||||
|
sub-document also has its own direct download action that exports only that sub-document's
|
||||||
|
attachments. Bulk export from the page always creates one flattened PDF per archive.
|
||||||
|
|
||||||
|
When changing archive transforms, preserve these invariants:
|
||||||
|
|
||||||
|
- An attachment move must not duplicate or discard data.
|
||||||
|
- Sub-document IDs remain stable across edits and reordering.
|
||||||
|
- Single-PDF order is all sub-documents in order, each attachment in order, followed by loose
|
||||||
|
attachments in order.
|
||||||
|
- Dissolving a sub-document appends its attachments to the loose list.
|
||||||
|
- State helpers in `zip-processing.ts` should return new archive/list objects rather than mutating
|
||||||
|
component-owned state.
|
||||||
|
|
||||||
|
## ZIP, XML, and PDF processing
|
||||||
|
|
||||||
|
All case-file processing is intentionally local to the browser.
|
||||||
|
|
||||||
|
- `extractZipArchives` recursively inflates nested ZIP files.
|
||||||
|
- Ignore macOS metadata (`__MACOSX`, `.DS_Store`, and `._*`) and unsupported entries.
|
||||||
|
- `xjustiz_nachricht.xml` is metadata, not an export attachment. Its sender, receiver, and declared
|
||||||
|
document names determine the generated PDF name and preferred attachment order.
|
||||||
|
- XJustiz element matching must remain namespace tolerant. Malformed or absent metadata falls back
|
||||||
|
safely instead of preventing extraction.
|
||||||
|
- Supported attachments are PDFs and the image extensions listed in `zip-processing.ts`. Update the
|
||||||
|
extension classification and downstream preview/export behavior together when adding formats.
|
||||||
|
- PDF attachments are copied with pdf-lib. JPEG and PNG files can be embedded directly; other image
|
||||||
|
formats are decoded through browser image/canvas APIs and converted to PNG first.
|
||||||
|
- `AttachmentPreview.svelte` dynamically loads pdfjs-dist in `onMount`, copies PDF bytes before
|
||||||
|
rendering, and revokes object URLs during cleanup. Preserve those memory and data-integrity
|
||||||
|
safeguards.
|
||||||
|
- Empty archives/export groups should continue to fail explicitly rather than creating invalid PDFs.
|
||||||
|
|
||||||
|
Browser-only APIs used by this flow include `File`, `DOMParser`, `Image`, canvas, `Blob`, `URL`,
|
||||||
|
localStorage, and `crypto.randomUUID()`. Do not invoke browser-only work during SSR or at module
|
||||||
|
initialization. Keep it in event handlers, `onMount`, effects guarded by hydration, or functions only
|
||||||
|
called in the browser.
|
||||||
|
|
||||||
|
## Batch processing pattern for single-PDF tools
|
||||||
|
|
||||||
|
Tools that transform one PDF per run (compress, watermark, encrypt, …) are being retrofitted to
|
||||||
|
accept multiple files. Follow the established compress retrofit when adding the pattern to another
|
||||||
|
tool; see `plan/batch-processing-plan.md` for the full design.
|
||||||
|
|
||||||
|
- `src/lib/batch.ts` owns the engine: `createBatchItem(file)`, `runBatch(items, process, update,
|
||||||
|
options?)` and `formatBytes`. `runBatch` processes strictly sequentially (one PDF in flight for
|
||||||
|
memory safety), never throws — per-item failures are caught and written to the item as a German
|
||||||
|
error message — and consults the optional `shouldProcess` predicate so rows removed mid-run are
|
||||||
|
skipped.
|
||||||
|
- `src/lib/components/BatchFileList.svelte` renders the rows: name, size, status chip
|
||||||
|
(Wartet / Verarbeite… / Fertig ✓ / Fehler), per-row download, remove, and retry buttons with
|
||||||
|
accessible names including the file name, a progress line (“Datei 3 von 7 wird verarbeitet…”), a
|
||||||
|
total result-size summary, a memory warning (>20 files or any file >25 MB), and the bulk
|
||||||
|
“Alle herunterladen” ZIP action (via fflate, named `{toolname}_{date}.zip`) shown once ≥2 results
|
||||||
|
exist. Duplicate result names inside the ZIP are suffixed instead of overwriting.
|
||||||
|
- Page wiring: keep `items = $state<BatchItem[]>([])`, append newly dropped files (do not replace;
|
||||||
|
deduplicate by name + size), pass a `process(file)` callback that reads the current option state
|
||||||
|
so option changes mid-run affect only subsequent files, and gate the run button on pending items.
|
||||||
|
Files too large to rasterize (>100 MB) are marked as error rows immediately on selection instead
|
||||||
|
of blocking the batch.
|
||||||
|
- Per-tool ZIP base names: compress uses `komprimiert`, watermark `wasserzeichen`, encrypt
|
||||||
|
`geschuetzt`, decrypt `entsperrt`. Choose an analogous German name per tool and cover the batch
|
||||||
|
flow with Playwright tests (multi-file ZIP contents, failure continuation, retry, removal).
|
||||||
|
|
||||||
|
## Svelte conventions
|
||||||
|
|
||||||
|
- Use Svelte 5 runes and current event syntax: `$props()`, `$state`, `$derived`, `$effect`, snippets,
|
||||||
|
`{@render ...}`, and handlers such as `onclick` rather than legacy APIs.
|
||||||
|
- Keep component scripts typed with `<script lang="ts">`.
|
||||||
|
- Define a local `Props` type for non-trivial component props.
|
||||||
|
- Use callback props for child-to-parent updates; do not mutate prop objects in place.
|
||||||
|
- Use `$lib/...` aliases for application imports instead of long relative paths.
|
||||||
|
- Name reusable components in PascalCase and route files according to SvelteKit conventions.
|
||||||
|
- Preserve semantic HTML, accessible names, visible focus states, and keyboard alternatives. Do not
|
||||||
|
make drag-and-drop the only way to perform a critical action.
|
||||||
|
- Clean up global listeners, object URLs, PDF loading tasks, and other browser resources.
|
||||||
|
|
||||||
|
The Vite configuration forces runes mode for project files, so do not introduce legacy Svelte
|
||||||
|
component patterns.
|
||||||
|
|
||||||
|
## shadcn-svelte and component conventions
|
||||||
|
|
||||||
|
`components.json` configures the official shadcn-svelte registry, the `nova` style, Lucide icons, and
|
||||||
|
these aliases:
|
||||||
|
|
||||||
|
- UI components: `$lib/components/ui`
|
||||||
|
- General components: `$lib/components`
|
||||||
|
- Utilities: `$lib/utils`
|
||||||
|
|
||||||
|
Use existing local UI components before creating replacements. Follow the exports actually present
|
||||||
|
on disk: the card is composed through `import * as Card from '$lib/components/ui/card'`, while the
|
||||||
|
current button is a default import from `$lib/components/ui/button.svelte`.
|
||||||
|
|
||||||
|
When adding a registry component, use the project runner, inspect every generated file, and preserve
|
||||||
|
local customizations:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
pnpm dlx shadcn-svelte@latest add <component>
|
||||||
|
```
|
||||||
|
|
||||||
|
Do not overwrite or bulk-update customized components without explicit approval. Use `cn()` for
|
||||||
|
conditional class merging and existing variants before adding one-off component styling.
|
||||||
|
|
||||||
|
## Styling conventions
|
||||||
|
|
||||||
|
Use Tailwind utilities in Svelte markup and semantic theme tokens from `src/routes/layout.css`.
|
||||||
|
|
||||||
|
- Prefer `bg-background`, `text-foreground`, `bg-primary`, `text-muted-foreground`, `border-border`,
|
||||||
|
and other configured semantic utilities over raw brand colors.
|
||||||
|
- Preserve the established Nominandum visual language: pale blue-gray background, white cards, dark
|
||||||
|
blue primary text, red/coral secondary actions, rounded containers, and Nunito typography.
|
||||||
|
- Use `flex`/`grid` with `gap-*`, responsive variants, and `size-*` when width and height are equal.
|
||||||
|
- Use `cn()` rather than constructing partial Tailwind class names. Keep complete class names visible
|
||||||
|
to Tailwind's scanner.
|
||||||
|
- Prefer component variants and theme variables over overriding UI-component colors in call sites.
|
||||||
|
- Add global CSS only for imports, tokens, font faces, animations, or behavior that utilities cannot
|
||||||
|
reasonably express.
|
||||||
|
- Tailwind 4 is configured through Vite and `layout.css`; there is no `tailwind.config` file.
|
||||||
|
- Maintain contrast, responsive layouts, drag-state feedback, and visible focus treatment.
|
||||||
|
|
||||||
|
Theme-level changes belong in `src/routes/layout.css`. Do not reintroduce the removed Skeleton theme
|
||||||
|
or refer to the old `nom-theme.css` architecture.
|
||||||
|
|
||||||
|
## Formatting
|
||||||
|
|
||||||
|
Follow the checked-in Prettier configuration:
|
||||||
|
|
||||||
|
- Tabs for indentation
|
||||||
|
- Single quotes in scripts and styles where supported
|
||||||
|
- No trailing commas
|
||||||
|
- 100-character print width
|
||||||
|
- Svelte-aware and Tailwind-aware formatting
|
||||||
|
|
||||||
|
Use `pnpm exec prettier --write <changed-files>` for targeted formatting. Avoid formatting unrelated
|
||||||
|
files solely to clean up existing differences.
|
||||||
|
|
||||||
|
## Product and content constraints
|
||||||
|
|
||||||
|
- User-facing copy is German unless a task explicitly requests another language.
|
||||||
|
- Preserve legal text in `datenschutz/+page.svelte` and `impressum/+page.svelte` unless explicitly
|
||||||
|
asked to change it.
|
||||||
|
- Keep the Nominandum logo, footer artwork, and theme consistent across routes.
|
||||||
|
- ZIP selection accepts multiple files matching `.zip`, `application/zip`, or
|
||||||
|
`application/x-zip-compressed`.
|
||||||
|
- Resetting the file input after selection is intentional so the same archive can be selected again.
|
||||||
|
- Do not add uploads, network requests, server processing, analytics, or case-data persistence without
|
||||||
|
an explicit requirement and privacy review.
|
||||||
|
|
||||||
|
## Change discipline
|
||||||
|
|
||||||
|
- Keep changes scoped and preserve unrelated work, including untracked files.
|
||||||
|
- Inspect existing components, archive helpers, and theme tokens before adding abstractions.
|
||||||
|
- Keep browser processing behavior and archive-order invariants covered when editing shared helpers.
|
||||||
|
- Do not hand-edit generated output or lockfile contents. Update the lockfile only through pnpm when
|
||||||
|
dependency changes are required.
|
||||||
|
- Avoid dependencies when the task can be completed with Svelte, browser APIs, Tailwind, or existing
|
||||||
|
packages.
|
||||||
|
- After implementation, review changed files, run relevant checks, and mention remaining warnings or
|
||||||
|
limitations.
|
||||||
|
|||||||
+5
-1
@@ -10,18 +10,21 @@
|
|||||||
"prepare": "svelte-kit sync || echo ''",
|
"prepare": "svelte-kit sync || echo ''",
|
||||||
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
||||||
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
|
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
|
||||||
|
"test:e2e": "playwright test",
|
||||||
"lint": "prettier --check .",
|
"lint": "prettier --check .",
|
||||||
"format": "prettier --write ."
|
"format": "prettier --write ."
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@fontsource-variable/geist": "^5.2.9",
|
"@fontsource-variable/geist": "^5.2.9",
|
||||||
"@lucide/svelte": "^1.18.0",
|
"@lucide/svelte": "^1.18.0",
|
||||||
|
"@playwright/test": "^1.62.1",
|
||||||
"@sveltejs/adapter-node": "^5.5.7",
|
"@sveltejs/adapter-node": "^5.5.7",
|
||||||
"@sveltejs/kit": "^2.63.0",
|
"@sveltejs/kit": "^2.63.0",
|
||||||
"@sveltejs/vite-plugin-svelte": "^7.1.2",
|
"@sveltejs/vite-plugin-svelte": "^7.1.2",
|
||||||
"@tailwindcss/forms": "^0.5.11",
|
"@tailwindcss/forms": "^0.5.11",
|
||||||
"@tailwindcss/typography": "^0.5.19",
|
"@tailwindcss/typography": "^0.5.19",
|
||||||
"@tailwindcss/vite": "^4.3.0",
|
"@tailwindcss/vite": "^4.3.0",
|
||||||
|
"@types/node": "^26.3.0",
|
||||||
"bits-ui": "2.18.1",
|
"bits-ui": "2.18.1",
|
||||||
"class-variance-authority": "0.7.1",
|
"class-variance-authority": "0.7.1",
|
||||||
"clsx": "2.1.1",
|
"clsx": "2.1.1",
|
||||||
@@ -32,13 +35,14 @@
|
|||||||
"svelte": "^5.56.1",
|
"svelte": "^5.56.1",
|
||||||
"svelte-check": "^4.6.0",
|
"svelte-check": "^4.6.0",
|
||||||
"tailwind-merge": "3.6.0",
|
"tailwind-merge": "3.6.0",
|
||||||
"tailwind-variants": "^3.2.2",
|
"tailwind-variants": "^3.3.1",
|
||||||
"tailwindcss": "^4.3.0",
|
"tailwindcss": "^4.3.0",
|
||||||
"tw-animate-css": "^1.4.0",
|
"tw-animate-css": "^1.4.0",
|
||||||
"typescript": "^6.0.3",
|
"typescript": "^6.0.3",
|
||||||
"vite": "^8.0.16"
|
"vite": "^8.0.16"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@cantoo/pdf-lib": "^2.9.1",
|
||||||
"fflate": "^0.8.3",
|
"fflate": "^0.8.3",
|
||||||
"pdf-lib": "^1.17.1",
|
"pdf-lib": "^1.17.1",
|
||||||
"pdfjs-dist": "^6.0.227"
|
"pdfjs-dist": "^6.0.227"
|
||||||
|
|||||||
@@ -1,256 +0,0 @@
|
|||||||
# Plan: Sub-documents within a ZIP archive
|
|
||||||
|
|
||||||
## Goal
|
|
||||||
|
|
||||||
For a processed ZIP archive the user can already **reorder** attachments and **export** them as a single merged PDF. This feature adds the ability to:
|
|
||||||
|
|
||||||
1. **Select** some of the archive's attachments and **group them into a "sub-document"** (a named, ordered subset of attachments).
|
|
||||||
2. **Reorder** attachments *within* a sub-document — and **reorder** the sub-documents themselves — exactly like the parent archive today.
|
|
||||||
3. **Choose an export mode** for the sub-documents:
|
|
||||||
- **Separate files** → each sub-document becomes its own downloaded PDF.
|
|
||||||
- **One PDF** → all sub-documents are merged into a single downloaded PDF (the existing top-level "PDF herunterladen" behaviour, now scoped to sub-documents when they exist).
|
|
||||||
|
|
||||||
The existing flow (no sub-documents created → one merged PDF for the whole archive) stays intact as a fallback.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Open decisions (please confirm before implementation)
|
|
||||||
|
|
||||||
These affect the data model and UI. Default proposals are marked ✅.
|
|
||||||
|
|
||||||
### D1 — Partition model
|
|
||||||
- ✅ **A. Optional grouping.** Attachments can be "loose" (not part of any sub-document) *or* members of a sub-document. Sub-documents are created on demand by selecting attachments.
|
|
||||||
- B. **Exclusive partition.** Once the user starts creating sub-documents, every attachment must belong to exactly one.
|
|
||||||
- C. **Shared/multi-membership.** An attachment can appear in multiple sub-documents (references, not moves).
|
|
||||||
|
|
||||||
> Recommendation: **A**. It is the least disruptive, matches the current "flat list" mental model, and degrades gracefully when the user does nothing.
|
|
||||||
|
|
||||||
### D2 — Selection lifecycle
|
|
||||||
- ✅ **Selection mode toggle.** A small "Auswählen" button in the archive header switches the editor into selection mode. Only then are checkboxes rendered on each thumbnail; in normal mode the editor looks exactly like today (no permanent checkbox clutter). While in selection mode the user toggles attachments, then clicks "Teildokument erstellen" to consume the selection (which clears the selection but leaves selection mode on, so they can immediately create another sub-document). Exiting selection mode via a "Fertig" button (or Esc) clears any pending selection.
|
|
||||||
- A sub-document is created from the *current* selection. Afterwards each attachment can be moved into / out of / between sub-documents via drag-drop or a context action.
|
|
||||||
- Alternative considered: persistent always-visible checkboxes — rejected as visually noisy for the common case where the user only wants to reorder.
|
|
||||||
|
|
||||||
### D3 — Download scope when sub-documents exist
|
|
||||||
The existing single archive-level "PDF herunterladen" button behaviour needs clarification. Proposed:
|
|
||||||
- ✅ When **no** sub-documents exist → today's behaviour (merge all attachments into one PDF).
|
|
||||||
- When sub-documents **do** exist → show a **download mode toggle** offered to the user at export time:
|
|
||||||
- **Getrennt** (separate): one PDF per sub-document (loose attachments are downloaded individually, matching their kind — PDF passthrough, image as-is or as a tiny PDF — TBD in D4).
|
|
||||||
- **Eine PDF** (one): all sub-documents (and loose attachments, optionally — see D5) merged in sub-document order, with attachment order preserved inside each.
|
|
||||||
- ✅ "Separate" downloads are triggered sequentially (same pattern as today's `exportAllZipArchivesAsPdf`).
|
|
||||||
|
|
||||||
### D4 — Format of "separate" downloads
|
|
||||||
- ✅ Each sub-document is downloaded as a **PDF** (merge its attachments with the existing `mergeProcessedZipArchive` logic, scoped to the sub-document's attachments). Naming: `{archiveName} - {subDocumentName}.pdf`.
|
|
||||||
- B. Each sub-document is downloaded as a **ZIP** containing its original files.
|
|
||||||
- C. Mixed: keep each attachment's original format (PDFs passthrough, images as image files).
|
|
||||||
|
|
||||||
> Recommendation: **A** for consistency with the existing PDF-first export. We can revisit if the actual need is "preserve original files."
|
|
||||||
|
|
||||||
### D5 — Treatment of loose (ungrouped) attachments at export
|
|
||||||
- ✅ When exporting **separate**: loose attachments are downloaded each as their own PDF (or skipped — needs a quick confirm).
|
|
||||||
- ✅ When exporting **one PDF**: loose attachments are appended at the end (in their existing order) — or skipped.
|
|
||||||
- B. Ignore loose attachments entirely during export.
|
|
||||||
|
|
||||||
> Recommendation: **include loose attachments** at the end of both modes, so nothing is silently lost. Confirm with user.
|
|
||||||
|
|
||||||
### D6 — Naming of sub-documents
|
|
||||||
- ✅ A sub-document has a user-editable name (default e.g. `Teildokument 1`, `Teildokument 2`, …) shown next to its attachments, mirroring the archive name input.
|
|
||||||
|
|
||||||
### D7 — Persistence
|
|
||||||
- ✅ Sub-document structure lives only in component state (same as today's reordering) — no localStorage persistence required for v1. (We already do not persist reordering.)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Data model (`src/lib/zip-processing.ts`)
|
|
||||||
|
|
||||||
Introduce a `SubDocument` type alongside `ZipAttachment` and extend `ProcessedZipArchive`.
|
|
||||||
|
|
||||||
```ts
|
|
||||||
export type SubDocument = {
|
|
||||||
id: string; // stable id for keyed {#each} and drag state
|
|
||||||
name: string; // user-editable
|
|
||||||
attachments: ZipAttachment[];
|
|
||||||
};
|
|
||||||
|
|
||||||
export type ProcessedZipArchive = {
|
|
||||||
name: string;
|
|
||||||
attachments: ZipAttachment[]; // loose attachments (unchanged shape)
|
|
||||||
subDocuments: SubDocument[]; // NEW — defaults to [] for backward compat
|
|
||||||
};
|
|
||||||
```
|
|
||||||
|
|
||||||
Notes:
|
|
||||||
- Attachments inside `SubDocument` reuse the existing `ZipAttachment` type unchanged.
|
|
||||||
- `extractZipEntries` populates `subDocuments: []`. All existing call sites continue to work.
|
|
||||||
- A sub-document's `id` is generated with `crypto.randomUUID()` (available in browser + modern Node) — never re-derived from index, so reordering stays stable.
|
|
||||||
|
|
||||||
### New pure helpers (unit-test-friendly, no DOM)
|
|
||||||
|
|
||||||
```ts
|
|
||||||
// Create a sub-document from a list of attachments.
|
|
||||||
export const createSubDocument = (
|
|
||||||
name: string,
|
|
||||||
attachments: ZipAttachment[]
|
|
||||||
): SubDocument => ({ id: crypto.randomUUID(), name, attachments });
|
|
||||||
|
|
||||||
// Move an attachment from the loose list (or another sub-document) into a sub-document.
|
|
||||||
export const moveAttachmentIntoSubDocument = (
|
|
||||||
archive: ProcessedZipArchive,
|
|
||||||
attachmentPath: string,
|
|
||||||
source: { kind: 'loose' } | { kind: 'subDocument'; id: string },
|
|
||||||
target: { kind: 'loose'; index: number } | { kind: 'subDocument'; id: string; index: number }
|
|
||||||
): ProcessedZipArchive => { /* immutable update */ };
|
|
||||||
|
|
||||||
// Reorder sub-documents (same shape as the existing moveAttachment helper).
|
|
||||||
export const moveSubDocument = (
|
|
||||||
archive: ProcessedZipArchive,
|
|
||||||
fromIndex: number,
|
|
||||||
toIndex: number
|
|
||||||
): ProcessedZipArchive => { /* ... */ };
|
|
||||||
|
|
||||||
// Flatten the archive into an ordered list of attachments for "one PDF" export.
|
|
||||||
// sub-document order is preserved; loose attachments appended at the end (per D5).
|
|
||||||
export const flattenArchiveForMerge = (
|
|
||||||
archive: ProcessedZipArchive
|
|
||||||
): ZipAttachment[] => {
|
|
||||||
const ordered: ZipAttachment[] = [];
|
|
||||||
for (const sub of archive.subDocuments) ordered.push(...sub.attachments);
|
|
||||||
ordered.push(...archive.attachments);
|
|
||||||
return ordered;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Build one merged-PDF byte array per export unit.
|
|
||||||
// - mode 'single' → returns [ { name, bytes } ] from the whole archive (existing behaviour)
|
|
||||||
// - mode 'separate' → returns one entry per sub-document (+ loose attachments per D5)
|
|
||||||
export type ExportUnit = { name: string; bytes: Uint8Array };
|
|
||||||
export const buildArchiveExport = async (
|
|
||||||
archive: ProcessedZipArchive,
|
|
||||||
mode: 'single' | 'separate'
|
|
||||||
): Promise<ExportUnit[]> => { /* ... */ };
|
|
||||||
```
|
|
||||||
|
|
||||||
`mergeProcessedZipArchive` is refactored to delegate to a new `mergeAttachments(attachments): Promise<Uint8Array>` (a thin rename of the existing inner loop) so both "single" and "separate" share the same merging code.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## UI
|
|
||||||
|
|
||||||
### `ProcessedZipArchiveEditor.svelte` (main work)
|
|
||||||
|
|
||||||
State additions:
|
|
||||||
- `subDocuments = $state<SubDocument[]>([])` synced from `archive.subDocuments` in the existing `$effect`.
|
|
||||||
- `selectionMode = $state<boolean>(false)` — whether selection checkboxes are currently shown on thumbnails.
|
|
||||||
- `selectedPaths = $state<Set<string>>([])` — currently selected attachment paths (keyed by `attachment.path`, which is unique within an archive after extraction). Only meaningful while `selectionMode` is on.
|
|
||||||
- `downloadMode = $state<'single' | 'separate'>('single')` (only shown when sub-documents exist).
|
|
||||||
- Existing drag handlers generalised to handle three drag sources/targets:
|
|
||||||
- loose attachment ↔ loose attachment (today's behaviour),
|
|
||||||
- loose attachment → sub-document,
|
|
||||||
- sub-document attachment ↔ sub-document attachment,
|
|
||||||
- sub-document ↔ sub-document (reorder).
|
|
||||||
- Drag-and-drop is disabled while in selection mode (so a click toggles selection rather than starting a drag).
|
|
||||||
|
|
||||||
Layout:
|
|
||||||
- Keep the existing horizontal `<ul>` for **loose** attachments.
|
|
||||||
- In the header, add a small toggle button labelled **"Auswählen"** (icon: `IconMenu` or a checkmark lucide icon). When active it reads **"Fertig"** (and selection mode is on). In normal mode thumbnails have no checkbox at all — the editor looks identical to today.
|
|
||||||
- While in selection mode, each thumbnail card shows a **checkbox** in the top-left corner and the whole card becomes a click target that toggles its membership in `selectedPaths`. Drag-to-reorder is suspended in this mode to avoid click/drag ambiguity.
|
|
||||||
- A new **inline action bar** appears above the loose-attachment list when `selectionMode && selectedPaths.size > 0`:
|
|
||||||
- Button **"Teildokument erstellen"** → calls `createSubDocument` with the selected attachments, removes them from the loose list, clears `selectedPaths`, and keeps `selectionMode` on so the user can immediately build the next sub-document.
|
|
||||||
- `Esc` exits selection mode (clearing `selectedPaths`); the "Fertig" button does the same.
|
|
||||||
- Below the loose list, render `subDocuments` as a **vertical stack of "sub-document blocks"**. Each block is itself a horizontal reorderable `<ul>` of attachments (same `AttachmentPreview`, same drag handle `::`, same thumbnail width). Each block has:
|
|
||||||
- an editable name input (mirrors archive name input styling),
|
|
||||||
- a count label,
|
|
||||||
- a "remove" action that returns its attachments back to the loose list,
|
|
||||||
- a draggable header (`::`) to reorder the block among its siblings.
|
|
||||||
- Sub-document blocks are draggable as a whole via their header (HTML5 DnD, mirroring the existing pattern in `handleDragStart/Drop`).
|
|
||||||
|
|
||||||
Export controls (header area):
|
|
||||||
- If `subDocuments.length === 0` → existing **"PDF herunterladen"** button + behaviour (unchanged).
|
|
||||||
- If `subDocuments.length > 0` → replace with a **segmented control** (`Eine PDF` / `Getrennt`) plus the download button labeled accordingly:
|
|
||||||
- `Eine PDF` → `buildArchiveExport(archive, 'single')`, one `downloadPdfBytes` call.
|
|
||||||
- `Getrennt` → `buildArchiveExport(archive, 'separate')`, sequential `downloadPdfBytes` per unit (matches today's `exportAllZipArchivesAsPdf` loop).
|
|
||||||
|
|
||||||
All state changes flow through `onArchiveChange` (already plumbed to `updateProcessedZipFile` in `+page.svelte`) so the top-level array stays the source of truth.
|
|
||||||
|
|
||||||
### `+page.svelte`
|
|
||||||
- `exportAllZipArchivesAsPdf` needs to honour per-archive export mode. Two options:
|
|
||||||
- ✅ Keep it simple: bulk export uses the existing **"single"** mode for every archive (sub-documents flattened). Add a note in the tooltip.
|
|
||||||
- B. Read each archive's last-chosen mode (requires propagating `downloadMode` up). Defer to a follow-up if desired.
|
|
||||||
- The settings-drawer bulk export button label stays; behaviour unchanged for v1.
|
|
||||||
|
|
||||||
### No changes required
|
|
||||||
- `ZipDropzone`, `AttachmentPreview`, services, `xml-reading.service`, `zip-inflating.service`, layout.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Implementation phases
|
|
||||||
|
|
||||||
### Phase 1 — Data model + pure helpers
|
|
||||||
- Edit `src/lib/zip-processing.ts`:
|
|
||||||
- Add `SubDocument` type and extend `ProcessedZipArchive` with `subDocuments: SubDocument[]` (default `[]`).
|
|
||||||
- Set `subDocuments: []` in `extractZipEntries`.
|
|
||||||
- Extract `mergeAttachments(attachments: ZipAttachment[]): Promise<Uint8Array>` from `mergeProcessedZipArchive`.
|
|
||||||
- Add the pure helpers: `createSubDocument`, `moveAttachmentIntoSubDocument`, `moveSubDocument`, `removeAttachmentFromSubDocument` (returns attachments to the loose list), `flattenArchiveForMerge`, `buildArchiveExport`.
|
|
||||||
- Verification: TypeScript compiles (`pnpm check` / `svelte-check`); existing tests (if any) still pass; existing UI still renders unchanged.
|
|
||||||
|
|
||||||
### Phase 2 — Sub-document block component
|
|
||||||
- New `src/lib/components/SubDocumentEditor.svelte`:
|
|
||||||
- Props: `subDocument`, `thumbnailWidth`, `onSubDocumentChange`, `onRemove`, `drag handlers` (or pass a generic reorder callback).
|
|
||||||
- Renders the editable name, count, remove button, draggable header, horizontal attachment list (reusing `AttachmentPreview` + the same DnD styling).
|
|
||||||
- Internally mirrors `ProcessedZipArchiveEditor`'s drag logic, scoped to the block.
|
|
||||||
- Verify: can be unit-rendered in isolation (storybook-equivalent or a throwaway page snippet).
|
|
||||||
|
|
||||||
### Phase 3 — Selection mode + sub-document creation in `ProcessedZipArchiveEditor`
|
|
||||||
- Add `selectionMode` and `selectedPaths` state and the "Auswählen" / "Fertig" header toggle.
|
|
||||||
- While `selectionMode` is on, render a checkbox on each loose thumbnail and make the whole card a click-to-toggle target; suspend drag-to-reorder to avoid click/drag ambiguity.
|
|
||||||
- Add the inline action bar with **"Teildokument erstellen"**, visible when a selection exists.
|
|
||||||
- On create: call `createSubDocument`, remove the selected attachments from the loose list, clear `selectedPaths`, keep `selectionMode` on, and emit `onArchiveChange`.
|
|
||||||
- `Esc` / "Fertig" exits selection mode and clears the selection.
|
|
||||||
- On sub-document removal: push its attachments back into the loose list (appended at the end by default).
|
|
||||||
- Verify: toggling selection mode shows/hides checkboxes; in normal mode the editor looks identical to today; creating, populating, and removing sub-documents works end-to-end with reordering still working for loose attachments.
|
|
||||||
|
|
||||||
### Phase 4 — Sub-document drag interactions
|
|
||||||
- Generalise the existing HTML5 DnD handlers to support the three move scenarios listed above. Use the `dataTransfer` payload to encode `{ source: 'loose' | `sub:${id}`, index }`.
|
|
||||||
- Reorder sub-document blocks via header drag.
|
|
||||||
- Verify: items can be moved loose↔sub and sub↔sub; sub-document order can be changed; nothing is duplicated or lost (count invariant: `loose.length + Σ sub.attachments.length === total attachments`).
|
|
||||||
|
|
||||||
### Phase 5 — Export modes
|
|
||||||
- Replace header download button with the segmented control + button when sub-documents exist.
|
|
||||||
- Wire `buildArchiveExport(archive, mode)` to `downloadPdfBytes` (single) and the sequential loop (separate).
|
|
||||||
- `+page.svelte`: keep `exportAllZipArchivesAsPdf` in single mode; optionally surface a small hint when an archive has sub-documents.
|
|
||||||
- Verify:
|
|
||||||
- No sub-documents → existing single-PDF download unchanged.
|
|
||||||
- With sub-documents, "Eine PDF" flattens and downloads one PDF.
|
|
||||||
- With sub-documents, "Getrennt" downloads N files named `{archiveName} - {subName}.pdf`.
|
|
||||||
|
|
||||||
### Phase 6 — Polish
|
|
||||||
- Empty states (e.g. empty sub-document block, no loose attachments).
|
|
||||||
- Disable the create-sub-document button when selection is empty.
|
|
||||||
- Accessible labels + keyboard operability for checkboxes / segmented control (`bits-ui` SegmentedControl if available, otherwise native radio group).
|
|
||||||
- Update `agents.md` "ZIP/PDF processing details" + "UI/state details" sections to mention sub-documents.
|
|
||||||
- Manual smoke test on a real `xjustiz` ZIP with nested archives.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Verification checklist (definition of done)
|
|
||||||
|
|
||||||
- [ ] `pnpm check` (or `svelte-check`) passes with no new errors.
|
|
||||||
- [ ] `pnpm build` succeeds.
|
|
||||||
- [ ] Loading a ZIP without creating sub-documents produces the exact same merged PDF as today.
|
|
||||||
- [ ] Toggling "Auswählen" shows checkboxes on thumbnails; toggling "Fertig" (or pressing Esc) hides them and clears the selection. In normal mode the editor looks identical to today (no checkboxes).
|
|
||||||
- [ ] Selecting ≥1 attachments and clicking "Teildokument erstellen" removes them from the loose list and adds a named sub-document block, then clears the selection while staying in selection mode for the next group.
|
|
||||||
- [ ] Drag-reordering works for: loose attachments, attachments inside a sub-document, sub-document blocks themselves, and moving an attachment loose↔sub.
|
|
||||||
- [ ] With sub-documents present:
|
|
||||||
- "Eine PDF" downloads a single PDF containing every attachment (sub-documents first, then loose).
|
|
||||||
- "Getrennt" downloads one PDF per sub-document, each correctly named.
|
|
||||||
- [ ] Removing a sub-document returns its attachments to the loose list; total attachment count is invariant.
|
|
||||||
- [ ] Bulk "Alle ZIP-Archive als PDF exportieren" still works (single mode).
|
|
||||||
- [ ] `agents.md` updated.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Out of scope (follow-ups)
|
|
||||||
|
|
||||||
- Persisting sub-document structure to `localStorage`.
|
|
||||||
- Renaming attachments themselves (only sub-document names are editable in v1).
|
|
||||||
- Exporting "separate" as ZIPs or original-format files (see D4-C).
|
|
||||||
- Per-archive export mode remembered by the bulk exporter (D3 follow-up).
|
|
||||||
- Drag-and-drop between *different archives* in `+page.svelte` (today each editor is self-contained).
|
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
/// <reference types="node" />
|
||||||
|
|
||||||
|
import { defineConfig } from '@playwright/test';
|
||||||
|
import { existsSync } from 'node:fs';
|
||||||
|
|
||||||
|
const chromiumExecutable =
|
||||||
|
process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH ??
|
||||||
|
(existsSync('/usr/bin/chromium') ? '/usr/bin/chromium' : undefined);
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
testDir: './tests/e2e',
|
||||||
|
fullyParallel: false,
|
||||||
|
retries: 0,
|
||||||
|
reporter: 'line',
|
||||||
|
use: {
|
||||||
|
baseURL: 'http://127.0.0.1:4174',
|
||||||
|
trace: 'retain-on-failure',
|
||||||
|
video: 'retain-on-failure',
|
||||||
|
launchOptions: {
|
||||||
|
executablePath: chromiumExecutable,
|
||||||
|
args: ['--no-sandbox']
|
||||||
|
}
|
||||||
|
},
|
||||||
|
webServer: {
|
||||||
|
command: 'pnpm dev --host 127.0.0.1 --port 4174 --strictPort',
|
||||||
|
url: 'http://127.0.0.1:4174',
|
||||||
|
reuseExistingServer: true,
|
||||||
|
timeout: 120_000
|
||||||
|
}
|
||||||
|
});
|
||||||
Generated
+166
-34
@@ -8,6 +8,9 @@ importers:
|
|||||||
|
|
||||||
.:
|
.:
|
||||||
dependencies:
|
dependencies:
|
||||||
|
'@cantoo/pdf-lib':
|
||||||
|
specifier: ^2.9.1
|
||||||
|
version: 2.9.1(html-entities@2.6.0)
|
||||||
fflate:
|
fflate:
|
||||||
specifier: ^0.8.3
|
specifier: ^0.8.3
|
||||||
version: 0.8.3
|
version: 0.8.3
|
||||||
@@ -24,15 +27,18 @@ importers:
|
|||||||
'@lucide/svelte':
|
'@lucide/svelte':
|
||||||
specifier: ^1.18.0
|
specifier: ^1.18.0
|
||||||
version: 1.18.0(svelte@5.56.3)
|
version: 1.18.0(svelte@5.56.3)
|
||||||
|
'@playwright/test':
|
||||||
|
specifier: ^1.62.1
|
||||||
|
version: 1.62.1
|
||||||
'@sveltejs/adapter-node':
|
'@sveltejs/adapter-node':
|
||||||
specifier: ^5.5.7
|
specifier: ^5.5.7
|
||||||
version: 5.5.7(@sveltejs/kit@2.64.0(@sveltejs/vite-plugin-svelte@7.1.2(svelte@5.56.3)(vite@8.0.16(jiti@2.7.0)))(svelte@5.56.3)(typescript@6.0.3)(vite@8.0.16(jiti@2.7.0)))
|
version: 5.5.7(@sveltejs/kit@2.64.0(@sveltejs/vite-plugin-svelte@7.1.2(svelte@5.56.3)(vite@8.0.16(@types/node@26.3.0)(jiti@2.7.0)))(svelte@5.56.3)(typescript@6.0.3)(vite@8.0.16(@types/node@26.3.0)(jiti@2.7.0)))
|
||||||
'@sveltejs/kit':
|
'@sveltejs/kit':
|
||||||
specifier: ^2.63.0
|
specifier: ^2.63.0
|
||||||
version: 2.64.0(@sveltejs/vite-plugin-svelte@7.1.2(svelte@5.56.3)(vite@8.0.16(jiti@2.7.0)))(svelte@5.56.3)(typescript@6.0.3)(vite@8.0.16(jiti@2.7.0))
|
version: 2.64.0(@sveltejs/vite-plugin-svelte@7.1.2(svelte@5.56.3)(vite@8.0.16(@types/node@26.3.0)(jiti@2.7.0)))(svelte@5.56.3)(typescript@6.0.3)(vite@8.0.16(@types/node@26.3.0)(jiti@2.7.0))
|
||||||
'@sveltejs/vite-plugin-svelte':
|
'@sveltejs/vite-plugin-svelte':
|
||||||
specifier: ^7.1.2
|
specifier: ^7.1.2
|
||||||
version: 7.1.2(svelte@5.56.3)(vite@8.0.16(jiti@2.7.0))
|
version: 7.1.2(svelte@5.56.3)(vite@8.0.16(@types/node@26.3.0)(jiti@2.7.0))
|
||||||
'@tailwindcss/forms':
|
'@tailwindcss/forms':
|
||||||
specifier: ^0.5.11
|
specifier: ^0.5.11
|
||||||
version: 0.5.11(tailwindcss@4.3.0)
|
version: 0.5.11(tailwindcss@4.3.0)
|
||||||
@@ -41,10 +47,13 @@ importers:
|
|||||||
version: 0.5.20(tailwindcss@4.3.0)
|
version: 0.5.20(tailwindcss@4.3.0)
|
||||||
'@tailwindcss/vite':
|
'@tailwindcss/vite':
|
||||||
specifier: ^4.3.0
|
specifier: ^4.3.0
|
||||||
version: 4.3.0(vite@8.0.16(jiti@2.7.0))
|
version: 4.3.0(vite@8.0.16(@types/node@26.3.0)(jiti@2.7.0))
|
||||||
|
'@types/node':
|
||||||
|
specifier: ^26.3.0
|
||||||
|
version: 26.3.0
|
||||||
bits-ui:
|
bits-ui:
|
||||||
specifier: 2.18.1
|
specifier: 2.18.1
|
||||||
version: 2.18.1(@internationalized/date@3.12.2)(@sveltejs/kit@2.64.0(@sveltejs/vite-plugin-svelte@7.1.2(svelte@5.56.3)(vite@8.0.16(jiti@2.7.0)))(svelte@5.56.3)(typescript@6.0.3)(vite@8.0.16(jiti@2.7.0)))(svelte@5.56.3)
|
version: 2.18.1(@internationalized/date@3.12.2)(@sveltejs/kit@2.64.0(@sveltejs/vite-plugin-svelte@7.1.2(svelte@5.56.3)(vite@8.0.16(@types/node@26.3.0)(jiti@2.7.0)))(svelte@5.56.3)(typescript@6.0.3)(vite@8.0.16(@types/node@26.3.0)(jiti@2.7.0)))(svelte@5.56.3)
|
||||||
class-variance-authority:
|
class-variance-authority:
|
||||||
specifier: 0.7.1
|
specifier: 0.7.1
|
||||||
version: 0.7.1
|
version: 0.7.1
|
||||||
@@ -73,8 +82,8 @@ importers:
|
|||||||
specifier: 3.6.0
|
specifier: 3.6.0
|
||||||
version: 3.6.0
|
version: 3.6.0
|
||||||
tailwind-variants:
|
tailwind-variants:
|
||||||
specifier: ^3.2.2
|
specifier: ^3.3.1
|
||||||
version: 3.2.2(tailwind-merge@3.6.0)(tailwindcss@4.3.0)
|
version: 3.3.1(tailwind-merge@3.6.0)(tailwindcss@4.3.0)
|
||||||
tailwindcss:
|
tailwindcss:
|
||||||
specifier: ^4.3.0
|
specifier: ^4.3.0
|
||||||
version: 4.3.0
|
version: 4.3.0
|
||||||
@@ -86,10 +95,15 @@ importers:
|
|||||||
version: 6.0.3
|
version: 6.0.3
|
||||||
vite:
|
vite:
|
||||||
specifier: ^8.0.16
|
specifier: ^8.0.16
|
||||||
version: 8.0.16(jiti@2.7.0)
|
version: 8.0.16(@types/node@26.3.0)(jiti@2.7.0)
|
||||||
|
|
||||||
packages:
|
packages:
|
||||||
|
|
||||||
|
'@cantoo/pdf-lib@2.9.1':
|
||||||
|
resolution: {integrity: sha512-wAKXXDjp5lO0wS6d+k0FRpmWsULb95WhHKYx2fonTbv4zIfuIdCBz2nyTVVDGJpNDLl6o4w9aSTxQisBvvd60A==}
|
||||||
|
peerDependencies:
|
||||||
|
html-entities: ^2.3.2
|
||||||
|
|
||||||
'@emnapi/core@1.10.0':
|
'@emnapi/core@1.10.0':
|
||||||
resolution: {integrity: sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==}
|
resolution: {integrity: sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==}
|
||||||
|
|
||||||
@@ -225,6 +239,11 @@ packages:
|
|||||||
'@pdf-lib/upng@1.0.1':
|
'@pdf-lib/upng@1.0.1':
|
||||||
resolution: {integrity: sha512-dQK2FUMQtowVP00mtIksrlZhdFXQZPC+taih1q4CvPZ5vqdxR/LKBaFg0oAfzd1GlHZXXSPdQfzQnt+ViGvEIQ==}
|
resolution: {integrity: sha512-dQK2FUMQtowVP00mtIksrlZhdFXQZPC+taih1q4CvPZ5vqdxR/LKBaFg0oAfzd1GlHZXXSPdQfzQnt+ViGvEIQ==}
|
||||||
|
|
||||||
|
'@playwright/test@1.62.1':
|
||||||
|
resolution: {integrity: sha512-DTcUc8qii+cpHvtOwggMtBRMjKZHXYWdw8syRYu2vtzuq4Wxphqq4NfCs5Zt44L6mA8rfDfj+PHnxFc/FeK6mQ==}
|
||||||
|
engines: {node: '>=20'}
|
||||||
|
hasBin: true
|
||||||
|
|
||||||
'@polka/url@1.0.0-next.29':
|
'@polka/url@1.0.0-next.29':
|
||||||
resolution: {integrity: sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==}
|
resolution: {integrity: sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==}
|
||||||
|
|
||||||
@@ -665,6 +684,9 @@ packages:
|
|||||||
'@types/estree@1.0.9':
|
'@types/estree@1.0.9':
|
||||||
resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==}
|
resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==}
|
||||||
|
|
||||||
|
'@types/node@26.3.0':
|
||||||
|
resolution: {integrity: sha512-L3fgrnchriRC2ExBflb8j4uZZURHZfQsmQeyVzhjcHW4kkwVyo8/0h1B2MVzMTrYUJYu6G7EWs14hW/L9putqw==}
|
||||||
|
|
||||||
'@types/resolve@1.20.2':
|
'@types/resolve@1.20.2':
|
||||||
resolution: {integrity: sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q==}
|
resolution: {integrity: sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q==}
|
||||||
|
|
||||||
@@ -702,6 +724,20 @@ packages:
|
|||||||
resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==}
|
resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==}
|
||||||
engines: {node: '>=6'}
|
engines: {node: '>=6'}
|
||||||
|
|
||||||
|
color-convert@2.0.1:
|
||||||
|
resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==}
|
||||||
|
engines: {node: '>=7.0.0'}
|
||||||
|
|
||||||
|
color-name@1.1.4:
|
||||||
|
resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==}
|
||||||
|
|
||||||
|
color-string@1.9.1:
|
||||||
|
resolution: {integrity: sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg==}
|
||||||
|
|
||||||
|
color@4.2.3:
|
||||||
|
resolution: {integrity: sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A==}
|
||||||
|
engines: {node: '>=12.5.0'}
|
||||||
|
|
||||||
commander@14.0.3:
|
commander@14.0.3:
|
||||||
resolution: {integrity: sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==}
|
resolution: {integrity: sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==}
|
||||||
engines: {node: '>=20'}
|
engines: {node: '>=20'}
|
||||||
@@ -767,6 +803,11 @@ packages:
|
|||||||
fflate@0.8.3:
|
fflate@0.8.3:
|
||||||
resolution: {integrity: sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==}
|
resolution: {integrity: sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==}
|
||||||
|
|
||||||
|
fsevents@2.3.2:
|
||||||
|
resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==}
|
||||||
|
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
|
||||||
|
os: [darwin]
|
||||||
|
|
||||||
fsevents@2.3.3:
|
fsevents@2.3.3:
|
||||||
resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
|
resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
|
||||||
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
|
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
|
||||||
@@ -782,9 +823,15 @@ packages:
|
|||||||
resolution: {integrity: sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==}
|
resolution: {integrity: sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==}
|
||||||
engines: {node: '>= 0.4'}
|
engines: {node: '>= 0.4'}
|
||||||
|
|
||||||
|
html-entities@2.6.0:
|
||||||
|
resolution: {integrity: sha512-kig+rMn/QOVRvr7c86gQ8lWXq+Hkv6CbAH1hLu+RG338StTpE8Z0b44SDVaqVu7HGKf27frdmUYEs9hTUX/cLQ==}
|
||||||
|
|
||||||
inline-style-parser@0.2.7:
|
inline-style-parser@0.2.7:
|
||||||
resolution: {integrity: sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==}
|
resolution: {integrity: sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==}
|
||||||
|
|
||||||
|
is-arrayish@0.3.4:
|
||||||
|
resolution: {integrity: sha512-m6UrgzFVUYawGBh1dUsWR5M2Clqic9RVXC/9f8ceNlv2IcO9j9J/z8UoCLPqtsPBFNzEpfR3xftohbfqDx8EQA==}
|
||||||
|
|
||||||
is-core-module@2.16.2:
|
is-core-module@2.16.2:
|
||||||
resolution: {integrity: sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA==}
|
resolution: {integrity: sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA==}
|
||||||
engines: {node: '>= 0.4'}
|
engines: {node: '>= 0.4'}
|
||||||
@@ -910,6 +957,9 @@ packages:
|
|||||||
node-fetch-native@1.6.7:
|
node-fetch-native@1.6.7:
|
||||||
resolution: {integrity: sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q==}
|
resolution: {integrity: sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q==}
|
||||||
|
|
||||||
|
node-html-better-parser@1.5.9:
|
||||||
|
resolution: {integrity: sha512-z1I5UINMezJXYL9cH3h0a9KBth2G978gSLlfkpQ+CQzzVHVQy9gpARgm9eDsz1O4gn1HtgUqjdAIYxKFZm6uHQ==}
|
||||||
|
|
||||||
obug@2.1.2:
|
obug@2.1.2:
|
||||||
resolution: {integrity: sha512-AWGB9WFcRXOQs48Z/udjI5ZcZMHXwX8XPByNpOydgcGsDLIzjGizhoMWJyKAWze7AVW/2W1i+/gPX4YtKe5cyg==}
|
resolution: {integrity: sha512-AWGB9WFcRXOQs48Z/udjI5ZcZMHXwX8XPByNpOydgcGsDLIzjGizhoMWJyKAWze7AVW/2W1i+/gPX4YtKe5cyg==}
|
||||||
engines: {node: '>=12.20.0'}
|
engines: {node: '>=12.20.0'}
|
||||||
@@ -917,6 +967,9 @@ packages:
|
|||||||
pako@1.0.11:
|
pako@1.0.11:
|
||||||
resolution: {integrity: sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==}
|
resolution: {integrity: sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==}
|
||||||
|
|
||||||
|
pako@2.2.0:
|
||||||
|
resolution: {integrity: sha512-zJq6RP/5q+TO2OpFV3FHzlPnFjmkb7Nc99a5SNjJE+uu/PkpChs+NIZSSzbBoD+6kjiISXjfYdwj1ZRQ81dz/w==}
|
||||||
|
|
||||||
path-parse@1.0.7:
|
path-parse@1.0.7:
|
||||||
resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==}
|
resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==}
|
||||||
|
|
||||||
@@ -934,6 +987,16 @@ packages:
|
|||||||
resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==}
|
resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==}
|
||||||
engines: {node: '>=12'}
|
engines: {node: '>=12'}
|
||||||
|
|
||||||
|
playwright-core@1.62.1:
|
||||||
|
resolution: {integrity: sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw==}
|
||||||
|
engines: {node: '>=20'}
|
||||||
|
hasBin: true
|
||||||
|
|
||||||
|
playwright@1.62.1:
|
||||||
|
resolution: {integrity: sha512-0M+L3LAD8/nm554LOla9Ayx0j0tmFZ0FBcoQ7F1VuVHpM/XpiC8RcDzBQB8W5+hA8L22THxELzeF+2WcUzvcLg==}
|
||||||
|
engines: {node: '>=20'}
|
||||||
|
hasBin: true
|
||||||
|
|
||||||
postcss-selector-parser@6.0.10:
|
postcss-selector-parser@6.0.10:
|
||||||
resolution: {integrity: sha512-IQ7TZdoaqbT+LCpShg46jnZVlhWD2w6iQYAcYXfHARZ7X1t/UGhhceQDs5X0cGqKvYlHNOuv7Oa1xmb0oQuA3w==}
|
resolution: {integrity: sha512-IQ7TZdoaqbT+LCpShg46jnZVlhWD2w6iQYAcYXfHARZ7X1t/UGhhceQDs5X0cGqKvYlHNOuv7Oa1xmb0oQuA3w==}
|
||||||
engines: {node: '>=4'}
|
engines: {node: '>=4'}
|
||||||
@@ -1050,6 +1113,9 @@ packages:
|
|||||||
peerDependencies:
|
peerDependencies:
|
||||||
svelte: ^5.0.0
|
svelte: ^5.0.0
|
||||||
|
|
||||||
|
simple-swizzle@0.2.4:
|
||||||
|
resolution: {integrity: sha512-nAu1WFPQSMNr2Zn9PGSZK9AGn4t/y97lEm+MXTtUDwfP0ksAIX4nO+6ruD9Jwut4C49SB1Ws+fbXsm/yScWOHw==}
|
||||||
|
|
||||||
sirv@3.0.2:
|
sirv@3.0.2:
|
||||||
resolution: {integrity: sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g==}
|
resolution: {integrity: sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g==}
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
@@ -1089,15 +1155,17 @@ packages:
|
|||||||
tailwind-merge@3.6.0:
|
tailwind-merge@3.6.0:
|
||||||
resolution: {integrity: sha512-uxL7qAVQriqRQPAyK3pj66VqskWqoZ37PW94jwOTwNfq/z9oyu1V+eqrZqtR2+fCiXdYOZe/Modt8GtvqNzu+w==}
|
resolution: {integrity: sha512-uxL7qAVQriqRQPAyK3pj66VqskWqoZ37PW94jwOTwNfq/z9oyu1V+eqrZqtR2+fCiXdYOZe/Modt8GtvqNzu+w==}
|
||||||
|
|
||||||
tailwind-variants@3.2.2:
|
tailwind-variants@3.3.1:
|
||||||
resolution: {integrity: sha512-Mi4kHeMTLvKlM98XPnK+7HoBPmf4gygdFmqQPaDivc3DpYS6aIY6KiG/PgThrGvii5YZJqRsPz0aPyhoFzmZgg==}
|
resolution: {integrity: sha512-4pAvwUtM4HKBiRZftncAbpn6V9Hhwoa5Fl7O2u5zbp7Z5Cvu+/o/6+176WY3WCEES209543quG8zFIcXCsc5Jw==}
|
||||||
engines: {node: '>=16.x', pnpm: '>=7.x'}
|
engines: {node: '>=16.9.x', pnpm: '>=7.x'}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
tailwind-merge: '>=3.0.0'
|
tailwind-merge: '>=3.0.0'
|
||||||
tailwindcss: '*'
|
tailwindcss: '*'
|
||||||
peerDependenciesMeta:
|
peerDependenciesMeta:
|
||||||
tailwind-merge:
|
tailwind-merge:
|
||||||
optional: true
|
optional: true
|
||||||
|
tailwindcss:
|
||||||
|
optional: true
|
||||||
|
|
||||||
tailwindcss@4.3.0:
|
tailwindcss@4.3.0:
|
||||||
resolution: {integrity: sha512-y6nxMGB1nMW9R6k96e5gdIFzcfL/gTJRNaqGes1YvkLnPVXzWgbqFF2yLC0T8G774n24cx3Pe8XrKoniCOAH+Q==}
|
resolution: {integrity: sha512-y6nxMGB1nMW9R6k96e5gdIFzcfL/gTJRNaqGes1YvkLnPVXzWgbqFF2yLC0T8G774n24cx3Pe8XrKoniCOAH+Q==}
|
||||||
@@ -1128,6 +1196,9 @@ packages:
|
|||||||
engines: {node: '>=14.17'}
|
engines: {node: '>=14.17'}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
|
undici-types@8.3.0:
|
||||||
|
resolution: {integrity: sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==}
|
||||||
|
|
||||||
util-deprecate@1.0.2:
|
util-deprecate@1.0.2:
|
||||||
resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==}
|
resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==}
|
||||||
|
|
||||||
@@ -1187,6 +1258,16 @@ packages:
|
|||||||
|
|
||||||
snapshots:
|
snapshots:
|
||||||
|
|
||||||
|
'@cantoo/pdf-lib@2.9.1(html-entities@2.6.0)':
|
||||||
|
dependencies:
|
||||||
|
'@pdf-lib/standard-fonts': 1.0.0
|
||||||
|
'@pdf-lib/upng': 1.0.1
|
||||||
|
color: 4.2.3
|
||||||
|
html-entities: 2.6.0
|
||||||
|
node-html-better-parser: 1.5.9
|
||||||
|
pako: 2.2.0
|
||||||
|
tslib: 2.8.1
|
||||||
|
|
||||||
'@emnapi/core@1.10.0':
|
'@emnapi/core@1.10.0':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@emnapi/wasi-threads': 1.2.1
|
'@emnapi/wasi-threads': 1.2.1
|
||||||
@@ -1308,6 +1389,10 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
pako: 1.0.11
|
pako: 1.0.11
|
||||||
|
|
||||||
|
'@playwright/test@1.62.1':
|
||||||
|
dependencies:
|
||||||
|
playwright: 1.62.1
|
||||||
|
|
||||||
'@polka/url@1.0.0-next.29': {}
|
'@polka/url@1.0.0-next.29': {}
|
||||||
|
|
||||||
'@rolldown/binding-android-arm64@1.0.3':
|
'@rolldown/binding-android-arm64@1.0.3':
|
||||||
@@ -1485,20 +1570,20 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
acorn: 8.16.0
|
acorn: 8.16.0
|
||||||
|
|
||||||
'@sveltejs/adapter-node@5.5.7(@sveltejs/kit@2.64.0(@sveltejs/vite-plugin-svelte@7.1.2(svelte@5.56.3)(vite@8.0.16(jiti@2.7.0)))(svelte@5.56.3)(typescript@6.0.3)(vite@8.0.16(jiti@2.7.0)))':
|
'@sveltejs/adapter-node@5.5.7(@sveltejs/kit@2.64.0(@sveltejs/vite-plugin-svelte@7.1.2(svelte@5.56.3)(vite@8.0.16(@types/node@26.3.0)(jiti@2.7.0)))(svelte@5.56.3)(typescript@6.0.3)(vite@8.0.16(@types/node@26.3.0)(jiti@2.7.0)))':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@rollup/plugin-commonjs': 29.0.3(rollup@4.62.2)
|
'@rollup/plugin-commonjs': 29.0.3(rollup@4.62.2)
|
||||||
'@rollup/plugin-json': 6.1.0(rollup@4.62.2)
|
'@rollup/plugin-json': 6.1.0(rollup@4.62.2)
|
||||||
'@rollup/plugin-node-resolve': 16.0.3(rollup@4.62.2)
|
'@rollup/plugin-node-resolve': 16.0.3(rollup@4.62.2)
|
||||||
'@rollup/plugin-replace': 6.0.3(rollup@4.62.2)
|
'@rollup/plugin-replace': 6.0.3(rollup@4.62.2)
|
||||||
'@sveltejs/kit': 2.64.0(@sveltejs/vite-plugin-svelte@7.1.2(svelte@5.56.3)(vite@8.0.16(jiti@2.7.0)))(svelte@5.56.3)(typescript@6.0.3)(vite@8.0.16(jiti@2.7.0))
|
'@sveltejs/kit': 2.64.0(@sveltejs/vite-plugin-svelte@7.1.2(svelte@5.56.3)(vite@8.0.16(@types/node@26.3.0)(jiti@2.7.0)))(svelte@5.56.3)(typescript@6.0.3)(vite@8.0.16(@types/node@26.3.0)(jiti@2.7.0))
|
||||||
rollup: 4.62.2
|
rollup: 4.62.2
|
||||||
|
|
||||||
'@sveltejs/kit@2.64.0(@sveltejs/vite-plugin-svelte@7.1.2(svelte@5.56.3)(vite@8.0.16(jiti@2.7.0)))(svelte@5.56.3)(typescript@6.0.3)(vite@8.0.16(jiti@2.7.0))':
|
'@sveltejs/kit@2.64.0(@sveltejs/vite-plugin-svelte@7.1.2(svelte@5.56.3)(vite@8.0.16(@types/node@26.3.0)(jiti@2.7.0)))(svelte@5.56.3)(typescript@6.0.3)(vite@8.0.16(@types/node@26.3.0)(jiti@2.7.0))':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@standard-schema/spec': 1.1.0
|
'@standard-schema/spec': 1.1.0
|
||||||
'@sveltejs/acorn-typescript': 1.0.10(acorn@8.16.0)
|
'@sveltejs/acorn-typescript': 1.0.10(acorn@8.16.0)
|
||||||
'@sveltejs/vite-plugin-svelte': 7.1.2(svelte@5.56.3)(vite@8.0.16(jiti@2.7.0))
|
'@sveltejs/vite-plugin-svelte': 7.1.2(svelte@5.56.3)(vite@8.0.16(@types/node@26.3.0)(jiti@2.7.0))
|
||||||
'@types/cookie': 0.6.0
|
'@types/cookie': 0.6.0
|
||||||
acorn: 8.16.0
|
acorn: 8.16.0
|
||||||
cookie: 0.6.0
|
cookie: 0.6.0
|
||||||
@@ -1510,20 +1595,20 @@ snapshots:
|
|||||||
set-cookie-parser: 3.1.0
|
set-cookie-parser: 3.1.0
|
||||||
sirv: 3.0.2
|
sirv: 3.0.2
|
||||||
svelte: 5.56.3
|
svelte: 5.56.3
|
||||||
vite: 8.0.16(jiti@2.7.0)
|
vite: 8.0.16(@types/node@26.3.0)(jiti@2.7.0)
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
typescript: 6.0.3
|
typescript: 6.0.3
|
||||||
|
|
||||||
'@sveltejs/load-config@0.1.1': {}
|
'@sveltejs/load-config@0.1.1': {}
|
||||||
|
|
||||||
'@sveltejs/vite-plugin-svelte@7.1.2(svelte@5.56.3)(vite@8.0.16(jiti@2.7.0))':
|
'@sveltejs/vite-plugin-svelte@7.1.2(svelte@5.56.3)(vite@8.0.16(@types/node@26.3.0)(jiti@2.7.0))':
|
||||||
dependencies:
|
dependencies:
|
||||||
deepmerge: 4.3.1
|
deepmerge: 4.3.1
|
||||||
magic-string: 0.30.21
|
magic-string: 0.30.21
|
||||||
obug: 2.1.2
|
obug: 2.1.2
|
||||||
svelte: 5.56.3
|
svelte: 5.56.3
|
||||||
vite: 8.0.16(jiti@2.7.0)
|
vite: 8.0.16(@types/node@26.3.0)(jiti@2.7.0)
|
||||||
vitefu: 1.1.3(vite@8.0.16(jiti@2.7.0))
|
vitefu: 1.1.3(vite@8.0.16(@types/node@26.3.0)(jiti@2.7.0))
|
||||||
|
|
||||||
'@swc/helpers@0.5.23':
|
'@swc/helpers@0.5.23':
|
||||||
dependencies:
|
dependencies:
|
||||||
@@ -1600,12 +1685,12 @@ snapshots:
|
|||||||
postcss-selector-parser: 6.0.10
|
postcss-selector-parser: 6.0.10
|
||||||
tailwindcss: 4.3.0
|
tailwindcss: 4.3.0
|
||||||
|
|
||||||
'@tailwindcss/vite@4.3.0(vite@8.0.16(jiti@2.7.0))':
|
'@tailwindcss/vite@4.3.0(vite@8.0.16(@types/node@26.3.0)(jiti@2.7.0))':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@tailwindcss/node': 4.3.0
|
'@tailwindcss/node': 4.3.0
|
||||||
'@tailwindcss/oxide': 4.3.0
|
'@tailwindcss/oxide': 4.3.0
|
||||||
tailwindcss: 4.3.0
|
tailwindcss: 4.3.0
|
||||||
vite: 8.0.16(jiti@2.7.0)
|
vite: 8.0.16(@types/node@26.3.0)(jiti@2.7.0)
|
||||||
|
|
||||||
'@tybys/wasm-util@0.10.2':
|
'@tybys/wasm-util@0.10.2':
|
||||||
dependencies:
|
dependencies:
|
||||||
@@ -1616,6 +1701,10 @@ snapshots:
|
|||||||
|
|
||||||
'@types/estree@1.0.9': {}
|
'@types/estree@1.0.9': {}
|
||||||
|
|
||||||
|
'@types/node@26.3.0':
|
||||||
|
dependencies:
|
||||||
|
undici-types: 8.3.0
|
||||||
|
|
||||||
'@types/resolve@1.20.2': {}
|
'@types/resolve@1.20.2': {}
|
||||||
|
|
||||||
'@types/trusted-types@2.0.7': {}
|
'@types/trusted-types@2.0.7': {}
|
||||||
@@ -1626,15 +1715,15 @@ snapshots:
|
|||||||
|
|
||||||
axobject-query@4.1.0: {}
|
axobject-query@4.1.0: {}
|
||||||
|
|
||||||
bits-ui@2.18.1(@internationalized/date@3.12.2)(@sveltejs/kit@2.64.0(@sveltejs/vite-plugin-svelte@7.1.2(svelte@5.56.3)(vite@8.0.16(jiti@2.7.0)))(svelte@5.56.3)(typescript@6.0.3)(vite@8.0.16(jiti@2.7.0)))(svelte@5.56.3):
|
bits-ui@2.18.1(@internationalized/date@3.12.2)(@sveltejs/kit@2.64.0(@sveltejs/vite-plugin-svelte@7.1.2(svelte@5.56.3)(vite@8.0.16(@types/node@26.3.0)(jiti@2.7.0)))(svelte@5.56.3)(typescript@6.0.3)(vite@8.0.16(@types/node@26.3.0)(jiti@2.7.0)))(svelte@5.56.3):
|
||||||
dependencies:
|
dependencies:
|
||||||
'@floating-ui/core': 1.7.5
|
'@floating-ui/core': 1.7.5
|
||||||
'@floating-ui/dom': 1.7.6
|
'@floating-ui/dom': 1.7.6
|
||||||
'@internationalized/date': 3.12.2
|
'@internationalized/date': 3.12.2
|
||||||
esm-env: 1.2.2
|
esm-env: 1.2.2
|
||||||
runed: 0.35.1(@sveltejs/kit@2.64.0(@sveltejs/vite-plugin-svelte@7.1.2(svelte@5.56.3)(vite@8.0.16(jiti@2.7.0)))(svelte@5.56.3)(typescript@6.0.3)(vite@8.0.16(jiti@2.7.0)))(svelte@5.56.3)
|
runed: 0.35.1(@sveltejs/kit@2.64.0(@sveltejs/vite-plugin-svelte@7.1.2(svelte@5.56.3)(vite@8.0.16(@types/node@26.3.0)(jiti@2.7.0)))(svelte@5.56.3)(typescript@6.0.3)(vite@8.0.16(@types/node@26.3.0)(jiti@2.7.0)))(svelte@5.56.3)
|
||||||
svelte: 5.56.3
|
svelte: 5.56.3
|
||||||
svelte-toolbelt: 0.10.6(@sveltejs/kit@2.64.0(@sveltejs/vite-plugin-svelte@7.1.2(svelte@5.56.3)(vite@8.0.16(jiti@2.7.0)))(svelte@5.56.3)(typescript@6.0.3)(vite@8.0.16(jiti@2.7.0)))(svelte@5.56.3)
|
svelte-toolbelt: 0.10.6(@sveltejs/kit@2.64.0(@sveltejs/vite-plugin-svelte@7.1.2(svelte@5.56.3)(vite@8.0.16(@types/node@26.3.0)(jiti@2.7.0)))(svelte@5.56.3)(typescript@6.0.3)(vite@8.0.16(@types/node@26.3.0)(jiti@2.7.0)))(svelte@5.56.3)
|
||||||
tabbable: 6.4.0
|
tabbable: 6.4.0
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- '@sveltejs/kit'
|
- '@sveltejs/kit'
|
||||||
@@ -1649,6 +1738,22 @@ snapshots:
|
|||||||
|
|
||||||
clsx@2.1.1: {}
|
clsx@2.1.1: {}
|
||||||
|
|
||||||
|
color-convert@2.0.1:
|
||||||
|
dependencies:
|
||||||
|
color-name: 1.1.4
|
||||||
|
|
||||||
|
color-name@1.1.4: {}
|
||||||
|
|
||||||
|
color-string@1.9.1:
|
||||||
|
dependencies:
|
||||||
|
color-name: 1.1.4
|
||||||
|
simple-swizzle: 0.2.4
|
||||||
|
|
||||||
|
color@4.2.3:
|
||||||
|
dependencies:
|
||||||
|
color-convert: 2.0.1
|
||||||
|
color-string: 1.9.1
|
||||||
|
|
||||||
commander@14.0.3: {}
|
commander@14.0.3: {}
|
||||||
|
|
||||||
commondir@1.0.1: {}
|
commondir@1.0.1: {}
|
||||||
@@ -1686,6 +1791,9 @@ snapshots:
|
|||||||
|
|
||||||
fflate@0.8.3: {}
|
fflate@0.8.3: {}
|
||||||
|
|
||||||
|
fsevents@2.3.2:
|
||||||
|
optional: true
|
||||||
|
|
||||||
fsevents@2.3.3:
|
fsevents@2.3.3:
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
@@ -1697,8 +1805,12 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
function-bind: 1.1.2
|
function-bind: 1.1.2
|
||||||
|
|
||||||
|
html-entities@2.6.0: {}
|
||||||
|
|
||||||
inline-style-parser@0.2.7: {}
|
inline-style-parser@0.2.7: {}
|
||||||
|
|
||||||
|
is-arrayish@0.3.4: {}
|
||||||
|
|
||||||
is-core-module@2.16.2:
|
is-core-module@2.16.2:
|
||||||
dependencies:
|
dependencies:
|
||||||
hasown: 2.0.4
|
hasown: 2.0.4
|
||||||
@@ -1784,10 +1896,16 @@ snapshots:
|
|||||||
|
|
||||||
node-fetch-native@1.6.7: {}
|
node-fetch-native@1.6.7: {}
|
||||||
|
|
||||||
|
node-html-better-parser@1.5.9:
|
||||||
|
dependencies:
|
||||||
|
html-entities: 2.6.0
|
||||||
|
|
||||||
obug@2.1.2: {}
|
obug@2.1.2: {}
|
||||||
|
|
||||||
pako@1.0.11: {}
|
pako@1.0.11: {}
|
||||||
|
|
||||||
|
pako@2.2.0: {}
|
||||||
|
|
||||||
path-parse@1.0.7: {}
|
path-parse@1.0.7: {}
|
||||||
|
|
||||||
pdf-lib@1.17.1:
|
pdf-lib@1.17.1:
|
||||||
@@ -1805,6 +1923,14 @@ snapshots:
|
|||||||
|
|
||||||
picomatch@4.0.4: {}
|
picomatch@4.0.4: {}
|
||||||
|
|
||||||
|
playwright-core@1.62.1: {}
|
||||||
|
|
||||||
|
playwright@1.62.1:
|
||||||
|
dependencies:
|
||||||
|
playwright-core: 1.62.1
|
||||||
|
optionalDependencies:
|
||||||
|
fsevents: 2.3.2
|
||||||
|
|
||||||
postcss-selector-parser@6.0.10:
|
postcss-selector-parser@6.0.10:
|
||||||
dependencies:
|
dependencies:
|
||||||
cssesc: 3.0.0
|
cssesc: 3.0.0
|
||||||
@@ -1890,14 +2016,14 @@ snapshots:
|
|||||||
'@rollup/rollup-win32-x64-msvc': 4.62.2
|
'@rollup/rollup-win32-x64-msvc': 4.62.2
|
||||||
fsevents: 2.3.3
|
fsevents: 2.3.3
|
||||||
|
|
||||||
runed@0.35.1(@sveltejs/kit@2.64.0(@sveltejs/vite-plugin-svelte@7.1.2(svelte@5.56.3)(vite@8.0.16(jiti@2.7.0)))(svelte@5.56.3)(typescript@6.0.3)(vite@8.0.16(jiti@2.7.0)))(svelte@5.56.3):
|
runed@0.35.1(@sveltejs/kit@2.64.0(@sveltejs/vite-plugin-svelte@7.1.2(svelte@5.56.3)(vite@8.0.16(@types/node@26.3.0)(jiti@2.7.0)))(svelte@5.56.3)(typescript@6.0.3)(vite@8.0.16(@types/node@26.3.0)(jiti@2.7.0)))(svelte@5.56.3):
|
||||||
dependencies:
|
dependencies:
|
||||||
dequal: 2.0.3
|
dequal: 2.0.3
|
||||||
esm-env: 1.2.2
|
esm-env: 1.2.2
|
||||||
lz-string: 1.5.0
|
lz-string: 1.5.0
|
||||||
svelte: 5.56.3
|
svelte: 5.56.3
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
'@sveltejs/kit': 2.64.0(@sveltejs/vite-plugin-svelte@7.1.2(svelte@5.56.3)(vite@8.0.16(jiti@2.7.0)))(svelte@5.56.3)(typescript@6.0.3)(vite@8.0.16(jiti@2.7.0))
|
'@sveltejs/kit': 2.64.0(@sveltejs/vite-plugin-svelte@7.1.2(svelte@5.56.3)(vite@8.0.16(@types/node@26.3.0)(jiti@2.7.0)))(svelte@5.56.3)(typescript@6.0.3)(vite@8.0.16(@types/node@26.3.0)(jiti@2.7.0))
|
||||||
|
|
||||||
sade@1.8.1:
|
sade@1.8.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
@@ -1913,6 +2039,10 @@ snapshots:
|
|||||||
svelte: 5.56.3
|
svelte: 5.56.3
|
||||||
tailwind-merge: 3.6.0
|
tailwind-merge: 3.6.0
|
||||||
|
|
||||||
|
simple-swizzle@0.2.4:
|
||||||
|
dependencies:
|
||||||
|
is-arrayish: 0.3.4
|
||||||
|
|
||||||
sirv@3.0.2:
|
sirv@3.0.2:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@polka/url': 1.0.0-next.29
|
'@polka/url': 1.0.0-next.29
|
||||||
@@ -1940,10 +2070,10 @@ snapshots:
|
|||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- picomatch
|
- picomatch
|
||||||
|
|
||||||
svelte-toolbelt@0.10.6(@sveltejs/kit@2.64.0(@sveltejs/vite-plugin-svelte@7.1.2(svelte@5.56.3)(vite@8.0.16(jiti@2.7.0)))(svelte@5.56.3)(typescript@6.0.3)(vite@8.0.16(jiti@2.7.0)))(svelte@5.56.3):
|
svelte-toolbelt@0.10.6(@sveltejs/kit@2.64.0(@sveltejs/vite-plugin-svelte@7.1.2(svelte@5.56.3)(vite@8.0.16(@types/node@26.3.0)(jiti@2.7.0)))(svelte@5.56.3)(typescript@6.0.3)(vite@8.0.16(@types/node@26.3.0)(jiti@2.7.0)))(svelte@5.56.3):
|
||||||
dependencies:
|
dependencies:
|
||||||
clsx: 2.1.1
|
clsx: 2.1.1
|
||||||
runed: 0.35.1(@sveltejs/kit@2.64.0(@sveltejs/vite-plugin-svelte@7.1.2(svelte@5.56.3)(vite@8.0.16(jiti@2.7.0)))(svelte@5.56.3)(typescript@6.0.3)(vite@8.0.16(jiti@2.7.0)))(svelte@5.56.3)
|
runed: 0.35.1(@sveltejs/kit@2.64.0(@sveltejs/vite-plugin-svelte@7.1.2(svelte@5.56.3)(vite@8.0.16(@types/node@26.3.0)(jiti@2.7.0)))(svelte@5.56.3)(typescript@6.0.3)(vite@8.0.16(@types/node@26.3.0)(jiti@2.7.0)))(svelte@5.56.3)
|
||||||
style-to-object: 1.0.14
|
style-to-object: 1.0.14
|
||||||
svelte: 5.56.3
|
svelte: 5.56.3
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
@@ -1974,11 +2104,10 @@ snapshots:
|
|||||||
|
|
||||||
tailwind-merge@3.6.0: {}
|
tailwind-merge@3.6.0: {}
|
||||||
|
|
||||||
tailwind-variants@3.2.2(tailwind-merge@3.6.0)(tailwindcss@4.3.0):
|
tailwind-variants@3.3.1(tailwind-merge@3.6.0)(tailwindcss@4.3.0):
|
||||||
dependencies:
|
|
||||||
tailwindcss: 4.3.0
|
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
tailwind-merge: 3.6.0
|
tailwind-merge: 3.6.0
|
||||||
|
tailwindcss: 4.3.0
|
||||||
|
|
||||||
tailwindcss@4.3.0: {}
|
tailwindcss@4.3.0: {}
|
||||||
|
|
||||||
@@ -1999,9 +2128,11 @@ snapshots:
|
|||||||
|
|
||||||
typescript@6.0.3: {}
|
typescript@6.0.3: {}
|
||||||
|
|
||||||
|
undici-types@8.3.0: {}
|
||||||
|
|
||||||
util-deprecate@1.0.2: {}
|
util-deprecate@1.0.2: {}
|
||||||
|
|
||||||
vite@8.0.16(jiti@2.7.0):
|
vite@8.0.16(@types/node@26.3.0)(jiti@2.7.0):
|
||||||
dependencies:
|
dependencies:
|
||||||
lightningcss: 1.32.0
|
lightningcss: 1.32.0
|
||||||
picomatch: 4.0.4
|
picomatch: 4.0.4
|
||||||
@@ -2009,11 +2140,12 @@ snapshots:
|
|||||||
rolldown: 1.0.3
|
rolldown: 1.0.3
|
||||||
tinyglobby: 0.2.17
|
tinyglobby: 0.2.17
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
|
'@types/node': 26.3.0
|
||||||
fsevents: 2.3.3
|
fsevents: 2.3.3
|
||||||
jiti: 2.7.0
|
jiti: 2.7.0
|
||||||
|
|
||||||
vitefu@1.1.3(vite@8.0.16(jiti@2.7.0)):
|
vitefu@1.1.3(vite@8.0.16(@types/node@26.3.0)(jiti@2.7.0)):
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
vite: 8.0.16(jiti@2.7.0)
|
vite: 8.0.16(@types/node@26.3.0)(jiti@2.7.0)
|
||||||
|
|
||||||
zimmerframe@1.1.4: {}
|
zimmerframe@1.1.4: {}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
<meta charset="utf-8" />
|
<meta charset="utf-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
<meta name="text-scale" content="scale" />
|
<meta name="text-scale" content="scale" />
|
||||||
|
<link rel="icon" href="%sveltekit.assets%/favicon.svg" type="image/svg+xml" />
|
||||||
%sveltekit.head%
|
%sveltekit.head%
|
||||||
</head>
|
</head>
|
||||||
<body data-sveltekit-preload-data="hover">
|
<body data-sveltekit-preload-data="hover">
|
||||||
|
|||||||
+20
-21
@@ -1,31 +1,30 @@
|
|||||||
export function ripple(node: HTMLElement) {
|
export function ripple(node: HTMLElement) {
|
||||||
function handleClick(event: MouseEvent) {
|
function handleClick(event: MouseEvent) {
|
||||||
const circle = document.createElement("span");
|
const circle = document.createElement('span');
|
||||||
|
|
||||||
const rect = node.getBoundingClientRect();
|
const rect = node.getBoundingClientRect();
|
||||||
const size = Math.max(rect.width, rect.height);
|
const size = Math.max(rect.width, rect.height);
|
||||||
|
|
||||||
circle.style.width = `${size}px`;
|
circle.style.width = `${size}px`;
|
||||||
circle.style.height = `${size}px`;
|
circle.style.height = `${size}px`;
|
||||||
|
|
||||||
circle.style.left = `${event.clientX - rect.left - size / 2}px`;
|
circle.style.left = `${event.clientX - rect.left - size / 2}px`;
|
||||||
circle.style.top = `${event.clientY - rect.top - size / 2}px`;
|
circle.style.top = `${event.clientY - rect.top - size / 2}px`;
|
||||||
|
|
||||||
circle.className =
|
circle.className = 'absolute rounded-full bg-white/30 animate-ripple pointer-events-none';
|
||||||
"absolute rounded-full bg-white/30 animate-ripple pointer-events-none";
|
|
||||||
|
|
||||||
node.appendChild(circle);
|
node.appendChild(circle);
|
||||||
|
|
||||||
circle.addEventListener("animationend", () => {
|
circle.addEventListener('animationend', () => {
|
||||||
circle.remove();
|
circle.remove();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
node.addEventListener("click", handleClick);
|
node.addEventListener('click', handleClick);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
destroy() {
|
destroy() {
|
||||||
node.removeEventListener("click", handleClick);
|
node.removeEventListener('click', handleClick);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
export type BatchItemStatus = 'pending' | 'processing' | 'done' | 'error';
|
||||||
|
|
||||||
|
export type BatchItem = {
|
||||||
|
id: string;
|
||||||
|
file: File;
|
||||||
|
status: BatchItemStatus;
|
||||||
|
resultBytes?: Uint8Array;
|
||||||
|
resultName?: string;
|
||||||
|
errorMessage?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type BatchProcessResult = { bytes: Uint8Array; name: string };
|
||||||
|
|
||||||
|
export type BatchRunOptions = {
|
||||||
|
/** Error message written to a row when `process` throws for it. */
|
||||||
|
errorMessage?: string;
|
||||||
|
/**
|
||||||
|
* Optional predicate consulted before each item; when it returns `false`
|
||||||
|
* (for example because the row was removed mid-run), the item is skipped.
|
||||||
|
*/
|
||||||
|
shouldProcess?: (id: string) => boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const createBatchItem = (file: File): BatchItem => ({
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
file,
|
||||||
|
status: 'pending'
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Processes the given items strictly one after another so only one PDF is in
|
||||||
|
* flight at a time. Never throws: a failing item is marked as an error row and
|
||||||
|
* the queue continues with the remaining files.
|
||||||
|
*/
|
||||||
|
export const runBatch = async (
|
||||||
|
items: BatchItem[],
|
||||||
|
process: (file: File) => Promise<BatchProcessResult>,
|
||||||
|
update: (id: string, patch: Partial<BatchItem>) => void,
|
||||||
|
options: BatchRunOptions = {}
|
||||||
|
): Promise<void> => {
|
||||||
|
const { errorMessage = 'Die Datei konnte nicht verarbeitet werden.', shouldProcess } = options;
|
||||||
|
|
||||||
|
for (const item of items) {
|
||||||
|
if (shouldProcess && !shouldProcess(item.id)) continue;
|
||||||
|
|
||||||
|
update(item.id, { status: 'processing', errorMessage: null });
|
||||||
|
try {
|
||||||
|
const result = await process(item.file);
|
||||||
|
update(item.id, {
|
||||||
|
status: 'done',
|
||||||
|
resultBytes: result.bytes,
|
||||||
|
resultName: result.name,
|
||||||
|
errorMessage: null
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Batch processing failed for ${item.file.name}:`, error);
|
||||||
|
update(item.id, {
|
||||||
|
status: 'error',
|
||||||
|
resultBytes: undefined,
|
||||||
|
resultName: undefined,
|
||||||
|
errorMessage
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const formatBytes = (bytes: number) => {
|
||||||
|
if (bytes < 1024) return `${bytes} B`;
|
||||||
|
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||||
|
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||||
|
};
|
||||||
@@ -0,0 +1,176 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { Download, RotateCcw, Trash2 } from '@lucide/svelte';
|
||||||
|
import { zipSync } from 'fflate';
|
||||||
|
import Button from '$lib/components/ui/button.svelte';
|
||||||
|
import { downloadBlob } from '$lib/download';
|
||||||
|
import { formatBytes, type BatchItem } from '$lib/batch';
|
||||||
|
import { RASTERIZATION_WARNING_BYTES } from '$lib/rasterization-limits';
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
items: BatchItem[];
|
||||||
|
/** Base file name of the bulk ZIP, e.g. `komprimiert` → `komprimiert_2026-02-12.zip`. */
|
||||||
|
zipBaseName: string;
|
||||||
|
onDownload: (item: BatchItem) => void;
|
||||||
|
onRemove: (id: string) => void;
|
||||||
|
onRetry: (id: string) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
let { items, zipBaseName, onDownload, onRemove, onRetry }: Props = $props();
|
||||||
|
|
||||||
|
const doneItems = $derived(items.filter((item) => item.status === 'done'));
|
||||||
|
const totalResultBytes = $derived(
|
||||||
|
doneItems.reduce((sum, item) => sum + (item.resultBytes?.byteLength ?? 0), 0)
|
||||||
|
);
|
||||||
|
const processingIndex = $derived(items.findIndex((item) => item.status === 'processing'));
|
||||||
|
const showMemoryWarning = $derived(
|
||||||
|
items.length > 20 || items.some((item) => item.file.size > RASTERIZATION_WARNING_BYTES)
|
||||||
|
);
|
||||||
|
|
||||||
|
const uniqueEntryName = (name: string, usedNames: Set<string>) => {
|
||||||
|
if (!usedNames.has(name)) {
|
||||||
|
usedNames.add(name);
|
||||||
|
return name;
|
||||||
|
}
|
||||||
|
const dotIndex = name.lastIndexOf('.');
|
||||||
|
const base = dotIndex > 0 ? name.slice(0, dotIndex) : name;
|
||||||
|
const extension = dotIndex > 0 ? name.slice(dotIndex) : '';
|
||||||
|
let counter = 2;
|
||||||
|
let candidate = `${base}_2${extension}`;
|
||||||
|
while (usedNames.has(candidate)) {
|
||||||
|
counter += 1;
|
||||||
|
candidate = `${base}_${counter}${extension}`;
|
||||||
|
}
|
||||||
|
usedNames.add(candidate);
|
||||||
|
return candidate;
|
||||||
|
};
|
||||||
|
|
||||||
|
const downloadZip = () => {
|
||||||
|
const entries: Record<string, Uint8Array> = {};
|
||||||
|
const usedNames = new Set<string>();
|
||||||
|
for (const item of doneItems) {
|
||||||
|
if (!item.resultBytes || !item.resultName) continue;
|
||||||
|
entries[uniqueEntryName(item.resultName, usedNames)] = item.resultBytes;
|
||||||
|
}
|
||||||
|
if (Object.keys(entries).length === 0) return;
|
||||||
|
const dateStamp = new Date().toISOString().slice(0, 10);
|
||||||
|
downloadBlob(zipSync(entries), `${zipBaseName}_${dateStamp}.zip`, 'application/zip');
|
||||||
|
};
|
||||||
|
|
||||||
|
const statusChipClass = (status: BatchItem['status']) => {
|
||||||
|
switch (status) {
|
||||||
|
case 'processing':
|
||||||
|
return 'bg-amber-100 text-amber-800';
|
||||||
|
case 'done':
|
||||||
|
return 'bg-emerald-100 text-emerald-800';
|
||||||
|
case 'error':
|
||||||
|
return 'bg-red-100 text-red-800';
|
||||||
|
default:
|
||||||
|
return 'bg-accent text-primary';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const statusLabel = (status: BatchItem['status']) => {
|
||||||
|
switch (status) {
|
||||||
|
case 'processing':
|
||||||
|
return 'Verarbeite…';
|
||||||
|
case 'done':
|
||||||
|
return 'Fertig ✓';
|
||||||
|
case 'error':
|
||||||
|
return 'Fehler';
|
||||||
|
default:
|
||||||
|
return 'Wartet';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="rounded-2xl border border-border bg-card p-4 space-y-3">
|
||||||
|
<div class="flex flex-wrap items-center justify-between gap-2">
|
||||||
|
<h2 class="text-lg font-semibold text-foreground">Dateien ({items.length})</h2>
|
||||||
|
{#if doneItems.length > 0}
|
||||||
|
<p class="text-sm text-primary">
|
||||||
|
Ergebnisse gesamt: <span class="font-semibold">{formatBytes(totalResultBytes)}</span>
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if processingIndex !== -1}
|
||||||
|
<p class="text-sm text-primary" role="status">
|
||||||
|
Datei {processingIndex + 1} von {items.length} wird verarbeitet…
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if showMemoryWarning}
|
||||||
|
<div class="rounded-lg border border-amber-200 bg-amber-50 p-3 text-sm text-amber-800">
|
||||||
|
Bei sehr vielen oder sehr großen Dateien kann der Arbeitsspeicher knapp werden. Laden Sie
|
||||||
|
fertige Ergebnisse früh herunter und entfernen Sie sie aus der Liste.
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<ul class="divide-y divide-border">
|
||||||
|
{#each items as item (item.id)}
|
||||||
|
<li class="flex flex-wrap items-center gap-3 py-3" data-batch-item={item.file.name}>
|
||||||
|
<div class="min-w-0 flex-1">
|
||||||
|
<p class="truncate text-sm font-medium text-foreground">{item.file.name}</p>
|
||||||
|
<p class="text-xs text-primary">
|
||||||
|
{formatBytes(item.file.size)}
|
||||||
|
{#if item.status === 'done' && item.resultBytes}
|
||||||
|
→ {formatBytes(item.resultBytes.length)}
|
||||||
|
{/if}
|
||||||
|
</p>
|
||||||
|
{#if item.status === 'error' && item.errorMessage}
|
||||||
|
<p class="mt-0.5 text-xs text-red-700" role="alert">{item.errorMessage}</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<span
|
||||||
|
class="shrink-0 rounded-full px-2.5 py-0.5 text-xs font-semibold {statusChipClass(
|
||||||
|
item.status
|
||||||
|
)}"
|
||||||
|
>
|
||||||
|
{statusLabel(item.status)}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<div class="flex shrink-0 items-center gap-1">
|
||||||
|
{#if item.status === 'done' && item.resultBytes && item.resultName}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="rounded-lg p-2 text-primary transition hover:bg-accent focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-400"
|
||||||
|
aria-label="{item.file.name} herunterladen"
|
||||||
|
title="{item.file.name} herunterladen"
|
||||||
|
onclick={() => onDownload(item)}
|
||||||
|
>
|
||||||
|
<Download class="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
{#if item.status === 'error'}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="rounded-lg p-2 text-primary transition hover:bg-accent focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-400"
|
||||||
|
aria-label="{item.file.name} erneut versuchen"
|
||||||
|
title="{item.file.name} erneut versuchen"
|
||||||
|
onclick={() => onRetry(item.id)}
|
||||||
|
>
|
||||||
|
<RotateCcw class="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="rounded-lg p-2 text-primary transition hover:bg-accent focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-400 disabled:cursor-not-allowed disabled:opacity-40"
|
||||||
|
aria-label="{item.file.name} entfernen"
|
||||||
|
title="{item.file.name} entfernen"
|
||||||
|
disabled={item.status === 'processing'}
|
||||||
|
onclick={() => onRemove(item.id)}
|
||||||
|
>
|
||||||
|
<Trash2 class="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
{#if doneItems.length >= 2}
|
||||||
|
<div class="flex justify-end pt-1">
|
||||||
|
<Button onclick={downloadZip} icon={Download}>Alle herunterladen</Button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,332 @@
|
|||||||
|
<script lang="ts" module>
|
||||||
|
export type ZipProcessingJobStatus = 'processing' | 'complete' | 'error';
|
||||||
|
|
||||||
|
export type ZipProcessingJob = {
|
||||||
|
id: string;
|
||||||
|
file: File;
|
||||||
|
status: ZipProcessingJobStatus;
|
||||||
|
archiveCount?: number;
|
||||||
|
error?: string;
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
import { Check, CircleDashed, TriangleAlert } from '@lucide/svelte';
|
||||||
|
import Button from '$lib/components/ui/button.svelte';
|
||||||
|
import * as Card from '$lib/components/ui/card/index';
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
jobs: ZipProcessingJob[];
|
||||||
|
compact?: boolean;
|
||||||
|
onRetry: (jobId: string) => void;
|
||||||
|
onRemove: (jobId: string) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
let { jobs, compact = false, onRetry, onRemove }: Props = $props();
|
||||||
|
|
||||||
|
let processingCount = $derived(jobs.filter((job) => job.status === 'processing').length);
|
||||||
|
let completedCount = $derived(jobs.filter((job) => job.status === 'complete').length);
|
||||||
|
let failedJobs = $derived(jobs.filter((job) => job.status === 'error'));
|
||||||
|
let completionPercent = $derived(jobs.length === 0 ? 0 : (completedCount / jobs.length) * 100);
|
||||||
|
|
||||||
|
const formatFileSize = (size: number) => {
|
||||||
|
if (size < 1024 * 1024) return `${Math.max(1, Math.round(size / 1024))} KB`;
|
||||||
|
return `${(size / (1024 * 1024)).toFixed(1)} MB`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const archiveCountLabel = (count: number | undefined) => {
|
||||||
|
if (count === undefined) return 'Bereit';
|
||||||
|
return `${count} Archiv${count === 1 ? '' : 'e'} bereit`;
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if compact}
|
||||||
|
<Card.Root size="sm" class="border-l-4 border-l-secondary">
|
||||||
|
<p class="sr-only" role="status" aria-live="polite">
|
||||||
|
{completedCount} von {jobs.length} Dateien bereit, {processingCount} in Bearbeitung,
|
||||||
|
{failedJobs.length} fehlgeschlagen.
|
||||||
|
</p>
|
||||||
|
<Card.Content class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||||
|
<div class="flex min-w-0 items-center gap-3">
|
||||||
|
<div class="relative grid size-10 shrink-0 place-items-center" aria-hidden="true">
|
||||||
|
<div class="compact-folder"></div>
|
||||||
|
{#if processingCount > 0}
|
||||||
|
<CircleDashed
|
||||||
|
class="absolute -right-0.5 -bottom-0.5 size-4 animate-spin text-secondary"
|
||||||
|
/>
|
||||||
|
{:else}
|
||||||
|
<TriangleAlert class="absolute -right-0.5 -bottom-0.5 size-4 text-destructive" />
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<div class="min-w-0">
|
||||||
|
<p class="font-bold text-primary-900">
|
||||||
|
{#if processingCount > 0}
|
||||||
|
{processingCount} ZIP-Datei{processingCount === 1 ? '' : 'en'}
|
||||||
|
{processingCount === 1 ? ' wird' : ' werden'} noch geöffnet
|
||||||
|
{:else}
|
||||||
|
{failedJobs.length} ZIP-Datei{failedJobs.length === 1 ? '' : 'en'} konnte{failedJobs.length ===
|
||||||
|
1
|
||||||
|
? ''
|
||||||
|
: 'n'} nicht geöffnet werden
|
||||||
|
{/if}
|
||||||
|
</p>
|
||||||
|
<p class="truncate text-[13px] text-primary-700">
|
||||||
|
{jobs
|
||||||
|
.filter((job) =>
|
||||||
|
processingCount > 0 ? job.status === 'processing' : job.status === 'error'
|
||||||
|
)
|
||||||
|
.map((job) => job.file.name)
|
||||||
|
.join(', ')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p class="shrink-0 text-[12px] font-medium text-primary-700">
|
||||||
|
Verarbeitung nur in Ihrem Browser
|
||||||
|
</p>
|
||||||
|
</Card.Content>
|
||||||
|
|
||||||
|
{#if failedJobs.length > 0}
|
||||||
|
<Card.Footer class="flex flex-col items-stretch gap-2">
|
||||||
|
{#each failedJobs as job (job.id)}
|
||||||
|
<div class="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||||||
|
<p class="min-w-0 text-[13px] font-medium text-destructive">
|
||||||
|
<span class="font-bold">{job.file.name}</span> konnte nicht geöffnet werden.
|
||||||
|
</p>
|
||||||
|
<div class="flex shrink-0 gap-1">
|
||||||
|
<Button variant="tertiary" class="h-8 min-w-0 px-3" onclick={() => onRetry(job.id)}
|
||||||
|
>Erneut versuchen</Button
|
||||||
|
>
|
||||||
|
<Button variant="tertiary" class="h-8 min-w-0 px-3" onclick={() => onRemove(job.id)}
|
||||||
|
>Entfernen</Button
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</Card.Footer>
|
||||||
|
{/if}
|
||||||
|
</Card.Root>
|
||||||
|
{:else}
|
||||||
|
<Card.Root class="mx-auto w-full max-w-2xl shadow-sm">
|
||||||
|
<p class="sr-only" role="status" aria-live="polite">
|
||||||
|
{completedCount} von {jobs.length} Dateien bereit, {processingCount} in Bearbeitung,
|
||||||
|
{failedJobs.length} fehlgeschlagen.
|
||||||
|
</p>
|
||||||
|
<Card.Header class="place-items-center px-6 pt-4 text-center sm:px-10">
|
||||||
|
<div class="case-intake" aria-hidden="true">
|
||||||
|
<div class="folder-back"></div>
|
||||||
|
<div class="document-sheet">
|
||||||
|
<span></span>
|
||||||
|
<span></span>
|
||||||
|
<span></span>
|
||||||
|
</div>
|
||||||
|
<div class="folder-front"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card.Title class="mt-3 text-[22px] font-extrabold text-primary-900">
|
||||||
|
{#if processingCount > 0}
|
||||||
|
beA-Archive werden vorbereitet
|
||||||
|
{:else if failedJobs.length > 0 && completedCount === 0}
|
||||||
|
Archive konnten nicht vorbereitet werden
|
||||||
|
{:else}
|
||||||
|
Archive sind bereit
|
||||||
|
{/if}
|
||||||
|
</Card.Title>
|
||||||
|
<Card.Description class="max-w-md font-medium text-primary-700">
|
||||||
|
Die Dateien werden ausschließlich in Ihrem Browser geöffnet und nicht hochgeladen.
|
||||||
|
</Card.Description>
|
||||||
|
</Card.Header>
|
||||||
|
|
||||||
|
<Card.Content class="flex flex-col gap-4 px-6 sm:px-10">
|
||||||
|
<div class="flex flex-col gap-2">
|
||||||
|
<div class="flex items-center justify-between gap-4 text-[13px] font-bold text-primary-900">
|
||||||
|
<span>{completedCount} von {jobs.length} Dateien bereit</span>
|
||||||
|
{#if processingCount > 0}
|
||||||
|
<span class="font-medium text-primary-700">Bitte kurz warten</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="h-1.5 overflow-hidden rounded-full bg-primary-100"
|
||||||
|
role="progressbar"
|
||||||
|
aria-label="Vorbereitete ZIP-Dateien"
|
||||||
|
aria-valuemin="0"
|
||||||
|
aria-valuemax={jobs.length}
|
||||||
|
aria-valuenow={completedCount}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="h-full rounded-full bg-success transition-[width] duration-500"
|
||||||
|
style:width={`${completionPercent}%`}
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ul class="flex flex-col gap-2" aria-label="Status der ZIP-Dateien">
|
||||||
|
{#each jobs as job (job.id)}
|
||||||
|
<li
|
||||||
|
class="grid grid-cols-[auto_minmax(0,1fr)] items-center gap-3 rounded-xl border bg-background/55 px-3 py-3 sm:grid-cols-[auto_minmax(0,1fr)_auto]"
|
||||||
|
>
|
||||||
|
<div class="grid size-8 place-items-center rounded-full bg-card" aria-hidden="true">
|
||||||
|
{#if job.status === 'processing'}
|
||||||
|
<CircleDashed class="size-5 animate-spin text-secondary" />
|
||||||
|
{:else if job.status === 'complete'}
|
||||||
|
<Check class="size-5 text-success" />
|
||||||
|
{:else}
|
||||||
|
<TriangleAlert class="size-5 text-destructive" />
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="min-w-0">
|
||||||
|
<p class="truncate text-[14px] font-bold text-primary-900">{job.file.name}</p>
|
||||||
|
<p class="job-meta text-[12px] text-primary-700">{formatFileSize(job.file.size)}</p>
|
||||||
|
{#if job.status === 'error'}
|
||||||
|
<p class="mt-1 text-[12px] leading-snug text-destructive">
|
||||||
|
<span class="font-bold">Konnte nicht geöffnet werden.</span>
|
||||||
|
<span class="block">
|
||||||
|
{job.error ?? 'Prüfen Sie, ob die Datei ein gültiges beA-ZIP-Archiv ist.'}
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="col-start-2 flex flex-wrap items-center gap-1 sm:col-start-3 sm:row-start-1"
|
||||||
|
>
|
||||||
|
{#if job.status === 'processing'}
|
||||||
|
<span class="text-[12px] font-bold text-secondary">Wird geöffnet</span>
|
||||||
|
{:else if job.status === 'complete'}
|
||||||
|
<span class="text-[12px] font-bold text-success">
|
||||||
|
{archiveCountLabel(job.archiveCount)}
|
||||||
|
</span>
|
||||||
|
{:else}
|
||||||
|
<Button variant="tertiary" class="h-8 min-w-0 px-3" onclick={() => onRetry(job.id)}
|
||||||
|
>Erneut versuchen</Button
|
||||||
|
>
|
||||||
|
<Button variant="tertiary" class="h-8 min-w-0 px-3" onclick={() => onRemove(job.id)}
|
||||||
|
>Entfernen</Button
|
||||||
|
>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
</Card.Content>
|
||||||
|
</Card.Root>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.job-meta {
|
||||||
|
font-family: 'Geist Variable', ui-monospace, monospace;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
.case-intake {
|
||||||
|
position: relative;
|
||||||
|
width: 7rem;
|
||||||
|
height: 5.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.folder-back,
|
||||||
|
.folder-front,
|
||||||
|
.compact-folder {
|
||||||
|
position: absolute;
|
||||||
|
background: color-mix(in srgb, var(--primary) 12%, white);
|
||||||
|
border: 2px solid var(--primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.folder-back {
|
||||||
|
inset: 1.55rem 0 0;
|
||||||
|
border-radius: 0.65rem 0.65rem 0.8rem 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.folder-back::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
left: -2px;
|
||||||
|
top: -0.8rem;
|
||||||
|
width: 3.4rem;
|
||||||
|
height: 1rem;
|
||||||
|
border: 2px solid var(--primary);
|
||||||
|
border-bottom: 0;
|
||||||
|
border-radius: 0.55rem 0.55rem 0 0;
|
||||||
|
background: color-mix(in srgb, var(--primary) 12%, white);
|
||||||
|
}
|
||||||
|
|
||||||
|
.document-sheet {
|
||||||
|
position: absolute;
|
||||||
|
z-index: 1;
|
||||||
|
left: 50%;
|
||||||
|
top: 0.2rem;
|
||||||
|
display: flex;
|
||||||
|
width: 3.7rem;
|
||||||
|
height: 4.6rem;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.42rem;
|
||||||
|
border: 2px solid var(--primary);
|
||||||
|
border-radius: 0.35rem;
|
||||||
|
background: var(--card);
|
||||||
|
padding: 0.8rem 0.65rem;
|
||||||
|
box-shadow: 0 0.4rem 1rem rgb(18 47 98 / 12%);
|
||||||
|
animation: file-intake 1.8s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.document-sheet span {
|
||||||
|
display: block;
|
||||||
|
height: 0.18rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: color-mix(in srgb, var(--primary) 30%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.document-sheet span:nth-child(2) {
|
||||||
|
width: 72%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.document-sheet span:nth-child(3) {
|
||||||
|
width: 88%;
|
||||||
|
background: var(--secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.folder-front {
|
||||||
|
z-index: 2;
|
||||||
|
inset: 2.7rem 0 0;
|
||||||
|
border-radius: 0.45rem 0.45rem 0.8rem 0.8rem;
|
||||||
|
background: color-mix(in srgb, var(--primary) 18%, white);
|
||||||
|
transform: perspective(8rem) rotateX(-5deg);
|
||||||
|
transform-origin: bottom;
|
||||||
|
}
|
||||||
|
|
||||||
|
.compact-folder {
|
||||||
|
inset: 0.65rem 0.2rem 0.25rem;
|
||||||
|
border-radius: 0.35rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.compact-folder::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
left: -2px;
|
||||||
|
top: -0.4rem;
|
||||||
|
width: 1rem;
|
||||||
|
height: 0.5rem;
|
||||||
|
border: 2px solid var(--primary);
|
||||||
|
border-bottom: 0;
|
||||||
|
border-radius: 0.25rem 0.25rem 0 0;
|
||||||
|
background: color-mix(in srgb, var(--primary) 12%, white);
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes file-intake {
|
||||||
|
0%,
|
||||||
|
18% {
|
||||||
|
transform: translate(-50%, -0.45rem);
|
||||||
|
}
|
||||||
|
55%,
|
||||||
|
100% {
|
||||||
|
transform: translate(-50%, 0.65rem);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.document-sheet,
|
||||||
|
:global(.animate-spin) {
|
||||||
|
animation: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,230 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { Download, FilePlus2, Plus, Settings2, Trash2 } from '@lucide/svelte';
|
||||||
|
import Button from '$lib/components/ui/button.svelte';
|
||||||
|
import * as AlertDialog from '$lib/components/ui/alert-dialog/index.js';
|
||||||
|
import * as Popover from '$lib/components/ui/popover/index.js';
|
||||||
|
import { Slider } from '$lib/components/ui/slider/index.js';
|
||||||
|
import { Separator } from '$lib/components/ui/separator/index.js';
|
||||||
|
import ZipFilePicker from '$lib/components/ZipFilePicker.svelte';
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
selectedZipCount: number;
|
||||||
|
processedArchiveCount: number;
|
||||||
|
pendingZipCount: number;
|
||||||
|
thumbnailWidth: number;
|
||||||
|
onThumbnailWidthChange: (value: number) => void;
|
||||||
|
/** Replaces the current workspace with the confirmed files. */
|
||||||
|
onRequestReplacement: (files: File[]) => void | Promise<void>;
|
||||||
|
/** Appends the files to the current workspace. */
|
||||||
|
onAppendFiles: (files: File[]) => void;
|
||||||
|
onExportAll: () => void | Promise<void>;
|
||||||
|
onClear: () => void | Promise<void>;
|
||||||
|
};
|
||||||
|
|
||||||
|
let {
|
||||||
|
selectedZipCount,
|
||||||
|
processedArchiveCount,
|
||||||
|
pendingZipCount,
|
||||||
|
thumbnailWidth,
|
||||||
|
onThumbnailWidthChange,
|
||||||
|
onRequestReplacement,
|
||||||
|
onAppendFiles,
|
||||||
|
onExportAll,
|
||||||
|
onClear
|
||||||
|
}: Props = $props();
|
||||||
|
|
||||||
|
// Replacement files stay in temporary component state until the user confirms;
|
||||||
|
// cancelling the confirmation must never touch the current workspace.
|
||||||
|
let pendingReplacementFiles: File[] | null = $state(null);
|
||||||
|
let clearDialogOpen = $state(false);
|
||||||
|
let isCommitting = $state(false);
|
||||||
|
let isExporting = $state(false);
|
||||||
|
|
||||||
|
const statusText = $derived.by(() => {
|
||||||
|
const zipText = `${selectedZipCount} ${selectedZipCount === 1 ? 'ZIP' : 'ZIPs'}`;
|
||||||
|
const archiveText = `${processedArchiveCount} ${
|
||||||
|
processedArchiveCount === 1 ? 'Archiv' : 'Archive'
|
||||||
|
} geöffnet`;
|
||||||
|
const pendingText =
|
||||||
|
pendingZipCount > 0
|
||||||
|
? ` · ${pendingZipCount} ${pendingZipCount === 1 ? 'wird' : 'werden'} geöffnet`
|
||||||
|
: '';
|
||||||
|
return `${zipText} · ${archiveText}${pendingText}`;
|
||||||
|
});
|
||||||
|
|
||||||
|
const commit = async (action: () => void | Promise<void>) => {
|
||||||
|
isCommitting = true;
|
||||||
|
try {
|
||||||
|
await action();
|
||||||
|
} finally {
|
||||||
|
isCommitting = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const confirmReplacement = () => {
|
||||||
|
const files = pendingReplacementFiles;
|
||||||
|
pendingReplacementFiles = null;
|
||||||
|
if (!files || files.length === 0) return;
|
||||||
|
void commit(() => onRequestReplacement(files));
|
||||||
|
};
|
||||||
|
|
||||||
|
const confirmClear = () => {
|
||||||
|
clearDialogOpen = false;
|
||||||
|
void commit(onClear);
|
||||||
|
};
|
||||||
|
|
||||||
|
const exportAll = async () => {
|
||||||
|
isExporting = true;
|
||||||
|
try {
|
||||||
|
await onExportAll();
|
||||||
|
} finally {
|
||||||
|
isExporting = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="flex w-full flex-col gap-3 border-b border-primary-100 pb-4 sm:flex-row sm:items-center sm:justify-between"
|
||||||
|
>
|
||||||
|
<p class="text-sm font-bold text-primary-700" aria-live="polite">
|
||||||
|
{statusText}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
|
<ZipFilePicker
|
||||||
|
pickerId="replace-zip"
|
||||||
|
ariaLabel="Neue ZIP bearbeiten"
|
||||||
|
disabled={isCommitting}
|
||||||
|
onFilesSelected={(files) => (pendingReplacementFiles = files)}
|
||||||
|
>
|
||||||
|
<FilePlus2 class="size-4" aria-hidden="true" />
|
||||||
|
Neue ZIP bearbeiten
|
||||||
|
</ZipFilePicker>
|
||||||
|
|
||||||
|
<Popover.Root>
|
||||||
|
<Popover.Trigger aria-label="Einstellungen">
|
||||||
|
{#snippet child({ props })}
|
||||||
|
<Button variant="bordered" {...props}>
|
||||||
|
<Settings2 class="size-4" aria-hidden="true" />
|
||||||
|
<span class="hidden sm:inline">Einstellungen</span>
|
||||||
|
</Button>
|
||||||
|
{/snippet}
|
||||||
|
</Popover.Trigger>
|
||||||
|
|
||||||
|
<Popover.Content
|
||||||
|
align="end"
|
||||||
|
sideOffset={8}
|
||||||
|
class="w-[min(22rem,calc(100vw-2rem))] gap-4 rounded-2xl border border-primary-100 p-4 shadow-lg ring-0"
|
||||||
|
>
|
||||||
|
<span class="sr-only">Einstellungen</span>
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-3">
|
||||||
|
<span class="text-[12px] font-bold uppercase tracking-wide text-primary-700">
|
||||||
|
Ansicht
|
||||||
|
</span>
|
||||||
|
<div class="flex items-center justify-between gap-3 text-sm font-bold text-primary-900">
|
||||||
|
<span>Vorschaugröße</span>
|
||||||
|
<span class="tabular-nums text-primary-700">{thumbnailWidth} px</span>
|
||||||
|
</div>
|
||||||
|
<Slider
|
||||||
|
type="single"
|
||||||
|
thumbAriaLabel="Vorschaugröße"
|
||||||
|
min={100}
|
||||||
|
max={400}
|
||||||
|
step={1}
|
||||||
|
value={thumbnailWidth}
|
||||||
|
onValueChange={(value: number | number[]) => {
|
||||||
|
if (typeof value === 'number') onThumbnailWidthChange(value);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
class="flex justify-between text-[12px] font-medium text-primary-700"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<span>Klein</span>
|
||||||
|
<span>Groß</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-2">
|
||||||
|
<span class="text-[12px] font-bold uppercase tracking-wide text-primary-700">
|
||||||
|
Archiv-Aktionen
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<ZipFilePicker
|
||||||
|
pickerId="append-zip"
|
||||||
|
ariaLabel="Weitere ZIP hinzufügen"
|
||||||
|
variant="bordered"
|
||||||
|
disabled={isCommitting}
|
||||||
|
class="w-full justify-start"
|
||||||
|
onFilesSelected={onAppendFiles}
|
||||||
|
>
|
||||||
|
<Plus class="size-4" aria-hidden="true" />
|
||||||
|
Weitere ZIP hinzufügen
|
||||||
|
</ZipFilePicker>
|
||||||
|
|
||||||
|
{#if processedArchiveCount >= 2}
|
||||||
|
<Button
|
||||||
|
variant="bordered"
|
||||||
|
disabled={isExporting || isCommitting}
|
||||||
|
class="w-full justify-start"
|
||||||
|
onclick={() => void exportAll()}
|
||||||
|
>
|
||||||
|
<Download class="size-4" aria-hidden="true" />
|
||||||
|
Alle Archive als PDF herunterladen
|
||||||
|
</Button>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<Separator class="my-1" />
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="tertiary"
|
||||||
|
disabled={isCommitting}
|
||||||
|
class="w-full justify-start text-destructive hover:bg-destructive/10 hover:text-destructive"
|
||||||
|
onclick={() => (clearDialogOpen = true)}
|
||||||
|
>
|
||||||
|
<Trash2 class="size-4" aria-hidden="true" />
|
||||||
|
Aktuelle Bearbeitung leeren
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Popover.Content>
|
||||||
|
</Popover.Root>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<AlertDialog.Root
|
||||||
|
open={pendingReplacementFiles !== null}
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
if (!open) pendingReplacementFiles = null;
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<AlertDialog.Content class="max-w-sm">
|
||||||
|
<AlertDialog.Title class="text-base font-bold text-primary-900">
|
||||||
|
Aktuelle Bearbeitung ersetzen?
|
||||||
|
</AlertDialog.Title>
|
||||||
|
<AlertDialog.Description>
|
||||||
|
Archive und nicht heruntergeladene Änderungen in dieser Ansicht werden entfernt. Bereits
|
||||||
|
heruntergeladene PDFs bleiben erhalten.
|
||||||
|
</AlertDialog.Description>
|
||||||
|
<AlertDialog.Footer>
|
||||||
|
<AlertDialog.Cancel>Abbrechen</AlertDialog.Cancel>
|
||||||
|
<AlertDialog.Action onclick={confirmReplacement}>Neue ZIP öffnen</AlertDialog.Action>
|
||||||
|
</AlertDialog.Footer>
|
||||||
|
</AlertDialog.Content>
|
||||||
|
</AlertDialog.Root>
|
||||||
|
|
||||||
|
<AlertDialog.Root bind:open={clearDialogOpen}>
|
||||||
|
<AlertDialog.Content class="max-w-sm">
|
||||||
|
<AlertDialog.Title class="text-base font-bold text-primary-900">
|
||||||
|
Aktuelle Bearbeitung leeren?
|
||||||
|
</AlertDialog.Title>
|
||||||
|
<AlertDialog.Description>
|
||||||
|
Alle Archive und nicht heruntergeladene Änderungen in dieser Ansicht werden entfernt. Bereits
|
||||||
|
heruntergeladene PDFs bleiben erhalten.
|
||||||
|
</AlertDialog.Description>
|
||||||
|
<AlertDialog.Footer>
|
||||||
|
<AlertDialog.Cancel>Abbrechen</AlertDialog.Cancel>
|
||||||
|
<AlertDialog.Action onclick={confirmClear}>Bearbeitung leeren</AlertDialog.Action>
|
||||||
|
</AlertDialog.Footer>
|
||||||
|
</AlertDialog.Content>
|
||||||
|
</AlertDialog.Root>
|
||||||
@@ -0,0 +1,139 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { Archive } from '@lucide/svelte';
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
onFilesSelected: (files: File[]) => void;
|
||||||
|
onFilesRejected?: (files: File[]) => void;
|
||||||
|
accept?: string;
|
||||||
|
multiple?: boolean;
|
||||||
|
ariaLabel?: string;
|
||||||
|
label?: string;
|
||||||
|
sublabel?: string;
|
||||||
|
icon?: typeof Archive;
|
||||||
|
class?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
let {
|
||||||
|
onFilesSelected,
|
||||||
|
onFilesRejected,
|
||||||
|
accept = '.pdf,application/pdf',
|
||||||
|
multiple = false,
|
||||||
|
ariaLabel = 'PDF-Dateien hinzufügen',
|
||||||
|
label = 'Dateien hinzufügen',
|
||||||
|
sublabel = 'PDF hier ablegen oder zum Auswählen klicken',
|
||||||
|
icon = undefined,
|
||||||
|
class: className = ''
|
||||||
|
}: Props = $props();
|
||||||
|
|
||||||
|
let fileInput = $state<HTMLInputElement | null>(null);
|
||||||
|
let isDragging = $state(false);
|
||||||
|
let rejectionMessage = $state<string | null>(null);
|
||||||
|
let dragDepth = 0;
|
||||||
|
|
||||||
|
const matchesAccept = (file: File) =>
|
||||||
|
accept
|
||||||
|
.split(',')
|
||||||
|
.map((value) => value.trim().toLowerCase())
|
||||||
|
.filter(Boolean)
|
||||||
|
.some((filter) => {
|
||||||
|
if (filter.startsWith('.')) return file.name.toLowerCase().endsWith(filter);
|
||||||
|
if (filter.endsWith('/*')) return file.type.toLowerCase().startsWith(filter.slice(0, -1));
|
||||||
|
return file.type.toLowerCase() === filter;
|
||||||
|
});
|
||||||
|
|
||||||
|
const processFiles = (files: FileList | null | undefined) => {
|
||||||
|
if (!files) return;
|
||||||
|
|
||||||
|
const received = Array.from(files);
|
||||||
|
const accepted = received.filter(matchesAccept);
|
||||||
|
const rejected = received.filter((file) => !matchesAccept(file));
|
||||||
|
|
||||||
|
if (rejected.length > 0) {
|
||||||
|
rejectionMessage = `${rejected.map((file) => file.name).join(', ')}: Dateityp nicht unterstützt.`;
|
||||||
|
onFilesRejected?.(rejected);
|
||||||
|
}
|
||||||
|
if (accepted.length > 0) {
|
||||||
|
rejectionMessage = null;
|
||||||
|
onFilesSelected(multiple ? accepted : accepted.slice(0, 1));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const openFileDialog = () => fileInput?.click();
|
||||||
|
|
||||||
|
const handleFileSelection = (event: Event) => {
|
||||||
|
const input = event.currentTarget as HTMLInputElement;
|
||||||
|
processFiles(input.files);
|
||||||
|
input.value = '';
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDragEnter = (event: DragEvent) => {
|
||||||
|
event.preventDefault();
|
||||||
|
dragDepth += 1;
|
||||||
|
isDragging = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDragOver = (event: DragEvent) => {
|
||||||
|
event.preventDefault();
|
||||||
|
if (event.dataTransfer) event.dataTransfer.dropEffect = 'copy';
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDragLeave = (event: DragEvent) => {
|
||||||
|
event.preventDefault();
|
||||||
|
dragDepth = Math.max(0, dragDepth - 1);
|
||||||
|
if (dragDepth === 0) isDragging = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDrop = (event: DragEvent) => {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
dragDepth = 0;
|
||||||
|
isDragging = false;
|
||||||
|
processFiles(event.dataTransfer?.files);
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<input
|
||||||
|
bind:this={fileInput}
|
||||||
|
class="hidden"
|
||||||
|
type="file"
|
||||||
|
{accept}
|
||||||
|
{multiple}
|
||||||
|
onchange={handleFileSelection}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="flex w-full cursor-pointer flex-col items-center justify-center gap-2 rounded-2xl border-2 border-dashed px-6 py-10 text-center transition-all duration-150 focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-400 {isDragging
|
||||||
|
? 'scale-[1.02] border-primary bg-primary-100/70 shadow-lg ring-4 ring-primary/25'
|
||||||
|
: 'border-primary-300 bg-white hover:border-primary-400 hover:bg-primary-50/60'} {className}"
|
||||||
|
aria-label={ariaLabel}
|
||||||
|
data-dropzone-ready={fileInput ? 'true' : 'false'}
|
||||||
|
onclick={openFileDialog}
|
||||||
|
ondragenter={handleDragEnter}
|
||||||
|
ondragover={handleDragOver}
|
||||||
|
ondragleave={handleDragLeave}
|
||||||
|
ondrop={handleDrop}
|
||||||
|
>
|
||||||
|
{#if icon}
|
||||||
|
{@const IconComponent = icon}
|
||||||
|
<IconComponent
|
||||||
|
class="h-10 w-10 text-primary transition-transform duration-150 {isDragging
|
||||||
|
? 'scale-125'
|
||||||
|
: ''}"
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
<div class="flex flex-row items-baseline justify-center gap-2">
|
||||||
|
<span class="text-[18px] font-bold text-primary-900">{label}</span>
|
||||||
|
<span class="text-[20px] font-extrabold text-primary">+</span>
|
||||||
|
</div>
|
||||||
|
{#if sublabel}
|
||||||
|
<p class="text-[13px] text-primary-700">{sublabel}</p>
|
||||||
|
{/if}
|
||||||
|
{#if isDragging}
|
||||||
|
<p class="text-[13px] font-bold text-primary">Jetzt loslassen zum Hinzufügen</p>
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{#if rejectionMessage}
|
||||||
|
<p class="mt-2 text-sm font-medium text-red-700" role="alert">{rejectionMessage}</p>
|
||||||
|
{/if}
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { Snippet } from 'svelte';
|
||||||
|
import type { PdfThumbnail } from '$lib/pdf-thumbnails';
|
||||||
|
import { cn } from '$lib/utils';
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
thumbnails: PdfThumbnail[];
|
||||||
|
selectedPages?: Set<number>;
|
||||||
|
onPageClick?: (pageNumber: number) => void;
|
||||||
|
thumbnailWidth?: number;
|
||||||
|
class?: string;
|
||||||
|
pageActions?: Snippet<[pageNumber: number]>;
|
||||||
|
cardClass?: (pageNumber: number) => string;
|
||||||
|
};
|
||||||
|
|
||||||
|
let {
|
||||||
|
thumbnails,
|
||||||
|
selectedPages = new Set<number>(),
|
||||||
|
onPageClick,
|
||||||
|
thumbnailWidth = 120,
|
||||||
|
class: className = '',
|
||||||
|
pageActions,
|
||||||
|
cardClass
|
||||||
|
}: Props = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class={cn('grid gap-3', className)}
|
||||||
|
style="grid-template-columns: repeat(auto-fill, minmax({thumbnailWidth}px, 1fr));"
|
||||||
|
>
|
||||||
|
{#each thumbnails as thumb}
|
||||||
|
{@const isSelected = selectedPages.has(thumb.pageNumber)}
|
||||||
|
{#if onPageClick}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class={cn(
|
||||||
|
'group relative flex flex-col items-center gap-2 rounded-xl border-2 p-2 transition focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-400',
|
||||||
|
isSelected
|
||||||
|
? 'border-primary bg-primary/5'
|
||||||
|
: 'border-transparent hover:border-primary-200 hover:bg-primary-50/50'
|
||||||
|
)}
|
||||||
|
onclick={() => onPageClick(thumb.pageNumber)}
|
||||||
|
>
|
||||||
|
{#if cardClass}
|
||||||
|
<div
|
||||||
|
class={cn(
|
||||||
|
'flex aspect-square w-full items-center justify-center',
|
||||||
|
cardClass(thumb.pageNumber)
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src={thumb.imageUrl}
|
||||||
|
alt="Seite {thumb.pageNumber}"
|
||||||
|
class="max-h-full max-w-full rounded border border-primary-100 bg-white object-contain"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<img
|
||||||
|
src={thumb.imageUrl}
|
||||||
|
alt="Seite {thumb.pageNumber}"
|
||||||
|
class="w-full rounded border border-primary-100 bg-white object-contain"
|
||||||
|
style="aspect-ratio: {thumb.width} / {thumb.height};"
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
<span class="text-[12px] font-medium text-primary-700">Seite {thumb.pageNumber}</span>
|
||||||
|
{#if isSelected}
|
||||||
|
<div
|
||||||
|
class="absolute top-1 right-1 flex h-5 w-5 items-center justify-center rounded-full bg-primary text-[10px] font-bold text-white"
|
||||||
|
>
|
||||||
|
✓
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
|
{:else}
|
||||||
|
<div
|
||||||
|
class={cn(
|
||||||
|
'group relative flex flex-col items-center gap-2 rounded-xl border-2 border-transparent p-2',
|
||||||
|
isSelected && 'border-primary bg-primary/5'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{#if cardClass}
|
||||||
|
<div
|
||||||
|
class={cn(
|
||||||
|
'flex aspect-square w-full items-center justify-center',
|
||||||
|
cardClass(thumb.pageNumber)
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src={thumb.imageUrl}
|
||||||
|
alt="Seite {thumb.pageNumber}"
|
||||||
|
class="max-h-full max-w-full rounded border border-primary-100 bg-white object-contain"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<img
|
||||||
|
src={thumb.imageUrl}
|
||||||
|
alt="Seite {thumb.pageNumber}"
|
||||||
|
class="w-full rounded border border-primary-100 bg-white object-contain"
|
||||||
|
style="aspect-ratio: {thumb.width} / {thumb.height};"
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
<span class="text-[12px] font-medium text-primary-700">Seite {thumb.pageNumber}</span>
|
||||||
|
{#if pageActions}
|
||||||
|
{@render pageActions(thumb.pageNumber)}
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
@@ -4,6 +4,7 @@
|
|||||||
import SubDocumentEditor from '$lib/components/SubDocumentEditor.svelte';
|
import SubDocumentEditor from '$lib/components/SubDocumentEditor.svelte';
|
||||||
import {
|
import {
|
||||||
buildArchiveExport,
|
buildArchiveExport,
|
||||||
|
buildSubDocumentExport,
|
||||||
createSubDocument,
|
createSubDocument,
|
||||||
dissolveSubDocument,
|
dissolveSubDocument,
|
||||||
moveAttachmentIntoSubDocument,
|
moveAttachmentIntoSubDocument,
|
||||||
@@ -41,8 +42,11 @@
|
|||||||
|
|
||||||
let downloadMode = $state<ExportMode>('single');
|
let downloadMode = $state<ExportMode>('single');
|
||||||
let isMerging = $state(false);
|
let isMerging = $state(false);
|
||||||
|
let downloadingSubDocumentId = $state<string | null>(null);
|
||||||
let mergeError = $state<string | null>(null);
|
let mergeError = $state<string | null>(null);
|
||||||
|
|
||||||
|
const isExportActive = $derived(isMerging || downloadingSubDocumentId !== null);
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
archiveName = archive.name;
|
archiveName = archive.name;
|
||||||
looseAttachments = archive.attachments.map((attachment) => ({ ...attachment }));
|
looseAttachments = archive.attachments.map((attachment) => ({ ...attachment }));
|
||||||
@@ -293,7 +297,7 @@
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isMerging) {
|
if (isExportActive) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -313,6 +317,29 @@
|
|||||||
isMerging = false;
|
isMerging = false;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const downloadSubDocument = async (subDocument: SubDocument) => {
|
||||||
|
if (isExportActive || subDocument.attachments.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
downloadingSubDocumentId = subDocument.id;
|
||||||
|
mergeError = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const exportUnit = await buildSubDocumentExport(archiveName, subDocument);
|
||||||
|
|
||||||
|
downloadPdfBytes(exportUnit.bytes, exportUnit.name);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(
|
||||||
|
`Failed to export sub-document "${subDocument.name}" from archive ${archive.name}`,
|
||||||
|
error
|
||||||
|
);
|
||||||
|
mergeError = `Teildokument „${subDocument.name}“ konnte nicht als PDF erstellt werden.`;
|
||||||
|
} finally {
|
||||||
|
downloadingSubDocumentId = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:window onkeydown={handleWindowKeydown} />
|
<svelte:window onkeydown={handleWindowKeydown} />
|
||||||
@@ -354,17 +381,21 @@
|
|||||||
|
|
||||||
{#if hasSubDocuments}
|
{#if hasSubDocuments}
|
||||||
<div
|
<div
|
||||||
class="inline-flex items-center rounded-[16px] border border-primary-200 bg-white p-0.5"
|
class={cn(
|
||||||
|
'inline-flex items-center rounded-[16px] border border-primary-200 bg-white p-0.5',
|
||||||
|
downloadingSubDocumentId !== null ? 'opacity-60' : ''
|
||||||
|
)}
|
||||||
role="radiogroup"
|
role="radiogroup"
|
||||||
aria-label="Export-Modus"
|
aria-label="Export-Modus"
|
||||||
>
|
>
|
||||||
{#each [{ value: 'single', label: 'Eine PDF' }, { value: 'separate', label: 'Getrennt' }] as option (option.value)}
|
{#each [{ value: 'single', label: 'Eine PDF' }, { value: 'separate', label: 'Getrennt' }] as option (option.value)}
|
||||||
<label
|
<label
|
||||||
class={cn(
|
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
|
downloadMode === option.value
|
||||||
? 'bg-primary text-primary-foreground'
|
? '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
|
<input
|
||||||
@@ -373,6 +404,7 @@
|
|||||||
name={`download-mode-${archive.name}`}
|
name={`download-mode-${archive.name}`}
|
||||||
value={option.value}
|
value={option.value}
|
||||||
checked={downloadMode === option.value}
|
checked={downloadMode === option.value}
|
||||||
|
disabled={downloadingSubDocumentId !== null}
|
||||||
onchange={() => {
|
onchange={() => {
|
||||||
downloadMode = option.value as ExportMode;
|
downloadMode = option.value as ExportMode;
|
||||||
}}
|
}}
|
||||||
@@ -386,7 +418,7 @@
|
|||||||
<Button
|
<Button
|
||||||
class="bg-primary"
|
class="bg-primary"
|
||||||
type="button"
|
type="button"
|
||||||
disabled={(looseAttachments.length === 0 && subDocuments.length === 0) || isMerging}
|
disabled={(looseAttachments.length === 0 && subDocuments.length === 0) || isExportActive}
|
||||||
onclick={downloadArchive}
|
onclick={downloadArchive}
|
||||||
>
|
>
|
||||||
{isMerging ? 'PDF wird erstellt' : 'PDF herunterladen'}
|
{isMerging ? 'PDF wird erstellt' : 'PDF herunterladen'}
|
||||||
@@ -495,6 +527,9 @@
|
|||||||
onBlockDragOver={handleBlockDragOver}
|
onBlockDragOver={handleBlockDragOver}
|
||||||
onBlockDrop={handleBlockDrop}
|
onBlockDrop={handleBlockDrop}
|
||||||
onBlockDragEnd={handleBlockDragEnd}
|
onBlockDragEnd={handleBlockDragEnd}
|
||||||
|
onDownload={() => downloadSubDocument(subDocument)}
|
||||||
|
isDownloading={downloadingSubDocumentId === subDocument.id}
|
||||||
|
downloadDisabled={isExportActive}
|
||||||
/>
|
/>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import AttachmentPreview from '$lib/components/AttachmentPreview.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 type { AttachmentLocation, SubDocument, ZipAttachment } from '$lib/zip-processing';
|
||||||
import { cn } from '$lib/utils';
|
import { cn } from '$lib/utils';
|
||||||
|
|
||||||
@@ -16,6 +17,9 @@
|
|||||||
onBlockDragOver?: (blockIndex: number, event: DragEvent) => void;
|
onBlockDragOver?: (blockIndex: number, event: DragEvent) => void;
|
||||||
onBlockDrop?: (blockIndex: number, event: DragEvent) => void;
|
onBlockDrop?: (blockIndex: number, event: DragEvent) => void;
|
||||||
onBlockDragEnd?: () => void;
|
onBlockDragEnd?: () => void;
|
||||||
|
onDownload?: () => void;
|
||||||
|
isDownloading?: boolean;
|
||||||
|
downloadDisabled?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
let {
|
let {
|
||||||
@@ -30,7 +34,10 @@
|
|||||||
onBlockDragStart = () => {},
|
onBlockDragStart = () => {},
|
||||||
onBlockDragOver = () => {},
|
onBlockDragOver = () => {},
|
||||||
onBlockDrop = () => {},
|
onBlockDrop = () => {},
|
||||||
onBlockDragEnd = () => {}
|
onBlockDragEnd = () => {},
|
||||||
|
onDownload,
|
||||||
|
isDownloading = false,
|
||||||
|
downloadDisabled = false
|
||||||
}: Props = $props();
|
}: Props = $props();
|
||||||
|
|
||||||
let subDocumentName = $state('');
|
let subDocumentName = $state('');
|
||||||
@@ -200,6 +207,19 @@
|
|||||||
{attachments.length} Datei{attachments.length === 1 ? '' : 'en'}
|
{attachments.length} Datei{attachments.length === 1 ? '' : 'en'}
|
||||||
</span>
|
</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
|
<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"
|
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"
|
type="button"
|
||||||
|
|||||||
@@ -1,76 +1,21 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
type Props = {
|
import FileDropzone from '$lib/components/FileDropzone.svelte';
|
||||||
onFilesSelected: (files: File[]) => void;
|
import { ZIP_ACCEPT_TYPES } from '$lib/zip-selection';
|
||||||
class?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
let { onFilesSelected, class: className = "" }: Props = $props();
|
type Props = {
|
||||||
|
onFilesSelected: (files: File[]) => void;
|
||||||
|
class?: string;
|
||||||
|
};
|
||||||
|
|
||||||
let fileInput: HTMLInputElement | null = null;
|
let { onFilesSelected, class: className = '' }: Props = $props();
|
||||||
let isDragging = $state(false);
|
|
||||||
|
|
||||||
const processFiles = (files: FileList | null | undefined) => {
|
|
||||||
if (!files) return;
|
|
||||||
|
|
||||||
const filtered = Array.from(files).filter((file) => {
|
|
||||||
const name = file.name.toLowerCase();
|
|
||||||
return (
|
|
||||||
name.endsWith('.zip') ||
|
|
||||||
file.type === 'application/zip' ||
|
|
||||||
file.type === 'application/x-zip-compressed'
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
if (filtered.length > 0) {
|
|
||||||
onFilesSelected(filtered);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const openFileDialog = () => fileInput?.click();
|
|
||||||
|
|
||||||
const handleFileSelection = (event: Event) => {
|
|
||||||
const input = event.currentTarget as HTMLInputElement;
|
|
||||||
processFiles(input.files);
|
|
||||||
input.value = ''; // Reset so same file can be uploaded twice
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDragOver = (event: DragEvent) => {
|
|
||||||
event.preventDefault();
|
|
||||||
isDragging = true;
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDragLeave = (event: DragEvent) => {
|
|
||||||
event.preventDefault();
|
|
||||||
isDragging = false;
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDrop = (event: DragEvent) => {
|
|
||||||
event.preventDefault();
|
|
||||||
isDragging = false;
|
|
||||||
processFiles(event.dataTransfer?.files);
|
|
||||||
};
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<input
|
<FileDropzone
|
||||||
bind:this={fileInput}
|
{onFilesSelected}
|
||||||
class="hidden"
|
accept={ZIP_ACCEPT_TYPES}
|
||||||
type="file"
|
multiple={true}
|
||||||
accept=".zip,application/zip,application/x-zip-compressed"
|
ariaLabel="beA-ZIP-Dateien hinzufügen"
|
||||||
multiple
|
label="Dateien hinzufügen"
|
||||||
onchange={handleFileSelection}
|
sublabel="beA-ZIP-Dateien hier ablegen oder zum Auswählen klicken"
|
||||||
|
class="min-h-[calc(-180px+100vh)] border-0 bg-transparent {className}"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="flex min-h-[calc(-180px+100vh)] w-full grow cursor-pointer flex-col justify-center border-0 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 { isAcceptedZipFile, ZIP_ACCEPT_TYPES } from '$lib/zip-selection';
|
||||||
|
import type { Snippet } from 'svelte';
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
/** Identifies the hidden input for tests, e.g. `replace-zip` or `append-zip`. */
|
||||||
|
pickerId: string;
|
||||||
|
ariaLabel: string;
|
||||||
|
onFilesSelected: (files: File[]) => void;
|
||||||
|
children: Snippet;
|
||||||
|
multiple?: boolean;
|
||||||
|
disabled?: boolean;
|
||||||
|
variant?: ButtonVariant;
|
||||||
|
class?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
let {
|
||||||
|
pickerId,
|
||||||
|
ariaLabel,
|
||||||
|
onFilesSelected,
|
||||||
|
children,
|
||||||
|
multiple = true,
|
||||||
|
disabled = false,
|
||||||
|
variant = 'primary',
|
||||||
|
class: className = ''
|
||||||
|
}: Props = $props();
|
||||||
|
|
||||||
|
let fileInput = $state<HTMLInputElement | null>(null);
|
||||||
|
|
||||||
|
const handleFileSelection = (event: Event) => {
|
||||||
|
const input = event.currentTarget as HTMLInputElement;
|
||||||
|
const files = Array.from(input.files ?? []).filter(isAcceptedZipFile);
|
||||||
|
|
||||||
|
// Reset first so selecting the same file again still fires `change`.
|
||||||
|
input.value = '';
|
||||||
|
|
||||||
|
if (files.length === 0) return;
|
||||||
|
onFilesSelected(multiple ? files : files.slice(0, 1));
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<input
|
||||||
|
bind:this={fileInput}
|
||||||
|
class="hidden"
|
||||||
|
type="file"
|
||||||
|
accept={ZIP_ACCEPT_TYPES}
|
||||||
|
{multiple}
|
||||||
|
data-zip-picker={pickerId}
|
||||||
|
onchange={handleFileSelection}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
{variant}
|
||||||
|
{disabled}
|
||||||
|
aria-label={ariaLabel}
|
||||||
|
class={className}
|
||||||
|
onclick={() => fileInput?.click()}
|
||||||
|
>
|
||||||
|
{@render children()}
|
||||||
|
</Button>
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { AlertDialog as AlertDialogPrimitive } from 'bits-ui';
|
||||||
|
import {
|
||||||
|
buttonVariants,
|
||||||
|
type ButtonVariant,
|
||||||
|
type ButtonSize
|
||||||
|
} from '$lib/components/ui/button.svelte';
|
||||||
|
import { cn } from '$lib/utils.js';
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
class: className,
|
||||||
|
variant = 'primary' as ButtonVariant,
|
||||||
|
size = 'standard' as ButtonSize,
|
||||||
|
...restProps
|
||||||
|
}: AlertDialogPrimitive.ActionProps & {
|
||||||
|
variant?: ButtonVariant;
|
||||||
|
size?: ButtonSize;
|
||||||
|
} = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<AlertDialogPrimitive.Action
|
||||||
|
bind:ref
|
||||||
|
data-slot="alert-dialog-action"
|
||||||
|
class={cn(buttonVariants({ variant, size }), 'cn-alert-dialog-action', className)}
|
||||||
|
{...restProps}
|
||||||
|
/>
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { AlertDialog as AlertDialogPrimitive } from 'bits-ui';
|
||||||
|
import {
|
||||||
|
buttonVariants,
|
||||||
|
type ButtonVariant,
|
||||||
|
type ButtonSize
|
||||||
|
} from '$lib/components/ui/button.svelte';
|
||||||
|
import { cn } from '$lib/utils.js';
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
class: className,
|
||||||
|
variant = 'bordered' as ButtonVariant,
|
||||||
|
size = 'standard' as ButtonSize,
|
||||||
|
...restProps
|
||||||
|
}: AlertDialogPrimitive.CancelProps & {
|
||||||
|
variant?: ButtonVariant;
|
||||||
|
size?: ButtonSize;
|
||||||
|
} = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<AlertDialogPrimitive.Cancel
|
||||||
|
bind:ref
|
||||||
|
data-slot="alert-dialog-cancel"
|
||||||
|
class={cn(buttonVariants({ variant, size }), 'cn-alert-dialog-cancel', className)}
|
||||||
|
{...restProps}
|
||||||
|
/>
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { AlertDialog as AlertDialogPrimitive } from 'bits-ui';
|
||||||
|
import { cn, type WithoutChild, type WithoutChildrenOrChild } from '$lib/utils.js';
|
||||||
|
import AlertDialogOverlay from './alert-dialog-overlay.svelte';
|
||||||
|
import AlertDialogPortal from './alert-dialog-portal.svelte';
|
||||||
|
import type { ComponentProps } from 'svelte';
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
class: className,
|
||||||
|
size = 'default',
|
||||||
|
portalProps,
|
||||||
|
...restProps
|
||||||
|
}: WithoutChild<AlertDialogPrimitive.ContentProps> & {
|
||||||
|
size?: 'default' | 'sm';
|
||||||
|
portalProps?: WithoutChildrenOrChild<ComponentProps<typeof AlertDialogPortal>>;
|
||||||
|
} = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<AlertDialogPortal {...portalProps}>
|
||||||
|
<AlertDialogOverlay />
|
||||||
|
<AlertDialogPrimitive.Content
|
||||||
|
bind:ref
|
||||||
|
data-slot="alert-dialog-content"
|
||||||
|
data-size={size}
|
||||||
|
class={cn(
|
||||||
|
'gap-4 rounded-xl bg-popover p-4 text-popover-foreground ring-1 ring-foreground/10 duration-100 data-[size=default]:max-w-xs data-[size=sm]:max-w-xs data-[size=default]:sm:max-w-sm data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95 group/alert-dialog-content fixed top-1/2 left-1/2 z-50 grid w-full -translate-x-1/2 -translate-y-1/2 outline-none',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...restProps}
|
||||||
|
/>
|
||||||
|
</AlertDialogPortal>
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { AlertDialog as AlertDialogPrimitive } from 'bits-ui';
|
||||||
|
import { cn } from '$lib/utils.js';
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
class: className,
|
||||||
|
...restProps
|
||||||
|
}: AlertDialogPrimitive.DescriptionProps = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<AlertDialogPrimitive.Description
|
||||||
|
bind:ref
|
||||||
|
data-slot="alert-dialog-description"
|
||||||
|
class={cn(
|
||||||
|
'text-sm text-balance text-muted-foreground md:text-pretty *:[a]:underline *:[a]:underline-offset-3 *:[a]:hover:text-foreground',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...restProps}
|
||||||
|
/>
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { cn, type WithElementRef } from '$lib/utils.js';
|
||||||
|
import type { HTMLAttributes } from 'svelte/elements';
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
class: className,
|
||||||
|
children,
|
||||||
|
...restProps
|
||||||
|
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div
|
||||||
|
bind:this={ref}
|
||||||
|
data-slot="alert-dialog-footer"
|
||||||
|
class={cn(
|
||||||
|
'-mx-4 -mb-4 rounded-b-xl border-t bg-muted/50 p-4 flex flex-col-reverse gap-2 group-data-[size=sm]/alert-dialog-content:grid group-data-[size=sm]/alert-dialog-content:grid-cols-2 sm:flex-row sm:justify-end',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...restProps}
|
||||||
|
>
|
||||||
|
{@render children?.()}
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { cn, type WithElementRef } from '$lib/utils.js';
|
||||||
|
import type { HTMLAttributes } from 'svelte/elements';
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
class: className,
|
||||||
|
children,
|
||||||
|
...restProps
|
||||||
|
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div
|
||||||
|
bind:this={ref}
|
||||||
|
data-slot="alert-dialog-header"
|
||||||
|
class={cn(
|
||||||
|
'grid grid-rows-[auto_1fr] place-items-center gap-1.5 text-center has-data-[slot=alert-dialog-media]:grid-rows-[auto_auto_1fr] has-data-[slot=alert-dialog-media]:gap-x-4 sm:group-data-[size=default]/alert-dialog-content:place-items-start sm:group-data-[size=default]/alert-dialog-content:text-left sm:group-data-[size=default]/alert-dialog-content:has-data-[slot=alert-dialog-media]:grid-rows-[auto_1fr]',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...restProps}
|
||||||
|
>
|
||||||
|
{@render children?.()}
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { cn, type WithElementRef } from '$lib/utils.js';
|
||||||
|
import type { HTMLAttributes } from 'svelte/elements';
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
class: className,
|
||||||
|
children,
|
||||||
|
...restProps
|
||||||
|
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div
|
||||||
|
bind:this={ref}
|
||||||
|
data-slot="alert-dialog-media"
|
||||||
|
class={cn(
|
||||||
|
"mb-2 inline-flex size-10 items-center justify-center rounded-md bg-muted sm:group-data-[size=default]/alert-dialog-content:row-span-2 *:[svg:not([class*='size-'])]:size-6",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...restProps}
|
||||||
|
>
|
||||||
|
{@render children?.()}
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { AlertDialog as AlertDialogPrimitive } from 'bits-ui';
|
||||||
|
import { cn } from '$lib/utils.js';
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
class: className,
|
||||||
|
...restProps
|
||||||
|
}: AlertDialogPrimitive.OverlayProps = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<AlertDialogPrimitive.Overlay
|
||||||
|
bind:ref
|
||||||
|
data-slot="alert-dialog-overlay"
|
||||||
|
class={cn(
|
||||||
|
'bg-black/10 duration-100 supports-backdrop-filter:backdrop-blur-xs data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0 fixed inset-0 z-50',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...restProps}
|
||||||
|
/>
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { AlertDialog as AlertDialogPrimitive } from 'bits-ui';
|
||||||
|
|
||||||
|
let { ...restProps }: AlertDialogPrimitive.PortalProps = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<AlertDialogPrimitive.Portal {...restProps} />
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { AlertDialog as AlertDialogPrimitive } from 'bits-ui';
|
||||||
|
import { cn } from '$lib/utils.js';
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
class: className,
|
||||||
|
...restProps
|
||||||
|
}: AlertDialogPrimitive.TitleProps = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<AlertDialogPrimitive.Title
|
||||||
|
bind:ref
|
||||||
|
data-slot="alert-dialog-title"
|
||||||
|
class={cn(
|
||||||
|
'text-base font-medium sm:group-data-[size=default]/alert-dialog-content:group-has-data-[slot=alert-dialog-media]/alert-dialog-content:col-start-2',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...restProps}
|
||||||
|
/>
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { AlertDialog as AlertDialogPrimitive } from 'bits-ui';
|
||||||
|
|
||||||
|
let { ref = $bindable(null), ...restProps }: AlertDialogPrimitive.TriggerProps = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<AlertDialogPrimitive.Trigger bind:ref data-slot="alert-dialog-trigger" {...restProps} />
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { AlertDialog as AlertDialogPrimitive } from 'bits-ui';
|
||||||
|
|
||||||
|
let { open = $bindable(false), ...restProps }: AlertDialogPrimitive.RootProps = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<AlertDialogPrimitive.Root bind:open {...restProps} />
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import Action from './alert-dialog-action.svelte';
|
||||||
|
import Cancel from './alert-dialog-cancel.svelte';
|
||||||
|
import Content from './alert-dialog-content.svelte';
|
||||||
|
import Description from './alert-dialog-description.svelte';
|
||||||
|
import Footer from './alert-dialog-footer.svelte';
|
||||||
|
import Header from './alert-dialog-header.svelte';
|
||||||
|
import Media from './alert-dialog-media.svelte';
|
||||||
|
import Overlay from './alert-dialog-overlay.svelte';
|
||||||
|
import Portal from './alert-dialog-portal.svelte';
|
||||||
|
import Title from './alert-dialog-title.svelte';
|
||||||
|
import Trigger from './alert-dialog-trigger.svelte';
|
||||||
|
import Root from './alert-dialog.svelte';
|
||||||
|
|
||||||
|
export {
|
||||||
|
Root,
|
||||||
|
Title,
|
||||||
|
Action,
|
||||||
|
Cancel,
|
||||||
|
Portal,
|
||||||
|
Footer,
|
||||||
|
Header,
|
||||||
|
Trigger,
|
||||||
|
Overlay,
|
||||||
|
Content,
|
||||||
|
Description,
|
||||||
|
Media,
|
||||||
|
//
|
||||||
|
Root as AlertDialog,
|
||||||
|
Title as AlertDialogTitle,
|
||||||
|
Action as AlertDialogAction,
|
||||||
|
Cancel as AlertDialogCancel,
|
||||||
|
Portal as AlertDialogPortal,
|
||||||
|
Footer as AlertDialogFooter,
|
||||||
|
Header as AlertDialogHeader,
|
||||||
|
Trigger as AlertDialogTrigger,
|
||||||
|
Overlay as AlertDialogOverlay,
|
||||||
|
Content as AlertDialogContent,
|
||||||
|
Description as AlertDialogDescription,
|
||||||
|
Media as AlertDialogMedia
|
||||||
|
};
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import Close from './popover-close.svelte';
|
||||||
|
import Content from './popover-content.svelte';
|
||||||
|
import Description from './popover-description.svelte';
|
||||||
|
import Header from './popover-header.svelte';
|
||||||
|
import Portal from './popover-portal.svelte';
|
||||||
|
import Title from './popover-title.svelte';
|
||||||
|
import Trigger from './popover-trigger.svelte';
|
||||||
|
import Root from './popover.svelte';
|
||||||
|
|
||||||
|
export {
|
||||||
|
Root,
|
||||||
|
Content,
|
||||||
|
Description,
|
||||||
|
Header,
|
||||||
|
Title,
|
||||||
|
Trigger,
|
||||||
|
Close,
|
||||||
|
Portal,
|
||||||
|
//
|
||||||
|
Root as Popover,
|
||||||
|
Content as PopoverContent,
|
||||||
|
Description as PopoverDescription,
|
||||||
|
Header as PopoverHeader,
|
||||||
|
Title as PopoverTitle,
|
||||||
|
Trigger as PopoverTrigger,
|
||||||
|
Close as PopoverClose,
|
||||||
|
Portal as PopoverPortal
|
||||||
|
};
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { Popover as PopoverPrimitive } from 'bits-ui';
|
||||||
|
|
||||||
|
let { ref = $bindable(null), ...restProps }: PopoverPrimitive.CloseProps = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<PopoverPrimitive.Close bind:ref data-slot="popover-close" {...restProps} />
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { Popover as PopoverPrimitive } from 'bits-ui';
|
||||||
|
import { cn, type WithoutChildrenOrChild } from '$lib/utils.js';
|
||||||
|
import PopoverPortal from './popover-portal.svelte';
|
||||||
|
import type { ComponentProps } from 'svelte';
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
class: className,
|
||||||
|
sideOffset = 4,
|
||||||
|
align = 'center',
|
||||||
|
portalProps,
|
||||||
|
...restProps
|
||||||
|
}: PopoverPrimitive.ContentProps & {
|
||||||
|
portalProps?: WithoutChildrenOrChild<ComponentProps<typeof PopoverPortal>>;
|
||||||
|
} = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<PopoverPortal {...portalProps}>
|
||||||
|
<PopoverPrimitive.Content
|
||||||
|
bind:ref
|
||||||
|
data-slot="popover-content"
|
||||||
|
{sideOffset}
|
||||||
|
{align}
|
||||||
|
class={cn(
|
||||||
|
'flex flex-col gap-2.5 rounded-lg bg-popover p-2.5 text-sm text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 z-50 w-72 origin-(--transform-origin) outline-hidden',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...restProps}
|
||||||
|
/>
|
||||||
|
</PopoverPortal>
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { cn, type WithElementRef } from '$lib/utils.js';
|
||||||
|
import type { HTMLAttributes } from 'svelte/elements';
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
class: className,
|
||||||
|
children,
|
||||||
|
...restProps
|
||||||
|
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div
|
||||||
|
bind:this={ref}
|
||||||
|
data-slot="popover-description"
|
||||||
|
class={cn('text-muted-foreground', className)}
|
||||||
|
{...restProps}
|
||||||
|
>
|
||||||
|
{@render children?.()}
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { cn, type WithElementRef } from '$lib/utils.js';
|
||||||
|
import type { HTMLAttributes } from 'svelte/elements';
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
class: className,
|
||||||
|
children,
|
||||||
|
...restProps
|
||||||
|
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div
|
||||||
|
bind:this={ref}
|
||||||
|
data-slot="popover-header"
|
||||||
|
class={cn('flex flex-col gap-0.5 text-sm', className)}
|
||||||
|
{...restProps}
|
||||||
|
>
|
||||||
|
{@render children?.()}
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { Popover as PopoverPrimitive } from 'bits-ui';
|
||||||
|
|
||||||
|
let { ...restProps }: PopoverPrimitive.PortalProps = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<PopoverPrimitive.Portal {...restProps} />
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { cn, type WithElementRef } from '$lib/utils.js';
|
||||||
|
import type { HTMLAttributes } from 'svelte/elements';
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
class: className,
|
||||||
|
children,
|
||||||
|
...restProps
|
||||||
|
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div bind:this={ref} data-slot="popover-title" class={cn('font-medium', className)} {...restProps}>
|
||||||
|
{@render children?.()}
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { Popover as PopoverPrimitive } from 'bits-ui';
|
||||||
|
import { cn } from '$lib/utils.js';
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
class: className,
|
||||||
|
...restProps
|
||||||
|
}: PopoverPrimitive.TriggerProps = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<PopoverPrimitive.Trigger
|
||||||
|
bind:ref
|
||||||
|
data-slot="popover-trigger"
|
||||||
|
class={cn('', className)}
|
||||||
|
{...restProps}
|
||||||
|
/>
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { Popover as PopoverPrimitive } from 'bits-ui';
|
||||||
|
|
||||||
|
let { open = $bindable(false), ...restProps }: PopoverPrimitive.RootProps = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<PopoverPrimitive.Root bind:open {...restProps} />
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import Root from './separator.svelte';
|
||||||
|
|
||||||
|
export {
|
||||||
|
Root,
|
||||||
|
//
|
||||||
|
Root as Separator
|
||||||
|
};
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { Separator as SeparatorPrimitive } from 'bits-ui';
|
||||||
|
import { cn } from '$lib/utils.js';
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
class: className,
|
||||||
|
'data-slot': dataSlot = 'separator',
|
||||||
|
...restProps
|
||||||
|
}: SeparatorPrimitive.RootProps = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<SeparatorPrimitive.Root
|
||||||
|
bind:ref
|
||||||
|
data-slot={dataSlot}
|
||||||
|
class={cn(
|
||||||
|
'shrink-0 bg-border data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:w-px',
|
||||||
|
// this is different in shadcn/ui but self-stretch breaks things for us
|
||||||
|
'data-[orientation=vertical]:h-full',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...restProps}
|
||||||
|
/>
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import Root from './slider.svelte';
|
||||||
|
|
||||||
|
export {
|
||||||
|
Root,
|
||||||
|
//
|
||||||
|
Root as Slider
|
||||||
|
};
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { Slider as SliderPrimitive } from 'bits-ui';
|
||||||
|
import { cn, type WithoutChildrenOrChild } from '$lib/utils.js';
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
value = $bindable(),
|
||||||
|
orientation = 'horizontal',
|
||||||
|
thumbId = undefined,
|
||||||
|
thumbAriaLabel = undefined,
|
||||||
|
class: className,
|
||||||
|
...restProps
|
||||||
|
}: WithoutChildrenOrChild<SliderPrimitive.RootProps> & {
|
||||||
|
thumbId?: string | undefined;
|
||||||
|
thumbAriaLabel?: string | undefined;
|
||||||
|
} = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<!--
|
||||||
|
Discriminated Unions + Destructing (required for bindable) do not
|
||||||
|
get along, so we shut typescript up by casting `value` to `never`.
|
||||||
|
-->
|
||||||
|
<SliderPrimitive.Root
|
||||||
|
bind:ref
|
||||||
|
bind:value={value as never}
|
||||||
|
data-slot="slider"
|
||||||
|
{orientation}
|
||||||
|
class={cn(
|
||||||
|
'data-vertical:min-h-40 relative flex w-full touch-none items-center select-none data-disabled:opacity-50 data-vertical:h-full data-vertical:w-auto data-vertical:flex-col',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...restProps}
|
||||||
|
>
|
||||||
|
{#snippet children({ thumbItems })}
|
||||||
|
<span
|
||||||
|
data-slot="slider-track"
|
||||||
|
data-orientation={orientation}
|
||||||
|
class={cn(
|
||||||
|
'rounded-full bg-muted data-horizontal:h-1 data-horizontal:w-full data-vertical:h-full data-vertical:w-1 relative grow overflow-hidden bg-muted data-horizontal:w-full data-vertical:h-full'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<SliderPrimitive.Range
|
||||||
|
data-slot="slider-range"
|
||||||
|
class={cn('bg-primary absolute select-none data-horizontal:h-full data-vertical:w-full')}
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
{#each thumbItems as thumb (thumb.index)}
|
||||||
|
<SliderPrimitive.Thumb
|
||||||
|
data-slot="slider-thumb"
|
||||||
|
index={thumb.index}
|
||||||
|
id={thumbId}
|
||||||
|
aria-label={thumbAriaLabel}
|
||||||
|
class="relative size-3 rounded-full border border-ring bg-white ring-ring/50 transition-[color,box-shadow] after:absolute after:-inset-2 hover:ring-3 focus-visible:ring-3 focus-visible:outline-hidden active:ring-3 block shrink-0 select-none disabled:pointer-events-none disabled:opacity-50"
|
||||||
|
/>
|
||||||
|
{/each}
|
||||||
|
{/snippet}
|
||||||
|
</SliderPrimitive.Root>
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
export const downloadBlob = (
|
||||||
|
bytes: Uint8Array,
|
||||||
|
fileName: string,
|
||||||
|
mimeType: string = 'application/pdf'
|
||||||
|
) => {
|
||||||
|
const buffer = new Uint8Array(bytes.byteLength);
|
||||||
|
buffer.set(bytes);
|
||||||
|
const blob = new Blob([buffer.buffer as ArrayBuffer], { type: mimeType });
|
||||||
|
const objectUrl = URL.createObjectURL(blob);
|
||||||
|
const downloadLink = document.createElement('a');
|
||||||
|
|
||||||
|
downloadLink.href = objectUrl;
|
||||||
|
downloadLink.download = fileName;
|
||||||
|
downloadLink.rel = 'noopener';
|
||||||
|
downloadLink.click();
|
||||||
|
window.setTimeout(() => URL.revokeObjectURL(objectUrl), 1000);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const downloadBlobUrl = (blobUrl: string, fileName: string) => {
|
||||||
|
const downloadLink = document.createElement('a');
|
||||||
|
downloadLink.href = blobUrl;
|
||||||
|
downloadLink.download = fileName;
|
||||||
|
downloadLink.rel = 'noopener';
|
||||||
|
downloadLink.click();
|
||||||
|
};
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
import { PDFDocument } from 'pdf-lib';
|
||||||
|
|
||||||
|
export type CompressionPreset = {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
description: string;
|
||||||
|
scale: number;
|
||||||
|
quality: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const compressionPresets: CompressionPreset[] = [
|
||||||
|
{
|
||||||
|
id: 'strong',
|
||||||
|
label: 'Starke Komprimierung',
|
||||||
|
description: '~72 DPI, JPEG 50%',
|
||||||
|
scale: 1,
|
||||||
|
quality: 0.5
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'balanced',
|
||||||
|
label: 'Ausgewogen',
|
||||||
|
description: '~108 DPI, JPEG 70%',
|
||||||
|
scale: 1.5,
|
||||||
|
quality: 0.7
|
||||||
|
},
|
||||||
|
{ id: 'light', label: 'Leicht', description: '~144 DPI, JPEG 85%', scale: 2, quality: 0.85 }
|
||||||
|
];
|
||||||
|
|
||||||
|
export const defaultCompressionPreset = compressionPresets[1];
|
||||||
|
|
||||||
|
const loadPdfjs = async () => {
|
||||||
|
const pdfjsLib = await import('pdfjs-dist');
|
||||||
|
const { getDocument, GlobalWorkerOptions } = pdfjsLib;
|
||||||
|
GlobalWorkerOptions.workerSrc = await import('pdfjs-dist/build/pdf.worker.mjs?url').then(
|
||||||
|
(module) => module.default
|
||||||
|
);
|
||||||
|
return { getDocument };
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rasterizes every page of `file` with the given preset and rebuilds a
|
||||||
|
* JPEG-compressed PDF from the rendered pages. Throws when the file cannot be
|
||||||
|
* processed; callers are responsible for user-facing error messages.
|
||||||
|
*/
|
||||||
|
export const compressWithPreset = async (
|
||||||
|
file: File,
|
||||||
|
preset: CompressionPreset
|
||||||
|
): Promise<Uint8Array> => {
|
||||||
|
const { getDocument } = await loadPdfjs();
|
||||||
|
|
||||||
|
const bytes = new Uint8Array(await file.arrayBuffer());
|
||||||
|
const loadingTask = getDocument({ data: bytes });
|
||||||
|
|
||||||
|
try {
|
||||||
|
const pdfjsDoc = await loadingTask.promise;
|
||||||
|
const outputPdf = await PDFDocument.create();
|
||||||
|
|
||||||
|
for (let pageNumber = 1; pageNumber <= pdfjsDoc.numPages; pageNumber += 1) {
|
||||||
|
const page = await pdfjsDoc.getPage(pageNumber);
|
||||||
|
const viewport = page.getViewport({ scale: preset.scale });
|
||||||
|
const canvas = document.createElement('canvas');
|
||||||
|
canvas.width = Math.ceil(viewport.width);
|
||||||
|
canvas.height = Math.ceil(viewport.height);
|
||||||
|
const canvasContext = canvas.getContext('2d');
|
||||||
|
if (!canvasContext) throw new Error('Could not create canvas context');
|
||||||
|
|
||||||
|
await page.render({
|
||||||
|
canvas,
|
||||||
|
canvasContext,
|
||||||
|
viewport,
|
||||||
|
background: '#ffffff'
|
||||||
|
}).promise;
|
||||||
|
|
||||||
|
const jpegBlob = await new Promise<Blob>((resolve, reject) => {
|
||||||
|
canvas.toBlob(
|
||||||
|
(result) => (result ? resolve(result) : reject(new Error('JPEG encode failed'))),
|
||||||
|
'image/jpeg',
|
||||||
|
preset.quality
|
||||||
|
);
|
||||||
|
});
|
||||||
|
const jpegImage = await outputPdf.embedJpg(new Uint8Array(await jpegBlob.arrayBuffer()));
|
||||||
|
const originalViewport = page.getViewport({ scale: 1 });
|
||||||
|
const pdfPage = outputPdf.addPage([originalViewport.width, originalViewport.height]);
|
||||||
|
pdfPage.drawImage(jpegImage, {
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
width: pdfPage.getWidth(),
|
||||||
|
height: pdfPage.getHeight()
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return await outputPdf.save();
|
||||||
|
} finally {
|
||||||
|
await loadingTask.destroy();
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import { rgb, type RGB } from 'pdf-lib';
|
||||||
|
|
||||||
|
export type OverlayPosition =
|
||||||
|
| 'center'
|
||||||
|
| 'top-left'
|
||||||
|
| 'top-center'
|
||||||
|
| 'top-right'
|
||||||
|
| 'bottom-left'
|
||||||
|
| 'bottom-center'
|
||||||
|
| 'bottom-right';
|
||||||
|
|
||||||
|
export type WatermarkPosition = OverlayPosition | 'diagonal';
|
||||||
|
|
||||||
|
export const getPositionCoords = (
|
||||||
|
pageWidth: number,
|
||||||
|
pageHeight: number,
|
||||||
|
markWidth: number,
|
||||||
|
markHeight: number,
|
||||||
|
position: WatermarkPosition,
|
||||||
|
margin = 20
|
||||||
|
): { x: number; y: number } => {
|
||||||
|
const horizontallyCentered = (pageWidth - markWidth) / 2;
|
||||||
|
|
||||||
|
switch (position) {
|
||||||
|
case 'center':
|
||||||
|
case 'diagonal':
|
||||||
|
return { x: horizontallyCentered, y: (pageHeight - markHeight) / 2 };
|
||||||
|
case 'top-left':
|
||||||
|
return { x: margin, y: pageHeight - markHeight - margin };
|
||||||
|
case 'top-center':
|
||||||
|
return { x: horizontallyCentered, y: pageHeight - markHeight - margin };
|
||||||
|
case 'top-right':
|
||||||
|
return { x: pageWidth - markWidth - margin, y: pageHeight - markHeight - margin };
|
||||||
|
case 'bottom-left':
|
||||||
|
return { x: margin, y: margin };
|
||||||
|
case 'bottom-center':
|
||||||
|
return { x: horizontallyCentered, y: margin };
|
||||||
|
case 'bottom-right':
|
||||||
|
return { x: pageWidth - markWidth - margin, y: margin };
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const parseHexColor = (hexColor: string): RGB => {
|
||||||
|
const match = /^#([\da-f]{2})([\da-f]{2})([\da-f]{2})$/i.exec(hexColor);
|
||||||
|
if (!match) return rgb(0.8, 0.1, 0.1);
|
||||||
|
|
||||||
|
return rgb(
|
||||||
|
Number.parseInt(match[1], 16) / 255,
|
||||||
|
Number.parseInt(match[2], 16) / 255,
|
||||||
|
Number.parseInt(match[3], 16) / 255
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import { PDFDocument } from 'pdf-lib';
|
||||||
|
|
||||||
|
export const mergePdfFiles = async (files: File[]): Promise<Uint8Array> => {
|
||||||
|
if (files.length < 2) throw new Error('At least two PDF files are required.');
|
||||||
|
|
||||||
|
const mergedDocument = await PDFDocument.create();
|
||||||
|
for (const file of files) {
|
||||||
|
const sourceDocument = await PDFDocument.load(new Uint8Array(await file.arrayBuffer()));
|
||||||
|
const copiedPages = await mergedDocument.copyPages(
|
||||||
|
sourceDocument,
|
||||||
|
sourceDocument.getPageIndices()
|
||||||
|
);
|
||||||
|
for (const page of copiedPages) mergedDocument.addPage(page);
|
||||||
|
}
|
||||||
|
|
||||||
|
return mergedDocument.save();
|
||||||
|
};
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
export type PdfThumbnail = {
|
||||||
|
pageNumber: number;
|
||||||
|
imageUrl: string;
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type PdfDocumentHandle = {
|
||||||
|
pageCount: number;
|
||||||
|
thumbnails: PdfThumbnail[];
|
||||||
|
destroy: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function loadPdfDocument(data: Uint8Array): Promise<PdfDocumentHandle> {
|
||||||
|
const pdfjsLib = await import('pdfjs-dist');
|
||||||
|
const { getDocument, GlobalWorkerOptions } = pdfjsLib;
|
||||||
|
|
||||||
|
GlobalWorkerOptions.workerSrc = await import('pdfjs-dist/build/pdf.worker.mjs?url').then(
|
||||||
|
(module) => module.default
|
||||||
|
);
|
||||||
|
|
||||||
|
const loadingTask = getDocument({ data: new Uint8Array(data) });
|
||||||
|
const thumbnails: PdfThumbnail[] = [];
|
||||||
|
|
||||||
|
try {
|
||||||
|
const pdfDocument = await loadingTask.promise;
|
||||||
|
|
||||||
|
for (let pageNumber = 1; pageNumber <= pdfDocument.numPages; pageNumber += 1) {
|
||||||
|
const page = await pdfDocument.getPage(pageNumber);
|
||||||
|
const viewport = page.getViewport({ scale: 0.5 });
|
||||||
|
const canvas = document.createElement('canvas');
|
||||||
|
canvas.width = Math.ceil(viewport.width);
|
||||||
|
canvas.height = Math.ceil(viewport.height);
|
||||||
|
const canvasContext = canvas.getContext('2d');
|
||||||
|
|
||||||
|
if (!canvasContext) throw new Error('Could not create thumbnail canvas context.');
|
||||||
|
|
||||||
|
await page.render({ canvas, canvasContext, viewport }).promise;
|
||||||
|
const blob = await new Promise<Blob>((resolve, reject) => {
|
||||||
|
canvas.toBlob(
|
||||||
|
(result) =>
|
||||||
|
result ? resolve(result) : reject(new Error('Could not encode PDF thumbnail.')),
|
||||||
|
'image/png'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
thumbnails.push({
|
||||||
|
pageNumber,
|
||||||
|
imageUrl: URL.createObjectURL(blob),
|
||||||
|
width: viewport.width,
|
||||||
|
height: viewport.height
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let destroyed = false;
|
||||||
|
return {
|
||||||
|
pageCount: pdfDocument.numPages,
|
||||||
|
thumbnails,
|
||||||
|
destroy: () => {
|
||||||
|
if (destroyed) return;
|
||||||
|
destroyed = true;
|
||||||
|
for (const thumbnail of thumbnails) URL.revokeObjectURL(thumbnail.imageUrl);
|
||||||
|
void loadingTask.destroy();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
for (const thumbnail of thumbnails) URL.revokeObjectURL(thumbnail.imageUrl);
|
||||||
|
await loadingTask.destroy();
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
let pendingZipFiles: File[] = [];
|
||||||
|
|
||||||
|
export const queueZipFiles = (files: File[]) => {
|
||||||
|
pendingZipFiles = [...pendingZipFiles, ...files];
|
||||||
|
};
|
||||||
|
|
||||||
|
export const takeQueuedZipFiles = () => {
|
||||||
|
const files = pendingZipFiles;
|
||||||
|
pendingZipFiles = [];
|
||||||
|
return files;
|
||||||
|
};
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
export const RASTERIZATION_WARNING_BYTES = 25 * 1024 * 1024;
|
||||||
|
export const RASTERIZATION_MAX_BYTES = 100 * 1024 * 1024;
|
||||||
|
|
||||||
|
export const isRasterizationTooLarge = (file: File | null) =>
|
||||||
|
file !== null && file.size > RASTERIZATION_MAX_BYTES;
|
||||||
|
|
||||||
|
export const shouldWarnAboutRasterization = (file: File | null) =>
|
||||||
|
file !== null && file.size > RASTERIZATION_WARNING_BYTES;
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
export type Tool = {
|
||||||
|
slug: string;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
icon: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const tools: Tool[] = [
|
||||||
|
{
|
||||||
|
slug: 'stamp',
|
||||||
|
title: 'Stempeln',
|
||||||
|
description: 'Versehen Sie Seiten mit Textstempeln wie „Beglaubigt“ oder „Eilt“.',
|
||||||
|
icon: 'stamp'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
slug: 'watermark',
|
||||||
|
title: 'Wasserzeichen',
|
||||||
|
description: 'Fügen Sie ein Wasserzeichen zu Ihrem PDF hinzu.',
|
||||||
|
icon: 'droplets'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
slug: 'encrypt',
|
||||||
|
title: 'Passwort setzen',
|
||||||
|
description: 'Verschlüsseln Sie Ihr PDF mit einem Passwort.',
|
||||||
|
icon: 'lock'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
slug: 'decrypt',
|
||||||
|
title: 'Passwort entfernen',
|
||||||
|
description: 'Entsperren Sie ein passwortgeschütztes PDF.',
|
||||||
|
icon: 'unlock'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
slug: 'convert',
|
||||||
|
title: 'Konvertieren',
|
||||||
|
description: 'Konvertieren Sie ein PDF in Bilder.',
|
||||||
|
icon: 'image'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
slug: 'compress',
|
||||||
|
title: 'Komprimieren',
|
||||||
|
description: 'Komprimieren Sie ein PDF für kleinere Dateigröße.',
|
||||||
|
icon: 'minimize'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
slug: 'separate',
|
||||||
|
title: 'Seiten entfernen',
|
||||||
|
description: 'Entfernen Sie Seiten aus einem PDF.',
|
||||||
|
icon: 'scissors'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
slug: 'rotate',
|
||||||
|
title: 'Seiten drehen',
|
||||||
|
description: 'Drehen Sie einzelne oder alle Seiten eines PDFs.',
|
||||||
|
icon: 'rotate-cw'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
slug: 'merge',
|
||||||
|
title: 'PDFs zusammenfügen',
|
||||||
|
description: 'Fügen Sie mehrere PDFs zu einer Datei zusammen.',
|
||||||
|
icon: 'merge'
|
||||||
|
}
|
||||||
|
];
|
||||||
+4
-4
@@ -1,13 +1,13 @@
|
|||||||
import { clsx, type ClassValue } from "clsx";
|
import { clsx, type ClassValue } from 'clsx';
|
||||||
import { twMerge } from "tailwind-merge";
|
import { twMerge } from 'tailwind-merge';
|
||||||
|
|
||||||
export function cn(...inputs: ClassValue[]) {
|
export function cn(...inputs: ClassValue[]) {
|
||||||
return twMerge(clsx(inputs));
|
return twMerge(clsx(inputs));
|
||||||
}
|
}
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
export type WithoutChild<T> = T extends { child?: any } ? Omit<T, "child"> : T;
|
export type WithoutChild<T> = T extends { child?: any } ? Omit<T, 'child'> : T;
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
export type WithoutChildren<T> = T extends { children?: any } ? Omit<T, "children"> : T;
|
export type WithoutChildren<T> = T extends { children?: any } ? Omit<T, 'children'> : T;
|
||||||
export type WithoutChildrenOrChild<T> = WithoutChildren<WithoutChild<T>>;
|
export type WithoutChildrenOrChild<T> = WithoutChildren<WithoutChild<T>>;
|
||||||
export type WithElementRef<T, U extends HTMLElement = HTMLElement> = T & { ref?: U | null };
|
export type WithElementRef<T, U extends HTMLElement = HTMLElement> = T & { ref?: U | null };
|
||||||
|
|||||||
@@ -572,6 +572,25 @@ export const dissolveSubDocument = (
|
|||||||
const stripPdfExtension = (name: string) =>
|
const stripPdfExtension = (name: string) =>
|
||||||
name.toLowerCase().endsWith('.pdf') ? name.slice(0, -'.pdf'.length) : name;
|
name.toLowerCase().endsWith('.pdf') ? name.slice(0, -'.pdf'.length) : name;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a single PDF export unit for one sub-document. The resulting filename
|
||||||
|
* follows the same convention as the separate archive export:
|
||||||
|
* `{archiveName} - {subDocumentName}` (without duplicate `.pdf` suffixes).
|
||||||
|
*/
|
||||||
|
export const buildSubDocumentExport = async (
|
||||||
|
archiveName: string,
|
||||||
|
subDocument: SubDocument
|
||||||
|
): Promise<ExportUnit> => {
|
||||||
|
if (subDocument.attachments.length === 0) {
|
||||||
|
throw new Error('Cannot create a PDF from an empty sub-document.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
name: `${stripPdfExtension(archiveName)} - ${stripPdfExtension(subDocument.name)}`,
|
||||||
|
bytes: await mergeAttachments(subDocument.attachments)
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Build one or more PDF export units from the archive.
|
* Build one or more PDF export units from the archive.
|
||||||
*
|
*
|
||||||
@@ -607,10 +626,7 @@ export const buildArchiveExport = async (
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
separateUnits.push({
|
separateUnits.push(await buildSubDocumentExport(archive.name, subDocument));
|
||||||
name: `${stripPdfExtension(archive.name)} - ${stripPdfExtension(subDocument.name)}`,
|
|
||||||
bytes: await mergeAttachments(subDocument.attachments)
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (archive.attachments.length > 0) {
|
if (archive.attachments.length > 0) {
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
export const ZIP_ACCEPT_TYPES = '.zip,application/zip,application/x-zip-compressed';
|
||||||
|
|
||||||
|
export const isAcceptedZipFile = (file: File): boolean => {
|
||||||
|
if (file.name.toLowerCase().endsWith('.zip')) return true;
|
||||||
|
return ['application/zip', 'application/x-zip-compressed'].includes(file.type.toLowerCase());
|
||||||
|
};
|
||||||
+57
-43
@@ -1,51 +1,65 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import './layout.css';
|
import './layout.css';
|
||||||
import favicon from '$lib/assets/favicon.svg';
|
import favicon from '$lib/assets/favicon.svg';
|
||||||
|
|
||||||
let {children} = $props();
|
let { children } = $props();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:head>
|
<svelte:head>
|
||||||
<link rel="icon" href={favicon}/>
|
<link rel="icon" href={favicon} />
|
||||||
</svelte:head>
|
</svelte:head>
|
||||||
<header style="height:72px" class="bg-white shadow grid place-content-center">
|
<div class="flex min-h-dvh flex-col">
|
||||||
<a href="/">
|
<header style="height:72px" class="grid shrink-0 place-content-center bg-white shadow">
|
||||||
<img height="33px" src="/logo.svg" alt="logo"/>
|
<a href="/">
|
||||||
</a>
|
<img height="33px" src="/logo.svg" alt="logo" />
|
||||||
</header>
|
</a>
|
||||||
{@render children()}
|
</header>
|
||||||
|
|
||||||
<footer class="flex flex-col justify-end gap-15 min-h-78.5 h-96 px-4 mt-8.75 text-white py-8.75"
|
<div class="flex-1">
|
||||||
style="background-image: url('/footer.svg');background-position: top right; background-size: cover; background-repeat: no-repeat">
|
{@render children()}
|
||||||
<div class="w-full max-w-360 mx-auto">
|
</div>
|
||||||
<h2 class="font-bold mb-4">Nominandum GmbH</h2>
|
|
||||||
|
|
||||||
<div class="flex flex-row flex-wrap gap-15 text-[14px] leading-5 mx-auto">
|
<footer
|
||||||
<div class="not-sm:hidden">
|
class="mt-8.75 flex h-96 min-h-78.5 shrink-0 flex-col justify-end gap-15 px-4 py-8.75 text-white"
|
||||||
Holstenwall 1<br/>
|
style="background-image: url('/footer.svg');background-position: top right; background-size: cover; background-repeat: no-repeat"
|
||||||
20355 Hamburg<br/>
|
>
|
||||||
<br/>
|
<div class="mx-auto w-full max-w-360">
|
||||||
info[at]nom.legal
|
<h2 class="mb-4 hidden font-bold">Nominandum GmbH</h2>
|
||||||
</div>
|
|
||||||
<div class="not-sm:hidden">
|
<div class="mx-auto flex flex-row flex-wrap gap-15 text-[14px] leading-5">
|
||||||
Geschäftsführer Andreas Damke <br/>
|
<div>
|
||||||
AG Hamburg <br/>
|
<a class="underline" href="/impressum">Impressum</a><br />
|
||||||
HRB 161778<br/>
|
<a class="underline" href="/datenschutz">Datenschutzerklärung</a><br />
|
||||||
UstId. DE321802543
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
|
||||||
<a class="underline" href="">Impressum</a><br/>
|
<div class="mx-auto hidden flex-row flex-wrap gap-15 text-[14px] leading-5">
|
||||||
<a class="underline" href="">Datenschutzerklärung</a><br/>
|
<div class="not-sm:hidden">
|
||||||
<a class="underline" href="">Nutzungsbedingungen</a><br/>
|
Holstenwall 1<br />
|
||||||
</div>
|
20355 Hamburg<br />
|
||||||
<div class="grow">
|
<br />
|
||||||
<a class="underline" href="">Instagram</a><br/>
|
info[at]nom.legal
|
||||||
<a class="underline" href="">LinkedIn</a><br/>
|
</div>
|
||||||
</div>
|
<div class="not-sm:hidden">
|
||||||
<div class="self-end text-gray-400">
|
Geschäftsführer Andreas Damke <br />
|
||||||
© 2018-{new Date().getFullYear()} all rights reserved.<br/>
|
AG Hamburg <br />
|
||||||
Nominandum is a protected trademark.
|
HRB 161778<br />
|
||||||
</div>
|
UstId. DE321802543
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<div>
|
||||||
</footer>
|
<a class="underline" href="/impressum">Impressum</a><br />
|
||||||
|
<a class="underline" href="/datenschutz">Datenschutzerklärung</a><br />
|
||||||
|
<a class="underline" href="">Nutzungsbedingungen</a><br />
|
||||||
|
</div>
|
||||||
|
<div class="grow">
|
||||||
|
<a class="underline" href="">Instagram</a><br />
|
||||||
|
<a class="underline" href="">LinkedIn</a><br />
|
||||||
|
</div>
|
||||||
|
<div class="self-end text-gray-400">
|
||||||
|
© 2018-{new Date().getFullYear()} all rights reserved.<br />
|
||||||
|
Nominandum is a protected trademark.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
|||||||
+105
-198
@@ -1,211 +1,118 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import {onMount} from 'svelte';
|
import {
|
||||||
import Button from '$lib/components/ui/button.svelte';
|
Archive,
|
||||||
import ProcessedZipArchiveEditor from '$lib/components/ProcessedZipArchiveEditor.svelte';
|
Droplets,
|
||||||
import ZipDropzone from '$lib/components/ZipDropzone.svelte';
|
Lock,
|
||||||
import {
|
Unlock,
|
||||||
extractZipArchives,
|
Image,
|
||||||
mergeProcessedZipArchive,
|
Minimize2,
|
||||||
type ProcessedZipArchive
|
Scissors,
|
||||||
} from '$lib/zip-processing';
|
Merge,
|
||||||
import * as Card from "$lib/components/ui/card/index";
|
Stamp,
|
||||||
|
RotateCw,
|
||||||
|
FileText
|
||||||
|
} from '@lucide/svelte';
|
||||||
|
import { goto } from '$app/navigation';
|
||||||
|
import FileDropzone from '$lib/components/FileDropzone.svelte';
|
||||||
|
import * as Card from '$lib/components/ui/card/index';
|
||||||
|
import { queueZipFiles } from '$lib/pending-files';
|
||||||
|
import { tools } from '$lib/tools';
|
||||||
|
|
||||||
const THUMBNAIL_WIDTH_STORAGE_KEY = 'thumbnailWidth';
|
const iconMap: Record<string, typeof FileText> = {
|
||||||
|
droplets: Droplets,
|
||||||
|
lock: Lock,
|
||||||
|
unlock: Unlock,
|
||||||
|
image: Image,
|
||||||
|
minimize: Minimize2,
|
||||||
|
scissors: Scissors,
|
||||||
|
merge: Merge,
|
||||||
|
stamp: Stamp,
|
||||||
|
'rotate-cw': RotateCw
|
||||||
|
};
|
||||||
|
|
||||||
let selectedZipFiles: File[] = $state([]);
|
const isZipFile = (file: File) =>
|
||||||
let processedZipFiles: ProcessedZipArchive[] = $state([]);
|
file.name.toLowerCase().endsWith('.zip') ||
|
||||||
let pendingZipCount = $state(0);
|
file.type === 'application/zip' ||
|
||||||
let thumbnailWidth = $state(200);
|
file.type === 'application/x-zip-compressed';
|
||||||
let thumbnailWidthHydrated = $state(false);
|
|
||||||
let archiveGeneration = 0;
|
|
||||||
|
|
||||||
const clampThumbnailWidth = (value: number) => Math.min(400, Math.max(100, value));
|
const openBeaFiles = (files: File[]) => {
|
||||||
|
const zipFiles = files.filter(isZipFile);
|
||||||
|
if (zipFiles.length === 0) return;
|
||||||
|
|
||||||
onMount(() => {
|
queueZipFiles(zipFiles);
|
||||||
const storedValue = localStorage.getItem(THUMBNAIL_WIDTH_STORAGE_KEY);
|
void goto('/tools/bea');
|
||||||
|
};
|
||||||
|
|
||||||
if (storedValue !== null) {
|
const handleWindowDragOver = (event: DragEvent) => {
|
||||||
const parsedValue = Number(storedValue);
|
if (!event.dataTransfer?.types.includes('Files')) return;
|
||||||
|
event.preventDefault();
|
||||||
|
};
|
||||||
|
|
||||||
if (Number.isFinite(parsedValue)) {
|
const handleWindowDrop = (event: DragEvent) => {
|
||||||
thumbnailWidth = clampThumbnailWidth(parsedValue);
|
if (!event.dataTransfer?.files.length) return;
|
||||||
}
|
event.preventDefault();
|
||||||
}
|
openBeaFiles(Array.from(event.dataTransfer.files));
|
||||||
|
};
|
||||||
thumbnailWidthHydrated = true;
|
|
||||||
});
|
|
||||||
|
|
||||||
$effect(() => {
|
|
||||||
if (!thumbnailWidthHydrated) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
localStorage.setItem(THUMBNAIL_WIDTH_STORAGE_KEY, String(thumbnailWidth));
|
|
||||||
});
|
|
||||||
|
|
||||||
const toArrayBuffer = (bytes: Uint8Array) => {
|
|
||||||
const copy = new Uint8Array(bytes.byteLength);
|
|
||||||
copy.set(bytes);
|
|
||||||
return copy.buffer;
|
|
||||||
};
|
|
||||||
|
|
||||||
const downloadPdfBytes = (bytes: Uint8Array, fileName: string) => {
|
|
||||||
const pdfBlob = new Blob([toArrayBuffer(bytes)], {type: 'application/pdf'});
|
|
||||||
const objectUrl = URL.createObjectURL(pdfBlob);
|
|
||||||
const downloadLink = document.createElement('a');
|
|
||||||
|
|
||||||
downloadLink.href = objectUrl;
|
|
||||||
downloadLink.download = fileName.toLowerCase().endsWith('.pdf') ? fileName : `${fileName}.pdf`;
|
|
||||||
downloadLink.rel = 'noopener';
|
|
||||||
downloadLink.click();
|
|
||||||
window.setTimeout(() => URL.revokeObjectURL(objectUrl), 1000);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleFilesSelected = (files: File[]) => {
|
|
||||||
selectedZipFiles = [...selectedZipFiles, ...files];
|
|
||||||
|
|
||||||
for (const file of files) {
|
|
||||||
void processZipFile(file, archiveGeneration);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const processZipFile = async (file: File, generation: number) => {
|
|
||||||
pendingZipCount += 1;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const processedZipArchives = await extractZipArchives(file);
|
|
||||||
|
|
||||||
if (generation !== archiveGeneration) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
processedZipFiles = [...processedZipFiles, ...processedZipArchives];
|
|
||||||
} catch (error) {
|
|
||||||
console.error(`Failed to process ${file.name}`, error);
|
|
||||||
} finally {
|
|
||||||
if (generation === archiveGeneration) {
|
|
||||||
pendingZipCount -= 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const updateProcessedZipFile = (index: number, archive: ProcessedZipArchive) => {
|
|
||||||
processedZipFiles = processedZipFiles.map((existingArchive, existingIndex) =>
|
|
||||||
existingIndex === index ? archive : existingArchive
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const exportAllZipArchivesAsPdf = async () => {
|
|
||||||
for (const archive of processedZipFiles) {
|
|
||||||
try {
|
|
||||||
const mergedBytes = await mergeProcessedZipArchive(archive);
|
|
||||||
downloadPdfBytes(mergedBytes, archive.name);
|
|
||||||
} catch (error) {
|
|
||||||
console.error(`Failed to export archive ${archive.name}`, error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const deleteAllZipArchives = () => {
|
|
||||||
archiveGeneration += 1;
|
|
||||||
selectedZipFiles = [];
|
|
||||||
processedZipFiles = [];
|
|
||||||
pendingZipCount = 0;
|
|
||||||
};
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#snippet content()}
|
<svelte:head>
|
||||||
{#if selectedZipFiles.length === 0}
|
<title>beA-Edit — Werkzeugkasten für das besondere elektronische Anwaltspostfach</title>
|
||||||
<Card.Root>
|
</svelte:head>
|
||||||
<Card.Content>
|
|
||||||
<ZipDropzone onFilesSelected={handleFilesSelected}/>
|
|
||||||
</Card.Content>
|
|
||||||
</Card.Root>
|
|
||||||
{:else}
|
|
||||||
<details class="fixed bottom-4 right-4 z-20">
|
|
||||||
<summary
|
|
||||||
class="flex h-12 w-12 cursor-pointer list-none items-center justify-center rounded-full border border-primary-200 bg-white text-lg font-semibold text-primary-800 shadow-lg transition hover:border-primary-300 hover:bg-primary-50"
|
|
||||||
>
|
|
||||||
<span class="sr-only">Settings</span>
|
|
||||||
<span>⚙️</span>
|
|
||||||
</summary>
|
|
||||||
|
|
||||||
<div
|
<svelte:window ondragover={handleWindowDragOver} ondrop={handleWindowDrop} />
|
||||||
class="absolute bottom-14 right-0 w-72 rounded-xl border border-primary-100 bg-white p-4 shadow-lg"
|
|
||||||
>
|
|
||||||
<div class="flex flex-col gap-3">
|
|
||||||
<div class="flex gap-2">
|
|
||||||
<Button
|
|
||||||
class="flex-1"
|
|
||||||
type="button"
|
|
||||||
disabled={processedZipFiles.length === 0}
|
|
||||||
onclick={exportAllZipArchivesAsPdf}
|
|
||||||
>
|
|
||||||
Alle ZIP-Archive als PDF exportieren
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<Button
|
<div class="flex flex-col gap-10 px-4 py-10">
|
||||||
class="flex-1"
|
<!-- Hero -->
|
||||||
type="button"
|
<div class="flex flex-col items-center gap-2 text-center">
|
||||||
disabled={selectedZipFiles.length === 0}
|
<h1 class="text-[28px] font-bold text-primary-900">Werkzeugkasten für beA</h1>
|
||||||
variant="secondary"
|
<p class="max-w-lg text-[15px] font-medium text-primary-700">
|
||||||
onclick={deleteAllZipArchives}
|
Bereiten Sie ZIP-Archive aus dem besonderen elektronischen Anwaltspostfach (beA) auf — plus
|
||||||
>
|
praktische PDF-Werkzeuge.
|
||||||
Alle ZIP-Archive löschen
|
</p>
|
||||||
</Button>
|
<p class="max-w-lg text-[13px] text-primary-700">
|
||||||
</div>
|
Alle Werkzeuge laufen zu 100 % in Ihrem Browser — keine Dateien werden hochgeladen.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
<label class="flex flex-col gap-2">
|
<!-- ZIP entry point -->
|
||||||
<div
|
<div class="mx-auto w-full max-w-2xl">
|
||||||
class="flex items-center justify-between gap-3 text-sm font-medium text-primary-900"
|
<FileDropzone
|
||||||
>
|
onFilesSelected={openBeaFiles}
|
||||||
<span>Thumbnail Breite</span>
|
accept=".zip,application/zip,application/x-zip-compressed"
|
||||||
<span class="tabular-nums text-primary-700">{thumbnailWidth}px</span>
|
multiple={true}
|
||||||
</div>
|
ariaLabel="beA-ZIP öffnen"
|
||||||
|
label="beA-ZIP öffnen"
|
||||||
|
sublabel="ZIP aus dem besonderen elektronischen Anwaltspostfach (beA) hier ablegen oder zum Auswählen klicken"
|
||||||
|
icon={Archive}
|
||||||
|
class="py-10"
|
||||||
|
/>
|
||||||
|
<p class="mt-2 text-center text-[12px] text-primary-600">
|
||||||
|
Tipp: In beA exportierte Archive enden auf .zip und enthalten Ihre Anlagen als PDF.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
<input
|
<!-- Tool grid -->
|
||||||
class="w-full accent-primary-700"
|
<div class="mx-auto w-full max-w-4xl">
|
||||||
type="range"
|
<h2 class="mb-4 text-[18px] font-bold text-primary-900">Weitere PDF-Werkzeuge</h2>
|
||||||
min="100"
|
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
max="400"
|
{#each tools as tool}
|
||||||
step="1"
|
{@const Icon = iconMap[tool.icon] ?? FileText}
|
||||||
bind:value={thumbnailWidth}
|
<a href="/tools/{tool.slug}" class="block">
|
||||||
/>
|
<Card.Root class="h-full transition hover:shadow-md cursor-pointer">
|
||||||
</label>
|
<Card.Content class="flex flex-col gap-3 py-6">
|
||||||
</div>
|
<div
|
||||||
</div>
|
class="flex h-10 w-10 items-center justify-center rounded-xl bg-primary/10 text-primary"
|
||||||
</details>
|
>
|
||||||
<div
|
<Icon class="h-5 w-5" />
|
||||||
class="flex min-h-[calc(-180px+100vh)] w-full grow flex-col justify-center gap-4 px-4 py-6"
|
</div>
|
||||||
>
|
<div>
|
||||||
<div class="mx-auto flex w-full flex-col gap-3">
|
<h3 class="text-[15px] font-bold text-primary-900">{tool.title}</h3>
|
||||||
{#if pendingZipCount > 0}
|
<p class="mt-1 text-[13px] leading-snug text-primary-700">{tool.description}</p>
|
||||||
<div
|
</div>
|
||||||
class="rounded-container bg-primary-50 px-4 py-3 text-[14px] font-medium text-primary-900"
|
</Card.Content>
|
||||||
>
|
</Card.Root>
|
||||||
Noch {pendingZipCount} ZIP-Datei{pendingZipCount === 1 ? '' : 'en'} in Bearbeitung.
|
</a>
|
||||||
</div>
|
{/each}
|
||||||
{/if}
|
</div>
|
||||||
|
</div>
|
||||||
{#if processedZipFiles.length > 0}
|
</div>
|
||||||
<div class="flex flex-col gap-4">
|
|
||||||
{#each processedZipFiles as file, index}
|
|
||||||
<ProcessedZipArchiveEditor
|
|
||||||
archive={file}
|
|
||||||
{thumbnailWidth}
|
|
||||||
onArchiveChange={(updatedArchive) => updateProcessedZipFile(index, updatedArchive)}
|
|
||||||
/>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
{/snippet}
|
|
||||||
|
|
||||||
<section class="px-4">
|
|
||||||
<div class="flex flex-wrap items-center justify-between gap-3 py-6">
|
|
||||||
<h1 class="h1 text-[20px] font-bold text-primary-900">beA-Edit</h1>
|
|
||||||
</div>
|
|
||||||
{@render content()}
|
|
||||||
</section>
|
|
||||||
|
|||||||
@@ -0,0 +1,414 @@
|
|||||||
|
<script>
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>Datenschutzerklärung | Nominandum</title>
|
||||||
|
<meta
|
||||||
|
name="description"
|
||||||
|
content="Informationen zum Datenschutz und zur Verarbeitung personenbezogener Daten bei Nominandum."
|
||||||
|
/>
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
<main class="min-h-screen bg-primary-50/50 text-surface-800">
|
||||||
|
<header class="border-b-4 border-tertiary-400 bg-primary-900 text-white bg-[#244272]">
|
||||||
|
<div class="mx-auto max-w-7xl px-4 py-12 sm:px-8 sm:py-16 lg:py-20">
|
||||||
|
<a
|
||||||
|
class="mb-10 inline-flex items-center gap-2 rounded-sm text-sm font-bold text-primary-200 transition-colors hover:text-white focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-white"
|
||||||
|
href="/"
|
||||||
|
aria-label="Zurück zur Startseite"
|
||||||
|
>
|
||||||
|
<span aria-hidden="true">←</span>
|
||||||
|
Startseite
|
||||||
|
</a>
|
||||||
|
<p class="mb-3 text-xs font-extrabold tracking-[0.16em] text-tertiary-300 uppercase">
|
||||||
|
Rechtliches · Datenschutz
|
||||||
|
</p>
|
||||||
|
<h1
|
||||||
|
id="page-title"
|
||||||
|
class="max-w-4xl text-4xl leading-none font-extrabold tracking-tight text-white sm:text-6xl lg:text-7xl"
|
||||||
|
>
|
||||||
|
Datenschutz­erklärung
|
||||||
|
</h1>
|
||||||
|
<p class="mt-6 max-w-2xl text-lg leading-relaxed text-primary-100 sm:text-xl">
|
||||||
|
Was wir erfassen, warum wir es verarbeiten und welche Rechte Sie dabei haben.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="mx-auto grid max-w-7xl gap-8 px-4 py-8 sm:px-8 sm:py-12 lg:grid-cols-[14rem_minmax(0,1fr)] lg:gap-12 lg:py-16"
|
||||||
|
>
|
||||||
|
<aside class="hidden lg:block" aria-label="Inhaltsverzeichnis">
|
||||||
|
<div class="sticky top-8">
|
||||||
|
<p class="mb-4 text-xs font-extrabold tracking-[0.16em] text-primary-700 uppercase">
|
||||||
|
Inhalt
|
||||||
|
</p>
|
||||||
|
<nav
|
||||||
|
class="flex flex-col border-l border-primary-200 [&_a]:grid [&_a]:grid-cols-[2rem_1fr] [&_a]:gap-2 [&_a]:border-l-2 [&_a]:border-transparent [&_a]:px-4 [&_a]:py-2.5 [&_a]:text-sm [&_a]:font-bold [&_a]:text-surface-600 [&_a]:transition-colors [&_a:hover]:border-tertiary-500 [&_a:hover]:text-primary-900 [&_span]:font-extrabold [&_span]:text-primary-400"
|
||||||
|
>
|
||||||
|
<a href="#ueberblick"><span>01</span>Auf einen Blick</a>
|
||||||
|
<a href="#hosting"><span>02</span>Hosting</a>
|
||||||
|
<a href="#pflichtinformationen"><span>03</span>Pflichtinformationen</a>
|
||||||
|
<a href="#datenerfassung"><span>04</span>Datenerfassung</a>
|
||||||
|
</nav>
|
||||||
|
<p class="mt-6 text-xs leading-relaxed text-surface-500">Vier Kapitel</p>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<article aria-labelledby="page-title">
|
||||||
|
<div
|
||||||
|
class="[&_address]:my-6 [&_address]:w-fit [&_address]:rounded-r-xl [&_address]:border-l-4 [&_address]:border-tertiary-400 [&_address]:bg-primary-50 [&_address]:px-5 [&_address]:py-4 [&_address]:not-italic [&_address]:leading-relaxed [&_h2]:mb-8 [&_h2]:text-2xl [&_h2]:leading-tight [&_h2]:font-extrabold [&_h2]:tracking-tight [&_h2]:text-primary-900 sm:[&_h2]:text-3xl [&_h3]:mt-10 [&_h3]:mb-3 [&_h3]:text-xl [&_h3]:leading-snug [&_h3]:font-extrabold [&_h3]:text-primary-800 [&_h4]:mt-6 [&_h4]:mb-2 [&_h4]:font-extrabold [&_h4]:text-surface-900 [&_li]:leading-relaxed [&_p]:leading-[1.75] [&_p+p]:mt-4 [&_section]:scroll-mt-6 [&_section]:rounded-container [&_section]:border [&_section]:border-primary-100 [&_section]:bg-white [&_section]:p-6 [&_section]:shadow-sm sm:[&_section]:p-10 lg:[&_section]:p-14 [&_section+section]:mt-6 [&_ul]:my-5 [&_ul]:grid [&_ul]:list-disc [&_ul]:gap-2 [&_ul]:pl-6 [&_li::marker]:text-tertiary-600 [&_.legal-notice]:my-6 [&_.legal-notice]:rounded-xl [&_.legal-notice]:border [&_.legal-notice]:border-primary-200 [&_.legal-notice]:bg-primary-50 [&_.legal-notice]:p-5 sm:[&_.legal-notice]:p-7 [&_.legal-notice_p]:text-sm [&_.legal-notice_p]:font-bold [&_.legal-notice_p]:tracking-wide [&_.legal-notice_p]:text-primary-950 [&_.source]:mt-8 [&_.source]:flex [&_.source]:items-center [&_.source]:gap-3 [&_.source]:rounded-container [&_.source]:border [&_.source]:border-primary-100 [&_.source]:bg-white [&_.source]:px-6 [&_.source]:py-4 [&_.source]:text-sm [&_.source]:text-surface-500 [&_.source_span]:font-extrabold [&_.source_span]:tracking-wider [&_.source_span]:text-primary-700 [&_.source_span]:uppercase [&_.source_a]:font-bold [&_.source_a]:text-primary-700 [&_.source_a]:underline [&_.source_a]:underline-offset-4"
|
||||||
|
>
|
||||||
|
<section id="ueberblick" data-section="01">
|
||||||
|
<h2>1. Datenschutz auf einen Blick</h2>
|
||||||
|
<h3>Allgemeine Hinweise</h3>
|
||||||
|
<p>
|
||||||
|
Die folgenden Hinweise geben einen einfachen Überblick darüber, was mit Ihren
|
||||||
|
personenbezogenen Daten passiert, wenn Sie diese Website besuchen. Personenbezogene
|
||||||
|
Daten sind alle Daten, mit denen Sie persönlich identifiziert werden können.
|
||||||
|
Ausführliche Informationen zum Thema Datenschutz entnehmen Sie unserer unter diesem Text
|
||||||
|
aufgeführten Datenschutzerklärung.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h3>Datenerfassung auf dieser Website</h3>
|
||||||
|
<h4>Wer ist verantwortlich für die Datenerfassung auf dieser Website?</h4>
|
||||||
|
<p>
|
||||||
|
Die Datenverarbeitung auf dieser Website erfolgt durch den Websitebetreiber. Dessen
|
||||||
|
Kontaktdaten können Sie dem Abschnitt „Hinweis zur Verantwortlichen Stelle“ in dieser
|
||||||
|
Datenschutzerklärung entnehmen.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h4>Wie erfassen wir Ihre Daten?</h4>
|
||||||
|
<p>
|
||||||
|
Ihre Daten werden zum einen dadurch erhoben, dass Sie uns diese mitteilen. Hierbei kann
|
||||||
|
es sich z. B. um Daten handeln, die Sie in ein Kontaktformular eingeben.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
Andere Daten werden automatisch oder nach Ihrer Einwilligung beim Besuch der Website
|
||||||
|
durch unsere IT-Systeme erfasst. Das sind vor allem technische Daten (z. B.
|
||||||
|
Internetbrowser, Betriebssystem oder Uhrzeit des Seitenaufrufs). Die Erfassung dieser
|
||||||
|
Daten erfolgt automatisch, sobald Sie diese Website betreten.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h4>Wofür nutzen wir Ihre Daten?</h4>
|
||||||
|
<p>
|
||||||
|
Ein Teil der Daten wird erhoben, um eine fehlerfreie Bereitstellung der Website zu
|
||||||
|
gewährleisten. Andere Daten können zur Analyse Ihres Nutzerverhaltens verwendet werden.
|
||||||
|
Sofern über die Website Verträge geschlossen oder angebahnt werden können, werden die
|
||||||
|
übermittelten Daten auch für Vertragsangebote, Bestellungen oder sonstige
|
||||||
|
Auftragsanfragen verarbeitet.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h4>Welche Rechte haben Sie bezüglich Ihrer Daten?</h4>
|
||||||
|
<p>
|
||||||
|
Sie haben jederzeit das Recht, unentgeltlich Auskunft über Herkunft, Empfänger und Zweck
|
||||||
|
Ihrer gespeicherten personenbezogenen Daten zu erhalten. Sie haben außerdem ein Recht,
|
||||||
|
die Berichtigung oder Löschung dieser Daten zu verlangen. Wenn Sie eine Einwilligung zur
|
||||||
|
Datenverarbeitung erteilt haben, können Sie diese Einwilligung jederzeit für die Zukunft
|
||||||
|
widerrufen. Außerdem haben Sie das Recht, unter bestimmten Umständen die Einschränkung
|
||||||
|
der Verarbeitung Ihrer personenbezogenen Daten zu verlangen. Des Weiteren steht Ihnen
|
||||||
|
ein Beschwerderecht bei der zuständigen Aufsichtsbehörde zu.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
Hierzu sowie zu weiteren Fragen zum Thema Datenschutz können Sie sich jederzeit an uns
|
||||||
|
wenden.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h3>Analyse-Tools und Tools von Drittanbietern</h3>
|
||||||
|
<p>
|
||||||
|
Beim Besuch dieser Website kann Ihr Surf-Verhalten statistisch ausgewertet werden. Das
|
||||||
|
geschieht vor allem mit sogenannten Analyseprogrammen.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
Detaillierte Informationen zu diesen Analyseprogrammen finden Sie in der folgenden
|
||||||
|
Datenschutzerklärung.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="hosting" data-section="02">
|
||||||
|
<h2>2. Hosting</h2>
|
||||||
|
<p>Wir hosten die Inhalte unserer Website bei folgendem Anbieter:</p>
|
||||||
|
|
||||||
|
<h3>Externes Hosting</h3>
|
||||||
|
<p>
|
||||||
|
Diese Website wird extern gehostet. Die personenbezogenen Daten, die auf dieser Website
|
||||||
|
erfasst werden, werden auf den Servern des Hosters / der Hoster gespeichert. Hierbei
|
||||||
|
kann es sich v. a. um IP-Adressen, Kontaktanfragen, Meta- und Kommunikationsdaten,
|
||||||
|
Vertragsdaten, Kontaktdaten, Namen, Websitezugriffe und sonstige Daten, die über eine
|
||||||
|
Website generiert werden, handeln.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
Das externe Hosting erfolgt zum Zwecke der Vertragserfüllung gegenüber unseren
|
||||||
|
potenziellen und bestehenden Kunden (Art. 6 Abs. 1 lit. b DSGVO) und im Interesse einer
|
||||||
|
sicheren, schnellen und effizienten Bereitstellung unseres Online-Angebots durch einen
|
||||||
|
professionellen Anbieter (Art. 6 Abs. 1 lit. f DSGVO). Sofern eine entsprechende
|
||||||
|
Einwilligung abgefragt wurde, erfolgt die Verarbeitung ausschließlich auf Grundlage von
|
||||||
|
Art. 6 Abs. 1 lit. a DSGVO und § 25 Abs. 1 TDDDG, soweit die Einwilligung die
|
||||||
|
Speicherung von Cookies oder den Zugriff auf Informationen im Endgerät des Nutzers (z.
|
||||||
|
B. Device-Fingerprinting) im Sinne des TDDDG umfasst. Die Einwilligung ist jederzeit
|
||||||
|
widerrufbar.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
Unser(e) Hoster wird bzw. werden Ihre Daten nur insoweit verarbeiten, wie dies zur
|
||||||
|
Erfüllung seiner Leistungspflichten erforderlich ist und unsere Weisungen in Bezug auf
|
||||||
|
diese Daten befolgen.
|
||||||
|
</p>
|
||||||
|
<p>Wir setzen folgende(n) Hoster ein:</p>
|
||||||
|
|
||||||
|
<address>
|
||||||
|
netcup GmbH<br />
|
||||||
|
Emmy-Noether-Str. 10<br />
|
||||||
|
76131 Karlsruhe
|
||||||
|
</address>
|
||||||
|
|
||||||
|
<h3>Auftragsverarbeitung</h3>
|
||||||
|
<p>
|
||||||
|
Wir haben einen Vertrag über Auftragsverarbeitung (AVV) zur Nutzung des oben genannten
|
||||||
|
Dienstes geschlossen. Hierbei handelt es sich um einen datenschutzrechtlich
|
||||||
|
vorgeschriebenen Vertrag, der gewährleistet, dass dieser die personenbezogenen Daten
|
||||||
|
unserer Websitebesucher nur nach unseren Weisungen und unter Einhaltung der DSGVO
|
||||||
|
verarbeitet.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="pflichtinformationen" data-section="03">
|
||||||
|
<h2>3. Allgemeine Hinweise und Pflichtinformationen</h2>
|
||||||
|
<h3>Datenschutz</h3>
|
||||||
|
<p>
|
||||||
|
Die Betreiber dieser Seiten nehmen den Schutz Ihrer persönlichen Daten sehr ernst. Wir
|
||||||
|
behandeln Ihre personenbezogenen Daten vertraulich und entsprechend den gesetzlichen
|
||||||
|
Datenschutzvorschriften sowie dieser Datenschutzerklärung.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
Wenn Sie diese Website benutzen, werden verschiedene personenbezogene Daten erhoben.
|
||||||
|
Personenbezogene Daten sind Daten, mit denen Sie persönlich identifiziert werden können.
|
||||||
|
Die vorliegende Datenschutzerklärung erläutert, welche Daten wir erheben und wofür wir
|
||||||
|
sie nutzen. Sie erläutert auch, wie und zu welchem Zweck das geschieht.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
Wir weisen darauf hin, dass die Datenübertragung im Internet (z. B. bei der
|
||||||
|
Kommunikation per E-Mail) Sicherheitslücken aufweisen kann. Ein lückenloser Schutz der
|
||||||
|
Daten vor dem Zugriff durch Dritte ist nicht möglich.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h3>Hinweis zur verantwortlichen Stelle</h3>
|
||||||
|
<p>Die verantwortliche Stelle für die Datenverarbeitung auf dieser Website ist:</p>
|
||||||
|
|
||||||
|
<address>
|
||||||
|
Robin Ehlert<br />
|
||||||
|
Am Frohnacker 3<br />
|
||||||
|
76829 Landau in der Pfalz<br /><br />
|
||||||
|
Telefon: +49 (0) 1782877207<br />
|
||||||
|
E-Mail: post@rehlert.de
|
||||||
|
</address>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
Verantwortliche Stelle ist die natürliche oder juristische Person, die allein oder
|
||||||
|
gemeinsam mit anderen über die Zwecke und Mittel der Verarbeitung von personenbezogenen
|
||||||
|
Daten (z. B. Namen, E-Mail-Adressen o. Ä.) entscheidet.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h3>Speicherdauer</h3>
|
||||||
|
<p>
|
||||||
|
Soweit innerhalb dieser Datenschutzerklärung keine speziellere Speicherdauer genannt
|
||||||
|
wurde, verbleiben Ihre personenbezogenen Daten bei uns, bis der Zweck für die
|
||||||
|
Datenverarbeitung entfällt. Wenn Sie ein berechtigtes Löschersuchen geltend machen oder
|
||||||
|
eine Einwilligung zur Datenverarbeitung widerrufen, werden Ihre Daten gelöscht, sofern
|
||||||
|
wir keine anderen rechtlich zulässigen Gründe für die Speicherung Ihrer
|
||||||
|
personenbezogenen Daten haben (z. B. steuer- oder handelsrechtliche
|
||||||
|
Aufbewahrungsfristen); im letztgenannten Fall erfolgt die Löschung nach Fortfall dieser
|
||||||
|
Gründe.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h3>
|
||||||
|
Allgemeine Hinweise zu den Rechtsgrundlagen der Datenverarbeitung auf dieser Website
|
||||||
|
</h3>
|
||||||
|
<p>
|
||||||
|
Sofern Sie in die Datenverarbeitung eingewilligt haben, verarbeiten wir Ihre
|
||||||
|
personenbezogenen Daten auf Grundlage von Art. 6 Abs. 1 lit. a DSGVO bzw. Art. 9 Abs. 2
|
||||||
|
lit. a DSGVO, sofern besondere Datenkategorien nach Art. 9 Abs. 1 DSGVO verarbeitet
|
||||||
|
werden. Im Falle einer ausdrücklichen Einwilligung in die Übertragung personenbezogener
|
||||||
|
Daten in Drittstaaten erfolgt die Datenverarbeitung außerdem auf Grundlage von Art. 49
|
||||||
|
Abs. 1 lit. a DSGVO. Sofern Sie in die Speicherung von Cookies oder in den Zugriff auf
|
||||||
|
Informationen in Ihr Endgerät (z. B. via Device-Fingerprinting) eingewilligt haben,
|
||||||
|
erfolgt die Datenverarbeitung zusätzlich auf Grundlage von § 25 Abs. 1 TDDDG. Die
|
||||||
|
Einwilligung ist jederzeit widerrufbar. Sind Ihre Daten zur Vertragserfüllung oder zur
|
||||||
|
Durchführung vorvertraglicher Maßnahmen erforderlich, verarbeiten wir Ihre Daten auf
|
||||||
|
Grundlage des Art. 6 Abs. 1 lit. b DSGVO. Des Weiteren verarbeiten wir Ihre Daten,
|
||||||
|
sofern diese zur Erfüllung einer rechtlichen Verpflichtung erforderlich sind auf
|
||||||
|
Grundlage von Art. 6 Abs. 1 lit. c DSGVO. Die Datenverarbeitung kann ferner auf
|
||||||
|
Grundlage unseres berechtigten Interesses nach Art. 6 Abs. 1 lit. f DSGVO erfolgen. Über
|
||||||
|
die jeweils im Einzelfall einschlägigen Rechtsgrundlagen wird in den folgenden Absätzen
|
||||||
|
dieser Datenschutzerklärung informiert.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h3>Empfänger von personenbezogenen Daten</h3>
|
||||||
|
<p>
|
||||||
|
Im Rahmen unserer Geschäftstätigkeit arbeiten wir mit verschiedenen externen Stellen
|
||||||
|
zusammen. Dabei ist teilweise auch eine Übermittlung von personenbezogenen Daten an
|
||||||
|
diese externen Stellen erforderlich. Wir geben personenbezogene Daten nur dann an
|
||||||
|
externe Stellen weiter, wenn dies im Rahmen einer Vertragserfüllung erforderlich ist,
|
||||||
|
wenn wir gesetzlich hierzu verpflichtet sind (z. B. Weitergabe von Daten an
|
||||||
|
Steuerbehörden), wenn wir ein berechtigtes Interesse nach Art. 6 Abs. 1 lit. f DSGVO an
|
||||||
|
der Weitergabe haben oder wenn eine sonstige Rechtsgrundlage die Datenweitergabe
|
||||||
|
erlaubt. Beim Einsatz von Auftragsverarbeitern geben wir personenbezogene Daten unserer
|
||||||
|
Kunden nur auf Grundlage eines gültigen Vertrags über Auftragsverarbeitung weiter. Im
|
||||||
|
Falle einer gemeinsamen Verarbeitung wird ein Vertrag über gemeinsame Verarbeitung
|
||||||
|
geschlossen.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h3>Widerruf Ihrer Einwilligung zur Datenverarbeitung</h3>
|
||||||
|
<p>
|
||||||
|
Viele Datenverarbeitungsvorgänge sind nur mit Ihrer ausdrücklichen Einwilligung möglich.
|
||||||
|
Sie können eine bereits erteilte Einwilligung jederzeit widerrufen. Die Rechtmäßigkeit
|
||||||
|
der bis zum Widerruf erfolgten Datenverarbeitung bleibt vom Widerruf unberührt.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h3>
|
||||||
|
Widerspruchsrecht gegen die Datenerhebung in besonderen Fällen sowie gegen Direktwerbung
|
||||||
|
(Art. 21 DSGVO)
|
||||||
|
</h3>
|
||||||
|
<div class="legal-notice">
|
||||||
|
<p>
|
||||||
|
WENN DIE DATENVERARBEITUNG AUF GRUNDLAGE VON ART. 6 ABS. 1 LIT. E ODER F DSGVO
|
||||||
|
ERFOLGT, HABEN SIE JEDERZEIT DAS RECHT, AUS GRÜNDEN, DIE SICH AUS IHRER BESONDEREN
|
||||||
|
SITUATION ERGEBEN, GEGEN DIE VERARBEITUNG IHRER PERSONENBEZOGENEN DATEN WIDERSPRUCH
|
||||||
|
EINZULEGEN; DIES GILT AUCH FÜR EIN AUF DIESE BESTIMMUNGEN GESTÜTZTES PROFILING. DIE
|
||||||
|
JEWEILIGE RECHTSGRUNDLAGE, AUF DENEN EINE VERARBEITUNG BERUHT, ENTNEHMEN SIE DIESER
|
||||||
|
DATENSCHUTZERKLÄRUNG. WENN SIE WIDERSPRUCH EINLEGEN, WERDEN WIR IHRE BETROFFENEN
|
||||||
|
PERSONENBEZOGENEN DATEN NICHT MEHR VERARBEITEN, ES SEI DENN, WIR KÖNNEN ZWINGENDE
|
||||||
|
SCHUTZWÜRDIGE GRÜNDE FÜR DIE VERARBEITUNG NACHWEISEN, DIE IHRE INTERESSEN, RECHTE UND
|
||||||
|
FREIHEITEN ÜBERWIEGEN ODER DIE VERARBEITUNG DIENT DER GELTENDMACHUNG, AUSÜBUNG ODER
|
||||||
|
VERTEIDIGUNG VON RECHTSANSPRÜCHEN (WIDERSPRUCH NACH ART. 21 ABS. 1 DSGVO).
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
WERDEN IHRE PERSONENBEZOGENEN DATEN VERARBEITET, UM DIREKTWERBUNG ZU BETREIBEN, SO
|
||||||
|
HABEN SIE DAS RECHT, JEDERZEIT WIDERSPRUCH GEGEN DIE VERARBEITUNG SIE BETREFFENDER
|
||||||
|
PERSONENBEZOGENER DATEN ZUM ZWECKE DERARTIGER WERBUNG EINZULEGEN; DIES GILT AUCH FÜR
|
||||||
|
DAS PROFILING, SOWEIT ES MIT SOLCHER DIREKTWERBUNG IN VERBINDUNG STEHT. WENN SIE
|
||||||
|
WIDERSPRECHEN, WERDEN IHRE PERSONENBEZOGENEN DATEN ANSCHLIESSEND NICHT MEHR ZUM ZWECKE
|
||||||
|
DER DIREKTWERBUNG VERWENDET (WIDERSPRUCH NACH ART. 21 ABS. 2 DSGVO).
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h3>Beschwerderecht bei der zuständigen Aufsichtsbehörde</h3>
|
||||||
|
<p>
|
||||||
|
Im Falle von Verstößen gegen die DSGVO steht den Betroffenen ein Beschwerderecht bei
|
||||||
|
einer Aufsichtsbehörde, insbesondere in dem Mitgliedstaat ihres gewöhnlichen
|
||||||
|
Aufenthalts, ihres Arbeitsplatzes oder des Orts des mutmaßlichen Verstoßes zu. Das
|
||||||
|
Beschwerderecht besteht unbeschadet anderweitiger verwaltungsrechtlicher oder
|
||||||
|
gerichtlicher Rechtsbehelfe.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h3>Recht auf Datenübertragbarkeit</h3>
|
||||||
|
<p>
|
||||||
|
Sie haben das Recht, Daten, die wir auf Grundlage Ihrer Einwilligung oder in Erfüllung
|
||||||
|
eines Vertrags automatisiert verarbeiten, an sich oder an einen Dritten in einem
|
||||||
|
gängigen, maschinenlesbaren Format aushändigen zu lassen. Sofern Sie die direkte
|
||||||
|
Übertragung der Daten an einen anderen Verantwortlichen verlangen, erfolgt dies nur,
|
||||||
|
soweit es technisch machbar ist.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h3>Auskunft, Berichtigung und Löschung</h3>
|
||||||
|
<p>
|
||||||
|
Sie haben im Rahmen der geltenden gesetzlichen Bestimmungen jederzeit das Recht auf
|
||||||
|
unentgeltliche Auskunft über Ihre gespeicherten personenbezogenen Daten, deren Herkunft
|
||||||
|
und Empfänger und den Zweck der Datenverarbeitung und ggf. ein Recht auf Berichtigung
|
||||||
|
oder Löschung dieser Daten. Hierzu sowie zu weiteren Fragen zum Thema personenbezogene
|
||||||
|
Daten können Sie sich jederzeit an uns wenden.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h3>Recht auf Einschränkung der Verarbeitung</h3>
|
||||||
|
<p>
|
||||||
|
Sie haben das Recht, die Einschränkung der Verarbeitung Ihrer personenbezogenen Daten zu
|
||||||
|
verlangen. Hierzu können Sie sich jederzeit an uns wenden. Das Recht auf Einschränkung
|
||||||
|
der Verarbeitung besteht in folgenden Fällen:
|
||||||
|
</p>
|
||||||
|
<ul>
|
||||||
|
<li>
|
||||||
|
Wenn Sie die Richtigkeit Ihrer bei uns gespeicherten personenbezogenen Daten
|
||||||
|
bestreiten, benötigen wir in der Regel Zeit, um dies zu überprüfen. Für die Dauer der
|
||||||
|
Prüfung haben Sie das Recht, die Einschränkung der Verarbeitung Ihrer
|
||||||
|
personenbezogenen Daten zu verlangen.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
Wenn die Verarbeitung Ihrer personenbezogenen Daten unrechtmäßig geschah/geschieht,
|
||||||
|
können Sie statt der Löschung die Einschränkung der Datenverarbeitung verlangen.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
Wenn wir Ihre personenbezogenen Daten nicht mehr benötigen, Sie sie jedoch zur
|
||||||
|
Ausübung, Verteidigung oder Geltendmachung von Rechtsansprüchen benötigen, haben Sie
|
||||||
|
das Recht, statt der Löschung die Einschränkung der Verarbeitung Ihrer
|
||||||
|
personenbezogenen Daten zu verlangen.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
Wenn Sie einen Widerspruch nach Art. 21 Abs. 1 DSGVO eingelegt haben, muss eine
|
||||||
|
Abwägung zwischen Ihren und unseren Interessen vorgenommen werden. Solange noch nicht
|
||||||
|
feststeht, wessen Interessen überwiegen, haben Sie das Recht, die Einschränkung der
|
||||||
|
Verarbeitung Ihrer personenbezogenen Daten zu verlangen.
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
<p>
|
||||||
|
Wenn Sie die Verarbeitung Ihrer personenbezogenen Daten eingeschränkt haben, dürfen
|
||||||
|
diese Daten – von ihrer Speicherung abgesehen – nur mit Ihrer Einwilligung oder zur
|
||||||
|
Geltendmachung, Ausübung oder Verteidigung von Rechtsansprüchen oder zum Schutz der
|
||||||
|
Rechte einer anderen natürlichen oder juristischen Person oder aus Gründen eines
|
||||||
|
wichtigen öffentlichen Interesses der Europäischen Union oder eines Mitgliedstaats
|
||||||
|
verarbeitet werden.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h3>SSL- bzw. TLS-Verschlüsselung</h3>
|
||||||
|
<p>
|
||||||
|
Diese Seite nutzt aus Sicherheitsgründen und zum Schutz der Übertragung vertraulicher
|
||||||
|
Inhalte, wie zum Beispiel Bestellungen oder Anfragen, die Sie an uns als Seitenbetreiber
|
||||||
|
senden, eine SSL- bzw. TLS-Verschlüsselung. Eine verschlüsselte Verbindung erkennen Sie
|
||||||
|
daran, dass die Adresszeile des Browsers von „http://“ auf „https://“ wechselt und an
|
||||||
|
dem Schloss-Symbol in Ihrer Browserzeile.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
Wenn die SSL- bzw. TLS-Verschlüsselung aktiviert ist, können die Daten, die Sie an uns
|
||||||
|
übermitteln, nicht von Dritten mitgelesen werden.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h3>Widerspruch gegen Werbe-E-Mails</h3>
|
||||||
|
<p>
|
||||||
|
Der Nutzung von im Rahmen der Impressumspflicht veröffentlichten Kontaktdaten zur
|
||||||
|
Übersendung von nicht ausdrücklich angeforderter Werbung und Informationsmaterialien
|
||||||
|
wird hiermit widersprochen. Die Betreiber der Seiten behalten sich ausdrücklich
|
||||||
|
rechtliche Schritte im Falle der unverlangten Zusendung von Werbeinformationen, etwa
|
||||||
|
durch Spam-E-Mails, vor.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="datenerfassung" data-section="04">
|
||||||
|
<h2>4. Datenerfassung auf dieser Website</h2>
|
||||||
|
<h3>Server-Log-Dateien</h3>
|
||||||
|
<p>
|
||||||
|
Der Provider der Seiten erhebt und speichert automatisch Informationen in so genannten
|
||||||
|
Server-Log-Dateien, die Ihr Browser automatisch an uns übermittelt. Dies sind:
|
||||||
|
</p>
|
||||||
|
<ul>
|
||||||
|
<li>Browsertyp und Browserversion</li>
|
||||||
|
<li>verwendetes Betriebssystem</li>
|
||||||
|
<li>Referrer URL</li>
|
||||||
|
<li>Hostname des zugreifenden Rechners</li>
|
||||||
|
<li>Uhrzeit der Serveranfrage</li>
|
||||||
|
<li>IP-Adresse</li>
|
||||||
|
</ul>
|
||||||
|
<p>Eine Zusammenführung dieser Daten mit anderen Datenquellen wird nicht vorgenommen.</p>
|
||||||
|
<p>
|
||||||
|
Die Erfassung dieser Daten erfolgt auf Grundlage von Art. 6 Abs. 1 lit. f DSGVO. Der
|
||||||
|
Websitebetreiber hat ein berechtigtes Interesse an der technisch fehlerfreien
|
||||||
|
Darstellung und der Optimierung seiner Website – hierzu müssen die Server-Log-Files
|
||||||
|
erfasst werden.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<p class="source">
|
||||||
|
<span>Quelle</span>
|
||||||
|
<a href="https://www.e-recht24.de">e-recht24.de</a>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
<svelte:head>
|
||||||
|
<title>Impressum | Nominandum</title>
|
||||||
|
<meta name="description" content="Anbieterkennzeichnung und Kontaktdaten von Nominandum." />
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
<main class="min-h-screen bg-primary-50/50 text-surface-800">
|
||||||
|
<header class="border-b-4 border-tertiary-400 bg-primary-900 text-white bg-[#244272]">
|
||||||
|
<div class="mx-auto max-w-7xl px-4 py-12 sm:px-8 sm:py-16 lg:py-20">
|
||||||
|
<a
|
||||||
|
class="mb-10 inline-flex items-center gap-2 rounded-sm text-sm font-bold text-primary-200 transition-colors hover:text-white focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-white"
|
||||||
|
href="/"
|
||||||
|
aria-label="Zurück zur Startseite"
|
||||||
|
>
|
||||||
|
<span aria-hidden="true">←</span>
|
||||||
|
Startseite
|
||||||
|
</a>
|
||||||
|
<p class="mb-3 text-xs font-extrabold tracking-[0.16em] text-tertiary-300 uppercase">
|
||||||
|
Rechtliches · Anbieterkennzeichnung
|
||||||
|
</p>
|
||||||
|
<h1
|
||||||
|
id="page-title"
|
||||||
|
class="text-4xl leading-none font-extrabold tracking-tight text-white sm:text-6xl lg:text-7xl"
|
||||||
|
>
|
||||||
|
Impressum
|
||||||
|
</h1>
|
||||||
|
<p class="mt-6 max-w-2xl text-lg leading-relaxed text-primary-100 sm:text-xl">
|
||||||
|
Verantwortliche Person, ladungsfähige Anschrift und direkter Kontakt.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="mx-auto max-w-7xl px-4 py-8 sm:px-8 sm:py-12 lg:py-16">
|
||||||
|
<article
|
||||||
|
class="relative grid overflow-hidden rounded-container border border-primary-100 bg-white shadow-sm md:grid-cols-2"
|
||||||
|
aria-labelledby="page-title"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="absolute top-0 right-6 rounded-b-lg bg-tertiary-400 px-3 py-2 text-xs font-extrabold tracking-[0.14em] text-primary-950"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
IMP
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section class="p-7 sm:p-10 lg:p-14" aria-labelledby="address-heading">
|
||||||
|
<p class="mb-8 text-sm font-extrabold text-primary-400">01</p>
|
||||||
|
<h2
|
||||||
|
id="address-heading"
|
||||||
|
class="text-2xl font-extrabold tracking-tight text-primary-900 sm:text-3xl"
|
||||||
|
>
|
||||||
|
Anschrift
|
||||||
|
</h2>
|
||||||
|
<address class="mt-8 flex flex-col gap-1 text-base leading-relaxed not-italic sm:text-lg">
|
||||||
|
<strong class="mb-2 font-extrabold text-primary-900">Robin Ehlert</strong>
|
||||||
|
<span>Am Frohnacker 3</span>
|
||||||
|
<span>76829 Landau in der Pfalz</span>
|
||||||
|
</address>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section
|
||||||
|
class="border-t border-primary-100 bg-primary-50/60 p-7 sm:p-10 md:border-t-0 md:border-l lg:p-14"
|
||||||
|
aria-labelledby="contact-heading"
|
||||||
|
>
|
||||||
|
<p class="mb-8 text-sm font-extrabold text-primary-400">02</p>
|
||||||
|
<h2
|
||||||
|
id="contact-heading"
|
||||||
|
class="text-2xl font-extrabold tracking-tight text-primary-900 sm:text-3xl"
|
||||||
|
>
|
||||||
|
Kontakt
|
||||||
|
</h2>
|
||||||
|
<dl class="mt-8 grid gap-6">
|
||||||
|
<div>
|
||||||
|
<dt class="mb-1 text-xs font-extrabold tracking-wider text-surface-500 uppercase">
|
||||||
|
Telefon
|
||||||
|
</dt>
|
||||||
|
<dd>
|
||||||
|
<a
|
||||||
|
class="rounded-sm text-lg font-bold text-primary-800 underline decoration-primary-300 underline-offset-4 hover:text-primary-600 focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-primary-700"
|
||||||
|
href="tel:+491782877207">+49 (0) 178 2877207</a
|
||||||
|
>
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt class="mb-1 text-xs font-extrabold tracking-wider text-surface-500 uppercase">
|
||||||
|
E-Mail
|
||||||
|
</dt>
|
||||||
|
<dd>
|
||||||
|
<a
|
||||||
|
class="rounded-sm text-lg font-bold text-primary-800 underline decoration-primary-300 underline-offset-4 hover:text-primary-600 focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-primary-700"
|
||||||
|
href="mailto:jm@rehlert.de">jm@rehlert.de</a
|
||||||
|
>
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
</section>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
+12
-11
@@ -1,8 +1,8 @@
|
|||||||
@import 'tailwindcss';
|
@import 'tailwindcss';
|
||||||
/*@import '../globals.css';*/
|
/*@import '../globals.css';*/
|
||||||
@import "tw-animate-css";
|
@import 'tw-animate-css';
|
||||||
@import "shadcn-svelte/tailwind.css";
|
@import 'shadcn-svelte/tailwind.css';
|
||||||
@import "@fontsource-variable/geist";
|
@import '@fontsource-variable/geist';
|
||||||
|
|
||||||
@custom-variant dark (&:is(.dark *));
|
@custom-variant dark (&:is(.dark *));
|
||||||
|
|
||||||
@@ -17,22 +17,23 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
--background: #EEF4F6;
|
--background: #eef4f6;
|
||||||
--foreground: #122F62;
|
--foreground: #122f62;
|
||||||
--card: oklch(1 0 0);
|
--card: oklch(1 0 0);
|
||||||
--card-foreground: oklch(0.145 0 0);
|
--card-foreground: oklch(0.145 0 0);
|
||||||
--popover: oklch(1 0 0);
|
--popover: oklch(1 0 0);
|
||||||
--popover-foreground: oklch(0.145 0 0);
|
--popover-foreground: oklch(0.145 0 0);
|
||||||
--primary: #122f62;
|
--primary: #122f62;
|
||||||
--primary-foreground: oklch(0.985 0 0);
|
--primary-foreground: oklch(0.985 0 0);
|
||||||
--secondary: #EA544B;
|
--secondary: #ea544b;
|
||||||
--secondary-end: #FF6F6D;
|
--secondary-end: #ff6f6d;
|
||||||
--secondary-foreground: oklch(0.205 0 0);
|
--secondary-foreground: oklch(0.205 0 0);
|
||||||
--muted: oklch(0.97 0 0);
|
--muted: oklch(0.97 0 0);
|
||||||
--muted-foreground: oklch(0.556 0 0);
|
--muted-foreground: oklch(0.556 0 0);
|
||||||
--accent: #d6dce5;
|
--accent: #d6dce5;
|
||||||
--accent-foreground: oklch(0.205 0 0);
|
--accent-foreground: oklch(0.205 0 0);
|
||||||
--destructive: #ff0027;
|
--destructive: #ff0027;
|
||||||
|
--success: #4e8a72;
|
||||||
--border: oklch(0.922 0 0);
|
--border: oklch(0.922 0 0);
|
||||||
--input: oklch(0.922 0 0);
|
--input: oklch(0.922 0 0);
|
||||||
--ring: oklch(0.708 0 0);
|
--ring: oklch(0.708 0 0);
|
||||||
@@ -54,20 +55,21 @@
|
|||||||
|
|
||||||
.dark {
|
.dark {
|
||||||
--background: #212121;
|
--background: #212121;
|
||||||
--foreground: #FFFFFF;
|
--foreground: #ffffff;
|
||||||
--card: oklch(0.205 0 0);
|
--card: oklch(0.205 0 0);
|
||||||
--card-foreground: oklch(0.985 0 0);
|
--card-foreground: oklch(0.985 0 0);
|
||||||
--popover: oklch(0.205 0 0);
|
--popover: oklch(0.205 0 0);
|
||||||
--popover-foreground: oklch(0.985 0 0);
|
--popover-foreground: oklch(0.985 0 0);
|
||||||
--primary: #92b2eb;
|
--primary: #92b2eb;
|
||||||
--primary-foreground: oklch(0.205 0 0);
|
--primary-foreground: oklch(0.205 0 0);
|
||||||
--secondary: linear-gradient(90deg, #EA544B 0%, #FF6F6D 100%);
|
--secondary: linear-gradient(90deg, #ea544b 0%, #ff6f6d 100%);
|
||||||
--secondary-foreground: oklch(0.985 0 0);
|
--secondary-foreground: oklch(0.985 0 0);
|
||||||
--muted: oklch(0.269 0 0);
|
--muted: oklch(0.269 0 0);
|
||||||
--muted-foreground: oklch(0.708 0 0);
|
--muted-foreground: oklch(0.708 0 0);
|
||||||
--accent: oklch(0.269 0 0);
|
--accent: oklch(0.269 0 0);
|
||||||
--accent-foreground: oklch(0.985 0 0);
|
--accent-foreground: oklch(0.985 0 0);
|
||||||
--destructive: #ff0027;
|
--destructive: #ff0027;
|
||||||
|
--success: #79c4a3;
|
||||||
--border: oklch(1 0 0 / 10%);
|
--border: oklch(1 0 0 / 10%);
|
||||||
--input: oklch(1 0 0 / 15%);
|
--input: oklch(1 0 0 / 15%);
|
||||||
--ring: oklch(0.556 0 0);
|
--ring: oklch(0.556 0 0);
|
||||||
@@ -105,6 +107,7 @@
|
|||||||
--color-input: var(--input);
|
--color-input: var(--input);
|
||||||
--color-border: var(--border);
|
--color-border: var(--border);
|
||||||
--color-destructive: var(--destructive);
|
--color-destructive: var(--destructive);
|
||||||
|
--color-success: var(--success);
|
||||||
--color-accent-foreground: var(--accent-foreground);
|
--color-accent-foreground: var(--accent-foreground);
|
||||||
--color-accent: var(--accent);
|
--color-accent: var(--accent);
|
||||||
--color-muted-foreground: var(--muted-foreground);
|
--color-muted-foreground: var(--muted-foreground);
|
||||||
@@ -140,7 +143,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
/* ripple */
|
/* ripple */
|
||||||
|
|
||||||
@keyframes ripple {
|
@keyframes ripple {
|
||||||
@@ -158,4 +160,3 @@
|
|||||||
.animate-ripple {
|
.animate-ripple {
|
||||||
animation: ripple 600ms ease-out;
|
animation: ripple 600ms ease-out;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { ArrowLeft } from '@lucide/svelte';
|
||||||
|
|
||||||
|
let { children } = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<section class="px-4">
|
||||||
|
<div class="flex flex-wrap items-center gap-3 py-6">
|
||||||
|
<a
|
||||||
|
href="/"
|
||||||
|
class="flex items-center gap-2 text-[14px] font-medium text-primary-700 transition hover:text-primary-900"
|
||||||
|
>
|
||||||
|
<ArrowLeft class="h-4 w-4" />
|
||||||
|
Zurück
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
{@render children()}
|
||||||
|
</section>
|
||||||
@@ -0,0 +1,200 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
import BeaWorkspaceControls from '$lib/components/BeaWorkspaceControls.svelte';
|
||||||
|
import ProcessedZipArchiveEditor from '$lib/components/ProcessedZipArchiveEditor.svelte';
|
||||||
|
import ZipDropzone from '$lib/components/ZipDropzone.svelte';
|
||||||
|
import BeaArchiveProcessing, {
|
||||||
|
type ZipProcessingJob
|
||||||
|
} from '$lib/components/BeaArchiveProcessing.svelte';
|
||||||
|
import {
|
||||||
|
extractZipArchives,
|
||||||
|
mergeProcessedZipArchive,
|
||||||
|
type ProcessedZipArchive
|
||||||
|
} from '$lib/zip-processing';
|
||||||
|
import { downloadBlob } from '$lib/download';
|
||||||
|
import { takeQueuedZipFiles } from '$lib/pending-files';
|
||||||
|
import * as Card from '$lib/components/ui/card/index';
|
||||||
|
|
||||||
|
const THUMBNAIL_WIDTH_STORAGE_KEY = 'thumbnailWidth';
|
||||||
|
|
||||||
|
let selectedZipFiles: File[] = $state([]);
|
||||||
|
let processedZipFiles: ProcessedZipArchive[] = $state([]);
|
||||||
|
let zipJobs: ZipProcessingJob[] = $state([]);
|
||||||
|
let pendingZipCount = $derived(zipJobs.filter((job) => job.status === 'processing').length);
|
||||||
|
let failedZipCount = $derived(zipJobs.filter((job) => job.status === 'error').length);
|
||||||
|
let thumbnailWidth = $state(200);
|
||||||
|
let thumbnailWidthHydrated = $state(false);
|
||||||
|
let archiveGeneration = 0;
|
||||||
|
|
||||||
|
const clampThumbnailWidth = (value: number) => Math.min(400, Math.max(100, value));
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
const storedValue = localStorage.getItem(THUMBNAIL_WIDTH_STORAGE_KEY);
|
||||||
|
|
||||||
|
if (storedValue !== null) {
|
||||||
|
const parsedValue = Number(storedValue);
|
||||||
|
|
||||||
|
if (Number.isFinite(parsedValue)) {
|
||||||
|
thumbnailWidth = clampThumbnailWidth(parsedValue);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
thumbnailWidthHydrated = true;
|
||||||
|
|
||||||
|
const queuedFiles = takeQueuedZipFiles();
|
||||||
|
if (queuedFiles.length > 0) handleFilesSelected(queuedFiles);
|
||||||
|
});
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (!thumbnailWidthHydrated) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
localStorage.setItem(THUMBNAIL_WIDTH_STORAGE_KEY, String(thumbnailWidth));
|
||||||
|
});
|
||||||
|
|
||||||
|
const updateZipJob = (jobId: string, update: Partial<ZipProcessingJob>) => {
|
||||||
|
zipJobs = zipJobs.map((job) => (job.id === jobId ? { ...job, ...update } : job));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleFilesSelected = (files: File[]) => {
|
||||||
|
selectedZipFiles = [...selectedZipFiles, ...files];
|
||||||
|
|
||||||
|
const jobs = files.map((file) => ({
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
file,
|
||||||
|
status: 'processing' as const
|
||||||
|
}));
|
||||||
|
zipJobs = [...zipJobs, ...jobs];
|
||||||
|
|
||||||
|
for (const job of jobs) {
|
||||||
|
void processZipFile(job.id, job.file, archiveGeneration);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const processZipFile = async (jobId: string, file: File, generation: number) => {
|
||||||
|
updateZipJob(jobId, { status: 'processing', error: undefined, archiveCount: undefined });
|
||||||
|
|
||||||
|
try {
|
||||||
|
const processedZipArchives = await extractZipArchives(file);
|
||||||
|
|
||||||
|
if (generation !== archiveGeneration || !zipJobs.some((job) => job.id === jobId)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
processedZipFiles = [...processedZipFiles, ...processedZipArchives];
|
||||||
|
updateZipJob(jobId, {
|
||||||
|
status: 'complete',
|
||||||
|
archiveCount: processedZipArchives.length
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Failed to process ${file.name}`, error);
|
||||||
|
|
||||||
|
if (generation === archiveGeneration && zipJobs.some((job) => job.id === jobId)) {
|
||||||
|
updateZipJob(jobId, {
|
||||||
|
status: 'error',
|
||||||
|
error: 'Prüfen Sie, ob die Datei ein gültiges beA-ZIP-Archiv ist.'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const retryZipFile = (jobId: string) => {
|
||||||
|
const job = zipJobs.find((candidate) => candidate.id === jobId);
|
||||||
|
if (!job || job.status === 'processing') return;
|
||||||
|
|
||||||
|
void processZipFile(job.id, job.file, archiveGeneration);
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeZipFile = (jobId: string) => {
|
||||||
|
const job = zipJobs.find((candidate) => candidate.id === jobId);
|
||||||
|
if (!job || job.status === 'processing') return;
|
||||||
|
|
||||||
|
zipJobs = zipJobs.filter((candidate) => candidate.id !== jobId);
|
||||||
|
selectedZipFiles = selectedZipFiles.filter((file) => file !== job.file);
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateProcessedZipFile = (index: number, archive: ProcessedZipArchive) => {
|
||||||
|
processedZipFiles = processedZipFiles.map((existingArchive, existingIndex) =>
|
||||||
|
existingIndex === index ? archive : existingArchive
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const exportAllZipArchivesAsPdf = async () => {
|
||||||
|
for (const archive of processedZipFiles) {
|
||||||
|
try {
|
||||||
|
const mergedBytes = await mergeProcessedZipArchive(archive);
|
||||||
|
downloadBlob(mergedBytes, archive.name);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Failed to export archive ${archive.name}`, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const resetWorkspace = () => {
|
||||||
|
archiveGeneration += 1;
|
||||||
|
selectedZipFiles = [];
|
||||||
|
processedZipFiles = [];
|
||||||
|
zipJobs = [];
|
||||||
|
};
|
||||||
|
|
||||||
|
const replaceWithZipFiles = (files: File[]) => {
|
||||||
|
resetWorkspace();
|
||||||
|
// handleFilesSelected captures the incremented archiveGeneration, so results
|
||||||
|
// from the previous workspace can no longer append.
|
||||||
|
handleFilesSelected(files);
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#snippet content()}
|
||||||
|
{#if selectedZipFiles.length === 0}
|
||||||
|
<Card.Root>
|
||||||
|
<Card.Content>
|
||||||
|
<ZipDropzone onFilesSelected={handleFilesSelected} />
|
||||||
|
</Card.Content>
|
||||||
|
</Card.Root>
|
||||||
|
{:else if zipJobs.length > 0}
|
||||||
|
<div class="flex w-full grow flex-col gap-4 px-4 py-6">
|
||||||
|
<BeaWorkspaceControls
|
||||||
|
selectedZipCount={selectedZipFiles.length}
|
||||||
|
processedArchiveCount={processedZipFiles.length}
|
||||||
|
{pendingZipCount}
|
||||||
|
{thumbnailWidth}
|
||||||
|
onThumbnailWidthChange={(value) => (thumbnailWidth = value)}
|
||||||
|
onRequestReplacement={replaceWithZipFiles}
|
||||||
|
onAppendFiles={handleFilesSelected}
|
||||||
|
onExportAll={exportAllZipArchivesAsPdf}
|
||||||
|
onClear={resetWorkspace}
|
||||||
|
/>
|
||||||
|
<div class="mx-auto flex w-full flex-col gap-3">
|
||||||
|
{#if processedZipFiles.length === 0}
|
||||||
|
<BeaArchiveProcessing jobs={zipJobs} onRetry={retryZipFile} onRemove={removeZipFile} />
|
||||||
|
{:else if pendingZipCount > 0 || failedZipCount > 0}
|
||||||
|
<BeaArchiveProcessing
|
||||||
|
jobs={zipJobs}
|
||||||
|
compact
|
||||||
|
onRetry={retryZipFile}
|
||||||
|
onRemove={removeZipFile}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if processedZipFiles.length > 0}
|
||||||
|
<div class="flex flex-col gap-4">
|
||||||
|
{#each processedZipFiles as file, index}
|
||||||
|
<ProcessedZipArchiveEditor
|
||||||
|
archive={file}
|
||||||
|
{thumbnailWidth}
|
||||||
|
onArchiveChange={(updatedArchive) => updateProcessedZipFile(index, updatedArchive)}
|
||||||
|
/>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{/snippet}
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h1 class="h1 text-[20px] font-bold text-primary-900">beA-Edit</h1>
|
||||||
|
{@render content()}
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,171 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import BatchFileList from '$lib/components/BatchFileList.svelte';
|
||||||
|
import FileDropzone from '$lib/components/FileDropzone.svelte';
|
||||||
|
import Button from '$lib/components/ui/button.svelte';
|
||||||
|
import { createBatchItem, runBatch, type BatchItem } from '$lib/batch';
|
||||||
|
import { downloadBlob } from '$lib/download';
|
||||||
|
import {
|
||||||
|
compressWithPreset,
|
||||||
|
compressionPresets,
|
||||||
|
defaultCompressionPreset
|
||||||
|
} from '$lib/pdf-compression';
|
||||||
|
import { isRasterizationTooLarge, RASTERIZATION_MAX_BYTES } from '$lib/rasterization-limits';
|
||||||
|
import { AlertTriangle } from '@lucide/svelte';
|
||||||
|
|
||||||
|
let items = $state<BatchItem[]>([]);
|
||||||
|
let selectedPreset = $state(defaultCompressionPreset.id);
|
||||||
|
let isRunning = $state(false);
|
||||||
|
|
||||||
|
const pendingCount = $derived(items.filter((item) => item.status === 'pending').length);
|
||||||
|
const canStart = $derived(pendingCount > 0 && !isRunning);
|
||||||
|
|
||||||
|
const handleFilesSelected = (files: File[]) => {
|
||||||
|
const knownKeys = new Set(items.map((item) => `${item.file.name}:${item.file.size}`));
|
||||||
|
const additions = files
|
||||||
|
.filter((file) => !knownKeys.has(`${file.name}:${file.size}`))
|
||||||
|
.map((file) => {
|
||||||
|
const item = createBatchItem(file);
|
||||||
|
if (isRasterizationTooLarge(file)) {
|
||||||
|
item.status = 'error';
|
||||||
|
item.errorMessage = `Diese Datei ist größer als ${RASTERIZATION_MAX_BYTES / (1024 * 1024)} MB und kann aus Speichergründen nicht im Browser gerastert werden.`;
|
||||||
|
}
|
||||||
|
return item;
|
||||||
|
});
|
||||||
|
items = [...items, ...additions];
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateItem = (id: string, patch: Partial<BatchItem>) => {
|
||||||
|
items = items.map((item) => (item.id === id ? { ...item, ...patch } : item));
|
||||||
|
};
|
||||||
|
|
||||||
|
const processFile = async (file: File) => {
|
||||||
|
// The preset is read per file so an option change during a run only
|
||||||
|
// affects the still pending files.
|
||||||
|
const preset =
|
||||||
|
compressionPresets.find((entry) => entry.id === selectedPreset) ?? defaultCompressionPreset;
|
||||||
|
const bytes = await compressWithPreset(file, preset);
|
||||||
|
return { bytes, name: `${file.name.replace(/\.pdf$/i, '')}_komprimiert.pdf` };
|
||||||
|
};
|
||||||
|
|
||||||
|
const startBatch = async () => {
|
||||||
|
if (isRunning) return;
|
||||||
|
const queue = items.filter((item) => item.status === 'pending');
|
||||||
|
if (queue.length === 0) return;
|
||||||
|
|
||||||
|
isRunning = true;
|
||||||
|
try {
|
||||||
|
await runBatch(queue, processFile, updateItem, {
|
||||||
|
errorMessage: 'Diese PDF konnte nicht komprimiert werden.',
|
||||||
|
// Skip rows removed while they were still waiting in the queue.
|
||||||
|
shouldProcess: (id) => items.some((item) => item.id === id)
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
isRunning = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const retryItem = async (id: string) => {
|
||||||
|
if (isRunning) return;
|
||||||
|
const item = items.find((entry) => entry.id === id);
|
||||||
|
if (!item) return;
|
||||||
|
|
||||||
|
isRunning = true;
|
||||||
|
try {
|
||||||
|
await runBatch([item], processFile, updateItem, {
|
||||||
|
errorMessage: 'Diese PDF konnte nicht komprimiert werden.'
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
isRunning = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeItem = (id: string) => {
|
||||||
|
items = items.filter((item) => item.id !== id);
|
||||||
|
};
|
||||||
|
|
||||||
|
const downloadItem = (item: BatchItem) => {
|
||||||
|
if (item.resultBytes && item.resultName) downloadBlob(item.resultBytes, item.resultName);
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="mx-auto max-w-4xl space-y-6">
|
||||||
|
<div>
|
||||||
|
<h1 class="text-2xl font-bold text-foreground">Komprimieren</h1>
|
||||||
|
<p class="mt-2 text-sm text-primary">
|
||||||
|
Reduzieren Sie die Dateigröße Ihrer PDFs durch Komprimierung – einzeln oder mehrere in einem
|
||||||
|
Durchlauf.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="rounded-lg border border-amber-200 bg-amber-50 p-4 flex gap-3">
|
||||||
|
<AlertTriangle class="h-5 w-5 shrink-0 text-amber-600 mt-0.5" />
|
||||||
|
<div class="text-sm text-amber-800">
|
||||||
|
<p class="font-medium">Hinweis zur Qualität</p>
|
||||||
|
<p class="mt-1">
|
||||||
|
Das PDF wird neu gerendert, wodurch Text zu Bildern wird. Ausgewählter Text,
|
||||||
|
Durchsuchbarkeit und Vektorgrafiken gehen verloren.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<FileDropzone
|
||||||
|
onFilesSelected={handleFilesSelected}
|
||||||
|
multiple
|
||||||
|
label={items.length === 0 ? 'Dateien hinzufügen' : 'Weitere Dateien ablegen'}
|
||||||
|
sublabel={items.length === 0
|
||||||
|
? 'PDFs hier ablegen oder zum Auswählen klicken'
|
||||||
|
: 'Weitere PDFs hinzufügen – bestehende Dateien bleiben erhalten'}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{#if items.length > 0}
|
||||||
|
<div class="rounded-2xl border border-border bg-card p-4 space-y-4">
|
||||||
|
<h2 class="text-lg font-semibold text-foreground">Komprimierungsstufe</h2>
|
||||||
|
<p class="text-xs text-primary">Die Einstellung gilt für alle Dateien der Liste.</p>
|
||||||
|
|
||||||
|
<div class="space-y-2">
|
||||||
|
{#each compressionPresets as preset (preset.id)}
|
||||||
|
<label
|
||||||
|
class="flex items-start gap-3 rounded-lg border p-3 cursor-pointer transition {selectedPreset ===
|
||||||
|
preset.id
|
||||||
|
? 'border-primary bg-primary/5'
|
||||||
|
: 'border-border hover:border-primary-300'}"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="compression-preset"
|
||||||
|
value={preset.id}
|
||||||
|
bind:group={selectedPreset}
|
||||||
|
class="mt-0.5 h-4 w-4"
|
||||||
|
/>
|
||||||
|
<div>
|
||||||
|
<p class="text-sm font-medium text-foreground">{preset.label}</p>
|
||||||
|
<p class="text-xs text-primary">{preset.description}</p>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if isRunning}
|
||||||
|
<p class="text-xs text-primary" role="note">
|
||||||
|
Änderungen an der Komprimierungsstufe gelten für die noch ausstehenden Dateien.
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if items.length > 0}
|
||||||
|
<BatchFileList
|
||||||
|
{items}
|
||||||
|
zipBaseName="komprimiert"
|
||||||
|
onDownload={downloadItem}
|
||||||
|
onRemove={removeItem}
|
||||||
|
onRetry={retryItem}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="flex justify-end">
|
||||||
|
<Button onclick={startBatch} disabled={!canStart}>
|
||||||
|
{isRunning ? 'Wird komprimiert...' : 'Komprimieren'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,192 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import FileDropzone from '$lib/components/FileDropzone.svelte';
|
||||||
|
import RasterizationWarning from '$lib/components/RasterizationWarning.svelte';
|
||||||
|
import Button from '$lib/components/ui/button.svelte';
|
||||||
|
import { downloadBlob, downloadBlobUrl } from '$lib/download';
|
||||||
|
import { isRasterizationTooLarge } from '$lib/rasterization-limits';
|
||||||
|
import { zipSync } from 'fflate';
|
||||||
|
import { Image } from '@lucide/svelte';
|
||||||
|
|
||||||
|
type OutputFormat = 'png' | 'jpeg';
|
||||||
|
|
||||||
|
let pdfFile: File | null = $state(null);
|
||||||
|
let outputFormat = $state<OutputFormat>('png');
|
||||||
|
let scale = $state(2);
|
||||||
|
let isProcessing = $state(false);
|
||||||
|
let error = $state<string | null>(null);
|
||||||
|
let progress = $state(0);
|
||||||
|
let totalPages = $state(0);
|
||||||
|
const rasterizationBlocked = $derived(isRasterizationTooLarge(pdfFile));
|
||||||
|
|
||||||
|
const handleFileSelected = (files: File[]) => {
|
||||||
|
pdfFile = files[0] ?? null;
|
||||||
|
error = null;
|
||||||
|
progress = 0;
|
||||||
|
totalPages = 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
const convertPdf = async () => {
|
||||||
|
if (!pdfFile) return;
|
||||||
|
|
||||||
|
isProcessing = true;
|
||||||
|
error = null;
|
||||||
|
progress = 0;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const pdfjsLib = await import('pdfjs-dist');
|
||||||
|
const { getDocument, GlobalWorkerOptions } = pdfjsLib;
|
||||||
|
GlobalWorkerOptions.workerSrc = await import('pdfjs-dist/build/pdf.worker.mjs?url').then(
|
||||||
|
(module) => module.default
|
||||||
|
);
|
||||||
|
|
||||||
|
const loadingTask = getDocument({
|
||||||
|
data: new Uint8Array(await pdfFile.arrayBuffer())
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const pdfDocument = await loadingTask.promise;
|
||||||
|
const renderScale = Number(scale);
|
||||||
|
const baseName = pdfFile.name.replace(/\.pdf$/i, '');
|
||||||
|
const mimeType = outputFormat === 'png' ? 'image/png' : 'image/jpeg';
|
||||||
|
const extension = outputFormat === 'png' ? 'png' : 'jpg';
|
||||||
|
totalPages = pdfDocument.numPages;
|
||||||
|
|
||||||
|
const renderPage = async (pageNumber: number) => {
|
||||||
|
const page = await pdfDocument.getPage(pageNumber);
|
||||||
|
const viewport = page.getViewport({ scale: renderScale });
|
||||||
|
const canvas = document.createElement('canvas');
|
||||||
|
canvas.width = Math.ceil(viewport.width);
|
||||||
|
canvas.height = Math.ceil(viewport.height);
|
||||||
|
const canvasContext = canvas.getContext('2d');
|
||||||
|
if (!canvasContext) throw new Error('Could not create canvas context');
|
||||||
|
|
||||||
|
await page.render({ canvas, canvasContext, viewport, background: '#ffffff' }).promise;
|
||||||
|
return new Promise<Blob>((resolve, reject) => {
|
||||||
|
canvas.toBlob(
|
||||||
|
(result) => (result ? resolve(result) : reject(new Error('Image encode failed'))),
|
||||||
|
mimeType,
|
||||||
|
outputFormat === 'jpeg' ? 0.9 : undefined
|
||||||
|
);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
if (pdfDocument.numPages === 1) {
|
||||||
|
const blob = await renderPage(1);
|
||||||
|
const objectUrl = URL.createObjectURL(blob);
|
||||||
|
downloadBlobUrl(objectUrl, `${baseName}.${extension}`);
|
||||||
|
window.setTimeout(() => URL.revokeObjectURL(objectUrl), 1000);
|
||||||
|
progress = 1;
|
||||||
|
} else {
|
||||||
|
const images: Record<string, Uint8Array> = {};
|
||||||
|
for (let pageNumber = 1; pageNumber <= pdfDocument.numPages; pageNumber += 1) {
|
||||||
|
const blob = await renderPage(pageNumber);
|
||||||
|
images[`seite_${String(pageNumber).padStart(3, '0')}.${extension}`] = new Uint8Array(
|
||||||
|
await blob.arrayBuffer()
|
||||||
|
);
|
||||||
|
progress = pageNumber;
|
||||||
|
}
|
||||||
|
downloadBlob(zipSync(images), `${baseName}_bilder.zip`, 'application/zip');
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
await loadingTask.destroy();
|
||||||
|
}
|
||||||
|
} catch (conversionError) {
|
||||||
|
console.error('Conversion failed:', conversionError);
|
||||||
|
error = 'Fehler beim Konvertieren des PDFs.';
|
||||||
|
} finally {
|
||||||
|
isProcessing = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="mx-auto max-w-4xl space-y-6">
|
||||||
|
<div>
|
||||||
|
<h1 class="text-2xl font-bold text-primary-900">Konvertieren</h1>
|
||||||
|
<p class="mt-2 text-sm text-primary-700">Konvertieren Sie ein PDF in Bilder (PNG oder JPEG).</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if !pdfFile}
|
||||||
|
<FileDropzone onFilesSelected={handleFileSelected} />
|
||||||
|
{:else}
|
||||||
|
<div class="space-y-6">
|
||||||
|
<FileDropzone
|
||||||
|
onFilesSelected={handleFileSelected}
|
||||||
|
label="Anderes PDF ablegen"
|
||||||
|
sublabel={pdfFile.name}
|
||||||
|
class="py-4"
|
||||||
|
/>
|
||||||
|
<RasterizationWarning file={pdfFile} />
|
||||||
|
<div class="rounded-xl border border-primary-200 bg-white p-4 space-y-4">
|
||||||
|
<div class="flex items-center gap-2 text-primary-900">
|
||||||
|
<Image class="h-5 w-5" />
|
||||||
|
<h2 class="text-lg font-semibold">{pdfFile.name}</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<p class="text-sm font-medium text-primary-900 mb-2">Bildformat</p>
|
||||||
|
<div class="flex gap-3">
|
||||||
|
<label class="flex items-center gap-2 cursor-pointer">
|
||||||
|
<input type="radio" bind:group={outputFormat} value="png" class="h-4 w-4" />
|
||||||
|
<span class="text-sm text-primary-700">PNG (verlustfrei)</span>
|
||||||
|
</label>
|
||||||
|
<label class="flex items-center gap-2 cursor-pointer">
|
||||||
|
<input type="radio" bind:group={outputFormat} value="jpeg" class="h-4 w-4" />
|
||||||
|
<span class="text-sm text-primary-700">JPEG (kleiner)</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-primary-900 mb-1" for="render-scale">
|
||||||
|
Auflösung: {scale}x
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="render-scale"
|
||||||
|
type="range"
|
||||||
|
min="1"
|
||||||
|
max="4"
|
||||||
|
step="0.5"
|
||||||
|
bind:value={scale}
|
||||||
|
class="w-full"
|
||||||
|
/>
|
||||||
|
<p class="text-xs text-primary-600 mt-1">
|
||||||
|
{scale === 1
|
||||||
|
? '~72 DPI'
|
||||||
|
: scale === 2
|
||||||
|
? '~150 DPI'
|
||||||
|
: scale === 3
|
||||||
|
? '~216 DPI'
|
||||||
|
: '~288 DPI'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if isProcessing && totalPages > 0}
|
||||||
|
<div class="space-y-1">
|
||||||
|
<div class="flex justify-between text-xs text-primary-700">
|
||||||
|
<span>Wird konvertiert...</span>
|
||||||
|
<span>{progress} / {totalPages}</span>
|
||||||
|
</div>
|
||||||
|
<div class="h-2 w-full rounded-full bg-primary-100">
|
||||||
|
<div
|
||||||
|
class="h-2 rounded-full bg-primary transition-all"
|
||||||
|
style="width: {(progress / totalPages) * 100}%"
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if error}
|
||||||
|
<div class="rounded-lg border border-red-200 bg-red-50 p-4 text-sm text-red-800">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="flex justify-end gap-2">
|
||||||
|
<Button onclick={convertPdf} disabled={isProcessing || rasterizationBlocked}>
|
||||||
|
{isProcessing ? 'Wird konvertiert...' : 'Konvertieren & herunterladen'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,215 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { PDFDocument } from 'pdf-lib';
|
||||||
|
import BatchFileList from '$lib/components/BatchFileList.svelte';
|
||||||
|
import FileDropzone from '$lib/components/FileDropzone.svelte';
|
||||||
|
import Button from '$lib/components/ui/button.svelte';
|
||||||
|
import { createBatchItem, runBatch, type BatchItem } from '$lib/batch';
|
||||||
|
import { downloadBlob } from '$lib/download';
|
||||||
|
import { isRasterizationTooLarge, RASTERIZATION_MAX_BYTES } from '$lib/rasterization-limits';
|
||||||
|
import { Unlock, AlertTriangle } from '@lucide/svelte';
|
||||||
|
|
||||||
|
let items = $state<BatchItem[]>([]);
|
||||||
|
let password = $state('');
|
||||||
|
let isRunning = $state(false);
|
||||||
|
let error = $state<string | null>(null);
|
||||||
|
|
||||||
|
const pendingCount = $derived(items.filter((item) => item.status === 'pending').length);
|
||||||
|
const canRun = $derived(!isRunning && pendingCount > 0 && password.length > 0);
|
||||||
|
|
||||||
|
const handleFilesSelected = (files: File[]) => {
|
||||||
|
const knownKeys = new Set(items.map((item) => `${item.file.name}:${item.file.size}`));
|
||||||
|
const additions = files
|
||||||
|
.filter((file) => !knownKeys.has(`${file.name}:${file.size}`))
|
||||||
|
.map((file) => {
|
||||||
|
const item = createBatchItem(file);
|
||||||
|
if (isRasterizationTooLarge(file)) {
|
||||||
|
item.status = 'error';
|
||||||
|
item.errorMessage = `Diese Datei ist größer als ${RASTERIZATION_MAX_BYTES / (1024 * 1024)} MB und kann aus Speichergründen nicht im Browser gerastert werden.`;
|
||||||
|
}
|
||||||
|
return item;
|
||||||
|
});
|
||||||
|
items = [...items, ...additions];
|
||||||
|
error = null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateItem = (id: string, patch: Partial<BatchItem>) => {
|
||||||
|
items = items.map((item) => (item.id === id ? { ...item, ...patch } : item));
|
||||||
|
};
|
||||||
|
|
||||||
|
const processFile = async (file: File) => {
|
||||||
|
const pdfjsLib = await import('pdfjs-dist');
|
||||||
|
const { getDocument, GlobalWorkerOptions } = pdfjsLib;
|
||||||
|
GlobalWorkerOptions.workerSrc = await import('pdfjs-dist/build/pdf.worker.mjs?url').then(
|
||||||
|
(module) => module.default
|
||||||
|
);
|
||||||
|
|
||||||
|
const loadingTask = getDocument({
|
||||||
|
data: new Uint8Array(await file.arrayBuffer()),
|
||||||
|
password
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const pdfDocument = await loadingTask.promise;
|
||||||
|
const outputPdf = await PDFDocument.create();
|
||||||
|
|
||||||
|
for (let pageNumber = 1; pageNumber <= pdfDocument.numPages; pageNumber += 1) {
|
||||||
|
const page = await pdfDocument.getPage(pageNumber);
|
||||||
|
const viewport = page.getViewport({ scale: 2 });
|
||||||
|
const canvas = document.createElement('canvas');
|
||||||
|
canvas.width = Math.ceil(viewport.width);
|
||||||
|
canvas.height = Math.ceil(viewport.height);
|
||||||
|
const canvasContext = canvas.getContext('2d');
|
||||||
|
if (!canvasContext) throw new Error('Could not create canvas context');
|
||||||
|
|
||||||
|
await page.render({
|
||||||
|
canvas,
|
||||||
|
canvasContext,
|
||||||
|
viewport,
|
||||||
|
background: '#ffffff'
|
||||||
|
}).promise;
|
||||||
|
const pngBlob = await new Promise<Blob>((resolve, reject) => {
|
||||||
|
canvas.toBlob(
|
||||||
|
(result) => (result ? resolve(result) : reject(new Error('PNG encode failed'))),
|
||||||
|
'image/png'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
const pngImage = await outputPdf.embedPng(new Uint8Array(await pngBlob.arrayBuffer()));
|
||||||
|
const originalViewport = page.getViewport({ scale: 1 });
|
||||||
|
const pdfPage = outputPdf.addPage([originalViewport.width, originalViewport.height]);
|
||||||
|
pdfPage.drawImage(pngImage, {
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
width: pdfPage.getWidth(),
|
||||||
|
height: pdfPage.getHeight()
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const bytes = await outputPdf.save();
|
||||||
|
return { bytes, name: `${file.name.replace(/\.pdf$/i, '')}_entsperrt.pdf` };
|
||||||
|
} finally {
|
||||||
|
await loadingTask.destroy();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const startBatch = async () => {
|
||||||
|
if (isRunning) return;
|
||||||
|
const queue = items.filter((item) => item.status === 'pending');
|
||||||
|
if (queue.length === 0) return;
|
||||||
|
|
||||||
|
isRunning = true;
|
||||||
|
try {
|
||||||
|
await runBatch(queue, processFile, updateItem, {
|
||||||
|
errorMessage:
|
||||||
|
'Fehler beim Entsperren. Bitte prüfen Sie das Passwort und versuchen Sie es erneut.',
|
||||||
|
// Skip rows removed while they were still waiting in the queue.
|
||||||
|
shouldProcess: (id) => items.some((item) => item.id === id)
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
isRunning = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const retryItem = async (id: string) => {
|
||||||
|
if (isRunning) return;
|
||||||
|
const item = items.find((entry) => entry.id === id);
|
||||||
|
if (!item) return;
|
||||||
|
|
||||||
|
isRunning = true;
|
||||||
|
try {
|
||||||
|
await runBatch([item], processFile, updateItem, {
|
||||||
|
errorMessage:
|
||||||
|
'Fehler beim Entsperren. Bitte prüfen Sie das Passwort und versuchen Sie es erneut.'
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
isRunning = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeItem = (id: string) => {
|
||||||
|
items = items.filter((item) => item.id !== id);
|
||||||
|
};
|
||||||
|
|
||||||
|
const downloadItem = (item: BatchItem) => {
|
||||||
|
if (item.resultBytes && item.resultName) downloadBlob(item.resultBytes, item.resultName);
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="mx-auto max-w-4xl space-y-6">
|
||||||
|
<div>
|
||||||
|
<h1 class="text-2xl font-bold text-foreground">Passwort entfernen</h1>
|
||||||
|
<p class="mt-2 text-sm text-primary">
|
||||||
|
Entfernen Sie den Passwortschutz von PDF-Dokumenten – einzeln oder mehrere in einem Durchlauf.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="rounded-lg border border-amber-200 bg-amber-50 p-4 flex gap-3">
|
||||||
|
<AlertTriangle class="h-5 w-5 shrink-0 text-amber-600 mt-0.5" />
|
||||||
|
<div class="text-sm text-amber-800">
|
||||||
|
<p class="font-medium">Hinweis zur Qualität</p>
|
||||||
|
<p class="mt-1">
|
||||||
|
Das PDF wird neu gerendert, wodurch Text zu Bildern wird. Ausgewählter Text,
|
||||||
|
Durchsuchbarkeit und Vektorgrafiken gehen verloren. Die Dateigröße kann größer werden.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<FileDropzone
|
||||||
|
onFilesSelected={handleFilesSelected}
|
||||||
|
multiple
|
||||||
|
label={items.length === 0 ? 'Dateien hinzufügen' : 'Weitere Dateien ablegen'}
|
||||||
|
sublabel={items.length === 0
|
||||||
|
? 'PDFs hier ablegen oder zum Auswählen klicken'
|
||||||
|
: 'Weitere PDFs hinzufügen – bestehende Dateien bleiben erhalten'}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{#if items.length > 0}
|
||||||
|
<div class="rounded-2xl border border-border bg-card p-4 space-y-4">
|
||||||
|
<div class="flex items-center gap-2 text-foreground">
|
||||||
|
<Unlock class="h-5 w-5" />
|
||||||
|
<h2 class="text-lg font-semibold">Passwort</h2>
|
||||||
|
</div>
|
||||||
|
<p class="text-xs text-primary">Das Passwort gilt für alle Dateien der Liste.</p>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-foreground mb-1" for="decrypt-password">
|
||||||
|
Passwort
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="decrypt-password"
|
||||||
|
type="password"
|
||||||
|
bind:value={password}
|
||||||
|
class="w-full rounded-lg border border-border px-3 py-2 text-sm focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary"
|
||||||
|
placeholder="Passwort der PDFs eingeben"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if isRunning}
|
||||||
|
<p class="text-xs text-primary" role="note">
|
||||||
|
Änderungen am Passwort gelten für die noch ausstehenden Dateien.
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<BatchFileList
|
||||||
|
{items}
|
||||||
|
zipBaseName="entsperrt"
|
||||||
|
onDownload={downloadItem}
|
||||||
|
onRemove={removeItem}
|
||||||
|
onRetry={retryItem}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if error}
|
||||||
|
<div class="rounded-lg border border-red-200 bg-red-50 p-4 text-sm text-red-800">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if items.length > 0}
|
||||||
|
<div class="flex justify-end">
|
||||||
|
<Button onclick={startBatch} disabled={!canRun}>
|
||||||
|
{isRunning ? 'Wird entsperrt...' : 'Passwort entfernen'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,192 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { PDFDocument } from '@cantoo/pdf-lib';
|
||||||
|
import BatchFileList from '$lib/components/BatchFileList.svelte';
|
||||||
|
import FileDropzone from '$lib/components/FileDropzone.svelte';
|
||||||
|
import Button from '$lib/components/ui/button.svelte';
|
||||||
|
import { createBatchItem, runBatch, type BatchItem } from '$lib/batch';
|
||||||
|
import { downloadBlob } from '$lib/download';
|
||||||
|
import { Lock } from '@lucide/svelte';
|
||||||
|
|
||||||
|
let items = $state<BatchItem[]>([]);
|
||||||
|
let password = $state('');
|
||||||
|
let confirmPassword = $state('');
|
||||||
|
let allowPrinting = $state(true);
|
||||||
|
let allowCopying = $state(true);
|
||||||
|
let isRunning = $state(false);
|
||||||
|
let error = $state<string | null>(null);
|
||||||
|
|
||||||
|
const pendingCount = $derived(items.filter((item) => item.status === 'pending').length);
|
||||||
|
const canRun = $derived(
|
||||||
|
!isRunning && pendingCount > 0 && password.length > 0 && password === confirmPassword
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleFilesSelected = (files: File[]) => {
|
||||||
|
const knownKeys = new Set(items.map((item) => `${item.file.name}:${item.file.size}`));
|
||||||
|
const additions = files
|
||||||
|
.filter((file) => !knownKeys.has(`${file.name}:${file.size}`))
|
||||||
|
.map((file) => createBatchItem(file));
|
||||||
|
items = [...items, ...additions];
|
||||||
|
error = null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateItem = (id: string, patch: Partial<BatchItem>) => {
|
||||||
|
items = items.map((item) => (item.id === id ? { ...item, ...patch } : item));
|
||||||
|
};
|
||||||
|
|
||||||
|
const processFile = async (file: File) => {
|
||||||
|
// The password and permissions are read per file so a change during a
|
||||||
|
// run only affects the still pending files.
|
||||||
|
const bytes = new Uint8Array(await file.arrayBuffer());
|
||||||
|
const pdfDoc = await PDFDocument.load(bytes);
|
||||||
|
|
||||||
|
pdfDoc.encrypt({
|
||||||
|
userPassword: password,
|
||||||
|
ownerPassword: crypto.randomUUID(),
|
||||||
|
permissions: {
|
||||||
|
printing: allowPrinting,
|
||||||
|
copying: allowCopying
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const encryptedBytes = await pdfDoc.save();
|
||||||
|
return { bytes: encryptedBytes, name: `${file.name.replace(/\.pdf$/i, '')}_geschuetzt.pdf` };
|
||||||
|
};
|
||||||
|
|
||||||
|
const startBatch = async () => {
|
||||||
|
if (isRunning) return;
|
||||||
|
const queue = items.filter((item) => item.status === 'pending');
|
||||||
|
if (queue.length === 0) return;
|
||||||
|
|
||||||
|
isRunning = true;
|
||||||
|
try {
|
||||||
|
await runBatch(queue, processFile, updateItem, {
|
||||||
|
errorMessage: 'Diese PDF konnte nicht verschlüsselt werden.',
|
||||||
|
// Skip rows removed while they were still waiting in the queue.
|
||||||
|
shouldProcess: (id) => items.some((item) => item.id === id)
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
isRunning = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const retryItem = async (id: string) => {
|
||||||
|
if (isRunning) return;
|
||||||
|
const item = items.find((entry) => entry.id === id);
|
||||||
|
if (!item) return;
|
||||||
|
|
||||||
|
isRunning = true;
|
||||||
|
try {
|
||||||
|
await runBatch([item], processFile, updateItem, {
|
||||||
|
errorMessage: 'Diese PDF konnte nicht verschlüsselt werden.'
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
isRunning = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeItem = (id: string) => {
|
||||||
|
items = items.filter((item) => item.id !== id);
|
||||||
|
};
|
||||||
|
|
||||||
|
const downloadItem = (item: BatchItem) => {
|
||||||
|
if (item.resultBytes && item.resultName) downloadBlob(item.resultBytes, item.resultName);
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="mx-auto max-w-4xl space-y-6">
|
||||||
|
<div>
|
||||||
|
<h1 class="text-2xl font-bold text-foreground">Passwort setzen</h1>
|
||||||
|
<p class="mt-2 text-sm text-primary">
|
||||||
|
Verschlüsseln Sie Ihre PDFs mit einem Passwort, um sie vor unbefugtem Zugriff zu schützen –
|
||||||
|
einzeln oder mehrere in einem Durchlauf.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<FileDropzone
|
||||||
|
onFilesSelected={handleFilesSelected}
|
||||||
|
multiple
|
||||||
|
label={items.length === 0 ? 'Dateien hinzufügen' : 'Weitere Dateien ablegen'}
|
||||||
|
sublabel={items.length === 0
|
||||||
|
? 'PDFs hier ablegen oder zum Auswählen klicken'
|
||||||
|
: 'Weitere PDFs hinzufügen – bestehende Dateien bleiben erhalten'}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{#if items.length > 0}
|
||||||
|
<div class="rounded-2xl border border-border bg-card p-4 space-y-4">
|
||||||
|
<div class="flex items-center gap-2 text-foreground">
|
||||||
|
<Lock class="h-5 w-5" />
|
||||||
|
<h2 class="text-lg font-semibold">Passwort</h2>
|
||||||
|
</div>
|
||||||
|
<p class="text-xs text-primary">Das Passwort gilt für alle Dateien der Liste.</p>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-foreground mb-1" for="encrypt-password">
|
||||||
|
Passwort
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="encrypt-password"
|
||||||
|
type="password"
|
||||||
|
bind:value={password}
|
||||||
|
class="w-full rounded-lg border border-border px-3 py-2 text-sm focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary"
|
||||||
|
placeholder="Passwort eingeben"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
class="block text-sm font-medium text-foreground mb-1"
|
||||||
|
for="encrypt-password-confirmation"
|
||||||
|
>
|
||||||
|
Passwort bestätigen
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="encrypt-password-confirmation"
|
||||||
|
type="password"
|
||||||
|
bind:value={confirmPassword}
|
||||||
|
class="w-full rounded-lg border border-border px-3 py-2 text-sm focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary"
|
||||||
|
placeholder="Passwort wiederholen"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-2">
|
||||||
|
<p class="text-sm font-medium text-foreground">Berechtigungen</p>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<input type="checkbox" id="allow-printing" bind:checked={allowPrinting} class="h-4 w-4" />
|
||||||
|
<label for="allow-printing" class="text-sm text-primary">Drucken erlauben</label>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<input type="checkbox" id="allow-copying" bind:checked={allowCopying} class="h-4 w-4" />
|
||||||
|
<label for="allow-copying" class="text-sm text-primary">Kopieren erlauben</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if isRunning}
|
||||||
|
<p class="text-xs text-primary" role="note">
|
||||||
|
Änderungen am Passwort gelten für die noch ausstehenden Dateien.
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<BatchFileList
|
||||||
|
{items}
|
||||||
|
zipBaseName="geschuetzt"
|
||||||
|
onDownload={downloadItem}
|
||||||
|
onRemove={removeItem}
|
||||||
|
onRetry={retryItem}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if error}
|
||||||
|
<div class="rounded-lg border border-red-200 bg-red-50 p-4 text-sm text-red-800">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if items.length > 0}
|
||||||
|
<div class="flex justify-end">
|
||||||
|
<Button onclick={startBatch} disabled={!canRun}>
|
||||||
|
{isRunning ? 'Wird verschlüsselt...' : 'Passwort setzen'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import FileDropzone from '$lib/components/FileDropzone.svelte';
|
||||||
|
import Button from '$lib/components/ui/button.svelte';
|
||||||
|
import { downloadBlob } from '$lib/download';
|
||||||
|
import { mergePdfFiles } from '$lib/pdf-processing';
|
||||||
|
import { Trash2 } from '@lucide/svelte';
|
||||||
|
|
||||||
|
let pdfFiles: File[] = $state([]);
|
||||||
|
let isProcessing = $state(false);
|
||||||
|
let error = $state<string | null>(null);
|
||||||
|
|
||||||
|
const handleFilesSelected = (files: File[]) => {
|
||||||
|
pdfFiles = [...pdfFiles, ...files];
|
||||||
|
error = null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeFile = (index: number) => {
|
||||||
|
pdfFiles = pdfFiles.filter((_, i) => i !== index);
|
||||||
|
};
|
||||||
|
|
||||||
|
const moveFile = (fromIndex: number, toIndex: number) => {
|
||||||
|
if (toIndex < 0 || toIndex >= pdfFiles.length) return;
|
||||||
|
const newFiles = [...pdfFiles];
|
||||||
|
const [moved] = newFiles.splice(fromIndex, 1);
|
||||||
|
newFiles.splice(toIndex, 0, moved);
|
||||||
|
pdfFiles = newFiles;
|
||||||
|
};
|
||||||
|
|
||||||
|
const mergePdfs = async () => {
|
||||||
|
if (pdfFiles.length < 2) return;
|
||||||
|
|
||||||
|
isProcessing = true;
|
||||||
|
error = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
downloadBlob(await mergePdfFiles(pdfFiles), 'zusammengefuegt.pdf');
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Merge failed:', err);
|
||||||
|
error = 'Fehler beim Zusammenfügen der PDFs.';
|
||||||
|
} finally {
|
||||||
|
isProcessing = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="mx-auto max-w-4xl space-y-6">
|
||||||
|
<div>
|
||||||
|
<h1 class="text-2xl font-bold text-primary-900">PDFs zusammenfügen</h1>
|
||||||
|
<p class="mt-2 text-sm text-primary-700">
|
||||||
|
Fügen Sie mehrere PDF-Dateien zu einem einzigen Dokument zusammen.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<FileDropzone onFilesSelected={handleFilesSelected} multiple={true} />
|
||||||
|
|
||||||
|
{#if pdfFiles.length > 0}
|
||||||
|
<div class="space-y-4">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<h2 class="text-lg font-semibold text-primary-900">
|
||||||
|
{pdfFiles.length} Datei{pdfFiles.length === 1 ? '' : 'en'} ausgewählt
|
||||||
|
</h2>
|
||||||
|
<Button onclick={mergePdfs} disabled={isProcessing || pdfFiles.length < 2}>
|
||||||
|
{isProcessing ? 'Wird zusammengefügt...' : 'PDFs zusammenfügen'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if error}
|
||||||
|
<div class="rounded-lg border border-red-200 bg-red-50 p-4 text-sm text-red-800">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="space-y-2">
|
||||||
|
{#each pdfFiles as file, index}
|
||||||
|
<div class="flex items-center gap-3 rounded-lg border border-primary-200 bg-white p-3">
|
||||||
|
<span
|
||||||
|
class="flex h-8 w-8 items-center justify-center rounded-full bg-primary/10 text-sm font-semibold text-primary"
|
||||||
|
>
|
||||||
|
{index + 1}
|
||||||
|
</span>
|
||||||
|
<span class="flex-1 truncate text-sm text-primary-900">{file.name}</span>
|
||||||
|
<div class="flex gap-1">
|
||||||
|
<Button
|
||||||
|
variant="tertiary"
|
||||||
|
class="min-w-0 px-3"
|
||||||
|
onclick={() => moveFile(index, index - 1)}
|
||||||
|
disabled={index === 0}
|
||||||
|
>
|
||||||
|
↑
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="tertiary"
|
||||||
|
class="min-w-0 px-3"
|
||||||
|
onclick={() => moveFile(index, index + 1)}
|
||||||
|
disabled={index === pdfFiles.length - 1}
|
||||||
|
>
|
||||||
|
↓
|
||||||
|
</Button>
|
||||||
|
<Button variant="tertiary" class="min-w-0 px-3" onclick={() => removeFile(index)}>
|
||||||
|
<Trash2 class="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,219 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { onDestroy } from 'svelte';
|
||||||
|
import { degrees, PDFDocument } from 'pdf-lib';
|
||||||
|
import { RotateCcw, RotateCw } from '@lucide/svelte';
|
||||||
|
import FileDropzone from '$lib/components/FileDropzone.svelte';
|
||||||
|
import PdfPageGrid from '$lib/components/PdfPageGrid.svelte';
|
||||||
|
import Button from '$lib/components/ui/button.svelte';
|
||||||
|
import { downloadBlob } from '$lib/download';
|
||||||
|
import { loadPdfDocument, type PdfDocumentHandle } from '$lib/pdf-thumbnails';
|
||||||
|
|
||||||
|
let pdfFile: File | null = $state(null);
|
||||||
|
let pdfHandle: PdfDocumentHandle | null = $state(null);
|
||||||
|
let rotations = $state(new Map<number, number>());
|
||||||
|
let isProcessing = $state(false);
|
||||||
|
let error = $state<string | null>(null);
|
||||||
|
let loadingError = $state<string | null>(null);
|
||||||
|
|
||||||
|
const rotatedCount = $derived(rotations.size);
|
||||||
|
const hasRotations = $derived(rotatedCount > 0);
|
||||||
|
|
||||||
|
const isEncryptedPdfError = (loadError: unknown) => {
|
||||||
|
const message =
|
||||||
|
loadError instanceof Error ? `${loadError.name} ${loadError.message}` : String(loadError);
|
||||||
|
return /password|encrypted|encryption|verschl/i.test(message);
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadErrorMessage = (loadError: unknown) =>
|
||||||
|
isEncryptedPdfError(loadError)
|
||||||
|
? 'Dieses PDF ist passwortgeschützt. Entfernen Sie zuerst unter „Passwort entfernen“ den Schutz.'
|
||||||
|
: 'PDF konnte nicht geladen werden. Prüfen Sie, ob die Datei gültig ist.';
|
||||||
|
|
||||||
|
const handleFileSelected = async (files: File[]) => {
|
||||||
|
const file = files[0];
|
||||||
|
if (!file) return;
|
||||||
|
|
||||||
|
pdfHandle?.destroy();
|
||||||
|
pdfHandle = null;
|
||||||
|
pdfFile = file;
|
||||||
|
rotations = new Map();
|
||||||
|
loadingError = null;
|
||||||
|
error = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const bytes = new Uint8Array(await file.arrayBuffer());
|
||||||
|
pdfHandle = await loadPdfDocument(bytes);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to load PDF:', err);
|
||||||
|
loadingError = loadErrorMessage(err);
|
||||||
|
pdfHandle = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const rotatePage = (pageNumber: number, delta: number) => {
|
||||||
|
const next = new Map(rotations);
|
||||||
|
const current = next.get(pageNumber) ?? 0;
|
||||||
|
const value = (((current + delta) % 360) + 360) % 360;
|
||||||
|
if (value === 0) {
|
||||||
|
next.delete(pageNumber);
|
||||||
|
} else {
|
||||||
|
next.set(pageNumber, value);
|
||||||
|
}
|
||||||
|
rotations = next;
|
||||||
|
};
|
||||||
|
|
||||||
|
const rotateAll = (delta: number) => {
|
||||||
|
if (!pdfHandle) return;
|
||||||
|
for (let pageNumber = 1; pageNumber <= pdfHandle.pageCount; pageNumber += 1) {
|
||||||
|
rotatePage(pageNumber, delta);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const resetRotations = () => {
|
||||||
|
rotations = new Map();
|
||||||
|
};
|
||||||
|
|
||||||
|
const previewClass = (pageNumber: number) => {
|
||||||
|
const delta = rotations.get(pageNumber) ?? 0;
|
||||||
|
switch (delta) {
|
||||||
|
case 90:
|
||||||
|
return 'rotate-90';
|
||||||
|
case 180:
|
||||||
|
return 'rotate-180';
|
||||||
|
case 270:
|
||||||
|
return 'rotate-[270deg]';
|
||||||
|
default:
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const rotateAndDownload = async () => {
|
||||||
|
if (!pdfFile || !pdfHandle || !hasRotations) return;
|
||||||
|
|
||||||
|
isProcessing = true;
|
||||||
|
error = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const bytes = new Uint8Array(await pdfFile.arrayBuffer());
|
||||||
|
const pdfDoc = await PDFDocument.load(bytes);
|
||||||
|
for (const [index, page] of pdfDoc.getPages().entries()) {
|
||||||
|
const delta = rotations.get(index + 1);
|
||||||
|
if (!delta) continue;
|
||||||
|
const current = page.getRotation().angle;
|
||||||
|
page.setRotation(degrees((((current + delta) % 360) + 360) % 360));
|
||||||
|
}
|
||||||
|
|
||||||
|
const resultBytes = await pdfDoc.save();
|
||||||
|
const baseName = pdfFile.name.replace(/\.pdf$/i, '');
|
||||||
|
downloadBlob(resultBytes, `${baseName}_gedreht.pdf`);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to rotate pages:', err);
|
||||||
|
error = isEncryptedPdfError(err)
|
||||||
|
? 'Dieses PDF ist passwortgeschützt. Entfernen Sie zuerst unter „Passwort entfernen“ den Schutz.'
|
||||||
|
: 'Fehler beim Drehen der Seiten.';
|
||||||
|
} finally {
|
||||||
|
isProcessing = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
onDestroy(() => {
|
||||||
|
pdfHandle?.destroy();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="mx-auto max-w-4xl space-y-6">
|
||||||
|
<div>
|
||||||
|
<h1 class="text-2xl font-bold text-primary-900">Seiten drehen</h1>
|
||||||
|
<p class="mt-2 text-sm text-primary-700">
|
||||||
|
Drehen Sie einzelne oder alle Seiten eines PDFs — verlustfrei und ohne Qualitätsverlust.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if !pdfHandle}
|
||||||
|
<FileDropzone onFilesSelected={handleFileSelected} />
|
||||||
|
{#if loadingError}
|
||||||
|
<div class="mt-4 rounded-lg border border-red-200 bg-red-50 p-4 text-sm text-red-800">
|
||||||
|
{loadingError}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{:else}
|
||||||
|
<div class="space-y-4">
|
||||||
|
<FileDropzone
|
||||||
|
onFilesSelected={handleFileSelected}
|
||||||
|
label="Anderes PDF ablegen"
|
||||||
|
sublabel={pdfFile?.name ?? ''}
|
||||||
|
class="py-4"
|
||||||
|
/>
|
||||||
|
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||||
|
<h2 class="text-lg font-semibold text-primary-900">
|
||||||
|
{pdfFile?.name} — {pdfHandle.pageCount} Seite{pdfHandle.pageCount === 1 ? '' : 'n'}
|
||||||
|
</h2>
|
||||||
|
<div class="flex flex-wrap gap-2">
|
||||||
|
<Button variant="tertiary" class="min-w-0 px-3" onclick={() => rotateAll(90)}>
|
||||||
|
Alle 90° rechts
|
||||||
|
</Button>
|
||||||
|
<Button variant="tertiary" class="min-w-0 px-3" onclick={() => rotateAll(270)}>
|
||||||
|
Alle 90° links
|
||||||
|
</Button>
|
||||||
|
<Button variant="tertiary" class="min-w-0 px-3" onclick={() => rotateAll(180)}>
|
||||||
|
Alle 180°
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="tertiary"
|
||||||
|
class="min-w-0 px-3"
|
||||||
|
onclick={resetRotations}
|
||||||
|
disabled={!hasRotations}
|
||||||
|
>
|
||||||
|
Zurücksetzen
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<PdfPageGrid thumbnails={pdfHandle.thumbnails} thumbnailWidth={160} cardClass={previewClass}>
|
||||||
|
{#snippet pageActions(pageNumber)}
|
||||||
|
{#if (rotations.get(pageNumber) ?? 0) !== 0}
|
||||||
|
<span
|
||||||
|
class="absolute top-1 right-1 rounded-full bg-primary px-1.5 py-0.5 text-[10px] font-bold text-white"
|
||||||
|
>
|
||||||
|
{rotations.get(pageNumber)}°
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
<div class="flex gap-1">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="flex h-7 w-7 items-center justify-center rounded-lg border border-primary-200 bg-white text-primary transition hover:bg-primary-50 focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-400"
|
||||||
|
aria-label="Seite {pageNumber} um 90° nach links drehen"
|
||||||
|
onclick={() => rotatePage(pageNumber, 270)}
|
||||||
|
>
|
||||||
|
<RotateCcw class="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="flex h-7 w-7 items-center justify-center rounded-lg border border-primary-200 bg-white text-primary transition hover:bg-primary-50 focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-400"
|
||||||
|
aria-label="Seite {pageNumber} um 90° nach rechts drehen"
|
||||||
|
onclick={() => rotatePage(pageNumber, 90)}
|
||||||
|
>
|
||||||
|
<RotateCw class="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{/snippet}
|
||||||
|
</PdfPageGrid>
|
||||||
|
|
||||||
|
{#if error}
|
||||||
|
<div class="rounded-lg border border-red-200 bg-red-50 p-4 text-sm text-red-800">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<p class="text-sm text-primary-700">
|
||||||
|
{rotatedCount} von {pdfHandle.pageCount} Seite{pdfHandle.pageCount === 1 ? '' : 'n'}
|
||||||
|
gedreht
|
||||||
|
</p>
|
||||||
|
<Button onclick={rotateAndDownload} disabled={isProcessing || !hasRotations}>
|
||||||
|
{isProcessing ? 'Wird verarbeitet...' : 'Drehen & herunterladen'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,162 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { onDestroy } from 'svelte';
|
||||||
|
import { PDFDocument } from 'pdf-lib';
|
||||||
|
import FileDropzone from '$lib/components/FileDropzone.svelte';
|
||||||
|
import PdfPageGrid from '$lib/components/PdfPageGrid.svelte';
|
||||||
|
import Button from '$lib/components/ui/button.svelte';
|
||||||
|
import { downloadBlob } from '$lib/download';
|
||||||
|
import { loadPdfDocument, type PdfDocumentHandle } from '$lib/pdf-thumbnails';
|
||||||
|
|
||||||
|
let pdfFile: File | null = $state(null);
|
||||||
|
let pdfHandle: PdfDocumentHandle | null = $state(null);
|
||||||
|
let selectedPages = $state(new Set<number>());
|
||||||
|
let isProcessing = $state(false);
|
||||||
|
let error = $state<string | null>(null);
|
||||||
|
let loadingError = $state<string | null>(null);
|
||||||
|
|
||||||
|
const handleFileSelected = async (files: File[]) => {
|
||||||
|
const file = files[0];
|
||||||
|
if (!file) return;
|
||||||
|
|
||||||
|
pdfHandle?.destroy();
|
||||||
|
pdfHandle = null;
|
||||||
|
pdfFile = file;
|
||||||
|
selectedPages = new Set();
|
||||||
|
loadingError = null;
|
||||||
|
error = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const bytes = new Uint8Array(await file.arrayBuffer());
|
||||||
|
pdfHandle = await loadPdfDocument(bytes);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to load PDF:', err);
|
||||||
|
loadingError = 'PDF konnte nicht geladen werden.';
|
||||||
|
pdfHandle = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const togglePage = (pageNumber: number) => {
|
||||||
|
const next = new Set(selectedPages);
|
||||||
|
if (next.has(pageNumber)) {
|
||||||
|
next.delete(pageNumber);
|
||||||
|
} else {
|
||||||
|
next.add(pageNumber);
|
||||||
|
}
|
||||||
|
selectedPages = next;
|
||||||
|
};
|
||||||
|
|
||||||
|
const selectAll = () => {
|
||||||
|
if (!pdfHandle) return;
|
||||||
|
const all = new Set<number>();
|
||||||
|
for (let i = 1; i <= pdfHandle.pageCount; i++) all.add(i);
|
||||||
|
selectedPages = all;
|
||||||
|
};
|
||||||
|
|
||||||
|
const selectNone = () => {
|
||||||
|
selectedPages = new Set();
|
||||||
|
};
|
||||||
|
|
||||||
|
const removePages = async () => {
|
||||||
|
if (!pdfFile || !pdfHandle || selectedPages.size === 0) return;
|
||||||
|
|
||||||
|
isProcessing = true;
|
||||||
|
error = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const bytes = new Uint8Array(await pdfFile.arrayBuffer());
|
||||||
|
const sourcePdf = await PDFDocument.load(bytes);
|
||||||
|
const outputPdf = await PDFDocument.create();
|
||||||
|
|
||||||
|
const pagesToKeep: number[] = [];
|
||||||
|
for (let i = 0; i < sourcePdf.getPageCount(); i++) {
|
||||||
|
if (!selectedPages.has(i + 1)) {
|
||||||
|
pagesToKeep.push(i);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pagesToKeep.length === 0) {
|
||||||
|
error = 'Es muss mindestens eine Seite übrig bleiben.';
|
||||||
|
isProcessing = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const copiedPages = await outputPdf.copyPages(sourcePdf, pagesToKeep);
|
||||||
|
copiedPages.forEach((page) => outputPdf.addPage(page));
|
||||||
|
|
||||||
|
const resultBytes = await outputPdf.save();
|
||||||
|
const baseName = pdfFile.name.replace(/\.pdf$/i, '');
|
||||||
|
downloadBlob(resultBytes, `${baseName}_ohne_seiten.pdf`);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to remove pages:', err);
|
||||||
|
error = 'Fehler beim Entfernen der Seiten.';
|
||||||
|
} finally {
|
||||||
|
isProcessing = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
onDestroy(() => {
|
||||||
|
pdfHandle?.destroy();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="mx-auto max-w-4xl space-y-6">
|
||||||
|
<div>
|
||||||
|
<h1 class="text-2xl font-bold text-primary-900">Seiten entfernen</h1>
|
||||||
|
<p class="mt-2 text-sm text-primary-700">
|
||||||
|
Wählen Sie Seiten aus, die Sie aus dem PDF entfernen möchten.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if !pdfHandle}
|
||||||
|
<FileDropzone onFilesSelected={handleFileSelected} />
|
||||||
|
{#if loadingError}
|
||||||
|
<div class="mt-4 rounded-lg border border-red-200 bg-red-50 p-4 text-sm text-red-800">
|
||||||
|
{loadingError}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{:else}
|
||||||
|
<div class="space-y-4">
|
||||||
|
<FileDropzone
|
||||||
|
onFilesSelected={handleFileSelected}
|
||||||
|
label="Anderes PDF ablegen"
|
||||||
|
sublabel={pdfFile?.name ?? ''}
|
||||||
|
class="py-4"
|
||||||
|
/>
|
||||||
|
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||||
|
<h2 class="text-lg font-semibold text-primary-900">
|
||||||
|
{pdfFile?.name} — {pdfHandle.pageCount} Seite{pdfHandle.pageCount === 1 ? '' : 'n'}
|
||||||
|
</h2>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<Button variant="tertiary" class="min-w-0 px-3" onclick={selectAll}>
|
||||||
|
Alle auswählen
|
||||||
|
</Button>
|
||||||
|
<Button variant="tertiary" class="min-w-0 px-3" onclick={selectNone}>
|
||||||
|
Auswahl aufheben
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<PdfPageGrid thumbnails={pdfHandle.thumbnails} {selectedPages} onPageClick={togglePage} />
|
||||||
|
|
||||||
|
{#if error}
|
||||||
|
<div class="rounded-lg border border-red-200 bg-red-50 p-4 text-sm text-red-800">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<p class="text-sm text-primary-700">
|
||||||
|
{selectedPages.size} Seite{selectedPages.size === 1 ? '' : 'n'} zum Entfernen ausgewählt
|
||||||
|
</p>
|
||||||
|
<Button
|
||||||
|
onclick={removePages}
|
||||||
|
disabled={isProcessing ||
|
||||||
|
selectedPages.size === 0 ||
|
||||||
|
selectedPages.size >= pdfHandle.pageCount}
|
||||||
|
>
|
||||||
|
{isProcessing ? 'Wird verarbeitet...' : 'Seiten entfernen & herunterladen'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,713 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { onDestroy } from 'svelte';
|
||||||
|
import { ChevronLeft, ChevronRight, Stamp } from '@lucide/svelte';
|
||||||
|
import { degrees, PDFDocument, StandardFonts, type PDFFont } from 'pdf-lib';
|
||||||
|
import FileDropzone from '$lib/components/FileDropzone.svelte';
|
||||||
|
import PdfPageGrid from '$lib/components/PdfPageGrid.svelte';
|
||||||
|
import Button from '$lib/components/ui/button.svelte';
|
||||||
|
import { downloadBlob } from '$lib/download';
|
||||||
|
import { getPositionCoords, parseHexColor, type OverlayPosition } from '$lib/pdf-overlay';
|
||||||
|
import { loadPdfDocument, type PdfDocumentHandle } from '$lib/pdf-thumbnails';
|
||||||
|
|
||||||
|
const PRESETS = ['BEGLAUBIGT', 'AUSFERTIGUNG', 'KOPIE', 'EILT', 'ENTWURF', 'ERLEDIGT'] as const;
|
||||||
|
const PADDING = 8;
|
||||||
|
const PAGE_MARGIN = 20;
|
||||||
|
const MIN_EXPORT_FONT_SIZE = 4;
|
||||||
|
const MIN_EXPORT_IMAGE_SIZE = 20;
|
||||||
|
|
||||||
|
type StampImage = {
|
||||||
|
name: string;
|
||||||
|
bytes: Uint8Array;
|
||||||
|
mimeType: 'image/png' | 'image/jpeg';
|
||||||
|
objectUrl: string;
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
let pdfFile: File | null = $state(null);
|
||||||
|
let pdfHandle: PdfDocumentHandle | null = $state(null);
|
||||||
|
let stampInput = $state('BEGLAUBIGT');
|
||||||
|
let withDate = $state(false);
|
||||||
|
let fontSize = $state(24);
|
||||||
|
let stampImage = $state<StampImage | null>(null);
|
||||||
|
let isLoadingImage = $state(false);
|
||||||
|
let withBorder = $state(false);
|
||||||
|
let imageSize = $state(120);
|
||||||
|
let stampColor = $state('#cc1a1a');
|
||||||
|
let position = $state<OverlayPosition>('top-right');
|
||||||
|
let rotation = $state(0);
|
||||||
|
let applyToAll = $state(true);
|
||||||
|
let selectedPages = $state(new Set<number>());
|
||||||
|
let currentPage = $state(1);
|
||||||
|
let isLoading = $state(false);
|
||||||
|
let isProcessing = $state(false);
|
||||||
|
let error = $state<string | null>(null);
|
||||||
|
let handleGeneration = 0;
|
||||||
|
|
||||||
|
const formatGermanDate = (date: Date) =>
|
||||||
|
[date.getDate(), date.getMonth() + 1, date.getFullYear()]
|
||||||
|
.map((part, index) => (index < 2 ? String(part).padStart(2, '0') : String(part)))
|
||||||
|
.join('.');
|
||||||
|
|
||||||
|
const buildStampText = (input: string, appendDate: boolean, date: Date) => {
|
||||||
|
const uppercaseText = input.trim().toLocaleUpperCase('de-DE');
|
||||||
|
return `${uppercaseText}${appendDate ? ` ${formatGermanDate(date)}` : ''}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const previewText = $derived(buildStampText(stampInput, withDate, new Date()));
|
||||||
|
const selectedPreset = $derived(
|
||||||
|
PRESETS.find((preset) => preset === stampInput.trim().toLocaleUpperCase('de-DE'))
|
||||||
|
);
|
||||||
|
const canProcess = $derived(
|
||||||
|
pdfFile !== null &&
|
||||||
|
pdfHandle !== null &&
|
||||||
|
(stampImage !== null || previewText.length > 0) &&
|
||||||
|
!isLoading &&
|
||||||
|
!isLoadingImage &&
|
||||||
|
!isProcessing &&
|
||||||
|
(applyToAll || selectedPages.size > 0)
|
||||||
|
);
|
||||||
|
const previewFontSize = $derived(
|
||||||
|
Math.max(6, Math.min(Number(fontSize) * 0.5, 240 / Math.max(8, previewText.length * 0.62)))
|
||||||
|
);
|
||||||
|
|
||||||
|
const isEncryptedPdfError = (loadError: unknown) => {
|
||||||
|
const message =
|
||||||
|
loadError instanceof Error ? `${loadError.name} ${loadError.message}` : String(loadError);
|
||||||
|
return /password|encrypted|encryption|verschl/i.test(message);
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadErrorMessage = (loadError: unknown) =>
|
||||||
|
isEncryptedPdfError(loadError)
|
||||||
|
? 'Dieses PDF ist passwortgeschützt. Entfernen Sie zuerst unter „Passwort entfernen“ den Schutz.'
|
||||||
|
: 'PDF konnte nicht geladen werden. Prüfen Sie, ob die Datei gültig ist.';
|
||||||
|
|
||||||
|
const handleFileSelected = async (files: File[]) => {
|
||||||
|
const file = files[0];
|
||||||
|
if (!file) return;
|
||||||
|
|
||||||
|
const generation = ++handleGeneration;
|
||||||
|
pdfHandle?.destroy();
|
||||||
|
pdfHandle = null;
|
||||||
|
pdfFile = file;
|
||||||
|
selectedPages = new Set();
|
||||||
|
currentPage = 1;
|
||||||
|
error = null;
|
||||||
|
isLoading = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const handle = await loadPdfDocument(new Uint8Array(await file.arrayBuffer()));
|
||||||
|
if (generation !== handleGeneration) {
|
||||||
|
handle.destroy();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
pdfHandle = handle;
|
||||||
|
} catch (loadError) {
|
||||||
|
console.error('Failed to load PDF for stamping:', loadError);
|
||||||
|
if (generation === handleGeneration) error = loadErrorMessage(loadError);
|
||||||
|
} finally {
|
||||||
|
if (generation === handleGeneration) isLoading = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const selectPreset = (preset: (typeof PRESETS)[number]) => {
|
||||||
|
stampInput = preset;
|
||||||
|
};
|
||||||
|
|
||||||
|
const clearStampImage = () => {
|
||||||
|
if (stampImage) URL.revokeObjectURL(stampImage.objectUrl);
|
||||||
|
stampImage = null;
|
||||||
|
withBorder = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
const detectImageMimeType = (bytes: Uint8Array): 'image/png' | 'image/jpeg' | null => {
|
||||||
|
if (
|
||||||
|
bytes.length > 8 &&
|
||||||
|
bytes[0] === 0x89 &&
|
||||||
|
bytes[1] === 0x50 &&
|
||||||
|
bytes[2] === 0x4e &&
|
||||||
|
bytes[3] === 0x47
|
||||||
|
) {
|
||||||
|
return 'image/png';
|
||||||
|
}
|
||||||
|
if (bytes.length > 3 && bytes[0] === 0xff && bytes[1] === 0xd8 && bytes[2] === 0xff) {
|
||||||
|
return 'image/jpeg';
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleImageSelected = async (event: Event) => {
|
||||||
|
const fileInput = event.target as HTMLInputElement;
|
||||||
|
const file = fileInput.files?.[0];
|
||||||
|
if (!file) return;
|
||||||
|
|
||||||
|
isLoadingImage = true;
|
||||||
|
error = null;
|
||||||
|
try {
|
||||||
|
const bytes = new Uint8Array(await file.arrayBuffer());
|
||||||
|
const mimeType = detectImageMimeType(bytes);
|
||||||
|
if (!mimeType) {
|
||||||
|
fileInput.value = '';
|
||||||
|
error = 'Das Stempelbild muss eine PNG- oder JPG-Datei sein.';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const bitmap = await createImageBitmap(new Blob([bytes], { type: mimeType }));
|
||||||
|
const dimensions = { width: bitmap.width, height: bitmap.height };
|
||||||
|
bitmap.close();
|
||||||
|
if (stampImage) URL.revokeObjectURL(stampImage.objectUrl);
|
||||||
|
stampImage = {
|
||||||
|
name: file.name,
|
||||||
|
bytes,
|
||||||
|
mimeType,
|
||||||
|
objectUrl: URL.createObjectURL(file),
|
||||||
|
...dimensions
|
||||||
|
};
|
||||||
|
withBorder = false;
|
||||||
|
} catch {
|
||||||
|
fileInput.value = '';
|
||||||
|
error =
|
||||||
|
'Das Stempelbild konnte nicht gelesen werden. Verwenden Sie eine gültige PNG- oder JPG-Datei.';
|
||||||
|
} finally {
|
||||||
|
isLoadingImage = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const togglePage = (pageNumber: number) => {
|
||||||
|
const next = new Set(selectedPages);
|
||||||
|
if (next.has(pageNumber)) next.delete(pageNumber);
|
||||||
|
else next.add(pageNumber);
|
||||||
|
selectedPages = next;
|
||||||
|
};
|
||||||
|
|
||||||
|
const showPreviousPage = () => {
|
||||||
|
currentPage = Math.max(1, currentPage - 1);
|
||||||
|
};
|
||||||
|
|
||||||
|
const showNextPage = () => {
|
||||||
|
if (!pdfHandle) return;
|
||||||
|
currentPage = Math.min(pdfHandle.pageCount, currentPage + 1);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getPreviewPositionStyle = (previewPosition: OverlayPosition) => {
|
||||||
|
switch (previewPosition) {
|
||||||
|
case 'center':
|
||||||
|
return 'left: 50%; top: 50%; transform: translate(-50%, -50%);';
|
||||||
|
case 'top-left':
|
||||||
|
return 'left: 5%; top: 5%;';
|
||||||
|
case 'top-center':
|
||||||
|
return 'left: 50%; top: 5%; transform: translateX(-50%);';
|
||||||
|
case 'top-right':
|
||||||
|
return 'right: 5%; top: 5%;';
|
||||||
|
case 'bottom-left':
|
||||||
|
return 'bottom: 5%; left: 5%;';
|
||||||
|
case 'bottom-center':
|
||||||
|
return 'bottom: 5%; left: 50%; transform: translateX(-50%);';
|
||||||
|
case 'bottom-right':
|
||||||
|
return 'bottom: 5%; right: 5%;';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getRotatedStampPlacement = (
|
||||||
|
pageWidth: number,
|
||||||
|
pageHeight: number,
|
||||||
|
boxWidth: number,
|
||||||
|
boxHeight: number,
|
||||||
|
stampPosition: OverlayPosition,
|
||||||
|
angle: number
|
||||||
|
) => {
|
||||||
|
const radians = (angle * Math.PI) / 180;
|
||||||
|
const cosine = Math.cos(radians);
|
||||||
|
const sine = Math.sin(radians);
|
||||||
|
const rotatedCorners = [
|
||||||
|
{ x: 0, y: 0 },
|
||||||
|
{ x: boxWidth * cosine, y: boxWidth * sine },
|
||||||
|
{ x: -boxHeight * sine, y: boxHeight * cosine },
|
||||||
|
{
|
||||||
|
x: boxWidth * cosine - boxHeight * sine,
|
||||||
|
y: boxWidth * sine + boxHeight * cosine
|
||||||
|
}
|
||||||
|
];
|
||||||
|
const minX = Math.min(...rotatedCorners.map((corner) => corner.x));
|
||||||
|
const maxX = Math.max(...rotatedCorners.map((corner) => corner.x));
|
||||||
|
const minY = Math.min(...rotatedCorners.map((corner) => corner.y));
|
||||||
|
const maxY = Math.max(...rotatedCorners.map((corner) => corner.y));
|
||||||
|
const boundingCoordinates = getPositionCoords(
|
||||||
|
pageWidth,
|
||||||
|
pageHeight,
|
||||||
|
maxX - minX,
|
||||||
|
maxY - minY,
|
||||||
|
stampPosition,
|
||||||
|
PAGE_MARGIN
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
x: boundingCoordinates.x - minX,
|
||||||
|
y: boundingCoordinates.y - minY,
|
||||||
|
cosine,
|
||||||
|
sine
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const fitStampToPage = (
|
||||||
|
font: PDFFont,
|
||||||
|
text: string,
|
||||||
|
requestedSize: number,
|
||||||
|
pageWidth: number,
|
||||||
|
pageHeight: number
|
||||||
|
) => {
|
||||||
|
const availableWidth = pageWidth - PAGE_MARGIN * 2;
|
||||||
|
const availableHeight = pageHeight - PAGE_MARGIN * 2;
|
||||||
|
let size = requestedSize;
|
||||||
|
|
||||||
|
while (size > MIN_EXPORT_FONT_SIZE) {
|
||||||
|
const width = font.widthOfTextAtSize(text, size) + PADDING * 2;
|
||||||
|
const height = font.heightAtSize(size) + PADDING * 2;
|
||||||
|
if (width <= availableWidth && height <= availableHeight) break;
|
||||||
|
size = Math.max(MIN_EXPORT_FONT_SIZE, size - 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
const textWidth = font.widthOfTextAtSize(text, size);
|
||||||
|
const textHeight = font.heightAtSize(size);
|
||||||
|
if (textWidth + PADDING * 2 > availableWidth || textHeight + PADDING * 2 > availableHeight) {
|
||||||
|
throw new Error(
|
||||||
|
'Der Stempeltext ist für mindestens eine Seite zu lang. Kürzen Sie den Text.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
size,
|
||||||
|
textHeight,
|
||||||
|
boxWidth: textWidth + PADDING * 2,
|
||||||
|
boxHeight: textHeight + PADDING * 2
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const getImageStampBox = (
|
||||||
|
image: { width: number; height: number },
|
||||||
|
pageWidth: number,
|
||||||
|
pageHeight: number
|
||||||
|
) => {
|
||||||
|
const padding = withBorder ? PADDING : 0;
|
||||||
|
const aspectRatio = image.width / image.height;
|
||||||
|
let drawHeight = Number(imageSize);
|
||||||
|
let drawWidth = drawHeight * aspectRatio;
|
||||||
|
|
||||||
|
const availableWidth = pageWidth - PAGE_MARGIN * 2 - padding * 2;
|
||||||
|
const availableHeight = pageHeight - PAGE_MARGIN * 2 - padding * 2;
|
||||||
|
if (drawWidth > availableWidth || drawHeight > availableHeight) {
|
||||||
|
const scale = Math.min(availableWidth / drawWidth, availableHeight / drawHeight);
|
||||||
|
drawWidth *= scale;
|
||||||
|
drawHeight *= scale;
|
||||||
|
}
|
||||||
|
if (drawHeight < MIN_EXPORT_IMAGE_SIZE) {
|
||||||
|
drawHeight = MIN_EXPORT_IMAGE_SIZE;
|
||||||
|
drawWidth = drawHeight * aspectRatio;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
drawWidth,
|
||||||
|
drawHeight,
|
||||||
|
boxWidth: drawWidth + padding * 2,
|
||||||
|
boxHeight: drawHeight + padding * 2,
|
||||||
|
padding
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const applyStamp = async () => {
|
||||||
|
if (!pdfFile || !pdfHandle || !canProcess) return;
|
||||||
|
|
||||||
|
isProcessing = true;
|
||||||
|
error = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const stampText = buildStampText(stampInput, withDate, new Date());
|
||||||
|
const pdfDoc = await PDFDocument.load(new Uint8Array(await pdfFile.arrayBuffer()));
|
||||||
|
const font = await pdfDoc.embedFont(StandardFonts.HelveticaBold);
|
||||||
|
const embeddedImage = stampImage
|
||||||
|
? stampImage.mimeType === 'image/png'
|
||||||
|
? await pdfDoc.embedPng(stampImage.bytes)
|
||||||
|
: await pdfDoc.embedJpg(stampImage.bytes)
|
||||||
|
: null;
|
||||||
|
const pages = pdfDoc.getPages();
|
||||||
|
const pageIndexes = applyToAll
|
||||||
|
? pages.map((_, index) => index)
|
||||||
|
: Array.from(selectedPages, (pageNumber) => pageNumber - 1);
|
||||||
|
const color = parseHexColor(stampColor);
|
||||||
|
|
||||||
|
for (const pageIndex of pageIndexes) {
|
||||||
|
const page = pages[pageIndex];
|
||||||
|
if (!page) continue;
|
||||||
|
|
||||||
|
const { width, height } = page.getSize();
|
||||||
|
const angle = Number(rotation);
|
||||||
|
const box = embeddedImage
|
||||||
|
? getImageStampBox(embeddedImage, width, height)
|
||||||
|
: fitStampToPage(font, stampText, Number(fontSize), width, height);
|
||||||
|
const placement = getRotatedStampPlacement(
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
box.boxWidth,
|
||||||
|
box.boxHeight,
|
||||||
|
position,
|
||||||
|
angle
|
||||||
|
);
|
||||||
|
|
||||||
|
if (embeddedImage ? withBorder : true) {
|
||||||
|
page.drawRectangle({
|
||||||
|
x: placement.x,
|
||||||
|
y: placement.y,
|
||||||
|
width: box.boxWidth,
|
||||||
|
height: box.boxHeight,
|
||||||
|
borderColor: color,
|
||||||
|
borderWidth: 2,
|
||||||
|
rotate: degrees(angle)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (embeddedImage) {
|
||||||
|
const imageBox = box as { drawWidth: number; drawHeight: number; padding: number };
|
||||||
|
const offsetX = imageBox.padding;
|
||||||
|
const offsetY = imageBox.padding;
|
||||||
|
page.drawImage(embeddedImage, {
|
||||||
|
x: placement.x + offsetX * placement.cosine - offsetY * placement.sine,
|
||||||
|
y: placement.y + offsetX * placement.sine + offsetY * placement.cosine,
|
||||||
|
width: imageBox.drawWidth,
|
||||||
|
height: imageBox.drawHeight,
|
||||||
|
rotate: degrees(angle)
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
const textOffsetX = PADDING;
|
||||||
|
const textOffsetY = PADDING + (box as { textHeight: number }).textHeight * 0.2;
|
||||||
|
page.drawText(stampText, {
|
||||||
|
x: placement.x + textOffsetX * placement.cosine - textOffsetY * placement.sine,
|
||||||
|
y: placement.y + textOffsetX * placement.sine + textOffsetY * placement.cosine,
|
||||||
|
size: (box as { size: number }).size,
|
||||||
|
font,
|
||||||
|
color,
|
||||||
|
rotate: degrees(angle)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const resultBytes = await pdfDoc.save();
|
||||||
|
const baseName = pdfFile.name.replace(/\.pdf$/i, '');
|
||||||
|
downloadBlob(resultBytes, `${baseName}_stempel.pdf`);
|
||||||
|
} catch (processingError) {
|
||||||
|
console.error('Failed to stamp PDF:', processingError);
|
||||||
|
error = isEncryptedPdfError(processingError)
|
||||||
|
? loadErrorMessage(processingError)
|
||||||
|
: processingError instanceof Error && processingError.message.startsWith('Der Stempeltext')
|
||||||
|
? processingError.message
|
||||||
|
: 'Der Stempel konnte nicht angewendet werden. Prüfen Sie die PDF-Datei.';
|
||||||
|
} finally {
|
||||||
|
isProcessing = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
onDestroy(() => {
|
||||||
|
handleGeneration += 1;
|
||||||
|
pdfHandle?.destroy();
|
||||||
|
if (stampImage) URL.revokeObjectURL(stampImage.objectUrl);
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>PDF stempeln — beA-Edit</title>
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
<div class="mx-auto flex max-w-5xl flex-col gap-6 pb-10">
|
||||||
|
<div>
|
||||||
|
<div class="mb-2 flex items-center gap-2 text-secondary">
|
||||||
|
<Stamp class="size-5" aria-hidden="true" />
|
||||||
|
<span class="text-xs font-extrabold tracking-[0.18em] uppercase">Dokumentenvermerk</span>
|
||||||
|
</div>
|
||||||
|
<h1 class="text-2xl font-bold text-foreground">Stempeln</h1>
|
||||||
|
<p class="mt-2 max-w-2xl text-sm text-primary">
|
||||||
|
Setzen Sie einen Text- oder Bildstempel auf alle oder ausgewählte PDF-Seiten.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<FileDropzone
|
||||||
|
onFilesSelected={handleFileSelected}
|
||||||
|
label={pdfHandle ? 'Anderes PDF ablegen' : 'PDF zum Stempeln hinzufügen'}
|
||||||
|
sublabel={isLoading
|
||||||
|
? 'Vorschau wird geladen …'
|
||||||
|
: (pdfFile?.name ?? 'PDF hier ablegen oder auswählen')}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{#if pdfHandle}
|
||||||
|
<div class="grid gap-6 lg:grid-cols-[minmax(0,1fr)_minmax(320px,0.9fr)]">
|
||||||
|
<section
|
||||||
|
class="flex flex-col gap-5 rounded-2xl border border-border bg-card p-5"
|
||||||
|
aria-labelledby="stamp-options-title"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<h2 id="stamp-options-title" class="text-lg font-semibold text-foreground">
|
||||||
|
Stempel einstellen
|
||||||
|
</h2>
|
||||||
|
<p class="mt-1 text-xs text-muted-foreground">
|
||||||
|
Lange Texte werden für kleine Seiten automatisch verkleinert.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-2">
|
||||||
|
<label class="text-sm font-medium text-foreground" for="stamp-image">
|
||||||
|
Stempelbild (optional)
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="stamp-image"
|
||||||
|
type="file"
|
||||||
|
accept="image/png,image/jpeg"
|
||||||
|
onchange={handleImageSelected}
|
||||||
|
class="w-full rounded-lg border border-input bg-background px-3 py-2 text-sm file:mr-3 file:rounded-md file:border-0 file:bg-primary file:px-3 file:py-1.5 file:text-sm file:font-semibold file:text-primary-foreground"
|
||||||
|
/>
|
||||||
|
{#if stampImage}
|
||||||
|
<div
|
||||||
|
class="flex items-center justify-between gap-2 rounded-lg border border-border bg-muted/40 px-3 py-2 text-sm"
|
||||||
|
>
|
||||||
|
<span class="min-w-0 truncate" title={stampImage.name}>{stampImage.name}</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="text-xs font-semibold text-primary underline hover:no-underline"
|
||||||
|
onclick={clearStampImage}
|
||||||
|
>
|
||||||
|
Bild entfernen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if stampImage}
|
||||||
|
<label class="flex items-center gap-2 text-sm text-foreground" for="stamp-border">
|
||||||
|
<input id="stamp-border" type="checkbox" bind:checked={withBorder} />
|
||||||
|
Rahmen um das Bild anzeigen
|
||||||
|
</label>
|
||||||
|
<div class="grid gap-x-4 gap-y-3 sm:grid-cols-[minmax(0,1fr)_auto] sm:items-center">
|
||||||
|
<label class="text-sm font-medium text-foreground" for="stamp-image-size">
|
||||||
|
Bildgröße: {imageSize} pt
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="stamp-image-size"
|
||||||
|
type="range"
|
||||||
|
min="40"
|
||||||
|
max="300"
|
||||||
|
step="5"
|
||||||
|
bind:value={imageSize}
|
||||||
|
class="sm:col-span-2"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<fieldset class="flex flex-col gap-2">
|
||||||
|
<legend class="text-sm font-medium text-foreground">Vorlage</legend>
|
||||||
|
<div class="flex flex-wrap gap-2">
|
||||||
|
{#each PRESETS as preset}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="rounded-full border px-3 py-1.5 text-xs font-bold tracking-wide transition focus:outline-none focus-visible:ring-2 focus-visible:ring-ring {selectedPreset ===
|
||||||
|
preset
|
||||||
|
? 'border-primary bg-primary text-primary-foreground'
|
||||||
|
: 'border-border bg-background text-foreground hover:border-primary/40'}"
|
||||||
|
aria-pressed={selectedPreset === preset}
|
||||||
|
onclick={() => selectPreset(preset)}
|
||||||
|
>
|
||||||
|
{preset}
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-2">
|
||||||
|
<label class="text-sm font-medium text-foreground" for="stamp-text">Stempeltext</label>
|
||||||
|
<input
|
||||||
|
id="stamp-text"
|
||||||
|
type="text"
|
||||||
|
bind:value={stampInput}
|
||||||
|
maxlength="120"
|
||||||
|
class="w-full rounded-lg border border-input bg-background px-3 py-2 text-sm uppercase"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label class="flex items-center gap-2 text-sm text-foreground" for="stamp-date">
|
||||||
|
<input id="stamp-date" type="checkbox" bind:checked={withDate} />
|
||||||
|
Datum anhängen
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div class="grid gap-x-4 gap-y-3 sm:grid-cols-[minmax(0,1fr)_auto] sm:items-center">
|
||||||
|
<label class="text-sm font-medium text-foreground" for="stamp-font-size">
|
||||||
|
Schriftgröße: {fontSize} pt
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="stamp-font-size"
|
||||||
|
type="range"
|
||||||
|
min="12"
|
||||||
|
max="48"
|
||||||
|
bind:value={fontSize}
|
||||||
|
class="sm:col-span-2"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="grid gap-x-4 gap-y-3 sm:grid-cols-[minmax(0,1fr)_auto] sm:items-center">
|
||||||
|
<label class="text-sm font-medium text-foreground" for="stamp-rotation">
|
||||||
|
Drehung: {rotation}°
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="stamp-rotation"
|
||||||
|
type="range"
|
||||||
|
min="-45"
|
||||||
|
max="45"
|
||||||
|
bind:value={rotation}
|
||||||
|
class="sm:col-span-2"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid gap-4 sm:grid-cols-2">
|
||||||
|
<div class="flex flex-col gap-2">
|
||||||
|
<label class="text-sm font-medium text-foreground" for="stamp-position">Position</label>
|
||||||
|
<select
|
||||||
|
id="stamp-position"
|
||||||
|
bind:value={position}
|
||||||
|
class="rounded-lg border border-input bg-background px-3 py-2 text-sm"
|
||||||
|
>
|
||||||
|
<option value="top-left">Oben links</option>
|
||||||
|
<option value="top-center">Oben mittig</option>
|
||||||
|
<option value="top-right">Oben rechts</option>
|
||||||
|
<option value="center">Zentriert</option>
|
||||||
|
<option value="bottom-left">Unten links</option>
|
||||||
|
<option value="bottom-center">Unten mittig</option>
|
||||||
|
<option value="bottom-right">Unten rechts</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-col gap-2">
|
||||||
|
<label class="text-sm font-medium text-foreground" for="stamp-color">Farbe</label>
|
||||||
|
<input
|
||||||
|
id="stamp-color"
|
||||||
|
type="color"
|
||||||
|
bind:value={stampColor}
|
||||||
|
class="h-10 w-full cursor-pointer rounded-lg border border-input bg-background p-1"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label class="flex items-center gap-2 text-sm text-foreground" for="stamp-all-pages">
|
||||||
|
<input id="stamp-all-pages" type="checkbox" bind:checked={applyToAll} />
|
||||||
|
Auf alle Seiten anwenden
|
||||||
|
</label>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section
|
||||||
|
class="flex flex-col gap-4 rounded-2xl border border-border bg-card p-5"
|
||||||
|
aria-labelledby="stamp-preview-title"
|
||||||
|
>
|
||||||
|
<div class="flex items-center justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<h2 id="stamp-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"
|
||||||
|
>{currentPage} / {pdfHandle.pageCount}</span
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if pdfHandle.thumbnails[currentPage - 1]}
|
||||||
|
{@const currentThumbnail = pdfHandle.thumbnails[currentPage - 1]}
|
||||||
|
<div
|
||||||
|
class="relative mx-auto w-full max-w-lg overflow-hidden rounded-lg border border-border bg-white shadow-sm"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src={currentThumbnail.imageUrl}
|
||||||
|
alt="Vorschau von Seite {currentPage}"
|
||||||
|
class="block w-full"
|
||||||
|
style="aspect-ratio: {currentThumbnail.width} / {currentThumbnail.height};"
|
||||||
|
/>
|
||||||
|
<div class="absolute max-w-[90%]" style={getPreviewPositionStyle(position)}>
|
||||||
|
{#if stampImage}
|
||||||
|
<div
|
||||||
|
data-testid="stamp-preview"
|
||||||
|
class={withBorder ? 'border-2' : ''}
|
||||||
|
style="border-color: {stampColor}; padding: {withBorder
|
||||||
|
? '2px'
|
||||||
|
: '0'}; transform: rotate({rotation}deg);"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src={stampImage.objectUrl}
|
||||||
|
alt="Stempelbild"
|
||||||
|
class="block"
|
||||||
|
style="width: {imageSize * 0.5}px; max-width: 100%;"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div
|
||||||
|
data-testid="stamp-preview"
|
||||||
|
class="border-2 px-2 py-1 font-sans font-extrabold leading-tight whitespace-nowrap"
|
||||||
|
style="border-color: {stampColor}; color: {stampColor}; font-size: {previewFontSize}px; transform: rotate({rotation}deg);"
|
||||||
|
>
|
||||||
|
{previewText}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if pdfHandle.pageCount > 1}
|
||||||
|
<div class="flex items-center justify-center gap-3">
|
||||||
|
<Button
|
||||||
|
variant="tertiary"
|
||||||
|
class="min-w-0 px-3"
|
||||||
|
onclick={showPreviousPage}
|
||||||
|
disabled={currentPage === 1}
|
||||||
|
aria-label="Vorherige Seite"
|
||||||
|
>
|
||||||
|
<ChevronLeft aria-hidden="true" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="tertiary"
|
||||||
|
class="min-w-0 px-3"
|
||||||
|
onclick={showNextPage}
|
||||||
|
disabled={currentPage === pdfHandle.pageCount}
|
||||||
|
aria-label="Nächste Seite"
|
||||||
|
>
|
||||||
|
<ChevronRight aria-hidden="true" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if !applyToAll}
|
||||||
|
<section class="flex flex-col gap-3" aria-labelledby="stamp-pages-title">
|
||||||
|
<div>
|
||||||
|
<h2 id="stamp-pages-title" class="text-lg font-semibold text-foreground">
|
||||||
|
Seiten auswählen
|
||||||
|
</h2>
|
||||||
|
<p class="mt-1 text-sm text-primary">
|
||||||
|
{selectedPages.size} Seite{selectedPages.size === 1 ? '' : 'n'} ausgewählt
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<PdfPageGrid thumbnails={pdfHandle.thumbnails} {selectedPages} onPageClick={togglePage} />
|
||||||
|
</section>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="flex flex-col items-end gap-3">
|
||||||
|
{#if error}
|
||||||
|
<p
|
||||||
|
class="w-full rounded-lg border border-destructive/25 bg-card p-4 text-sm text-destructive"
|
||||||
|
role="alert"
|
||||||
|
>
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
<Button size="large" onclick={applyStamp} disabled={!canProcess}>
|
||||||
|
{isProcessing ? 'Stempel wird angewendet …' : 'Stempel anwenden & herunterladen'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{:else if error}
|
||||||
|
<p
|
||||||
|
class="rounded-lg border border-destructive/25 bg-card p-4 text-sm text-destructive"
|
||||||
|
role="alert"
|
||||||
|
>
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,353 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { onDestroy } from 'svelte';
|
||||||
|
import { degrees, PDFDocument, StandardFonts } from 'pdf-lib';
|
||||||
|
import BatchFileList from '$lib/components/BatchFileList.svelte';
|
||||||
|
import FileDropzone from '$lib/components/FileDropzone.svelte';
|
||||||
|
import PdfPageGrid from '$lib/components/PdfPageGrid.svelte';
|
||||||
|
import Button from '$lib/components/ui/button.svelte';
|
||||||
|
import { createBatchItem, runBatch, type BatchItem } from '$lib/batch';
|
||||||
|
import { downloadBlob } from '$lib/download';
|
||||||
|
import { getPositionCoords, parseHexColor, type WatermarkPosition } from '$lib/pdf-overlay';
|
||||||
|
import { loadPdfDocument, type PdfDocumentHandle } from '$lib/pdf-thumbnails';
|
||||||
|
|
||||||
|
type WatermarkKind = 'text' | 'image';
|
||||||
|
|
||||||
|
let items = $state<BatchItem[]>([]);
|
||||||
|
let watermarkKind = $state<WatermarkKind>('text');
|
||||||
|
let watermarkText = $state('VERTRAULICH');
|
||||||
|
let watermarkImage: File | null = $state(null);
|
||||||
|
let fontSize = $state(50);
|
||||||
|
let imageWidth = $state(160);
|
||||||
|
let opacity = $state(0.3);
|
||||||
|
let rotation = $state(0);
|
||||||
|
let position = $state<WatermarkPosition>('diagonal');
|
||||||
|
let watermarkColor = $state('#cc1a1a');
|
||||||
|
let applyToAll = $state(true);
|
||||||
|
let selectedPages = $state(new Set<number>());
|
||||||
|
let isRunning = $state(false);
|
||||||
|
let error = $state<string | null>(null);
|
||||||
|
|
||||||
|
// Thumbnail handle for the single-file page selection grid only; multiple
|
||||||
|
// files skip preview loading to keep memory flat.
|
||||||
|
let pdfHandle: PdfDocumentHandle | null = $state(null);
|
||||||
|
let handleGeneration = 0;
|
||||||
|
|
||||||
|
const pendingCount = $derived(items.filter((item) => item.status === 'pending').length);
|
||||||
|
const watermarkReady = $derived(
|
||||||
|
watermarkKind === 'text' ? watermarkText.trim().length > 0 : watermarkImage !== null
|
||||||
|
);
|
||||||
|
const pageSelectionAvailable = $derived(items.length === 1 && pdfHandle !== null);
|
||||||
|
const requiresPageSelection = $derived(pageSelectionAvailable && !applyToAll);
|
||||||
|
const canRun = $derived(
|
||||||
|
!isRunning && pendingCount > 0 && watermarkReady && !requiresPageSelection
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleFilesSelected = async (files: File[]) => {
|
||||||
|
const knownKeys = new Set(items.map((item) => `${item.file.name}:${item.file.size}`));
|
||||||
|
const additions = files
|
||||||
|
.filter((file) => !knownKeys.has(`${file.name}:${file.size}`))
|
||||||
|
.map((file) => createBatchItem(file));
|
||||||
|
if (additions.length === 0) return;
|
||||||
|
|
||||||
|
items = [...items, ...additions];
|
||||||
|
selectedPages = new Set();
|
||||||
|
error = null;
|
||||||
|
|
||||||
|
pdfHandle?.destroy();
|
||||||
|
pdfHandle = null;
|
||||||
|
if (items.length !== 1) return;
|
||||||
|
|
||||||
|
const generation = ++handleGeneration;
|
||||||
|
try {
|
||||||
|
const handle = await loadPdfDocument(new Uint8Array(await items[0].file.arrayBuffer()));
|
||||||
|
if (generation !== handleGeneration || items.length !== 1) {
|
||||||
|
handle.destroy();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
pdfHandle = handle;
|
||||||
|
} catch (loadError) {
|
||||||
|
console.error('Failed to load PDF for watermarking:', loadError);
|
||||||
|
error = 'PDF konnte nicht geladen werden.';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleImageSelected = (files: File[]) => {
|
||||||
|
watermarkImage = files[0] ?? null;
|
||||||
|
error = null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const togglePage = (pageNumber: number) => {
|
||||||
|
const next = new Set(selectedPages);
|
||||||
|
if (next.has(pageNumber)) next.delete(pageNumber);
|
||||||
|
else next.add(pageNumber);
|
||||||
|
selectedPages = next;
|
||||||
|
};
|
||||||
|
|
||||||
|
const processFile = async (file: File) => {
|
||||||
|
const pdfDoc = await PDFDocument.load(new Uint8Array(await file.arrayBuffer()));
|
||||||
|
const pages = pdfDoc.getPages();
|
||||||
|
// Options (including the page selection) are read per file so a change
|
||||||
|
// during a run only affects the still pending files. With multiple files
|
||||||
|
// the watermark is applied to all pages of every file.
|
||||||
|
const useAllPages = items.length > 1 || applyToAll;
|
||||||
|
const pageIndexes = useAllPages
|
||||||
|
? pages.map((_, index) => index)
|
||||||
|
: Array.from(selectedPages, (pageNumber) => pageNumber - 1);
|
||||||
|
const numericOpacity = Number(opacity);
|
||||||
|
const numericRotation = position === 'diagonal' ? 45 : Number(rotation);
|
||||||
|
|
||||||
|
if (watermarkKind === 'text') {
|
||||||
|
const font = await pdfDoc.embedFont(StandardFonts.HelveticaBold);
|
||||||
|
const numericFontSize = Number(fontSize);
|
||||||
|
|
||||||
|
for (const pageIndex of pageIndexes) {
|
||||||
|
const page = pages[pageIndex];
|
||||||
|
if (!page) continue;
|
||||||
|
const { width, height } = page.getSize();
|
||||||
|
const textWidth = font.widthOfTextAtSize(watermarkText, numericFontSize);
|
||||||
|
const textHeight = font.heightAtSize(numericFontSize);
|
||||||
|
const coordinates = getPositionCoords(width, height, textWidth, textHeight, position);
|
||||||
|
|
||||||
|
page.drawText(watermarkText, {
|
||||||
|
...coordinates,
|
||||||
|
size: numericFontSize,
|
||||||
|
font,
|
||||||
|
color: parseHexColor(watermarkColor),
|
||||||
|
opacity: numericOpacity,
|
||||||
|
rotate: degrees(numericRotation)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else if (watermarkImage) {
|
||||||
|
const imageBytes = new Uint8Array(await watermarkImage.arrayBuffer());
|
||||||
|
const embeddedImage =
|
||||||
|
watermarkImage.type === 'image/jpeg' || /\.jpe?g$/i.test(watermarkImage.name)
|
||||||
|
? await pdfDoc.embedJpg(imageBytes)
|
||||||
|
: await pdfDoc.embedPng(imageBytes);
|
||||||
|
const markWidth = Number(imageWidth);
|
||||||
|
const markHeight = markWidth * (embeddedImage.height / embeddedImage.width);
|
||||||
|
|
||||||
|
for (const pageIndex of pageIndexes) {
|
||||||
|
const page = pages[pageIndex];
|
||||||
|
if (!page) continue;
|
||||||
|
const { width, height } = page.getSize();
|
||||||
|
const coordinates = getPositionCoords(width, height, markWidth, markHeight, position);
|
||||||
|
page.drawImage(embeddedImage, {
|
||||||
|
...coordinates,
|
||||||
|
width: markWidth,
|
||||||
|
height: markHeight,
|
||||||
|
opacity: numericOpacity,
|
||||||
|
rotate: degrees(numericRotation)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const bytes = await pdfDoc.save();
|
||||||
|
return { bytes, name: `${file.name.replace(/\.pdf$/i, '')}_wasserzeichen.pdf` };
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateItem = (id: string, patch: Partial<BatchItem>) => {
|
||||||
|
items = items.map((item) => (item.id === id ? { ...item, ...patch } : item));
|
||||||
|
};
|
||||||
|
|
||||||
|
const startBatch = async () => {
|
||||||
|
if (isRunning) return;
|
||||||
|
const queue = items.filter((item) => item.status === 'pending');
|
||||||
|
if (queue.length === 0) return;
|
||||||
|
|
||||||
|
isRunning = true;
|
||||||
|
try {
|
||||||
|
await runBatch(queue, processFile, updateItem, {
|
||||||
|
errorMessage: 'Diese PDF konnte nicht mit einem Wasserzeichen versehen werden.',
|
||||||
|
// Skip rows removed while they were still waiting in the queue.
|
||||||
|
shouldProcess: (id) => items.some((item) => item.id === id)
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
isRunning = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const retryItem = async (id: string) => {
|
||||||
|
if (isRunning) return;
|
||||||
|
const item = items.find((entry) => entry.id === id);
|
||||||
|
if (!item) return;
|
||||||
|
|
||||||
|
isRunning = true;
|
||||||
|
try {
|
||||||
|
await runBatch([item], processFile, updateItem, {
|
||||||
|
errorMessage: 'Diese PDF konnte nicht mit einem Wasserzeichen versehen werden.'
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
isRunning = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeItem = (id: string) => {
|
||||||
|
items = items.filter((item) => item.id !== id);
|
||||||
|
};
|
||||||
|
|
||||||
|
const downloadItem = (item: BatchItem) => {
|
||||||
|
if (item.resultBytes && item.resultName) downloadBlob(item.resultBytes, item.resultName);
|
||||||
|
};
|
||||||
|
|
||||||
|
onDestroy(() => pdfHandle?.destroy());
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="mx-auto max-w-4xl space-y-6">
|
||||||
|
<div>
|
||||||
|
<h1 class="text-2xl font-bold text-foreground">Wasserzeichen</h1>
|
||||||
|
<p class="mt-2 text-sm text-primary">
|
||||||
|
Fügen Sie ein Text- oder Bildwasserzeichen hinzu – einzeln oder für mehrere PDFs in einem
|
||||||
|
Durchlauf.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<FileDropzone
|
||||||
|
onFilesSelected={handleFilesSelected}
|
||||||
|
multiple
|
||||||
|
label={items.length === 0 ? 'Dateien hinzufügen' : 'Weitere Dateien ablegen'}
|
||||||
|
sublabel={items.length === 0
|
||||||
|
? 'PDFs hier ablegen oder zum Auswählen klicken'
|
||||||
|
: 'Weitere PDFs hinzufügen – bestehende Dateien bleiben erhalten'}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{#if items.length > 0}
|
||||||
|
<div class="space-y-4 rounded-2xl border border-border bg-card p-4">
|
||||||
|
<h2 class="text-lg font-semibold text-foreground">Wasserzeichen-Einstellungen</h2>
|
||||||
|
|
||||||
|
<fieldset class="flex gap-4">
|
||||||
|
<legend class="mb-2 text-sm font-medium text-foreground">Art</legend>
|
||||||
|
<label class="flex items-center gap-2 text-sm">
|
||||||
|
<input type="radio" bind:group={watermarkKind} value="text" /> Text
|
||||||
|
</label>
|
||||||
|
<label class="flex items-center gap-2 text-sm">
|
||||||
|
<input type="radio" bind:group={watermarkKind} value="image" /> Bild
|
||||||
|
</label>
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
|
{#if watermarkKind === 'text'}
|
||||||
|
<label class="block text-sm font-medium text-foreground" for="watermark-text">Text</label>
|
||||||
|
<input
|
||||||
|
id="watermark-text"
|
||||||
|
type="text"
|
||||||
|
bind:value={watermarkText}
|
||||||
|
class="w-full rounded-lg border border-border px-3 py-2 text-sm"
|
||||||
|
/>
|
||||||
|
{:else}
|
||||||
|
<FileDropzone
|
||||||
|
onFilesSelected={handleImageSelected}
|
||||||
|
accept=".png,.jpg,.jpeg,image/png,image/jpeg"
|
||||||
|
ariaLabel="Wasserzeichenbild hinzufügen"
|
||||||
|
label="Wasserzeichenbild ablegen"
|
||||||
|
sublabel={watermarkImage?.name ?? 'PNG oder JPG'}
|
||||||
|
class="py-4"
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||||
|
{#if watermarkKind === 'text'}
|
||||||
|
<label class="text-sm font-medium text-foreground" for="watermark-font-size">
|
||||||
|
Schriftgröße: {fontSize}
|
||||||
|
</label>
|
||||||
|
<input id="watermark-font-size" type="range" min="10" max="120" bind:value={fontSize} />
|
||||||
|
{:else}
|
||||||
|
<label class="text-sm font-medium text-foreground" for="watermark-image-width">
|
||||||
|
Bildbreite: {imageWidth}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="watermark-image-width"
|
||||||
|
type="range"
|
||||||
|
min="10"
|
||||||
|
max="400"
|
||||||
|
bind:value={imageWidth}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<label class="text-sm font-medium text-foreground" for="watermark-opacity">
|
||||||
|
Deckkraft: {Math.round(Number(opacity) * 100)}%
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="watermark-opacity"
|
||||||
|
type="range"
|
||||||
|
min="0.05"
|
||||||
|
max="1"
|
||||||
|
step="0.05"
|
||||||
|
bind:value={opacity}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<label class="text-sm font-medium text-foreground" for="watermark-rotation">
|
||||||
|
Drehung: {rotation}°
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="watermark-rotation"
|
||||||
|
type="range"
|
||||||
|
min="0"
|
||||||
|
max="360"
|
||||||
|
bind:value={rotation}
|
||||||
|
disabled={position === 'diagonal'}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<label class="text-sm font-medium text-foreground" for="watermark-position">Position</label>
|
||||||
|
<select id="watermark-position" bind:value={position} class="rounded-lg border px-3 py-2">
|
||||||
|
<option value="center">Zentriert</option>
|
||||||
|
<option value="diagonal">Diagonal</option>
|
||||||
|
<option value="top-left">Oben links</option>
|
||||||
|
<option value="top-right">Oben rechts</option>
|
||||||
|
<option value="bottom-left">Unten links</option>
|
||||||
|
<option value="bottom-right">Unten rechts</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if watermarkKind === 'text'}
|
||||||
|
<label class="block text-sm font-medium text-foreground" for="watermark-color">Farbe</label>
|
||||||
|
<input
|
||||||
|
id="watermark-color"
|
||||||
|
type="color"
|
||||||
|
bind:value={watermarkColor}
|
||||||
|
class="h-10 w-14 cursor-pointer"
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if items.length > 1}
|
||||||
|
<p class="text-xs text-primary" role="note">
|
||||||
|
Die Einstellungen gelten für alle Dateien. Das Wasserzeichen wird auf allen Seiten jeder
|
||||||
|
Datei angewendet.
|
||||||
|
{#if isRunning}Änderungen wirken auf die noch ausstehenden Dateien.{/if}
|
||||||
|
</p>
|
||||||
|
{:else}
|
||||||
|
<label class="flex items-center gap-2 text-sm text-foreground" for="all-pages">
|
||||||
|
<input type="checkbox" id="all-pages" bind:checked={applyToAll} />
|
||||||
|
Auf alle Seiten anwenden
|
||||||
|
</label>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if pageSelectionAvailable && !applyToAll}
|
||||||
|
<div>
|
||||||
|
<p class="mb-3 text-sm font-medium">Seiten auswählen:</p>
|
||||||
|
<PdfPageGrid thumbnails={pdfHandle!.thumbnails} {selectedPages} onPageClick={togglePage} />
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if items.length > 0}
|
||||||
|
<BatchFileList
|
||||||
|
{items}
|
||||||
|
zipBaseName="wasserzeichen"
|
||||||
|
onDownload={downloadItem}
|
||||||
|
onRemove={removeItem}
|
||||||
|
onRetry={retryItem}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if error}
|
||||||
|
<div class="rounded-lg border border-red-200 bg-red-50 p-4 text-sm text-red-800">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="flex justify-end">
|
||||||
|
<Button onclick={startBatch} disabled={!canRun}>
|
||||||
|
{isRunning ? 'Wird verarbeitet...' : 'Wasserzeichen anwenden'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
<svg width="145" height="145" viewBox="0 0 145 145" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<mask id="mask0_0_1944" style="mask-type:luminance" maskUnits="userSpaceOnUse" x="57" y="1" width="76" height="126">
|
||||||
|
<path fill-rule="evenodd" clip-rule="evenodd" d="M57.0215 1.05981L132.831 1.05981L132.831 126.972L57.0215 126.972L57.0215 1.05981Z" fill="white"/>
|
||||||
|
</mask>
|
||||||
|
<g mask="url(#mask0_0_1944)">
|
||||||
|
<path fill-rule="evenodd" clip-rule="evenodd" d="M132.831 14.4075L132.818 111.812C132.81 111.937 132.801 112.059 132.797 112.185C132.784 112.822 132.717 113.509 132.579 114.272C132.47 114.909 132.315 115.546 132.118 116.158C131.925 116.766 131.699 117.349 131.456 117.894C131.204 118.434 130.928 118.967 130.622 119.474C130.311 119.985 129.984 120.471 129.632 120.928C129.293 121.381 128.911 121.821 128.513 122.249C128.123 122.659 127.696 123.062 127.243 123.447C127.147 123.531 127.05 123.619 126.925 123.737C126.711 123.904 126.451 124.08 126.149 124.282L125.839 124.487C125.353 124.818 124.85 125.116 124.301 125.397C123.735 125.694 123.165 125.941 122.607 126.151C121.987 126.382 121.354 126.566 120.725 126.7L120.34 126.788C120.038 126.851 119.757 126.918 119.564 126.943C119.464 126.952 119.355 126.947 119.25 126.947C119.032 126.956 118.797 126.947 118.579 126.96C117.728 126.998 116.873 126.947 116.035 126.805C115.821 126.771 115.611 126.742 115.402 126.717C115.234 126.692 115.008 126.662 114.937 126.641L57.0215 110.961L77.6692 88.4309L107.161 96.5333L107.174 13.8752C107.182 6.80817 112.937 1.05731 120 1.05731C126.883 1.05731 132.508 6.51476 132.805 13.389L132.831 14.4075Z" fill="#FF6F6D"/>
|
||||||
|
</g>
|
||||||
|
<mask id="mask1_0_1944" style="mask-type:luminance" maskUnits="userSpaceOnUse" x="12" y="18" width="77" height="127">
|
||||||
|
<path fill-rule="evenodd" clip-rule="evenodd" d="M12.9463 18.4465L88.7558 18.4465L88.7558 144.358H12.9463L12.9463 18.4465Z" fill="white"/>
|
||||||
|
</mask>
|
||||||
|
<g mask="url(#mask1_0_1944)">
|
||||||
|
<path fill-rule="evenodd" clip-rule="evenodd" d="M30.8405 18.7709C30.765 18.75 30.5429 18.7206 30.3794 18.6997C30.1656 18.6745 29.9518 18.6452 29.7423 18.6117C28.8998 18.4691 28.0447 18.4188 27.2022 18.4608C26.9758 18.4691 26.7453 18.465 26.5315 18.4733C26.4225 18.4733 26.3177 18.465 26.2129 18.4733C26.0243 18.4985 25.7393 18.5614 25.4417 18.6326L25.0519 18.7164C24.4273 18.8548 23.7944 19.035 23.1699 19.2655C22.6124 19.4709 22.0423 19.7266 21.4765 20.02C20.9315 20.2967 20.4286 20.5985 19.9381 20.9296L19.628 21.135C19.3304 21.3362 19.0621 21.5122 18.8525 21.6757C18.7226 21.8015 18.6304 21.8853 18.5298 21.9691C18.0813 22.3506 17.6537 22.7529 17.2681 23.1679C16.8615 23.5913 16.4843 24.0356 16.1406 24.4883C15.7927 24.9451 15.4657 25.4314 15.1555 25.9427C14.8537 26.4457 14.5687 26.9823 14.3214 27.5272C14.0741 28.0721 13.8478 28.6505 13.6591 29.2499C13.4621 29.8703 13.3071 30.5116 13.1981 31.1403C13.0639 31.9116 12.9885 32.5906 12.9801 33.2277C12.9759 33.3577 12.9675 33.4834 12.9591 33.605L12.9424 131.491C12.9591 138.617 18.7142 144.359 25.777 144.359C32.844 144.359 38.5991 138.608 38.6075 131.541L38.6117 49.0845L68.1121 56.9815L88.7556 34.4559L60.0894 26.693L30.8405 18.7709Z" fill="#FF6F6D"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 3.1 KiB |
@@ -0,0 +1,987 @@
|
|||||||
|
import { expect, test, type Download, type Locator, type Page } from '@playwright/test';
|
||||||
|
import {
|
||||||
|
decodePDFRawStream,
|
||||||
|
degrees,
|
||||||
|
PDFArray,
|
||||||
|
PDFDocument,
|
||||||
|
PDFRawStream,
|
||||||
|
StandardFonts,
|
||||||
|
rgb
|
||||||
|
} from 'pdf-lib';
|
||||||
|
import { PDFDocument as EncryptablePdfDocument } from '@cantoo/pdf-lib';
|
||||||
|
import { unzipSync, zipSync } from 'fflate';
|
||||||
|
import { readFile } from 'node:fs/promises';
|
||||||
|
|
||||||
|
const createPdf = async (pageCount: number, label: string) => {
|
||||||
|
const document = await PDFDocument.create();
|
||||||
|
const font = await document.embedFont(StandardFonts.Helvetica);
|
||||||
|
for (let pageNumber = 1; pageNumber <= pageCount; pageNumber += 1) {
|
||||||
|
const page = document.addPage([420, 594]);
|
||||||
|
page.drawText(`${label} – Seite ${pageNumber}`, {
|
||||||
|
x: 40,
|
||||||
|
y: 520,
|
||||||
|
size: 20,
|
||||||
|
font,
|
||||||
|
color: rgb(0.1, 0.2, 0.4)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return Buffer.from(await document.save());
|
||||||
|
};
|
||||||
|
|
||||||
|
const createStampPng = (page: Page) =>
|
||||||
|
page.evaluate(
|
||||||
|
async () =>
|
||||||
|
new Promise<string>((resolve, reject) => {
|
||||||
|
const canvas = document.createElement('canvas');
|
||||||
|
canvas.width = 32;
|
||||||
|
canvas.height = 32;
|
||||||
|
const context = canvas.getContext('2d');
|
||||||
|
if (!context) {
|
||||||
|
reject(new Error('Kein 2D-Kontext'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
context.fillStyle = '#cc1a1a';
|
||||||
|
context.fillRect(0, 0, 32, 32);
|
||||||
|
canvas.toBlob(async (blob) => {
|
||||||
|
if (!blob) {
|
||||||
|
reject(new Error('PNG konnte nicht erzeugt werden'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const bytes = new Uint8Array(await blob.arrayBuffer());
|
||||||
|
let binary = '';
|
||||||
|
bytes.forEach((byte) => {
|
||||||
|
binary += String.fromCharCode(byte);
|
||||||
|
});
|
||||||
|
resolve(btoa(binary));
|
||||||
|
}, 'image/png');
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
const dropFiles = async (
|
||||||
|
page: Page,
|
||||||
|
target: Locator | 'window',
|
||||||
|
files: Array<{ name: string; mimeType: string; buffer: Buffer }>
|
||||||
|
) => {
|
||||||
|
await page.locator('[data-dropzone-ready="true"]').first().waitFor();
|
||||||
|
const dataTransfer = await page.evaluateHandle(
|
||||||
|
(droppedFiles) => {
|
||||||
|
const transfer = new DataTransfer();
|
||||||
|
for (const droppedFile of droppedFiles) {
|
||||||
|
const bytes = Uint8Array.from(atob(droppedFile.base64), (character) =>
|
||||||
|
character.charCodeAt(0)
|
||||||
|
);
|
||||||
|
transfer.items.add(
|
||||||
|
new File([bytes], droppedFile.name, {
|
||||||
|
type: droppedFile.mimeType,
|
||||||
|
lastModified: Date.now()
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return transfer;
|
||||||
|
},
|
||||||
|
files.map((file) => ({
|
||||||
|
name: file.name,
|
||||||
|
mimeType: file.mimeType,
|
||||||
|
base64: file.buffer.toString('base64')
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
|
||||||
|
if (target === 'window') {
|
||||||
|
await page.evaluate((transfer) => {
|
||||||
|
window.dispatchEvent(
|
||||||
|
new DragEvent('drop', { dataTransfer: transfer, bubbles: true, cancelable: true })
|
||||||
|
);
|
||||||
|
}, dataTransfer);
|
||||||
|
} else {
|
||||||
|
await target.dispatchEvent('drop', { dataTransfer });
|
||||||
|
}
|
||||||
|
await dataTransfer.dispose();
|
||||||
|
};
|
||||||
|
|
||||||
|
const expectPdfDownload = async (download: Download, name: RegExp) => {
|
||||||
|
expect(download.suggestedFilename()).toMatch(name);
|
||||||
|
const path = await download.path();
|
||||||
|
expect(path).not.toBeNull();
|
||||||
|
const bytes = await readFile(path!);
|
||||||
|
expect(bytes.subarray(0, 5).toString()).toBe('%PDF-');
|
||||||
|
return bytes;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getPageContentStreamCount = (document: PDFDocument, pageIndex: number) => {
|
||||||
|
const contents = document.getPage(pageIndex).node.Contents();
|
||||||
|
if (!contents) return 0;
|
||||||
|
return contents instanceof PDFArray ? contents.size() : 1;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getPageContentText = (document: PDFDocument, pageIndex: number) => {
|
||||||
|
const contents = document.getPage(pageIndex).node.Contents();
|
||||||
|
const refs = contents instanceof PDFArray ? contents.asArray() : contents ? [contents] : [];
|
||||||
|
return refs
|
||||||
|
.map((ref) => document.context.lookup(ref))
|
||||||
|
.filter((stream): stream is PDFRawStream => stream instanceof PDFRawStream)
|
||||||
|
.map((stream) => Buffer.from(decodePDFRawStream(stream).decode()).toString('latin1'))
|
||||||
|
.join('\n');
|
||||||
|
};
|
||||||
|
|
||||||
|
const createEncryptedPdf = async (pageCount: number, label: string, password: string) => {
|
||||||
|
const document = await EncryptablePdfDocument.load(await createPdf(pageCount, label));
|
||||||
|
document.encrypt({
|
||||||
|
userPassword: password,
|
||||||
|
ownerPassword: 'owner-passwort',
|
||||||
|
permissions: { printing: true, copying: true }
|
||||||
|
});
|
||||||
|
return Buffer.from(await document.save());
|
||||||
|
};
|
||||||
|
|
||||||
|
const createPreRotatedPdf = async (pageCount: number, label: string) => {
|
||||||
|
const document = await PDFDocument.create();
|
||||||
|
const font = await document.embedFont(StandardFonts.Helvetica);
|
||||||
|
for (let pageNumber = 1; pageNumber <= pageCount; pageNumber += 1) {
|
||||||
|
const page = document.addPage([420, 594]);
|
||||||
|
page.drawText(`${label} – Seite ${pageNumber}`, {
|
||||||
|
x: 40,
|
||||||
|
y: 520,
|
||||||
|
size: 20,
|
||||||
|
font,
|
||||||
|
color: rgb(0.1, 0.2, 0.4)
|
||||||
|
});
|
||||||
|
if (pageNumber === 1) page.setRotation(degrees(90));
|
||||||
|
}
|
||||||
|
return Buffer.from(await document.save());
|
||||||
|
};
|
||||||
|
|
||||||
|
const trackPageErrors = (page: Page) => {
|
||||||
|
const errors: Error[] = [];
|
||||||
|
page.on('pageerror', (error) => errors.push(error));
|
||||||
|
return errors;
|
||||||
|
};
|
||||||
|
|
||||||
|
const pdfDropzone = (page: Page) => page.getByRole('button', { name: 'PDF-Dateien hinzufügen' });
|
||||||
|
const zipDropzone = (page: Page) =>
|
||||||
|
page.getByRole('button', { name: 'beA-ZIP-Dateien hinzufügen' });
|
||||||
|
|
||||||
|
const createZip = async (label: string, pageCount = 1) =>
|
||||||
|
Buffer.from(zipSync({ [`${label}.pdf`]: await createPdf(pageCount, label) }));
|
||||||
|
|
||||||
|
const newZipButton = (page: Page) => page.getByRole('button', { name: 'Neue ZIP bearbeiten' });
|
||||||
|
const settingsButton = (page: Page) =>
|
||||||
|
page.getByRole('button', { name: 'Einstellungen', exact: true });
|
||||||
|
const replaceZipInput = (page: Page) => page.locator('input[data-zip-picker="replace-zip"]');
|
||||||
|
const appendZipInput = (page: Page) => page.locator('input[data-zip-picker="append-zip"]');
|
||||||
|
const workspaceStatus = (page: Page, pattern: RegExp) => page.getByText(pattern);
|
||||||
|
|
||||||
|
const waitForLoadedWorkspace = async (
|
||||||
|
page: Page,
|
||||||
|
status: RegExp,
|
||||||
|
options?: { timeout?: number }
|
||||||
|
) => {
|
||||||
|
await expect(newZipButton(page)).toBeVisible();
|
||||||
|
await expect(workspaceStatus(page, status)).toBeVisible({ timeout: options?.timeout ?? 15_000 });
|
||||||
|
};
|
||||||
|
|
||||||
|
const replaceThroughDialog = async (page: Page, zip: Buffer, confirm: boolean) => {
|
||||||
|
await newZipButton(page).click();
|
||||||
|
await replaceZipInput(page).setInputFiles([
|
||||||
|
{ name: 'archiv-b.zip', mimeType: 'application/zip', buffer: zip }
|
||||||
|
]);
|
||||||
|
await expect(page.getByText('Aktuelle Bearbeitung ersetzen?')).toBeVisible();
|
||||||
|
if (confirm) {
|
||||||
|
await page.getByRole('button', { name: 'Neue ZIP öffnen' }).click();
|
||||||
|
} else {
|
||||||
|
await page.getByRole('button', { name: 'Abbrechen' }).click();
|
||||||
|
}
|
||||||
|
await expect(page.getByText('Aktuelle Bearbeitung ersetzen?')).toBeHidden();
|
||||||
|
};
|
||||||
|
|
||||||
|
test('start page exposes only working tools and forwards a dropped ZIP to beA', async ({
|
||||||
|
page
|
||||||
|
}) => {
|
||||||
|
const pageErrors = trackPageErrors(page);
|
||||||
|
const pdf = await createPdf(1, 'beA');
|
||||||
|
const zip = Buffer.from(zipSync({ 'anlage.pdf': pdf }));
|
||||||
|
|
||||||
|
await page.goto('/');
|
||||||
|
await expect(page.getByRole('heading', { name: 'Werkzeugkasten für beA' })).toBeVisible();
|
||||||
|
await expect(page.getByRole('link', { name: /Stempeln/ })).toBeVisible();
|
||||||
|
await expect(page.getByRole('link', { name: /Signieren/ })).toHaveCount(0);
|
||||||
|
|
||||||
|
await dropFiles(page, 'window', [
|
||||||
|
{ name: 'bea-archiv.zip', mimeType: 'application/zip', buffer: zip }
|
||||||
|
]);
|
||||||
|
|
||||||
|
await expect(page).toHaveURL(/\/tools\/bea$/);
|
||||||
|
await expect(page.getByText('1 lose Datei')).toBeVisible({ timeout: 15_000 });
|
||||||
|
expect(pageErrors).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('beA reports an invalid ZIP and lets the user remove it', async ({ page }) => {
|
||||||
|
const pageErrors = trackPageErrors(page);
|
||||||
|
|
||||||
|
await page.goto('/tools/bea');
|
||||||
|
await dropFiles(page, zipDropzone(page), [
|
||||||
|
{
|
||||||
|
name: 'defektes-archiv.zip',
|
||||||
|
mimeType: 'application/zip',
|
||||||
|
buffer: Buffer.from('kein gueltiges ZIP-Archiv')
|
||||||
|
}
|
||||||
|
]);
|
||||||
|
|
||||||
|
await expect(page.getByText('defektes-archiv.zip', { exact: true })).toBeVisible();
|
||||||
|
await expect(page.getByText('Konnte nicht geöffnet werden.')).toBeVisible();
|
||||||
|
await expect(page.getByRole('status')).toContainText('1 fehlgeschlagen');
|
||||||
|
await expect(page.getByRole('button', { name: 'Erneut versuchen' })).toBeVisible();
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: 'Entfernen' }).click();
|
||||||
|
await expect(zipDropzone(page)).toBeVisible();
|
||||||
|
expect(pageErrors).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('short tool pages keep the footer flush with the viewport bottom', async ({ page }) => {
|
||||||
|
const expectFooterAtViewportBottom = async (viewport: { width: number; height: number }) => {
|
||||||
|
await page.setViewportSize(viewport);
|
||||||
|
await page.goto('/tools/watermark');
|
||||||
|
|
||||||
|
const footerBottom = await page.locator('footer').evaluate((footer) => {
|
||||||
|
return footer.getBoundingClientRect().bottom;
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(Math.abs(footerBottom - viewport.height)).toBeLessThanOrEqual(1);
|
||||||
|
};
|
||||||
|
|
||||||
|
await expectFooterAtViewportBottom({ width: 1440, height: 900 });
|
||||||
|
await expectFooterAtViewportBottom({ width: 390, height: 844 });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('merge accepts dropped PDFs and downloads a merged PDF', async ({ page }) => {
|
||||||
|
const pageErrors = trackPageErrors(page);
|
||||||
|
await page.goto('/tools/merge');
|
||||||
|
await dropFiles(page, pdfDropzone(page), [
|
||||||
|
{ name: 'eins.pdf', mimeType: 'application/pdf', buffer: await createPdf(1, 'Eins') },
|
||||||
|
{ name: 'zwei.pdf', mimeType: 'application/pdf', buffer: await createPdf(2, 'Zwei') }
|
||||||
|
]);
|
||||||
|
|
||||||
|
await expect(page.getByText('2 Dateien ausgewählt')).toBeVisible();
|
||||||
|
const downloadPromise = page.waitForEvent('download');
|
||||||
|
await page.getByRole('button', { name: 'PDFs zusammenfügen' }).click();
|
||||||
|
await expectPdfDownload(await downloadPromise, /^zusammengefuegt\.pdf$/);
|
||||||
|
expect(pageErrors).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('separate renders dropped PDF pages and removes a selected page', async ({ page }) => {
|
||||||
|
const pageErrors = trackPageErrors(page);
|
||||||
|
await page.goto('/tools/separate');
|
||||||
|
await dropFiles(page, pdfDropzone(page), [
|
||||||
|
{ name: 'seiten.pdf', mimeType: 'application/pdf', buffer: await createPdf(3, 'Trennen') }
|
||||||
|
]);
|
||||||
|
|
||||||
|
await expect(page.getByRole('button', { name: /Seite 1/ })).toBeVisible({ timeout: 15_000 });
|
||||||
|
await page.getByRole('button', { name: /Seite 2/ }).click();
|
||||||
|
const downloadPromise = page.waitForEvent('download');
|
||||||
|
await page.getByRole('button', { name: 'Seiten entfernen & herunterladen' }).click();
|
||||||
|
await expectPdfDownload(await downloadPromise, /^seiten_ohne_seiten\.pdf$/);
|
||||||
|
expect(pageErrors).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rotate turns pages losslessly via /Rotate and reset clears deltas', async ({ page }) => {
|
||||||
|
const pageErrors = trackPageErrors(page);
|
||||||
|
await page.goto('/tools/rotate');
|
||||||
|
await dropFiles(page, pdfDropzone(page), [
|
||||||
|
{ name: 'drehung.pdf', mimeType: 'application/pdf', buffer: await createPdf(3, 'Drehen') }
|
||||||
|
]);
|
||||||
|
|
||||||
|
const downloadButton = page.getByRole('button', { name: 'Drehen & herunterladen' });
|
||||||
|
await expect(page.getByRole('button', { name: 'Seite 2 um 90° nach rechts drehen' })).toBeVisible(
|
||||||
|
{
|
||||||
|
timeout: 15_000
|
||||||
|
}
|
||||||
|
);
|
||||||
|
await expect(downloadButton).toBeDisabled();
|
||||||
|
|
||||||
|
const rotateRightPage2 = page.getByRole('button', { name: 'Seite 2 um 90° nach rechts drehen' });
|
||||||
|
await rotateRightPage2.click();
|
||||||
|
await rotateRightPage2.click();
|
||||||
|
await expect(page.getByText('1 von 3 Seiten gedreht')).toBeVisible();
|
||||||
|
await expect(page.getByText('180°', { exact: true })).toBeVisible();
|
||||||
|
|
||||||
|
const downloadPromise = page.waitForEvent('download');
|
||||||
|
await downloadButton.click();
|
||||||
|
const bytes = await expectPdfDownload(await downloadPromise, /^drehung_gedreht\.pdf$/);
|
||||||
|
const output = await PDFDocument.load(bytes);
|
||||||
|
expect(output.getPage(1).getRotation().angle).toBe(180);
|
||||||
|
expect(output.getPage(0).getRotation().angle).toBe(0);
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: 'Zurücksetzen' }).click();
|
||||||
|
await expect(page.getByText('0 von 3 Seiten gedreht')).toBeVisible();
|
||||||
|
await expect(downloadButton).toBeDisabled();
|
||||||
|
expect(pageErrors).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rotate adds the delta on top of an existing /Rotate value', async ({ page }) => {
|
||||||
|
const pageErrors = trackPageErrors(page);
|
||||||
|
await page.goto('/tools/rotate');
|
||||||
|
await dropFiles(page, pdfDropzone(page), [
|
||||||
|
{
|
||||||
|
name: 'vorgedreht.pdf',
|
||||||
|
mimeType: 'application/pdf',
|
||||||
|
buffer: await createPreRotatedPdf(2, 'Vorgedreht')
|
||||||
|
}
|
||||||
|
]);
|
||||||
|
|
||||||
|
await expect(page.getByRole('button', { name: 'Seite 1 um 90° nach rechts drehen' })).toBeVisible(
|
||||||
|
{
|
||||||
|
timeout: 15_000
|
||||||
|
}
|
||||||
|
);
|
||||||
|
await page.getByRole('button', { name: 'Seite 1 um 90° nach rechts drehen' }).click();
|
||||||
|
await expect(page.getByText('1 von 2 Seiten gedreht')).toBeVisible();
|
||||||
|
|
||||||
|
const downloadPromise = page.waitForEvent('download');
|
||||||
|
await page.getByRole('button', { name: 'Drehen & herunterladen' }).click();
|
||||||
|
const bytes = await expectPdfDownload(await downloadPromise, /^vorgedreht_gedreht\.pdf$/);
|
||||||
|
const output = await PDFDocument.load(bytes);
|
||||||
|
expect(output.getPage(0).getRotation().angle).toBe(180);
|
||||||
|
expect(output.getPage(1).getRotation().angle).toBe(0);
|
||||||
|
expect(pageErrors).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('watermark accepts numeric controls and downloads text and image watermarks', async ({
|
||||||
|
page
|
||||||
|
}) => {
|
||||||
|
const pageErrors = trackPageErrors(page);
|
||||||
|
await page.goto('/tools/watermark');
|
||||||
|
await dropFiles(page, pdfDropzone(page), [
|
||||||
|
{ name: 'marke.pdf', mimeType: 'application/pdf', buffer: await createPdf(1, 'Marke') }
|
||||||
|
]);
|
||||||
|
|
||||||
|
await expect(page.getByRole('heading', { name: 'Wasserzeichen-Einstellungen' })).toBeVisible();
|
||||||
|
await page.locator('#watermark-color').fill('#00ff00');
|
||||||
|
await page.locator('#watermark-opacity').fill('0.45');
|
||||||
|
await page.getByRole('button', { name: 'Wasserzeichen anwenden', exact: true }).click();
|
||||||
|
await expect(page.getByText('Fertig ✓')).toBeVisible({ timeout: 15_000 });
|
||||||
|
const downloadPromise = page.waitForEvent('download');
|
||||||
|
await page.getByRole('button', { name: 'marke.pdf herunterladen' }).click();
|
||||||
|
await expectPdfDownload(await downloadPromise, /^marke_wasserzeichen\.pdf$/);
|
||||||
|
|
||||||
|
await page.getByLabel('Bild', { exact: true }).check();
|
||||||
|
await dropFiles(page, page.getByRole('button', { name: 'Wasserzeichenbild hinzufügen' }), [
|
||||||
|
{
|
||||||
|
name: 'logo.png',
|
||||||
|
mimeType: 'image/png',
|
||||||
|
buffer: Buffer.from(
|
||||||
|
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAusB9Y9Z5rAAAAAASUVORK5CYII=',
|
||||||
|
'base64'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
]);
|
||||||
|
// The file is already done, so reset it to pending by removing and re-adding it.
|
||||||
|
await page.getByRole('button', { name: 'marke.pdf entfernen' }).click();
|
||||||
|
await dropFiles(page, pdfDropzone(page), [
|
||||||
|
{ name: 'marke.pdf', mimeType: 'application/pdf', buffer: await createPdf(1, 'Marke') }
|
||||||
|
]);
|
||||||
|
await page.getByRole('button', { name: 'Wasserzeichen anwenden', exact: true }).click();
|
||||||
|
await expect(page.getByText('Fertig ✓')).toBeVisible({ timeout: 15_000 });
|
||||||
|
const imageDownloadPromise = page.waitForEvent('download');
|
||||||
|
await page.getByRole('button', { name: 'marke.pdf herunterladen' }).click();
|
||||||
|
await expectPdfDownload(await imageDownloadPromise, /^marke_wasserzeichen\.pdf$/);
|
||||||
|
|
||||||
|
expect(pageErrors).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('stamp presets fill the editable text and EILT is applied to every page', async ({ page }) => {
|
||||||
|
const pageErrors = trackPageErrors(page);
|
||||||
|
await page.goto('/tools/stamp');
|
||||||
|
await dropFiles(page, pdfDropzone(page), [
|
||||||
|
{ name: 'akte.pdf', mimeType: 'application/pdf', buffer: await createPdf(3, 'Akte') }
|
||||||
|
]);
|
||||||
|
|
||||||
|
const stampInput = page.locator('#stamp-text');
|
||||||
|
for (const preset of ['BEGLAUBIGT', 'AUSFERTIGUNG', 'KOPIE', 'EILT', 'ENTWURF', 'ERLEDIGT']) {
|
||||||
|
await page.getByRole('button', { name: preset, exact: true }).click();
|
||||||
|
await expect(stampInput).toHaveValue(preset);
|
||||||
|
}
|
||||||
|
await page.getByRole('button', { name: 'EILT', exact: true }).click();
|
||||||
|
await page.locator('#stamp-rotation').fill('20');
|
||||||
|
await expect(page.getByTestId('stamp-preview')).toContainText('EILT');
|
||||||
|
await expect(page.getByTestId('stamp-preview')).not.toHaveCSS('transform', 'none');
|
||||||
|
|
||||||
|
const downloadPromise = page.waitForEvent('download');
|
||||||
|
await page.getByRole('button', { name: 'Stempel anwenden & herunterladen' }).click();
|
||||||
|
const outputBytes = await expectPdfDownload(await downloadPromise, /^akte_stempel\.pdf$/);
|
||||||
|
const output = await PDFDocument.load(outputBytes);
|
||||||
|
expect(output.getPageCount()).toBe(3);
|
||||||
|
for (let pageIndex = 0; pageIndex < output.getPageCount(); pageIndex += 1) {
|
||||||
|
expect(getPageContentStreamCount(output, pageIndex)).toBeGreaterThan(1);
|
||||||
|
}
|
||||||
|
expect(pageErrors).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('stamp custom text adds a German date and only changes selected pages', async ({ page }) => {
|
||||||
|
const pageErrors = trackPageErrors(page);
|
||||||
|
const inputBytes = await createPdf(3, 'Auswahl');
|
||||||
|
const input = await PDFDocument.load(inputBytes);
|
||||||
|
const originalStreamCounts = input
|
||||||
|
.getPages()
|
||||||
|
.map((_, pageIndex) => getPageContentStreamCount(input, pageIndex));
|
||||||
|
|
||||||
|
await page.goto('/tools/stamp');
|
||||||
|
await dropFiles(page, pdfDropzone(page), [
|
||||||
|
{ name: 'auswahl.pdf', mimeType: 'application/pdf', buffer: inputBytes }
|
||||||
|
]);
|
||||||
|
await page.locator('#stamp-text').fill('nur hier');
|
||||||
|
await page.getByRole('checkbox', { name: 'Datum anhängen' }).check();
|
||||||
|
await expect(page.getByTestId('stamp-preview')).toHaveText(/NUR HIER \d{2}\.\d{2}\.\d{4}/);
|
||||||
|
await page.getByRole('checkbox', { name: 'Auf alle Seiten anwenden' }).uncheck();
|
||||||
|
await page.getByRole('button', { name: /Seite 2/ }).click();
|
||||||
|
|
||||||
|
const downloadPromise = page.waitForEvent('download');
|
||||||
|
await page.getByRole('button', { name: 'Stempel anwenden & herunterladen' }).click();
|
||||||
|
const outputBytes = await expectPdfDownload(await downloadPromise, /^auswahl_stempel\.pdf$/);
|
||||||
|
const output = await PDFDocument.load(outputBytes);
|
||||||
|
expect(output.getPageCount()).toBe(3);
|
||||||
|
expect(getPageContentStreamCount(output, 0)).toBe(originalStreamCounts[0]);
|
||||||
|
expect(getPageContentStreamCount(output, 1)).toBeGreaterThan(originalStreamCounts[1]);
|
||||||
|
expect(getPageContentStreamCount(output, 2)).toBe(originalStreamCounts[2]);
|
||||||
|
expect(pageErrors).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('stamp uses an image instead of text and makes the border optional', async ({ page }) => {
|
||||||
|
const pageErrors = trackPageErrors(page);
|
||||||
|
await page.goto('/tools/stamp');
|
||||||
|
await dropFiles(page, pdfDropzone(page), [
|
||||||
|
{ name: 'bildakte.pdf', mimeType: 'application/pdf', buffer: await createPdf(2, 'Bild') }
|
||||||
|
]);
|
||||||
|
await page.locator('#stamp-image').setInputFiles({
|
||||||
|
name: 'stempel.png',
|
||||||
|
mimeType: 'image/png',
|
||||||
|
buffer: Buffer.from(await createStampPng(page), 'base64')
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(page.getByText('stempel.png')).toBeVisible();
|
||||||
|
await expect(page.getByTestId('stamp-preview').locator('img')).toBeVisible();
|
||||||
|
await expect(page.locator('#stamp-text')).toHaveCount(0);
|
||||||
|
await expect(page.getByRole('checkbox', { name: 'Rahmen um das Bild anzeigen' })).toBeVisible();
|
||||||
|
await expect(
|
||||||
|
page.getByRole('checkbox', { name: 'Rahmen um das Bild anzeigen' })
|
||||||
|
).not.toBeChecked();
|
||||||
|
|
||||||
|
const downloadPromise = page.waitForEvent('download');
|
||||||
|
await page.getByRole('button', { name: 'Stempel anwenden & herunterladen' }).click();
|
||||||
|
const outputBytes = await expectPdfDownload(await downloadPromise, /^bildakte_stempel\.pdf$/);
|
||||||
|
const output = await PDFDocument.load(outputBytes);
|
||||||
|
expect(output.getPageCount()).toBe(2);
|
||||||
|
// Image without frame: the image XObject is drawn, no stroked rectangle is present.
|
||||||
|
for (const pageIndex of [0, 1]) {
|
||||||
|
const content = getPageContentText(output, pageIndex);
|
||||||
|
expect(content).toContain(' Do\n');
|
||||||
|
expect(content).not.toContain(' RG');
|
||||||
|
}
|
||||||
|
|
||||||
|
await page.getByRole('checkbox', { name: 'Rahmen um das Bild anzeigen' }).check();
|
||||||
|
await expect(page.getByTestId('stamp-preview')).toHaveCSS('border-style', 'solid');
|
||||||
|
const borderedDownloadPromise = page.waitForEvent('download');
|
||||||
|
await page.getByRole('button', { name: 'Stempel anwenden & herunterladen' }).click();
|
||||||
|
const borderedBytes = await expectPdfDownload(
|
||||||
|
await borderedDownloadPromise,
|
||||||
|
/^bildakte_stempel\.pdf$/
|
||||||
|
);
|
||||||
|
const bordered = await PDFDocument.load(borderedBytes);
|
||||||
|
// The optional frame adds a stroked rectangle around the drawn image.
|
||||||
|
for (const pageIndex of [0, 1]) {
|
||||||
|
const content = getPageContentText(bordered, pageIndex);
|
||||||
|
expect(content).toContain(' Do\n');
|
||||||
|
expect(content).toContain(' RG');
|
||||||
|
expect(content).toMatch(/\bh\nS\n/);
|
||||||
|
}
|
||||||
|
expect(pageErrors).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('stamp explains how to handle a password-protected PDF', async ({ page }) => {
|
||||||
|
const pageErrors = trackPageErrors(page);
|
||||||
|
await page.goto('/tools/stamp');
|
||||||
|
await dropFiles(page, pdfDropzone(page), [
|
||||||
|
{
|
||||||
|
name: 'geschuetzt.pdf',
|
||||||
|
mimeType: 'application/pdf',
|
||||||
|
buffer: await createEncryptedPdf(1, 'Geschützt', 'test-passwort')
|
||||||
|
}
|
||||||
|
]);
|
||||||
|
|
||||||
|
await expect(page.getByRole('alert')).toContainText('passwortgeschützt');
|
||||||
|
await expect(page.getByRole('alert')).toContainText('Passwort entfernen');
|
||||||
|
expect(pageErrors).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('watermark batch applies to every file and bundles results in one ZIP', async ({ page }) => {
|
||||||
|
const pageErrors = trackPageErrors(page);
|
||||||
|
await page.goto('/tools/watermark');
|
||||||
|
await dropFiles(page, pdfDropzone(page), [
|
||||||
|
{ name: 'eins.pdf', mimeType: 'application/pdf', buffer: await createPdf(1, 'Eins') },
|
||||||
|
{ name: 'zwei.pdf', mimeType: 'application/pdf', buffer: await createPdf(2, 'Zwei') },
|
||||||
|
{
|
||||||
|
name: 'kaputt.pdf',
|
||||||
|
mimeType: 'application/pdf',
|
||||||
|
buffer: Buffer.from('kein gueltiges PDF')
|
||||||
|
}
|
||||||
|
]);
|
||||||
|
|
||||||
|
await expect(page.getByText('Dateien (3)')).toBeVisible();
|
||||||
|
await page.getByRole('button', { name: 'Wasserzeichen anwenden', exact: true }).click();
|
||||||
|
await expect(page.getByText('Fertig ✓')).toHaveCount(2, { timeout: 15_000 });
|
||||||
|
await expect(page.getByText('Fehler')).toBeVisible();
|
||||||
|
// With several files the per-file page selection is replaced by all pages.
|
||||||
|
await expect(page.getByRole('checkbox', { name: 'Auf alle Seiten anwenden' })).toHaveCount(0);
|
||||||
|
|
||||||
|
const downloadPromise = page.waitForEvent('download');
|
||||||
|
await page.getByRole('button', { name: 'Alle herunterladen' }).click();
|
||||||
|
const download = await downloadPromise;
|
||||||
|
expect(download.suggestedFilename()).toMatch(/^wasserzeichen_\d{4}-\d{2}-\d{2}\.zip$/);
|
||||||
|
const zipPath = await download.path();
|
||||||
|
expect(zipPath).not.toBeNull();
|
||||||
|
const entries = Object.keys(unzipSync(new Uint8Array(await readFile(zipPath!))));
|
||||||
|
expect(entries.sort()).toEqual(['eins_wasserzeichen.pdf', 'zwei_wasserzeichen.pdf']);
|
||||||
|
expect(pageErrors).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('encrypt protects a dropped PDF and decrypt rebuilds it with the password', async ({
|
||||||
|
page
|
||||||
|
}) => {
|
||||||
|
const pageErrors = trackPageErrors(page);
|
||||||
|
await page.goto('/tools/encrypt');
|
||||||
|
await dropFiles(page, pdfDropzone(page), [
|
||||||
|
{ name: 'geheim.pdf', mimeType: 'application/pdf', buffer: await createPdf(1, 'Geheim') }
|
||||||
|
]);
|
||||||
|
|
||||||
|
await page.locator('#encrypt-password').fill('test-passwort');
|
||||||
|
await page.locator('#encrypt-password-confirmation').fill('test-passwort');
|
||||||
|
await page.getByRole('button', { name: 'Passwort setzen', exact: true }).click();
|
||||||
|
await expect(page.getByText('Fertig ✓')).toBeVisible({ timeout: 15_000 });
|
||||||
|
const encryptedDownloadPromise = page.waitForEvent('download');
|
||||||
|
await page.getByRole('button', { name: 'geheim.pdf herunterladen' }).click();
|
||||||
|
const encryptedBytes = await expectPdfDownload(
|
||||||
|
await encryptedDownloadPromise,
|
||||||
|
/^geheim_geschuetzt\.pdf$/
|
||||||
|
);
|
||||||
|
|
||||||
|
await page.goto('/tools/decrypt');
|
||||||
|
await dropFiles(page, pdfDropzone(page), [
|
||||||
|
{
|
||||||
|
name: 'geheim_geschuetzt.pdf',
|
||||||
|
mimeType: 'application/pdf',
|
||||||
|
buffer: encryptedBytes
|
||||||
|
}
|
||||||
|
]);
|
||||||
|
await page.locator('#decrypt-password').fill('test-passwort');
|
||||||
|
await page.getByRole('button', { name: 'Passwort entfernen', exact: true }).click();
|
||||||
|
await expect(page.getByText('Fertig ✓')).toBeVisible({ timeout: 20_000 });
|
||||||
|
const decryptedDownloadPromise = page.waitForEvent('download');
|
||||||
|
await page.getByRole('button', { name: 'geheim_geschuetzt.pdf herunterladen' }).click();
|
||||||
|
await expectPdfDownload(await decryptedDownloadPromise, /^geheim_geschuetzt_entsperrt\.pdf$/);
|
||||||
|
expect(pageErrors).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('encrypt batch protects every file and bundles results in one ZIP', async ({ page }) => {
|
||||||
|
const pageErrors = trackPageErrors(page);
|
||||||
|
await page.goto('/tools/encrypt');
|
||||||
|
await dropFiles(page, pdfDropzone(page), [
|
||||||
|
{ name: 'eins.pdf', mimeType: 'application/pdf', buffer: await createPdf(1, 'Eins') },
|
||||||
|
{ name: 'zwei.pdf', mimeType: 'application/pdf', buffer: await createPdf(2, 'Zwei') }
|
||||||
|
]);
|
||||||
|
|
||||||
|
await expect(page.getByText('Dateien (2)')).toBeVisible();
|
||||||
|
await page.locator('#encrypt-password').fill('test-passwort');
|
||||||
|
await page.locator('#encrypt-password-confirmation').fill('test-passwort');
|
||||||
|
await page.getByRole('button', { name: 'Passwort setzen', exact: true }).click();
|
||||||
|
await expect(page.getByText('Fertig ✓')).toHaveCount(2, { timeout: 15_000 });
|
||||||
|
|
||||||
|
const downloadPromise = page.waitForEvent('download');
|
||||||
|
await page.getByRole('button', { name: 'Alle herunterladen' }).click();
|
||||||
|
const download = await downloadPromise;
|
||||||
|
expect(download.suggestedFilename()).toMatch(/^geschuetzt_\d{4}-\d{2}-\d{2}\.zip$/);
|
||||||
|
const zipPath = await download.path();
|
||||||
|
expect(zipPath).not.toBeNull();
|
||||||
|
const entries = Object.keys(unzipSync(new Uint8Array(await readFile(zipPath!))));
|
||||||
|
expect(entries.sort()).toEqual(['eins_geschuetzt.pdf', 'zwei_geschuetzt.pdf']);
|
||||||
|
expect(pageErrors).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('decrypt batch unlocks every file and bundles results in one ZIP', async ({ page }) => {
|
||||||
|
const pageErrors = trackPageErrors(page);
|
||||||
|
await page.goto('/tools/decrypt');
|
||||||
|
await dropFiles(page, pdfDropzone(page), [
|
||||||
|
{
|
||||||
|
name: 'eins_geschuetzt.pdf',
|
||||||
|
mimeType: 'application/pdf',
|
||||||
|
buffer: await createEncryptedPdf(1, 'Eins', 'test-passwort')
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'zwei_geschuetzt.pdf',
|
||||||
|
mimeType: 'application/pdf',
|
||||||
|
buffer: await createEncryptedPdf(2, 'Zwei', 'test-passwort')
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'kaputt.pdf',
|
||||||
|
mimeType: 'application/pdf',
|
||||||
|
buffer: Buffer.from('kein gueltiges PDF')
|
||||||
|
}
|
||||||
|
]);
|
||||||
|
|
||||||
|
await expect(page.getByText('Dateien (3)')).toBeVisible();
|
||||||
|
await page.locator('#decrypt-password').fill('test-passwort');
|
||||||
|
await page.getByRole('button', { name: 'Passwort entfernen', exact: true }).click();
|
||||||
|
await expect(page.getByText('Fertig ✓')).toHaveCount(2, { timeout: 20_000 });
|
||||||
|
await expect(page.getByText(/Fehler beim Entsperren/)).toBeVisible();
|
||||||
|
|
||||||
|
const downloadPromise = page.waitForEvent('download');
|
||||||
|
await page.getByRole('button', { name: 'Alle herunterladen' }).click();
|
||||||
|
const download = await downloadPromise;
|
||||||
|
expect(download.suggestedFilename()).toMatch(/^entsperrt_\d{4}-\d{2}-\d{2}\.zip$/);
|
||||||
|
const zipPath = await download.path();
|
||||||
|
expect(zipPath).not.toBeNull();
|
||||||
|
const entries = Object.keys(unzipSync(new Uint8Array(await readFile(zipPath!))));
|
||||||
|
expect(entries.sort()).toEqual([
|
||||||
|
'eins_geschuetzt_entsperrt.pdf',
|
||||||
|
'zwei_geschuetzt_entsperrt.pdf'
|
||||||
|
]);
|
||||||
|
expect(pageErrors).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('compress processes a single dropped file and offers its download per row', async ({
|
||||||
|
page
|
||||||
|
}) => {
|
||||||
|
const pageErrors = trackPageErrors(page);
|
||||||
|
await page.goto('/tools/compress');
|
||||||
|
await dropFiles(page, pdfDropzone(page), [
|
||||||
|
{ name: 'gross.pdf', mimeType: 'application/pdf', buffer: await createPdf(2, 'Komprimieren') }
|
||||||
|
]);
|
||||||
|
|
||||||
|
await expect(page.getByText('Dateien (1)')).toBeVisible();
|
||||||
|
await page.getByRole('button', { name: 'Komprimieren', exact: true }).click();
|
||||||
|
await expect(page.getByText('Fertig ✓')).toBeVisible({ timeout: 20_000 });
|
||||||
|
const downloadPromise = page.waitForEvent('download');
|
||||||
|
await page.getByRole('button', { name: 'gross.pdf herunterladen' }).click();
|
||||||
|
await expectPdfDownload(await downloadPromise, /^gross_komprimiert\.pdf$/);
|
||||||
|
expect(pageErrors).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('compress appends further files and bundles all results in one ZIP', async ({ page }) => {
|
||||||
|
const pageErrors = trackPageErrors(page);
|
||||||
|
await page.goto('/tools/compress');
|
||||||
|
await dropFiles(page, pdfDropzone(page), [
|
||||||
|
{ name: 'eins.pdf', mimeType: 'application/pdf', buffer: await createPdf(1, 'Eins') }
|
||||||
|
]);
|
||||||
|
await dropFiles(page, pdfDropzone(page), [
|
||||||
|
{ name: 'zwei.pdf', mimeType: 'application/pdf', buffer: await createPdf(2, 'Zwei') }
|
||||||
|
]);
|
||||||
|
|
||||||
|
await expect(page.getByText('Dateien (2)')).toBeVisible();
|
||||||
|
await page.getByRole('button', { name: 'Komprimieren', exact: true }).click();
|
||||||
|
await expect(page.getByText('Fertig ✓')).toHaveCount(2, { timeout: 30_000 });
|
||||||
|
|
||||||
|
const downloadPromise = page.waitForEvent('download');
|
||||||
|
await page.getByRole('button', { name: 'Alle herunterladen' }).click();
|
||||||
|
const download = await downloadPromise;
|
||||||
|
expect(download.suggestedFilename()).toMatch(/^komprimiert_\d{4}-\d{2}-\d{2}\.zip$/);
|
||||||
|
const zipPath = await download.path();
|
||||||
|
expect(zipPath).not.toBeNull();
|
||||||
|
const entries = Object.keys(unzipSync(new Uint8Array(await readFile(zipPath!))));
|
||||||
|
expect(entries.sort()).toEqual(['eins_komprimiert.pdf', 'zwei_komprimiert.pdf']);
|
||||||
|
expect(pageErrors).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('compress batch keeps processing after a failing file and supports retry and removal', async ({
|
||||||
|
page
|
||||||
|
}) => {
|
||||||
|
const pageErrors = trackPageErrors(page);
|
||||||
|
await page.goto('/tools/compress');
|
||||||
|
await dropFiles(page, pdfDropzone(page), [
|
||||||
|
{ name: 'gut.pdf', mimeType: 'application/pdf', buffer: await createPdf(1, 'Gut') },
|
||||||
|
{
|
||||||
|
name: 'kaputt.pdf',
|
||||||
|
mimeType: 'application/pdf',
|
||||||
|
buffer: Buffer.from('kein gueltiges PDF')
|
||||||
|
}
|
||||||
|
]);
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: 'Komprimieren', exact: true }).click();
|
||||||
|
await expect(page.getByText('Fertig ✓')).toHaveCount(1, { timeout: 30_000 });
|
||||||
|
await expect(page.getByText('Fehler')).toBeVisible();
|
||||||
|
|
||||||
|
// Only one result exists, so the bulk ZIP action stays hidden.
|
||||||
|
await expect(page.getByRole('button', { name: 'Alle herunterladen' })).toHaveCount(0);
|
||||||
|
|
||||||
|
// Retrying the broken file fails again without disturbing the finished row.
|
||||||
|
await page.getByRole('button', { name: 'kaputt.pdf erneut versuchen' }).click();
|
||||||
|
await expect(page.getByText('Fertig ✓')).toHaveCount(1);
|
||||||
|
await expect(page.getByText('Fehler')).toBeVisible();
|
||||||
|
|
||||||
|
const downloadPromise = page.waitForEvent('download');
|
||||||
|
await page.getByRole('button', { name: 'gut.pdf herunterladen' }).click();
|
||||||
|
await expectPdfDownload(await downloadPromise, /^gut_komprimiert\.pdf$/);
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: 'kaputt.pdf entfernen' }).click();
|
||||||
|
await expect(page.getByText('kaputt.pdf')).toHaveCount(0);
|
||||||
|
await expect(page.getByRole('button', { name: 'gut.pdf herunterladen' })).toBeVisible();
|
||||||
|
expect(pageErrors).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('convert bundles a multi-page PDF as a correctly named ZIP', async ({ page }) => {
|
||||||
|
const pageErrors = trackPageErrors(page);
|
||||||
|
await page.goto('/tools/convert');
|
||||||
|
await dropFiles(page, pdfDropzone(page), [
|
||||||
|
{ name: 'bilder.pdf', mimeType: 'application/pdf', buffer: await createPdf(2, 'Bilder') }
|
||||||
|
]);
|
||||||
|
|
||||||
|
const downloadPromise = page.waitForEvent('download');
|
||||||
|
await page.getByRole('button', { name: 'Konvertieren & herunterladen' }).click();
|
||||||
|
const download = await downloadPromise;
|
||||||
|
expect(download.suggestedFilename()).toBe('bilder_bilder.zip');
|
||||||
|
expect(download.suggestedFilename()).not.toContain('.pdf');
|
||||||
|
expect(pageErrors).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('cancelling the replacement confirmation keeps the current archive', async ({ page }) => {
|
||||||
|
const pageErrors = trackPageErrors(page);
|
||||||
|
const zipA = await createZip('archiv-a');
|
||||||
|
const zipB = await createZip('archiv-b');
|
||||||
|
|
||||||
|
await page.goto('/tools/bea');
|
||||||
|
await dropFiles(page, zipDropzone(page), [
|
||||||
|
{ name: 'archiv-a.zip', mimeType: 'application/zip', buffer: zipA }
|
||||||
|
]);
|
||||||
|
await waitForLoadedWorkspace(page, /1 ZIP · 1 Archiv geöffnet/);
|
||||||
|
|
||||||
|
await replaceThroughDialog(page, zipB, false);
|
||||||
|
|
||||||
|
await expect(workspaceStatus(page, /1 ZIP · 1 Archiv geöffnet/)).toBeVisible();
|
||||||
|
await expect(page.getByText('2 ZIPs')).toHaveCount(0);
|
||||||
|
expect(pageErrors).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('confirming the replacement opens only the new ZIP and drops stale results', async ({
|
||||||
|
page
|
||||||
|
}) => {
|
||||||
|
const pageErrors = trackPageErrors(page);
|
||||||
|
const zipA = await createZip('archiv-a', 3);
|
||||||
|
const zipB = await createZip('archiv-b');
|
||||||
|
|
||||||
|
await page.goto('/tools/bea');
|
||||||
|
await dropFiles(page, zipDropzone(page), [
|
||||||
|
{ name: 'archiv-a.zip', mimeType: 'application/zip', buffer: zipA }
|
||||||
|
]);
|
||||||
|
// Replace while the first ZIP may still be extracting; generation guarding
|
||||||
|
// must ensure no late result from archiv-a reappears.
|
||||||
|
await replaceThroughDialog(page, zipB, true);
|
||||||
|
|
||||||
|
await waitForLoadedWorkspace(page, /1 ZIP · 1 Archiv geöffnet/);
|
||||||
|
await expect(page.getByText('1 lose Datei')).toHaveCount(1);
|
||||||
|
expect(pageErrors).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('cancelling the system file picker leaves the workspace unchanged', async ({ page }) => {
|
||||||
|
const pageErrors = trackPageErrors(page);
|
||||||
|
const zipA = await createZip('archiv-a');
|
||||||
|
|
||||||
|
await page.goto('/tools/bea');
|
||||||
|
await dropFiles(page, zipDropzone(page), [
|
||||||
|
{ name: 'archiv-a.zip', mimeType: 'application/zip', buffer: zipA }
|
||||||
|
]);
|
||||||
|
await waitForLoadedWorkspace(page, /1 ZIP · 1 Archiv geöffnet/);
|
||||||
|
|
||||||
|
await newZipButton(page).click();
|
||||||
|
await replaceZipInput(page).setInputFiles([]);
|
||||||
|
await expect(page.getByText('Aktuelle Bearbeitung ersetzen?')).toHaveCount(0);
|
||||||
|
await expect(workspaceStatus(page, /1 ZIP · 1 Archiv geöffnet/)).toBeVisible();
|
||||||
|
expect(pageErrors).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the replacement input accepts the same ZIP twice', async ({ page }) => {
|
||||||
|
const pageErrors = trackPageErrors(page);
|
||||||
|
const zipA = await createZip('archiv-a');
|
||||||
|
const zipB = await createZip('archiv-b');
|
||||||
|
|
||||||
|
await page.goto('/tools/bea');
|
||||||
|
await dropFiles(page, zipDropzone(page), [
|
||||||
|
{ name: 'archiv-a.zip', mimeType: 'application/zip', buffer: zipA }
|
||||||
|
]);
|
||||||
|
await waitForLoadedWorkspace(page, /1 ZIP · 1 Archiv geöffnet/);
|
||||||
|
|
||||||
|
await replaceThroughDialog(page, zipB, true);
|
||||||
|
await waitForLoadedWorkspace(page, /1 ZIP · 1 Archiv geöffnet/);
|
||||||
|
|
||||||
|
await replaceThroughDialog(page, zipB, true);
|
||||||
|
await waitForLoadedWorkspace(page, /1 ZIP · 1 Archiv geöffnet/);
|
||||||
|
await expect(page.getByText('1 lose Datei')).toHaveCount(1);
|
||||||
|
expect(pageErrors).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('appending a ZIP keeps both archive editors visible', async ({ page }) => {
|
||||||
|
const pageErrors = trackPageErrors(page);
|
||||||
|
const zipA = await createZip('archiv-a');
|
||||||
|
const zipB = await createZip('archiv-b');
|
||||||
|
|
||||||
|
await page.goto('/tools/bea');
|
||||||
|
await dropFiles(page, zipDropzone(page), [
|
||||||
|
{ name: 'archiv-a.zip', mimeType: 'application/zip', buffer: zipA }
|
||||||
|
]);
|
||||||
|
await waitForLoadedWorkspace(page, /1 ZIP · 1 Archiv geöffnet/);
|
||||||
|
|
||||||
|
await settingsButton(page).click();
|
||||||
|
await appendZipInput(page).setInputFiles([
|
||||||
|
{ name: 'archiv-b.zip', mimeType: 'application/zip', buffer: zipB }
|
||||||
|
]);
|
||||||
|
|
||||||
|
await waitForLoadedWorkspace(page, /2 ZIPs · 2 Archive geöffnet/);
|
||||||
|
await expect(page.getByText('1 lose Datei')).toHaveCount(2);
|
||||||
|
expect(pageErrors).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('bulk export only appears for two or more processed archives', async ({ page }) => {
|
||||||
|
const pageErrors = trackPageErrors(page);
|
||||||
|
const zipA = await createZip('archiv-a');
|
||||||
|
const zipB = await createZip('archiv-b');
|
||||||
|
|
||||||
|
await page.goto('/tools/bea');
|
||||||
|
await dropFiles(page, zipDropzone(page), [
|
||||||
|
{ name: 'archiv-a.zip', mimeType: 'application/zip', buffer: zipA }
|
||||||
|
]);
|
||||||
|
await waitForLoadedWorkspace(page, /1 ZIP · 1 Archiv geöffnet/);
|
||||||
|
|
||||||
|
await settingsButton(page).click();
|
||||||
|
await expect(
|
||||||
|
page.getByRole('button', { name: 'Alle Archive als PDF herunterladen' })
|
||||||
|
).toHaveCount(0);
|
||||||
|
|
||||||
|
await appendZipInput(page).setInputFiles([
|
||||||
|
{ name: 'archiv-b.zip', mimeType: 'application/zip', buffer: zipB }
|
||||||
|
]);
|
||||||
|
await waitForLoadedWorkspace(page, /2 ZIPs · 2 Archive geöffnet/);
|
||||||
|
|
||||||
|
// The popover stays open, so the new bulk export action appears in place.
|
||||||
|
const exportButton = page.getByRole('button', { name: 'Alle Archive als PDF herunterladen' });
|
||||||
|
await expect(exportButton).toBeVisible();
|
||||||
|
|
||||||
|
const downloadPromise = page.waitForEvent('download');
|
||||||
|
await exportButton.click();
|
||||||
|
await expectPdfDownload(await downloadPromise, /\.pdf$/);
|
||||||
|
expect(pageErrors).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('clearing the workspace requires confirmation and returns to the dropzone', async ({
|
||||||
|
page
|
||||||
|
}) => {
|
||||||
|
const pageErrors = trackPageErrors(page);
|
||||||
|
const zipA = await createZip('archiv-a');
|
||||||
|
|
||||||
|
await page.goto('/tools/bea');
|
||||||
|
await dropFiles(page, zipDropzone(page), [
|
||||||
|
{ name: 'archiv-a.zip', mimeType: 'application/zip', buffer: zipA }
|
||||||
|
]);
|
||||||
|
await waitForLoadedWorkspace(page, /1 ZIP · 1 Archiv geöffnet/);
|
||||||
|
|
||||||
|
await settingsButton(page).click();
|
||||||
|
await page.getByRole('button', { name: 'Aktuelle Bearbeitung leeren' }).click();
|
||||||
|
await expect(page.getByText('Aktuelle Bearbeitung leeren?')).toBeVisible();
|
||||||
|
await page.getByRole('button', { name: 'Abbrechen' }).click();
|
||||||
|
await expect(workspaceStatus(page, /1 ZIP · 1 Archiv geöffnet/)).toBeVisible();
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: 'Aktuelle Bearbeitung leeren' }).click();
|
||||||
|
await page.getByRole('button', { name: 'Bearbeitung leeren', exact: true }).click();
|
||||||
|
await expect(zipDropzone(page)).toBeVisible();
|
||||||
|
await expect(newZipButton(page)).toHaveCount(0);
|
||||||
|
expect(pageErrors).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the preview-size slider works with arrow keys and survives a reload', async ({ page }) => {
|
||||||
|
const pageErrors = trackPageErrors(page);
|
||||||
|
const zipA = await createZip('archiv-a');
|
||||||
|
|
||||||
|
await page.goto('/tools/bea');
|
||||||
|
await dropFiles(page, zipDropzone(page), [
|
||||||
|
{ name: 'archiv-a.zip', mimeType: 'application/zip', buffer: zipA }
|
||||||
|
]);
|
||||||
|
await waitForLoadedWorkspace(page, /1 ZIP · 1 Archiv geöffnet/);
|
||||||
|
await expect(page.locator('li[style*="width: 200px"]').first()).toBeVisible();
|
||||||
|
|
||||||
|
await settingsButton(page).click();
|
||||||
|
const slider = page.getByRole('slider', { name: 'Vorschaugröße' });
|
||||||
|
await expect(page.getByText('200 px')).toBeVisible();
|
||||||
|
await slider.focus();
|
||||||
|
await page.keyboard.press('ArrowRight');
|
||||||
|
await expect(page.getByText('201 px')).toBeVisible();
|
||||||
|
await expect(page.locator('li[style*="width: 201px"]').first()).toBeVisible();
|
||||||
|
|
||||||
|
await page.reload();
|
||||||
|
await page.locator('[data-dropzone-ready="true"]').first().waitFor();
|
||||||
|
await dropFiles(page, zipDropzone(page), [
|
||||||
|
{ name: 'archiv-a.zip', mimeType: 'application/zip', buffer: zipA }
|
||||||
|
]);
|
||||||
|
await waitForLoadedWorkspace(page, /1 ZIP · 1 Archiv geöffnet/);
|
||||||
|
await settingsButton(page).click();
|
||||||
|
await expect(page.getByText('201 px')).toBeVisible();
|
||||||
|
await expect(await page.evaluate(() => localStorage.getItem('thumbnailWidth'))).toBe('201');
|
||||||
|
expect(pageErrors).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('settings closes on Escape and outside click and returns focus to its trigger', async ({
|
||||||
|
page
|
||||||
|
}) => {
|
||||||
|
const pageErrors = trackPageErrors(page);
|
||||||
|
const zipA = await createZip('archiv-a');
|
||||||
|
|
||||||
|
await page.goto('/tools/bea');
|
||||||
|
await dropFiles(page, zipDropzone(page), [
|
||||||
|
{ name: 'archiv-a.zip', mimeType: 'application/zip', buffer: zipA }
|
||||||
|
]);
|
||||||
|
await waitForLoadedWorkspace(page, /1 ZIP · 1 Archiv geöffnet/);
|
||||||
|
|
||||||
|
await expect(settingsButton(page)).toHaveAccessibleName('Einstellungen');
|
||||||
|
await settingsButton(page).click();
|
||||||
|
await expect(page.getByText('Vorschaugröße')).toBeVisible();
|
||||||
|
await page.keyboard.press('Escape');
|
||||||
|
await expect(page.getByText('Vorschaugröße')).toHaveCount(0);
|
||||||
|
await expect(settingsButton(page)).toBeFocused();
|
||||||
|
|
||||||
|
await settingsButton(page).click();
|
||||||
|
await expect(page.getByText('Vorschaugröße')).toBeVisible();
|
||||||
|
await page.getByRole('heading', { name: 'beA-Edit' }).click();
|
||||||
|
await expect(page.getByText('Vorschaugröße')).toHaveCount(0);
|
||||||
|
expect(pageErrors).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('mobile toolbar and settings popover stay inside the viewport', async ({ page }) => {
|
||||||
|
const pageErrors = trackPageErrors(page);
|
||||||
|
const zipA = await createZip('archiv-a');
|
||||||
|
|
||||||
|
await page.setViewportSize({ width: 390, height: 844 });
|
||||||
|
await page.goto('/tools/bea');
|
||||||
|
await dropFiles(page, zipDropzone(page), [
|
||||||
|
{ name: 'archiv-a.zip', mimeType: 'application/zip', buffer: zipA }
|
||||||
|
]);
|
||||||
|
await waitForLoadedWorkspace(page, /1 ZIP · 1 Archiv geöffnet/);
|
||||||
|
|
||||||
|
await settingsButton(page).click();
|
||||||
|
const popover = page.locator('[data-slot="popover-content"]');
|
||||||
|
await expect(popover).toBeVisible();
|
||||||
|
await expect(page.getByText('Weitere ZIP hinzufügen')).toBeVisible();
|
||||||
|
|
||||||
|
const popoverBox = await popover.boundingBox();
|
||||||
|
expect(popoverBox).not.toBeNull();
|
||||||
|
expect(popoverBox!.x).toBeGreaterThanOrEqual(0);
|
||||||
|
expect(popoverBox!.x + popoverBox!.width).toBeLessThanOrEqual(390);
|
||||||
|
expect(popoverBox!.y).toBeGreaterThanOrEqual(0);
|
||||||
|
expect(popoverBox!.y + popoverBox!.height).toBeLessThanOrEqual(844);
|
||||||
|
const popoverMetrics = await popover.evaluate((element) => ({
|
||||||
|
scrollWidth: element.scrollWidth,
|
||||||
|
clientWidth: element.clientWidth
|
||||||
|
}));
|
||||||
|
expect(popoverMetrics.scrollWidth).toBeLessThanOrEqual(popoverMetrics.clientWidth);
|
||||||
|
|
||||||
|
// The toolbar sits in normal flow, so it must not cover the archive header.
|
||||||
|
const toolbarBox = await workspaceStatus(page, /1 ZIP · 1 Archiv geöffnet/).boundingBox();
|
||||||
|
const archiveHeaderBox = await page.getByText('1 lose Datei').first().boundingBox();
|
||||||
|
expect(toolbarBox).not.toBeNull();
|
||||||
|
expect(archiveHeaderBox).not.toBeNull();
|
||||||
|
expect(toolbarBox!.y + toolbarBox!.height).toBeLessThanOrEqual(archiveHeaderBox!.y);
|
||||||
|
|
||||||
|
expect(pageErrors).toEqual([]);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user