Files
familienarchiv/frontend/src/routes/admin/invites/+page.svelte
Marcel 567612761d refactor: move lib-root files to lib/shared/ and finalize domain structure
- Move api.server.ts, errors.ts, types.ts, utils.ts, relativeTime.ts to lib/shared/
- Move person relationship components to lib/person/relationship/
- Move Stammbaum components to lib/person/genealogy/
- Move HelpPopover to lib/shared/primitives/
- Update all import paths across routes, specs, and lib files
- Update vi.mock() paths in server-project test files
- Remove now-empty legacy directories (components/, hooks/, server/, etc.)
- Update vite.config.ts coverage include paths for new structure
- Update frontend/CLAUDE.md to reflect domain-based lib/ layout

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-05 14:53:31 +02:00

374 lines
12 KiB
Svelte
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script lang="ts">
import { enhance } from '$app/forms';
import { m } from '$lib/paraglide/messages.js';
import { getErrorMessage } from '$lib/shared/errors';
import type { InviteListItem } from './+page.server.ts';
let {
data,
form
}: {
data: {
invites: InviteListItem[];
status: string;
loadError: string | null;
};
form?: {
createError?: string;
revokeError?: string;
created?: InviteListItem;
revoked?: string;
};
} = $props();
let copiedId = $state<string | null>(null);
let showNewForm = $state(false);
function copyLink(id: string, url: string) {
navigator.clipboard.writeText(url).then(() => {
copiedId = id;
setTimeout(() => {
copiedId = null;
}, 2000);
});
}
function statusLabel(status: string) {
switch (status) {
case 'active':
return m.admin_invite_status_active();
case 'exhausted':
return m.admin_invite_status_exhausted();
case 'revoked':
return m.admin_invite_status_revoked();
case 'expired':
return m.admin_invite_status_expired();
default:
return status;
}
}
function statusColor(status: string) {
switch (status) {
case 'active':
return 'text-green-700 bg-green-50';
case 'exhausted':
return 'text-gray-500 bg-gray-100';
case 'revoked':
return 'text-red-600 bg-red-50';
case 'expired':
return 'text-amber-600 bg-amber-50';
default:
return 'text-gray-500 bg-gray-100';
}
}
function statusIcon(status: string) {
switch (status) {
case 'active':
return '●';
case 'exhausted':
return '○';
case 'revoked':
return '✕';
case 'expired':
return '⏱';
default:
return '';
}
}
</script>
<svelte:head>
<title>{m.admin_tab_invites()} · Familienarchiv</title>
</svelte:head>
<div class="flex flex-1 flex-col overflow-y-auto bg-canvas">
<div class="flex items-center justify-between gap-4 border-b border-line bg-surface px-6 py-4">
<h1 class="font-sans text-sm font-bold tracking-widest text-ink uppercase">
{m.admin_invites_list_title()}
</h1>
<div class="flex items-center gap-3">
<!-- Status filter -->
<div
class="flex overflow-hidden rounded-sm border border-line font-sans text-xs font-bold tracking-widest uppercase"
>
<a
href="/admin/invites"
class="px-3 py-2 transition-colors {data.status !== 'all' ? 'bg-primary text-primary-fg' : 'bg-surface text-ink-2 hover:bg-muted'}"
>
{m.admin_invite_status_active()}
</a>
<a
href="/admin/invites?status=all"
class="border-l border-line px-3 py-2 transition-colors {data.status === 'all' ? 'bg-primary text-primary-fg' : 'bg-surface text-ink-2 hover:bg-muted'}"
>
{m.admin_btn_show_all()}
</a>
</div>
<button
type="button"
onclick={() => (showNewForm = !showNewForm)}
class="inline-flex items-center gap-1.5 bg-primary px-3 py-1.5 font-sans text-xs font-bold tracking-widest text-primary-fg uppercase transition-colors hover:bg-primary/90"
>
<svg
class="h-3.5 w-3.5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="2.5"
>
<path stroke-linecap="round" stroke-linejoin="round" d="M12 4.5v15m7.5-7.5h-15" />
</svg>
{m.admin_btn_new_invite()}
</button>
</div>
</div>
<div class="flex-1 space-y-6 overflow-y-auto px-6 py-6">
{#if data.loadError}
<div class="rounded-sm border border-red-200 bg-red-50 p-4 font-sans text-xs text-red-700">
{getErrorMessage(data.loadError)}
</div>
{/if}
{#if form?.created}
<div class="rounded-sm border border-green-200 bg-green-50 p-4">
<p class="mb-1 font-sans text-xs font-bold tracking-widest text-green-800 uppercase">
{m.admin_invite_created_title()}
</p>
<p class="mb-2 font-serif text-sm text-green-700">{m.admin_invite_created_desc()}</p>
<div class="flex items-center gap-2">
<code
class="flex-1 rounded border border-green-200 bg-white px-3 py-1.5 font-mono text-xs break-all text-green-900"
>
{form.created.shareableUrl}
</code>
<button
type="button"
onclick={() => copyLink(form!.created!.id, form!.created!.shareableUrl)}
class="flex-shrink-0 rounded border border-green-300 bg-white px-3 py-1.5 font-sans text-xs font-bold text-green-700 transition-colors hover:bg-green-50"
>
{copiedId === form.created.id ? m.admin_btn_copied() : m.admin_btn_copy_link()}
</button>
</div>
</div>
{/if}
{#if showNewForm}
<div class="rounded-sm border border-line bg-surface p-5 shadow-sm">
<h2 class="mb-4 font-sans text-xs font-bold tracking-widest text-ink-2 uppercase">
{m.admin_btn_new_invite()}
</h2>
<form
method="POST"
action="?/create"
use:enhance
class="grid grid-cols-1 gap-4 sm:grid-cols-2"
>
<div class="sm:col-span-2">
<label
for="invite-label"
class="mb-1 block font-sans text-xs font-bold tracking-widest text-ink-2 uppercase"
>
{m.admin_new_invite_label()}
</label>
<input
id="invite-label"
type="text"
name="label"
class="block w-full border border-line px-3 py-2 font-serif text-sm text-ink focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
/>
</div>
<div>
<label
for="invite-max-uses"
class="mb-1 block font-sans text-xs font-bold tracking-widest text-ink-2 uppercase"
>
{m.admin_new_invite_max_uses()}
</label>
<input
id="invite-max-uses"
type="number"
name="maxUses"
min="1"
class="block w-full border border-line px-3 py-2 font-serif text-sm text-ink focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
/>
</div>
<div>
<label
for="invite-expires-at"
class="mb-1 block font-sans text-xs font-bold tracking-widest text-ink-2 uppercase"
>
{m.admin_new_invite_expires()}
</label>
<input
id="invite-expires-at"
type="datetime-local"
name="expiresAt"
class="block w-full border border-line px-3 py-2 font-serif text-sm text-ink focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
/>
</div>
<div>
<label
for="invite-prefill-first"
class="mb-1 block font-sans text-xs font-bold tracking-widest text-ink-2 uppercase"
>
{m.admin_new_invite_prefill_first()}
</label>
<input
id="invite-prefill-first"
type="text"
name="prefillFirstName"
class="block w-full border border-line px-3 py-2 font-serif text-sm text-ink focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
/>
</div>
<div>
<label
for="invite-prefill-last"
class="mb-1 block font-sans text-xs font-bold tracking-widest text-ink-2 uppercase"
>
{m.admin_new_invite_prefill_last()}
</label>
<input
id="invite-prefill-last"
type="text"
name="prefillLastName"
class="block w-full border border-line px-3 py-2 font-serif text-sm text-ink focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
/>
</div>
<div class="sm:col-span-2">
<label
for="invite-prefill-email"
class="mb-1 block font-sans text-xs font-bold tracking-widest text-ink-2 uppercase"
>
{m.admin_new_invite_prefill_email()}
</label>
<input
id="invite-prefill-email"
type="email"
name="prefillEmail"
class="block w-full border border-line px-3 py-2 font-serif text-sm text-ink focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
/>
</div>
{#if form?.createError}
<div class="font-sans text-xs font-medium text-red-600 sm:col-span-2">
{getErrorMessage(form.createError)}
</div>
{/if}
<div class="flex justify-end gap-3 sm:col-span-2">
<button
type="button"
onclick={() => (showNewForm = false)}
class="px-4 py-2 font-sans text-xs font-bold tracking-widest text-ink-2 uppercase transition-colors hover:text-ink"
>
{m.btn_cancel()}
</button>
<button
type="submit"
class="bg-primary px-4 py-2 font-sans text-xs font-bold tracking-widest text-primary-fg uppercase transition-colors hover:bg-primary/90"
>
{m.admin_btn_new_invite()}
</button>
</div>
</form>
</div>
{/if}
<!-- Invite table -->
<div class="overflow-hidden rounded-sm border border-line bg-surface shadow-sm">
{#if data.invites.length === 0}
<p class="px-6 py-8 text-center font-serif text-sm text-ink-3">{m.admin_invites_empty()}</p>
{:else}
<div class="overflow-x-auto">
<table class="w-full text-sm">
<thead>
<tr class="border-b border-line">
<th
class="px-4 py-3 text-left font-sans text-xs font-bold tracking-widest text-ink-3 uppercase"
>{m.admin_invite_col_code()}</th
>
<th
class="px-4 py-3 text-left font-sans text-xs font-bold tracking-widest text-ink-3 uppercase"
>{m.admin_invite_col_label()}</th
>
<th
class="px-4 py-3 text-left font-sans text-xs font-bold tracking-widest text-ink-3 uppercase"
>{m.admin_invite_col_uses()}</th
>
<th
class="px-4 py-3 text-left font-sans text-xs font-bold tracking-widest text-ink-3 uppercase"
>{m.admin_invite_col_expiry()}</th
>
<th
class="px-4 py-3 text-left font-sans text-xs font-bold tracking-widest text-ink-3 uppercase"
>{m.admin_invite_col_status()}</th
>
<th
class="px-4 py-3 text-left font-sans text-xs font-bold tracking-widest text-ink-3 uppercase"
>{m.admin_invite_col_link()}</th
>
<th class="px-4 py-3"></th>
</tr>
</thead>
<tbody class="divide-y divide-line">
{#each data.invites as invite (invite.id)}
<tr class="hover:bg-muted/40">
<td class="px-4 py-3 font-mono text-xs text-ink">{invite.displayCode}</td>
<td class="px-4 py-3 font-serif text-sm text-ink">{invite.label ?? ''}</td>
<td class="px-4 py-3 font-sans text-xs text-ink-2">
{invite.useCount} / {invite.maxUses != null ? invite.maxUses : m.admin_invite_unlimited()}
</td>
<td class="px-4 py-3 font-sans text-xs text-ink-2">
{invite.expiresAt
? new Intl.DateTimeFormat('de-DE', { dateStyle: 'medium' }).format(new Date(invite.expiresAt))
: m.admin_invite_no_expiry()}
</td>
<td class="px-4 py-3">
<span
class="inline-flex items-center gap-1 rounded px-2 py-0.5 font-sans text-xs font-bold {statusColor(invite.status)}"
aria-label={statusLabel(invite.status)}
>
<span aria-hidden="true">{statusIcon(invite.status)}</span>
{statusLabel(invite.status)}
</span>
</td>
<td class="px-4 py-3">
<button
type="button"
onclick={() => copyLink(invite.id, invite.shareableUrl)}
class="font-sans text-xs text-brand-navy/60 transition-colors hover:text-brand-navy"
aria-label="{m.admin_btn_copy_link()}: {invite.displayCode}"
title={invite.shareableUrl}
>
{copiedId === invite.id ? m.admin_btn_copied() : m.admin_btn_copy_link()}
</button>
</td>
<td class="px-4 py-3 text-right">
{#if invite.status === 'active'}
<form method="POST" action="?/revoke" use:enhance>
<input type="hidden" name="id" value={invite.id} />
<button
type="submit"
onclick={(e) => {
if (!confirm(m.admin_invite_revoke_confirm())) e.preventDefault();
}}
class="font-sans text-xs font-bold tracking-widest text-red-500 uppercase transition-colors hover:text-red-700"
>
{m.admin_btn_revoke()}
</button>
</form>
{/if}
</td>
</tr>
{/each}
</tbody>
</table>
</div>
{/if}
</div>
</div>
</div>