Some checks failed
CI / Unit & Component Tests (push) Failing after 3m7s
CI / OCR Service Tests (push) Successful in 37s
CI / Backend Unit Tests (push) Failing after 2m47s
CI / Unit & Component Tests (pull_request) Failing after 2m34s
CI / OCR Service Tests (pull_request) Successful in 34s
CI / Backend Unit Tests (pull_request) Failing after 2m43s
- WCAG 1.3.1: add for/id pairs to all 6 fields in the create-invite form - WCAG 1.4.1: add status icon (●○✕⏱) to status badge alongside label - Add aria-label to copy-link buttons in the invite table - Replace hardcoded German strings with i18n keys (Alle, Widerrufen, Link kopieren, Kopiert, Abbrechen) - Increase filter button touch targets py-1.5 → py-2 - Add 5 unit tests for register page load function (no-code, ok, error-with-code, error-without-code, URL-encoding) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
374 lines
12 KiB
Svelte
374 lines
12 KiB
Svelte
<script lang="ts">
|
||
import { enhance } from '$app/forms';
|
||
import { m } from '$lib/paraglide/messages.js';
|
||
import { getErrorMessage } from '$lib/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>
|