ui: minimalist /admin/feedback/[id] detail — header collapse + share strip

The header was the densest surface in the app: 8 controls in a single
flex-wrap row plus a separate Share section bolted between header and tabs.
This commit collapses both into something readable.

Header:

- 8 visible controls → 3 visible. Status pill is now clickable and toggles
  between open/closed (optimistic), replacing the Close/Reopen button. The
  ⋯ menu absorbs Copy /f/<slug>, Export CSV, Export JSON, and a separator
  before Delete (still in red). All gone from the top-row strip: the raw
  /f/slug, Copy link, Preview, Close/Reopen, CSV, JSON, Delete buttons.
- Quiet text-only .fb-back-link replaces the chip-style "← All forms"
  button.
- New .fb-detail-head primitive lays out title-block on the left + actions
  on the right with proper flex-wrap behaviour.

Share:

- Standalone <section data-fb-share> deleted. Its job moves to a new
  inline .fb-share-strip directly under the title in the header.
- Strip always shows a usable URL: short_url if it exists, else the raw
  /f/<slug>. Copy + Open ↗ buttons sit alongside.
- Below the strip, a compact <details class="fb-share-strip__replace">
  holds the slug input + Create/Replace button. Summary text adapts to
  whether a short link already exists.

Tab body:

- Drop the inner <h2> in every tab body (the active tab pill names the
  section). All four tab bodies now use .fb-tab-body for consistent top
  padding (var(--space-6)).
- "X responses already received…" warning becomes a muted .fb-question__help
  line, not a .fb-banner box.
- Visual / JSON toggle becomes a real .fb-segment control matching the
  shape of .fb-tabs (consistency).
- Save row uses .fb-save-row with the version pill ("Current version: vN")
  rendered as a quiet .fb-version-note next to the Save button instead of
  decorating the H2 like before.
- Submissions table extracted to a small <style> block (.fb-detail-table)
  instead of inline style="..." chunks.

Click-outside-to-close + Escape close any open ⋯ menu, mirroring the list
page. Polling, refresh, and all backend contracts unchanged.

Delete still uses confirm() per m's override — deletion remains a deliberate
two-step action, no undo toast.
This commit is contained in:
mAi
2026-05-06 12:31:00 +02:00
parent 80f2f82ac1
commit 94f6ba934d

View File

