Block repeat submissions per participant by default. No new fingerprint
column — dedup against the existing client_session_id and (client_ip,
user_agent) we already store on feedback_submissions.
Schema migration `fdbck_feedback_instances_add_single_submission` adds
single_submission BOOLEAN NOT NULL DEFAULT true (applied via Supabase MCP).
Existing instances default to true. Author opts out per-instance via the
new toggle on /admin/feedback/new and the detail Edit tab.
Server (POST /api/public/feedback/<slug>/submit): when
inst.single_submission is true, look up the most recent existing submission
matching instance_id AND (client_session_id = body.client_session_id) OR
(client_ip = req.ip AND user_agent = req.user_agent). Two separate
parameterised queries instead of a single PostgREST `.or()` filter — the
user-controlled session id has no character restriction in the schema, so
splicing it into a filter string would risk PostgREST filter injection.
Returns 409 with { error: 'already_submitted', submitted_at, display_name,
answers } so the client can render the previous answers without an extra
round-trip.
Client (/f/<slug>): on 409, replace the form with a read-only "already
submitted on <date>" card listing the previous answers per question. Reuses
question shape via a small summariseSubmittedAnswer() helper covering all
six question types including date_ranked_choice rating maps. No submit
button on the read-only view; live results polling still kicks off.
Admin schemas (InstanceCreate + InstanceUpdate) accept the new
single_submission boolean. POST/PATCH endpoints persist it.
bun check 0 errors, bun test 25 pass, bun build OK.
694 lines
21 KiB
Svelte
694 lines
21 KiB
Svelte
<script lang="ts">
|
|
import '$lib/styles/feedback.css';
|
|
import { onMount, onDestroy } from 'svelte';
|
|
import { goto, invalidateAll } from '$app/navigation';
|
|
import type { PageData } from './$types';
|
|
import { FeedbackFormDefinitionSchema, type FeedbackFormDefinition } from '$lib/schemas';
|
|
import type { AggregatedResults } from '$lib/server/results';
|
|
import Results from '$lib/components/Results.svelte';
|
|
import FormBuilder from '$lib/components/FormBuilder.svelte';
|
|
import Icon from '$lib/components/Icon.svelte';
|
|
|
|
let { data }: { data: PageData } = $props();
|
|
|
|
let inst = $state(data.instance);
|
|
let submissions = $state(data.submissions);
|
|
let posts = $state(data.posts);
|
|
let results = $state<AggregatedResults | null>(data.results);
|
|
|
|
let activeTab = $state<'chat' | 'submissions' | 'results' | 'edit'>('chat');
|
|
let actionError = $state<string | null>(null);
|
|
let actionInFlight = $state(false);
|
|
|
|
let editTitle = $state(inst.title);
|
|
let editDescription = $state(inst.description ?? '');
|
|
let editChatEnabled = $state(inst.chat_enabled);
|
|
let editLiveResults = $state(inst.live_results_enabled);
|
|
let editSingleSubmission = $state(inst.single_submission ?? true);
|
|
|
|
let shareSlugInput = $state('');
|
|
let shareInFlight = $state(false);
|
|
let shareError = $state<string | null>(null);
|
|
let shareCopied = $state(false);
|
|
|
|
let editMode = $state<'visual' | 'json'>('visual');
|
|
let editForm = $state<FeedbackFormDefinition | null>(
|
|
inst.form_definition ? (inst.form_definition as FeedbackFormDefinition) : null,
|
|
);
|
|
let editFormJson = $state(inst.form_definition ? JSON.stringify(inst.form_definition, null, 2) : '');
|
|
|
|
// Optimistic status pill — same pattern as the list page.
|
|
let optimisticStatus = $state<'open' | 'closed' | null>(null);
|
|
const effectiveStatus = $derived<'open' | 'closed'>(optimisticStatus ?? inst.status);
|
|
|
|
function syncJsonFromVisual(): void {
|
|
editFormJson = editForm ? JSON.stringify(editForm, null, 2) : '';
|
|
}
|
|
|
|
function syncVisualFromJson(): boolean {
|
|
const trimmed = editFormJson.trim();
|
|
if (!trimmed) {
|
|
editForm = null;
|
|
return true;
|
|
}
|
|
try {
|
|
const parsed = JSON.parse(trimmed);
|
|
editForm = FeedbackFormDefinitionSchema.parse(parsed);
|
|
return true;
|
|
} catch (err) {
|
|
actionError = `JSON ungültig: ${err instanceof Error ? err.message : 'parse error'}`;
|
|
return false;
|
|
}
|
|
}
|
|
|
|
function switchEditMode(next: 'visual' | 'json'): void {
|
|
if (next === editMode) return;
|
|
actionError = null;
|
|
if (next === 'json') syncJsonFromVisual();
|
|
else if (!syncVisualFromJson()) return;
|
|
editMode = next;
|
|
}
|
|
|
|
function ensureBuilderForm(): void {
|
|
if (!editForm) editForm = { questions: [{ id: 'q1', label: 'Question 1', type: 'short_text', required: false }] };
|
|
}
|
|
|
|
let pollHandle: ReturnType<typeof setInterval> | null = null;
|
|
|
|
const formDef = $derived(inst.form_definition as FeedbackFormDefinition | null);
|
|
const questions = $derived(formDef?.questions ?? []);
|
|
const formVersion = $derived(formDef?.version ?? null);
|
|
|
|
async function refresh(): Promise<void> {
|
|
try {
|
|
const res = await fetch(`/api/admin/feedback/${inst.id}`, { headers: { Accept: 'application/json' } });
|
|
if (!res.ok) return;
|
|
const j = (await res.json()) as {
|
|
instance: typeof inst;
|
|
submissions: typeof submissions;
|
|
posts: typeof posts;
|
|
results: AggregatedResults | null;
|
|
};
|
|
inst = j.instance;
|
|
submissions = j.submissions;
|
|
posts = j.posts;
|
|
results = j.results;
|
|
} catch {
|
|
// transient
|
|
}
|
|
}
|
|
|
|
async function toggleHide(postId: string, currentlyHidden: boolean): Promise<void> {
|
|
actionError = null;
|
|
try {
|
|
const res = await fetch(`/api/admin/feedback/${inst.id}/posts/${postId}/hide`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ hidden: !currentlyHidden }),
|
|
});
|
|
if (!res.ok) {
|
|
const j = (await res.json().catch(() => ({}))) as { error?: string };
|
|
actionError = j.error ?? `Error ${res.status}`;
|
|
return;
|
|
}
|
|
await refresh();
|
|
} catch (e) {
|
|
actionError = e instanceof Error ? e.message : 'Network error';
|
|
}
|
|
}
|
|
|
|
async function toggleStatus(): Promise<void> {
|
|
if (actionInFlight) return;
|
|
const current = effectiveStatus;
|
|
const next: 'open' | 'closed' = current === 'open' ? 'closed' : 'open';
|
|
optimisticStatus = next;
|
|
actionInFlight = true;
|
|
actionError = null;
|
|
try {
|
|
const res = await fetch(`/api/admin/feedback/${inst.id}`, {
|
|
method: 'PATCH',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ status: next }),
|
|
});
|
|
if (!res.ok) {
|
|
const j = (await res.json().catch(() => ({}))) as { error?: string };
|
|
actionError = j.error ?? `Error ${res.status}`;
|
|
optimisticStatus = null;
|
|
return;
|
|
}
|
|
await refresh();
|
|
optimisticStatus = null;
|
|
} catch (e) {
|
|
actionError = e instanceof Error ? e.message : 'Network error';
|
|
optimisticStatus = null;
|
|
} finally {
|
|
actionInFlight = false;
|
|
}
|
|
}
|
|
|
|
async function saveEdits(): Promise<void> {
|
|
actionError = null;
|
|
actionInFlight = true;
|
|
|
|
let parsedForm: FeedbackFormDefinition | null = null;
|
|
if (editMode === 'json') {
|
|
const trimmed = editFormJson.trim();
|
|
if (trimmed) {
|
|
try {
|
|
parsedForm = FeedbackFormDefinitionSchema.parse(JSON.parse(trimmed));
|
|
} catch (err) {
|
|
actionError = `Invalid JSON: ${err instanceof Error ? err.message : 'parse error'}`;
|
|
actionInFlight = false;
|
|
return;
|
|
}
|
|
}
|
|
} else {
|
|
parsedForm = editForm;
|
|
if (parsedForm) {
|
|
try {
|
|
parsedForm = FeedbackFormDefinitionSchema.parse(parsedForm);
|
|
} catch (err) {
|
|
actionError = `Invalid questions: ${err instanceof Error ? err.message : 'parse error'}`;
|
|
actionInFlight = false;
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
|
|
try {
|
|
const res = await fetch(`/api/admin/feedback/${inst.id}`, {
|
|
method: 'PATCH',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
title: editTitle,
|
|
description: editDescription || null,
|
|
chat_enabled: editChatEnabled,
|
|
live_results_enabled: editLiveResults,
|
|
single_submission: editSingleSubmission,
|
|
form_definition: parsedForm,
|
|
}),
|
|
});
|
|
if (!res.ok) {
|
|
const j = (await res.json().catch(() => ({}))) as { error?: string; details?: unknown };
|
|
actionError = j.error ?? `Error ${res.status}`;
|
|
if (j.details) actionError += ' — ' + JSON.stringify(j.details);
|
|
return;
|
|
}
|
|
await refresh();
|
|
editForm = inst.form_definition ? (inst.form_definition as FeedbackFormDefinition) : null;
|
|
syncJsonFromVisual();
|
|
activeTab = inst.form_definition ? 'results' : (inst.chat_enabled ? 'chat' : 'submissions');
|
|
} catch (e) {
|
|
actionError = e instanceof Error ? e.message : 'Network error';
|
|
} finally {
|
|
actionInFlight = false;
|
|
}
|
|
}
|
|
|
|
async function destroy(): Promise<void> {
|
|
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 ?? `Error ${res.status}`;
|
|
return;
|
|
}
|
|
await goto('/admin/feedback');
|
|
} catch (e) {
|
|
actionError = e instanceof Error ? e.message : 'Network error';
|
|
} finally {
|
|
actionInFlight = false;
|
|
}
|
|
}
|
|
|
|
async function copyLink(): Promise<void> {
|
|
try {
|
|
await navigator.clipboard.writeText(`${location.origin}/f/${inst.slug}`);
|
|
} catch {
|
|
// silent
|
|
}
|
|
}
|
|
|
|
async function copyShortUrl(): Promise<void> {
|
|
if (!inst.short_url) return;
|
|
try {
|
|
await navigator.clipboard.writeText(inst.short_url);
|
|
shareCopied = true;
|
|
setTimeout(() => (shareCopied = false), 1500);
|
|
} catch {
|
|
// silent
|
|
}
|
|
}
|
|
|
|
async function copyShareStripUrl(): Promise<void> {
|
|
if (inst.short_url) {
|
|
await copyShortUrl();
|
|
} else {
|
|
await copyLink();
|
|
shareCopied = true;
|
|
setTimeout(() => (shareCopied = false), 1500);
|
|
}
|
|
}
|
|
|
|
async function createShareLink(): Promise<void> {
|
|
shareError = null;
|
|
shareInFlight = true;
|
|
try {
|
|
const slug = shareSlugInput.trim();
|
|
const body: { customSlug?: string } = {};
|
|
if (slug) body.customSlug = slug;
|
|
|
|
const res = await fetch(`/api/admin/feedback/${inst.id}/share`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(body),
|
|
});
|
|
if (!res.ok) {
|
|
const j = (await res.json().catch(() => ({}))) as { error?: string; details?: unknown };
|
|
shareError = j.error ?? `Error ${res.status}`;
|
|
if (j.details) shareError += ' — ' + JSON.stringify(j.details);
|
|
return;
|
|
}
|
|
const j = (await res.json()) as { instance: typeof inst };
|
|
inst = j.instance;
|
|
shareSlugInput = '';
|
|
} catch (e) {
|
|
shareError = e instanceof Error ? e.message : 'Network error';
|
|
} finally {
|
|
shareInFlight = false;
|
|
}
|
|
}
|
|
|
|
async function exportCsv(): Promise<void> {
|
|
window.location.href = `/api/admin/feedback/${inst.id}/export?format=csv`;
|
|
}
|
|
|
|
async function exportJson(): Promise<void> {
|
|
window.location.href = `/api/admin/feedback/${inst.id}/export?format=json`;
|
|
}
|
|
|
|
function fmtDateTime(iso: string): string {
|
|
return new Date(iso).toLocaleString();
|
|
}
|
|
|
|
function summarizeAnswer(v: unknown): string {
|
|
if (v === null || v === undefined) return '—';
|
|
if (typeof v === 'boolean') return v ? 'Yes' : 'No';
|
|
if (Array.isArray(v)) return v.join(', ');
|
|
if (typeof v === 'object') {
|
|
// date_ranked_choice answers are { optId: 1..5 | null } — terse summary for the table cell.
|
|
const ratings = Object.values(v as Record<string, unknown>).filter(
|
|
(x): x is number => typeof x === 'number' && Number.isFinite(x),
|
|
);
|
|
if (ratings.length === 0) return '—';
|
|
const avg = ratings.reduce((a, b) => a + b, 0) / ratings.length;
|
|
const fmt = avg.toFixed(1).replace(/\.0$/, '');
|
|
return `${fmt} avg (${ratings.length} rated)`;
|
|
}
|
|
return String(v);
|
|
}
|
|
|
|
function answerCellFor(qid: string, sub: { answers: Record<string, unknown> }): string {
|
|
return summarizeAnswer(sub.answers?.[qid]);
|
|
}
|
|
|
|
// Click-outside-to-close + Escape for any open <details class="fb-menu">.
|
|
function onDocClick(e: MouseEvent): void {
|
|
const target = e.target as Node | null;
|
|
if (!target) return;
|
|
document.querySelectorAll<HTMLDetailsElement>('details.fb-menu[open]').forEach((d) => {
|
|
if (!d.contains(target)) d.open = false;
|
|
});
|
|
}
|
|
function onDocKey(e: KeyboardEvent): void {
|
|
if (e.key !== 'Escape') return;
|
|
document.querySelectorAll<HTMLDetailsElement>('details.fb-menu[open]').forEach((d) => {
|
|
d.open = false;
|
|
});
|
|
}
|
|
|
|
onMount(() => {
|
|
pollHandle = setInterval(refresh, 5000);
|
|
document.addEventListener('click', onDocClick);
|
|
document.addEventListener('keydown', onDocKey);
|
|
return () => {
|
|
if (pollHandle) clearInterval(pollHandle);
|
|
};
|
|
});
|
|
|
|
onDestroy(() => {
|
|
if (pollHandle) clearInterval(pollHandle);
|
|
if (typeof document !== 'undefined') {
|
|
document.removeEventListener('click', onDocClick);
|
|
document.removeEventListener('keydown', onDocKey);
|
|
}
|
|
void invalidateAll;
|
|
});
|
|
</script>
|
|
|
|
<svelte:head>
|
|
<title>{inst.title} — Feedback admin</title>
|
|
<meta name="robots" content="noindex,nofollow" />
|
|
</svelte:head>
|
|
|
|
<div class="fb-shell">
|
|
<a href="/admin/feedback" class="fb-back-link">
|
|
<Icon name="arrow-left" /> Forms
|
|
</a>
|
|
|
|
<header class="fb-header">
|
|
<div class="fb-detail-head">
|
|
<div class="fb-detail-head__title">
|
|
<h1>{inst.title}</h1>
|
|
{#if inst.description}<p>{inst.description}</p>{/if}
|
|
</div>
|
|
<div class="fb-detail-head__actions">
|
|
<button
|
|
type="button"
|
|
class="fb-status-pill fb-status-pill--{effectiveStatus}"
|
|
disabled={actionInFlight}
|
|
onclick={toggleStatus}
|
|
aria-label={effectiveStatus === 'open' ? 'Open — click to close' : 'Closed — click to reopen'}
|
|
title={effectiveStatus === 'open' ? 'Click to close' : 'Click to reopen'}
|
|
>
|
|
<span class="fb-status-pill__dot"></span>{effectiveStatus}
|
|
</button>
|
|
<details class="fb-menu">
|
|
<!-- svelte-ignore a11y_no_redundant_roles -->
|
|
<summary class="fb-menu__btn" role="button" aria-label="More actions">⋯</summary>
|
|
<div class="fb-menu__panel" role="menu">
|
|
<button type="button" class="fb-menu__item" onclick={copyLink}>
|
|
<Icon name="copy" /> Copy /f/{inst.slug}
|
|
</button>
|
|
<button type="button" class="fb-menu__item" onclick={exportCsv}>
|
|
<Icon name="download" /> Export CSV
|
|
</button>
|
|
<button type="button" class="fb-menu__item" onclick={exportJson}>
|
|
<Icon name="download" /> Export JSON
|
|
</button>
|
|
<hr class="fb-menu__divider" />
|
|
<button
|
|
type="button"
|
|
class="fb-menu__item fb-menu__item--danger"
|
|
disabled={actionInFlight}
|
|
onclick={destroy}
|
|
>
|
|
<Icon name="trash" /> Delete
|
|
</button>
|
|
</div>
|
|
</details>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="fb-share-strip">
|
|
{#if inst.short_url}
|
|
<a
|
|
class="fb-share-strip__url"
|
|
href={inst.short_url}
|
|
target="_blank"
|
|
rel="noopener"
|
|
>{inst.short_url}</a>
|
|
{:else}
|
|
<a
|
|
class="fb-share-strip__url"
|
|
href={`/f/${inst.slug}`}
|
|
target="_blank"
|
|
rel="noopener"
|
|
>/f/{inst.slug}</a>
|
|
{/if}
|
|
<button type="button" class="fb-btn fb-btn--ghost fb-btn--sm" onclick={copyShareStripUrl}>
|
|
<Icon name="copy" /> {shareCopied ? 'Copied' : 'Copy'}
|
|
</button>
|
|
<a
|
|
class="fb-btn fb-btn--ghost fb-btn--sm"
|
|
href={inst.short_url ?? `/f/${inst.slug}`}
|
|
target="_blank"
|
|
rel="noopener"
|
|
>
|
|
<Icon name="external-link" /> Open
|
|
</a>
|
|
</div>
|
|
|
|
<details class="fb-share-strip__replace">
|
|
<summary>{inst.short_url ? 'Replace short link' : 'Create memorable short link'}</summary>
|
|
<div>
|
|
<label class="fb-question__label" for="fb-share-slug">Custom slug (optional)</label>
|
|
<div class="fb-save-row" style="margin-top: 0.4rem;">
|
|
<input
|
|
id="fb-share-slug"
|
|
class="fb-input"
|
|
maxlength="64"
|
|
placeholder="e.g. vote, session-feedback"
|
|
bind:value={shareSlugInput}
|
|
style="max-width: 320px; flex: 1;"
|
|
/>
|
|
<button
|
|
type="button"
|
|
class="fb-btn"
|
|
disabled={shareInFlight}
|
|
onclick={createShareLink}
|
|
>
|
|
{shareInFlight ? 'Creating…' : (inst.short_url ? 'Replace' : 'Create')}
|
|
</button>
|
|
</div>
|
|
<p class="fb-question__help" style="margin-top: 0.4rem;">
|
|
Leave blank for a random short code, or pick a memorable slug like
|
|
<code>vote</code> or <code>session-feedback</code>.
|
|
</p>
|
|
{#if shareError}
|
|
<div class="fb-inline-error">{shareError}</div>
|
|
{/if}
|
|
</div>
|
|
</details>
|
|
</header>
|
|
|
|
{#if actionError}
|
|
<div class="fb-banner fb-banner--error">{actionError}</div>
|
|
{/if}
|
|
|
|
<div class="fb-tabs">
|
|
{#each [
|
|
{ key: 'chat', label: `Chat (${posts.length})`, show: inst.chat_enabled },
|
|
{ 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)}
|
|
>
|
|
{t.label}
|
|
</button>
|
|
{/if}
|
|
{/each}
|
|
</div>
|
|
|
|
{#if activeTab === 'chat'}
|
|
<section class="fb-section fb-tab-body">
|
|
{#if posts.length === 0}
|
|
<p class="fb-empty">No messages yet.</p>
|
|
{:else}
|
|
<div class="fb-detail-list">
|
|
{#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 ?? '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"
|
|
class="fb-btn fb-btn--ghost fb-btn--sm"
|
|
style="margin-left: auto;"
|
|
onclick={() => toggleHide(p.id, p.hidden)}
|
|
>
|
|
{#if p.hidden}<Icon name="eye" /> Show{:else}<Icon name="eye-off" /> Hide{/if}
|
|
</button>
|
|
</div>
|
|
<div class="fb-chat__body" style="text-decoration: {p.hidden ? 'line-through' : 'none'};">
|
|
{p.body}
|
|
</div>
|
|
</div>
|
|
{/each}
|
|
</div>
|
|
{/if}
|
|
</section>
|
|
{:else if activeTab === 'results'}
|
|
<section class="fb-section fb-tab-body">
|
|
{#if results}
|
|
<Results {results} />
|
|
{:else}
|
|
<p class="fb-empty">No questions configured.</p>
|
|
{/if}
|
|
</section>
|
|
{:else if activeTab === 'submissions'}
|
|
<section class="fb-section fb-tab-body">
|
|
{#if submissions.length === 0}
|
|
<p class="fb-empty">No responses yet.</p>
|
|
{:else}
|
|
<div class="fb-detail-table-wrap">
|
|
<table class="fb-detail-table">
|
|
<thead>
|
|
<tr>
|
|
<th>When</th>
|
|
<th>Name</th>
|
|
{#each questions as q (q.id)}
|
|
<th>{q.label}</th>
|
|
{/each}
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{#each submissions as s (s.id)}
|
|
<tr>
|
|
<td class="fb-detail-table__date">{fmtDateTime(s.created_at)}</td>
|
|
<td>{s.display_name ?? 'anonymous'}</td>
|
|
{#each questions as q (q.id)}
|
|
<td class="fb-detail-table__cell">{answerCellFor(q.id, s)}</td>
|
|
{/each}
|
|
</tr>
|
|
{/each}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
{/if}
|
|
</section>
|
|
{:else if activeTab === 'edit'}
|
|
<section class="fb-section fb-tab-body">
|
|
{#if submissions.length > 0}
|
|
<p class="fb-question__help" style="margin-bottom: var(--space-4);">
|
|
{submissions.length} responses already received. Saving will automatically
|
|
bump the version — earlier responses keep their original snapshot.
|
|
</p>
|
|
{/if}
|
|
<div class="fb-question">
|
|
<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">Description</label>
|
|
<textarea id="fb-edit-desc" class="fb-textarea" maxlength="2000" rows="2" bind:value={editDescription}></textarea>
|
|
</div>
|
|
|
|
<label class="fb-toggle">
|
|
<span class="fb-toggle__text">
|
|
<span class="fb-toggle__label">Live chat</span>
|
|
<span class="fb-toggle__hint">Let participants post messages in real time.</span>
|
|
</span>
|
|
<input type="checkbox" bind:checked={editChatEnabled} />
|
|
</label>
|
|
|
|
<label class="fb-toggle">
|
|
<span class="fb-toggle__text">
|
|
<span class="fb-toggle__label">Live results</span>
|
|
<span class="fb-toggle__hint">Show participants the live aggregate after they submit.</span>
|
|
</span>
|
|
<input type="checkbox" bind:checked={editLiveResults} />
|
|
</label>
|
|
|
|
<label class="fb-toggle">
|
|
<span class="fb-toggle__text">
|
|
<span class="fb-toggle__label">Limit to one submission per participant</span>
|
|
<span class="fb-toggle__hint">Blocks repeats from the same browser or IP + user-agent.</span>
|
|
</span>
|
|
<input type="checkbox" bind:checked={editSingleSubmission} />
|
|
</label>
|
|
|
|
<div class="fb-question" style="margin-top: var(--space-5);">
|
|
<div class="fb-edit-questions-head">
|
|
<span class="fb-question__label" style="margin: 0;">Questions</span>
|
|
<div class="fb-segment" role="tablist" aria-label="Editor mode">
|
|
<button
|
|
type="button"
|
|
class="fb-segment__btn"
|
|
class:fb-segment__btn--active={editMode === 'visual'}
|
|
onclick={() => switchEditMode('visual')}
|
|
>Visual</button>
|
|
<button
|
|
type="button"
|
|
class="fb-segment__btn"
|
|
class:fb-segment__btn--active={editMode === 'json'}
|
|
onclick={() => switchEditMode('json')}
|
|
>JSON</button>
|
|
</div>
|
|
</div>
|
|
|
|
{#if editMode === 'visual'}
|
|
{#if editForm}
|
|
<FormBuilder bind:value={editForm as FeedbackFormDefinition} />
|
|
{:else}
|
|
<p class="fb-empty">No questions configured.</p>
|
|
<button type="button" class="fb-btn fb-btn--secondary fb-btn--sm" onclick={ensureBuilderForm}>
|
|
<Icon name="plus" /> Add questions
|
|
</button>
|
|
{/if}
|
|
{:else}
|
|
<textarea
|
|
id="fb-edit-form"
|
|
class="fb-textarea"
|
|
rows="14"
|
|
bind:value={editFormJson}
|
|
style="font-family: ui-monospace, 'SF Mono', Menlo, monospace; font-size: 0.85rem;"
|
|
></textarea>
|
|
{/if}
|
|
</div>
|
|
|
|
<div class="fb-save-row">
|
|
<button type="button" class="fb-btn" disabled={actionInFlight} onclick={saveEdits}>
|
|
<Icon name="check" /> {actionInFlight ? 'Saving…' : 'Save'}
|
|
</button>
|
|
{#if formVersion !== null}
|
|
<span class="fb-version-note">Current version: v{formVersion}</span>
|
|
{/if}
|
|
</div>
|
|
</section>
|
|
{/if}
|
|
</div>
|
|
|
|
<style>
|
|
.fb-detail-list {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: var(--space-2);
|
|
}
|
|
.fb-detail-table-wrap {
|
|
overflow-x: auto;
|
|
}
|
|
.fb-detail-table {
|
|
width: 100%;
|
|
border-collapse: collapse;
|
|
font-size: 0.875rem;
|
|
}
|
|
.fb-detail-table thead tr {
|
|
border-bottom: 1px solid var(--color-border-primary);
|
|
}
|
|
.fb-detail-table tbody tr {
|
|
border-bottom: 1px solid var(--color-border-primary);
|
|
}
|
|
.fb-detail-table th,
|
|
.fb-detail-table td {
|
|
text-align: left;
|
|
padding: 0.5rem 0.4rem;
|
|
}
|
|
.fb-detail-table__date {
|
|
white-space: nowrap;
|
|
color: var(--color-text-muted);
|
|
}
|
|
.fb-detail-table__cell {
|
|
max-width: 320px;
|
|
word-break: break-word;
|
|
}
|
|
.fb-edit-questions-head {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
gap: var(--space-2);
|
|
margin-bottom: var(--space-2);
|
|
flex-wrap: wrap;
|
|
}
|
|
</style>
|