Merge mai/cronus/fdbck-minimalist-ui: minimalist redesign — cards, header collapse, ⋯ menus, spacing scale
This commit is contained in:
706
docs/plans/ui-redesign.md
Normal file
706
docs/plans/ui-redesign.md
Normal file
@@ -0,0 +1,706 @@
|
||||
# fdbck — minimalist UI redesign
|
||||
|
||||
**Status:** design proposal, not yet approved.
|
||||
**Branch:** `mai/cronus/fdbck-minimalist-ui`.
|
||||
**Author:** cronus, 2026-05-06.
|
||||
|
||||
m's brief: *"While our app is quite simple, the layout of it seems overly complex
|
||||
and crowded. I want clearer separation (by negative space) between the different
|
||||
elements and smarter control buttons for a smooth UX."*
|
||||
|
||||
The app is already small (1 stylesheet, 2 reusable components, 5 routes). The
|
||||
problem isn't features — it's that the existing screens fight minimalism with
|
||||
*always-visible action rows*, *redundant copy*, *inline `style="..."` patches*,
|
||||
and *tight vertical rhythm*. This doc proposes per-screen edits that hold
|
||||
features constant but trade visible buttons for whitespace and progressive
|
||||
disclosure.
|
||||
|
||||
---
|
||||
|
||||
## 1. Audit
|
||||
|
||||
### 1.1 `/` (landing)
|
||||
|
||||
```
|
||||
┌────────────────────────────────────────┐
|
||||
│ fdbck │ ← 1.75rem h1
|
||||
│ Private feedback forms and live chat… │
|
||||
│ │
|
||||
│ This page is only reachable through… │
|
||||
│ [ Admin sign-in ] │ ← ghost button
|
||||
└────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
- Already close to minimalist — 20 lines of markup, two paragraphs, one ghost
|
||||
button. Mostly fine.
|
||||
- The wordmark `fdbck` is the brand moment but renders at the same size as
|
||||
every other page h1 (`1.75rem`). It should *feel* like a logo on the landing
|
||||
page, not a generic heading.
|
||||
- Two paragraphs ("Private feedback forms…" / "This page is only reachable…")
|
||||
partly say the same thing. The second one is a near-tautology — the user is
|
||||
already on the page, so "you got here through a private link" is implicit.
|
||||
- Vertical rhythm is tight: `1.5rem` between subtitle and section, then the
|
||||
button sits right under one short sentence.
|
||||
- Tiny: a marketing-blank landing for a tool whose CTA is "sign in" feels
|
||||
hollow. Either lean further into emptiness (just the wordmark + sign-in) or
|
||||
add one line of confidence-building text. We'll lean into emptiness.
|
||||
|
||||
### 1.2 `/login`
|
||||
|
||||
```
|
||||
┌────────────────────────────────────────┐
|
||||
│ Sign in │
|
||||
│ Admin access only. │
|
||||
│ │
|
||||
│ Email [______________] │
|
||||
│ Password [______________] │
|
||||
│ [ Sign in → ] │
|
||||
└────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
- "Sign in" + "Admin access only" is mild redundancy — the page is the form.
|
||||
- Error banner sits *between* the password field and the submit button, which
|
||||
pushes the button down on error and feels like a layout glitch. Better
|
||||
position: above the form (page-level error) or beneath the button as an
|
||||
inline string.
|
||||
- Two fields, one button, no dark patterns — already minimalist. Just needs
|
||||
more vertical breathing room and a quieter subtitle (or none).
|
||||
|
||||
### 1.3 `/admin/feedback` (list)
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────────────────────────┐
|
||||
│ Feedback forms [ + New form ] │
|
||||
│ Collect feedback through forms or live chat. Share a private… │
|
||||
│ │
|
||||
│ Your forms (4) │
|
||||
│ ┌──────────────────────────────────────────────────────────┐ │
|
||||
│ │ Title (link) [Form+chat] [open] │ │
|
||||
│ │ 12 responses · 7 messages · created 2026-05-01 │ │
|
||||
│ │ /f/long-slug-shown-raw │ │
|
||||
│ │ [Edit] [Copy link] [Open] [Close] [Delete] ← 5 buttons │ │
|
||||
│ └──────────────────────────────────────────────────────────┘ │
|
||||
│ … │
|
||||
└──────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
- **The biggest offender.** Five always-visible buttons per row (Edit, Copy
|
||||
link, Open, Close/Reopen, Delete) wrap onto multiple lines on narrow widths.
|
||||
Even on desktop they read as a noisy strip.
|
||||
- Each row has *border + shadow + bg + padding + status pill + mode pill +
|
||||
raw slug + meta line + button strip*. Nine visual objects per row.
|
||||
- Inline `style="…"` everywhere (`+page.svelte:108-156`). Should be classes in
|
||||
`feedback.css` so dark-mode and rhythm tokens stay consistent.
|
||||
- The raw `/f/<slug>` line is shown but has no purpose for the admin — they
|
||||
use Copy link or Open. It's just text noise.
|
||||
- Header repeats the page title in the body (`<h1>Feedback forms</h1>`) and
|
||||
then the section heading (`<h2>Your forms (N)</h2>`). The H2 mostly
|
||||
duplicates the H1, just with a count.
|
||||
|
||||
### 1.4 `/admin/feedback/new`
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────────────────────────┐
|
||||
│ [← All forms] │
|
||||
│ Create a new form │
|
||||
│ Set up a feedback form, a live chat session, or both. You'll… │
|
||||
│ │
|
||||
│ Title [______________________________] │
|
||||
│ Description [______________________________] │
|
||||
│ ☑ Enable live chat │
|
||||
│ Questions (JSON, optional) [ + Insert sample ] │
|
||||
│ ┌──────────────────────────────────────────────────────────┐ │
|
||||
│ │ { … 14-row JSON textarea, monospace … } │ │
|
||||
│ └──────────────────────────────────────────────────────────┘ │
|
||||
│ Leave empty for chat-only feedback. You can edit questions… │
|
||||
│ │
|
||||
│ [ ✓ Create form ] │
|
||||
└──────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
- The JSON textarea is a *power-user escape hatch* dropped into the same
|
||||
surface as a beginner-friendly title/description form. It dominates the
|
||||
page (14 rows, monospace font), even though the visual builder on the
|
||||
detail page is the recommended path.
|
||||
- "Insert sample" sits *to the right* of the label, inside the field's
|
||||
header — visually clever but also noise.
|
||||
- The chat checkbox is a single inline item floating in its own block. It
|
||||
looks afterthought-y. (It also reuses `.fb-option-row` styling, which is
|
||||
meant for question options.)
|
||||
- Helper text under the JSON is good, but it's the *third* descriptive
|
||||
paragraph on the page (`<p>Set up a feedback form…</p>`,
|
||||
`placeholder="…"`, `<div class="fb-question__help">`).
|
||||
|
||||
### 1.5 `/admin/feedback/[id]` (detail)
|
||||
|
||||
```
|
||||
┌────────────────────────────────────────────────────────────────────────┐
|
||||
│ [← All forms] │
|
||||
│ My session feedback │
|
||||
│ Some optional description text shown if present. │
|
||||
│ [open] /f/long-slug [Copy link] [Preview] [Close] [CSV] [JSON] [Delete] ← 8 chips/buttons in one row
|
||||
│ │
|
||||
│ ┌── Share ─────────────────────────────────────────────────────────┐ │
|
||||
│ │ Memorable short link — resolves to /f/long-slug. │ │
|
||||
│ │ https://msbls.de/vote [ Copy ] [ Open ↗ ] │ │
|
||||
│ │ ▸ Replace with a different short link │ │
|
||||
│ └──────────────────────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ [ Chat (3) ] [ Results (12) ] [ Responses (12) ] [ Edit ] │
|
||||
│ …active tab body… │
|
||||
└────────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
- **Header is the densest surface in the app.** Eight clickable controls in a
|
||||
flex-wrap row: status pill, raw slug, Copy link, Preview, Close/Reopen,
|
||||
CSV, JSON, Delete. On a 1024px viewport this wraps onto two lines.
|
||||
- "Copy link / Preview" in the header *and* a separate Share section *and*
|
||||
the slug shown raw — three different ways to access the same URL.
|
||||
- CSV / JSON download buttons sit between Close and Delete — they're not
|
||||
in the same gravitational tier (export is benign, Delete is nuclear), but
|
||||
they look identical (both `fb-btn--ghost fb-btn--sm`).
|
||||
- The Share section (recently added) was bolted onto the layout rather than
|
||||
designed into it. It sits between header and tabs, breaking the "header →
|
||||
tab → tab body" mental model.
|
||||
- The Edit tab's form has its own internal mini-toolbar (Visual / JSON
|
||||
toggle), which is correct but visually clashes with the parent tab strip
|
||||
one screen up.
|
||||
- Polling `setInterval(refresh, 5000)` is right; no UX change needed there.
|
||||
|
||||
### 1.6 `/f/[slug]` (participant)
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────┐
|
||||
│ Title │
|
||||
│ Optional description │
|
||||
│ [⚠ closed banner if closed] │
|
||||
│ │
|
||||
│ Dein Name (optional): [Anonym______] ← inline label + full-width input collide
|
||||
│ │
|
||||
│ ┌── Live-Feedback ──────────────────┐ │
|
||||
│ │ scroll list of posts │ │
|
||||
│ │ [textarea] │ │
|
||||
│ │ [ Senden ] │ │
|
||||
│ └───────────────────────────────────┘ │
|
||||
│ │
|
||||
│ ┌── Fragebogen ─────────────────────┐ │
|
||||
│ │ q1 [...] │ │
|
||||
│ │ q2 [...] │ │
|
||||
│ │ [ Absenden ] │ │
|
||||
│ └───────────────────────────────────┘ │
|
||||
│ │
|
||||
│ fdbck.msbls.de │
|
||||
└──────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
- Already simple. Needs *more* breathing room between Chat and Form sections
|
||||
— they read as two equally-weighted siblings, but in practice one of them
|
||||
is what the user came for.
|
||||
- The `Dein Name (optional):` label sits inline left of a full-width input
|
||||
(`fb-name-row`), while every other label in the participant form is on
|
||||
its own line above the input. The inline-label pattern is the only one
|
||||
of its kind on this page — kill it.
|
||||
- Footer `fdbck.msbls.de` is fine as a quiet trust signal.
|
||||
- Closed banner uses `--color-warning` palette which reads as alert; for a
|
||||
closed-form *neutral* state, a quieter visual is better.
|
||||
- Locale mix: admin pages use English (`Sign in`, `Edit`, `Delete`), but the
|
||||
participant page is German (`Senden`, `Absenden`, `Fragebogen`). Probably
|
||||
intentional (admins are us, participants are German-speaking) — flagging
|
||||
it so we don't accidentally homogenise during the redesign. **Not in
|
||||
scope to change.**
|
||||
|
||||
---
|
||||
|
||||
## 2. Principles
|
||||
|
||||
The following six rules apply to every screen. They are the lens for §3.
|
||||
|
||||
1. **Whitespace over dividers.** Replace borders/shadows/dividers with
|
||||
generous vertical space. The rule of thumb: if a card is bordered on a
|
||||
page that's already on a coloured `--gradient-bg`, the card competes with
|
||||
the page background — drop the border, keep the radius+padding, lean on
|
||||
negative space. Spacing scale: `0.5rem` (within a row), `1.25rem` (between
|
||||
form fields), `2.5rem` (between sections), `4rem` (above page footer).
|
||||
2. **One primary action per page.** Per screen, exactly one button uses the
|
||||
solid `.fb-btn` (filled green). Everything else is `--secondary` or
|
||||
`--ghost` or icon-only. Today most screens have 2–8 solid-or-near-solid
|
||||
actions competing for attention.
|
||||
3. **Smarter controls — collapse rows.** Anywhere we currently render 3+
|
||||
buttons in a strip, the redesign keeps **one** primary inline (or zero —
|
||||
"the row itself is the button") and tucks the rest behind a `⋯` menu
|
||||
(`<details>` summary or a small popover). Status flips become pill
|
||||
toggles instead of buttons. Destructive actions live *only* inside the
|
||||
`⋯` menu, never as a top-level visible button.
|
||||
4. **Visual hierarchy via type weight, not borders.** Inter is loaded — use
|
||||
weight `700` for page-h1, `600` for section-h2, `500` for form labels,
|
||||
`400` for body. Tracking `-0.02em` on headings (already in the CSS for
|
||||
h1) extended to h2. This lets us drop a lot of decorative chrome.
|
||||
5. **Optimistic UX.** Delete a row → row disappears immediately, with an
|
||||
undo toast for ~6s. Toggle status → pill switches instantly, network
|
||||
call in background. Errors revert the optimistic change and show a small
|
||||
inline message. We already have Svelte 5 `$state` everywhere, so this
|
||||
is plumbing, not architecture.
|
||||
6. **No inline `style="..."` for layout.** Every inline style currently in
|
||||
the .svelte files moves to a class in `feedback.css`. Two reasons: dark
|
||||
mode lives in `@media (prefers-color-scheme: dark)` rules and inline
|
||||
styles bypass it; and inline styles defeat the spacing-scale rule (1).
|
||||
|
||||
Optional principle 7 (suggest, but waiting for m): **list, not cards.** A
|
||||
naked padded list with hover-revealed actions beats card-grids for a small N
|
||||
(≈ <50 forms). Keeps the page feeling like *content*, not a dashboard.
|
||||
|
||||
---
|
||||
|
||||
## 3. Per-screen redesigns
|
||||
|
||||
### 3.1 `/` (landing)
|
||||
|
||||
```
|
||||
┌────────────────────────────────────────┐
|
||||
│ │
|
||||
│ │
|
||||
│ fdbck │ ← 2.5rem, weight 700, tracked -0.03em
|
||||
│ feedback by link │ ← muted, 1rem, weight 400
|
||||
│ │
|
||||
│ │
|
||||
│ [ Admin sign-in → ] │ ← single ghost button, sized fb-btn--lg
|
||||
│ │
|
||||
└────────────────────────────────────────┘
|
||||
↑ massive vertical centering
|
||||
```
|
||||
|
||||
- Centre the page vertically (max-width 480px, `min-height: 100vh`,
|
||||
`display: grid; place-items: center;`).
|
||||
- Drop the "This page is only reachable through a private link…" sentence —
|
||||
if a participant lands here they're not lost; the wordmark + sign-in is
|
||||
enough.
|
||||
- Wordmark grows to `2.5rem`, weight `700`, tracking `-0.03em`. Subtitle
|
||||
shrinks to `1rem` muted.
|
||||
- Single CTA, `fb-btn--lg fb-btn--ghost` with the existing `arrow-right`
|
||||
icon (already used on login submit).
|
||||
|
||||
### 3.2 `/login`
|
||||
|
||||
```
|
||||
┌────────────────────────────────────────┐
|
||||
│ │
|
||||
│ Sign in │ ← drop "Admin access only"
|
||||
│ │
|
||||
│ Email │
|
||||
│ [______________________] │
|
||||
│ │
|
||||
│ Password │
|
||||
│ [______________________] │
|
||||
│ │
|
||||
│ ⓘ inline error appears here, muted │
|
||||
│ │
|
||||
│ [ Sign in → ] │
|
||||
└────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
- Drop the "Admin access only." subtitle (the URL says it).
|
||||
- Vertical-centre the form (same grid trick as landing).
|
||||
- Inline error: render as a small muted line *below* the submit button
|
||||
rather than an `fb-banner--error` block above it. Less alarming, no
|
||||
layout shift.
|
||||
- Keep the existing primary `fb-btn` with arrow-right icon.
|
||||
|
||||
### 3.3 `/admin/feedback` (list) — **the biggest change**
|
||||
|
||||
```
|
||||
┌────────────────────────────────────────────────────────────────────┐
|
||||
│ Forms [ + New ] │ ← terse h1, primary CTA
|
||||
│ ───────── │
|
||||
│ │
|
||||
│ My session feedback ●open ⋯ │ ← row hover-reveals ⋯
|
||||
│ Form + chat · 12 responses · 7 messages │
|
||||
│ │
|
||||
│ ┌── 2.5rem of pure whitespace ──┐ │
|
||||
│ │
|
||||
│ Sprint 4 retro ○closed ⋯ │
|
||||
│ Form · 0 responses │
|
||||
│ │
|
||||
│ ┌── 2.5rem of pure whitespace ──┐ │
|
||||
│ │
|
||||
│ Quick chat ●open ⋯ │
|
||||
│ Chat · 21 messages │
|
||||
└────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**Row anatomy:**
|
||||
|
||||
- The whole row title is a link → goes to detail (today's "Edit" button
|
||||
becomes implicit).
|
||||
- Subtitle: mode + counts only. **Drop** the raw `/f/<slug>` line, the
|
||||
`created DATE` ("created" word), and the mode pill — fold mode into the
|
||||
subtitle text ("Form + chat · 12 responses").
|
||||
- Right side: a status dot+label (●open / ○closed) that *is the toggle* —
|
||||
click flips it (optimistic). One control replaces today's
|
||||
pill-+-Close-button pair.
|
||||
- Trailing `⋯` menu (`<details>` summary or popover) opens:
|
||||
- Copy link
|
||||
- Open ↗
|
||||
- Edit (explicit jump, even though row is also a link)
|
||||
- ──────
|
||||
- Delete (red)
|
||||
- Drop `border + shadow + bg` on the row container. Rely on row-padding
|
||||
+ `2.5rem` gap between rows. Hover state: faint `--color-bg-tertiary`
|
||||
background, no border.
|
||||
- Mobile (`< 640px`): keep the `⋯` always visible; on desktop reveal on
|
||||
hover for a calmer default state.
|
||||
|
||||
**Header:**
|
||||
|
||||
- Drop the descriptive paragraph ("Collect feedback through forms…"). The
|
||||
user already knows what fdbck is.
|
||||
- H1 reads "Forms" (terser than "Feedback forms", since we're already at
|
||||
`/admin/feedback`).
|
||||
- Drop the H2 "Your forms (N)" — the count moves into the H1 area as a
|
||||
small muted number, or drops entirely (the rows themselves are the
|
||||
count).
|
||||
- Keep one solid `+ New` button on the right.
|
||||
|
||||
**Empty state:** generous whitespace + "No forms yet — create your first
|
||||
one." + the same `+ New` button as primary CTA.
|
||||
|
||||
**Optimistic delete:** after confirming, row disappears immediately;
|
||||
toast at the bottom: "Form deleted. [Undo]" for 6s.
|
||||
|
||||
### 3.4 `/admin/feedback/new`
|
||||
|
||||
```
|
||||
┌────────────────────────────────────────────────────────────────────┐
|
||||
│ ← Forms │ ← terse back-link, no chip
|
||||
│ │
|
||||
│ New form │
|
||||
│ │
|
||||
│ Title │
|
||||
│ [______________________________________________] │
|
||||
│ │
|
||||
│ Description (optional) │
|
||||
│ [______________________________________________] │
|
||||
│ │
|
||||
│ ☐ Enable live chat │
|
||||
│ │
|
||||
│ ▸ Add questions now (advanced) │ ← collapsed by default
|
||||
│ │
|
||||
│ [ ✓ Create form ] │
|
||||
└────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
- Title + Description + chat checkbox are the *common path*. They occupy
|
||||
the full page width without any JSON noise.
|
||||
- The JSON textarea moves behind a `<details>` disclosure labelled "Add
|
||||
questions now (advanced)". Inside the disclosure we keep:
|
||||
- the `Insert sample` button (now sits *inside* the disclosure, where it
|
||||
belongs)
|
||||
- the textarea
|
||||
- the helper paragraph
|
||||
- Rationale: the visual builder on the detail page is the canonical path
|
||||
for adding questions; offering JSON paste at *creation* is power-user
|
||||
speed-up, not the default. By collapsing it, the new-form page reads
|
||||
like 4 inputs and a button.
|
||||
- Replace the back-link button with a plain `← Forms` text-link in muted
|
||||
weight 500, no chip styling.
|
||||
- Drop the page subtitle ("Set up a feedback form…"). The label "New form"
|
||||
+ the form below carry the meaning.
|
||||
- Move the chat toggle from a faux-`fb-option-row` checkbox to a proper
|
||||
`.fb-toggle` with a clean label-on-the-left layout (new class — small
|
||||
CSS addition).
|
||||
|
||||
### 3.5 `/admin/feedback/[id]` (detail) — **the second-biggest change**
|
||||
|
||||
```
|
||||
┌────────────────────────────────────────────────────────────────────────┐
|
||||
│ ← Forms │
|
||||
│ │
|
||||
│ My session feedback ●open ⋯ │ ← title + clickable status pill + ⋯
|
||||
│ Some optional description shown if present. │
|
||||
│ │
|
||||
│ msbls.de/vote [ Copy ] [ Open ↗ ] [ Replace ] │ ← share-link strip, INLINE in header
|
||||
│ │
|
||||
│ ───────────────────────────────────────────────────────────────── │ ← whitespace, no border
|
||||
│ │
|
||||
│ [ Chat (3) ] [ Results (12) ] [ Responses (12) ] [ Edit ] │
|
||||
│ │
|
||||
│ …active tab body, with 2.5rem top-padding… │
|
||||
└────────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**Header collapse — 8 controls → 3 visible:**
|
||||
|
||||
| today's control | redesign |
|
||||
| ------------------------------------ | ---------------------- |
|
||||
| status pill (display only) | status pill, clickable to toggle (optimistic) |
|
||||
| `/f/slug` raw text | dropped (the share strip below has the link) |
|
||||
| Copy link button | inside share strip |
|
||||
| Preview button | inside share strip ("Open ↗") |
|
||||
| Close / Reopen button | merged into status pill |
|
||||
| CSV button | inside `⋯` menu |
|
||||
| JSON button | inside `⋯` menu |
|
||||
| Delete button | inside `⋯` menu (red, last) |
|
||||
|
||||
`⋯` menu contents (in order, with separators):
|
||||
|
||||
1. Copy link (when the share strip is hidden on mobile)
|
||||
2. Export CSV
|
||||
3. Export JSON
|
||||
4. ──────
|
||||
5. Delete (red)
|
||||
|
||||
**Share section disappears as a separate block.** The short URL is shown
|
||||
inline in the header as one strip:
|
||||
|
||||
- If `inst.short_url` exists: show it + Copy + Open ↗ + Replace (Replace
|
||||
opens a small inline form *under* the strip — same `<details>` pattern
|
||||
as today, but compact).
|
||||
- If it doesn't: show "No short link · [ Create one ]" → click reveals
|
||||
the slug input + create button.
|
||||
|
||||
This kills today's awkward "section between header and tabs" structure
|
||||
and folds share into the place where the user expects "where do I share
|
||||
this".
|
||||
|
||||
**Tabs unchanged in count and labels.** They stay: Chat / Results /
|
||||
Responses / Edit. Selection style stays the segmented-pill (`fb-tabs` /
|
||||
`fb-tab--active`). Whitespace between tab strip and tab body grows from
|
||||
`1.25rem` to `2.5rem` so the tab body breathes.
|
||||
|
||||
**Edit tab inline polish:**
|
||||
|
||||
- Drop the duplicate `<h2>Edit · v3</h2>` heading inside the tab — the
|
||||
active tab pill already says "Edit". Save the version pill for a quiet
|
||||
spot next to the Save button.
|
||||
- The `Visual` / `JSON` toggle becomes a small segmented control in the
|
||||
same shape as the tab pills (consistency).
|
||||
- Save button is the page's primary action *while* this tab is active.
|
||||
No other solid button on the page in that state.
|
||||
|
||||
**Banner about responses-already-received** (`{submissions.length}
|
||||
responses already received…`) becomes a single muted line above the form,
|
||||
not a banner box.
|
||||
|
||||
### 3.6 `/f/[slug]` (participant)
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────┐
|
||||
│ │
|
||||
│ Title │ ← h1, room above
|
||||
│ Optional description │
|
||||
│ │
|
||||
│ ⓘ closed (only if closed, neutral) │ ← muted not yellow when closed
|
||||
│ │
|
||||
│ ───────────────────────────────────── │ ← 3rem gap
|
||||
│ │
|
||||
│ Live-Feedback │
|
||||
│ ┌── chat list ──────────────────────┐ │
|
||||
│ │ … │ │
|
||||
│ └───────────────────────────────────┘ │
|
||||
│ [ textarea (full width) ] │
|
||||
│ [Senden]│
|
||||
│ │
|
||||
│ ───────────────────────────────────── │ ← 3rem gap (same as above)
|
||||
│ │
|
||||
│ Fragebogen │
|
||||
│ q1 │
|
||||
│ q2 │
|
||||
│ │
|
||||
│ [ Absenden ] │
|
||||
│ │
|
||||
│ │
|
||||
│ fdbck.msbls.de │
|
||||
└──────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
- **Drop the inline-label name row.** Replace with a normal labelled field
|
||||
matching the rest of the form:
|
||||
|
||||
```
|
||||
Dein Name (optional)
|
||||
[Anonym__________________________]
|
||||
```
|
||||
|
||||
Or move it *below* the chat/form sections as a footer-style "Du
|
||||
schreibst als: Anonym ✎" with click-to-edit. (Suggested — see open
|
||||
question #5.) For v1 keep it at top but normalise its layout.
|
||||
- Increase section gap from `1.75rem` to `3rem` between the chat block
|
||||
and the form block, so they read as two distinct activities.
|
||||
- Closed banner: switch from amber `--color-warning` palette to a quiet
|
||||
neutral muted style (1px top border + small italic line) — closed is a
|
||||
state, not an alert.
|
||||
- Drop the `border-color: var(--color-primary)` on `.fb-chat__post--mine`
|
||||
in favour of a subtle left-border-only accent (3px, `--color-primary`).
|
||||
The current full pill-with-coloured-border treatment is loud.
|
||||
- Footer `fdbck.msbls.de` → wrap as a permalink to `/`, very muted.
|
||||
|
||||
---
|
||||
|
||||
## 4. Implementation plan
|
||||
|
||||
The redesign is *only* CSS + small `.svelte` markup edits. No new
|
||||
components, no new dependencies, no schema changes. Suggested commit order
|
||||
(each commit is independently mergeable and testable):
|
||||
|
||||
### Commit 1 — CSS foundation: spacing scale + typography rhythm
|
||||
|
||||
- `src/lib/styles/feedback.css`:
|
||||
- Add a spacing scale as CSS custom properties (`--space-1` through
|
||||
`--space-7`, or named `--gap-row / --gap-field / --gap-section /
|
||||
--gap-foot`).
|
||||
- Bump `.fb-section` margin-bottom from `1.75rem` → `--gap-section`
|
||||
(≈ `2.5rem`).
|
||||
- `.fb-header h1` weight stays 700; `.fb-section h2` weight bumps from
|
||||
`600` → `600` (no change) but tracking `-0.01em` already there.
|
||||
- Add `.fb-page-narrow` (max-width `480px`) and `.fb-page-center`
|
||||
(vertical grid centre) modifiers for landing/login.
|
||||
- Add `.fb-toggle` for label-left+switch-right boolean fields.
|
||||
- Add `.fb-row` for spacious list rows (no border, no shadow, hover
|
||||
bg) plus `.fb-row__title`, `.fb-row__meta`, `.fb-row__actions`.
|
||||
- Add `.fb-status-pill` (clickable open/closed pill).
|
||||
- Add `.fb-menu` (the `⋯` dropdown — implemented as `<details>` +
|
||||
`<summary>` + a positioned panel).
|
||||
- Add `.fb-share-strip` (the inline header share row in 3.5).
|
||||
- No structural Svelte changes in this commit — verify in the browser
|
||||
that the existing pages still render fine since added classes are
|
||||
unused additions.
|
||||
|
||||
### Commit 2 — landing + login (cheap wins)
|
||||
|
||||
- `src/routes/+page.svelte`: drop the second paragraph; wrap shell in
|
||||
`fb-page-center fb-page-narrow`; bump wordmark size.
|
||||
- `src/routes/login/+page.svelte`: drop the "Admin access only."
|
||||
subtitle; wrap in `fb-page-center fb-page-narrow`; move error from
|
||||
`fb-banner--error` block to a small inline muted line.
|
||||
- Verify both routes in the browser at 375px / 1024px / 1440px.
|
||||
|
||||
### Commit 3 — `/admin/feedback/new`
|
||||
|
||||
- `src/routes/admin/feedback/new/+page.svelte`:
|
||||
- Drop the page subtitle.
|
||||
- Replace the chat-checkbox block with `.fb-toggle`.
|
||||
- Wrap "Questions (JSON, optional)" + sample button + textarea +
|
||||
helper inside a `<details>` with summary "Add questions now
|
||||
(advanced)".
|
||||
- Remove inline `style="..."` on the back-link block; replace with a
|
||||
`.fb-back-link` class (text-only, muted).
|
||||
|
||||
### Commit 4 — `/admin/feedback` (list) — biggest change, isolated commit
|
||||
|
||||
- `src/routes/admin/feedback/+page.svelte`:
|
||||
- Drop the H1 paragraph.
|
||||
- Drop the H2 "Your forms (N)".
|
||||
- Replace each row (today's bordered card with 5 buttons) with the
|
||||
`.fb-row` pattern from commit 1: title is a link to detail,
|
||||
subtitle merges mode + counts, right side is `.fb-status-pill`
|
||||
(toggle) + `.fb-menu` trigger.
|
||||
- Move all inline `style="..."` to classes (no new feature).
|
||||
- Add the `⋯` menu component inline using `<details>` — no JS
|
||||
framework needed; click-outside-to-close via a small `addEventListener`
|
||||
in `onMount`.
|
||||
- Add optimistic delete with undo toast (small `<aside>` element fixed
|
||||
to bottom-centre, `setTimeout` 6s, calls `DELETE` only after timeout
|
||||
expires).
|
||||
- Verify the live deployed list still looks right after deploy.
|
||||
|
||||
### Commit 5 — `/admin/feedback/[id]` (detail) — second-biggest change
|
||||
|
||||
- `src/routes/admin/feedback/[id]/+page.svelte`:
|
||||
- Header collapse per 3.5: status becomes `.fb-status-pill`, drop
|
||||
raw `/f/slug` text, drop inline Copy/Preview/Close/Reopen/CSV/
|
||||
JSON/Delete buttons.
|
||||
- Add `.fb-share-strip` directly under the title/description in the
|
||||
header (replaces the standalone Share section below).
|
||||
- Add `.fb-menu` with Copy link / Export CSV / Export JSON /
|
||||
────── / Delete.
|
||||
- Delete the standalone `<section data-fb-share>` block — its
|
||||
contents are now in the header strip + a "Replace" inline form.
|
||||
- Inside Edit tab: drop the `<h2>Edit · v3</h2>`, move version pill
|
||||
next to Save; turn Visual/JSON into a segmented pill control
|
||||
matching `.fb-tabs`.
|
||||
- Move `submissions.length` warning from `.fb-banner` to muted line.
|
||||
- Replace inline `style="..."` with classes throughout.
|
||||
|
||||
### Commit 6 — `/f/[slug]` participant polish
|
||||
|
||||
- `src/routes/f/[slug]/+page.svelte`:
|
||||
- Replace the inline-label name row with a stacked-label field.
|
||||
- Increase gap between Chat and Form sections (use `--gap-section`).
|
||||
- Switch "(closed)" banner to neutral muted style (a new
|
||||
`.fb-status-line--muted` modifier on `.fb-banner` or a dedicated
|
||||
`.fb-closed-line` class).
|
||||
- Adjust `.fb-chat__post--mine` styling: drop full coloured border,
|
||||
add `border-left: 3px solid var(--color-primary)` only.
|
||||
- Wrap footer text in an `<a href="/">` with `aria-label="fdbck home"`.
|
||||
|
||||
### Commit 7 — sweep: optimistic toggles, hover-reveal actions
|
||||
|
||||
- Add the optimistic-update + undo-toast pattern to `setStatus` on the
|
||||
detail page (status pill instant flip, server call after).
|
||||
- Apply the same to per-row `toggleStatus` on the list page.
|
||||
- Verify dark-mode pass: walk all five routes in dark mode (browser
|
||||
emulator) since multiple inline `style="..."` strings were converted
|
||||
to classes that inherit from `:root` / `prefers-color-scheme`.
|
||||
|
||||
---
|
||||
|
||||
## 5. Open questions for m
|
||||
|
||||
These are decisions m should weigh in on before implementation. Each has
|
||||
my recommendation, but they're all reversible.
|
||||
|
||||
1. **Cards vs spacious list for `/admin/feedback`.** I'm proposing a
|
||||
spacious list (no border, lots of whitespace, hover-revealed `⋯`).
|
||||
The brief mentions cards as an option. **My recommendation: list.**
|
||||
Cards add visual chrome (border + shadow) which is the opposite of
|
||||
"negative space". Cards earn their keep when each item has rich
|
||||
content (image, multi-line preview); fdbck rows have title + 2
|
||||
meta lines.
|
||||
2. **Optimistic delete with 6s undo, or keep the `confirm()` modal?**
|
||||
Today both list and detail use `confirm("…cannot be undone")`. The
|
||||
undo toast is smoother but trades safety for fluency. **My
|
||||
recommendation: undo toast** — the row reappears identically if
|
||||
undone, the destructive call only fires after timeout, no data is
|
||||
actually at risk during the 6s window. Keep `confirm()` only on
|
||||
detail-page Delete (deleting from the detail page is a more
|
||||
deliberate action and merits an extra step).
|
||||
3. **Share-link strip in the detail header — does it belong there?**
|
||||
I'm folding the standalone Share section *into* the header (3.5).
|
||||
Alternative: keep Share as a section but move it *below* the tabs,
|
||||
so the header stays minimal. **My recommendation: in the header.**
|
||||
Sharing the link is the second-most-common action after viewing
|
||||
results; it belongs near the title.
|
||||
4. **Drop the Edit tab body's inner H2?** Today every tab body has an
|
||||
`<h2>` echoing the tab name (`<h2>Live chat</h2>`,
|
||||
`<h2>Results</h2>`, etc.). I want to drop these — the tab strip
|
||||
already labels the active section. But this is a stylistic call.
|
||||
**My recommendation: drop the inner H2s,** add `2rem` of top
|
||||
padding to the tab body so the active tab pill alone titles the
|
||||
section.
|
||||
5. **Participant name field — top, bottom-as-footer, or bottom-as-
|
||||
"edit"?** Today it's at the top with an inline label. I'm proposing
|
||||
stacking it the same as other fields. A bolder alternative: move
|
||||
it to a footer-style "Du schreibst als: Anonym ✎" below all
|
||||
sections. **My recommendation for v1: stacked label, top.** Bolder
|
||||
move waits for v2.
|
||||
6. **Locale homogenisation.** Admin pages are English, participant
|
||||
page is German. Out of scope for this redesign — flagging only so
|
||||
we don't accidentally translate one of them while doing the markup
|
||||
pass. Confirm this stays as-is.
|
||||
7. **Accent on `--color-primary`.** Same green stays per brief. But
|
||||
today some screens have `box-shadow: 0 0 0 3px rgb(22 163 74 /
|
||||
0.15)` on focus and others have `0.25` opacity — minor inconsistency.
|
||||
Worth normalising during commit 1? **My recommendation: yes,
|
||||
normalise to one focus-ring rule (0.2 opacity, 3px).**
|
||||
|
||||
---
|
||||
|
||||
## 6. Anti-scope (explicitly NOT in this redesign)
|
||||
|
||||
- Palette: no new colours, no new accent.
|
||||
- Stack: still SvelteKit + vanilla CSS + Inter. No new deps.
|
||||
- Routes: unchanged URL structure.
|
||||
- Data model: unchanged.
|
||||
- Locale: no homogenisation (see Q6).
|
||||
- Polling intervals (5s admin, 3s chat): unchanged.
|
||||
- Auth flow / share-link backend: unchanged (only the *display* of the
|
||||
share link in the detail header changes).
|
||||
@@ -23,6 +23,23 @@
|
||||
--color-border-secondary: #d1d5db;
|
||||
--color-border-focus: #16a34a;
|
||||
|
||||
/* Status pill tokens — surface background + foreground for open / closed */
|
||||
--color-status-open-bg: var(--color-primary-light);
|
||||
--color-status-open-fg: var(--color-primary-hover);
|
||||
--color-status-closed-bg: #fef3c7;
|
||||
--color-status-closed-fg: #78350f;
|
||||
|
||||
/* Spacing scale — single source of truth for vertical rhythm */
|
||||
--space-1: 0.5rem;
|
||||
--space-2: 0.75rem;
|
||||
--space-3: 1rem;
|
||||
--space-4: 1.25rem;
|
||||
--space-5: 1.5rem;
|
||||
--space-6: 2rem;
|
||||
--space-7: 2.5rem;
|
||||
--space-8: 3rem;
|
||||
--space-9: 4rem;
|
||||
|
||||
/* Radii / shadows */
|
||||
--radius-sm: 0.25rem;
|
||||
--radius-md: 0.5rem;
|
||||
@@ -73,6 +90,9 @@
|
||||
--color-border-secondary: #4b5563;
|
||||
--color-border-focus: #22c55e;
|
||||
|
||||
--color-status-closed-bg: rgba(245, 158, 11, 0.15);
|
||||
--color-status-closed-fg: #fcd34d;
|
||||
|
||||
--shadow-sm: 0 1px 2px 0 rgb(0 0 0 / 0.4);
|
||||
--shadow-md: 0 4px 6px -1px rgb(0 0 0 / 0.5), 0 2px 4px -2px rgb(0 0 0 / 0.4);
|
||||
--shadow-lg: 0 10px 15px -3px rgb(0 0 0 / 0.5), 0 4px 6px -4px rgb(0 0 0 / 0.4);
|
||||
@@ -160,7 +180,7 @@ body { min-height: 100vh; }
|
||||
|
||||
.fb-section {
|
||||
padding: 0;
|
||||
margin-bottom: 1.75rem;
|
||||
margin-bottom: var(--space-7);
|
||||
}
|
||||
|
||||
.fb-section h2 {
|
||||
@@ -208,7 +228,7 @@ body { min-height: 100vh; }
|
||||
.fb-select:focus {
|
||||
outline: none;
|
||||
border-color: var(--color-border-focus);
|
||||
box-shadow: 0 0 0 3px rgb(22 163 74 / 0.15);
|
||||
box-shadow: 0 0 0 3px rgb(22 163 74 / 0.2);
|
||||
}
|
||||
|
||||
.fb-input:hover:not(:focus),
|
||||
@@ -218,7 +238,7 @@ body { min-height: 100vh; }
|
||||
}
|
||||
|
||||
.fb-question {
|
||||
margin-bottom: 1.25rem;
|
||||
margin-bottom: var(--space-4);
|
||||
}
|
||||
|
||||
.fb-question__label {
|
||||
@@ -342,7 +362,7 @@ body { min-height: 100vh; }
|
||||
|
||||
.fb-btn:focus-visible {
|
||||
outline: none;
|
||||
box-shadow: 0 0 0 3px rgb(22 163 74 / 0.25);
|
||||
box-shadow: 0 0 0 3px rgb(22 163 74 / 0.2);
|
||||
}
|
||||
|
||||
.fb-btn:disabled {
|
||||
@@ -455,7 +475,8 @@ body { min-height: 100vh; }
|
||||
|
||||
.fb-chat__post--mine {
|
||||
background: var(--color-primary-light);
|
||||
border-color: var(--color-primary);
|
||||
border-color: var(--color-border-primary);
|
||||
border-left: 3px solid var(--color-primary);
|
||||
}
|
||||
|
||||
.fb-chat__post--hidden {
|
||||
@@ -792,3 +813,453 @@ body { min-height: 100vh; }
|
||||
grid-template-columns: 6rem minmax(0, 1fr) auto;
|
||||
}
|
||||
}
|
||||
|
||||
/* ─────────────────────────────────────────────────────────────────
|
||||
Minimalist redesign — utility classes added 2026-05-06.
|
||||
See docs/plans/ui-redesign.md for the rationale.
|
||||
───────────────────────────────────────────────────────────────── */
|
||||
|
||||
/* Page modifiers — vertical centering + narrow shell for landing/login. */
|
||||
|
||||
.fb-shell.fb-page-narrow {
|
||||
max-width: 480px;
|
||||
}
|
||||
|
||||
.fb-shell.fb-page-center {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
min-height: 100vh;
|
||||
padding-top: var(--space-6);
|
||||
padding-bottom: var(--space-9);
|
||||
}
|
||||
|
||||
/* Quiet text-only back-link — replaces the chip-style back button. */
|
||||
|
||||
.fb-back-link {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
color: var(--color-text-muted);
|
||||
text-decoration: none;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
margin-bottom: var(--space-4);
|
||||
}
|
||||
|
||||
.fb-back-link:hover {
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
/* Inline error — quieter alternative to .fb-banner--error. */
|
||||
|
||||
.fb-inline-error {
|
||||
color: var(--color-error);
|
||||
font-size: 0.875rem;
|
||||
margin-top: 0.6rem;
|
||||
}
|
||||
|
||||
/* Toggle field — label-left, native checkbox-right (no UI library). */
|
||||
|
||||
.fb-toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
padding: var(--space-2) 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.fb-toggle__text {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.15rem;
|
||||
}
|
||||
|
||||
.fb-toggle__label {
|
||||
font-weight: 500;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.fb-toggle__hint {
|
||||
font-size: 0.825rem;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.fb-toggle input {
|
||||
margin: 0;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Status pill — clickable, toggles open/closed via the same control. */
|
||||
|
||||
.fb-status-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
padding: 0.3rem 0.65rem;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 999px;
|
||||
background: var(--color-bg-tertiary);
|
||||
color: var(--color-text-secondary);
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
font-family: inherit;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
transition: filter 150ms ease, box-shadow 150ms ease;
|
||||
}
|
||||
|
||||
.fb-status-pill:hover:not(:disabled) {
|
||||
filter: brightness(0.97);
|
||||
}
|
||||
|
||||
.fb-status-pill:focus-visible {
|
||||
outline: none;
|
||||
box-shadow: 0 0 0 3px rgb(22 163 74 / 0.2);
|
||||
}
|
||||
|
||||
.fb-status-pill:disabled {
|
||||
cursor: wait;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.fb-status-pill__dot {
|
||||
width: 0.5rem;
|
||||
height: 0.5rem;
|
||||
border-radius: 50%;
|
||||
background: currentColor;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.fb-status-pill--open {
|
||||
background: var(--color-status-open-bg);
|
||||
color: var(--color-status-open-fg);
|
||||
}
|
||||
|
||||
.fb-status-pill--closed {
|
||||
background: var(--color-status-closed-bg);
|
||||
color: var(--color-status-closed-fg);
|
||||
}
|
||||
|
||||
/* ⋯ menu — native <details>/<summary> with an absolutely-positioned panel. */
|
||||
|
||||
.fb-menu {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.fb-menu__btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
border: 1px solid transparent;
|
||||
border-radius: var(--radius-md);
|
||||
cursor: pointer;
|
||||
background: transparent;
|
||||
color: var(--color-text-secondary);
|
||||
font-size: 1.1rem;
|
||||
font-weight: 700;
|
||||
user-select: none;
|
||||
list-style: none;
|
||||
transition: background 150ms ease, border-color 150ms ease, color 150ms ease;
|
||||
}
|
||||
|
||||
.fb-menu__btn::-webkit-details-marker { display: none; }
|
||||
.fb-menu__btn::marker { content: ''; }
|
||||
|
||||
.fb-menu__btn:hover {
|
||||
background: var(--color-bg-tertiary);
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.fb-menu__btn:focus-visible {
|
||||
outline: none;
|
||||
box-shadow: 0 0 0 3px rgb(22 163 74 / 0.2);
|
||||
}
|
||||
|
||||
.fb-menu[open] > .fb-menu__btn {
|
||||
background: var(--color-bg-tertiary);
|
||||
border-color: var(--color-border-primary);
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.fb-menu__panel {
|
||||
position: absolute;
|
||||
top: calc(100% + 0.25rem);
|
||||
right: 0;
|
||||
z-index: 30;
|
||||
min-width: 12rem;
|
||||
background: var(--color-bg-primary);
|
||||
border: 1px solid var(--color-border-primary);
|
||||
border-radius: var(--radius-md);
|
||||
box-shadow: var(--shadow-lg);
|
||||
padding: 0.3rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.fb-menu__item {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.55rem;
|
||||
padding: 0.5rem 0.7rem;
|
||||
border-radius: var(--radius-sm);
|
||||
background: transparent;
|
||||
border: 0;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
color: var(--color-text-primary);
|
||||
font-size: 0.875rem;
|
||||
font-family: inherit;
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.fb-menu__item:hover:not(:disabled) {
|
||||
background: var(--color-bg-tertiary);
|
||||
}
|
||||
|
||||
.fb-menu__item:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.fb-menu__divider {
|
||||
border: 0;
|
||||
border-top: 1px solid var(--color-border-primary);
|
||||
margin: 0.25rem 0;
|
||||
}
|
||||
|
||||
.fb-menu__item--danger {
|
||||
color: var(--color-error);
|
||||
}
|
||||
|
||||
.fb-menu__item--danger:hover:not(:disabled) {
|
||||
background: rgb(220 38 38 / 0.08);
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.fb-menu__item--danger:hover:not(:disabled) {
|
||||
background: rgb(248 113 113 / 0.15);
|
||||
}
|
||||
}
|
||||
|
||||
/* Card — the new list-row container. Subtle bg on the gradient page bg,
|
||||
no border + shadow stack, generous internal padding. */
|
||||
|
||||
.fb-card {
|
||||
background: var(--color-bg-primary);
|
||||
padding: var(--space-5);
|
||||
border-radius: var(--radius-lg);
|
||||
}
|
||||
|
||||
.fb-card__head {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.fb-card__title {
|
||||
font-size: 1.0625rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-primary);
|
||||
text-decoration: none;
|
||||
letter-spacing: -0.01em;
|
||||
line-height: 1.35;
|
||||
word-break: break-word;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.fb-card__title:hover {
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
.fb-card__actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.fb-card__meta {
|
||||
font-size: 0.875rem;
|
||||
color: var(--color-text-muted);
|
||||
margin-top: 0.4rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.fb-card-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: var(--space-5);
|
||||
}
|
||||
|
||||
@media (min-width: 640px) {
|
||||
.fb-card-grid {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: var(--space-6);
|
||||
}
|
||||
}
|
||||
|
||||
/* Empty state — generous whitespace + soft text. */
|
||||
|
||||
.fb-empty {
|
||||
padding: var(--space-7) 0;
|
||||
text-align: center;
|
||||
color: var(--color-text-muted);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-4);
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
/* Inline share strip — replaces the standalone Share section in the
|
||||
detail-page header. Subtle border so the URL reads as a chip. */
|
||||
|
||||
.fb-share-strip {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.6rem;
|
||||
flex-wrap: wrap;
|
||||
margin-top: var(--space-3);
|
||||
padding: 0.55rem 0.75rem;
|
||||
background: var(--color-bg-primary);
|
||||
border: 1px solid var(--color-border-primary);
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
|
||||
.fb-share-strip__url {
|
||||
font-family: ui-monospace, 'SF Mono', Menlo, monospace;
|
||||
font-size: 0.9rem;
|
||||
color: var(--color-text-primary);
|
||||
text-decoration: none;
|
||||
word-break: break-all;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.fb-share-strip__url:hover {
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
.fb-share-strip__placeholder {
|
||||
color: var(--color-text-muted);
|
||||
font-size: 0.875rem;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.fb-share-strip__replace {
|
||||
margin-top: var(--space-3);
|
||||
}
|
||||
|
||||
.fb-share-strip__replace summary {
|
||||
cursor: pointer;
|
||||
color: var(--color-text-muted);
|
||||
font-size: 0.85rem;
|
||||
margin-bottom: var(--space-2);
|
||||
}
|
||||
|
||||
.fb-share-strip__replace summary:hover {
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
/* Closed-state line — neutral muted, replaces .fb-banner--closed at the
|
||||
top of the participant page so closed reads as a state, not an alert. */
|
||||
|
||||
.fb-closed-line {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: var(--space-2) 0;
|
||||
border-top: 1px solid var(--color-border-primary);
|
||||
border-bottom: 1px solid var(--color-border-primary);
|
||||
color: var(--color-text-muted);
|
||||
font-size: 0.9rem;
|
||||
font-style: italic;
|
||||
margin-bottom: var(--space-5);
|
||||
}
|
||||
|
||||
/* Segmented control — small variant of .fb-tabs for inline controls
|
||||
(e.g. Visual / JSON toggle on the Edit tab). */
|
||||
|
||||
.fb-segment {
|
||||
display: inline-flex;
|
||||
gap: 0.2rem;
|
||||
padding: 0.2rem;
|
||||
background: var(--color-bg-tertiary);
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
|
||||
.fb-segment__btn {
|
||||
background: transparent;
|
||||
border: 0;
|
||||
padding: 0.35rem 0.7rem;
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
color: var(--color-text-secondary);
|
||||
font-size: 0.8rem;
|
||||
font-weight: 500;
|
||||
font-family: inherit;
|
||||
transition: background 150ms ease, color 150ms ease;
|
||||
}
|
||||
|
||||
.fb-segment__btn:hover:not(.fb-segment__btn--active) {
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.fb-segment__btn--active {
|
||||
background: var(--color-bg-primary);
|
||||
color: var(--color-text-primary);
|
||||
font-weight: 600;
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
/* Detail-page header — denser controls cluster with whitespace below. */
|
||||
|
||||
.fb-detail-head {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-3);
|
||||
margin-top: var(--space-3);
|
||||
}
|
||||
|
||||
.fb-detail-head__title {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.fb-detail-head__actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Tab body — generous top padding so the tab pill alone titles the section. */
|
||||
|
||||
.fb-tab-body {
|
||||
padding-top: var(--space-6);
|
||||
}
|
||||
|
||||
/* Saved-version note — quiet line above the Save button on the Edit tab. */
|
||||
|
||||
.fb-version-note {
|
||||
font-size: 0.8rem;
|
||||
color: var(--color-text-muted);
|
||||
margin-top: 0.4rem;
|
||||
}
|
||||
|
||||
.fb-save-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
flex-wrap: wrap;
|
||||
margin-top: var(--space-4);
|
||||
}
|
||||
|
||||
@@ -1,20 +1,45 @@
|
||||
<script lang="ts">
|
||||
import Icon from '$lib/components/Icon.svelte';
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>fdbck — feedback by link</title>
|
||||
<meta name="robots" content="noindex,nofollow" />
|
||||
</svelte:head>
|
||||
|
||||
<div class="fb-shell">
|
||||
<header class="fb-header">
|
||||
<h1>fdbck</h1>
|
||||
<p>
|
||||
Private feedback forms and live chat — share a link, get answers.
|
||||
</p>
|
||||
<div class="fb-shell fb-page-narrow fb-page-center">
|
||||
<header class="fb-header fb-landing">
|
||||
<h1 class="fb-landing__wordmark">fdbck</h1>
|
||||
<p class="fb-landing__tagline">feedback by link</p>
|
||||
</header>
|
||||
|
||||
<div class="fb-section">
|
||||
<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 sign-in</a>
|
||||
</p>
|
||||
<div class="fb-landing__cta">
|
||||
<a href="/login" class="fb-btn fb-btn--ghost fb-btn--lg">
|
||||
Admin sign-in <Icon name="arrow-right" />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.fb-landing {
|
||||
text-align: center;
|
||||
margin-bottom: var(--space-8);
|
||||
}
|
||||
.fb-landing__wordmark {
|
||||
font-size: 2.5rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.03em;
|
||||
margin: 0;
|
||||
line-height: 1;
|
||||
}
|
||||
.fb-landing__tagline {
|
||||
color: var(--color-text-muted);
|
||||
font-size: 1rem;
|
||||
font-weight: 400;
|
||||
margin: 0.5rem 0 0;
|
||||
}
|
||||
.fb-landing__cta {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script lang="ts">
|
||||
import '$lib/styles/feedback.css';
|
||||
import type { PageData } from './$types';
|
||||
import { onMount, onDestroy } from 'svelte';
|
||||
import { invalidateAll } from '$app/navigation';
|
||||
import Icon from '$lib/components/Icon.svelte';
|
||||
|
||||
@@ -49,9 +50,18 @@
|
||||
}
|
||||
}
|
||||
|
||||
// Optimistic status toggle — flip the pill instantly, then PATCH. On
|
||||
// failure, revert by calling invalidateAll() (server has the truth).
|
||||
let optimisticStatus = $state<Record<string, 'open' | 'closed'>>({});
|
||||
|
||||
function effectiveStatus(i: { id: string; status: 'open' | 'closed' }): 'open' | 'closed' {
|
||||
return optimisticStatus[i.id] ?? i.status;
|
||||
}
|
||||
|
||||
async function toggleStatus(id: string, current: 'open' | 'closed'): Promise<void> {
|
||||
if (rowBusy[id]) return;
|
||||
const next = current === 'open' ? 'closed' : 'open';
|
||||
optimisticStatus = { ...optimisticStatus, [id]: next };
|
||||
rowBusy = { ...rowBusy, [id]: true };
|
||||
setRowError(id, null);
|
||||
try {
|
||||
@@ -63,11 +73,17 @@
|
||||
if (!res.ok) {
|
||||
const j = (await res.json().catch(() => ({}))) as { error?: string };
|
||||
setRowError(id, j.error ?? `Error ${res.status}`);
|
||||
const { [id]: _, ...rest } = optimisticStatus;
|
||||
optimisticStatus = rest;
|
||||
return;
|
||||
}
|
||||
await invalidateAll();
|
||||
const { [id]: _, ...rest } = optimisticStatus;
|
||||
optimisticStatus = rest;
|
||||
} catch (err) {
|
||||
setRowError(id, err instanceof Error ? err.message : 'Network error');
|
||||
const { [id]: _, ...rest } = optimisticStatus;
|
||||
optimisticStatus = rest;
|
||||
} finally {
|
||||
const { [id]: _, ...rest } = rowBusy;
|
||||
rowBusy = rest;
|
||||
@@ -82,6 +98,43 @@
|
||||
return '—';
|
||||
}
|
||||
|
||||
function subtitleFor(i: PageData['instances'][number]): string {
|
||||
const parts: string[] = [modeLabel(i)];
|
||||
parts.push(`${i.counts.submissions} ${i.counts.submissions === 1 ? 'response' : 'responses'}`);
|
||||
if (i.chat_enabled) {
|
||||
parts.push(`${i.counts.posts} ${i.counts.posts === 1 ? 'message' : 'messages'}`);
|
||||
}
|
||||
return parts.join(' · ');
|
||||
}
|
||||
|
||||
// Click-outside-to-close for any open <details class="fb-menu">. Native
|
||||
// <details> only toggles via summary clicks; this adds the dismiss-on-
|
||||
// outside-click pattern users expect from dropdown menus.
|
||||
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(() => {
|
||||
document.addEventListener('click', onDocClick);
|
||||
document.addEventListener('keydown', onDocKey);
|
||||
});
|
||||
onDestroy(() => {
|
||||
if (typeof document !== 'undefined') {
|
||||
document.removeEventListener('click', onDocClick);
|
||||
document.removeEventListener('keydown', onDocKey);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
@@ -90,72 +143,81 @@
|
||||
</svelte:head>
|
||||
|
||||
<div class="fb-shell">
|
||||
<header class="fb-header">
|
||||
<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"><Icon name="plus" /> New form</a>
|
||||
</div>
|
||||
<header class="fb-header fb-list-head">
|
||||
<h1>Forms</h1>
|
||||
<a href="/admin/feedback/new" class="fb-btn"><Icon name="plus" /> New form</a>
|
||||
</header>
|
||||
|
||||
<section class="fb-section">
|
||||
<h2>Your forms ({data.instances.length})</h2>
|
||||
{#if data.instances.length === 0}
|
||||
<p style="color: var(--color-text-muted);">No forms yet.</p>
|
||||
{:else}
|
||||
<div style="display: flex; flex-direction: column; gap: 0.5rem;">
|
||||
{#if data.instances.length === 0}
|
||||
<div class="fb-empty">
|
||||
<p>No forms yet.</p>
|
||||
<a href="/admin/feedback/new" class="fb-btn"><Icon name="plus" /> Create your first form</a>
|
||||
</div>
|
||||
{:else}
|
||||
<section class="fb-section">
|
||||
<div class="fb-card-grid">
|
||||
{#each data.instances as i (i.id)}
|
||||
<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(--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: var(--color-primary-light); color: var(--color-primary-hover);">open</span>
|
||||
{/if}
|
||||
</div>
|
||||
<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(--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; align-items: flex-start;">
|
||||
<a class="fb-btn fb-btn--secondary fb-btn--sm" href="/admin/feedback/{i.id}"><Icon name="edit" /> Edit</a>
|
||||
<button type="button" class="fb-btn fb-btn--ghost fb-btn--sm" onclick={() => copyLink(i.slug)}><Icon name="copy" /> Copy link</button>
|
||||
<a class="fb-btn fb-btn--ghost fb-btn--sm" href="/f/{i.slug}" target="_blank" rel="noopener"><Icon name="external-link" /> Open</a>
|
||||
{@const status = effectiveStatus(i)}
|
||||
<div class="fb-card">
|
||||
<div class="fb-card__head">
|
||||
<a class="fb-card__title" href="/admin/feedback/{i.id}">{i.title}</a>
|
||||
<div class="fb-card__actions">
|
||||
<button
|
||||
type="button"
|
||||
class="fb-btn fb-btn--ghost fb-btn--sm"
|
||||
class="fb-status-pill fb-status-pill--{status}"
|
||||
disabled={rowBusy[i.id]}
|
||||
onclick={() => toggleStatus(i.id, i.status)}
|
||||
onclick={() => toggleStatus(i.id, status)}
|
||||
aria-label={status === 'open' ? 'Open — click to close' : 'Closed — click to reopen'}
|
||||
title={status === 'open' ? 'Click to close' : 'Click to reopen'}
|
||||
>
|
||||
{#if i.status === 'open'}<Icon name="lock" /> Close{:else}<Icon name="unlock" /> Reopen{/if}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="fb-btn fb-btn--danger fb-btn--sm"
|
||||
disabled={rowBusy[i.id]}
|
||||
onclick={() => destroyInstance(i.id, i.title)}
|
||||
>
|
||||
<Icon name="trash" /> Delete
|
||||
<span class="fb-status-pill__dot"></span>{status}
|
||||
</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(i.slug)}>
|
||||
<Icon name="copy" /> Copy link
|
||||
</button>
|
||||
<a class="fb-menu__item" href="/f/{i.slug}" target="_blank" rel="noopener">
|
||||
<Icon name="external-link" /> Open
|
||||
</a>
|
||||
<a class="fb-menu__item" href="/admin/feedback/{i.id}">
|
||||
<Icon name="edit" /> Edit
|
||||
</a>
|
||||
<hr class="fb-menu__divider" />
|
||||
<button
|
||||
type="button"
|
||||
class="fb-menu__item fb-menu__item--danger"
|
||||
disabled={rowBusy[i.id]}
|
||||
onclick={() => destroyInstance(i.id, i.title)}
|
||||
>
|
||||
<Icon name="trash" /> Delete
|
||||
</button>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
</div>
|
||||
<div class="fb-card__meta">{subtitleFor(i)}</div>
|
||||
{#if rowError[i.id]}
|
||||
<div class="fb-banner fb-banner--error" style="margin-top: 0.6rem; margin-bottom: 0;">{rowError[i.id]}</div>
|
||||
<div class="fb-inline-error">{rowError[i.id]}</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</section>
|
||||
</section>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.fb-list-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-3);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.fb-list-head h1 {
|
||||
margin: 0;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -80,12 +80,12 @@
|
||||
</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;">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>
|
||||
<h1>New form</h1>
|
||||
</header>
|
||||
|
||||
<section class="fb-section">
|
||||
@@ -112,17 +112,23 @@
|
||||
></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>
|
||||
<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={chatEnabled} />
|
||||
</label>
|
||||
|
||||
<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--secondary fb-btn--sm" onclick={pasteSample}><Icon name="plus" /> Insert sample</button>
|
||||
<details class="fb-question fb-new-advanced">
|
||||
<summary>Add questions now (advanced)</summary>
|
||||
<p class="fb-question__help" style="margin-top: 0.6rem;">
|
||||
You can also edit questions visually after the form is created.
|
||||
</p>
|
||||
<div class="fb-save-row" style="margin-top: 0.75rem;">
|
||||
<button type="button" class="fb-btn fb-btn--secondary fb-btn--sm" onclick={pasteSample}>
|
||||
<Icon name="plus" /> Insert sample
|
||||
</button>
|
||||
</div>
|
||||
<textarea
|
||||
id="fb-new-form"
|
||||
@@ -130,12 +136,9 @@
|
||||
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;"
|
||||
style="font-family: ui-monospace, 'SF Mono', Menlo, monospace; font-size: 0.85rem; margin-top: 0.6rem;"
|
||||
></textarea>
|
||||
<div class="fb-question__help">
|
||||
Leave empty for chat-only feedback. You can edit questions visually after the form is created.
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
{#if createError}
|
||||
<div class="fb-banner fb-banner--error">{createError}</div>
|
||||
@@ -147,3 +150,20 @@
|
||||
</form>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.fb-new-advanced > summary {
|
||||
cursor: pointer;
|
||||
color: var(--color-text-muted);
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
padding: 0.4rem 0;
|
||||
user-select: none;
|
||||
}
|
||||
.fb-new-advanced > summary:hover {
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
.fb-new-advanced[open] > summary {
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -303,14 +303,14 @@
|
||||
</header>
|
||||
|
||||
{#if isClosed || chatStatus === 'closed'}
|
||||
<div class="fb-banner fb-banner--closed">
|
||||
<div class="fb-closed-line">
|
||||
Diese Feedback-Sitzung ist geschlossen — neue Beiträge sind nicht mehr möglich.
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="fb-section">
|
||||
<div class="fb-name-row">
|
||||
<label for="fb-name">Dein Name (optional):</label>
|
||||
<section class="fb-section">
|
||||
<div class="fb-question">
|
||||
<label class="fb-question__label" for="fb-name">Dein Name (optional)</label>
|
||||
<input
|
||||
id="fb-name"
|
||||
type="text"
|
||||
@@ -321,7 +321,7 @@
|
||||
oninput={saveName}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{#if chatEnabled}
|
||||
<section class="fb-section" aria-label="Live-Chat">
|
||||
@@ -543,5 +543,15 @@
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
<div class="fb-foot">fdbck.msbls.de</div>
|
||||
<div class="fb-foot"><a href="/" class="fb-foot__link">fdbck.msbls.de</a></div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.fb-foot__link {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
.fb-foot__link:hover {
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -39,10 +39,9 @@
|
||||
<meta name="robots" content="noindex,nofollow" />
|
||||
</svelte:head>
|
||||
|
||||
<div class="fb-shell">
|
||||
<div class="fb-shell fb-page-narrow fb-page-center">
|
||||
<header class="fb-header">
|
||||
<h1>Sign in</h1>
|
||||
<p>Admin access only.</p>
|
||||
</header>
|
||||
|
||||
<section class="fb-section">
|
||||
@@ -70,14 +69,14 @@
|
||||
/>
|
||||
</div>
|
||||
|
||||
{#if error}
|
||||
<div class="fb-banner fb-banner--error">{error}</div>
|
||||
{/if}
|
||||
|
||||
<button type="submit" class="fb-btn" disabled={inFlight}>
|
||||
{inFlight ? 'Signing in…' : 'Sign in'}
|
||||
<Icon name="arrow-right" />
|
||||
</button>
|
||||
|
||||
{#if error}
|
||||
<div class="fb-inline-error" role="alert">{error}</div>
|
||||
{/if}
|
||||
</form>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user