feat: QR-Code, vCard-Export, Org-Gruppenansicht im Adressbuch
- 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:
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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)} />
|
||||
@@ -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}
|
||||
@@ -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, "&").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
|
||||
? `<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>
|
||||
|
||||
Reference in New Issue
Block a user