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

View File

@@ -450,6 +450,49 @@ body { min-height: 100vh; }
margin-top: 2rem; 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 */ /* Form builder */
.fb-builder { .fb-builder {

View File

@@ -1,5 +1,5 @@
<svelte:head> <svelte:head>
<title>fdbck — Feedback per Link</title> <title>fdbck — feedback by link</title>
<meta name="robots" content="noindex,nofollow" /> <meta name="robots" content="noindex,nofollow" />
</svelte:head> </svelte:head>
@@ -7,15 +7,14 @@
<header class="fb-header"> <header class="fb-header">
<h1>fdbck</h1> <h1>fdbck</h1>
<p> <p>
Per-Link Feedback-Forms und Live-Chat-Masken. Private feedback forms and live chat — share a link, get answers.
Anonym, ohne Anmeldung, nur mit langem Link.
</p> </p>
</header> </header>
<div class="fb-section"> <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;"> <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> </p>
</div> </div>
</div> </div>

View File

@@ -1,36 +1,12 @@
<script lang="ts"> <script lang="ts">
import '$lib/styles/feedback.css'; import '$lib/styles/feedback.css';
import type { PageData } from './$types'; import type { PageData } from './$types';
import { goto } from '$app/navigation'; import { invalidateAll } from '$app/navigation';
let { data }: { data: PageData } = $props(); let { data }: { data: PageData } = $props();
let creating = $state(false); let rowError = $state<Record<string, string>>({});
let createError = $state<string | null>(null); let rowBusy = $state<Record<string, boolean>>({});
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> { async function copyLink(slug: string): Promise<void> {
try { 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 { function modeLabel(i: { form_definition: unknown | null; chat_enabled: boolean }): string {
const hasForm = i.form_definition != null; 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 (hasForm) return 'Form';
if (i.chat_enabled) return 'Chat'; if (i.chat_enabled) return 'Chat';
return '—'; 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> </script>
<svelte:head> <svelte:head>
@@ -100,103 +90,68 @@
<div class="fb-shell"> <div class="fb-shell">
<header class="fb-header"> <header class="fb-header">
<h1>Feedback Instances</h1> <div style="display: flex; align-items: flex-start; justify-content: space-between; gap: 1rem; flex-wrap: wrap;">
<p>Forms und Live-Chat-Masken — per langem Slug zugänglich.</p> <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> </header>
<section class="fb-section"> <section class="fb-section">
<h2>Neue Instance</h2> <h2>Your forms ({data.instances.length})</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} {#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} {:else}
<div style="display: flex; flex-direction: column; gap: 0.5rem;"> <div style="display: flex; flex-direction: column; gap: 0.5rem;">
{#each data.instances as i (i.id)} {#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="display: flex; justify-content: space-between; gap: 0.5rem; flex-wrap: wrap;">
<div style="min-width: 0; flex: 1;"> <div style="min-width: 0; flex: 1;">
<div style="display: flex; align-items: center; gap: 0.5rem; flex-wrap: wrap;"> <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> <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(--fb-border); border-radius: 999px; color: var(--fb-muted);"> <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)} {modeLabel(i)}
</span> </span>
{#if i.status === 'closed'} {#if i.status === 'closed'}
<span style="font-size: 0.78rem; padding: 0.1rem 0.45rem; border-radius: 999px; background: #fef3c7; color: #78350f;">closed</span> <span style="font-size: 0.78rem; padding: 0.1rem 0.45rem; border-radius: 999px; background: #fef3c7; color: #78350f;">closed</span>
{:else} {: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} {/if}
</div> </div>
<div style="font-size: 0.85rem; color: var(--fb-muted); margin-top: 0.2rem;"> <div style="font-size: 0.85rem; color: var(--color-text-muted); margin-top: 0.2rem;">
{i.counts.submissions} Submissions · {i.counts.posts} Posts · seit {new Date(i.created_at).toLocaleDateString()} {i.counts.submissions} responses · {i.counts.posts} messages · created {new Date(i.created_at).toLocaleDateString()}
</div> </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} /f/{i.slug}
</div> </div>
</div> </div>
<div style="display: flex; gap: 0.4rem; flex-wrap: wrap;"> <div style="display: flex; gap: 0.4rem; flex-wrap: wrap; align-items: flex-start;">
<button type="button" class="fb-btn fb-btn--ghost" onclick={() => copyLink(i.slug)}>Link kopieren</button> <a class="fb-btn fb-btn--ghost" href="/admin/feedback/{i.id}">Edit</a>
<a class="fb-btn fb-btn--ghost" href="/f/{i.slug}" target="_blank" rel="noopener">Öffnen</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>
</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> </div>
{/each} {/each}
</div> </div>

View File

@@ -59,7 +59,7 @@
} }
function ensureBuilderForm(): void { 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; let pollHandle: ReturnType<typeof setInterval> | null = null;
@@ -97,12 +97,12 @@
}); });
if (!res.ok) { if (!res.ok) {
const j = (await res.json().catch(() => ({}))) as { error?: string }; const j = (await res.json().catch(() => ({}))) as { error?: string };
actionError = j.error ?? `Fehler ${res.status}`; actionError = j.error ?? `Error ${res.status}`;
return; return;
} }
await refresh(); await refresh();
} catch (e) { } catch (e) {
actionError = e instanceof Error ? e.message : 'Netzwerkfehler'; actionError = e instanceof Error ? e.message : 'Network error';
} }
} }
@@ -117,12 +117,12 @@
}); });
if (!res.ok) { if (!res.ok) {
const j = (await res.json().catch(() => ({}))) as { error?: string }; const j = (await res.json().catch(() => ({}))) as { error?: string };
actionError = j.error ?? `Fehler ${res.status}`; actionError = j.error ?? `Error ${res.status}`;
return; return;
} }
await refresh(); await refresh();
} catch (e) { } catch (e) {
actionError = e instanceof Error ? e.message : 'Netzwerkfehler'; actionError = e instanceof Error ? e.message : 'Network error';
} finally { } finally {
actionInFlight = false; actionInFlight = false;
} }
@@ -139,7 +139,7 @@
try { try {
parsedForm = FeedbackFormDefinitionSchema.parse(JSON.parse(trimmed)); parsedForm = FeedbackFormDefinitionSchema.parse(JSON.parse(trimmed));
} catch (err) { } 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; actionInFlight = false;
return; return;
} }
@@ -150,7 +150,7 @@
try { try {
parsedForm = FeedbackFormDefinitionSchema.parse(parsedForm); parsedForm = FeedbackFormDefinitionSchema.parse(parsedForm);
} catch (err) { } 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; actionInFlight = false;
return; return;
} }
@@ -171,7 +171,7 @@
}); });
if (!res.ok) { if (!res.ok) {
const j = (await res.json().catch(() => ({}))) as { error?: string; details?: unknown }; 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); if (j.details) actionError += ' — ' + JSON.stringify(j.details);
return; return;
} }
@@ -180,26 +180,26 @@
syncJsonFromVisual(); syncJsonFromVisual();
activeTab = inst.form_definition ? 'results' : (inst.chat_enabled ? 'chat' : 'submissions'); activeTab = inst.form_definition ? 'results' : (inst.chat_enabled ? 'chat' : 'submissions');
} catch (e) { } catch (e) {
actionError = e instanceof Error ? e.message : 'Netzwerkfehler'; actionError = e instanceof Error ? e.message : 'Network error';
} finally { } finally {
actionInFlight = false; actionInFlight = false;
} }
} }
async function destroy(): Promise<void> { 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; if (!ok) return;
actionInFlight = true; actionInFlight = true;
try { try {
const res = await fetch(`/api/admin/feedback/${inst.id}`, { method: 'DELETE' }); const res = await fetch(`/api/admin/feedback/${inst.id}`, { method: 'DELETE' });
if (!res.ok) { if (!res.ok) {
const j = (await res.json().catch(() => ({}))) as { error?: string }; const j = (await res.json().catch(() => ({}))) as { error?: string };
actionError = j.error ?? `Fehler ${res.status}`; actionError = j.error ?? `Error ${res.status}`;
return; return;
} }
await goto('/admin/feedback'); await goto('/admin/feedback');
} catch (e) { } catch (e) {
actionError = e instanceof Error ? e.message : 'Netzwerkfehler'; actionError = e instanceof Error ? e.message : 'Network error';
} finally { } finally {
actionInFlight = false; actionInFlight = false;
} }
@@ -219,7 +219,7 @@
function summarizeAnswer(v: unknown): string { function summarizeAnswer(v: unknown): string {
if (v === null || v === undefined) return '—'; 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(', '); if (Array.isArray(v)) return v.join(', ');
return String(v); return String(v);
} }
@@ -250,37 +250,37 @@
</script> </script>
<svelte:head> <svelte:head>
<title>{inst.title} — Feedback Admin</title> <title>{inst.title} — Feedback admin</title>
<meta name="robots" content="noindex,nofollow" /> <meta name="robots" content="noindex,nofollow" />
</svelte:head> </svelte:head>
<div class="fb-shell"> <div class="fb-shell">
<header class="fb-header"> <header class="fb-header">
<div style="display: flex; gap: 0.5rem; align-items: center; flex-wrap: wrap;"> <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> </div>
<h1 style="margin-top: 0.5rem;">{inst.title}</h1> <h1 style="margin-top: 0.5rem;">{inst.title}</h1>
{#if inst.description}<p>{inst.description}</p>{/if} {#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;"> <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} {inst.status}
</span> </span>
<span style="font-size: 0.85rem; color: var(--fb-muted);">/f/{inst.slug}</span> <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}>Link kopieren</button> <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">Vorschau</a> <a class="fb-btn fb-btn--ghost" href="/f/{inst.slug}" target="_blank" rel="noopener">Preview</a>
{#if inst.status === 'open'} {#if inst.status === 'open'}
<button type="button" class="fb-btn fb-btn--ghost" disabled={actionInFlight} onclick={() => setStatus('closed')}> <button type="button" class="fb-btn fb-btn--ghost" disabled={actionInFlight} onclick={() => setStatus('closed')}>
Schließen Close
</button> </button>
{:else} {:else}
<button type="button" class="fb-btn fb-btn--ghost" disabled={actionInFlight} onclick={() => setStatus('open')}> <button type="button" class="fb-btn fb-btn--ghost" disabled={actionInFlight} onclick={() => setStatus('open')}>
Wieder öffnen Reopen
</button> </button>
{/if} {/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={exportCsv}>Export CSV</button>
<button type="button" class="fb-btn fb-btn--ghost" onclick={exportJson}>JSON-Export</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}>Löschen</button> <button type="button" class="fb-btn fb-btn--danger" disabled={actionInFlight} onclick={destroy}>Delete</button>
</div> </div>
</header> </header>
@@ -288,18 +288,19 @@
<div class="fb-banner fb-banner--error">{actionError}</div> <div class="fb-banner fb-banner--error">{actionError}</div>
{/if} {/if}
<div style="display: flex; gap: 0.25rem; border-bottom: 1px solid var(--fb-border); margin-bottom: 1rem;"> <div class="fb-tabs">
{#each [ {#each [
{ key: 'chat', label: `Chat (${posts.length})`, show: inst.chat_enabled }, { key: 'chat', label: `Chat (${posts.length})`, show: inst.chat_enabled },
{ key: 'results', label: `Ergebnisse${results ? ` (${results.total_submissions})` : ''}`, show: !!inst.form_definition }, { key: 'results', label: `Results${results ? ` (${results.total_submissions})` : ''}`, show: !!inst.form_definition },
{ key: 'submissions', label: `Submissions (${submissions.length})`, show: !!inst.form_definition }, { key: 'submissions', label: `Responses (${submissions.length})`, show: !!inst.form_definition },
{ key: 'edit', label: 'Bearbeiten', show: true }, { key: 'edit', label: 'Edit', show: true },
] as t (t.key)} ] as t (t.key)}
{#if t.show} {#if t.show}
<button <button
type="button" type="button"
class="fb-tab"
class:fb-tab--active={activeTab === t.key}
onclick={() => (activeTab = t.key as typeof activeTab)} 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} {t.label}
</button> </button>
@@ -309,23 +310,23 @@
{#if activeTab === 'chat'} {#if activeTab === 'chat'}
<section class="fb-section"> <section class="fb-section">
<h2>Live-Chat</h2> <h2>Live chat</h2>
{#if posts.length === 0} {#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} {:else}
<div style="display: flex; flex-direction: column; gap: 0.5rem;"> <div style="display: flex; flex-direction: column; gap: 0.5rem;">
{#each posts as p (p.id)} {#each posts as p (p.id)}
<div class="fb-chat__post {p.hidden ? 'fb-chat__post--hidden' : ''}"> <div class="fb-chat__post {p.hidden ? 'fb-chat__post--hidden' : ''}">
<div class="fb-chat__meta"> <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>{fmtDateTime(p.created_at)}</span>
<span style="font-size: 0.75rem;">session: {p.client_session_id.slice(0, 8)}</span> <span style="font-size: 0.75rem;">session: {p.client_session_id.slice(0, 8)}</span>
<button <button
type="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)} onclick={() => toggleHide(p.id, p.hidden)}
> >
{p.hidden ? 'Wieder anzeigen' : 'Verstecken'} {p.hidden ? 'Show' : 'Hide'}
</button> </button>
</div> </div>
<div class="fb-chat__body" style="text-decoration: {p.hidden ? 'line-through' : 'none'};"> <div class="fb-chat__body" style="text-decoration: {p.hidden ? 'line-through' : 'none'};">
@@ -338,24 +339,24 @@
</section> </section>
{:else if activeTab === 'results'} {:else if activeTab === 'results'}
<section class="fb-section"> <section class="fb-section">
<h2>Ergebnisse{formVersion ? ` · v${formVersion}` : ''}</h2> <h2>Results{formVersion ? ` · v${formVersion}` : ''}</h2>
{#if results} {#if results}
<Results {results} /> <Results {results} />
{:else} {:else}
<p style="color: var(--fb-muted);">Keine Form-Definition.</p> <p style="color: var(--color-text-muted);">No questions configured.</p>
{/if} {/if}
</section> </section>
{:else if activeTab === 'submissions'} {:else if activeTab === 'submissions'}
<section class="fb-section"> <section class="fb-section">
<h2>Form Submissions</h2> <h2>Responses</h2>
{#if submissions.length === 0} {#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} {:else}
<div style="overflow-x: auto;"> <div style="overflow-x: auto;">
<table style="width: 100%; border-collapse: collapse; font-size: 0.875rem;"> <table style="width: 100%; border-collapse: collapse; font-size: 0.875rem;">
<thead> <thead>
<tr style="border-bottom: 1px solid var(--fb-border);"> <tr style="border-bottom: 1px solid var(--color-border-primary);">
<th style="text-align: left; padding: 0.5rem 0.4rem;">Wann</th> <th style="text-align: left; padding: 0.5rem 0.4rem;">When</th>
<th style="text-align: left; padding: 0.5rem 0.4rem;">Name</th> <th style="text-align: left; padding: 0.5rem 0.4rem;">Name</th>
{#each questions as q (q.id)} {#each questions as q (q.id)}
<th style="text-align: left; padding: 0.5rem 0.4rem;">{q.label}</th> <th style="text-align: left; padding: 0.5rem 0.4rem;">{q.label}</th>
@@ -364,9 +365,9 @@
</thead> </thead>
<tbody> <tbody>
{#each submissions as s (s.id)} {#each submissions as s (s.id)}
<tr style="border-bottom: 1px solid var(--fb-border);"> <tr style="border-bottom: 1px solid var(--color-border-primary);">
<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; white-space: nowrap; color: var(--color-text-muted);">{fmtDateTime(s.created_at)}</td>
<td style="padding: 0.5rem 0.4rem;">{s.display_name ?? 'anonym'}</td> <td style="padding: 0.5rem 0.4rem;">{s.display_name ?? 'anonymous'}</td>
{#each questions as q (q.id)} {#each questions as q (q.id)}
<td style="padding: 0.5rem 0.4rem; max-width: 320px; word-break: break-word;"> <td style="padding: 0.5rem 0.4rem; max-width: 320px; word-break: break-word;">
{answerCellFor(q.id, s)} {answerCellFor(q.id, s)}
@@ -381,47 +382,47 @@
</section> </section>
{:else if activeTab === 'edit'} {:else if activeTab === 'edit'}
<section class="fb-section"> <section class="fb-section">
<h2>Bearbeiten{formVersion ? ` · v${formVersion}` : ''}</h2> <h2>Edit{formVersion ? ` · v${formVersion}` : ''}</h2>
{#if submissions.length > 0} {#if submissions.length > 0}
<div class="fb-banner"> <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> </div>
{/if} {/if}
<div class="fb-question"> <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} /> <input id="fb-edit-title" class="fb-input" maxlength="200" bind:value={editTitle} />
</div> </div>
<div class="fb-question"> <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> <textarea id="fb-edit-desc" class="fb-textarea" maxlength="2000" rows="2" bind:value={editDescription}></textarea>
</div> </div>
<div class="fb-question"> <div class="fb-question">
<label class="fb-option-row" style="display:inline-flex;"> <label class="fb-option-row" style="display:inline-flex;">
<input type="checkbox" bind:checked={editChatEnabled} /> <input type="checkbox" bind:checked={editChatEnabled} />
<span>Live-Chat aktiv</span> <span>Enable live chat</span>
</label> </label>
</div> </div>
<div class="fb-question"> <div class="fb-question">
<label class="fb-option-row" style="display:inline-flex;"> <label class="fb-option-row" style="display:inline-flex;">
<input type="checkbox" bind:checked={editLiveResults} /> <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> </label>
</div> </div>
<div class="fb-question"> <div class="fb-question">
<div style="display: flex; align-items: center; gap: 0.5rem; margin-bottom: 0.5rem; flex-wrap: wrap;"> <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;"> <div style="display: inline-flex; gap: 0.25rem; margin-left: auto;">
<button <button
type="button" type="button"
class="fb-btn fb-btn--ghost" 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')} onclick={() => switchEditMode('visual')}
>Visuell</button> >Visual</button>
<button <button
type="button" type="button"
class="fb-btn fb-btn--ghost" 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')} onclick={() => switchEditMode('json')}
>JSON</button> >JSON</button>
</div> </div>
@@ -431,8 +432,8 @@
{#if editForm} {#if editForm}
<FormBuilder bind:value={editForm as FeedbackFormDefinition} /> <FormBuilder bind:value={editForm as FeedbackFormDefinition} />
{:else} {:else}
<p style="color: var(--fb-muted);">Kein Formular angelegt.</p> <p style="color: var(--color-text-muted);">No questions configured.</p>
<button type="button" class="fb-btn fb-btn--ghost" onclick={ensureBuilderForm}>+ Formular anlegen</button> <button type="button" class="fb-btn fb-btn--ghost" onclick={ensureBuilderForm}>+ Add questions</button>
{/if} {/if}
{:else} {:else}
<textarea <textarea
@@ -446,7 +447,7 @@
</div> </div>
<button type="button" class="fb-btn" disabled={actionInFlight} onclick={saveEdits}> <button type="button" class="fb-btn" disabled={actionInFlight} onclick={saveEdits}>
{actionInFlight ? 'Speichere …' : 'Speichern'} {actionInFlight ? 'Saving…' : 'Save'}
</button> </button>
</section> </section>
{/if} {/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) { if (!res.ok) {
const j = (await res.json().catch(() => ({}))) as { error?: string }; const j = (await res.json().catch(() => ({}))) as { error?: string };
error = j.error ?? `Fehler ${res.status}`; error = j.error ?? `Error ${res.status}`;
return; return;
} }
await goto(data.next); await goto(data.next);
} catch (e) { } catch (e) {
error = e instanceof Error ? e.message : 'Netzwerkfehler'; error = e instanceof Error ? e.message : 'Network error';
} finally { } finally {
inFlight = false; inFlight = false;
} }
@@ -34,20 +34,20 @@
</script> </script>
<svelte:head> <svelte:head>
<title>Login — fdbck</title> <title>Sign in — fdbck</title>
<meta name="robots" content="noindex,nofollow" /> <meta name="robots" content="noindex,nofollow" />
</svelte:head> </svelte:head>
<div class="fb-shell"> <div class="fb-shell">
<header class="fb-header"> <header class="fb-header">
<h1>Admin-Login</h1> <h1>Sign in</h1>
<p>Nur für m.</p> <p>Admin access only.</p>
</header> </header>
<section class="fb-section"> <section class="fb-section">
<form onsubmit={signIn}> <form onsubmit={signIn}>
<div class="fb-question"> <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 <input
id="login-email" id="login-email"
class="fb-input" class="fb-input"
@@ -58,7 +58,7 @@
/> />
</div> </div>
<div class="fb-question"> <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 <input
id="login-password" id="login-password"
class="fb-input" class="fb-input"
@@ -74,7 +74,7 @@
{/if} {/if}
<button type="submit" class="fb-btn" disabled={inFlight}> <button type="submit" class="fb-btn" disabled={inFlight}>
{inFlight ? 'Logging in …' : 'Einloggen'} {inFlight ? 'Signing in…' : 'Sign in'}
</button> </button>
</form> </form>
</section> </section>