Merge mai/dokploy/admin-list-actions: per-row actions + English rewrite + segmented tabs + /admin/feedback/new
This commit is contained in:
@@ -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"
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
7
src/routes/admin/feedback/new/+page.server.ts
Normal file
7
src/routes/admin/feedback/new/+page.server.ts
Normal 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 {};
|
||||
};
|
||||
148
src/routes/admin/feedback/new/+page.svelte
Normal file
148
src/routes/admin/feedback/new/+page.svelte
Normal 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>
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user