Merge mai/dokploy/admin-list-actions: per-row actions + English rewrite + segmented tabs + /admin/feedback/new

This commit is contained in:
m
2026-05-05 18:51:43 +02:00
8 changed files with 383 additions and 230 deletions

View File

@@ -4,12 +4,12 @@
let { value = $bindable() }: { value: FeedbackFormDefinition } = $props();
const TYPE_LABELS: Record<FeedbackQuestion['type'], string> = {
short_text: 'Kurztext',
long_text: 'Langtext',
single_choice: 'Single-Choice',
multi_choice: 'Multi-Choice',
scale: 'Skala',
boolean: 'Ja/Nein',
short_text: 'Short text',
long_text: 'Long text',
single_choice: 'Single choice',
multi_choice: 'Multiple choice',
scale: 'Scale',
boolean: 'Yes / No',
};
const TYPES: FeedbackQuestion['type'][] = [
@@ -28,7 +28,7 @@
}
function defaultQuestion(type: FeedbackQuestion['type']): FeedbackQuestion {
const base = { id: uid(), label: 'Neue Frage', required: false } as const;
const base = { id: uid(), label: 'New question', required: false } as const;
switch (type) {
case 'short_text':
case 'long_text':
@@ -126,9 +126,9 @@
{/each}
</select>
<div class="fb-builder__card-actions">
<button type="button" class="fb-builder__icon-btn" disabled={i === 0} onclick={() => move(i, -1)} aria-label="Nach oben"></button>
<button type="button" class="fb-builder__icon-btn" disabled={i === value.questions.length - 1} onclick={() => move(i, 1)} aria-label="Nach unten"></button>
<button type="button" class="fb-builder__icon-btn fb-builder__icon-btn--danger" disabled={value.questions.length === 1} onclick={() => remove(i)} aria-label="Entfernen"></button>
<button type="button" class="fb-builder__icon-btn" disabled={i === 0} onclick={() => move(i, -1)} aria-label="Move up"></button>
<button type="button" class="fb-builder__icon-btn" disabled={i === value.questions.length - 1} onclick={() => move(i, 1)} aria-label="Move down"></button>
<button type="button" class="fb-builder__icon-btn fb-builder__icon-btn--danger" disabled={value.questions.length === 1} onclick={() => remove(i)} aria-label="Remove"></button>
</div>
</div>
@@ -148,12 +148,12 @@
checked={q.required === true}
onchange={(e) => update(i, { required: (e.target as HTMLInputElement).checked })}
/>
<span>Pflichtfeld</span>
<span>Required</span>
</label>
{#if q.type === 'short_text' || q.type === 'long_text'}
<div class="fb-question">
<label class="fb-question__label">Platzhalter (optional)</label>
<label class="fb-question__label">Placeholder (optional)</label>
<input
class="fb-input"
maxlength="100"
@@ -163,7 +163,7 @@
</div>
{:else if q.type === 'single_choice' || q.type === 'multi_choice'}
<div class="fb-question">
<label class="fb-question__label">Optionen</label>
<label class="fb-question__label">Options</label>
<div class="fb-builder__options">
{#each q.options as opt, optIdx (optIdx)}
<div class="fb-builder__option-row">
@@ -178,7 +178,7 @@
class="fb-builder__icon-btn fb-builder__icon-btn--danger"
disabled={q.options.length <= 2}
onclick={() => removeOption(i, optIdx)}
aria-label="Option entfernen"
aria-label="Remove option"
></button>
</div>
{/each}
@@ -210,7 +210,7 @@
/>
</div>
<div class="fb-question">
<label class="fb-question__label">Min-Label (opt.)</label>
<label class="fb-question__label">Min label (optional)</label>
<input
class="fb-input"
maxlength="50"
@@ -219,7 +219,7 @@
/>
</div>
<div class="fb-question">
<label class="fb-question__label">Max-Label (opt.)</label>
<label class="fb-question__label">Max label (optional)</label>
<input
class="fb-input"
maxlength="50"
@@ -231,7 +231,7 @@
{/if}
<div class="fb-question">
<label class="fb-question__label">Hilfetext (optional)</label>
<label class="fb-question__label">Help text (optional)</label>
<input
class="fb-input"
maxlength="500"

View File

@@ -450,6 +450,49 @@ body { min-height: 100vh; }
margin-top: 2rem;
}
/* Tab strip — segmented pills */
.fb-tabs {
display: flex;
gap: 0.3rem;
flex-wrap: wrap;
padding: 0.25rem;
background: var(--color-bg-tertiary);
border-radius: var(--radius-md);
margin-bottom: 1.25rem;
}
.fb-tab {
background: transparent;
border: 1px solid transparent;
padding: 0.5rem 0.95rem;
border-radius: var(--radius-md);
cursor: pointer;
color: var(--color-text-secondary);
font-size: 0.9rem;
font-weight: 500;
font-family: inherit;
transition: background 150ms ease, color 150ms ease, border-color 150ms ease;
}
.fb-tab:hover:not(.fb-tab--active) {
background: var(--color-bg-primary);
color: var(--color-text-primary);
}
.fb-tab--active {
background: var(--color-primary-light);
color: var(--color-primary-hover);
border-color: var(--color-primary);
font-weight: 600;
}
@media (prefers-color-scheme: dark) {
.fb-tab--active {
color: var(--color-primary);
}
}
/* Form builder */
.fb-builder {

View File

@@ -1,5 +1,5 @@
<svelte:head>
<title>fdbck — Feedback per Link</title>
<title>fdbck — feedback by link</title>
<meta name="robots" content="noindex,nofollow" />
</svelte:head>
@@ -7,15 +7,14 @@
<header class="fb-header">
<h1>fdbck</h1>
<p>
Per-Link Feedback-Forms und Live-Chat-Masken.
Anonym, ohne Anmeldung, nur mit langem Link.
Private feedback forms and live chat — share a link, get answers.
</p>
</header>
<div class="fb-section">
<p>Diese Seite ist nur über persönlich geteilte Links erreichbar.</p>
<p>This page is only reachable through a private link shared with you.</p>
<p style="margin-top: 0.75rem;">
<a href="/login" class="fb-btn fb-btn--ghost">Admin-Login</a>
<a href="/login" class="fb-btn fb-btn--ghost">Admin sign-in</a>
</p>
</div>
</div>

View File

@@ -1,36 +1,12 @@
<script lang="ts">
import '$lib/styles/feedback.css';
import type { PageData } from './$types';
import { goto } from '$app/navigation';
import { invalidateAll } 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;
}
let rowError = $state<Record<string, string>>({});
let rowBusy = $state<Record<string, boolean>>({});
async function copyLink(slug: string): Promise<void> {
try {
@@ -41,56 +17,70 @@
}
}
function setRowError(id: string, msg: string | null): void {
if (msg === null) {
const { [id]: _, ...rest } = rowError;
rowError = rest;
} else {
rowError = { ...rowError, [id]: msg };
}
}
async function destroyInstance(id: string, instTitle: string): Promise<void> {
if (rowBusy[id]) return;
const ok = confirm(`Delete "${instTitle}" and all its responses and messages? This cannot be undone.`);
if (!ok) return;
rowBusy = { ...rowBusy, [id]: true };
setRowError(id, null);
try {
const res = await fetch(`/api/admin/feedback/${id}`, { method: 'DELETE' });
if (!res.ok) {
const j = (await res.json().catch(() => ({}))) as { error?: string };
setRowError(id, j.error ?? `Error ${res.status}`);
return;
}
await invalidateAll();
} catch (err) {
setRowError(id, err instanceof Error ? err.message : 'Network error');
} finally {
const { [id]: _, ...rest } = rowBusy;
rowBusy = rest;
}
}
async function toggleStatus(id: string, current: 'open' | 'closed'): Promise<void> {
if (rowBusy[id]) return;
const next = current === 'open' ? 'closed' : 'open';
rowBusy = { ...rowBusy, [id]: true };
setRowError(id, null);
try {
const res = await fetch(`/api/admin/feedback/${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 };
setRowError(id, j.error ?? `Error ${res.status}`);
return;
}
await invalidateAll();
} catch (err) {
setRowError(id, err instanceof Error ? err.message : 'Network error');
} finally {
const { [id]: _, ...rest } = rowBusy;
rowBusy = rest;
}
}
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 && 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>
@@ -100,103 +90,68 @@
<div class="fb-shell">
<header class="fb-header">
<h1>Feedback Instances</h1>
<p>Forms und Live-Chat-Masken — per langem Slug zugänglich.</p>
<div style="display: flex; align-items: flex-start; justify-content: space-between; gap: 1rem; flex-wrap: wrap;">
<div>
<h1 style="margin: 0 0 0.25rem;">Feedback forms</h1>
<p style="margin: 0;">Collect feedback through forms or live chat. Share a private link with your audience.</p>
</div>
<a href="/admin/feedback/new" class="fb-btn">+ New form</a>
</div>
</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>
<h2>Your forms ({data.instances.length})</h2>
{#if data.instances.length === 0}
<p style="color: var(--fb-muted);">Noch keine Instance erstellt.</p>
<p style="color: var(--color-text-muted);">No forms yet.</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="border: 1px solid var(--color-border-primary); border-radius: var(--radius-lg); padding: 0.85rem; background: var(--color-bg-primary); box-shadow: var(--shadow-sm);">
<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);">
<a href="/admin/feedback/{i.id}" style="font-weight: 600; color: var(--color-text-primary); text-decoration: none;">{i.title}</a>
<span style="font-size: 0.78rem; padding: 0.1rem 0.45rem; border: 1px solid var(--color-border-primary); border-radius: 999px; color: var(--color-text-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>
<span style="font-size: 0.78rem; padding: 0.1rem 0.45rem; border-radius: 999px; background: var(--color-primary-light); color: var(--color-primary-hover);">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 style="font-size: 0.85rem; color: var(--color-text-muted); margin-top: 0.2rem;">
{i.counts.submissions} responses · {i.counts.posts} messages · created {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;">
<div style="font-size: 0.78rem; color: var(--color-text-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 style="display: flex; gap: 0.4rem; flex-wrap: wrap; align-items: flex-start;">
<a class="fb-btn fb-btn--ghost" href="/admin/feedback/{i.id}">Edit</a>
<button type="button" class="fb-btn fb-btn--ghost" onclick={() => copyLink(i.slug)}>Copy link</button>
<a class="fb-btn fb-btn--ghost" href="/f/{i.slug}" target="_blank" rel="noopener">Open</a>
<button
type="button"
class="fb-btn fb-btn--ghost"
disabled={rowBusy[i.id]}
onclick={() => toggleStatus(i.id, i.status)}
>
{i.status === 'open' ? 'Close' : 'Reopen'}
</button>
<button
type="button"
class="fb-btn fb-btn--danger"
disabled={rowBusy[i.id]}
onclick={() => destroyInstance(i.id, i.title)}
>
Delete
</button>
</div>
</div>
{#if rowError[i.id]}
<div class="fb-banner fb-banner--error" style="margin-top: 0.6rem; margin-bottom: 0;">{rowError[i.id]}</div>
{/if}
</div>
{/each}
</div>

View File

@@ -59,7 +59,7 @@
}
function ensureBuilderForm(): void {
if (!editForm) editForm = { questions: [{ id: 'q1', label: 'Frage 1', type: 'short_text', required: false }] };
if (!editForm) editForm = { questions: [{ id: 'q1', label: 'Question 1', type: 'short_text', required: false }] };
}
let pollHandle: ReturnType<typeof setInterval> | null = null;
@@ -97,12 +97,12 @@
});
if (!res.ok) {
const j = (await res.json().catch(() => ({}))) as { error?: string };
actionError = j.error ?? `Fehler ${res.status}`;
actionError = j.error ?? `Error ${res.status}`;
return;
}
await refresh();
} catch (e) {
actionError = e instanceof Error ? e.message : 'Netzwerkfehler';
actionError = e instanceof Error ? e.message : 'Network error';
}
}
@@ -117,12 +117,12 @@
});
if (!res.ok) {
const j = (await res.json().catch(() => ({}))) as { error?: string };
actionError = j.error ?? `Fehler ${res.status}`;
actionError = j.error ?? `Error ${res.status}`;
return;
}
await refresh();
} catch (e) {
actionError = e instanceof Error ? e.message : 'Netzwerkfehler';
actionError = e instanceof Error ? e.message : 'Network error';
} finally {
actionInFlight = false;
}
@@ -139,7 +139,7 @@
try {
parsedForm = FeedbackFormDefinitionSchema.parse(JSON.parse(trimmed));
} catch (err) {
actionError = `JSON ungültig: ${err instanceof Error ? err.message : 'parse error'}`;
actionError = `Invalid JSON: ${err instanceof Error ? err.message : 'parse error'}`;
actionInFlight = false;
return;
}
@@ -150,7 +150,7 @@
try {
parsedForm = FeedbackFormDefinitionSchema.parse(parsedForm);
} catch (err) {
actionError = `Form-Definition ungültig: ${err instanceof Error ? err.message : 'parse error'}`;
actionError = `Invalid questions: ${err instanceof Error ? err.message : 'parse error'}`;
actionInFlight = false;
return;
}
@@ -171,7 +171,7 @@
});
if (!res.ok) {
const j = (await res.json().catch(() => ({}))) as { error?: string; details?: unknown };
actionError = j.error ?? `Fehler ${res.status}`;
actionError = j.error ?? `Error ${res.status}`;
if (j.details) actionError += ' — ' + JSON.stringify(j.details);
return;
}
@@ -180,26 +180,26 @@
syncJsonFromVisual();
activeTab = inst.form_definition ? 'results' : (inst.chat_enabled ? 'chat' : 'submissions');
} catch (e) {
actionError = e instanceof Error ? e.message : 'Netzwerkfehler';
actionError = e instanceof Error ? e.message : 'Network error';
} finally {
actionInFlight = false;
}
}
async function destroy(): Promise<void> {
const ok = confirm(`Wirklich Instance "${inst.title}" inkl. aller Submissions/Posts löschen?`);
const ok = confirm(`Delete "${inst.title}" and all its responses and messages? This cannot be undone.`);
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}`;
actionError = j.error ?? `Error ${res.status}`;
return;
}
await goto('/admin/feedback');
} catch (e) {
actionError = e instanceof Error ? e.message : 'Netzwerkfehler';
actionError = e instanceof Error ? e.message : 'Network error';
} finally {
actionInFlight = false;
}
@@ -219,7 +219,7 @@
function summarizeAnswer(v: unknown): string {
if (v === null || v === undefined) return '—';
if (typeof v === 'boolean') return v ? 'Ja' : 'Nein';
if (typeof v === 'boolean') return v ? 'Yes' : 'No';
if (Array.isArray(v)) return v.join(', ');
return String(v);
}
@@ -250,37 +250,37 @@
</script>
<svelte:head>
<title>{inst.title} — Feedback Admin</title>
<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>
<a href="/admin/feedback" style="color: var(--color-text-muted); text-decoration: none;">All forms</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;'}">
<span style="font-size: 0.85rem; padding: 0.15rem 0.5rem; border-radius: 999px; {inst.status === 'closed' ? 'background:#fef3c7; color:#78350f;' : 'background:var(--color-primary-light); color:var(--color-primary-hover);'}">
{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>
<span style="font-size: 0.85rem; color: var(--color-text-muted);">/f/{inst.slug}</span>
<button type="button" class="fb-btn fb-btn--ghost" onclick={copyLink}>Copy link</button>
<a class="fb-btn fb-btn--ghost" href="/f/{inst.slug}" target="_blank" rel="noopener">Preview</a>
{#if inst.status === 'open'}
<button type="button" class="fb-btn fb-btn--ghost" disabled={actionInFlight} onclick={() => setStatus('closed')}>
Schließen
Close
</button>
{:else}
<button type="button" class="fb-btn fb-btn--ghost" disabled={actionInFlight} onclick={() => setStatus('open')}>
Wieder öffnen
Reopen
</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>
<button type="button" class="fb-btn fb-btn--ghost" onclick={exportCsv}>Export CSV</button>
<button type="button" class="fb-btn fb-btn--ghost" onclick={exportJson}>Export JSON</button>
<button type="button" class="fb-btn fb-btn--danger" disabled={actionInFlight} onclick={destroy}>Delete</button>
</div>
</header>
@@ -288,18 +288,19 @@
<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;">
<div class="fb-tabs">
{#each [
{ key: 'chat', label: `Chat (${posts.length})`, show: inst.chat_enabled },
{ key: 'results', label: `Ergebnisse${results ? ` (${results.total_submissions})` : ''}`, show: !!inst.form_definition },
{ key: 'submissions', label: `Submissions (${submissions.length})`, show: !!inst.form_definition },
{ key: 'edit', label: 'Bearbeiten', show: true },
{ key: 'results', label: `Results${results ? ` (${results.total_submissions})` : ''}`, show: !!inst.form_definition },
{ key: 'submissions', label: `Responses (${submissions.length})`, show: !!inst.form_definition },
{ key: 'edit', label: 'Edit', show: true },
] as t (t.key)}
{#if t.show}
<button
type="button"
class="fb-tab"
class:fb-tab--active={activeTab === t.key}
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>
@@ -309,23 +310,23 @@
{#if activeTab === 'chat'}
<section class="fb-section">
<h2>Live-Chat</h2>
<h2>Live chat</h2>
{#if posts.length === 0}
<p style="color: var(--fb-muted);">Noch keine Posts.</p>
<p style="color: var(--color-text-muted);">No messages yet.</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 class="fb-chat__name">{p.display_name ?? 'anonymous'}</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);"
style="margin-left: auto; background: none; border: 1px solid var(--color-border-primary); border-radius: 4px; padding: 0.15rem 0.5rem; font-size: 0.8rem; cursor: pointer; color: var(--color-text-primary);"
onclick={() => toggleHide(p.id, p.hidden)}
>
{p.hidden ? 'Wieder anzeigen' : 'Verstecken'}
{p.hidden ? 'Show' : 'Hide'}
</button>
</div>
<div class="fb-chat__body" style="text-decoration: {p.hidden ? 'line-through' : 'none'};">
@@ -338,24 +339,24 @@
</section>
{:else if activeTab === 'results'}
<section class="fb-section">
<h2>Ergebnisse{formVersion ? ` · v${formVersion}` : ''}</h2>
<h2>Results{formVersion ? ` · v${formVersion}` : ''}</h2>
{#if results}
<Results {results} />
{:else}
<p style="color: var(--fb-muted);">Keine Form-Definition.</p>
<p style="color: var(--color-text-muted);">No questions configured.</p>
{/if}
</section>
{:else if activeTab === 'submissions'}
<section class="fb-section">
<h2>Form Submissions</h2>
<h2>Responses</h2>
{#if submissions.length === 0}
<p style="color: var(--fb-muted);">Noch keine Submissions.</p>
<p style="color: var(--color-text-muted);">No responses yet.</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>
<tr style="border-bottom: 1px solid var(--color-border-primary);">
<th style="text-align: left; padding: 0.5rem 0.4rem;">When</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>
@@ -364,9 +365,9 @@
</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>
<tr style="border-bottom: 1px solid var(--color-border-primary);">
<td style="padding: 0.5rem 0.4rem; white-space: nowrap; color: var(--color-text-muted);">{fmtDateTime(s.created_at)}</td>
<td style="padding: 0.5rem 0.4rem;">{s.display_name ?? 'anonymous'}</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)}
@@ -381,47 +382,47 @@
</section>
{:else if activeTab === 'edit'}
<section class="fb-section">
<h2>Bearbeiten{formVersion ? ` · v${formVersion}` : ''}</h2>
<h2>Edit{formVersion ? ` · v${formVersion}` : ''}</h2>
{#if submissions.length > 0}
<div class="fb-banner">
{submissions.length} Antworten bereits eingegangen. Beim Speichern wird die Version automatisch hochgezählt — alte Antworten behalten ihren Snapshot.
{submissions.length} responses already received. Saving will automatically bump the version — earlier responses keep their original snapshot.
</div>
{/if}
<div class="fb-question">
<label class="fb-question__label" for="fb-edit-title">Titel</label>
<label class="fb-question__label" for="fb-edit-title">Title</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>
<label class="fb-question__label" for="fb-edit-desc">Description</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>
<span>Enable live chat</span>
</label>
</div>
<div class="fb-question">
<label class="fb-option-row" style="display:inline-flex;">
<input type="checkbox" bind:checked={editLiveResults} />
<span>Live-Ergebnisse auf der Teilnehmerseite zeigen (nach Absenden)</span>
<span>Show live results on the participant page after submitting</span>
</label>
</div>
<div class="fb-question">
<div style="display: flex; align-items: center; gap: 0.5rem; margin-bottom: 0.5rem; flex-wrap: wrap;">
<span class="fb-question__label" style="margin: 0;">Form-Definition</span>
<span class="fb-question__label" style="margin: 0;">Questions</span>
<div style="display: inline-flex; gap: 0.25rem; margin-left: auto;">
<button
type="button"
class="fb-btn fb-btn--ghost"
style="padding: 0.3rem 0.7rem; min-height: 0; font-size: 0.85rem; {editMode === 'visual' ? 'background: var(--fb-hidden-bg);' : ''}"
style="padding: 0.3rem 0.7rem; min-height: 0; font-size: 0.85rem; {editMode === 'visual' ? 'background: var(--color-bg-tertiary);' : ''}"
onclick={() => switchEditMode('visual')}
>Visuell</button>
>Visual</button>
<button
type="button"
class="fb-btn fb-btn--ghost"
style="padding: 0.3rem 0.7rem; min-height: 0; font-size: 0.85rem; {editMode === 'json' ? 'background: var(--fb-hidden-bg);' : ''}"
style="padding: 0.3rem 0.7rem; min-height: 0; font-size: 0.85rem; {editMode === 'json' ? 'background: var(--color-bg-tertiary);' : ''}"
onclick={() => switchEditMode('json')}
>JSON</button>
</div>
@@ -431,8 +432,8 @@
{#if editForm}
<FormBuilder bind:value={editForm as FeedbackFormDefinition} />
{:else}
<p style="color: var(--fb-muted);">Kein Formular angelegt.</p>
<button type="button" class="fb-btn fb-btn--ghost" onclick={ensureBuilderForm}>+ Formular anlegen</button>
<p style="color: var(--color-text-muted);">No questions configured.</p>
<button type="button" class="fb-btn fb-btn--ghost" onclick={ensureBuilderForm}>+ Add questions</button>
{/if}
{:else}
<textarea
@@ -446,7 +447,7 @@
</div>
<button type="button" class="fb-btn" disabled={actionInFlight} onclick={saveEdits}>
{actionInFlight ? 'Speichere …' : 'Speichern'}
{actionInFlight ? 'Saving…' : 'Save'}
</button>
</section>
{/if}

View File

@@ -0,0 +1,7 @@
import type { PageServerLoad } from './$types';
import { redirect } from '@sveltejs/kit';
export const load: PageServerLoad = async ({ locals, url }) => {
if (!locals.userId) throw redirect(303, `/login?redirect=${encodeURIComponent(url.pathname)}`);
return {};
};

View File

@@ -0,0 +1,148 @@
<script lang="ts">
import '$lib/styles/feedback.css';
import { goto } from '$app/navigation';
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: 'Quick feedback after the session — thanks for your time.',
outro: 'Thanks!',
questions: [
{ id: 'overall', type: 'scale', label: 'How would you rate the session overall?', required: true, min: 1, max: 5, min_label: 'poor', max_label: 'great' },
{ id: 'helpful', type: 'long_text', label: 'What was particularly helpful?', placeholder: 'optional' },
{ id: 'improve', type: 'long_text', label: 'What should we improve?', placeholder: 'optional' },
{ id: 'recommend', type: 'boolean', label: 'Would you recommend the session?', required: true },
],
},
null,
2,
);
function pasteSample(): void {
formJson = SAMPLE_FORM;
}
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 = `Invalid JSON: ${err instanceof Error ? err.message : 'parse error'}`;
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 ?? `Error ${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 : 'Network error';
} finally {
creating = false;
}
}
</script>
<svelte:head>
<title>Create form — fdbck</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(--color-text-muted); text-decoration: none;">← All forms</a>
</div>
<h1 style="margin-top: 0.5rem;">Create a new form</h1>
<p>Set up a feedback form, a live chat session, or both. You'll get a private link to share.</p>
</header>
<section class="fb-section">
<form onsubmit={createInstance}>
<div class="fb-question">
<label class="fb-question__label" for="fb-new-title">Title</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">Description (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>Enable live chat</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">Questions (JSON, optional)</label>
<button type="button" class="fb-btn fb-btn--ghost" onclick={pasteSample}>Insert sample</button>
</div>
<textarea
id="fb-new-form"
class="fb-textarea"
rows="14"
placeholder={'{\n "questions": [\n { "id": "q1", "type": "short_text", "label": "Your 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">
Leave empty for chat-only feedback. You can edit questions visually after the form is created.
</div>
</div>
{#if createError}
<div class="fb-banner fb-banner--error">{createError}</div>
{/if}
<button type="submit" class="fb-btn" disabled={creating}>
{creating ? 'Creating…' : 'Create form'}
</button>
</form>
</section>
</div>

View File

@@ -21,12 +21,12 @@
});
if (!res.ok) {
const j = (await res.json().catch(() => ({}))) as { error?: string };
error = j.error ?? `Fehler ${res.status}`;
error = j.error ?? `Error ${res.status}`;
return;
}
await goto(data.next);
} catch (e) {
error = e instanceof Error ? e.message : 'Netzwerkfehler';
error = e instanceof Error ? e.message : 'Network error';
} finally {
inFlight = false;
}
@@ -34,20 +34,20 @@
</script>
<svelte:head>
<title>Login — fdbck</title>
<title>Sign in — 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>
<h1>Sign in</h1>
<p>Admin access only.</p>
</header>
<section class="fb-section">
<form onsubmit={signIn}>
<div class="fb-question">
<label class="fb-question__label" for="login-email">E-Mail</label>
<label class="fb-question__label" for="login-email">Email</label>
<input
id="login-email"
class="fb-input"
@@ -58,7 +58,7 @@
/>
</div>
<div class="fb-question">
<label class="fb-question__label" for="login-password">Passwort</label>
<label class="fb-question__label" for="login-password">Password</label>
<input
id="login-password"
class="fb-input"
@@ -74,7 +74,7 @@
{/if}
<button type="submit" class="fb-btn" disabled={inFlight}>
{inFlight ? 'Logging in …' : 'Einloggen'}
{inFlight ? 'Signing in…' : 'Sign in'}
</button>
</form>
</section>