From 982a7a3b73bc7702243e7ac9c5dfd5670ea64b09 Mon Sep 17 00:00:00 2001 From: Peter Meier Date: Thu, 4 Jun 2026 11:15:57 +0200 Subject: [PATCH] feat: QR-Code, vCard-Export, Org-Gruppenansicht im Adressbuch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - vcard.ts: shared vCard-Generator (downloadVCard, downloadVCardAll, buildVCardString) - QrModal.svelte + QrButton.svelte: wiederverwendbare QR-Komponenten mit PNG-Download - AdressbuchBlock: Org-Gruppenansicht (Toggle), QR + vCard pro Kontakt, Buchstaben-Trennzeilen, Such-Highlight, "Alle exportieren", Print-Styles - ContactCard: vCard-Download-Button - PostActions: QR-Button für Seiten-URL - qrcode npm-Paket hinzugefügt Co-Authored-By: Claude Sonnet 4.6 --- package-lock.json | 334 +++++++++++++++++- package.json | 2 + scripts/generate-icons.mjs | 5 + src/lib/components/ContactCard.svelte | 41 ++- src/lib/components/PostActions.svelte | 7 + src/lib/components/QrButton.svelte | 30 ++ src/lib/components/QrModal.svelte | 101 ++++++ .../components/blocks/AdressbuchBlock.svelte | 318 ++++++++++++----- src/lib/iconify-mdi-subset.generated.json | 21 +- src/lib/vcard.ts | 47 +++ 10 files changed, 802 insertions(+), 104 deletions(-) create mode 100644 src/lib/components/QrButton.svelte create mode 100644 src/lib/components/QrModal.svelte create mode 100644 src/lib/vcard.ts diff --git a/package-lock.json b/package-lock.json index fe8259d..9290833 100644 --- a/package-lock.json +++ b/package-lock.json @@ -18,6 +18,7 @@ "class-variance-authority": "^0.7.1", "leaflet": "^1.9.4", "marked": "^17.0.2", + "qrcode": "^1.5.4", "sanitize-html": "^2.13.1", "sharp": "^0.34.5" }, @@ -28,6 +29,7 @@ "@sveltejs/vite-plugin-svelte": "^5.0.3", "@tailwindcss/vite": "^4.1.18", "@types/node": "^22.10.0", + "@types/qrcode": "^1.5.6", "@types/sanitize-html": "^2.13.0", "@vitest/coverage-v8": "^2.1.9", "openapi-typescript": "^7.4.0", @@ -2292,6 +2294,16 @@ "undici-types": "~6.21.0" } }, + "node_modules/@types/qrcode": { + "version": "1.5.6", + "resolved": "https://registry.npmjs.org/@types/qrcode/-/qrcode-1.5.6.tgz", + "integrity": "sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/resolve": { "version": "1.20.2", "resolved": "https://registry.npmjs.org/@types/resolve/-/resolve-1.20.2.tgz", @@ -2573,6 +2585,15 @@ "node": ">=8" } }, + "node_modules/camelcase": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", + "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/chai": { "version": "5.3.3", "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", @@ -2635,6 +2656,87 @@ "url": "https://polar.sh/cva" } }, + "node_modules/cliui": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz", + "integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==", + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.0", + "wrap-ansi": "^6.2.0" + } + }, + "node_modules/cliui/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/cliui/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/cliui/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT" + }, + "node_modules/cliui/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/cliui/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/cliui/node_modules/wrap-ansi": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz", + "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/clsx": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", @@ -2648,7 +2750,6 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dev": true, "license": "MIT", "dependencies": { "color-name": "~1.1.4" @@ -2661,7 +2762,6 @@ "version": "1.1.4", "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true, "license": "MIT" }, "node_modules/colorette": { @@ -2736,6 +2836,15 @@ } } }, + "node_modules/decamelize": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz", + "integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/deep-eql": { "version": "5.0.2", "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", @@ -2770,6 +2879,12 @@ "integrity": "sha512-Gp6rDldRsFh/7XuouDbxMH3Mx8GMCcgzIb1pDTvNyn8pZGQ22u+Wa+lGV9dQCltFQ7uVw0MhRyb8XDskNFOReA==", "license": "MIT" }, + "node_modules/dijkstrajs": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/dijkstrajs/-/dijkstrajs-1.0.3.tgz", + "integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==", + "license": "MIT" + }, "node_modules/dom-serializer": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz", @@ -2996,6 +3111,19 @@ } } }, + "node_modules/find-up": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", + "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "license": "MIT", + "dependencies": { + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/foreground-child": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", @@ -3038,6 +3166,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, "node_modules/glob": { "version": "10.5.0", "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", @@ -3179,7 +3316,6 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -3640,6 +3776,18 @@ "integrity": "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA==", "license": "MIT" }, + "node_modules/locate-path": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", + "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "license": "MIT", + "dependencies": { + "p-locate": "^4.1.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/loupe": { "version": "3.2.1", "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", @@ -3792,6 +3940,42 @@ "typescript": "^5.x" } }, + "node_modules/p-limit": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "license": "MIT", + "dependencies": { + "p-try": "^2.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", + "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", + "license": "MIT", + "dependencies": { + "p-limit": "^2.2.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/p-try": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", + "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/package-json-from-dist": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", @@ -3823,6 +4007,15 @@ "integrity": "sha512-/2qh0lav6CmI15FzA3i/2Bzk2zCgQhGMkvhOhKNcBVQ1ldgpbfiNTVslmooUmWJcADi1f1kIeynbDRVzNlfR6Q==", "license": "MIT" }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/path-key": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", @@ -3903,6 +4096,15 @@ "node": ">=4" } }, + "node_modules/pngjs": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz", + "integrity": "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==", + "license": "MIT", + "engines": { + "node": ">=10.13.0" + } + }, "node_modules/postcss": { "version": "8.5.8", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz", @@ -3931,6 +4133,23 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/qrcode": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz", + "integrity": "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==", + "license": "MIT", + "dependencies": { + "dijkstrajs": "^1.0.1", + "pngjs": "^5.0.0", + "yargs": "^15.3.1" + }, + "bin": { + "qrcode": "bin/qrcode" + }, + "engines": { + "node": ">=10.13.0" + } + }, "node_modules/readdirp": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", @@ -3945,6 +4164,15 @@ "url": "https://paulmillr.com/funding/" } }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/require-from-string": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", @@ -3955,6 +4183,12 @@ "node": ">=0.10.0" } }, + "node_modules/require-main-filename": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz", + "integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==", + "license": "ISC" + }, "node_modules/resolve": { "version": "1.22.11", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", @@ -4060,6 +4294,12 @@ "node": ">=10" } }, + "node_modules/set-blocking": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz", + "integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==", + "license": "ISC" + }, "node_modules/set-cookie-parser": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-3.0.1.tgz", @@ -5792,6 +6032,12 @@ "node": ">= 8" } }, + "node_modules/which-module": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz", + "integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==", + "license": "ISC" + }, "node_modules/why-is-node-running": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", @@ -5907,6 +6153,12 @@ "node": ">=8" } }, + "node_modules/y18n": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz", + "integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==", + "license": "ISC" + }, "node_modules/yaml-ast-parser": { "version": "0.0.43", "resolved": "https://registry.npmjs.org/yaml-ast-parser/-/yaml-ast-parser-0.0.43.tgz", @@ -5914,6 +6166,28 @@ "dev": true, "license": "Apache-2.0" }, + "node_modules/yargs": { + "version": "15.4.1", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz", + "integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==", + "license": "MIT", + "dependencies": { + "cliui": "^6.0.0", + "decamelize": "^1.2.0", + "find-up": "^4.1.0", + "get-caller-file": "^2.0.1", + "require-directory": "^2.1.1", + "require-main-filename": "^2.0.0", + "set-blocking": "^2.0.0", + "string-width": "^4.2.0", + "which-module": "^2.0.0", + "y18n": "^4.0.0", + "yargs-parser": "^18.1.2" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/yargs-parser": { "version": "21.1.1", "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", @@ -5924,6 +6198,60 @@ "node": ">=12" } }, + "node_modules/yargs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/yargs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT" + }, + "node_modules/yargs/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/yargs/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/yargs/node_modules/yargs-parser": { + "version": "18.1.3", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz", + "integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==", + "license": "ISC", + "dependencies": { + "camelcase": "^5.0.0", + "decamelize": "^1.2.0" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/zimmerframe": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/zimmerframe/-/zimmerframe-1.1.4.tgz", diff --git a/package.json b/package.json index daa9c62..f8f151c 100644 --- a/package.json +++ b/package.json @@ -26,6 +26,7 @@ "@sveltejs/vite-plugin-svelte": "^5.0.3", "@tailwindcss/vite": "^4.1.18", "@types/node": "^22.10.0", + "@types/qrcode": "^1.5.6", "@types/sanitize-html": "^2.13.0", "@vitest/coverage-v8": "^2.1.9", "openapi-typescript": "^7.4.0", @@ -47,6 +48,7 @@ "class-variance-authority": "^0.7.1", "leaflet": "^1.9.4", "marked": "^17.0.2", + "qrcode": "^1.5.4", "sanitize-html": "^2.13.1", "sharp": "^0.34.5" }, diff --git a/scripts/generate-icons.mjs b/scripts/generate-icons.mjs index 3777905..4fda330 100644 --- a/scripts/generate-icons.mjs +++ b/scripts/generate-icons.mjs @@ -47,6 +47,11 @@ const EXTRA_ICONS = [ 'whatsapp', 'telegram', 'import-contacts', + 'arrow-top-right', + 'card-account-details-outline', + 'qrcode', + 'sort-alphabetical-ascending', + 'account-group-outline', // Common file-type icons (FilesBlock: f.iconName) 'file-pdf-box', 'file-word-box', diff --git a/src/lib/components/ContactCard.svelte b/src/lib/components/ContactCard.svelte index 5d16127..e6a500a 100644 --- a/src/lib/components/ContactCard.svelte +++ b/src/lib/components/ContactCard.svelte @@ -2,6 +2,7 @@ import Icon from "@iconify/svelte"; import "$lib/iconify-offline"; import type { ContactEntry } from "$lib/block-types"; + import { downloadVCard } from "$lib/vcard"; let { contact }: { contact: ContactEntry } = $props(); @@ -17,24 +18,34 @@ class="not-prose card-surface relative flex h-full min-w-0 flex-col gap-2 overflow-hidden rounded-sm p-4 transition-[transform,box-shadow,border-color] duration-200 hover:-translate-y-0.5 hover:border-wald-300 hover:shadow-md" >
-