@@ -36,6 +36,10 @@
);
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) : '';
}
@@ -112,9 +116,13 @@
}
}
async function setStatus(next: 'open' | 'closed'): Promise<void> {
actionError = null;
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',
@@ -124,11 +132,14 @@
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;
}
@@ -230,6 +241,16 @@
}
}
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;
@@ -259,6 +280,14 @@
}
}
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();
}
@@ -274,16 +303,25 @@
return summarizeAnswer(sub.answers?.[qid]);
}
async function exportCsv(): Promise<void> {
window.location.href = `/api/admin/feedback/${inst.id}/export?format=csv`;
// 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;
});
}
async function exportJson(): Promise<void> {
window.location.href = `/api/admin/feedback/${inst.id}/export?format=json`;
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);
};
@@ -291,6 +329,10 @@
onDestroy(() => {
if (pollHandle) clearInterval(pollHandle);
if (typeof document !== 'undefined') {
document.removeEventListener('click', onDocClick);
document.removeEventListener('keydown', onDocKey);
}
void invalidateAll;
});
</script>
@@ -301,104 +343,95 @@
</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 style="display: flex; gap: 0.5rem; align-items: center; flex-wrap: wrap;">
<a href="/admin/feedback" class="fb-btn fb-btn--ghost fb-btn--sm"><Icon name="arrow-left" /> 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.2rem 0.6rem; border-radius: 999px; font-weight: 500; {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(--color-text-muted);">/f/{inst.slug}</span>
<button type="button" class="fb-btn fb-btn--ghost fb-btn--sm" onclick={copyLink}><Icon name="copy" /> Copy link</button>
<a class="fb-btn fb-btn--ghost fb-btn--sm" href="/f/{inst.slug}" target="_blank" rel="noopener"><Icon name="external-link" /> Preview</a>
{#if inst.status === 'open'}
<button type="button" class="fb-btn fb-btn--secondary fb-btn--sm" disabled={actionInFlight} onclick={() => setStatus('closed')}>
<Icon name="lock" /> Close
<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>
{:else}
<button type="button" class="fb-btn fb-btn--secondary fb-btn--sm" disabled={actionInFlight} onclick={() => setStatus('open')}>
<Icon name="unlock" /> Reopen
</button>
{/if}
<button type="button" class="fb-btn fb-btn--ghost fb-btn--sm" onclick={exportCsv}><Icon name="download" /> CSV</button>
<button type="button" class="fb-btn fb-btn--ghost fb-btn--sm" onclick={exportJson}><Icon name="download" /> JSON</button>
<button type="button" class="fb-btn fb-btn--danger fb-btn--sm" disabled={actionInFlight} onclick={destroy}><Icon name="trash" /> Delete</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>
</header>
{#if actionError}
<div class="fb-banner fb-banner--error">{actionError}</div>
{/if}
<section class="fb-section" data-fb-share>
<h2>Share</h2>
{#if inst.short_url}
<p style="margin: 0 0 0.5rem 0; color: var(--color-text-muted); font-size: 0.85rem;">
Memorable short link — resolves to <code>/f/{inst.slug}</code>.
</p>
<div style="display: flex; gap: 0.5rem; flex-wrap: wrap; align-items: center;">
<div class="fb-share-strip">
{#if inst.short_url}
<a
class="fb-share-strip__url"
href={inst.short_url}
target="_blank"
rel="noopener"
style="font-family: ui-monospace, 'SF Mono', Menlo, monospace; font-size: 0.95rem; word-break: break-all;"
>
{inst.short_url}
</a>
<button type="button" class="fb-btn fb-btn--ghost" onclick={copyShortUrl}>
{shareCopied ? 'Copied' : 'Copy'}
</button>
<a class="fb-btn fb-btn--ghost" href={inst.short_url} target="_blank" rel="noopener">
Open ↗
</a>
</div>
<details style="margin-top: 0.75rem;">
<summary style="cursor: pointer; color: var(--color-text-muted); font-size: 0.85rem;">
Replace with a different short link
</summary>
<div style="margin-top: 0.5rem;">
<label class="fb-question__label" for="fb-share-slug-replace">Custom slug (optional)</label>
<div style="display: flex; gap: 0.5rem; flex-wrap: wrap; align-items: center;">
<input
id="fb-share-slug-replace"
class="fb-input"
maxlength="64"
placeholder="e.g. vote, session-feedback"
bind:value={shareSlugInput}
style="max-width: 320px;"
/>
<button
type="button"
class="fb-btn"
disabled={shareInFlight}
onclick={createShareLink}
>
{shareInFlight ? 'Creating…' : 'Create new'}
</button>
</div>
<p style="margin: 0.4rem 0 0 0; color: var(--color-text-muted); font-size: 0.8rem;">
Leave blank for a random short code, or pick a memorable slug like <code>vote</code> or <code>session-feedback</code>.
</p>
</div>
</details>
{:else}
<p style="margin: 0 0 0.5rem 0; color: var(--color-text-muted); font-size: 0.85rem;">
Create a memorable short link (e.g. <code>https://msbls.de/vote</code>) that redirects to this form.
</p>
<div style="margin-top: 0.5rem;">
>{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 style="display: flex; gap: 0.5rem; flex-wrap: wrap; align-items: center;">
<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;"
style="max-width: 320px; flex: 1;"
/>
<button
type="button"
@@ -406,18 +439,23 @@
disabled={shareInFlight}
onclick={createShareLink}
>
{shareInFlight ? 'Creating…' : 'Create short link'}
{shareInFlight ? 'Creating…' : (inst.short_url ? 'Replace' : 'Create')}
</button>
</div>
<p style="margin: 0.4rem 0 0 0; color: var(--color-text-muted); font-size: 0.8rem;">
Leave blank for a random short code, or pick a memorable slug like <code>vote</code> or <code>session-feedback</code>.
<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>
{/if}
{#if shareError}
<div class="fb-banner fb-banner--error" style="margin-top: 0.5rem;">{shareError}</div>
{/if}
</section>
</details>
</header>
{#if actionError}
<div class="fb-banner fb-banner--error">{actionError}</div>
{/if}
<div class="fb-tabs">
{#each [
@@ -440,12 +478,11 @@
</div>
{#if activeTab === 'chat'}
<section class="fb-section">
<h2>Live chat</h2>
<section class="fb-section fb-tab-body">
{#if posts.length === 0}
<p style="color: var(--color-text-muted);">No messages yet.</p>
<p class="fb-empty">No messages yet.</p>
{:else}
<div style="display: flex; flex-direction: column; gap: 0.5rem;">
<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">
@@ -470,40 +507,36 @@
{/if}
</section>
{:else if activeTab === 'results'}
<section class="fb-section">
<h2>Results{formVersion ? ` · v${formVersion}` : ''}</h2>
<section class="fb-section fb-tab-body">
{#if results}
<Results {results} />
{:else}
<p style="color: var(--color-text-muted);">No questions configured.</p>
<p class="fb-empty">No questions configured.</p>
{/if}
</section>
{:else if activeTab === 'submissions'}
<section class="fb-section">
<h2>Responses</h2>
<section class="fb-section fb-tab-body">
{#if submissions.length === 0}
<p style="color: var(--color-text-muted);">No responses yet.</p>
<p class="fb-empty">No responses yet.</p>
{:else}
<div style="overflow-x: auto;">
<table style="width: 100%; border-collapse: collapse; font-size: 0.875rem;">
<div class="fb-detail-table-wrap">
<table class="fb-detail-table">
<thead>
<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>
<tr>
<th>When</th>
<th>Name</th>
{#each questions as q (q.id)}
<th style="text-align: left; padding: 0.5rem 0.4rem;">{q.label}</th>
<th>{q.label}</th>
{/each}
</tr>
</thead>
<tbody>
{#each submissions as s (s.id)}
<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>
<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 style="padding: 0.5rem 0.4rem; max-width: 320px; word-break: break-word;">
{answerCellFor(q.id, s)}
</td>
<td class="fb-detail-table__cell">{answerCellFor(q.id, s)}</td>
{/each}
</tr>
{/each}
@@ -513,12 +546,12 @@
{/if}
</section>
{:else if activeTab === 'edit'}
<section class="fb-section">
<h2>Edit{formVersion ? ` · v${formVersion}` : ''}</h2>
<section class="fb-section fb-tab-body">
{#if submissions.length > 0}
<div class="fb-banner">
{submissions.length} responses already received. Saving will automatically bump the version — earlier responses keep their original snapshot.
</div>
<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>
@@ -528,31 +561,37 @@
<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>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>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;">
<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>
<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 style="display: inline-flex; gap: 0.25rem; margin-left: auto;">
<div class="fb-segment" role="tablist" aria-label="Editor mode">
<button
type="button"
class="fb-btn fb-btn--sm {editMode === 'visual' ? 'fb-btn--secondary' : 'fb-btn--ghost'}"
class="fb-segment__btn"
class:fb-segment__btn--active={editMode === 'visual'}
onclick={() => switchEditMode('visual')}
>Visual</button>
<button
type="button"
class="fb-btn fb-btn--sm {editMode === 'json' ? 'fb-btn--secondary' : 'fb-btn--ghost'}"
class="fb-segment__btn"
class:fb-segment__btn--active={editMode === 'json'}
onclick={() => switchEditMode('json')}
>JSON</button>
</div>
@@ -562,8 +601,10 @@
{#if editForm}
<FormBuilder bind:value={editForm as FeedbackFormDefinition} />
{:else}
<p style="color: var(--color-text-muted);">No questions configured.</p>
<button type="button" class="fb-btn fb-btn--secondary fb-btn--sm" onclick={ensureBuilderForm}><Icon name="plus" /> Add questions</button>
<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
@@ -576,9 +617,57 @@
{/if}
</div>
<button type="button" class="fb-btn" disabled={actionInFlight} onclick={saveEdits}>
<Icon name="check" /> {actionInFlight ? 'Saving…' : 'Save'}
</button>
<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>