Compare commits
18
Commits
47ed80bd24
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d2f2953ecb | ||
|
|
44ca534cfa | ||
|
|
0f7bb4b6eb | ||
|
|
52ca2c24f4 | ||
|
|
0b032b2cd8 | ||
|
|
9785973e8b | ||
|
|
1c6d9cce0f | ||
|
|
8a5ee9fe20 | ||
|
|
ef96f523e3 | ||
|
|
e3518eaed0 | ||
|
|
0629ca88c9 | ||
|
|
b400bf2f7c | ||
|
|
33f0804c53 | ||
|
|
1a9f0e876c | ||
|
|
0cbbc6f46d | ||
|
|
d00a7e2783 | ||
|
|
15a3fc5877 | ||
|
|
187f7b3a6a |
@@ -0,0 +1,11 @@
|
|||||||
|
.git
|
||||||
|
.gitignore
|
||||||
|
.idea
|
||||||
|
.vscode
|
||||||
|
.svelte-kit
|
||||||
|
build
|
||||||
|
node_modules
|
||||||
|
npm-debug.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
name: Build and Push Docker Image
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Docker login to Gitea Registry
|
||||||
|
run: |
|
||||||
|
echo "${{ secrets.REGISTRY_PASSWORD }}" | docker login "${{ vars.REGISTRY_URL }}" \
|
||||||
|
-u "${{ secrets.REGISTRY_USER }}" \
|
||||||
|
--password-stdin
|
||||||
|
|
||||||
|
- name: Build Docker image
|
||||||
|
run: |
|
||||||
|
docker build \
|
||||||
|
-t "${{ vars.REGISTRY_URL }}/${{ vars.IMAGE_NAME }}:latest" \
|
||||||
|
-t "${{ vars.REGISTRY_URL }}/${{ vars.IMAGE_NAME }}:${{ gitea.sha }}" \
|
||||||
|
.
|
||||||
|
|
||||||
|
- name: Push Docker image
|
||||||
|
run: |
|
||||||
|
docker push "${{ vars.REGISTRY_URL }}/${{ vars.IMAGE_NAME }}:latest"
|
||||||
|
docker push "${{ vars.REGISTRY_URL }}/${{ vars.IMAGE_NAME }}:${{ gitea.sha }}"
|
||||||
|
|
||||||
|
- name: Deploy
|
||||||
|
run: |
|
||||||
|
curl https://dokploy.robosoft-solutions.de/api/deploy/compose/vETS7jxOixux3wDPobAaL
|
||||||
Generated
+2615
File diff suppressed because it is too large
Load Diff
Generated
+199
-46
@@ -3,32 +3,40 @@
|
|||||||
<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$/.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$/package.json" beforeDir="false" afterPath="$PROJECT_DIR$/package.json" afterDir="false" />
|
||||||
|
<change beforePath="$PROJECT_DIR$/pnpm-lock.yaml" beforeDir="false" afterPath="$PROJECT_DIR$/pnpm-lock.yaml" afterDir="false" />
|
||||||
|
<change beforePath="$PROJECT_DIR$/src/lib/components/ProcessedZipArchiveEditor.svelte" beforeDir="false" afterPath="$PROJECT_DIR$/src/lib/components/ProcessedZipArchiveEditor.svelte" afterDir="false" />
|
||||||
|
<change beforePath="$PROJECT_DIR$/src/lib/zip-processing.ts" beforeDir="false" afterPath="$PROJECT_DIR$/src/lib/zip-processing.ts" afterDir="false" />
|
||||||
|
<change beforePath="$PROJECT_DIR$/vite.config.ts" beforeDir="false" afterPath="$PROJECT_DIR$/vite.config.ts" afterDir="false" />
|
||||||
</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="+page.svelte" />
|
<option value="CSS File" />
|
||||||
|
<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">
|
||||||
@@ -38,23 +46,23 @@
|
|||||||
<component name="MetaFilesCheckinStateConfiguration" checkMetaFiles="true" />
|
<component name="MetaFilesCheckinStateConfiguration" checkMetaFiles="true" />
|
||||||
<component name="NextEditCompletionFeaturesState">
|
<component name="NextEditCompletionFeaturesState">
|
||||||
<decayedCancelled>
|
<decayedCancelled>
|
||||||
<entry key="MS100" value="1.0" />
|
<entry key="MS100" value="1.7534270927402278" />
|
||||||
<entry key="MS500" value="1.000030264792808" />
|
<entry key="MS500" value="3.946749409858449" />
|
||||||
<entry key="S2" value="1.0741710498535333" />
|
<entry key="S2" value="10.803582735333821" />
|
||||||
<entry key="S5" value="1.353259435419701" />
|
<entry key="S5" value="15.959801828989763" />
|
||||||
<entry key="S10" value="1.5943563202488056" />
|
<entry key="S10" value="18.60984028553002" />
|
||||||
<entry key="S30" value="1.8407798506380604" />
|
<entry key="S30" value="20.774011757305356" />
|
||||||
<entry key="S60" value="1.916982485499306" />
|
<entry key="S60" value="21.423894438791837" />
|
||||||
<entry key="M2" value="1.9733993920394513" />
|
<entry key="M2" value="22.530930014590602" />
|
||||||
<entry key="M5" value="2.54046111637059" />
|
<entry key="M5" value="32.256903842215344" />
|
||||||
<entry key="M10" value="3.820331613615542" />
|
<entry key="M10" value="49.10264974172939" />
|
||||||
<entry key="M15" value="4.711774035901717" />
|
<entry key="M15" value="59.99418378616175" />
|
||||||
<entry key="M30" value="6.035032387334216" />
|
<entry key="M30" value="75.77225940331346" />
|
||||||
<entry key="H1" value="6.920684152516748" />
|
<entry key="H1" value="86.24194464495858" />
|
||||||
<entry key="H2" value="7.4336791591637175" />
|
<entry key="H2" value="92.30215756709606" />
|
||||||
<entry key="H4" value="7.709839735759553" />
|
<entry key="H4" value="95.56680916983069" />
|
||||||
<entry key="D1" value="7.950638029700117" />
|
<entry key="D1" value="98.41568550867962" />
|
||||||
<entry key="W1" value="7.992923355805816" />
|
<entry key="W1" value="98.91622423777348" />
|
||||||
</decayedCancelled>
|
</decayedCancelled>
|
||||||
<decayedSelected>
|
<decayedSelected>
|
||||||
<entry key="MS100" value="0.0" />
|
<entry key="MS100" value="0.0" />
|
||||||
@@ -76,23 +84,23 @@
|
|||||||
<entry key="W1" value="0.0" />
|
<entry key="W1" value="0.0" />
|
||||||
</decayedSelected>
|
</decayedSelected>
|
||||||
<decayedShown>
|
<decayedShown>
|
||||||
<entry key="MS100" value="0.004910208494225893" />
|
<entry key="MS100" value="1.572917306599136" />
|
||||||
<entry key="MS500" value="0.3453286512092814" />
|
<entry key="MS500" value="3.853231737204187" />
|
||||||
<entry key="S2" value="0.8228647326727687" />
|
<entry key="S2" value="10.722306786321894" />
|
||||||
<entry key="S5" value="1.215480411189489" />
|
<entry key="S5" value="15.90871853935142" />
|
||||||
<entry key="S10" value="1.5106753869300364" />
|
<entry key="S10" value="18.579462450925384" />
|
||||||
<entry key="S30" value="1.8078926104734718" />
|
<entry key="S30" value="20.762563658810205" />
|
||||||
<entry key="S60" value="1.8997675156293707" />
|
<entry key="S60" value="21.41797857399968" />
|
||||||
<entry key="M2" value="1.9645730736920846" />
|
<entry key="M2" value="22.5278009383226" />
|
||||||
<entry key="M5" value="2.5367044075651064" />
|
<entry key="M5" value="32.254976079390616" />
|
||||||
<entry key="M10" value="3.818241287421449" />
|
<entry key="M10" value="49.101123585535056" />
|
||||||
<entry key="M15" value="4.710284232071638" />
|
<entry key="M15" value="59.99293373937006" />
|
||||||
<entry key="M30" value="6.034216617353951" />
|
<entry key="M30" value="75.77147145663449" />
|
||||||
<entry key="H1" value="6.9202526638434145" />
|
<entry key="H1" value="86.24149819888382" />
|
||||||
<entry key="H2" value="7.433456593321955" />
|
<entry key="H2" value="92.30191941653102" />
|
||||||
<entry key="H4" value="7.709726618573428" />
|
<entry key="H4" value="95.56668611084393" />
|
||||||
<entry key="D1" value="7.9506189104308005" />
|
<entry key="D1" value="98.41566442286562" />
|
||||||
<entry key="W1" value="7.992920617800578" />
|
<entry key="W1" value="98.91622121110338" />
|
||||||
</decayedShown>
|
</decayedShown>
|
||||||
</component>
|
</component>
|
||||||
<component name="ProjectColorInfo">{
|
<component name="ProjectColorInfo">{
|
||||||
@@ -110,15 +118,21 @@
|
|||||||
<component name="PropertiesComponent"><![CDATA[{
|
<component name="PropertiesComponent"><![CDATA[{
|
||||||
"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.git.unshallow": "true",
|
||||||
"RunOnceActivity.readMode.enableVisualFormatting": "true",
|
"RunOnceActivity.readMode.enableVisualFormatting": "true",
|
||||||
"RunOnceActivity.typescript.service.memoryLimit.init": "true",
|
"RunOnceActivity.typescript.service.memoryLimit.init": "true",
|
||||||
"cidr.known.project.marker": "true",
|
"cidr.known.project.marker": "true",
|
||||||
"codeWithMe.voiceChat.enabledByDefault": "false",
|
"codeWithMe.voiceChat.enabledByDefault": "false",
|
||||||
"git-widget-placeholder": "develop",
|
"git-widget-placeholder": "features/shadcn",
|
||||||
"last_opened_file_path": "/home/rehlert/repos/juri-merger",
|
"last_opened_file_path": "/home/rehlert/repos/pulley",
|
||||||
|
"list.type.of.created.stylesheet": "CSS",
|
||||||
|
"node.js.detected.package.eslint": "true",
|
||||||
|
"node.js.detected.package.tslint": "true",
|
||||||
|
"node.js.selected.package.eslint": "(autodetect)",
|
||||||
"node.js.selected.package.tslint": "(autodetect)",
|
"node.js.selected.package.tslint": "(autodetect)",
|
||||||
"nodejs_package_manager_path": "pnpm",
|
"nodejs_package_manager_path": "pnpm",
|
||||||
"settings.editor.selected.configurable": "preferences.pluginManager",
|
"settings.editor.selected.configurable": "preferences.pluginManager",
|
||||||
@@ -134,7 +148,18 @@
|
|||||||
<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="5262000" />
|
<workItem from="1781251694220" duration="22281000" />
|
||||||
|
<workItem from="1781349431536" duration="3602000" />
|
||||||
|
<workItem from="1781378743498" duration="306000" />
|
||||||
|
<workItem from="1781534483559" duration="2103000" />
|
||||||
|
<workItem from="1781597500009" duration="2733000" />
|
||||||
|
<workItem from="1781621908149" duration="4463000" />
|
||||||
|
<workItem from="1781626468843" duration="1890000" />
|
||||||
|
<workItem from="1781673504756" duration="1513000" />
|
||||||
|
<workItem from="1781675346348" duration="3860000" />
|
||||||
|
<workItem from="1781797582572" duration="437000" />
|
||||||
|
<workItem from="1781844676215" duration="5049000" />
|
||||||
|
<workItem from="1782804485415" duration="4386000" />
|
||||||
</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" />
|
||||||
@@ -144,7 +169,111 @@
|
|||||||
<option name="project" value="LOCAL" />
|
<option name="project" value="LOCAL" />
|
||||||
<updated>1781256112218</updated>
|
<updated>1781256112218</updated>
|
||||||
</task>
|
</task>
|
||||||
<option name="localTasksCounter" value="2" />
|
<task id="LOCAL-00002" summary="adds dropzone to add zips">
|
||||||
|
<option name="closed" value="true" />
|
||||||
|
<created>1781257473070</created>
|
||||||
|
<option name="number" value="00002" />
|
||||||
|
<option name="presentableId" value="LOCAL-00002" />
|
||||||
|
<option name="project" value="LOCAL" />
|
||||||
|
<updated>1781257473070</updated>
|
||||||
|
</task>
|
||||||
|
<task id="LOCAL-00003" summary="zips are getting extracted">
|
||||||
|
<option name="closed" value="true" />
|
||||||
|
<created>1781258303003</created>
|
||||||
|
<option name="number" value="00003" />
|
||||||
|
<option name="presentableId" value="LOCAL-00003" />
|
||||||
|
<option name="project" value="LOCAL" />
|
||||||
|
<updated>1781258303003</updated>
|
||||||
|
</task>
|
||||||
|
<task id="LOCAL-00004" summary="parses xjustiz and orders attachments">
|
||||||
|
<option name="closed" value="true" />
|
||||||
|
<created>1781259433444</created>
|
||||||
|
<option name="number" value="00004" />
|
||||||
|
<option name="presentableId" value="LOCAL-00004" />
|
||||||
|
<option name="project" value="LOCAL" />
|
||||||
|
<updated>1781259433444</updated>
|
||||||
|
</task>
|
||||||
|
<task id="LOCAL-00005" summary="renders preview of zips">
|
||||||
|
<option name="closed" value="true" />
|
||||||
|
<created>1781260304085</created>
|
||||||
|
<option name="number" value="00005" />
|
||||||
|
<option name="presentableId" value="LOCAL-00005" />
|
||||||
|
<option name="project" value="LOCAL" />
|
||||||
|
<updated>1781260304085</updated>
|
||||||
|
</task>
|
||||||
|
<task id="LOCAL-00006" summary="design anpassungen">
|
||||||
|
<option name="closed" value="true" />
|
||||||
|
<created>1781261986439</created>
|
||||||
|
<option name="number" value="00006" />
|
||||||
|
<option name="presentableId" value="LOCAL-00006" />
|
||||||
|
<option name="project" value="LOCAL" />
|
||||||
|
<updated>1781261986439</updated>
|
||||||
|
</task>
|
||||||
|
<task id="LOCAL-00007" summary="renders thumbnails">
|
||||||
|
<option name="closed" value="true" />
|
||||||
|
<created>1781263980882</created>
|
||||||
|
<option name="number" value="00007" />
|
||||||
|
<option name="presentableId" value="LOCAL-00007" />
|
||||||
|
<option name="project" value="LOCAL" />
|
||||||
|
<updated>1781263980882</updated>
|
||||||
|
</task>
|
||||||
|
<task id="LOCAL-00008" summary="ignores mac special folders">
|
||||||
|
<option name="closed" value="true" />
|
||||||
|
<created>1781264183729</created>
|
||||||
|
<option name="number" value="00008" />
|
||||||
|
<option name="presentableId" value="LOCAL-00008" />
|
||||||
|
<option name="project" value="LOCAL" />
|
||||||
|
<updated>1781264183729</updated>
|
||||||
|
</task>
|
||||||
|
<task id="LOCAL-00009" summary="handles zips in zip correctly">
|
||||||
|
<option name="closed" value="true" />
|
||||||
|
<created>1781264568851</created>
|
||||||
|
<option name="number" value="00009" />
|
||||||
|
<option name="presentableId" value="LOCAL-00009" />
|
||||||
|
<option name="project" value="LOCAL" />
|
||||||
|
<updated>1781264568851</updated>
|
||||||
|
</task>
|
||||||
|
<task id="LOCAL-00010" summary="range settings">
|
||||||
|
<option name="closed" value="true" />
|
||||||
|
<created>1781267754325</created>
|
||||||
|
<option name="number" value="00010" />
|
||||||
|
<option name="presentableId" value="LOCAL-00010" />
|
||||||
|
<option name="project" value="LOCAL" />
|
||||||
|
<updated>1781267754325</updated>
|
||||||
|
</task>
|
||||||
|
<task id="LOCAL-00011" summary="name generation improvement">
|
||||||
|
<option name="closed" value="true" />
|
||||||
|
<created>1781268046478</created>
|
||||||
|
<option name="number" value="00011" />
|
||||||
|
<option name="presentableId" value="LOCAL-00011" />
|
||||||
|
<option name="project" value="LOCAL" />
|
||||||
|
<updated>1781268046478</updated>
|
||||||
|
</task>
|
||||||
|
<task id="LOCAL-00012" summary="The git changes introduce functionality to merge attachments from a processed ZIP archive into a single PDF file. Here is a summary of the changes: ### New Features - PDF Merging: Added the ability to merge multiple attachments (PDFs and images) from a processed ZIP archive into a single PDF document. - Image Conversion: Implemented automatic conversion of non-PNG images (like JPGs) to PNG format before embedding them into the merged PDF. ### File Changes - Dependencies: Added pdf-lib to the project's dependencies. - src/lib/zip-processing.ts: - Added mergeProcessedZipArchive to handle the core logic of creating a merged PDF. - Implemented helpers to embed PDF pages and convert/embed images. - Added toArrayBuffer and convertImageBytesToPng utilities for handling binary data and canvas-based image processing. - src/lib/components/ProcessedZipArchiveEditor.svelte: - Added a "PDF herunterladen" (Download PDF) button. - Added UI states to handle the merging process (loading state, error messages). - Integrated the mergeProcessedZipArchive function with the UI. - src/lib/components/AttachmentPreview.svelte: - Added a fix to ensure attachment.data is treated as a Uint8Array when loading PDF documents.">
|
||||||
|
<option name="closed" value="true" />
|
||||||
|
<created>1781350375505</created>
|
||||||
|
<option name="number" value="00012" />
|
||||||
|
<option name="presentableId" value="LOCAL-00012" />
|
||||||
|
<option name="project" value="LOCAL" />
|
||||||
|
<updated>1781350375505</updated>
|
||||||
|
</task>
|
||||||
|
<task id="LOCAL-00013" summary="adds shadcn, removes skeleton, implements button after styleguide">
|
||||||
|
<option name="closed" value="true" />
|
||||||
|
<created>1781626078662</created>
|
||||||
|
<option name="number" value="00013" />
|
||||||
|
<option name="presentableId" value="LOCAL-00013" />
|
||||||
|
<option name="project" value="LOCAL" />
|
||||||
|
<updated>1781626078662</updated>
|
||||||
|
</task>
|
||||||
|
<task id="LOCAL-00014" summary="adds icons">
|
||||||
|
<option name="closed" value="true" />
|
||||||
|
<created>1781845554523</created>
|
||||||
|
<option name="number" value="00014" />
|
||||||
|
<option name="presentableId" value="LOCAL-00014" />
|
||||||
|
<option name="project" value="LOCAL" />
|
||||||
|
<updated>1781845554523</updated>
|
||||||
|
</task>
|
||||||
|
<option name="localTasksCounter" value="15" />
|
||||||
<servers />
|
<servers />
|
||||||
</component>
|
</component>
|
||||||
<component name="TypeScriptGeneratedFilesManager">
|
<component name="TypeScriptGeneratedFilesManager">
|
||||||
@@ -152,10 +281,34 @@
|
|||||||
</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" />
|
||||||
<option name="LAST_COMMIT_MESSAGE" value="adapts styling of rvg.legal" />
|
<MESSAGE value="adds dropzone to add zips" />
|
||||||
|
<MESSAGE value="zips are getting extracted" />
|
||||||
|
<MESSAGE value="parses xjustiz and orders attachments" />
|
||||||
|
<MESSAGE value="renders preview of zips" />
|
||||||
|
<MESSAGE value="design anpassungen" />
|
||||||
|
<MESSAGE value="renders thumbnails" />
|
||||||
|
<MESSAGE value="ignores mac special folders" />
|
||||||
|
<MESSAGE value="handles zips in zip correctly" />
|
||||||
|
<MESSAGE value="range settings" />
|
||||||
|
<MESSAGE value="name generation improvement" />
|
||||||
|
<MESSAGE value="The git changes introduce functionality to merge attachments from a processed ZIP archive into a single PDF file. Here is a summary of the changes: ### New Features - PDF Merging: Added the ability to merge multiple attachments (PDFs and images) from a processed ZIP archive into a single PDF document. - Image Conversion: Implemented automatic conversion of non-PNG images (like JPGs) to PNG format before embedding them into the merged PDF. ### File Changes - Dependencies: Added pdf-lib to the project's dependencies. - src/lib/zip-processing.ts: - Added mergeProcessedZipArchive to handle the core logic of creating a merged PDF. - Implemented helpers to embed PDF pages and convert/embed images. - Added toArrayBuffer and convertImageBytesToPng utilities for handling binary data and canvas-based image processing. - src/lib/components/ProcessedZipArchiveEditor.svelte: - Added a "PDF herunterladen" (Download PDF) button. - Added UI states to handle the merging process (loading state, error messages). - Integrated the mergeProcessedZipArchive function with the UI. - src/lib/components/AttachmentPreview.svelte: - Added a fix to ensure attachment.data is treated as a Uint8Array when loading PDF documents." />
|
||||||
|
<MESSAGE value="adds shadcn, removes skeleton, implements button after styleguide" />
|
||||||
|
<MESSAGE value="adds icons" />
|
||||||
|
<option name="LAST_COMMIT_MESSAGE" value="adds icons" />
|
||||||
</component>
|
</component>
|
||||||
<component name="XDebuggerManager">
|
<component name="XDebuggerManager">
|
||||||
<breakpoint-manager>
|
<breakpoint-manager>
|
||||||
|
|||||||
+29
@@ -0,0 +1,29 @@
|
|||||||
|
# syntax=docker/dockerfile:1
|
||||||
|
|
||||||
|
FROM node:24-alpine AS base
|
||||||
|
ENV PNPM_HOME="/pnpm"
|
||||||
|
ENV PATH="$PNPM_HOME:$PATH"
|
||||||
|
RUN corepack enable && corepack prepare pnpm@11.15.1 --activate
|
||||||
|
|
||||||
|
FROM base AS dependencies
|
||||||
|
WORKDIR /app
|
||||||
|
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml .npmrc ./
|
||||||
|
RUN pnpm install --frozen-lockfile
|
||||||
|
|
||||||
|
FROM dependencies AS build
|
||||||
|
COPY . .
|
||||||
|
RUN pnpm build && pnpm prune --prod --ignore-scripts
|
||||||
|
|
||||||
|
FROM node:24-alpine AS runtime
|
||||||
|
WORKDIR /app
|
||||||
|
ENV NODE_ENV="production"
|
||||||
|
ENV HOST="0.0.0.0"
|
||||||
|
ENV PORT="3000"
|
||||||
|
|
||||||
|
COPY --from=build --chown=node:node /app/package.json ./
|
||||||
|
COPY --from=build --chown=node:node /app/node_modules ./node_modules
|
||||||
|
COPY --from=build --chown=node:node /app/build ./build
|
||||||
|
|
||||||
|
USER node
|
||||||
|
EXPOSE 3000
|
||||||
|
CMD ["node", "build/index.js"]
|
||||||
@@ -1,126 +1,93 @@
|
|||||||
# Project Agent Guide
|
# Project notes for agents
|
||||||
|
|
||||||
## Project overview
|
## Project snapshot
|
||||||
|
- **Repo name:** `juri-merger`
|
||||||
|
- **App name/branding:** `beA-Edit`, published by Nominandum GmbH.
|
||||||
|
- **Tech stack:** SvelteKit + Svelte 5 (runes) + TypeScript + Vite + Tailwind v4 (CSS-first).
|
||||||
|
- **UI system:** shadcn-svelte (style `nova`, lucide icons) layered on a custom "nom" theme via CSS variables in `src/routes/layout.css`.
|
||||||
|
- **Core purpose:** upload one or more ZIP files, extract relevant PDFs/images, preview them, optionally reorder attachments, and download a merged PDF.
|
||||||
|
- **Active branch:** `features/shadcn` (shadcn-svelte integration in progress).
|
||||||
|
|
||||||
This repository contains **juri-merger**, a German-language SvelteKit application whose current UI is branded as **beA-Edit**. The main route lets users select or drag ZIP files in the browser. It currently displays the selected filenames; no server-side upload or archive-processing flow is present in this version.
|
## Main user flow
|
||||||
|
1. User adds ZIP files via drag/drop or file picker (`ZipDropzone`).
|
||||||
|
2. `+page.svelte` starts async extraction for each file (tracked via `archiveGeneration` so stale in-flight extractions are discarded on bulk delete).
|
||||||
|
3. Each ZIP is processed into one or more `ProcessedZipArchive` entries.
|
||||||
|
4. `ProcessedZipArchiveEditor` shows thumbnails, allows reordering, and can download the merged PDF.
|
||||||
|
5. `AttachmentPreview` renders image previews directly and PDF previews via `pdfjs-dist`.
|
||||||
|
6. A fixed bottom-right settings drawer (`<details>`) exposes **export all** and **delete all** archive actions plus the thumbnail-width slider.
|
||||||
|
|
||||||
The application also contains German legal pages for the imprint and privacy policy.
|
## Important source files
|
||||||
|
- `src/routes/+page.svelte` — top-level page state, file selection, localStorage-backed thumbnail width, async ZIP processing, bulk export/delete, settings drawer.
|
||||||
|
- `src/routes/+layout.svelte` — app shell: header with logo and footer with Nominandum GmbH legal/imprint block.
|
||||||
|
- `src/routes/layout.css` — Tailwind v4 entrypoint; imports `tw-animate-css`, `shadcn-svelte/tailwind.css`, Geist + Nunito fonts; defines the semantic token layer (`--background`, `--primary`, `--secondary`, `--accent`, `--destructive`, `--radius`, etc.) mapping shadcn tokens onto the nom palette, including a `.dark` override.
|
||||||
|
- `src/lib/zip-processing.ts` — archive extraction, recursive nested ZIP handling, metadata ordering, PDF merge/export, image conversion.
|
||||||
|
- `src/lib/services/zip-inflating.service.ts` — thin wrapper around `fflate` unzip.
|
||||||
|
- `src/lib/services/xml-reading.service.ts` — parses XJustiz XML metadata (`sender`, `receiver`, `documentNames`).
|
||||||
|
- `src/lib/components/ZipDropzone.svelte` — file picker + drag/drop ZIP intake.
|
||||||
|
- `src/lib/components/ProcessedZipArchiveEditor.svelte` — archive editor (name input, drag reordering, per-archive PDF download, selection mode + sub-document management, export-mode switch).
|
||||||
|
- `src/lib/components/SubDocumentEditor.svelte` — sub-document block editor (editable name, count, dissolve action, draggable header for block reorder, internal horizontal reorderable attachment list).
|
||||||
|
- `src/lib/components/AttachmentPreview.svelte` — preview generation for images/PDFs.
|
||||||
|
- `src/lib/components/ui/button.svelte` — shadcn-backed button atom (see "Button system" below).
|
||||||
|
- `src/lib/components/ui/card/` — shadcn card composition (`Root/Content/Header/Title/Description/Footer/Action`, re-exported as `Card.*`).
|
||||||
|
- `src/lib/actions/ripple.ts` — Svelte action that renders a click ripple animation on an element.
|
||||||
|
- `src/lib/components/icons/` — hand-rolled SVG icon components (`IconSettings`, `IconFile`, `IconMenu`, `IconNeedle`, `IconOffice`, `IconPerson`, `IconTag`, `IconTrash`, `IconChain`, `IconUnknown`). If an icon is missing, report it, and we see if we can create
|
||||||
|
- `src/lib/utils.ts` — shared `cn()` helper (clsx + tailwind-merge) and `WithElementRef`/`WithoutChild` typing helpers used by shadcn components.
|
||||||
|
|
||||||
## Technology stack
|
## Button system
|
||||||
|
`src/lib/components/ui/button.svelte` is a shadcn-svelte–style primitive adapted to the nom theme:
|
||||||
|
- Built with `tailwind-variants` (`buttonVariants`) and the shared `cn()` helper.
|
||||||
|
- Variants: `primary`, `secondary` (gradient `secondary → --secondary-end`), `bordered`, `tertiary`, `success`.
|
||||||
|
- Sizes: `standard` (h-8, rounded-16px), `large` (h-12, rounded-24px).
|
||||||
|
- Renders either a `<button>` or an `<a>` (when `href` is set); both apply the `use:ripple` action.
|
||||||
|
- Accepts an optional `icon` prop (a Svelte component) rendered after `children`.
|
||||||
|
- `ButtonProps` merges `HTMLButtonAttributes` + `HTMLAnchorAttributes` with `WithElementRef` and `variant`/`size`/`icon`.
|
||||||
|
- Used across the editor, settings drawer, and the throwaway test buttons on the home `<section>` (still present on the branch).
|
||||||
|
|
||||||
- SvelteKit 2
|
## ZIP/PDF processing details
|
||||||
- Svelte 5 with runes mode forced for project source files
|
- ZIP entries are filtered to ignore macOS metadata (`__MACOSX`, `.DS_Store`, `._*`).
|
||||||
- TypeScript with 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.
|
||||||
- Skeleton 4 with a custom `nominandum` theme
|
- Ordering prefers document names from metadata before falling back to original attachment order.
|
||||||
- pnpm and `pnpm-lock.yaml`
|
- PDF merging uses `pdf-lib`; images are embedded as PNG/JPG, with browser-side canvas conversion for other image types.
|
||||||
- `@sveltejs/adapter-auto`
|
- **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.
|
||||||
- Prettier with the Svelte and Tailwind plugins
|
- The merge loop is shared via `mergeAttachments(attachments)`; `mergeProcessedZipArchive` flattens the archive via `flattenArchiveForMerge` (sub-documents first, then loose) so backwards compatibility holds.
|
||||||
|
- Pure helpers in `zip-processing.ts`: `createSubDocument`, `moveSubDocument`, `moveAttachmentInPlace`, `moveAttachmentIntoSubDocument`, `removeAttachmentFromSubDocument`, `dissolveSubDocument`, `flattenArchiveForMerge`, `buildArchiveExport(archive, 'single' | 'separate')`. Cross-list moves preserve the total attachment count invariant.
|
||||||
|
- **Export modes:** `single` flips the whole archive into one PDF (sub-docs flattened first); `separate` produces one PDF per non-empty sub-document (`{archiveName} - {subName}.pdf`) plus one for any residual loose attachments.
|
||||||
|
|
||||||
Use **pnpm**, not npm or yarn, when installing packages or running project scripts.
|
## UI/state details
|
||||||
|
- Thumbnail width is stored in `localStorage` under `thumbnailWidth` (key constant `THUMBNAIL_WIDTH_STORAGE_KEY`) and clamped to `100..400`; hydration is gated by a `thumbnailWidthHydrated` flag so the initial render does not fight `onMount`.
|
||||||
|
- Processing progress is shown as a count of pending ZIP files (`pendingZipCount`).
|
||||||
|
- The editor supports manual attachment reordering via drag-and-drop.
|
||||||
|
- The settings drawer contains: "Alle ZIP-Archive als PDF exportieren", "Alle ZIP-Archive löschen", and the thumbnail-width range input.
|
||||||
|
- Bulk delete bumps `archiveGeneration` so otherwise-orphaned async extractions no-op instead of re-populating state.
|
||||||
|
- **Sub-document editing (`ProcessedZipArchiveEditor.svelte`):**
|
||||||
|
- An "Auswählen" / "Fertig" toggle in the header puts the editor into selection mode. Only then are checkboxes rendered on each loose thumbnail (wrapped in a `<label>` for keyboard operability — Tab + Space); in normal mode the editor looks identical to today. `Esc` exits selection mode and clears the selection. Drag-to-reorder is suspended while in selection mode.
|
||||||
|
- An inline action bar with "Teildokument erstellen" appears whenever `selectionMode && selectedPaths.size > 0`. Creating a sub-document removes the selected attachments from the loose list, appends a new `SubDocument` block (named "Teildokument N"), clears the selection, and keeps selection mode on.
|
||||||
|
- Sub-documents render below the loose list as a vertical stack of `SubDocumentEditor` blocks. Each block has an editable name, a count, an "Auflösen" action (returns its attachments to the end of the loose list), a draggable header (`::`) to reorder the block, and its own horizontal reorderable attachment list.
|
||||||
|
- Drag-and-drop is generalised: HTML5 DnD payloads use MIME type `application/x-juri-merger-attachment` (carrying a `AttachmentLocation` of `{ kind: 'loose', index }` or `{ kind: 'subDocument', id, index }`) for attachment moves, and `application/x-juri-merger-block` for reordering whole sub-document blocks. Supported moves: loose↔loose, loose→sub, sub→loose, sub attachment↔sub attachment (same or different sub), and sub-document block↔sub-document block.
|
||||||
|
- Export control: with no sub-documents, the existing single "PDF herunterladen" button behaves unchanged. With sub-documents present, a segmented control ("Eine PDF" / "Getrennt") lets the user choose the export mode; "Eine PDF" downloads one flattened PDF, "Getrennt" downloads one PDF per sub-document sequentially.
|
||||||
|
- **`SubDocumentEditor.svelte`** is the new sub-document block component. It emits `onSubDocumentChange`, `onRemove`, `onAttachmentMove`, and block-level drag callbacks up to the parent; the parent stays the source of truth via `onArchiveChange`.
|
||||||
|
|
||||||
## Commands
|
## Dependencies worth noting
|
||||||
|
- `fflate` for ZIP extraction
|
||||||
|
- `pdf-lib` for PDF generation/merging
|
||||||
|
- `pdfjs-dist` for PDF thumbnail previews
|
||||||
|
- `bits-ui`, `shadcn-svelte` (CLI/runtime), `clsx`, `tailwind-merge`, `class-variance-authority`, `tailwind-variants`, `tw-animate-css` for the shadcn-svelte UI layer
|
||||||
|
- `@lucide/svelte` and a set of local SVG icon components for iconography
|
||||||
|
- `@fontsource-variable/geist` font; custom `Nunito` variable font bundled under `src/lib/assets/`
|
||||||
|
- `@tailwindcss/vite`, `@tailwindcss/forms`, `@tailwindcss/typography` for styling
|
||||||
|
|
||||||
```sh
|
## shadcn-svelte configuration
|
||||||
pnpm install # Install dependencies
|
- `components.json`: style `nova`, `iconLibrary` `lucide`, `baseColor` `neutral`, Tailwind CSS path `src/routes/layout.css`.
|
||||||
pnpm dev # Start the development server
|
- Aliases: `components → $lib/components`, `ui → $lib/components/ui`, `utils → $lib/utils`, `hooks → $lib/hooks`, `lib → $lib`.
|
||||||
pnpm build # Create a production build
|
- `src/lib/hooks/` exists but is currently empty.
|
||||||
pnpm preview # Preview the production build
|
- Generated components live under `src/lib/components/ui/`; `button.svelte` is the canonical adapted example (custom variants + ripple + icon prop).
|
||||||
pnpm check # Run Svelte and TypeScript diagnostics
|
|
||||||
pnpm check:watch # Run diagnostics in watch mode
|
|
||||||
pnpm lint # Check formatting with Prettier
|
|
||||||
pnpm format # Format the repository with Prettier
|
|
||||||
```
|
|
||||||
|
|
||||||
There is currently no automated unit or end-to-end test suite. For code changes, run at least:
|
## Current working tree observations
|
||||||
|
- Branch is `features/shadcn`.
|
||||||
```sh
|
- `agents.md` is being updated to match the post-shadcn-integration state.
|
||||||
pnpm check
|
- `plan/shadcn-svelte-plan.md` tracks the in-progress migration (only `button` + `card` generated so far; broader UI replacement is still optional/incremental).
|
||||||
pnpm lint
|
- Untracked: `plan/`, and several icons under `src/lib/components/icons/` (`IconChain`, `IconFile`, `IconMenu`, `IconNeedle`, `IconOffice`, `IconPerson`, `IconTag`, `IconTrash`, `IconUnknown`).
|
||||||
pnpm build
|
- Modified (uncommitted): `agents.md`, `src/lib/components/icons/IconSettings.svelte`, `src/lib/components/ui/button.svelte`, `src/routes/+page.svelte`, `.idea/workspace.xml`.
|
||||||
```
|
- `src/lib/services/` is now committed (was previously untracked).
|
||||||
|
|
||||||
If pre-existing warnings remain outside the files being changed, report them rather than making unrelated edits.
|
|
||||||
|
|
||||||
## Repository structure
|
|
||||||
|
|
||||||
```text
|
|
||||||
src/
|
|
||||||
├── app.html # German document shell and Skeleton theme selection
|
|
||||||
├── app.d.ts # SvelteKit application type declarations
|
|
||||||
├── lib/
|
|
||||||
│ ├── assets/ # Bundled favicon and Nunito font files
|
|
||||||
│ ├── components/ # Reusable Svelte components
|
|
||||||
│ │ ├── Button.svelte
|
|
||||||
│ │ ├── Card.svelte
|
|
||||||
│ │ └── ZipDropzone.svelte
|
|
||||||
│ ├── index.ts # Public `$lib` exports
|
|
||||||
│ └── nom-theme.css # Custom Skeleton theme tokens
|
|
||||||
└── routes/
|
|
||||||
├── +layout.svelte # Shared logo header and branded footer
|
|
||||||
├── +page.svelte # Main ZIP-selection page
|
|
||||||
├── layout.css # Tailwind/Skeleton imports and global font setup
|
|
||||||
├── datenschutz/+page.svelte # Privacy policy
|
|
||||||
└── impressum/+page.svelte # Imprint
|
|
||||||
static/ # Files served unchanged from the site root
|
|
||||||
```
|
|
||||||
|
|
||||||
Generated directories such as `.svelte-kit/`, `build/`, and `node_modules/` must not be edited or committed.
|
|
||||||
|
|
||||||
## Svelte conventions
|
|
||||||
|
|
||||||
- Use Svelte 5 runes and current event syntax. Prefer `$props()`, `$state`, `$derived`, snippets, `{@render ...}`, and handlers such as `onclick` over 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 communication, as `ZipDropzone.svelte` does with `onFilesSelected`.
|
|
||||||
- Use `$lib/...` for imports from `src/lib` rather than long relative paths.
|
|
||||||
- Name reusable component files in PascalCase and route files according to SvelteKit conventions.
|
|
||||||
- Keep browser-only APIs such as `File`, `FileList`, and drag-and-drop handling in components that execute in the browser. Do not introduce server-side upload behavior unless requested.
|
|
||||||
- Preserve semantic HTML. Interactive drop zones must remain keyboard-operable buttons with an accessible name.
|
|
||||||
|
|
||||||
The Vite configuration forces runes mode for application files, so do not add legacy Svelte component patterns.
|
|
||||||
|
|
||||||
## Styling conventions
|
|
||||||
|
|
||||||
Use **Tailwind utility classes in Svelte markup** for component and route styling. Orient new work around the existing classes in `+layout.svelte`, `+page.svelte`, `Card.svelte`, and `ZipDropzone.svelte`.
|
|
||||||
|
|
||||||
- Prefer existing theme utilities such as `bg-primary-50`, `text-primary-900`, `border-primary-500`, and `rounded-container`.
|
|
||||||
- Use the custom `primary`, `secondary`, `tertiary`, and `surface` scales defined in `src/lib/nom-theme.css` instead of adding arbitrary brand colors.
|
|
||||||
- Keep the established visual language: cool blue-gray page background, white cards, dark blue headings, subtle shadows, rounded containers, and the Nunito typeface.
|
|
||||||
- Use responsive Tailwind variants and ensure layouts work on narrow screens.
|
|
||||||
- Prefer utilities over new component-level `<style>` blocks or inline styles. Add global CSS only when defining shared theme primitives, imports, font faces, or behavior that cannot reasonably be expressed with utilities.
|
|
||||||
- Tailwind 4 is configured through Vite and `src/routes/layout.css`; there is no `tailwind.config` file.
|
|
||||||
- Keep complete utility class names visible to Tailwind. For variants, map each state to complete class strings rather than constructing fragments such as `text-${color}`.
|
|
||||||
- Preserve clear focus states, adequate contrast, and visible drag-and-drop feedback.
|
|
||||||
|
|
||||||
`src/app.html` applies `data-theme="nominandum"`. Theme-level changes belong in `src/lib/nom-theme.css`; global Tailwind and Skeleton imports belong in `src/routes/layout.css`.
|
|
||||||
|
|
||||||
## 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 the request explicitly asks for content changes.
|
|
||||||
- Keep the shared Nominandum logo, footer artwork, and theme consistent across routes.
|
|
||||||
- ZIP selection is client-side and accepts `.zip`, `application/zip`, and `application/x-zip-compressed` files.
|
|
||||||
- Resetting the file input after selection is intentional so the same archive can be selected twice.
|
|
||||||
- Do not add network requests, file uploads, analytics, or persistence without an explicit requirement.
|
|
||||||
|
|
||||||
## Change discipline
|
|
||||||
|
|
||||||
- Keep changes scoped to the request and preserve unrelated work, including untracked files.
|
|
||||||
- Inspect existing components and theme tokens before introducing new abstractions.
|
|
||||||
- Do not hand-edit generated output or lockfile contents. Update the lockfile only through pnpm when dependency changes are required.
|
|
||||||
- Avoid adding dependencies when the task can be completed with Svelte, browser APIs, Tailwind, or existing Skeleton packages.
|
|
||||||
- After implementation, review the changed files, run the relevant checks, and mention any remaining warnings or limitations.
|
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://shadcn-svelte.com/schema.json",
|
||||||
|
"tailwind": {
|
||||||
|
"css": "src/routes/layout.css",
|
||||||
|
"baseColor": "neutral"
|
||||||
|
},
|
||||||
|
"aliases": {
|
||||||
|
"components": "$lib/components",
|
||||||
|
"utils": "$lib/utils",
|
||||||
|
"ui": "$lib/components/ui",
|
||||||
|
"hooks": "$lib/hooks",
|
||||||
|
"lib": "$lib"
|
||||||
|
},
|
||||||
|
"typescript": true,
|
||||||
|
"registry": "https://shadcn-svelte.com/registry",
|
||||||
|
"style": "nova",
|
||||||
|
"iconLibrary": "lucide",
|
||||||
|
"menuColor": "default",
|
||||||
|
"menuAccent": "subtle"
|
||||||
|
}
|
||||||
+13
-3
@@ -14,23 +14,33 @@
|
|||||||
"format": "prettier --write ."
|
"format": "prettier --write ."
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@sveltejs/adapter-auto": "^7.0.1",
|
"@fontsource-variable/geist": "^5.2.9",
|
||||||
|
"@lucide/svelte": "^1.18.0",
|
||||||
|
"@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",
|
||||||
|
"bits-ui": "2.18.1",
|
||||||
|
"class-variance-authority": "0.7.1",
|
||||||
|
"clsx": "2.1.1",
|
||||||
"prettier": "^3.8.3",
|
"prettier": "^3.8.3",
|
||||||
"prettier-plugin-svelte": "^4.1.0",
|
"prettier-plugin-svelte": "^4.1.0",
|
||||||
"prettier-plugin-tailwindcss": "^0.8.0",
|
"prettier-plugin-tailwindcss": "^0.8.0",
|
||||||
|
"shadcn-svelte": "1.3.0",
|
||||||
"svelte": "^5.56.1",
|
"svelte": "^5.56.1",
|
||||||
"svelte-check": "^4.6.0",
|
"svelte-check": "^4.6.0",
|
||||||
|
"tailwind-merge": "3.6.0",
|
||||||
|
"tailwind-variants": "^3.2.2",
|
||||||
"tailwindcss": "^4.3.0",
|
"tailwindcss": "^4.3.0",
|
||||||
|
"tw-animate-css": "^1.4.0",
|
||||||
"typescript": "^6.0.3",
|
"typescript": "^6.0.3",
|
||||||
"vite": "^8.0.16"
|
"vite": "^8.0.16"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@skeletonlabs/skeleton": "^4.15.2",
|
"fflate": "^0.8.3",
|
||||||
"@skeletonlabs/skeleton-svelte": "^4.15.2"
|
"pdf-lib": "^1.17.1",
|
||||||
|
"pdfjs-dist": "^6.0.227"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,256 @@
|
|||||||
|
# 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).
|
||||||
Generated
+781
-546
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,31 @@
|
|||||||
|
export function ripple(node: HTMLElement) {
|
||||||
|
function handleClick(event: MouseEvent) {
|
||||||
|
const circle = document.createElement("span");
|
||||||
|
|
||||||
|
const rect = node.getBoundingClientRect();
|
||||||
|
const size = Math.max(rect.width, rect.height);
|
||||||
|
|
||||||
|
circle.style.width = `${size}px`;
|
||||||
|
circle.style.height = `${size}px`;
|
||||||
|
|
||||||
|
circle.style.left = `${event.clientX - rect.left - size / 2}px`;
|
||||||
|
circle.style.top = `${event.clientY - rect.top - size / 2}px`;
|
||||||
|
|
||||||
|
circle.className =
|
||||||
|
"absolute rounded-full bg-white/30 animate-ripple pointer-events-none";
|
||||||
|
|
||||||
|
node.appendChild(circle);
|
||||||
|
|
||||||
|
circle.addEventListener("animationend", () => {
|
||||||
|
circle.remove();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
node.addEventListener("click", handleClick);
|
||||||
|
|
||||||
|
return {
|
||||||
|
destroy() {
|
||||||
|
node.removeEventListener("click", handleClick);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,129 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { onMount } from "svelte";
|
||||||
|
|
||||||
|
import type {ZipAttachment} from "$lib/zip-processing";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
attachment: ZipAttachment;
|
||||||
|
class?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
let { attachment, class: className = "" }: Props = $props();
|
||||||
|
|
||||||
|
let imageUrl = $state<string | null>(null);
|
||||||
|
let previewError = $state<string | null>(null);
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
let objectUrl: string | null = null;
|
||||||
|
let cancelled = false;
|
||||||
|
|
||||||
|
const revokeObjectUrl = () => {
|
||||||
|
if (objectUrl) {
|
||||||
|
URL.revokeObjectURL(objectUrl);
|
||||||
|
objectUrl = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const setBlobImage = (blob: Blob) => {
|
||||||
|
revokeObjectUrl();
|
||||||
|
objectUrl = URL.createObjectURL(blob);
|
||||||
|
imageUrl = objectUrl;
|
||||||
|
};
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
const pdfjsLib = await import("pdfjs-dist");
|
||||||
|
const { getDocument, GlobalWorkerOptions } = pdfjsLib;
|
||||||
|
|
||||||
|
GlobalWorkerOptions.workerSrc = await import("pdfjs-dist/build/pdf.worker.mjs?url").then((m) => m.default);
|
||||||
|
|
||||||
|
const loadPreview = async () => {
|
||||||
|
previewError = null;
|
||||||
|
imageUrl = null;
|
||||||
|
|
||||||
|
if (attachment.kind === "image") {
|
||||||
|
const imageBuffer = attachment.data.buffer.slice(
|
||||||
|
attachment.data.byteOffset,
|
||||||
|
attachment.data.byteOffset + attachment.data.byteLength,
|
||||||
|
) as ArrayBuffer;
|
||||||
|
setBlobImage(new Blob([imageBuffer]));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// create a copy, getDocument seems to manipulate the data -> creating pdfs fails
|
||||||
|
const buffer = new Uint8Array(attachment.data);
|
||||||
|
const loadingTask = getDocument({ data: buffer });
|
||||||
|
const pdfDocument = await loadingTask.promise;
|
||||||
|
const page = await pdfDocument.getPage(1);
|
||||||
|
|
||||||
|
if (cancelled) {
|
||||||
|
void loadingTask.destroy();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const viewport = page.getViewport({ scale: 1 });
|
||||||
|
const canvasElement = document.createElement("canvas");
|
||||||
|
|
||||||
|
canvasElement.width = viewport.width;
|
||||||
|
canvasElement.height = viewport.height;
|
||||||
|
|
||||||
|
const canvasContext = canvasElement.getContext("2d");
|
||||||
|
|
||||||
|
if (!canvasContext) {
|
||||||
|
throw new Error("Could not create thumbnail canvas context.");
|
||||||
|
}
|
||||||
|
|
||||||
|
await page.render({ canvas: canvasElement, canvasContext, viewport }).promise;
|
||||||
|
|
||||||
|
if (cancelled) {
|
||||||
|
void loadingTask.destroy();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const blob = await new Promise<Blob>((resolve, reject) => {
|
||||||
|
canvasElement.toBlob((result) => {
|
||||||
|
if (result) {
|
||||||
|
resolve(result);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
reject(new Error("Could not create thumbnail blob."));
|
||||||
|
}, "image/png");
|
||||||
|
});
|
||||||
|
|
||||||
|
setBlobImage(blob);
|
||||||
|
|
||||||
|
await loadingTask.destroy();
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Failed to render preview for ${attachment.path}`, error);
|
||||||
|
previewError = "PDF-Vorschau konnte nicht geladen werden.";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
void loadPreview();
|
||||||
|
})();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
revokeObjectUrl();
|
||||||
|
};
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class={className}>
|
||||||
|
{#if previewError}
|
||||||
|
<div class="flex h-full min-h-0 items-center justify-center rounded-md border border-dashed border-primary-200 bg-primary-50 px-3 py-4 text-center text-[12px] text-primary-800">
|
||||||
|
{previewError}
|
||||||
|
</div>
|
||||||
|
{:else if imageUrl}
|
||||||
|
<img
|
||||||
|
alt={attachment.name}
|
||||||
|
class="block rounded-md border border-primary-100 bg-white object-contain"
|
||||||
|
src={imageUrl}
|
||||||
|
/>
|
||||||
|
{:else}
|
||||||
|
<div class="flex items-center justify-center rounded-md border border-dashed border-primary-200 bg-primary-50 px-3 py-4 text-center text-[12px] text-primary-800">
|
||||||
|
Vorschau wird erstellt
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
@@ -1,38 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
// Define the types for your props
|
|
||||||
type ButtonType = 'button' | 'submit' | 'reset'
|
|
||||||
type ButtonVariant = 'primary' | 'secondary'
|
|
||||||
|
|
||||||
// Define the props object including the Snippet for children
|
|
||||||
let {
|
|
||||||
type = 'button',
|
|
||||||
disabled = false,
|
|
||||||
variant = 'primary',
|
|
||||||
children
|
|
||||||
}: {
|
|
||||||
type?: ButtonType;
|
|
||||||
disabled?: boolean;
|
|
||||||
variant?: ButtonVariant
|
|
||||||
children: import('svelte').Snippet
|
|
||||||
} = $props();
|
|
||||||
|
|
||||||
const getBgColor = (variant: ButtonVariant) => {
|
|
||||||
return variant === 'primary' ? '#122f62' : 'transparent'
|
|
||||||
}
|
|
||||||
|
|
||||||
const getTextColor = (variant: ButtonVariant) => {
|
|
||||||
return variant === 'primary' ? 'white': 'primary-900'
|
|
||||||
}
|
|
||||||
|
|
||||||
let bgColor = $derived(getBgColor(variant))
|
|
||||||
let textColor = $derived(getTextColor(variant))
|
|
||||||
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<button
|
|
||||||
type="{type}"
|
|
||||||
class="rounded-container not-disabled:bg-[{bgColor}] disabled:bg-[#0000001f] not-disabled:text-{textColor} disabled:text-[#00000042] whitespace-nowrap text-center text-[14px] leading-9 font-bold px-4 py-0 focus:outline-none focus:ring-2 focus:ring-primary-300 shadow-sm"
|
|
||||||
disabled={disabled}
|
|
||||||
>
|
|
||||||
{@render children()}
|
|
||||||
</button>
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import type {Snippet} from "svelte";
|
|
||||||
|
|
||||||
type Props = {
|
|
||||||
title?: Snippet
|
|
||||||
content?: Snippet
|
|
||||||
footer?: Snippet
|
|
||||||
class?: string
|
|
||||||
}
|
|
||||||
let {title, content, footer, class: className = ""}: Props = $props()
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<div class="bg-white rounded-2xl shadow-sm py-2 px-2 {className}">
|
|
||||||
{#if title}
|
|
||||||
<header>{@render title()}</header>
|
|
||||||
{/if}
|
|
||||||
{#if content}
|
|
||||||
<main class="flex grow items-center">{@render content()}</main>
|
|
||||||
{/if}
|
|
||||||
{#if footer}
|
|
||||||
<footer>{@render footer()}</footer>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
@@ -0,0 +1,502 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import AttachmentPreview from '$lib/components/AttachmentPreview.svelte';
|
||||||
|
import Button from '$lib/components/ui/button.svelte';
|
||||||
|
import SubDocumentEditor from '$lib/components/SubDocumentEditor.svelte';
|
||||||
|
import {
|
||||||
|
buildArchiveExport,
|
||||||
|
createSubDocument,
|
||||||
|
dissolveSubDocument,
|
||||||
|
moveAttachmentIntoSubDocument,
|
||||||
|
moveSubDocument,
|
||||||
|
type AttachmentLocation,
|
||||||
|
type ExportMode,
|
||||||
|
type ProcessedZipArchive,
|
||||||
|
type SubDocument,
|
||||||
|
type ZipAttachment
|
||||||
|
} from '$lib/zip-processing';
|
||||||
|
import { cn } from '$lib/utils';
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
archive: ProcessedZipArchive;
|
||||||
|
thumbnailWidth: number;
|
||||||
|
onArchiveChange?: (archive: ProcessedZipArchive) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
let { archive, thumbnailWidth = 200, onArchiveChange = () => {} }: Props = $props();
|
||||||
|
|
||||||
|
const ATTACHMENT_MIME = 'application/x-juri-merger-attachment';
|
||||||
|
|
||||||
|
let archiveName = $state('');
|
||||||
|
let looseAttachments = $state<ZipAttachment[]>([]);
|
||||||
|
let subDocuments = $state<SubDocument[]>([]);
|
||||||
|
|
||||||
|
let selectionMode = $state(false);
|
||||||
|
let selectedPaths = $state<Set<string>>(new Set());
|
||||||
|
|
||||||
|
let dragIndex = $state<number | null>(null);
|
||||||
|
let dropIndex = $state<number | null>(null);
|
||||||
|
|
||||||
|
let blockDragIndex = $state<number | null>(null);
|
||||||
|
let blockDropIndex = $state<number | null>(null);
|
||||||
|
|
||||||
|
let downloadMode = $state<ExportMode>('single');
|
||||||
|
let isMerging = $state(false);
|
||||||
|
let mergeError = $state<string | null>(null);
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
archiveName = archive.name;
|
||||||
|
looseAttachments = archive.attachments.map((attachment) => ({ ...attachment }));
|
||||||
|
subDocuments = archive.subDocuments.map((subDocument) => ({
|
||||||
|
...subDocument,
|
||||||
|
attachments: subDocument.attachments.map((attachment) => ({ ...attachment }))
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
const toArrayBuffer = (bytes: Uint8Array) => {
|
||||||
|
const copy = new Uint8Array(bytes.byteLength);
|
||||||
|
copy.set(bytes);
|
||||||
|
return copy.buffer;
|
||||||
|
};
|
||||||
|
|
||||||
|
const normalizeFileName = (name: string) =>
|
||||||
|
name.toLowerCase().endsWith('.pdf') ? name : `${name}.pdf`;
|
||||||
|
|
||||||
|
const downloadPdfBytes = (bytes: Uint8Array, fileName: string) => {
|
||||||
|
const pdfBlob = new Blob([toArrayBuffer(bytes)], { type: 'application/pdf' });
|
||||||
|
const objectUrl = URL.createObjectURL(pdfBlob);
|
||||||
|
const downloadLink = document.createElement('a');
|
||||||
|
downloadLink.href = objectUrl;
|
||||||
|
downloadLink.download = normalizeFileName(fileName);
|
||||||
|
downloadLink.rel = 'noopener';
|
||||||
|
downloadLink.click();
|
||||||
|
window.setTimeout(() => URL.revokeObjectURL(objectUrl), 1000);
|
||||||
|
};
|
||||||
|
|
||||||
|
const composedArchive = (): ProcessedZipArchive => ({
|
||||||
|
...archive,
|
||||||
|
name: archiveName,
|
||||||
|
attachments: looseAttachments,
|
||||||
|
subDocuments
|
||||||
|
});
|
||||||
|
|
||||||
|
const emitArchiveChange = (nextArchive: ProcessedZipArchive) => {
|
||||||
|
archiveName = nextArchive.name;
|
||||||
|
looseAttachments = nextArchive.attachments;
|
||||||
|
subDocuments = nextArchive.subDocuments;
|
||||||
|
onArchiveChange(nextArchive);
|
||||||
|
};
|
||||||
|
|
||||||
|
const commit = () => {
|
||||||
|
onArchiveChange(composedArchive());
|
||||||
|
};
|
||||||
|
|
||||||
|
const hasSubDocuments = $derived(subDocuments.length > 0);
|
||||||
|
|
||||||
|
const toggleSelection = (path: string) => {
|
||||||
|
const next = new Set(selectedPaths);
|
||||||
|
|
||||||
|
if (next.has(path)) {
|
||||||
|
next.delete(path);
|
||||||
|
} else {
|
||||||
|
next.add(path);
|
||||||
|
}
|
||||||
|
|
||||||
|
selectedPaths = next;
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleSelectionMode = () => {
|
||||||
|
selectionMode = !selectionMode;
|
||||||
|
selectedPaths = new Set();
|
||||||
|
};
|
||||||
|
|
||||||
|
const exitSelectionMode = () => {
|
||||||
|
selectionMode = false;
|
||||||
|
selectedPaths = new Set();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleWindowKeydown = (event: KeyboardEvent) => {
|
||||||
|
if (event.key === 'Escape' && selectionMode) {
|
||||||
|
exitSelectionMode();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const createSubDocumentFromSelection = () => {
|
||||||
|
if (selectedPaths.size === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectedAttachments = looseAttachments.filter((attachment) =>
|
||||||
|
selectedPaths.has(attachment.path)
|
||||||
|
);
|
||||||
|
const remainingAttachments = looseAttachments.filter(
|
||||||
|
(attachment) => !selectedPaths.has(attachment.path)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (selectedAttachments.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const subDocumentNumber = subDocuments.length + 1;
|
||||||
|
const nextSubDocument = createSubDocument(
|
||||||
|
`Teildokument ${subDocumentNumber}`,
|
||||||
|
selectedAttachments
|
||||||
|
);
|
||||||
|
|
||||||
|
looseAttachments = remainingAttachments;
|
||||||
|
subDocuments = [...subDocuments, nextSubDocument];
|
||||||
|
selectedPaths = new Set();
|
||||||
|
commit();
|
||||||
|
};
|
||||||
|
|
||||||
|
const dissolveSubDocumentBlock = (subDocumentId: string) => {
|
||||||
|
const nextArchive = dissolveSubDocument(composedArchive(), subDocumentId);
|
||||||
|
|
||||||
|
emitArchiveChange(nextArchive);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubDocumentChange = (next: SubDocument) => {
|
||||||
|
subDocuments = subDocuments.map((subDocument) =>
|
||||||
|
subDocument.id === next.id ? next : subDocument
|
||||||
|
);
|
||||||
|
commit();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAttachmentMove = (source: AttachmentLocation, target: AttachmentLocation) => {
|
||||||
|
const nextArchive = moveAttachmentIntoSubDocument(composedArchive(), source, target);
|
||||||
|
|
||||||
|
emitArchiveChange(nextArchive);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleBlockDragStart = (blockIndex: number, _event: DragEvent) => {
|
||||||
|
blockDragIndex = blockIndex;
|
||||||
|
blockDropIndex = blockIndex;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleBlockDragOver = (blockIndex: number, _event: DragEvent) => {
|
||||||
|
blockDropIndex = blockIndex;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleBlockDrop = (blockIndex: number, _event: DragEvent) => {
|
||||||
|
if (blockDragIndex === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const fromIndex = blockDragIndex;
|
||||||
|
const nextArchive = moveSubDocument(composedArchive(), fromIndex, blockIndex);
|
||||||
|
|
||||||
|
emitArchiveChange(nextArchive);
|
||||||
|
blockDragIndex = null;
|
||||||
|
blockDropIndex = null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleBlockDragEnd = () => {
|
||||||
|
blockDragIndex = null;
|
||||||
|
blockDropIndex = null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const moveLooseAttachment = (fromIndex: number, toIndex: number) => {
|
||||||
|
if (
|
||||||
|
fromIndex === toIndex ||
|
||||||
|
fromIndex < 0 ||
|
||||||
|
toIndex < 0 ||
|
||||||
|
fromIndex >= looseAttachments.length ||
|
||||||
|
toIndex >= looseAttachments.length
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextAttachments = [...looseAttachments];
|
||||||
|
const [movedAttachment] = nextAttachments.splice(fromIndex, 1);
|
||||||
|
|
||||||
|
nextAttachments.splice(toIndex, 0, movedAttachment);
|
||||||
|
|
||||||
|
looseAttachments = nextAttachments;
|
||||||
|
commit();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleLooseDragStart = (index: number, event: DragEvent) => {
|
||||||
|
if (selectionMode) {
|
||||||
|
event.preventDefault();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
dragIndex = index;
|
||||||
|
dropIndex = index;
|
||||||
|
|
||||||
|
const source: AttachmentLocation = { kind: 'loose', index };
|
||||||
|
|
||||||
|
if (event.dataTransfer) {
|
||||||
|
event.dataTransfer.effectAllowed = 'move';
|
||||||
|
event.dataTransfer.setData(ATTACHMENT_MIME, JSON.stringify(source));
|
||||||
|
event.dataTransfer.setData('text/plain', String(index));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const parseAttachmentPayload = (event: DragEvent): AttachmentLocation | null => {
|
||||||
|
const raw = event.dataTransfer?.getData(ATTACHMENT_MIME);
|
||||||
|
|
||||||
|
if (!raw) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(raw) as AttachmentLocation;
|
||||||
|
|
||||||
|
if (parsed.kind === 'loose' || parsed.kind === 'subDocument') {
|
||||||
|
return parsed;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleLooseDragOver = (index: number, event: DragEvent) => {
|
||||||
|
if (event.dataTransfer?.types.includes(ATTACHMENT_MIME)) {
|
||||||
|
event.preventDefault();
|
||||||
|
dropIndex = index;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleLooseDrop = (index: number, event: DragEvent) => {
|
||||||
|
const source = parseAttachmentPayload(event);
|
||||||
|
|
||||||
|
if (!source) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
event.preventDefault();
|
||||||
|
|
||||||
|
if (source.kind === 'loose') {
|
||||||
|
moveLooseAttachment(source.index, index);
|
||||||
|
} else {
|
||||||
|
const nextArchive = moveAttachmentIntoSubDocument(composedArchive(), source, {
|
||||||
|
kind: 'loose',
|
||||||
|
index
|
||||||
|
});
|
||||||
|
|
||||||
|
emitArchiveChange(nextArchive);
|
||||||
|
}
|
||||||
|
|
||||||
|
dragIndex = null;
|
||||||
|
dropIndex = null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleLooseDragEnd = () => {
|
||||||
|
dragIndex = null;
|
||||||
|
dropIndex = null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const downloadArchive = async () => {
|
||||||
|
if (looseAttachments.length === 0 && subDocuments.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isMerging) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
isMerging = true;
|
||||||
|
mergeError = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const exportUnits = await buildArchiveExport(composedArchive(), downloadMode);
|
||||||
|
|
||||||
|
for (const unit of exportUnits) {
|
||||||
|
downloadPdfBytes(unit.bytes, unit.name);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Failed to export archive ${archive.name}`, error);
|
||||||
|
mergeError = 'PDF konnte nicht erstellt werden.';
|
||||||
|
} finally {
|
||||||
|
isMerging = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:window onkeydown={handleWindowKeydown} />
|
||||||
|
|
||||||
|
<section
|
||||||
|
class="flex flex-col gap-4 rounded-2xl border border-primary-100 bg-white px-4 py-4 shadow-sm"
|
||||||
|
>
|
||||||
|
<header class="flex flex-wrap items-baseline justify-between gap-2 text-primary">
|
||||||
|
<div class="min-w-0 flex-1">
|
||||||
|
<label class="block min-w-0">
|
||||||
|
<span class="sr-only">Archivname</span>
|
||||||
|
<input
|
||||||
|
class="w-full max-w-xl rounded-md border border-primary-200 bg-white px-3 py-2 text-[16px] font-bold text-primary-900 outline-none transition focus:border-primary-400 focus:ring-2 focus:ring-primary-200"
|
||||||
|
type="text"
|
||||||
|
value={archiveName}
|
||||||
|
draggable="false"
|
||||||
|
oninput={(event) => {
|
||||||
|
archiveName = (event.currentTarget as HTMLInputElement).value;
|
||||||
|
commit();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<p class="mt-1 text-[13px] text-primary">
|
||||||
|
{looseAttachments.length} lose Datei{looseAttachments.length === 1 ? '' : 'en'}
|
||||||
|
{#if hasSubDocuments}
|
||||||
|
· {subDocuments.length} Teildokument{subDocuments.length === 1 ? '' : 'e'}
|
||||||
|
{/if}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant={selectionMode ? 'secondary' : 'bordered'}
|
||||||
|
onclick={toggleSelectionMode}
|
||||||
|
>
|
||||||
|
{selectionMode ? 'Fertig' : 'Auswählen'}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{#if hasSubDocuments}
|
||||||
|
<div
|
||||||
|
class="inline-flex items-center rounded-[16px] border border-primary-200 bg-white p-0.5"
|
||||||
|
role="radiogroup"
|
||||||
|
aria-label="Export-Modus"
|
||||||
|
>
|
||||||
|
{#each [{ value: 'single', label: 'Eine PDF' }, { value: 'separate', label: 'Getrennt' }] as option (option.value)}
|
||||||
|
<label
|
||||||
|
class={cn(
|
||||||
|
'cursor-pointer rounded-[14px] px-3 py-1 text-[13px] font-semibold transition',
|
||||||
|
downloadMode === option.value
|
||||||
|
? 'bg-primary text-primary-foreground'
|
||||||
|
: 'text-primary-700 hover:bg-primary-50'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
class="sr-only"
|
||||||
|
type="radio"
|
||||||
|
name={`download-mode-${archive.name}`}
|
||||||
|
value={option.value}
|
||||||
|
checked={downloadMode === option.value}
|
||||||
|
onchange={() => {
|
||||||
|
downloadMode = option.value as ExportMode;
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{option.label}
|
||||||
|
</label>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<Button
|
||||||
|
class="bg-primary"
|
||||||
|
type="button"
|
||||||
|
disabled={(looseAttachments.length === 0 && subDocuments.length === 0) || isMerging}
|
||||||
|
onclick={downloadArchive}
|
||||||
|
>
|
||||||
|
{isMerging ? 'PDF wird erstellt' : 'PDF herunterladen'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{#if mergeError}
|
||||||
|
<div class="rounded-container bg-red-50 px-4 py-3 text-[14px] font-medium text-red-900">
|
||||||
|
{mergeError}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if selectionMode && selectedPaths.size > 0}
|
||||||
|
<div
|
||||||
|
class="flex flex-wrap items-center gap-3 rounded-xl border border-primary-200 bg-primary-50 px-4 py-2 text-[13px] font-medium text-primary-900"
|
||||||
|
>
|
||||||
|
<span>{selectedPaths.size} Datei{selectedPaths.size === 1 ? '' : 'en'} ausgewählt</span>
|
||||||
|
<Button type="button" onclick={createSubDocumentFromSelection}>Teildokument erstellen</Button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<ul class="flex gap-3 overflow-x-auto pb-1">
|
||||||
|
{#each looseAttachments as attachment, index (attachment.path)}
|
||||||
|
<li
|
||||||
|
class={cn(
|
||||||
|
'flex flex-col shrink-0 items-stretch gap-2 rounded-xl border bg-accent p-2 text-primary transition',
|
||||||
|
dropIndex === index ? 'border-primary-400 ring-1 ring-primary-200' : 'border-primary-100',
|
||||||
|
dragIndex === index ? 'opacity-60' : '',
|
||||||
|
selectedPaths.has(attachment.path) ? 'border-primary-500 ring-2 ring-primary-300' : ''
|
||||||
|
)}
|
||||||
|
style={`width: ${thumbnailWidth}px;`}
|
||||||
|
draggable={selectionMode ? 'false' : 'true'}
|
||||||
|
ondragstart={(event) => handleLooseDragStart(index, event)}
|
||||||
|
ondragover={(event) => handleLooseDragOver(index, event)}
|
||||||
|
ondrop={(event) => handleLooseDrop(index, event)}
|
||||||
|
ondragend={handleLooseDragEnd}
|
||||||
|
>
|
||||||
|
{#if selectionMode}
|
||||||
|
<label class="flex cursor-pointer flex-col gap-2">
|
||||||
|
<div class="flex items-center gap-2 px-1 text-[13px] font-medium text-primary-900">
|
||||||
|
<input
|
||||||
|
class="h-4 w-4 accent-primary-700"
|
||||||
|
type="checkbox"
|
||||||
|
checked={selectedPaths.has(attachment.path)}
|
||||||
|
onchange={() => toggleSelection(attachment.path)}
|
||||||
|
/>
|
||||||
|
<span class="truncate"
|
||||||
|
>{selectedPaths.has(attachment.path) ? 'Ausgewählt' : 'Auswählen'}</span
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="truncate text-[13px] font-medium leading-tight text-primary-900"
|
||||||
|
title={attachment.name}
|
||||||
|
>
|
||||||
|
{attachment.name}
|
||||||
|
</div>
|
||||||
|
<AttachmentPreview {attachment} />
|
||||||
|
</label>
|
||||||
|
{:else}
|
||||||
|
<div class="flex min-w-0 flex-1 flex-row items-baseline gap-2 py-1">
|
||||||
|
<button
|
||||||
|
class="w-fit cursor-grab rounded-md border border-primary-200 bg-white px-2 py-1 text-sm font-semibold leading-none text-primary-700 active:cursor-grabbing"
|
||||||
|
draggable="false"
|
||||||
|
type="button"
|
||||||
|
aria-label="Datei verschieben"
|
||||||
|
>
|
||||||
|
::
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="truncate text-[13px] font-medium leading-tight text-primary-900"
|
||||||
|
title={attachment.name}
|
||||||
|
>
|
||||||
|
{attachment.name}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<AttachmentPreview {attachment} />
|
||||||
|
{/if}
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
{#if looseAttachments.length === 0 && subDocuments.length === 0}
|
||||||
|
<div
|
||||||
|
class="rounded-md border border-dashed border-primary-200 bg-primary-50 px-4 py-4 text-[13px] text-primary-700"
|
||||||
|
>
|
||||||
|
Keine Dateien in diesem Archiv. Lade ein anderes ZIP, um weiterzuarbeiten.
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if hasSubDocuments}
|
||||||
|
<div class="flex flex-col gap-3">
|
||||||
|
{#each subDocuments as subDocument, blockIndex (subDocument.id)}
|
||||||
|
<SubDocumentEditor
|
||||||
|
{subDocument}
|
||||||
|
{blockIndex}
|
||||||
|
{thumbnailWidth}
|
||||||
|
isBlockDragSource={blockDragIndex === blockIndex}
|
||||||
|
isBlockDropTarget={blockDropIndex === blockIndex && blockDragIndex !== blockIndex}
|
||||||
|
onSubDocumentChange={handleSubDocumentChange}
|
||||||
|
onRemove={() => dissolveSubDocumentBlock(subDocument.id)}
|
||||||
|
onAttachmentMove={handleAttachmentMove}
|
||||||
|
onBlockDragStart={handleBlockDragStart}
|
||||||
|
onBlockDragOver={handleBlockDragOver}
|
||||||
|
onBlockDrop={handleBlockDrop}
|
||||||
|
onBlockDragEnd={handleBlockDragEnd}
|
||||||
|
/>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</section>
|
||||||
@@ -0,0 +1,286 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import AttachmentPreview from '$lib/components/AttachmentPreview.svelte';
|
||||||
|
import type { AttachmentLocation, SubDocument, ZipAttachment } from '$lib/zip-processing';
|
||||||
|
import { cn } from '$lib/utils';
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
subDocument: SubDocument;
|
||||||
|
blockIndex: number;
|
||||||
|
thumbnailWidth: number;
|
||||||
|
isBlockDragSource?: boolean;
|
||||||
|
isBlockDropTarget?: boolean;
|
||||||
|
onSubDocumentChange?: (next: SubDocument) => void;
|
||||||
|
onRemove?: () => void;
|
||||||
|
onAttachmentMove?: (source: AttachmentLocation, target: AttachmentLocation) => void;
|
||||||
|
onBlockDragStart?: (blockIndex: number, event: DragEvent) => void;
|
||||||
|
onBlockDragOver?: (blockIndex: number, event: DragEvent) => void;
|
||||||
|
onBlockDrop?: (blockIndex: number, event: DragEvent) => void;
|
||||||
|
onBlockDragEnd?: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
let {
|
||||||
|
subDocument,
|
||||||
|
blockIndex,
|
||||||
|
thumbnailWidth = 200,
|
||||||
|
isBlockDragSource = false,
|
||||||
|
isBlockDropTarget = false,
|
||||||
|
onSubDocumentChange = () => {},
|
||||||
|
onRemove = () => {},
|
||||||
|
onAttachmentMove = () => {},
|
||||||
|
onBlockDragStart = () => {},
|
||||||
|
onBlockDragOver = () => {},
|
||||||
|
onBlockDrop = () => {},
|
||||||
|
onBlockDragEnd = () => {}
|
||||||
|
}: Props = $props();
|
||||||
|
|
||||||
|
let subDocumentName = $state('');
|
||||||
|
let dragIndex = $state<number | null>(null);
|
||||||
|
let dropIndex = $state<number | null>(null);
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
subDocumentName = subDocument.name;
|
||||||
|
});
|
||||||
|
|
||||||
|
const emitChange = (overrides: Partial<SubDocument> = {}) => {
|
||||||
|
onSubDocumentChange({
|
||||||
|
...subDocument,
|
||||||
|
name: subDocumentName,
|
||||||
|
attachments: subDocument.attachments.map((attachment) => ({ ...attachment })),
|
||||||
|
...overrides
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const ATTACHMENT_MIME = 'application/x-juri-merger-attachment';
|
||||||
|
const BLOCK_MIME = 'application/x-juri-merger-block';
|
||||||
|
|
||||||
|
const parseAttachmentPayload = (event: DragEvent): AttachmentLocation | null => {
|
||||||
|
const raw = event.dataTransfer?.getData(ATTACHMENT_MIME);
|
||||||
|
|
||||||
|
if (!raw) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(raw) as AttachmentLocation;
|
||||||
|
|
||||||
|
if (parsed.kind === 'loose' || parsed.kind === 'subDocument') {
|
||||||
|
return parsed;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCardDragStart = (index: number, event: DragEvent) => {
|
||||||
|
dragIndex = index;
|
||||||
|
dropIndex = index;
|
||||||
|
|
||||||
|
const source: AttachmentLocation = {
|
||||||
|
kind: 'subDocument',
|
||||||
|
id: subDocument.id,
|
||||||
|
index
|
||||||
|
};
|
||||||
|
|
||||||
|
if (event.dataTransfer) {
|
||||||
|
event.dataTransfer.effectAllowed = 'move';
|
||||||
|
event.dataTransfer.setData(ATTACHMENT_MIME, JSON.stringify(source));
|
||||||
|
// keep a text/plain fallback so the drag is not silently cancelled
|
||||||
|
event.dataTransfer.setData('text/plain', String(index));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCardDragOver = (index: number, event: DragEvent) => {
|
||||||
|
if (event.dataTransfer?.types.includes(ATTACHMENT_MIME)) {
|
||||||
|
event.preventDefault();
|
||||||
|
dropIndex = index;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCardDrop = (index: number, event: DragEvent) => {
|
||||||
|
const source = parseAttachmentPayload(event);
|
||||||
|
|
||||||
|
if (!source) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
event.preventDefault();
|
||||||
|
|
||||||
|
const target: AttachmentLocation = {
|
||||||
|
kind: 'subDocument',
|
||||||
|
id: subDocument.id,
|
||||||
|
index
|
||||||
|
};
|
||||||
|
|
||||||
|
onAttachmentMove(source, target);
|
||||||
|
dragIndex = null;
|
||||||
|
dropIndex = null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCardDragEnd = () => {
|
||||||
|
dragIndex = null;
|
||||||
|
dropIndex = null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleNameInput = (event: Event) => {
|
||||||
|
subDocumentName = (event.currentTarget as HTMLInputElement).value;
|
||||||
|
emitChange();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleHeaderDragStart = (event: DragEvent) => {
|
||||||
|
// do not start a block drag when the user grabbed the remove button or name input
|
||||||
|
if (event.defaultPrevented) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.dataTransfer) {
|
||||||
|
event.dataTransfer.effectAllowed = 'move';
|
||||||
|
event.dataTransfer.setData(BLOCK_MIME, String(blockIndex));
|
||||||
|
event.dataTransfer.setData('text/plain', String(blockIndex));
|
||||||
|
}
|
||||||
|
|
||||||
|
onBlockDragStart(blockIndex, event);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleHeaderDragOver = (event: DragEvent) => {
|
||||||
|
if (event.dataTransfer?.types.includes(BLOCK_MIME)) {
|
||||||
|
event.preventDefault();
|
||||||
|
onBlockDragOver(blockIndex, event);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleHeaderDrop = (event: DragEvent) => {
|
||||||
|
const raw = event.dataTransfer?.getData(BLOCK_MIME);
|
||||||
|
|
||||||
|
if (raw === undefined || raw === '') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
event.preventDefault();
|
||||||
|
onBlockDrop(blockIndex, event);
|
||||||
|
};
|
||||||
|
|
||||||
|
const attachments: ZipAttachment[] = $derived(subDocument.attachments);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<section
|
||||||
|
class={cn(
|
||||||
|
'flex flex-col gap-3 rounded-xl border bg-primary-50/40 px-3 py-3 transition',
|
||||||
|
isBlockDropTarget ? 'border-primary-400 ring-1 ring-primary-200' : 'border-primary-100',
|
||||||
|
isBlockDragSource ? 'opacity-60' : ''
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<header class="flex flex-wrap items-center gap-2 text-primary">
|
||||||
|
<button
|
||||||
|
class="w-fit cursor-grab rounded-md border border-primary-200 bg-white px-2 py-1 text-sm font-semibold leading-none text-primary-700 active:cursor-grabbing"
|
||||||
|
draggable="true"
|
||||||
|
type="button"
|
||||||
|
aria-label="Teildokument verschieben"
|
||||||
|
ondragstart={handleHeaderDragStart}
|
||||||
|
ondragover={handleHeaderDragOver}
|
||||||
|
ondrop={handleHeaderDrop}
|
||||||
|
ondragend={onBlockDragEnd}
|
||||||
|
>
|
||||||
|
::
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<label class="block min-w-0 flex-1">
|
||||||
|
<span class="sr-only">Teildokumentname</span>
|
||||||
|
<input
|
||||||
|
class="w-full max-w-md rounded-md border border-primary-200 bg-white px-3 py-1.5 text-[15px] font-semibold text-primary-900 outline-none transition focus:border-primary-400 focus:ring-2 focus:ring-primary-200"
|
||||||
|
type="text"
|
||||||
|
value={subDocumentName}
|
||||||
|
oninput={handleNameInput}
|
||||||
|
draggable="false"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<span class="text-[13px] text-primary-700">
|
||||||
|
{attachments.length} Datei{attachments.length === 1 ? '' : 'en'}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<button
|
||||||
|
class="rounded-md border border-primary-200 bg-white px-2 py-1 text-[13px] font-medium text-primary-700 transition hover:border-red-300 hover:bg-red-50 hover:text-red-700"
|
||||||
|
type="button"
|
||||||
|
onclick={onRemove}
|
||||||
|
draggable="false"
|
||||||
|
ondragstart={(event) => event.preventDefault()}
|
||||||
|
>
|
||||||
|
Auflösen
|
||||||
|
</button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{#if attachments.length === 0}
|
||||||
|
<ul
|
||||||
|
class="flex items-center justify-center rounded-md border border-dashed border-primary-200 bg-white/60 px-4 py-6 text-[13px] text-primary-600"
|
||||||
|
ondragover={(event) => {
|
||||||
|
if (event.dataTransfer?.types.includes(ATTACHMENT_MIME)) {
|
||||||
|
event.preventDefault();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
ondrop={(event) => {
|
||||||
|
const source = parseAttachmentPayload(event);
|
||||||
|
|
||||||
|
if (!source) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
event.preventDefault();
|
||||||
|
|
||||||
|
const target: AttachmentLocation = {
|
||||||
|
kind: 'subDocument',
|
||||||
|
id: subDocument.id,
|
||||||
|
index: 0
|
||||||
|
};
|
||||||
|
|
||||||
|
onAttachmentMove(source, target);
|
||||||
|
dragIndex = null;
|
||||||
|
dropIndex = null;
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<li class="w-full text-center">
|
||||||
|
Dieses Teildokument ist leer. Ziehe Dateien hierher, um sie hinzuzufügen.
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
{:else}
|
||||||
|
<ul class="flex gap-3 overflow-x-auto pb-1">
|
||||||
|
{#each attachments as attachment, index (attachment.path)}
|
||||||
|
<li
|
||||||
|
class={cn(
|
||||||
|
'flex flex-col shrink-0 items-stretch gap-2 rounded-xl border bg-accent p-2 text-primary',
|
||||||
|
dropIndex === index
|
||||||
|
? 'border-primary-400 ring-1 ring-primary-200'
|
||||||
|
: 'border-primary-100',
|
||||||
|
dragIndex === index ? 'opacity-60' : ''
|
||||||
|
)}
|
||||||
|
style={`width: ${thumbnailWidth}px;`}
|
||||||
|
draggable="true"
|
||||||
|
ondragstart={(event) => handleCardDragStart(index, event)}
|
||||||
|
ondragover={(event) => handleCardDragOver(index, event)}
|
||||||
|
ondrop={(event) => handleCardDrop(index, event)}
|
||||||
|
ondragend={handleCardDragEnd}
|
||||||
|
>
|
||||||
|
<div class="flex min-w-0 flex-1 flex-row items-baseline gap-2 py-1">
|
||||||
|
<button
|
||||||
|
class="w-fit cursor-grab rounded-md border border-primary-200 bg-white px-2 py-1 text-sm font-semibold leading-none text-primary-700 active:cursor-grabbing"
|
||||||
|
draggable="false"
|
||||||
|
type="button"
|
||||||
|
aria-label="Datei verschieben"
|
||||||
|
>
|
||||||
|
::
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="truncate text-[13px] font-medium leading-tight text-primary-900"
|
||||||
|
title={attachment.name}
|
||||||
|
>
|
||||||
|
{attachment.name}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<AttachmentPreview {attachment} />
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
{/if}
|
||||||
|
</section>
|
||||||
@@ -62,7 +62,7 @@
|
|||||||
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="flex min-h-[calc(-180px+100vh)] w-full grow cursor-pointer flex-col justify-center border-0 bg-transparent p-0 text-center text-inherit appearance-none focus:outline-none transition-colors rounded-2xl {isDragging ? 'bg-gray-100 border-2 border-dashed border-primary-500' : ''} {className}"
|
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"
|
aria-label="ZIP-Dateien hinzufügen"
|
||||||
onclick={openFileDialog}
|
onclick={openFileDialog}
|
||||||
ondragover={handleDragOver}
|
ondragover={handleDragOver}
|
||||||
@@ -70,8 +70,7 @@
|
|||||||
ondrop={handleDrop}
|
ondrop={handleDrop}
|
||||||
>
|
>
|
||||||
<div class="flex flex-row items-baseline justify-center gap-2">
|
<div class="flex flex-row items-baseline justify-center gap-2">
|
||||||
<!-- Changed h1 to span for better semantic HTML inside a button -->
|
<span class="text-[20px] font-bold">Dateien hinzufügen</span>
|
||||||
<span class="text-[20px] font-bold text-primary-900">Dateien hinzufügen</span>
|
<span class="text-[20px] font-extrabold">+</span>
|
||||||
<span class="text-[20px] font-extrabold text-primary-900">+</span>
|
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
<script>
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M10.5618 6.94745L14.1001 3.31245C15.0133 2.37424 16.5206 2.37424 17.4338 3.31245L19.0544 4.97737C19.9334 5.88045 19.9334 7.31929 19.0544 8.22237L14.3368 13.069C13.4235 14.0072 11.9162 14.0072 11.003 13.069L10.5537 12.6075" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M12.8717 16.4126L7.66659 21.76L4.37911 18.3826C3.50007 17.4796 3.50008 16.0407 4.37911 15.1376L9.09675 10.291C10.01 9.35276 11.5173 9.35276 12.4305 10.291L12.8798 10.7525" stroke="currentColor" stroke-width="1.9" stroke-linecap="round"/>
|
||||||
|
</svg>
|
||||||
|
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
<script>
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svg width="23" height="24" viewBox="0 0 23 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path fill-rule="evenodd" clip-rule="evenodd" d="M18.6875 19.7575C18.6875 21.0807 17.6148 22.1533 16.2917 22.1533H4.3125V5.99287C4.3125 5.39212 4.54755 4.81522 4.96738 4.38552L7.17097 2.13015C7.60374 1.68721 8.19683 1.4375 8.81609 1.4375H16.3875C17.6578 1.4375 18.6875 2.46725 18.6875 3.7375V19.7575Z" stroke="currentColor" stroke-width="1.86875"/>
|
||||||
|
<path d="M7.06787 12.5889H15.6929" stroke="currentColor" stroke-width="1.725" stroke-linecap="round"/>
|
||||||
|
<path d="M7.06787 8.71875H15.6929" stroke="currentColor" stroke-width="1.725" stroke-linecap="round"/>
|
||||||
|
<path d="M7.06787 16.464H12.8179" stroke="currentColor" stroke-width="1.725" stroke-linecap="round"/>
|
||||||
|
</svg>
|
||||||
|
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
<script>
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svg width="21" height="15" viewBox="0 0 21 15" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path fill-rule="evenodd" clip-rule="evenodd" d="M0 13.2188C0 12.5457 0.545653 12 1.21875 12H16.8256C17.339 12 17.6536 12.5629 17.3845 13.0002L16.5 14.4375H1.21875C0.545653 14.4375 0 13.8918 0 13.2188Z" fill="currentColor"/>
|
||||||
|
<path fill-rule="evenodd" clip-rule="evenodd" d="M0 7.21875C0 6.54565 0.545653 6 1.21875 6H18.2738C18.7979 6 19.1106 6.58416 18.8198 7.02027L17.875 8.4375H1.21875C0.545653 8.4375 0 7.89185 0 7.21875Z" fill="currentColor"/>
|
||||||
|
<path fill-rule="evenodd" clip-rule="evenodd" d="M0 1.21875C0 0.545653 0.545653 0 1.21875 0H19.721C20.2555 0 20.5658 0.60479 20.2541 1.03898L19.25 2.4375H1.21875C0.545653 2.4375 0 1.89185 0 1.21875Z" fill="currentColor"/>
|
||||||
|
</svg>
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
<script>
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svg width="23" height="24" viewBox="0 0 23 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M20.4129 7.29267L14.1203 1C14.1203 1 11.8437 8.76535 4.14746 10.9744L11.7731 18.6C13.9805 10.9022 21.7475 8.62562 21.7475 8.62562L15.4548 2.33295" stroke="currentColor" stroke-width="2" stroke-linejoin="round"/>
|
||||||
|
<path d="M7.28204 14.2833L8.8 15.8656L1.51796 23.0833L1.36219 22.921C0.606308 22.1331 0.622023 20.8845 1.3975 20.1159L7.28204 14.2833Z" fill="currentColor"/>
|
||||||
|
</svg>
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
<script>
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path fill-rule="evenodd" clip-rule="evenodd" d="M12.9914 12.6763V15.4458V20.4H7.98481H3.6001V16.046V4.27311C3.6001 3.32208 4.49438 2.6246 5.41678 2.85624L11.8863 4.48089C12.5359 4.64402 12.9914 5.228 12.9914 5.89777V12.6763Z" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"/>
|
||||||
|
<path d="M13.6491 8.40002L20.5221 10.2682C21.1583 10.4411 21.5998 11.0186 21.5998 11.6779V12.6424V15.424V18.9392C21.5998 19.746 20.9457 20.4 20.1389 20.4H17.4409H12.2085" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"/>
|
||||||
|
<path d="M4.5 8.3H7.5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
|
||||||
|
<path d="M4.5 12.1H7.5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
|
||||||
|
<path d="M4.5 15.9H7.5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
|
||||||
|
<path d="M14 13.1H16" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
|
||||||
|
<path d="M14 16.9H16" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
|
||||||
|
</svg>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
<svg width="16" height="22" viewBox="0 0 16 22" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M9.27193 9.97498L13.4818 11.6926C14.3848 12.0611 14.9751 12.9394 14.9751 13.9148V15.4118V18.475C14.9751 19.8557 13.8558 20.975 12.4751 20.975H7.51156H0.975098V16.0858V13.8946C0.975098 12.9294 1.55327 12.0581 2.44261 11.6831L6.49358 9.97498" stroke="currentColor" stroke-width="1.95" stroke-linecap="round"/>
|
||||||
|
<circle cx="7.4751" cy="5.47498" r="4.5" stroke="currentColor" stroke-width="1.95"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 513 B |
@@ -0,0 +1,12 @@
|
|||||||
|
<script>
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svg width="22" height="23" viewBox="0 0 22 23" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M21 17.5834C21.5523 17.5834 22 18.0311 22 18.5834V19.5834H1C0.447716 19.5834 0 19.1357 0 18.5834C0 18.0311 0.447715 17.5834 1 17.5834H21Z" fill="#122F62"/>
|
||||||
|
<path d="M21 10.5834C21.5523 10.5834 22 11.0311 22 11.5834V12.5834H1C0.447716 12.5834 0 12.1357 0 11.5834C0 11.0311 0.447715 10.5834 1 10.5834H21Z" fill="#122F62"/>
|
||||||
|
<path d="M21 3.08337C21.5523 3.08337 22 3.53109 22 4.08337V5.08337H1C0.447716 5.08337 0 4.63566 0 4.08337C0 3.53109 0.447715 3.08337 1 3.08337H21Z" fill="#122F62"/>
|
||||||
|
<circle cx="6.5" cy="3.75" r="2.75" fill="white" stroke="#122F62" stroke-width="2"/>
|
||||||
|
<circle cx="15.5835" cy="11.0834" r="2.75" fill="white" stroke="#122F62" stroke-width="2"/>
|
||||||
|
<circle cx="6.5" cy="18.4167" r="2.75" fill="white" stroke="#122F62" stroke-width="2"/>
|
||||||
|
</svg>
|
||||||
|
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
<svg width="24" height="23" viewBox="0 0 24 23" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path fill-rule="evenodd" clip-rule="evenodd" d="M10.4142 5.48509C10.7066 5.19265 11.101 5.0248 11.5145 5.01676L17.3775 4.90268C18.2946 4.88483 19.039 5.64016 19.0077 6.55694L18.8111 12.3183C18.7973 12.7235 18.6301 13.1084 18.3434 13.3951L10.5219 21.2166C9.89704 21.8414 8.88398 21.8414 8.25914 21.2166L1.47091 14.4284L10.4142 5.48509Z" stroke="currentColor" stroke-width="2.08"/>
|
||||||
|
<circle cx="13.7192" cy="9.90002" r="1.5" fill="currentColor"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 556 B |
@@ -0,0 +1,10 @@
|
|||||||
|
<script>
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M18 7V19C18 20.1046 17.1046 21 16 21H6V7" stroke="currentColor" stroke-width="2"/>
|
||||||
|
<path d="M8 4C8 3.44772 8.44772 3 9 3H15C15.5523 3 16 3.44772 16 4V7H8V4Z" stroke="currentColor" stroke-width="1.8"/>
|
||||||
|
<path d="M3 7.5H21" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
|
||||||
|
</svg>
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
<script>
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svg width="19" height="19" viewBox="0 0 19 19" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M5.49023 1H12.6572C13.5748 1 14.375 1.62449 14.5977 2.51465L17.0977 12.5146C17.4132 13.7769 16.4584 15 15.1572 15H1.20508L3.52441 2.63184C3.70178 1.68589 4.5278 1 5.49023 1Z" stroke="currentColor" stroke-width="2"/>
|
||||||
|
<rect x="5" y="17" width="8" height="2" rx="1" fill="currentColor"/>
|
||||||
|
</svg>
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
<script lang="ts" module>
|
||||||
|
import {ripple} from "$lib/actions/ripple";
|
||||||
|
import {cn, type WithElementRef} from "$lib/utils.js";
|
||||||
|
import type {HTMLAnchorAttributes, HTMLButtonAttributes} from "svelte/elements";
|
||||||
|
import {type VariantProps, tv} from "tailwind-variants";
|
||||||
|
import type {Component} from "svelte";
|
||||||
|
|
||||||
|
export const buttonVariants = tv({
|
||||||
|
base: "relative overflow-hidden font-bold w-fit min-w-28 hover:not-disabled:cursor-pointer disabled:bg-[#E6E7EA] disabled:text-[#0B1F4366] inline-flex items-center justify-center gap-4",
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
primary: "bg-primary text-primary-foreground",
|
||||||
|
secondary: "bg-linear-to-r from-secondary to-[#FF6F6D] text-primary-foreground",
|
||||||
|
bordered: "text-primary border-[#E6E7EA] border-2 bg-white hover:bg-[#122F621A]",
|
||||||
|
tertiary: "text-primary hover:bg-[#122F621A]",
|
||||||
|
success: "text-white bg-[#0FAD80]"
|
||||||
|
},
|
||||||
|
size: {
|
||||||
|
standard: "px-6 h-8 rounded-[16px]",
|
||||||
|
large: "px-8 h-12 rounded-[24px]"
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: "primary",
|
||||||
|
size: "standard",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export type ButtonVariant = VariantProps<typeof buttonVariants>["variant"];
|
||||||
|
export type ButtonSize = VariantProps<typeof buttonVariants>["size"];
|
||||||
|
|
||||||
|
export type ButtonProps = WithElementRef<HTMLButtonAttributes> &
|
||||||
|
WithElementRef<HTMLAnchorAttributes> & {
|
||||||
|
variant?: ButtonVariant;
|
||||||
|
size?: ButtonSize;
|
||||||
|
icon?: Component;
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
let {
|
||||||
|
class: className,
|
||||||
|
variant = "primary",
|
||||||
|
size = "standard",
|
||||||
|
icon,
|
||||||
|
ref = $bindable(null),
|
||||||
|
href = undefined,
|
||||||
|
type = "button",
|
||||||
|
disabled,
|
||||||
|
children,
|
||||||
|
...restProps
|
||||||
|
}: ButtonProps = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if href}
|
||||||
|
<a
|
||||||
|
use:ripple
|
||||||
|
bind:this={ref}
|
||||||
|
data-slot="button"
|
||||||
|
class={cn("", buttonVariants({ variant, size }), className)}
|
||||||
|
href={disabled ? undefined : href}
|
||||||
|
aria-disabled={disabled}
|
||||||
|
role={disabled ? "link" : undefined}
|
||||||
|
tabindex={disabled ? -1 : undefined}
|
||||||
|
{...restProps}
|
||||||
|
>
|
||||||
|
{@render children?.()}
|
||||||
|
{#if icon}
|
||||||
|
{@const Icon = icon}
|
||||||
|
<Icon/>
|
||||||
|
{/if}
|
||||||
|
</a>
|
||||||
|
{:else}
|
||||||
|
<button
|
||||||
|
use:ripple
|
||||||
|
bind:this={ref}
|
||||||
|
data-slot="button"
|
||||||
|
class={cn("inline-flex items-center justify-center gap-4", buttonVariants({ variant, size }), className)}
|
||||||
|
{type}
|
||||||
|
{disabled}
|
||||||
|
{...restProps}
|
||||||
|
>
|
||||||
|
{@render children?.()}
|
||||||
|
{#if icon}
|
||||||
|
{@const Icon = icon}
|
||||||
|
<Icon/>
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { cn, type WithElementRef } from "$lib/utils.js";
|
||||||
|
import type { HTMLAttributes } from "svelte/elements";
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
class: className,
|
||||||
|
children,
|
||||||
|
...restProps
|
||||||
|
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div
|
||||||
|
bind:this={ref}
|
||||||
|
data-slot="card-action"
|
||||||
|
class={cn(
|
||||||
|
"cn-card-action col-start-2 row-span-2 row-start-1 self-start justify-self-end",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...restProps}
|
||||||
|
>
|
||||||
|
{@render children?.()}
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { HTMLAttributes } from "svelte/elements";
|
||||||
|
import { cn, type WithElementRef } from "$lib/utils.js";
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
class: className,
|
||||||
|
children,
|
||||||
|
...restProps
|
||||||
|
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div
|
||||||
|
bind:this={ref}
|
||||||
|
data-slot="card-content"
|
||||||
|
class={cn("px-4 group-data-[size=sm]/card:px-3", className)}
|
||||||
|
{...restProps}
|
||||||
|
>
|
||||||
|
{@render children?.()}
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { HTMLAttributes } from "svelte/elements";
|
||||||
|
import { cn, type WithElementRef } from "$lib/utils.js";
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
class: className,
|
||||||
|
children,
|
||||||
|
...restProps
|
||||||
|
}: WithElementRef<HTMLAttributes<HTMLParagraphElement>> = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<p
|
||||||
|
bind:this={ref}
|
||||||
|
data-slot="card-description"
|
||||||
|
class={cn("text-muted-foreground text-sm", className)}
|
||||||
|
{...restProps}
|
||||||
|
>
|
||||||
|
{@render children?.()}
|
||||||
|
</p>
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { cn, type WithElementRef } from "$lib/utils.js";
|
||||||
|
import type { HTMLAttributes } from "svelte/elements";
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
class: className,
|
||||||
|
children,
|
||||||
|
...restProps
|
||||||
|
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div
|
||||||
|
bind:this={ref}
|
||||||
|
data-slot="card-footer"
|
||||||
|
class={cn("bg-muted/50 rounded-b-xl border-t p-4 group-data-[size=sm]/card:p-3 flex items-center", className)}
|
||||||
|
{...restProps}
|
||||||
|
>
|
||||||
|
{@render children?.()}
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { cn, type WithElementRef } from "$lib/utils.js";
|
||||||
|
import type { HTMLAttributes } from "svelte/elements";
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
class: className,
|
||||||
|
children,
|
||||||
|
...restProps
|
||||||
|
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div
|
||||||
|
bind:this={ref}
|
||||||
|
data-slot="card-header"
|
||||||
|
class={cn(
|
||||||
|
"gap-1 rounded-t-xl px-4 group-data-[size=sm]/card:px-3 [.border-b]:pb-4 group-data-[size=sm]/card:[.border-b]:pb-3 group/card-header @container/card-header grid auto-rows-min items-start has-data-[slot=card-action]:grid-cols-[1fr_auto] has-data-[slot=card-description]:grid-rows-[auto_auto]",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...restProps}
|
||||||
|
>
|
||||||
|
{@render children?.()}
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { HTMLAttributes } from "svelte/elements";
|
||||||
|
import { cn, type WithElementRef } from "$lib/utils.js";
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
class: className,
|
||||||
|
children,
|
||||||
|
...restProps
|
||||||
|
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div
|
||||||
|
bind:this={ref}
|
||||||
|
data-slot="card-title"
|
||||||
|
class={cn("text-base leading-snug font-medium group-data-[size=sm]/card:text-sm", className)}
|
||||||
|
{...restProps}
|
||||||
|
>
|
||||||
|
{@render children?.()}
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { HTMLAttributes } from "svelte/elements";
|
||||||
|
import { cn, type WithElementRef } from "$lib/utils.js";
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
class: className,
|
||||||
|
children,
|
||||||
|
size = "default",
|
||||||
|
...restProps
|
||||||
|
}: WithElementRef<HTMLAttributes<HTMLDivElement>> & { size?: "default" | "sm" } = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div
|
||||||
|
bind:this={ref}
|
||||||
|
data-slot="card"
|
||||||
|
data-size={size}
|
||||||
|
class={cn("ring-foreground/10 bg-card text-card-foreground gap-4 overflow-hidden rounded-xl py-4 text-sm ring-1 has-data-[slot=card-footer]:pb-0 has-[>img:first-child]:pt-0 data-[size=sm]:gap-3 data-[size=sm]:py-3 data-[size=sm]:has-data-[slot=card-footer]:pb-0 *:[img:first-child]:rounded-t-xl *:[img:last-child]:rounded-b-xl group/card flex flex-col", className)}
|
||||||
|
{...restProps}
|
||||||
|
>
|
||||||
|
{@render children?.()}
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import Root from "./card.svelte";
|
||||||
|
import Content from "./card-content.svelte";
|
||||||
|
import Description from "./card-description.svelte";
|
||||||
|
import Footer from "./card-footer.svelte";
|
||||||
|
import Header from "./card-header.svelte";
|
||||||
|
import Title from "./card-title.svelte";
|
||||||
|
import Action from "./card-action.svelte";
|
||||||
|
|
||||||
|
export {
|
||||||
|
Root,
|
||||||
|
Content,
|
||||||
|
Description,
|
||||||
|
Footer,
|
||||||
|
Header,
|
||||||
|
Title,
|
||||||
|
Action,
|
||||||
|
//
|
||||||
|
Root as Card,
|
||||||
|
Content as CardContent,
|
||||||
|
Description as CardDescription,
|
||||||
|
Footer as CardFooter,
|
||||||
|
Header as CardHeader,
|
||||||
|
Title as CardTitle,
|
||||||
|
Action as CardAction,
|
||||||
|
};
|
||||||
@@ -1,108 +0,0 @@
|
|||||||
[data-theme='nominandum'] {
|
|
||||||
--text-scaling: 1.067;
|
|
||||||
|
|
||||||
--base-font-color: var(--color-surface-900);
|
|
||||||
--base-font-color-dark: var(--color-surface-50);
|
|
||||||
--base-font-family: Nunito, Helvetica Neue, sans-serif;
|
|
||||||
--base-font-size: inherit;
|
|
||||||
--base-line-height: 1.5;
|
|
||||||
--base-font-weight: 400;
|
|
||||||
|
|
||||||
--heading-font-color: var(--color-primary-900);
|
|
||||||
--heading-font-color-dark: var(--color-primary-900);
|
|
||||||
--heading-font-family: Nunito, Helvetica Neue, sans-serif;
|
|
||||||
--heading-font-weight: 400;
|
|
||||||
|
|
||||||
--anchor-font-color: var(--color-primary-600);
|
|
||||||
--anchor-font-color-dark: var(--color-primary-300);
|
|
||||||
--anchor-text-decoration: none;
|
|
||||||
--anchor-text-decoration-hover: underline;
|
|
||||||
|
|
||||||
--spacing: 0.25rem;
|
|
||||||
--radius-base: 0.5rem;
|
|
||||||
--radius-container: 24px;
|
|
||||||
|
|
||||||
--default-border-width: 1px;
|
|
||||||
--default-divide-width: 1px;
|
|
||||||
--default-ring-width: 2px;
|
|
||||||
|
|
||||||
--body-background-color: #EEF4F6;
|
|
||||||
--body-background-color-dark: #0f172a;
|
|
||||||
|
|
||||||
/* Primary: Material Blue */
|
|
||||||
/* Very Light / Ghostly */
|
|
||||||
--color-primary-50: oklch(96.8% 0.010 253deg);
|
|
||||||
--color-primary-100: oklch(92.8% 0.020 253deg);
|
|
||||||
--color-primary-200: oklch(86.5% 0.040 253deg);
|
|
||||||
--color-primary-300: oklch(77.5% 0.060 253deg);
|
|
||||||
--color-primary-400: oklch(68.5% 0.080 253deg);
|
|
||||||
|
|
||||||
/* Mid-range / Muted Tones */
|
|
||||||
--color-primary-500: oklch(59.5% 0.090 253deg);
|
|
||||||
--color-primary-600: oklch(52.5% 0.095 253deg);
|
|
||||||
--color-primary-700: oklch(45.5% 0.095 253deg);
|
|
||||||
|
|
||||||
/* Dark Range (Matching your base color) */
|
|
||||||
--color-primary-800: oklch(38.5% 0.090 253deg);
|
|
||||||
--color-primary-900: oklch(31.5% 0.090 253deg); /* YOUR BASE COLOR */
|
|
||||||
--color-primary-950: oklch(23.5% 0.070 253deg);
|
|
||||||
|
|
||||||
--color-primary-contrast-dark: var(--color-primary-950);
|
|
||||||
--color-primary-contrast-light: white;
|
|
||||||
--color-primary-contrast-50: var(--color-primary-contrast-dark);
|
|
||||||
--color-primary-contrast-100: var(--color-primary-contrast-dark);
|
|
||||||
--color-primary-contrast-200: var(--color-primary-contrast-dark);
|
|
||||||
--color-primary-contrast-300: var(--color-primary-contrast-dark);
|
|
||||||
--color-primary-contrast-400: white;
|
|
||||||
--color-primary-contrast-500: white;
|
|
||||||
--color-primary-contrast-600: white;
|
|
||||||
--color-primary-contrast-700: white;
|
|
||||||
--color-primary-contrast-800: white;
|
|
||||||
--color-primary-contrast-900: white;
|
|
||||||
--color-primary-contrast-950: white;
|
|
||||||
|
|
||||||
/* Secondary: dezentes Blau-Grau */
|
|
||||||
--color-secondary-50: oklch(97% 0.01 250deg);
|
|
||||||
--color-secondary-100: oklch(93.5% 0.015 250deg);
|
|
||||||
--color-secondary-200: oklch(88.5% 0.022 250deg);
|
|
||||||
--color-secondary-300: oklch(80% 0.035 250deg);
|
|
||||||
--color-secondary-400: oklch(68% 0.045 250deg);
|
|
||||||
--color-secondary-500: oklch(56% 0.055 250deg);
|
|
||||||
--color-secondary-600: oklch(48% 0.05 250deg);
|
|
||||||
--color-secondary-700: oklch(40% 0.045 250deg);
|
|
||||||
--color-secondary-800: oklch(32% 0.04 250deg);
|
|
||||||
--color-secondary-900: oklch(24% 0.035 250deg);
|
|
||||||
--color-secondary-950: oklch(17% 0.03 250deg);
|
|
||||||
|
|
||||||
--color-secondary-contrast-dark: var(--color-secondary-950);
|
|
||||||
--color-secondary-contrast-light: white;
|
|
||||||
|
|
||||||
/* Tertiary: Akzent Cyan */
|
|
||||||
--color-tertiary-50: oklch(97% 0.025 220deg);
|
|
||||||
--color-tertiary-100: oklch(93% 0.045 220deg);
|
|
||||||
--color-tertiary-200: oklch(87% 0.07 220deg);
|
|
||||||
--color-tertiary-300: oklch(78% 0.105 220deg);
|
|
||||||
--color-tertiary-400: oklch(69% 0.135 220deg);
|
|
||||||
--color-tertiary-500: oklch(60% 0.15 220deg);
|
|
||||||
--color-tertiary-600: oklch(52% 0.135 220deg);
|
|
||||||
--color-tertiary-700: oklch(44% 0.115 220deg);
|
|
||||||
--color-tertiary-800: oklch(36% 0.09 220deg);
|
|
||||||
--color-tertiary-900: oklch(29% 0.065 220deg);
|
|
||||||
--color-tertiary-950: oklch(21% 0.045 220deg);
|
|
||||||
|
|
||||||
/* Surface: Material-nahe kühle Grautöne */
|
|
||||||
--color-surface-50: oklch(98.5% 0.004 255deg);
|
|
||||||
--color-surface-100: oklch(96.5% 0.006 255deg);
|
|
||||||
--color-surface-200: oklch(92.5% 0.01 255deg);
|
|
||||||
--color-surface-300: oklch(86.5% 0.015 255deg);
|
|
||||||
--color-surface-400: oklch(74% 0.02 255deg);
|
|
||||||
--color-surface-500: oklch(62% 0.025 255deg);
|
|
||||||
--color-surface-600: oklch(50% 0.025 255deg);
|
|
||||||
--color-surface-700: oklch(39% 0.025 255deg);
|
|
||||||
--color-surface-800: oklch(29% 0.025 255deg);
|
|
||||||
--color-surface-900: oklch(21% 0.025 255deg);
|
|
||||||
--color-surface-950: oklch(14% 0.025 255deg);
|
|
||||||
|
|
||||||
--color-surface-contrast-dark: var(--color-surface-950);
|
|
||||||
--color-surface-contrast-light: white;
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
export type XJustizMetadata = {
|
||||||
|
sender: string;
|
||||||
|
receiver: string;
|
||||||
|
documentNames: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
const XML_TEXT_DECODER = new TextDecoder();
|
||||||
|
|
||||||
|
const matchesElementName = (element: Element, requestedName: string) => {
|
||||||
|
const requestedLocalName = requestedName.split('.').pop() ?? requestedName;
|
||||||
|
|
||||||
|
return (
|
||||||
|
element.localName === requestedName ||
|
||||||
|
element.localName === requestedLocalName ||
|
||||||
|
element.tagName === requestedName ||
|
||||||
|
element.tagName === requestedLocalName ||
|
||||||
|
element.tagName.endsWith(`:${requestedLocalName}`)
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getDirectChildByLocalName = (element: Element, localName: string) =>
|
||||||
|
Array.from(element.children).find((child) => matchesElementName(child, localName)) ?? null;
|
||||||
|
|
||||||
|
const getFirstDescendantByLocalName = (root: Document | Element, localName: string) =>
|
||||||
|
Array.from(root.querySelectorAll('*')).find((element) =>
|
||||||
|
matchesElementName(element, localName)
|
||||||
|
) ?? null;
|
||||||
|
|
||||||
|
const getTextContentByPath = (root: Element, path: string[]) => {
|
||||||
|
let current: Element | null = root;
|
||||||
|
|
||||||
|
for (const localName of path) {
|
||||||
|
current = current ? getDirectChildByLocalName(current, localName) : null;
|
||||||
|
|
||||||
|
if (!current) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const textContent = current.textContent?.trim();
|
||||||
|
|
||||||
|
return textContent ? textContent : null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getTextContentByLocalName = (root: Document | Element, localName: string) =>
|
||||||
|
getFirstDescendantByLocalName(root, localName)?.textContent?.trim() ?? null;
|
||||||
|
|
||||||
|
const parseXmlDocument = (xmlBytes: Uint8Array) => {
|
||||||
|
const parser = new DOMParser();
|
||||||
|
const document = parser.parseFromString(XML_TEXT_DECODER.decode(xmlBytes), 'application/xml');
|
||||||
|
|
||||||
|
if (document.querySelector('parsererror')) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return document;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const parseXJustizMetadata = (xmlBytes: Uint8Array): XJustizMetadata | null => {
|
||||||
|
const document = parseXmlDocument(xmlBytes);
|
||||||
|
|
||||||
|
if (!document) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sender = getTextContentByLocalName(document, 'aktenzeichen.absender') ?? '';
|
||||||
|
const receiver = getTextContentByLocalName(document, 'aktenzeichen.empfaenger') ?? '';
|
||||||
|
const schriftgutobjekte = getFirstDescendantByLocalName(document, 'schriftgutobjekte');
|
||||||
|
|
||||||
|
if (!schriftgutobjekte) {
|
||||||
|
return {
|
||||||
|
sender,
|
||||||
|
receiver,
|
||||||
|
documentNames: []
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const dokumentNodes = Array.from(schriftgutobjekte.children).filter((child) =>
|
||||||
|
matchesElementName(child, 'dokument')
|
||||||
|
);
|
||||||
|
|
||||||
|
const documentNames = dokumentNodes
|
||||||
|
.map((dokument) =>
|
||||||
|
getTextContentByPath(dokument, ['xjustiz.fachspezifischeDaten', 'datei', 'dateiname'])
|
||||||
|
)
|
||||||
|
.filter((name): name is string => Boolean(name));
|
||||||
|
|
||||||
|
return {
|
||||||
|
sender,
|
||||||
|
receiver,
|
||||||
|
documentNames
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import { unzip } from 'fflate';
|
||||||
|
|
||||||
|
export const unzipArchive = (data: Uint8Array) =>
|
||||||
|
new Promise<Record<string, Uint8Array>>((resolve, reject) => {
|
||||||
|
unzip(data, (error, files) => {
|
||||||
|
if (error) {
|
||||||
|
reject(error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
resolve(files);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import { clsx, type ClassValue } from "clsx";
|
||||||
|
import { twMerge } from "tailwind-merge";
|
||||||
|
|
||||||
|
export function cn(...inputs: ClassValue[]) {
|
||||||
|
return twMerge(clsx(inputs));
|
||||||
|
}
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
export type WithoutChild<T> = T extends { child?: any } ? Omit<T, "child"> : T;
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
export type WithoutChildren<T> = T extends { children?: any } ? Omit<T, "children"> : T;
|
||||||
|
export type WithoutChildrenOrChild<T> = WithoutChildren<WithoutChild<T>>;
|
||||||
|
export type WithElementRef<T, U extends HTMLElement = HTMLElement> = T & { ref?: U | null };
|
||||||
@@ -0,0 +1,628 @@
|
|||||||
|
import { PDFDocument } from 'pdf-lib';
|
||||||
|
|
||||||
|
import { parseXJustizMetadata, type XJustizMetadata } from './services/xml-reading.service';
|
||||||
|
import { unzipArchive } from './services/zip-inflating.service';
|
||||||
|
|
||||||
|
export type ZipAttachmentKind = 'pdf' | 'image';
|
||||||
|
|
||||||
|
export type ZipAttachment = {
|
||||||
|
name: string;
|
||||||
|
path: string;
|
||||||
|
kind: ZipAttachmentKind;
|
||||||
|
data: Uint8Array;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type SubDocument = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
attachments: ZipAttachment[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ProcessedZipArchive = {
|
||||||
|
name: string;
|
||||||
|
attachments: ZipAttachment[];
|
||||||
|
subDocuments: SubDocument[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ExportMode = 'single' | 'separate';
|
||||||
|
|
||||||
|
export type ExportUnit = {
|
||||||
|
name: string;
|
||||||
|
bytes: Uint8Array;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Drag source/target descriptors shared between the archive editor and the
|
||||||
|
* sub-document editor so HTML5 `dataTransfer` payloads stay consistent.
|
||||||
|
*/
|
||||||
|
export type AttachmentLocation =
|
||||||
|
| { kind: 'loose'; index: number }
|
||||||
|
| { kind: 'subDocument'; id: string; index: number };
|
||||||
|
|
||||||
|
const IMAGE_EXTENSIONS = new Set([
|
||||||
|
'.avif',
|
||||||
|
'.bmp',
|
||||||
|
'.gif',
|
||||||
|
'.heic',
|
||||||
|
'.jpeg',
|
||||||
|
'.jpg',
|
||||||
|
'.jp2',
|
||||||
|
'.png',
|
||||||
|
'.tif',
|
||||||
|
'.tiff',
|
||||||
|
'.webp'
|
||||||
|
]);
|
||||||
|
|
||||||
|
const ZIP_META_FILE_NAME = 'xjustiz_nachricht.xml';
|
||||||
|
|
||||||
|
const getBaseName = (path: string) => path.split('/').pop() ?? path;
|
||||||
|
|
||||||
|
const getCurrentDateString = (date: Date) =>
|
||||||
|
`${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`;
|
||||||
|
|
||||||
|
const createArchiveName = (date: Date, metadata: XJustizMetadata | null) => {
|
||||||
|
const datePrefix = getCurrentDateString(date);
|
||||||
|
|
||||||
|
if (!metadata) {
|
||||||
|
return `${datePrefix}_unbekannt_unbekannt.pdf`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${datePrefix}_${metadata.sender}_${metadata.receiver}.pdf`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getExtension = (path: string) => {
|
||||||
|
const baseName = getBaseName(path).toLowerCase();
|
||||||
|
const lastDotIndex = baseName.lastIndexOf('.');
|
||||||
|
|
||||||
|
return lastDotIndex === -1 ? '' : baseName.slice(lastDotIndex);
|
||||||
|
};
|
||||||
|
|
||||||
|
const isPdf = (path: string) => getExtension(path) === '.pdf';
|
||||||
|
|
||||||
|
const isImage = (path: string) => IMAGE_EXTENSIONS.has(getExtension(path));
|
||||||
|
|
||||||
|
const isZipArchive = (path: string) => getExtension(path) === '.zip';
|
||||||
|
|
||||||
|
const normalizeKey = (value: string) => value.replaceAll('\\', '/').toLowerCase().trim();
|
||||||
|
|
||||||
|
const isMacMetadataEntry = (path: string) => {
|
||||||
|
const normalizedPath = normalizeKey(path);
|
||||||
|
const baseName = getBaseName(normalizedPath);
|
||||||
|
|
||||||
|
return (
|
||||||
|
normalizedPath.startsWith('__macosx/') ||
|
||||||
|
normalizedPath.includes('/__macosx/') ||
|
||||||
|
baseName === '.ds_store' ||
|
||||||
|
baseName.startsWith('._')
|
||||||
|
);
|
||||||
|
};
|
||||||
|
const orderAttachmentsByDocumentNames = (attachments: ZipAttachment[], documentNames: string[]) => {
|
||||||
|
const buckets = new Map<string, ZipAttachment[]>();
|
||||||
|
|
||||||
|
for (const attachment of attachments) {
|
||||||
|
const keys = new Set([normalizeKey(attachment.path), normalizeKey(attachment.name)]);
|
||||||
|
|
||||||
|
for (const key of keys) {
|
||||||
|
const bucket = buckets.get(key) ?? [];
|
||||||
|
bucket.push(attachment);
|
||||||
|
buckets.set(key, bucket);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const usedAttachments = new Set<ZipAttachment>();
|
||||||
|
const orderedAttachments: ZipAttachment[] = [];
|
||||||
|
|
||||||
|
const takeAttachment = (key: string) => {
|
||||||
|
const bucket = buckets.get(key);
|
||||||
|
|
||||||
|
if (!bucket) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextAttachment = bucket.find((attachment) => !usedAttachments.has(attachment)) ?? null;
|
||||||
|
|
||||||
|
if (nextAttachment) {
|
||||||
|
usedAttachments.add(nextAttachment);
|
||||||
|
}
|
||||||
|
|
||||||
|
return nextAttachment;
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const documentName of documentNames) {
|
||||||
|
const normalizedName = normalizeKey(documentName);
|
||||||
|
const normalizedBaseName = normalizeKey(getBaseName(documentName));
|
||||||
|
const matchedAttachment = takeAttachment(normalizedName) ?? takeAttachment(normalizedBaseName);
|
||||||
|
|
||||||
|
if (matchedAttachment) {
|
||||||
|
orderedAttachments.push(matchedAttachment);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const attachment of attachments) {
|
||||||
|
if (!usedAttachments.has(attachment)) {
|
||||||
|
orderedAttachments.push(attachment);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return orderedAttachments;
|
||||||
|
};
|
||||||
|
|
||||||
|
const shouldSkipZipEntry = (path: string) => isMacMetadataEntry(path);
|
||||||
|
|
||||||
|
const isMetadataFile = (path: string) => getBaseName(path).toLowerCase() === ZIP_META_FILE_NAME;
|
||||||
|
|
||||||
|
const createAttachment = (path: string, data: Uint8Array): ZipAttachment => ({
|
||||||
|
name: getBaseName(path),
|
||||||
|
path,
|
||||||
|
kind: isPdf(path) ? 'pdf' : 'image',
|
||||||
|
data
|
||||||
|
});
|
||||||
|
|
||||||
|
const extractZipEntries = async (archiveBytes: Uint8Array): Promise<ProcessedZipArchive[]> => {
|
||||||
|
const files = await unzipArchive(archiveBytes);
|
||||||
|
const attachments: ZipAttachment[] = [];
|
||||||
|
const nestedArchives: ProcessedZipArchive[] = [];
|
||||||
|
let xjustizNachrichtXml: Uint8Array | null = null;
|
||||||
|
|
||||||
|
for (const [path, data] of Object.entries(files)) {
|
||||||
|
if (shouldSkipZipEntry(path)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isMetadataFile(path)) {
|
||||||
|
xjustizNachrichtXml = data;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isZipArchive(path)) {
|
||||||
|
const childArchives = await extractZipEntries(data);
|
||||||
|
nestedArchives.push(...childArchives);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isPdf(path) || isImage(path)) {
|
||||||
|
attachments.push(createAttachment(path, data));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const metadata = xjustizNachrichtXml ? parseXJustizMetadata(xjustizNachrichtXml) : null;
|
||||||
|
const documentNames = metadata?.documentNames ?? [];
|
||||||
|
const orderedAttachments = orderAttachmentsByDocumentNames(attachments, documentNames);
|
||||||
|
const archiveName = createArchiveName(new Date(), metadata);
|
||||||
|
const archives: ProcessedZipArchive[] = [];
|
||||||
|
|
||||||
|
if (orderedAttachments.length > 0) {
|
||||||
|
archives.push({
|
||||||
|
name: archiveName,
|
||||||
|
attachments: orderedAttachments,
|
||||||
|
subDocuments: []
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return [...archives, ...nestedArchives];
|
||||||
|
};
|
||||||
|
|
||||||
|
export const extractZipArchives = async (file: File): Promise<ProcessedZipArchive[]> => {
|
||||||
|
const archiveBytes = new Uint8Array(await file.arrayBuffer());
|
||||||
|
|
||||||
|
return extractZipEntries(archiveBytes);
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadImageElement = (src: string) =>
|
||||||
|
new Promise<HTMLImageElement>((resolve, reject) => {
|
||||||
|
const image = new Image();
|
||||||
|
image.decoding = 'async';
|
||||||
|
image.onload = () => resolve(image);
|
||||||
|
image.onerror = () => reject(new Error(`Unable to decode image asset: ${src}`));
|
||||||
|
image.src = src;
|
||||||
|
});
|
||||||
|
|
||||||
|
const toArrayBuffer = (bytes: Uint8Array) =>
|
||||||
|
(() => {
|
||||||
|
const copy = new Uint8Array(bytes.byteLength);
|
||||||
|
copy.set(bytes);
|
||||||
|
return copy.buffer;
|
||||||
|
})();
|
||||||
|
|
||||||
|
const convertImageBytesToPng = async (imageBytes: Uint8Array) => {
|
||||||
|
if (typeof document === 'undefined') {
|
||||||
|
throw new Error('Image conversion requires a browser environment.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const objectUrl = URL.createObjectURL(new Blob([toArrayBuffer(imageBytes)]));
|
||||||
|
|
||||||
|
try {
|
||||||
|
const image = await loadImageElement(objectUrl);
|
||||||
|
const canvas = document.createElement('canvas');
|
||||||
|
canvas.width = image.naturalWidth || image.width;
|
||||||
|
canvas.height = image.naturalHeight || image.height;
|
||||||
|
|
||||||
|
const context = canvas.getContext('2d');
|
||||||
|
|
||||||
|
if (!context) {
|
||||||
|
throw new Error('Could not create a canvas context for image conversion.');
|
||||||
|
}
|
||||||
|
|
||||||
|
context.drawImage(image, 0, 0);
|
||||||
|
|
||||||
|
const pngBlob = await new Promise<Blob>((resolve, reject) => {
|
||||||
|
canvas.toBlob((blob) => {
|
||||||
|
if (blob) {
|
||||||
|
resolve(blob);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
reject(new Error('Could not encode image asset as PNG.'));
|
||||||
|
}, 'image/png');
|
||||||
|
});
|
||||||
|
|
||||||
|
return new Uint8Array(await pngBlob.arrayBuffer());
|
||||||
|
} finally {
|
||||||
|
URL.revokeObjectURL(objectUrl);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const embedAttachmentImage = async (pdfDocument: PDFDocument, attachment: ZipAttachment) => {
|
||||||
|
const extension = getExtension(attachment.path);
|
||||||
|
const imageData = attachment.data;
|
||||||
|
|
||||||
|
if (extension === '.jpg' || extension === '.jpeg') {
|
||||||
|
return pdfDocument.embedJpg(imageData);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (extension === '.png') {
|
||||||
|
return pdfDocument.embedPng(imageData);
|
||||||
|
}
|
||||||
|
|
||||||
|
const pngBytes = await convertImageBytesToPng(imageData);
|
||||||
|
|
||||||
|
return pdfDocument.embedPng(pngBytes);
|
||||||
|
};
|
||||||
|
|
||||||
|
const mergeAttachmentIntoPdf = async (mergedPdf: PDFDocument, attachment: ZipAttachment) => {
|
||||||
|
if (attachment.kind === 'pdf') {
|
||||||
|
const sourcePdf = await PDFDocument.load(attachment.data, { ignoreEncryption: true });
|
||||||
|
const copiedPages = await mergedPdf.copyPages(sourcePdf, sourcePdf.getPageIndices());
|
||||||
|
|
||||||
|
for (const page of copiedPages) {
|
||||||
|
mergedPdf.addPage(page);
|
||||||
|
}
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const embeddedImage = await embedAttachmentImage(mergedPdf, attachment);
|
||||||
|
const page = mergedPdf.addPage([embeddedImage.width, embeddedImage.height]);
|
||||||
|
|
||||||
|
page.drawImage(embeddedImage, {
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
width: page.getWidth(),
|
||||||
|
height: page.getHeight()
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const mergeAttachments = async (attachments: ZipAttachment[]): Promise<Uint8Array> => {
|
||||||
|
if (attachments.length === 0) {
|
||||||
|
throw new Error('Cannot create a PDF from an empty attachment list.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const mergedPdf = await PDFDocument.create();
|
||||||
|
|
||||||
|
for (const attachment of attachments) {
|
||||||
|
await mergeAttachmentIntoPdf(mergedPdf, attachment);
|
||||||
|
}
|
||||||
|
|
||||||
|
return mergedPdf.save();
|
||||||
|
};
|
||||||
|
|
||||||
|
export const mergeProcessedZipArchive = async (archive: ProcessedZipArchive) => {
|
||||||
|
if (archive.attachments.length === 0 && archive.subDocuments.length === 0) {
|
||||||
|
throw new Error('Cannot create a PDF from an empty archive.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return mergeAttachments(flattenArchiveForMerge(archive));
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Flatten the archive into a single ordered attachment list, preserving
|
||||||
|
* sub-document order first and appending loose attachments at the end.
|
||||||
|
*/
|
||||||
|
export const flattenArchiveForMerge = (archive: ProcessedZipArchive): ZipAttachment[] => {
|
||||||
|
const ordered: ZipAttachment[] = [];
|
||||||
|
|
||||||
|
for (const subDocument of archive.subDocuments) {
|
||||||
|
ordered.push(...subDocument.attachments);
|
||||||
|
}
|
||||||
|
|
||||||
|
ordered.push(...archive.attachments);
|
||||||
|
|
||||||
|
return ordered;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new sub-document with a stable id from a list of attachments.
|
||||||
|
*/
|
||||||
|
export const createSubDocument = (name: string, attachments: ZipAttachment[]): SubDocument => ({
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
name,
|
||||||
|
attachments: attachments.map((attachment) => ({ ...attachment }))
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reorder sub-documents inside an archive. Matches the semantics of a single
|
||||||
|
* `splice(fromIndex, 1)` -> `splice(toIndex, 0, moved)` move.
|
||||||
|
*/
|
||||||
|
export const moveSubDocument = (
|
||||||
|
archive: ProcessedZipArchive,
|
||||||
|
fromIndex: number,
|
||||||
|
toIndex: number
|
||||||
|
): ProcessedZipArchive => {
|
||||||
|
if (
|
||||||
|
fromIndex === toIndex ||
|
||||||
|
fromIndex < 0 ||
|
||||||
|
toIndex < 0 ||
|
||||||
|
fromIndex >= archive.subDocuments.length ||
|
||||||
|
toIndex >= archive.subDocuments.length
|
||||||
|
) {
|
||||||
|
return archive;
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextSubDocuments = [...archive.subDocuments];
|
||||||
|
const [movedSubDocument] = nextSubDocuments.splice(fromIndex, 1);
|
||||||
|
|
||||||
|
nextSubDocuments.splice(toIndex, 0, movedSubDocument);
|
||||||
|
|
||||||
|
return { ...archive, subDocuments: nextSubDocuments };
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeAt = <T>(list: T[], index: number): T | null => {
|
||||||
|
if (index < 0 || index >= list.length) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const [item] = list.splice(index, 1);
|
||||||
|
|
||||||
|
return item ?? null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const insertAt = <T>(list: T[], index: number, item: T) => {
|
||||||
|
const clampedIndex = Math.max(0, Math.min(index, list.length));
|
||||||
|
list.splice(clampedIndex, 0, item);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reorder an attachment within its current location (loose list or a single
|
||||||
|
* sub-document's attachment list). Used by the per-list drag handlers.
|
||||||
|
*/
|
||||||
|
export const moveAttachmentInPlace = (
|
||||||
|
archive: ProcessedZipArchive,
|
||||||
|
location: AttachmentLocation,
|
||||||
|
toIndex: number
|
||||||
|
): ProcessedZipArchive => {
|
||||||
|
if (location.kind === 'loose') {
|
||||||
|
const nextAttachments = [...archive.attachments];
|
||||||
|
const moved = removeAt(nextAttachments, location.index);
|
||||||
|
|
||||||
|
if (!moved) {
|
||||||
|
return archive;
|
||||||
|
}
|
||||||
|
|
||||||
|
insertAt(nextAttachments, toIndex, moved);
|
||||||
|
|
||||||
|
return { ...archive, attachments: nextAttachments };
|
||||||
|
}
|
||||||
|
|
||||||
|
let changed = false;
|
||||||
|
|
||||||
|
const nextSubDocuments = archive.subDocuments.map((subDocument) => {
|
||||||
|
if (subDocument.id !== location.id) {
|
||||||
|
return subDocument;
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextAttachments = [...subDocument.attachments];
|
||||||
|
const moved = removeAt(nextAttachments, location.index);
|
||||||
|
|
||||||
|
if (!moved) {
|
||||||
|
return subDocument;
|
||||||
|
}
|
||||||
|
|
||||||
|
insertAt(nextAttachments, toIndex, moved);
|
||||||
|
changed = true;
|
||||||
|
|
||||||
|
return { ...subDocument, attachments: nextAttachments };
|
||||||
|
});
|
||||||
|
|
||||||
|
return changed ? { ...archive, subDocuments: nextSubDocuments } : archive;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Move an attachment from a source location to a target location. Either side
|
||||||
|
* may be the loose list or any sub-document. The attachment moves (not copies),
|
||||||
|
* so the total attachment count is invariant. When source and target point to
|
||||||
|
* the same list, this degenerates to an in-place reorder (explicitly delegated
|
||||||
|
* to `moveAttachmentInPlace` so the indices are normalized correctly).
|
||||||
|
*/
|
||||||
|
export const moveAttachmentIntoSubDocument = (
|
||||||
|
archive: ProcessedZipArchive,
|
||||||
|
source: AttachmentLocation,
|
||||||
|
target: AttachmentLocation
|
||||||
|
): ProcessedZipArchive => {
|
||||||
|
const sameList =
|
||||||
|
source.kind === 'loose' && target.kind === 'loose'
|
||||||
|
? true
|
||||||
|
: source.kind === 'subDocument' && target.kind === 'subDocument'
|
||||||
|
? source.id === target.id
|
||||||
|
: false;
|
||||||
|
|
||||||
|
if (sameList) {
|
||||||
|
return moveAttachmentInPlace(archive, source, target.index);
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextSubDocuments = archive.subDocuments.map((subDocument) => ({
|
||||||
|
...subDocument,
|
||||||
|
attachments: [...subDocument.attachments]
|
||||||
|
}));
|
||||||
|
const looseAttachments = [...archive.attachments];
|
||||||
|
|
||||||
|
const removeFromList = (location: AttachmentLocation): ZipAttachment | null => {
|
||||||
|
if (location.kind === 'loose') {
|
||||||
|
return removeAt(looseAttachments, location.index);
|
||||||
|
}
|
||||||
|
|
||||||
|
const target_ = nextSubDocuments.find((item) => item.id === location.id);
|
||||||
|
|
||||||
|
return target_ ? removeAt(target_.attachments, location.index) : null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const insertIntoList = (attachment: ZipAttachment, location: AttachmentLocation) => {
|
||||||
|
if (location.kind === 'loose') {
|
||||||
|
insertAt(looseAttachments, location.index, attachment);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const target_ = nextSubDocuments.find((item) => item.id === location.id);
|
||||||
|
|
||||||
|
if (!target_) {
|
||||||
|
// fall back to loose list end if the referenced sub-document vanished
|
||||||
|
looseAttachments.push(attachment);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
insertAt(target_.attachments, location.index, attachment);
|
||||||
|
};
|
||||||
|
|
||||||
|
const movedAttachment = removeFromList(source);
|
||||||
|
|
||||||
|
if (!movedAttachment) {
|
||||||
|
return archive;
|
||||||
|
}
|
||||||
|
|
||||||
|
insertIntoList(movedAttachment, target);
|
||||||
|
|
||||||
|
return {
|
||||||
|
...archive,
|
||||||
|
attachments: looseAttachments,
|
||||||
|
subDocuments: nextSubDocuments
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove a single attachment from a sub-document and return it to the end of
|
||||||
|
* the loose list. Useful as a targeted "move back to loose" action.
|
||||||
|
*/
|
||||||
|
export const removeAttachmentFromSubDocument = (
|
||||||
|
archive: ProcessedZipArchive,
|
||||||
|
subDocumentId: string,
|
||||||
|
attachmentIndex: number
|
||||||
|
): ProcessedZipArchive => {
|
||||||
|
const nextSubDocuments = archive.subDocuments.map((subDocument) => ({
|
||||||
|
...subDocument,
|
||||||
|
attachments: [...subDocument.attachments]
|
||||||
|
}));
|
||||||
|
const nextLooseAttachments = [...archive.attachments];
|
||||||
|
|
||||||
|
const target_ = nextSubDocuments.find((item) => item.id === subDocumentId);
|
||||||
|
|
||||||
|
if (!target_) {
|
||||||
|
return archive;
|
||||||
|
}
|
||||||
|
|
||||||
|
const moved = removeAt(target_.attachments, attachmentIndex);
|
||||||
|
|
||||||
|
if (!moved) {
|
||||||
|
return archive;
|
||||||
|
}
|
||||||
|
|
||||||
|
nextLooseAttachments.push(moved);
|
||||||
|
|
||||||
|
return {
|
||||||
|
...archive,
|
||||||
|
attachments: nextLooseAttachments,
|
||||||
|
subDocuments: nextSubDocuments
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove an entire sub-document and return its attachments to the end of the
|
||||||
|
* loose list. The sub-document ids of the remaining sub-documents are
|
||||||
|
* preserved.
|
||||||
|
*/
|
||||||
|
export const dissolveSubDocument = (
|
||||||
|
archive: ProcessedZipArchive,
|
||||||
|
subDocumentId: string
|
||||||
|
): ProcessedZipArchive => {
|
||||||
|
const dissolved = archive.subDocuments.find((item) => item.id === subDocumentId);
|
||||||
|
|
||||||
|
if (!dissolved) {
|
||||||
|
return archive;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...archive,
|
||||||
|
attachments: [...archive.attachments, ...dissolved.attachments],
|
||||||
|
subDocuments: archive.subDocuments.filter((item) => item.id !== subDocumentId)
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const stripPdfExtension = (name: string) =>
|
||||||
|
name.toLowerCase().endsWith('.pdf') ? name.slice(0, -'.pdf'.length) : name;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build one or more PDF export units from the archive.
|
||||||
|
*
|
||||||
|
* - `mode: 'single'` mirrors today's behaviour: one merged PDF covering
|
||||||
|
* sub-documents first, then loose attachments, named after the archive.
|
||||||
|
* - `mode: 'separate'` produces one PDF per sub-document (named
|
||||||
|
* `{archiveName} - {subDocumentName}.pdf`) plus a single merged PDF for any
|
||||||
|
* loose attachments (named after the archive). Empty groups are skipped.
|
||||||
|
*/
|
||||||
|
export const buildArchiveExport = async (
|
||||||
|
archive: ProcessedZipArchive,
|
||||||
|
mode: ExportMode
|
||||||
|
): Promise<ExportUnit[]> => {
|
||||||
|
if (mode === 'single') {
|
||||||
|
const attachments = flattenArchiveForMerge(archive);
|
||||||
|
|
||||||
|
if (attachments.length === 0) {
|
||||||
|
throw new Error('Cannot create a PDF from an empty archive.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
name: stripPdfExtension(archive.name),
|
||||||
|
bytes: await mergeAttachments(attachments)
|
||||||
|
}
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
const separateUnits: ExportUnit[] = [];
|
||||||
|
|
||||||
|
for (const subDocument of archive.subDocuments) {
|
||||||
|
if (subDocument.attachments.length === 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
separateUnits.push({
|
||||||
|
name: `${stripPdfExtension(archive.name)} - ${stripPdfExtension(subDocument.name)}`,
|
||||||
|
bytes: await mergeAttachments(subDocument.attachments)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (archive.attachments.length > 0) {
|
||||||
|
separateUnits.push({
|
||||||
|
name: stripPdfExtension(archive.name),
|
||||||
|
bytes: await mergeAttachments(archive.attachments)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (separateUnits.length === 0) {
|
||||||
|
throw new Error('Cannot create a PDF from an empty archive.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return separateUnits;
|
||||||
|
};
|
||||||
@@ -18,16 +18,9 @@
|
|||||||
<footer class="flex flex-col justify-end gap-15 min-h-78.5 h-96 px-4 mt-8.75 text-white py-8.75"
|
<footer class="flex flex-col justify-end gap-15 min-h-78.5 h-96 px-4 mt-8.75 text-white py-8.75"
|
||||||
style="background-image: url('/footer.svg');background-position: top right; background-size: cover; background-repeat: no-repeat">
|
style="background-image: url('/footer.svg');background-position: top right; background-size: cover; background-repeat: no-repeat">
|
||||||
<div class="w-full max-w-360 mx-auto">
|
<div class="w-full max-w-360 mx-auto">
|
||||||
<h2 class="hidden font-bold mb-4">Nominandum GmbH</h2>
|
<h2 class="font-bold mb-4">Nominandum GmbH</h2>
|
||||||
|
|
||||||
<div class="flex flex-row flex-wrap gap-15 text-[14px] leading-5 mx-auto">
|
<div class="flex flex-row flex-wrap gap-15 text-[14px] leading-5 mx-auto">
|
||||||
<div>
|
|
||||||
<a class="underline" href="/impressum">Impressum</a><br/>
|
|
||||||
<a class="underline" href="/datenschutz">Datenschutzerklärung</a><br/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="hidden flex flex-row flex-wrap gap-15 text-[14px] leading-5 mx-auto">
|
|
||||||
<div class="not-sm:hidden">
|
<div class="not-sm:hidden">
|
||||||
Holstenwall 1<br/>
|
Holstenwall 1<br/>
|
||||||
20355 Hamburg<br/>
|
20355 Hamburg<br/>
|
||||||
@@ -41,8 +34,8 @@
|
|||||||
UstId. DE321802543
|
UstId. DE321802543
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<a class="underline" href="/impressum">Impressum</a><br/>
|
<a class="underline" href="">Impressum</a><br/>
|
||||||
<a class="underline" href="/datenschutz">Datenschutzerklärung</a><br/>
|
<a class="underline" href="">Datenschutzerklärung</a><br/>
|
||||||
<a class="underline" href="">Nutzungsbedingungen</a><br/>
|
<a class="underline" href="">Nutzungsbedingungen</a><br/>
|
||||||
</div>
|
</div>
|
||||||
<div class="grow">
|
<div class="grow">
|
||||||
|
|||||||
+189
-13
@@ -1,35 +1,211 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import Card from "$lib/components/Card.svelte";
|
import {onMount} from 'svelte';
|
||||||
import ZipDropzone from "$lib/components/ZipDropzone.svelte";
|
import Button from '$lib/components/ui/button.svelte';
|
||||||
|
import ProcessedZipArchiveEditor from '$lib/components/ProcessedZipArchiveEditor.svelte';
|
||||||
|
import ZipDropzone from '$lib/components/ZipDropzone.svelte';
|
||||||
|
import {
|
||||||
|
extractZipArchives,
|
||||||
|
mergeProcessedZipArchive,
|
||||||
|
type ProcessedZipArchive
|
||||||
|
} from '$lib/zip-processing';
|
||||||
|
import * as Card from "$lib/components/ui/card/index";
|
||||||
|
|
||||||
|
const THUMBNAIL_WIDTH_STORAGE_KEY = 'thumbnailWidth';
|
||||||
|
|
||||||
let selectedZipFiles: File[] = $state([]);
|
let selectedZipFiles: File[] = $state([]);
|
||||||
|
let processedZipFiles: ProcessedZipArchive[] = $state([]);
|
||||||
|
let pendingZipCount = $state(0);
|
||||||
|
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;
|
||||||
|
});
|
||||||
|
|
||||||
|
$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[]) => {
|
const handleFilesSelected = (files: File[]) => {
|
||||||
selectedZipFiles = [...selectedZipFiles, ...files];
|
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()}
|
{#snippet content()}
|
||||||
{#if selectedZipFiles.length === 0}
|
{#if selectedZipFiles.length === 0}
|
||||||
|
<Card.Root>
|
||||||
|
<Card.Content>
|
||||||
<ZipDropzone onFilesSelected={handleFilesSelected}/>
|
<ZipDropzone onFilesSelected={handleFilesSelected}/>
|
||||||
|
</Card.Content>
|
||||||
|
</Card.Root>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="flex min-h-[calc(-180px+100vh)] w-full grow flex-col justify-center gap-4 px-4 py-6">
|
<details class="fixed bottom-4 right-4 z-20">
|
||||||
<div class="flex flex-row items-baseline justify-center gap-2">
|
<summary
|
||||||
<h2 class="h1 text-[20px] font-bold text-primary-900">ZIP-Dateien geladen</h2>
|
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
|
||||||
|
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
|
||||||
|
class="flex-1"
|
||||||
|
type="button"
|
||||||
|
disabled={selectedZipFiles.length === 0}
|
||||||
|
variant="secondary"
|
||||||
|
onclick={deleteAllZipArchives}
|
||||||
|
>
|
||||||
|
Alle ZIP-Archive löschen
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ul class="mx-auto w-full max-w-3xl space-y-2">
|
<label class="flex flex-col gap-2">
|
||||||
{#each selectedZipFiles as file}
|
<div
|
||||||
<li class="rounded-container bg-primary-50 px-4 py-3 text-[14px] font-medium text-primary-900">
|
class="flex items-center justify-between gap-3 text-sm font-medium text-primary-900"
|
||||||
{file.name}
|
>
|
||||||
</li>
|
<span>Thumbnail Breite</span>
|
||||||
|
<span class="tabular-nums text-primary-700">{thumbnailWidth}px</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<input
|
||||||
|
class="w-full accent-primary-700"
|
||||||
|
type="range"
|
||||||
|
min="100"
|
||||||
|
max="400"
|
||||||
|
step="1"
|
||||||
|
bind:value={thumbnailWidth}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
<div
|
||||||
|
class="flex min-h-[calc(-180px+100vh)] w-full grow flex-col justify-center gap-4 px-4 py-6"
|
||||||
|
>
|
||||||
|
<div class="mx-auto flex w-full flex-col gap-3">
|
||||||
|
{#if pendingZipCount > 0}
|
||||||
|
<div
|
||||||
|
class="rounded-container bg-primary-50 px-4 py-3 text-[14px] font-medium text-primary-900"
|
||||||
|
>
|
||||||
|
Noch {pendingZipCount} ZIP-Datei{pendingZipCount === 1 ? '' : 'en'} in Bearbeitung.
|
||||||
|
</div>
|
||||||
|
{/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}
|
{/each}
|
||||||
</ul>
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
{/snippet}
|
{/snippet}
|
||||||
|
|
||||||
<section class="px-4">
|
<section class="px-4">
|
||||||
<h1 class="h1 text-[20px] font-bold py-6 text-primary-900">beA-Edit</h1>
|
<div class="flex flex-wrap items-center justify-between gap-3 py-6">
|
||||||
<Card {content} class="h-full"/>
|
<h1 class="h1 text-[20px] font-bold text-primary-900">beA-Edit</h1>
|
||||||
|
</div>
|
||||||
|
{@render content()}
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -1,411 +0,0 @@
|
|||||||
<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">
|
|
||||||
<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>
|
|
||||||
@@ -1,97 +0,0 @@
|
|||||||
<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">
|
|
||||||
<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>
|
|
||||||
+151
-6
@@ -1,16 +1,161 @@
|
|||||||
@import 'tailwindcss';
|
@import 'tailwindcss';
|
||||||
@import '@skeletonlabs/skeleton/themes/cerberus';
|
/*@import '../globals.css';*/
|
||||||
@import '@skeletonlabs/skeleton';
|
@import "tw-animate-css";
|
||||||
@import '@skeletonlabs/skeleton-svelte';
|
@import "shadcn-svelte/tailwind.css";
|
||||||
@import '../lib/nom-theme.css';
|
@import "@fontsource-variable/geist";
|
||||||
|
|
||||||
|
@custom-variant dark (&:is(.dark *));
|
||||||
|
|
||||||
@plugin '@tailwindcss/forms';
|
@plugin '@tailwindcss/forms';
|
||||||
@plugin '@tailwindcss/typography';
|
@plugin '@tailwindcss/typography';
|
||||||
|
|
||||||
|
|
||||||
@font-face {
|
@font-face {
|
||||||
font-family: 'Nunito';
|
font-family: 'Nunito';
|
||||||
font-style: normal;
|
font-style: normal;
|
||||||
font-weight: 100 900;
|
font-weight: 100 900;
|
||||||
src: url('$lib/assets/Nunito-VariableFont_wght.ttf') format("truetype");
|
src: url('$lib/assets/Nunito-VariableFont_wght.ttf') format('truetype');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--background: #EEF4F6;
|
||||||
|
--foreground: #122F62;
|
||||||
|
--card: oklch(1 0 0);
|
||||||
|
--card-foreground: oklch(0.145 0 0);
|
||||||
|
--popover: oklch(1 0 0);
|
||||||
|
--popover-foreground: oklch(0.145 0 0);
|
||||||
|
--primary: #122f62;
|
||||||
|
--primary-foreground: oklch(0.985 0 0);
|
||||||
|
--secondary: #EA544B;
|
||||||
|
--secondary-end: #FF6F6D;
|
||||||
|
--secondary-foreground: oklch(0.205 0 0);
|
||||||
|
--muted: oklch(0.97 0 0);
|
||||||
|
--muted-foreground: oklch(0.556 0 0);
|
||||||
|
--accent: #d6dce5;
|
||||||
|
--accent-foreground: oklch(0.205 0 0);
|
||||||
|
--destructive: #ff0027;
|
||||||
|
--border: oklch(0.922 0 0);
|
||||||
|
--input: oklch(0.922 0 0);
|
||||||
|
--ring: oklch(0.708 0 0);
|
||||||
|
--chart-1: oklch(0.87 0 0);
|
||||||
|
--chart-2: oklch(0.556 0 0);
|
||||||
|
--chart-3: oklch(0.439 0 0);
|
||||||
|
--chart-4: oklch(0.371 0 0);
|
||||||
|
--chart-5: oklch(0.269 0 0);
|
||||||
|
--radius: 16px;
|
||||||
|
--sidebar: oklch(0.985 0 0);
|
||||||
|
--sidebar-foreground: oklch(0.145 0 0);
|
||||||
|
--sidebar-primary: oklch(0.205 0 0);
|
||||||
|
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||||
|
--sidebar-accent: oklch(0.97 0 0);
|
||||||
|
--sidebar-accent-foreground: oklch(0.205 0 0);
|
||||||
|
--sidebar-border: oklch(0.922 0 0);
|
||||||
|
--sidebar-ring: oklch(0.708 0 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark {
|
||||||
|
--background: #212121;
|
||||||
|
--foreground: #FFFFFF;
|
||||||
|
--card: oklch(0.205 0 0);
|
||||||
|
--card-foreground: oklch(0.985 0 0);
|
||||||
|
--popover: oklch(0.205 0 0);
|
||||||
|
--popover-foreground: oklch(0.985 0 0);
|
||||||
|
--primary: #92b2eb;
|
||||||
|
--primary-foreground: oklch(0.205 0 0);
|
||||||
|
--secondary: linear-gradient(90deg, #EA544B 0%, #FF6F6D 100%);
|
||||||
|
--secondary-foreground: oklch(0.985 0 0);
|
||||||
|
--muted: oklch(0.269 0 0);
|
||||||
|
--muted-foreground: oklch(0.708 0 0);
|
||||||
|
--accent: oklch(0.269 0 0);
|
||||||
|
--accent-foreground: oklch(0.985 0 0);
|
||||||
|
--destructive: #ff0027;
|
||||||
|
--border: oklch(1 0 0 / 10%);
|
||||||
|
--input: oklch(1 0 0 / 15%);
|
||||||
|
--ring: oklch(0.556 0 0);
|
||||||
|
--chart-1: oklch(0.87 0 0);
|
||||||
|
--chart-2: oklch(0.556 0 0);
|
||||||
|
--chart-3: oklch(0.439 0 0);
|
||||||
|
--chart-4: oklch(0.371 0 0);
|
||||||
|
--chart-5: oklch(0.269 0 0);
|
||||||
|
--sidebar: oklch(0.205 0 0);
|
||||||
|
--sidebar-foreground: oklch(0.985 0 0);
|
||||||
|
--sidebar-primary: oklch(0.488 0.243 264.376);
|
||||||
|
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||||
|
--sidebar-accent: oklch(0.269 0 0);
|
||||||
|
--sidebar-accent-foreground: oklch(0.985 0 0);
|
||||||
|
--sidebar-border: oklch(1 0 0 / 10%);
|
||||||
|
--sidebar-ring: oklch(0.556 0 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
@theme inline {
|
||||||
|
--font-sans: 'Nunito', sans-serif;
|
||||||
|
--color-sidebar-ring: var(--sidebar-ring);
|
||||||
|
--color-sidebar-border: var(--sidebar-border);
|
||||||
|
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||||
|
--color-sidebar-accent: var(--sidebar-accent);
|
||||||
|
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
|
||||||
|
--color-sidebar-primary: var(--sidebar-primary);
|
||||||
|
--color-sidebar-foreground: var(--sidebar-foreground);
|
||||||
|
--color-sidebar: var(--sidebar);
|
||||||
|
--color-chart-5: var(--chart-5);
|
||||||
|
--color-chart-4: var(--chart-4);
|
||||||
|
--color-chart-3: var(--chart-3);
|
||||||
|
--color-chart-2: var(--chart-2);
|
||||||
|
--color-chart-1: var(--chart-1);
|
||||||
|
--color-ring: var(--ring);
|
||||||
|
--color-input: var(--input);
|
||||||
|
--color-border: var(--border);
|
||||||
|
--color-destructive: var(--destructive);
|
||||||
|
--color-accent-foreground: var(--accent-foreground);
|
||||||
|
--color-accent: var(--accent);
|
||||||
|
--color-muted-foreground: var(--muted-foreground);
|
||||||
|
--color-muted: var(--muted);
|
||||||
|
--color-secondary-foreground: var(--secondary-foreground);
|
||||||
|
--color-secondary: var(--secondary);
|
||||||
|
--color-primary-foreground: var(--primary-foreground);
|
||||||
|
--color-primary: var(--primary);
|
||||||
|
--color-popover-foreground: var(--popover-foreground);
|
||||||
|
--color-popover: var(--popover);
|
||||||
|
--color-card-foreground: var(--card-foreground);
|
||||||
|
--color-card: var(--card);
|
||||||
|
--color-foreground: var(--foreground);
|
||||||
|
--color-background: var(--background);
|
||||||
|
--radius-sm: calc(var(--radius) * 0.6);
|
||||||
|
--radius-md: calc(var(--radius) * 0.8);
|
||||||
|
--radius-lg: var(--radius);
|
||||||
|
--radius-xl: calc(var(--radius) * 1.4);
|
||||||
|
--radius-2xl: calc(var(--radius) * 1.8);
|
||||||
|
--radius-3xl: calc(var(--radius) * 2.2);
|
||||||
|
--radius-4xl: calc(var(--radius) * 2.6);
|
||||||
|
}
|
||||||
|
|
||||||
|
@layer base {
|
||||||
|
* {
|
||||||
|
@apply border-border outline-ring/50;
|
||||||
|
}
|
||||||
|
body {
|
||||||
|
@apply bg-background text-foreground;
|
||||||
|
}
|
||||||
|
html {
|
||||||
|
@apply font-sans;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/* ripple */
|
||||||
|
|
||||||
|
@keyframes ripple {
|
||||||
|
from {
|
||||||
|
transform: scale(0);
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
to {
|
||||||
|
transform: scale(4);
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.animate-ripple {
|
||||||
|
animation: ripple 600ms ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
name: Build and Push Docker Image
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- develop
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Docker login to Gitea Registry
|
||||||
|
run: |
|
||||||
|
echo "${{ secrets.REGISTRY_PASSWORD }}" | docker login "${{ vars.REGISTRY_URL }}" \
|
||||||
|
-u "${{ secrets.REGISTRY_USER }}" \
|
||||||
|
--password-stdin
|
||||||
|
|
||||||
|
- name: Build Docker image
|
||||||
|
run: |
|
||||||
|
docker build \
|
||||||
|
-t "${{ vars.REGISTRY_URL }}/${{ vars.IMAGE_NAME }}:dev" \
|
||||||
|
-t "${{ vars.REGISTRY_URL }}/${{ vars.IMAGE_NAME }}:${{ gitea.sha }}" \
|
||||||
|
.
|
||||||
|
|
||||||
|
- name: Push Docker image
|
||||||
|
run: |
|
||||||
|
docker push "${{ vars.REGISTRY_URL }}/${{ vars.IMAGE_NAME }}:dev"
|
||||||
|
docker push "${{ vars.REGISTRY_URL }}/${{ vars.IMAGE_NAME }}:${{ gitea.sha }}"
|
||||||
|
|
||||||
|
- name: Deploy
|
||||||
|
run: |
|
||||||
|
curl https://dokploy.robosoft-solutions.de/api/deploy/compose/Miw8GJXF_v4Ebavr3mJwa
|
||||||
+1
-4
@@ -1,5 +1,5 @@
|
|||||||
import tailwindcss from '@tailwindcss/vite';
|
import tailwindcss from '@tailwindcss/vite';
|
||||||
import adapter from '@sveltejs/adapter-auto';
|
import adapter from '@sveltejs/adapter-node';
|
||||||
import { sveltekit } from '@sveltejs/kit/vite';
|
import { sveltekit } from '@sveltejs/kit/vite';
|
||||||
import { defineConfig } from 'vite';
|
import { defineConfig } from 'vite';
|
||||||
|
|
||||||
@@ -13,9 +13,6 @@ export default defineConfig({
|
|||||||
filename.split(/[/\\]/).includes('node_modules') ? undefined : true
|
filename.split(/[/\\]/).includes('node_modules') ? undefined : true
|
||||||
},
|
},
|
||||||
|
|
||||||
// adapter-auto only supports some environments, see https://svelte.dev/docs/kit/adapter-auto for a list.
|
|
||||||
// If your environment is not supported, or you settled on a specific environment, switch out the adapter.
|
|
||||||
// See https://svelte.dev/docs/kit/adapters for more information about adapters.
|
|
||||||
adapter: adapter()
|
adapter: adapter()
|
||||||
})
|
})
|
||||||
]
|
]
|
||||||
|
|||||||
Reference in New Issue
Block a user