{contact.name ?? ""}

+

{contact.name ?? ""}

- {#if contact.role || orgNames.length > 0} -
- {#if contact.role} - - {contact.role} - - {/if} - {#each orgNames as org} - - {org} - - {/each} -
- {/if} + {#if contact.role || orgNames.length > 0} +
+ {#if contact.role} + + {contact.role} + + {/if} + {#each orgNames as org} + + {org} + + {/each} +
+ {/if}
+ + + {#if contact.phone || contact.email}
{#if contact.phone} diff --git a/src/lib/components/PostActions.svelte b/src/lib/components/PostActions.svelte index f67d402..592698a 100644 --- a/src/lib/components/PostActions.svelte +++ b/src/lib/components/PostActions.svelte @@ -3,6 +3,7 @@ import "$lib/iconify-offline"; import { env } from "$env/dynamic/public"; import { useTranslate, T } from "$lib/translations"; + import QrButton from "$lib/components/QrButton.svelte"; let { url, @@ -194,6 +195,12 @@ > +
{#if readingTimeMinutes != null && readingTimeMinutes > 0} diff --git a/src/lib/components/QrButton.svelte b/src/lib/components/QrButton.svelte new file mode 100644 index 0000000..41de0c1 --- /dev/null +++ b/src/lib/components/QrButton.svelte @@ -0,0 +1,30 @@ + + + + + (open = false)} /> diff --git a/src/lib/components/QrModal.svelte b/src/lib/components/QrModal.svelte new file mode 100644 index 0000000..102db55 --- /dev/null +++ b/src/lib/components/QrModal.svelte @@ -0,0 +1,101 @@ + + +{#if value} + +
+
e.stopPropagation()} + role="dialog" + aria-modal="true" + > + + {#if label} +

{label}

+ {/if} + {#if sublabel} +

{sublabel}

+ {:else} +
+ {/if} + {#if qrSvg} + {@html qrSvg} + {:else} +
Generiere…
+ {/if} +

QR-Code scannen

+ +
+
+{/if} diff --git a/src/lib/components/blocks/AdressbuchBlock.svelte b/src/lib/components/blocks/AdressbuchBlock.svelte index 3a0971b..7971286 100644 --- a/src/lib/components/blocks/AdressbuchBlock.svelte +++ b/src/lib/components/blocks/AdressbuchBlock.svelte @@ -3,16 +3,14 @@ import "$lib/iconify-offline"; import { getBlockLayoutClasses } from "$lib/block-layout"; import type { AdressbuchBlockData, ContactEntry } from "$lib/block-types"; + import { downloadVCard, downloadVCardAll, buildVCardString } from "$lib/vcard"; + import QrButton from "$lib/components/QrButton.svelte"; let { block }: { block: AdressbuchBlockData } = $props(); const layoutClasses = $derived(getBlockLayoutClasses(block.layout)); + const allContacts = $derived((block.resolvedContacts ?? []) as ContactEntry[]); - const allContacts = $derived( - (block.resolvedContacts ?? []) as ContactEntry[] - ); - - // Unique orgs from all contacts (resolved objects only) const allOrgs = $derived(() => { const map = new Map(); for (const c of allContacts) { @@ -28,6 +26,7 @@ let search = $state(""); let activeOrg = $state(null); let activeLetter = $state(null); + let viewMode = $state<"alpha" | "org">("alpha"); const afterOrgFilter = $derived(() => allContacts.filter((c) => { @@ -65,20 +64,93 @@ ); }); }); + + // Alphabetisch gruppiert + const alphaGrouped = $derived(() => { + const map = new Map(); + for (const c of filtered()) { + const letter = (c.name ?? "").trim()[0]?.toUpperCase() ?? "#"; + if (!map.has(letter)) map.set(letter, []); + map.get(letter)!.push(c); + } + return [...map.entries()]; + }); + + // Nach Organisation gruppiert + const orgGrouped = $derived(() => { + const groups = new Map(); + const noOrg: ContactEntry[] = []; + for (const c of filtered()) { + const orgs = (c.organisations ?? []).filter( + (o): o is { _slug?: string; name?: string } => typeof o === "object" && !!o._slug && !!o.name + ); + if (orgs.length === 0) { + noOrg.push(c); + } else { + for (const o of orgs) { + const slug = o._slug!; + if (!groups.has(slug)) groups.set(slug, { name: o.name!, contacts: [] }); + groups.get(slug)!.contacts.push(c); + } + } + } + const result = [...groups.values()].sort((a, b) => a.name.localeCompare(b.name, "de")); + if (noOrg.length > 0) result.push({ name: "Weitere", contacts: noOrg }); + return result; + }); + + function escHtml(s: string): string { + return s.replace(/&/g, "&").replace(//g, ">"); + } + + function highlight(text: string, q: string): string { + if (!q.trim()) return escHtml(text); + const safe = q.trim().replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + return text + .split(new RegExp(`(${safe})`, "gi")) + .map((p, i) => + i % 2 === 1 + ? `${escHtml(p)}` + : escHtml(p) + ) + .join(""); + }
-
-
- - +
+
+
+ + +
+ +
+ + +
+
{/each}
- -
- {#each "ABCDEFGHIJKLMNOPQRSTUVWXYZ".split("") as letter} - {@const has = availableLetters().has(letter)} - - {/each} -
+ + {#if viewMode === "alpha"} +
+ {#each "ABCDEFGHIJKLMNOPQRSTUVWXYZ".split("") as letter} + {@const has = availableLetters().has(letter)} + + {/each} +
+ {/if}
@@ -123,59 +197,137 @@

Keine Kontakte gefunden.

{:else}
- {#each filtered() as contact (contact._slug)} -
- -
-

{contact.name ?? ""}

- {#if contact.role || (contact.organisations ?? []).some((o) => typeof o === "object" && o.name)} -
- {#if contact.role} - - {contact.role} - - {/if} - {#each (contact.organisations ?? []).filter((o): o is { _slug?: string; name?: string } => typeof o === "object" && !!o.name) as org} - - {org.name} - - {/each} -
- {/if} + {#if viewMode === "alpha"} + {#each alphaGrouped() as [letter, contacts]} +
+ {letter}
- - - {#if contact.phone || contact.email} -
- {#if contact.phone} -
- {contact.phone} - - - -
- {/if} - {#if contact.email} -
- {contact.email} - - - -
- {/if} -
- {/if} -
- {/each} + {#each contacts as contact (contact._slug)} + {@render contactRow(contact)} + {/each} + {/each} + {:else} + {#each orgGrouped() as group} +
+ {group.name} +
+ {#each group.contacts as contact (contact._slug)} + {@render contactRow(contact)} + {/each} + {/each} + {/if} +
+ +
+ +

{filtered().length} Kontakt{filtered().length !== 1 ? "e" : ""}

-

{filtered().length} Kontakt{filtered().length !== 1 ? "e" : ""}

{/if}
+ +{#snippet contactRow(contact: ContactEntry)} +
+
+

{@html highlight(contact.name ?? "", search.trim())}

+ {#if contact.role || (contact.organisations ?? []).some((o) => typeof o === "object" && o.name)} +
+ {#if contact.role} + + {contact.role} + + {/if} + {#each (contact.organisations ?? []).filter((o): o is { _slug?: string; name?: string } => typeof o === "object" && !!o.name) as org} + + {org.name} + + {/each} +
+ {/if} +
+ + +
+ + +
+ + {#if contact.note} +
+ + {contact.note} +
+ {/if} + + {#if contact.phone || contact.email} +
+ {#if contact.phone} +
+ + {contact.phone} + + + + +
+ {/if} + {#if contact.email} +
+ + {contact.email} + + + + +
+ {/if} +
+ {/if} +
+{/snippet} + + diff --git a/src/lib/iconify-mdi-subset.generated.json b/src/lib/iconify-mdi-subset.generated.json index efb77c5..6870a61 100644 --- a/src/lib/iconify-mdi-subset.generated.json +++ b/src/lib/iconify-mdi-subset.generated.json @@ -48,6 +48,9 @@ "shield-check-outline": { "body": "" }, + "card-account-details-outline": { + "body": "" + }, "phone-outline": { "body": "" }, @@ -84,9 +87,24 @@ "download": { "body": "" }, + "sort-alphabetical-ascending": { + "body": "" + }, + "account-group-outline": { + "body": "" + }, + "qrcode": { + "body": "" + }, "map-marker-outline": { "body": "" }, + "content-copy": { + "body": "" + }, + "arrow-top-right": { + "body": "" + }, "magnify-plus": { "body": "" }, @@ -123,9 +141,6 @@ "check": { "body": "" }, - "content-copy": { - "body": "" - }, "clock-alert-outline": { "body": "" }, diff --git a/src/lib/vcard.ts b/src/lib/vcard.ts new file mode 100644 index 0000000..149f950 --- /dev/null +++ b/src/lib/vcard.ts @@ -0,0 +1,47 @@ +import type { ContactEntry } from "./block-types"; + +function vcardEscape(s: string): string { + return s.replace(/\\/g, "\\\\").replace(/,/g, "\\,").replace(/;/g, "\\;").replace(/\n/g, "\\n"); +} + +function buildVCard(c: ContactEntry): string { + const name = vcardEscape(c.name ?? ""); + const orgs = (c.organisations ?? []) + .filter((o): o is { name?: string } => typeof o === "object" && !!o.name) + .map((o) => vcardEscape(o.name ?? "")) + .join(", "); + return [ + "BEGIN:VCARD", + "VERSION:3.0", + `FN:${name}`, + "N:;;;;", + orgs ? `ORG:${orgs}` : null, + c.role ? `TITLE:${vcardEscape(c.role)}` : null, + c.phone ? `TEL;TYPE=CELL:${c.phone.replace(/\s/g, "")}` : null, + c.email ? `EMAIL:${vcardEscape(c.email)}` : null, + c.note ? `ADR:;;${vcardEscape(c.note)};;;;` : null, + "END:VCARD", + ].filter(Boolean).join("\r\n"); +} + +export function buildVCardString(c: ContactEntry): string { + return buildVCard(c); +} + +function triggerDownload(content: string, filename: string): void { + const blob = new Blob([content], { type: "text/vcard;charset=utf-8" }); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = filename; + a.click(); + URL.revokeObjectURL(url); +} + +export function downloadVCard(c: ContactEntry): void { + triggerDownload(buildVCard(c), `${(c.name ?? "kontakt").toLowerCase().replace(/\s+/g, "-")}.vcf`); +} + +export function downloadVCardAll(contacts: ContactEntry[], filename = "kontakte.vcf"): void { + triggerDownload(contacts.map(buildVCard).join("\r\n"), filename); +}