feat: QR-Code, vCard-Export, Org-Gruppenansicht im Adressbuch
Deploy / verify (push) Successful in 1m10s
Deploy / deploy (push) Successful in 1m45s

- 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 <noreply@anthropic.com>
This commit is contained in:
Peter Meier
2026-06-04 11:15:57 +02:00
parent 4642b32271
commit 982a7a3b73
10 changed files with 802 additions and 104 deletions
+331 -3
View File
@@ -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",
+2
View File
@@ -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"
},
+5
View File
@@ -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',
+26 -15
View File
@@ -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"
>
<div class="flex flex-col gap-1.5">
<p class="truncate text-sm font-medium leading-tight text-stein-900">{contact.name ?? ""}</p>
<p class="truncate pr-6 text-sm font-medium leading-tight text-stein-900">{contact.name ?? ""}</p>
{#if contact.role || orgNames.length > 0}
<div class="flex flex-wrap gap-1">
{#if contact.role}
<span class="shrink-0 rounded-full border border-stein-200 bg-stein-100 px-1.5 py-0 text-[0.65rem] font-medium text-stein-600">
{contact.role}
</span>
{/if}
{#each orgNames as org}
<span class="shrink-0 rounded-full border border-wald-200 bg-wald-50 px-1.5 py-0 text-[0.65rem] font-medium text-wald-700">
{org}
</span>
{/each}
</div>
{/if}
{#if contact.role || orgNames.length > 0}
<div class="flex flex-wrap gap-1">
{#if contact.role}
<span class="shrink-0 rounded-full border border-stein-200 bg-stein-100 px-1.5 py-0 text-[0.65rem] font-medium text-stein-600">
{contact.role}
</span>
{/if}
{#each orgNames as org}
<span class="shrink-0 rounded-full border border-wald-200 bg-wald-50 px-1.5 py-0 text-[0.65rem] font-medium text-wald-700">
{org}
</span>
{/each}
</div>
{/if}
</div>
<!-- vCard download -->
<button
type="button"
title="Kontakt speichern (.vcf)"
onclick={() => downloadVCard(contact)}
class="absolute top-3 right-3 text-stein-300 transition-colors hover:text-wald-600"
>
<Icon icon="mdi:card-account-details-outline" class="size-4" />
</button>
{#if contact.phone || contact.email}
<div class="flex flex-col gap-1">
{#if contact.phone}
+7
View File
@@ -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 @@
>
<Icon icon="mdi:printer-outline" class="size-3.5" />
</button>
<QrButton
value={url}
label={title}
sublabel="Link teilen"
class="inline-flex items-center px-2.5 py-1 text-base text-stein-600 hover:bg-stein-50 hover:text-stein-900 transition-colors"
/>
</div>
{#if readingTimeMinutes != null && readingTimeMinutes > 0}
+30
View File
@@ -0,0 +1,30 @@
<script lang="ts">
import Icon from "@iconify/svelte";
import "$lib/iconify-offline";
import QrModal from "./QrModal.svelte";
let {
value,
label = "",
sublabel = "",
class: cls = "",
}: {
value: string;
label?: string;
sublabel?: string;
class?: string;
} = $props();
let open = $state(false);
</script>
<button
type="button"
title="QR-Code anzeigen"
onclick={() => (open = true)}
class={cls}
>
<Icon icon="mdi:qrcode" class="size-[1em]" />
</button>
<QrModal value={open ? value : null} {label} {sublabel} onclose={() => (open = false)} />
+101
View File
@@ -0,0 +1,101 @@
<script lang="ts">
import { onMount } from "svelte";
import Icon from "@iconify/svelte";
import "$lib/iconify-offline";
import QRCode from "qrcode";
let {
value,
label = "",
sublabel = "",
onclose,
}: {
value: string | null;
label?: string;
sublabel?: string;
onclose: () => void;
} = $props();
let qrSvg = $state("");
let qrPngUrl = $state("");
$effect(() => {
if (!value) { qrSvg = ""; qrPngUrl = ""; return; }
QRCode.toString(value, {
type: "svg",
errorCorrectionLevel: "M",
margin: 2,
color: { dark: "#1c1917", light: "#ffffff" },
}).then((svg) => { qrSvg = svg; });
QRCode.toDataURL(value, {
errorCorrectionLevel: "M",
margin: 2,
width: 512,
color: { dark: "#1c1917", light: "#ffffff" },
}).then((url) => { qrPngUrl = url; });
});
function downloadPng() {
if (!qrPngUrl) return;
const a = document.createElement("a");
a.href = qrPngUrl;
a.download = `${(label || "qrcode").toLowerCase().replace(/\s+/g, "-")}.png`;
a.click();
}
function handleKeydown(e: KeyboardEvent) {
if (e.key === "Escape") onclose();
}
onMount(() => {
window.addEventListener("keydown", handleKeydown);
return () => window.removeEventListener("keydown", handleKeydown);
});
</script>
{#if value}
<!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions -->
<div
class="not-prose fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4"
onclick={onclose}
>
<div
class="relative w-full max-w-xs rounded-sm border border-stein-200 bg-white p-6 shadow-xl"
onclick={(e) => e.stopPropagation()}
role="dialog"
aria-modal="true"
>
<button
type="button"
onclick={onclose}
class="absolute right-3 top-3 text-stein-400 hover:text-stein-700"
aria-label="Schließen"
>
<Icon icon="mdi:close" class="size-5" />
</button>
{#if label}
<p class="mb-1 text-sm font-medium text-stein-900">{label}</p>
{/if}
{#if sublabel}
<p class="mb-4 text-xs text-stein-400">{sublabel}</p>
{:else}
<div class="mb-4"></div>
{/if}
{#if qrSvg}
{@html qrSvg}
{:else}
<div class="flex h-48 items-center justify-center text-xs text-stein-300">Generiere…</div>
{/if}
<p class="mt-3 text-center text-[0.65rem] text-stein-400">QR-Code scannen</p>
<button
type="button"
onclick={downloadPng}
disabled={!qrPngUrl}
class="btn-outline btn-sm mt-3 w-full justify-center"
>
<Icon icon="mdi:download" class="size-3.5" />
Als PNG herunterladen
</button>
</div>
</div>
{/if}
+235 -83
View File
@@ -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<string, string>();
for (const c of allContacts) {
@@ -28,6 +26,7 @@
let search = $state("");
let activeOrg = $state<string | null>(null);
let activeLetter = $state<string | null>(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<string, ContactEntry[]>();
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<string, { name: string; contacts: ContactEntry[] }>();
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, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
}
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
? `<mark class="bg-wald-100 text-wald-900 rounded-xs px-px">${escHtml(p)}</mark>`
: escHtml(p)
)
.join("");
}
</script>
<div class="{layoutClasses} not-prose" data-block-type="adressbuch">
<!-- Search + Filter bar -->
<div class="mb-4 flex flex-col gap-2">
<div class="relative max-w-sm">
<Icon icon="mdi:magnify" class="absolute left-2.5 top-1/2 size-4 -translate-y-1/2 text-stein-400" />
<input
type="search"
placeholder="Suchen…"
bind:value={search}
class="w-full rounded-sm border border-stein-200 bg-white py-1.5 pl-8 pr-3 text-sm text-stein-900 placeholder:text-stein-400 focus:border-wald-400 focus:outline-none focus:ring-1 focus:ring-wald-400"
/>
<div class="no-print mb-4 flex flex-col gap-2">
<div class="flex gap-2">
<div class="relative max-w-sm flex-1">
<Icon icon="mdi:magnify" class="absolute left-2.5 top-1/2 size-4 -translate-y-1/2 text-stein-400" />
<input
type="search"
placeholder="Suchen…"
bind:value={search}
class="w-full rounded-sm border border-stein-200 bg-white py-1.5 pl-8 pr-3 text-sm text-stein-900 placeholder:text-stein-400 focus:border-wald-400 focus:outline-none focus:ring-1 focus:ring-wald-400"
/>
</div>
<!-- View toggle -->
<div class="flex rounded-sm border border-stein-200 overflow-hidden text-[11px] font-medium">
<button
type="button"
onclick={() => { viewMode = "alpha"; activeLetter = null; }}
class="px-2 py-1 transition-colors {viewMode === 'alpha' ? 'bg-wald-500 text-white' : 'bg-white text-stein-600 hover:bg-stein-50'}"
title="Alphabetisch"
>
<Icon icon="mdi:sort-alphabetical-ascending" class="size-4" />
</button>
<button
type="button"
onclick={() => { viewMode = "org"; activeLetter = null; }}
class="px-2 py-1 transition-colors {viewMode === 'org' ? 'bg-wald-500 text-white' : 'bg-white text-stein-600 hover:bg-stein-50'}"
title="Nach Organisation"
>
<Icon icon="mdi:account-group-outline" class="size-4" />
</button>
</div>
</div>
<div class="flex flex-wrap gap-1">
<button
onclick={() => (activeOrg = null)}
@@ -99,23 +171,25 @@
</button>
{/each}
</div>
<!-- Letter filter -->
<div class="flex flex-wrap gap-0.5">
{#each "ABCDEFGHIJKLMNOPQRSTUVWXYZ".split("") as letter}
{@const has = availableLetters().has(letter)}
<button
onclick={() => (activeLetter = activeLetter === letter ? null : letter)}
disabled={!has}
class="chip w-6 justify-center border-transparent {activeLetter === letter
? 'bg-wald-500 text-white'
: has
? 'text-stein-700 hover:bg-stein-100'
: 'cursor-default text-stein-300'}"
>
{letter}
</button>
{/each}
</div>
{#if viewMode === "alpha"}
<div class="flex flex-wrap gap-0.5">
{#each "ABCDEFGHIJKLMNOPQRSTUVWXYZ".split("") as letter}
{@const has = availableLetters().has(letter)}
<button
onclick={() => (activeLetter = activeLetter === letter ? null : letter)}
disabled={!has}
class="chip w-6 justify-center border-transparent {activeLetter === letter
? 'bg-wald-500 text-white'
: has
? 'text-stein-700 hover:bg-stein-100'
: 'cursor-default text-stein-300'}"
>
{letter}
</button>
{/each}
</div>
{/if}
</div>
<!-- Contact list -->
@@ -123,59 +197,137 @@
<p class="text-sm text-stein-400">Keine Kontakte gefunden.</p>
{:else}
<div class="divide-y divide-stein-100 rounded-sm border border-stein-200 bg-white">
{#each filtered() as contact (contact._slug)}
<div class="flex flex-col gap-2 px-4 py-3">
<!-- Name + badges -->
<div class="min-w-0">
<p class="text-sm font-medium text-stein-900">{contact.name ?? ""}</p>
{#if contact.role || (contact.organisations ?? []).some((o) => typeof o === "object" && o.name)}
<div class="mt-0.5 flex flex-nowrap gap-1 overflow-hidden">
{#if contact.role}
<span class="shrink-0 whitespace-nowrap rounded-full border border-stein-200 bg-stein-100 px-1.5 py-0 text-[0.65rem] font-medium text-stein-600">
{contact.role}
</span>
{/if}
{#each (contact.organisations ?? []).filter((o): o is { _slug?: string; name?: string } => typeof o === "object" && !!o.name) as org}
<span class="shrink-0 whitespace-nowrap rounded-full border border-wald-200 bg-wald-50 px-1.5 py-0 text-[0.65rem] font-medium text-wald-700">
{org.name}
</span>
{/each}
</div>
{/if}
{#if viewMode === "alpha"}
{#each alphaGrouped() as [letter, contacts]}
<div class="sticky top-0 z-10 border-b border-stein-100 bg-stein-50 px-4 py-1 text-[0.65rem] font-semibold tracking-widest text-stein-400">
{letter}
</div>
<!-- Contact info + action links -->
{#if contact.phone || contact.email}
<div class="flex flex-col gap-1">
{#if contact.phone}
<div class="flex items-center gap-2">
<span class="min-w-0 flex-1 truncate text-xs text-stein-600">{contact.phone}</span>
<a
href="tel:{contact.phone.replace(/\s/g, '')}"
title="Anrufen"
class="btn-outline btn-sm shrink-0 no-underline"
>
<Icon icon="mdi:phone-outline" class="size-3.5" />
</a>
</div>
{/if}
{#if contact.email}
<div class="flex items-center gap-2">
<span class="min-w-0 flex-1 truncate text-xs text-stein-600">{contact.email}</span>
<a
href="mailto:{contact.email}"
title="E-Mail schreiben"
class="btn-outline btn-sm shrink-0 no-underline"
>
<Icon icon="mdi:email-outline" class="size-3.5" />
</a>
</div>
{/if}
</div>
{/if}
</div>
{/each}
{#each contacts as contact (contact._slug)}
{@render contactRow(contact)}
{/each}
{/each}
{:else}
{#each orgGrouped() as group}
<div class="sticky top-0 z-10 border-b border-stein-100 bg-stein-50 px-4 py-1.5 text-xs font-semibold text-stein-600">
{group.name}
</div>
{#each group.contacts as contact (contact._slug)}
{@render contactRow(contact)}
{/each}
{/each}
{/if}
</div>
<div class="mt-2 flex items-center justify-between">
<button
type="button"
onclick={() => downloadVCardAll(filtered())}
class="no-print btn-outline btn-sm"
>
<Icon icon="mdi:download" class="size-3.5" />
Alle exportieren (.vcf)
</button>
<p class="text-xs text-stein-400">{filtered().length} Kontakt{filtered().length !== 1 ? "e" : ""}</p>
</div>
<p class="mt-2 text-right text-xs text-stein-400">{filtered().length} Kontakt{filtered().length !== 1 ? "e" : ""}</p>
{/if}
</div>
{#snippet contactRow(contact: ContactEntry)}
<div class="relative flex flex-col gap-2 px-4 py-3">
<div class="min-w-0 pr-14">
<p class="text-sm font-medium text-stein-900">{@html highlight(contact.name ?? "", search.trim())}</p>
{#if contact.role || (contact.organisations ?? []).some((o) => typeof o === "object" && o.name)}
<div class="mt-0.5 flex flex-nowrap gap-1 overflow-hidden">
{#if contact.role}
<span class="shrink-0 whitespace-nowrap rounded-full border border-stein-200 bg-stein-100 px-1.5 py-0 text-[0.65rem] font-medium text-stein-600">
{contact.role}
</span>
{/if}
{#each (contact.organisations ?? []).filter((o): o is { _slug?: string; name?: string } => typeof o === "object" && !!o.name) as org}
<span class="shrink-0 whitespace-nowrap rounded-full border border-wald-200 bg-wald-50 px-1.5 py-0 text-[0.65rem] font-medium text-wald-700">
{org.name}
</span>
{/each}
</div>
{/if}
</div>
<!-- Actions top-right -->
<div class="no-print absolute top-3 right-4 flex gap-2 text-base">
<QrButton
value={buildVCardString(contact)}
label={contact.name}
sublabel={contact.role}
class="text-stein-300 transition-colors hover:text-wald-600"
/>
<button
type="button"
title="Kontakt speichern (.vcf)"
onclick={() => downloadVCard(contact)}
class="text-stein-300 transition-colors hover:text-wald-600"
>
<Icon icon="mdi:card-account-details-outline" class="size-4" />
</button>
</div>
{#if contact.note}
<div class="flex items-center gap-1.5 text-xs text-stein-400">
<Icon icon="mdi:map-marker-outline" class="size-3.5 shrink-0" />
<span class="min-w-0 truncate">{contact.note}</span>
</div>
{/if}
{#if contact.phone || contact.email}
<div class="flex flex-col gap-1">
{#if contact.phone}
<div class="flex items-center gap-1.5">
<Icon icon="mdi:phone-outline" class="size-3.5 shrink-0 text-stein-400" />
<span class="min-w-0 truncate text-xs text-stein-600">{contact.phone}</span>
<button
type="button"
title="Nummer kopieren"
onclick={() => navigator.clipboard.writeText(contact.phone ?? "")}
class="no-print shrink-0 text-stein-300 transition-colors hover:text-wald-600"
>
<Icon icon="mdi:content-copy" class="size-3.5" />
</button>
<a
href="tel:{contact.phone.replace(/\s/g, '')}"
title="Anrufen"
class="no-print shrink-0 text-stein-300 no-underline transition-colors hover:text-wald-600"
>
<Icon icon="mdi:arrow-top-right" class="size-3.5" />
</a>
</div>
{/if}
{#if contact.email}
<div class="flex items-center gap-1.5">
<Icon icon="mdi:email-outline" class="size-3.5 shrink-0 text-stein-400" />
<span class="min-w-0 truncate text-xs text-stein-600">{contact.email}</span>
<button
type="button"
title="E-Mail kopieren"
onclick={() => navigator.clipboard.writeText(contact.email ?? "")}
class="no-print shrink-0 text-stein-300 transition-colors hover:text-wald-600"
>
<Icon icon="mdi:content-copy" class="size-3.5" />
</button>
<a
href="mailto:{contact.email}"
title="E-Mail schreiben"
class="no-print shrink-0 text-stein-300 no-underline transition-colors hover:text-wald-600"
>
<Icon icon="mdi:arrow-top-right" class="size-3.5" />
</a>
</div>
{/if}
</div>
{/if}
</div>
{/snippet}
<style>
@media print {
.no-print { display: none !important; }
}
</style>
+18 -3
View File
@@ -48,6 +48,9 @@
"shield-check-outline": {
"body": "<path fill=\"currentColor\" d=\"M21 11c0 5.55-3.84 10.74-9 12c-5.16-1.26-9-6.45-9-12V5l9-4l9 4zm-9 10c3.75-1 7-5.46 7-9.78V6.3l-7-3.12L5 6.3v4.92C5 15.54 8.25 20 12 21m-2-4l-4-4l1.41-1.41L10 14.17l6.59-6.59L18 9\"/>"
},
"card-account-details-outline": {
"body": "<path fill=\"currentColor\" d=\"M22 3H2c-1.09.04-1.96.91-2 2v14c.04 1.09.91 1.96 2 2h20c1.09-.04 1.96-.91 2-2V5a2.074 2.074 0 0 0-2-2m0 16H2V5h20zm-8-2v-1.25c0-1.66-3.34-2.5-5-2.5s-5 .84-5 2.5V17zM9 7a2.5 2.5 0 0 0-2.5 2.5A2.5 2.5 0 0 0 9 12a2.5 2.5 0 0 0 2.5-2.5A2.5 2.5 0 0 0 9 7m5 0v1h6V7zm0 2v1h6V9zm0 2v1h4v-1z\"/>"
},
"phone-outline": {
"body": "<path fill=\"currentColor\" d=\"M20 15.5c-1.2 0-2.5-.2-3.6-.6h-.3c-.3 0-.5.1-.7.3l-2.2 2.2c-2.8-1.5-5.2-3.8-6.6-6.6l2.2-2.2c.3-.3.4-.7.2-1c-.3-1.1-.5-2.4-.5-3.6c0-.5-.5-1-1-1H4c-.5 0-1 .5-1 1c0 9.4 7.6 17 17 17c.5 0 1-.5 1-1v-3.5c0-.5-.5-1-1-1M5 5h1.5c.1.9.3 1.8.5 2.6L5.8 8.8C5.4 7.6 5.1 6.3 5 5m14 14c-1.3-.1-2.6-.4-3.8-.8l1.2-1.2c.8.2 1.7.4 2.6.4z\"/>"
},
@@ -84,9 +87,24 @@
"download": {
"body": "<path fill=\"currentColor\" d=\"M5 20h14v-2H5m14-9h-4V3H9v6H5l7 7z\"/>"
},
"sort-alphabetical-ascending": {
"body": "<path fill=\"currentColor\" d=\"M19 17h3l-4 4l-4-4h3V3h2m-8 10v2l-3.33 4H11v2H5v-2l3.33-4H5v-2M9 3H7c-1.1 0-2 .9-2 2v6h2V9h2v2h2V5a2 2 0 0 0-2-2m0 4H7V5h2Z\"/>"
},
"account-group-outline": {
"body": "<path fill=\"currentColor\" d=\"M12 5a3.5 3.5 0 0 0-3.5 3.5A3.5 3.5 0 0 0 12 12a3.5 3.5 0 0 0 3.5-3.5A3.5 3.5 0 0 0 12 5m0 2a1.5 1.5 0 0 1 1.5 1.5A1.5 1.5 0 0 1 12 10a1.5 1.5 0 0 1-1.5-1.5A1.5 1.5 0 0 1 12 7M5.5 8A2.5 2.5 0 0 0 3 10.5c0 .94.53 1.75 1.29 2.18c.36.2.77.32 1.21.32s.85-.12 1.21-.32c.37-.21.68-.51.91-.87A5.42 5.42 0 0 1 6.5 8.5v-.28c-.3-.14-.64-.22-1-.22m13 0c-.36 0-.7.08-1 .22v.28c0 1.2-.39 2.36-1.12 3.31c.12.19.25.34.4.49a2.48 2.48 0 0 0 1.72.7c.44 0 .85-.12 1.21-.32c.76-.43 1.29-1.24 1.29-2.18A2.5 2.5 0 0 0 18.5 8M12 14c-2.34 0-7 1.17-7 3.5V19h14v-1.5c0-2.33-4.66-3.5-7-3.5m-7.29.55C2.78 14.78 0 15.76 0 17.5V19h3v-1.93c0-1.01.69-1.85 1.71-2.52m14.58 0c1.02.67 1.71 1.51 1.71 2.52V19h3v-1.5c0-1.74-2.78-2.72-4.71-2.95M12 16c1.53 0 3.24.5 4.23 1H7.77c.99-.5 2.7-1 4.23-1\"/>"
},
"qrcode": {
"body": "<path fill=\"currentColor\" d=\"M3 11h2v2H3zm8-6h2v4h-2zm-2 6h4v4h-2v-2H9zm6 0h2v2h2v-2h2v2h-2v2h2v4h-2v2h-2v-2h-4v2h-2v-4h4v-2h2v-2h-2zm4 8v-4h-2v4zM15 3h6v6h-6zm2 2v2h2V5zM3 3h6v6H3zm2 2v2h2V5zM3 15h6v6H3zm2 2v2h2v-2z\"/>"
},
"map-marker-outline": {
"body": "<path fill=\"currentColor\" d=\"M12 6.5A2.5 2.5 0 0 1 14.5 9a2.5 2.5 0 0 1-2.5 2.5A2.5 2.5 0 0 1 9.5 9A2.5 2.5 0 0 1 12 6.5M12 2a7 7 0 0 1 7 7c0 5.25-7 13-7 13S5 14.25 5 9a7 7 0 0 1 7-7m0 2a5 5 0 0 0-5 5c0 1 0 3 5 9.71C17 12 17 10 17 9a5 5 0 0 0-5-5\"/>"
},
"content-copy": {
"body": "<path fill=\"currentColor\" d=\"M19 21H8V7h11m0-2H8a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h11a2 2 0 0 0 2-2V7a2 2 0 0 0-2-2m-3-4H4a2 2 0 0 0-2 2v14h2V3h12z\"/>"
},
"arrow-top-right": {
"body": "<path fill=\"currentColor\" d=\"M5 17.59L15.59 7H9V5h10v10h-2V8.41L6.41 19z\"/>"
},
"magnify-plus": {
"body": "<path fill=\"currentColor\" d=\"M9 2a7 7 0 0 1 7 7c0 1.57-.5 3-1.39 4.19l.8.81H16l6 6l-2 2l-6-6v-.59l-.81-.8A6.9 6.9 0 0 1 9 16a7 7 0 0 1-7-7a7 7 0 0 1 7-7M8 5v3H5v2h3v3h2v-3h3V8h-3V5z\"/>"
},
@@ -123,9 +141,6 @@
"check": {
"body": "<path fill=\"currentColor\" d=\"M21 7L9 19l-5.5-5.5l1.41-1.41L9 16.17L19.59 5.59z\"/>"
},
"content-copy": {
"body": "<path fill=\"currentColor\" d=\"M19 21H8V7h11m0-2H8a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h11a2 2 0 0 0 2-2V7a2 2 0 0 0-2-2m-3-4H4a2 2 0 0 0-2 2v14h2V3h12z\"/>"
},
"clock-alert-outline": {
"body": "<path fill=\"currentColor\" d=\"M11 7v6l5.2 3.1l.8-1.2l-4.5-2.7V7zm9 5v6h2v-6zm0 8v2h2v-2zm-2 0c-1.7 1.3-3.7 2-6 2c-5.5 0-10-4.5-10-10S6.5 2 12 2c4.8 0 8.9 3.4 9.8 8h-2.1c-.9-3.4-4-6-7.7-6c-4.4 0-8 3.6-8 8s3.6 8 8 8c2.4 0 4.5-1.1 6-2.7z\"/>"
},
+47
View File
@@ -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);
}