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
+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>