admin pages (list + detail) + login page (Supabase email/password)

- /admin/feedback (page.server.ts + page.svelte): list with status/mode badges, counts, JSON-editor create form. flex()→fdb() rename done.
- /admin/feedback/[id] (page.server.ts + page.svelte): tabbed detail (Chat / Submissions / Edit), 5s admin polling, hide-toggle, close/reopen, CSV/JSON export, delete. flex()→fdb() rename done.
- /login: simple email + password form posting to /api/auth/sign-in. Pre-redirect if already authed (locals.userId in load). Honours ?redirect= query.

Pages otherwise byte-identical ports of the flexsiebels versions — schema
helper rename happens in /server/fdb.ts.

bun run check: 0 errors, 13 warnings (known false-positive 'data/inst captured
at init'; same pattern flexsiebels has).
This commit is contained in:
mAi
2026-05-05 11:36:42 +02:00
parent 4c68b48417
commit f9140a414a
6 changed files with 708 additions and 0 deletions

View File

@@ -0,0 +1,33 @@
import type { PageServerLoad } from './$types';
import { redirect } from '@sveltejs/kit';
import { fdb } from '$lib/server/fdb';
export const load: PageServerLoad = async ({ locals, url }) => {
if (!locals.userId) throw redirect(303, `/login?redirect=${encodeURIComponent(url.pathname)}`);
const { data: instances, error: listError } = await fdb()
.from('feedback_instances')
.select('id, slug, title, description, form_definition, chat_enabled, status, closed_at, created_at, updated_at')
.eq('owner_user_id', locals.userId)
.order('created_at', { ascending: false })
.limit(500);
if (listError) throw listError;
const ids = (instances || []).map((i: { id: string }) => i.id);
const counts: Record<string, { submissions: number; posts: number }> = {};
for (const id of ids) counts[id] = { submissions: 0, posts: 0 };
if (ids.length > 0) {
const [s, p] = await Promise.all([
fdb().from('feedback_submissions').select('instance_id').in('instance_id', ids),
fdb().from('feedback_posts').select('instance_id').in('instance_id', ids),
]);
if (s.error) throw s.error;
if (p.error) throw p.error;
for (const row of s.data || []) counts[(row as { instance_id: string }).instance_id].submissions++;
for (const row of p.data || []) counts[(row as { instance_id: string }).instance_id].posts++;
}
return {
instances: (instances || []).map((i) => ({ ...i, counts: counts[(i as { id: string }).id] })),
};
};

View File

@@ -0,0 +1,205 @@
<script lang="ts">
import '$lib/styles/feedback.css';
import type { PageData } from './$types';
import { goto } from '$app/navigation';
let { data }: { data: PageData } = $props();
let creating = $state(false);
let createError = $state<string | null>(null);
let title = $state('');
let description = $state('');
let chatEnabled = $state(true);
let formJson = $state('');
const SAMPLE_FORM = JSON.stringify(
{
intro: 'Kurzes Feedback nach der Schulung — danke für deine Zeit.',
outro: 'Danke!',
questions: [
{ id: 'overall', type: 'scale', label: 'Wie war die Schulung insgesamt?', required: true, min: 1, max: 5, min_label: 'schwach', max_label: 'super' },
{ id: 'helpful', type: 'long_text', label: 'Was war besonders hilfreich?', placeholder: 'optional' },
{ id: 'improve', type: 'long_text', label: 'Was sollten wir verbessern?', placeholder: 'optional' },
{ id: 'recommend', type: 'boolean', label: 'Würdest du die Schulung weiterempfehlen?', required: true },
],
},
null,
2,
);
function pasteSample(): void {
formJson = SAMPLE_FORM;
}
async function copyLink(slug: string): Promise<void> {
try {
const url = `${location.origin}/f/${slug}`;
await navigator.clipboard.writeText(url);
} catch {
// fall through
}
}
function modeLabel(i: { form_definition: unknown | null; chat_enabled: boolean }): string {
const hasForm = i.form_definition != null;
if (hasForm && i.chat_enabled) return 'Form + Chat';
if (hasForm) return 'Form';
if (i.chat_enabled) return 'Chat';
return '—';
}
async function createInstance(e: SubmitEvent): Promise<void> {
e.preventDefault();
creating = true;
createError = null;
let parsedForm: unknown = null;
const trimmed = formJson.trim();
if (trimmed) {
try {
parsedForm = JSON.parse(trimmed);
} catch (err) {
createError = `JSON-Parse-Fehler: ${err instanceof Error ? err.message : 'invalid'}`;
creating = false;
return;
}
}
try {
const res = await fetch('/api/admin/feedback', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
title,
description: description || undefined,
form_definition: parsedForm,
chat_enabled: chatEnabled,
}),
});
if (!res.ok) {
const j = (await res.json().catch(() => ({}))) as { error?: string; details?: unknown };
createError = j.error ?? `Fehler ${res.status}`;
if (j.details) createError += ' — ' + JSON.stringify(j.details);
return;
}
const j = (await res.json()) as { instance: { id: string } };
await goto(`/admin/feedback/${j.instance.id}`);
} catch (err) {
createError = err instanceof Error ? err.message : 'Netzwerkfehler';
} finally {
creating = false;
}
}
</script>
<svelte:head>
<title>Feedback — Admin</title>
<meta name="robots" content="noindex,nofollow" />
</svelte:head>
<div class="fb-shell">
<header class="fb-header">
<h1>Feedback Instances</h1>
<p>Forms und Live-Chat-Masken — per langem Slug zugänglich.</p>
</header>
<section class="fb-section">
<h2>Neue Instance</h2>
<form onsubmit={createInstance}>
<div class="fb-question">
<label class="fb-question__label" for="fb-new-title">Titel</label>
<input
id="fb-new-title"
class="fb-input"
maxlength="200"
required
bind:value={title}
/>
</div>
<div class="fb-question">
<label class="fb-question__label" for="fb-new-desc">Beschreibung (optional)</label>
<textarea
id="fb-new-desc"
class="fb-textarea"
maxlength="2000"
rows="2"
bind:value={description}
></textarea>
</div>
<div class="fb-question">
<label class="fb-option-row" style="display:inline-flex;">
<input type="checkbox" bind:checked={chatEnabled} />
<span>Live-Chat aktiv</span>
</label>
</div>
<div class="fb-question">
<div style="display:flex; align-items:center; justify-content:space-between; margin-bottom:0.4rem;">
<label class="fb-question__label" for="fb-new-form">Form-Definition (JSON, optional)</label>
<button type="button" class="fb-btn fb-btn--ghost" onclick={pasteSample}>Beispiel einfügen</button>
</div>
<textarea
id="fb-new-form"
class="fb-textarea"
rows="14"
placeholder={'{\n "questions": [\n { "id": "q1", "type": "short_text", "label": "Dein Name?" }\n ]\n}'}
bind:value={formJson}
style="font-family: ui-monospace, 'SF Mono', Menlo, monospace; font-size: 0.85rem;"
></textarea>
<div class="fb-question__help">
Leer lassen für Chat-only. Schema-Doku: docs/plans/feedback-feature.md §5.
</div>
</div>
{#if createError}
<div class="fb-banner fb-banner--error">{createError}</div>
{/if}
<button type="submit" class="fb-btn" disabled={creating}>
{creating ? 'Erstelle …' : 'Instance erstellen'}
</button>
</form>
</section>
<section class="fb-section">
<h2>Bestehende Instances ({data.instances.length})</h2>
{#if data.instances.length === 0}
<p style="color: var(--fb-muted);">Noch keine Instance erstellt.</p>
{:else}
<div style="display: flex; flex-direction: column; gap: 0.5rem;">
{#each data.instances as i (i.id)}
<div style="border: 1px solid var(--fb-border); border-radius: 6px; padding: 0.75rem;">
<div style="display: flex; justify-content: space-between; gap: 0.5rem; flex-wrap: wrap;">
<div style="min-width: 0; flex: 1;">
<div style="display: flex; align-items: center; gap: 0.5rem; flex-wrap: wrap;">
<a href="/admin/feedback/{i.id}" style="font-weight: 600; color: var(--fb-fg); text-decoration: none;">{i.title}</a>
<span style="font-size: 0.78rem; padding: 0.1rem 0.45rem; border: 1px solid var(--fb-border); border-radius: 999px; color: var(--fb-muted);">
{modeLabel(i)}
</span>
{#if i.status === 'closed'}
<span style="font-size: 0.78rem; padding: 0.1rem 0.45rem; border-radius: 999px; background: #fef3c7; color: #78350f;">closed</span>
{:else}
<span style="font-size: 0.78rem; padding: 0.1rem 0.45rem; border-radius: 999px; background: #dcfce7; color: #14532d;">open</span>
{/if}
</div>
<div style="font-size: 0.85rem; color: var(--fb-muted); margin-top: 0.2rem;">
{i.counts.submissions} Submissions · {i.counts.posts} Posts · seit {new Date(i.created_at).toLocaleDateString()}
</div>
<div style="font-size: 0.78rem; color: var(--fb-muted); margin-top: 0.2rem; word-break: break-all;">
/f/{i.slug}
</div>
</div>
<div style="display: flex; gap: 0.4rem; flex-wrap: wrap;">
<button type="button" class="fb-btn fb-btn--ghost" onclick={() => copyLink(i.slug)}>Link kopieren</button>
<a class="fb-btn fb-btn--ghost" href="/f/{i.slug}" target="_blank" rel="noopener">Öffnen</a>
</div>
</div>
</div>
{/each}
</div>
{/if}
</section>
</div>

View File

@@ -0,0 +1,33 @@
import type { PageServerLoad } from './$types';
import { error, redirect } from '@sveltejs/kit';
import { fdb } from '$lib/server/fdb';
import { getInstanceById } from '$lib/server/feedback';
export const load: PageServerLoad = async ({ params, locals, url }) => {
if (!locals.userId) throw redirect(303, `/login?redirect=${encodeURIComponent(url.pathname)}`);
const inst = await getInstanceById(params.id);
if (!inst) error(404, 'Feedback instance not found');
if (inst.owner_user_id !== locals.userId) error(403, 'Not your instance');
const [s, p] = await Promise.all([
fdb().from('feedback_submissions')
.select('id, display_name, client_session_id, answers, created_at')
.eq('instance_id', inst.id)
.order('created_at', { ascending: false })
.limit(2000),
fdb().from('feedback_posts')
.select('id, display_name, client_session_id, body, hidden, created_at')
.eq('instance_id', inst.id)
.order('created_at', { ascending: true })
.limit(2000),
]);
if (s.error) throw s.error;
if (p.error) throw p.error;
return {
instance: inst,
submissions: s.data || [],
posts: p.data || [],
};
};

View File

@@ -0,0 +1,344 @@
<script lang="ts">
import '$lib/styles/feedback.css';
import { onMount, onDestroy } from 'svelte';
import { goto, invalidateAll } from '$app/navigation';
import type { PageData } from './$types';
import type { FeedbackFormDefinition } from '$lib/server/schemas';
let { data }: { data: PageData } = $props();
let inst = $state(data.instance);
let submissions = $state(data.submissions);
let posts = $state(data.posts);
let activeTab = $state<'chat' | 'submissions' | 'edit'>('chat');
let actionError = $state<string | null>(null);
let actionInFlight = $state(false);
let editTitle = $state(inst.title);
let editDescription = $state(inst.description ?? '');
let editChatEnabled = $state(inst.chat_enabled);
let editFormJson = $state(inst.form_definition ? JSON.stringify(inst.form_definition, null, 2) : '');
let pollHandle: ReturnType<typeof setInterval> | null = null;
const formDef = $derived(inst.form_definition as FeedbackFormDefinition | null);
const questions = $derived(formDef?.questions ?? []);
async function refresh(): Promise<void> {
try {
const res = await fetch(`/api/admin/feedback/${inst.id}`, { headers: { Accept: 'application/json' } });
if (!res.ok) return;
const j = (await res.json()) as { instance: typeof inst; submissions: typeof submissions; posts: typeof posts };
inst = j.instance;
submissions = j.submissions;
posts = j.posts;
} catch {
// transient
}
}
async function toggleHide(postId: string, currentlyHidden: boolean): Promise<void> {
actionError = null;
try {
const res = await fetch(`/api/admin/feedback/${inst.id}/posts/${postId}/hide`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ hidden: !currentlyHidden }),
});
if (!res.ok) {
const j = (await res.json().catch(() => ({}))) as { error?: string };
actionError = j.error ?? `Fehler ${res.status}`;
return;
}
await refresh();
} catch (e) {
actionError = e instanceof Error ? e.message : 'Netzwerkfehler';
}
}
async function setStatus(next: 'open' | 'closed'): Promise<void> {
actionError = null;
actionInFlight = true;
try {
const res = await fetch(`/api/admin/feedback/${inst.id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ status: next }),
});
if (!res.ok) {
const j = (await res.json().catch(() => ({}))) as { error?: string };
actionError = j.error ?? `Fehler ${res.status}`;
return;
}
await refresh();
} catch (e) {
actionError = e instanceof Error ? e.message : 'Netzwerkfehler';
} finally {
actionInFlight = false;
}
}
async function saveEdits(): Promise<void> {
actionError = null;
actionInFlight = true;
let parsedForm: unknown = null;
const trimmed = editFormJson.trim();
if (trimmed) {
try {
parsedForm = JSON.parse(trimmed);
} catch (err) {
actionError = `JSON-Parse-Fehler: ${err instanceof Error ? err.message : 'invalid'}`;
actionInFlight = false;
return;
}
}
try {
const res = await fetch(`/api/admin/feedback/${inst.id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
title: editTitle,
description: editDescription || null,
chat_enabled: editChatEnabled,
form_definition: parsedForm,
}),
});
if (!res.ok) {
const j = (await res.json().catch(() => ({}))) as { error?: string; details?: unknown };
actionError = j.error ?? `Fehler ${res.status}`;
if (j.details) actionError += ' — ' + JSON.stringify(j.details);
return;
}
await refresh();
activeTab = inst.chat_enabled ? 'chat' : 'submissions';
} catch (e) {
actionError = e instanceof Error ? e.message : 'Netzwerkfehler';
} finally {
actionInFlight = false;
}
}
async function destroy(): Promise<void> {
const ok = confirm(`Wirklich Instance "${inst.title}" inkl. aller Submissions/Posts löschen?`);
if (!ok) return;
actionInFlight = true;
try {
const res = await fetch(`/api/admin/feedback/${inst.id}`, { method: 'DELETE' });
if (!res.ok) {
const j = (await res.json().catch(() => ({}))) as { error?: string };
actionError = j.error ?? `Fehler ${res.status}`;
return;
}
await goto('/admin/feedback');
} catch (e) {
actionError = e instanceof Error ? e.message : 'Netzwerkfehler';
} finally {
actionInFlight = false;
}
}
async function copyLink(): Promise<void> {
try {
await navigator.clipboard.writeText(`${location.origin}/f/${inst.slug}`);
} catch {
// silent
}
}
function fmtDateTime(iso: string): string {
return new Date(iso).toLocaleString();
}
function summarizeAnswer(v: unknown): string {
if (v === null || v === undefined) return '—';
if (typeof v === 'boolean') return v ? 'Ja' : 'Nein';
if (Array.isArray(v)) return v.join(', ');
return String(v);
}
function answerCellFor(qid: string, sub: { answers: Record<string, unknown> }): string {
return summarizeAnswer(sub.answers?.[qid]);
}
async function exportCsv(): Promise<void> {
window.location.href = `/api/admin/feedback/${inst.id}/export?format=csv`;
}
async function exportJson(): Promise<void> {
window.location.href = `/api/admin/feedback/${inst.id}/export?format=json`;
}
onMount(() => {
pollHandle = setInterval(refresh, 5000);
return () => {
if (pollHandle) clearInterval(pollHandle);
};
});
onDestroy(() => {
if (pollHandle) clearInterval(pollHandle);
void invalidateAll;
});
</script>
<svelte:head>
<title>{inst.title} — Feedback Admin</title>
<meta name="robots" content="noindex,nofollow" />
</svelte:head>
<div class="fb-shell">
<header class="fb-header">
<div style="display: flex; gap: 0.5rem; align-items: center; flex-wrap: wrap;">
<a href="/admin/feedback" style="color: var(--fb-muted); text-decoration: none;">← alle Instances</a>
</div>
<h1 style="margin-top: 0.5rem;">{inst.title}</h1>
{#if inst.description}<p>{inst.description}</p>{/if}
<div style="display: flex; gap: 0.5rem; flex-wrap: wrap; align-items: center; margin-top: 0.5rem;">
<span style="font-size: 0.85rem; padding: 0.15rem 0.5rem; border-radius: 999px; {inst.status === 'closed' ? 'background:#fef3c7; color:#78350f;' : 'background:#dcfce7; color:#14532d;'}">
{inst.status}
</span>
<span style="font-size: 0.85rem; color: var(--fb-muted);">/f/{inst.slug}</span>
<button type="button" class="fb-btn fb-btn--ghost" onclick={copyLink}>Link kopieren</button>
<a class="fb-btn fb-btn--ghost" href="/f/{inst.slug}" target="_blank" rel="noopener">Vorschau</a>
{#if inst.status === 'open'}
<button type="button" class="fb-btn fb-btn--ghost" disabled={actionInFlight} onclick={() => setStatus('closed')}>
Schließen
</button>
{:else}
<button type="button" class="fb-btn fb-btn--ghost" disabled={actionInFlight} onclick={() => setStatus('open')}>
Wieder öffnen
</button>
{/if}
<button type="button" class="fb-btn fb-btn--ghost" onclick={exportCsv}>CSV-Export</button>
<button type="button" class="fb-btn fb-btn--ghost" onclick={exportJson}>JSON-Export</button>
<button type="button" class="fb-btn fb-btn--danger" disabled={actionInFlight} onclick={destroy}>Löschen</button>
</div>
</header>
{#if actionError}
<div class="fb-banner fb-banner--error">{actionError}</div>
{/if}
<div style="display: flex; gap: 0.25rem; border-bottom: 1px solid var(--fb-border); margin-bottom: 1rem;">
{#each [
{ key: 'chat', label: `Chat (${posts.length})`, show: inst.chat_enabled },
{ key: 'submissions', label: `Submissions (${submissions.length})`, show: !!inst.form_definition },
{ key: 'edit', label: 'Bearbeiten', show: true },
] as t (t.key)}
{#if t.show}
<button
type="button"
onclick={() => (activeTab = t.key as typeof activeTab)}
style="background: none; border: none; padding: 0.5rem 0.875rem; cursor: pointer; color: var(--fb-fg); border-bottom: 2px solid {activeTab === t.key ? 'var(--fb-accent)' : 'transparent'}; font-size: 0.95rem; font-weight: {activeTab === t.key ? '600' : '400'};"
>
{t.label}
</button>
{/if}
{/each}
</div>
{#if activeTab === 'chat'}
<section class="fb-section">
<h2>Live-Chat</h2>
{#if posts.length === 0}
<p style="color: var(--fb-muted);">Noch keine Posts.</p>
{:else}
<div style="display: flex; flex-direction: column; gap: 0.5rem;">
{#each posts as p (p.id)}
<div class="fb-chat__post {p.hidden ? 'fb-chat__post--hidden' : ''}">
<div class="fb-chat__meta">
<span class="fb-chat__name">{p.display_name ?? 'anonym'}</span>
<span>{fmtDateTime(p.created_at)}</span>
<span style="font-size: 0.75rem;">session: {p.client_session_id.slice(0, 8)}</span>
<button
type="button"
style="margin-left: auto; background: none; border: 1px solid var(--fb-border); border-radius: 4px; padding: 0.15rem 0.5rem; font-size: 0.8rem; cursor: pointer; color: var(--fb-fg);"
onclick={() => toggleHide(p.id, p.hidden)}
>
{p.hidden ? 'Wieder anzeigen' : 'Verstecken'}
</button>
</div>
<div class="fb-chat__body" style="text-decoration: {p.hidden ? 'line-through' : 'none'};">
{p.body}
</div>
</div>
{/each}
</div>
{/if}
</section>
{:else if activeTab === 'submissions'}
<section class="fb-section">
<h2>Form Submissions</h2>
{#if submissions.length === 0}
<p style="color: var(--fb-muted);">Noch keine Submissions.</p>
{:else}
<div style="overflow-x: auto;">
<table style="width: 100%; border-collapse: collapse; font-size: 0.875rem;">
<thead>
<tr style="border-bottom: 1px solid var(--fb-border);">
<th style="text-align: left; padding: 0.5rem 0.4rem;">Wann</th>
<th style="text-align: left; padding: 0.5rem 0.4rem;">Name</th>
{#each questions as q (q.id)}
<th style="text-align: left; padding: 0.5rem 0.4rem;">{q.label}</th>
{/each}
</tr>
</thead>
<tbody>
{#each submissions as s (s.id)}
<tr style="border-bottom: 1px solid var(--fb-border);">
<td style="padding: 0.5rem 0.4rem; white-space: nowrap; color: var(--fb-muted);">{fmtDateTime(s.created_at)}</td>
<td style="padding: 0.5rem 0.4rem;">{s.display_name ?? 'anonym'}</td>
{#each questions as q (q.id)}
<td style="padding: 0.5rem 0.4rem; max-width: 320px; word-break: break-word;">
{answerCellFor(q.id, s)}
</td>
{/each}
</tr>
{/each}
</tbody>
</table>
</div>
{/if}
</section>
{:else if activeTab === 'edit'}
<section class="fb-section">
<h2>Bearbeiten</h2>
{#if submissions.length > 0 || posts.length > 0}
<div class="fb-banner">
Diese Instance hat bereits Submissions/Posts. Form-Definitions-Änderungen können bestehende Antworten unbrauchbar machen.
</div>
{/if}
<div class="fb-question">
<label class="fb-question__label" for="fb-edit-title">Titel</label>
<input id="fb-edit-title" class="fb-input" maxlength="200" bind:value={editTitle} />
</div>
<div class="fb-question">
<label class="fb-question__label" for="fb-edit-desc">Beschreibung</label>
<textarea id="fb-edit-desc" class="fb-textarea" maxlength="2000" rows="2" bind:value={editDescription}></textarea>
</div>
<div class="fb-question">
<label class="fb-option-row" style="display:inline-flex;">
<input type="checkbox" bind:checked={editChatEnabled} />
<span>Live-Chat aktiv</span>
</label>
</div>
<div class="fb-question">
<label class="fb-question__label" for="fb-edit-form">Form-Definition (JSON)</label>
<textarea
id="fb-edit-form"
class="fb-textarea"
rows="14"
bind:value={editFormJson}
style="font-family: ui-monospace, 'SF Mono', Menlo, monospace; font-size: 0.85rem;"
></textarea>
</div>
<button type="button" class="fb-btn" disabled={actionInFlight} onclick={saveEdits}>
{actionInFlight ? 'Speichere …' : 'Speichern'}
</button>
</section>
{/if}
</div>

View File

@@ -0,0 +1,12 @@
import type { PageServerLoad } from './$types';
import { redirect } from '@sveltejs/kit';
export const load: PageServerLoad = async ({ locals, url }) => {
if (locals.userId) {
const next = url.searchParams.get('redirect') ?? '/admin/feedback';
throw redirect(303, next);
}
return {
next: url.searchParams.get('redirect') ?? '/admin/feedback',
};
};

View File

@@ -0,0 +1,81 @@
<script lang="ts">
import type { PageData } from './$types';
import { goto } from '$app/navigation';
let { data }: { data: PageData } = $props();
let email = $state('');
let password = $state('');
let error = $state<string | null>(null);
let inFlight = $state(false);
async function signIn(e: SubmitEvent): Promise<void> {
e.preventDefault();
inFlight = true;
error = null;
try {
const res = await fetch('/api/auth/sign-in', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password }),
});
if (!res.ok) {
const j = (await res.json().catch(() => ({}))) as { error?: string };
error = j.error ?? `Fehler ${res.status}`;
return;
}
await goto(data.next);
} catch (e) {
error = e instanceof Error ? e.message : 'Netzwerkfehler';
} finally {
inFlight = false;
}
}
</script>
<svelte:head>
<title>Login — fdbck</title>
<meta name="robots" content="noindex,nofollow" />
</svelte:head>
<div class="fb-shell">
<header class="fb-header">
<h1>Admin-Login</h1>
<p>Nur für m.</p>
</header>
<section class="fb-section">
<form onsubmit={signIn}>
<div class="fb-question">
<label class="fb-question__label" for="login-email">E-Mail</label>
<input
id="login-email"
class="fb-input"
type="email"
autocomplete="username"
required
bind:value={email}
/>
</div>
<div class="fb-question">
<label class="fb-question__label" for="login-password">Passwort</label>
<input
id="login-password"
class="fb-input"
type="password"
autocomplete="current-password"
required
bind:value={password}
/>
</div>
{#if error}
<div class="fb-banner fb-banner--error">{error}</div>
{/if}
<button type="submit" class="fb-btn" disabled={inFlight}>
{inFlight ? 'Logging in …' : 'Einloggen'}
</button>
</form>
</section>
</div>