Feedback inbox: read/unread + delete
Make the admin Feedback section a real inbox.
* DB: feedback.read_at column (schema + idempotent migration).
* API: feedback list returns read_at; POST /api/admin/feedback/{id}/read
{read} toggles it; DELETE /api/admin/feedback/{id} removes a message
(both admin-gated). admin_stats gains feedback_unread; the Attention strip
and the tab badge now count UNREAD, not total.
* Frontend: unread messages are highlighted with an accent rail + dot; an
Unread filter joins the category chips; each message has Mark read/unread
and Delete (confirm), with optimistic updates that revert on failure.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -2,7 +2,7 @@
|
||||
import { onMount } from 'svelte';
|
||||
import { goto } from '$app/navigation';
|
||||
import { page } from '$app/stores';
|
||||
import { getJSON } from '$lib/api.js';
|
||||
import { getJSON, postJSON, delJSON } from '$lib/api.js';
|
||||
import { auth, refresh } from '$lib/auth.svelte.js';
|
||||
|
||||
let stats = $state(null);
|
||||
@@ -62,10 +62,37 @@
|
||||
: sources
|
||||
);
|
||||
|
||||
// Feedback inbox filter.
|
||||
let fbCat = $state('all');
|
||||
let shownFeedback = $derived(fbCat === 'all' ? feedback : feedback.filter((f) => f.category === fbCat));
|
||||
// Feedback inbox: filter + read/unread + delete.
|
||||
let fbCat = $state('all'); // 'all' | 'unread' | a category
|
||||
let shownFeedback = $derived(
|
||||
fbCat === 'all' ? feedback
|
||||
: fbCat === 'unread' ? feedback.filter((f) => !f.read_at)
|
||||
: feedback.filter((f) => f.category === fbCat)
|
||||
);
|
||||
let fbCats = $derived([...new Set(feedback.map((f) => f.category))]);
|
||||
let unreadCount = $derived(feedback.filter((f) => !f.read_at).length);
|
||||
|
||||
async function markRead(f, read) {
|
||||
const prev = f.read_at;
|
||||
f.read_at = read ? new Date().toISOString().slice(0, 19).replace('T', ' ') : null; // optimistic
|
||||
feedback = [...feedback];
|
||||
try {
|
||||
await postJSON(`/api/admin/feedback/${f.id}/read`, { read });
|
||||
} catch {
|
||||
f.read_at = prev; // revert on failure
|
||||
feedback = [...feedback];
|
||||
}
|
||||
}
|
||||
async function removeFeedback(f) {
|
||||
if (!confirm('Delete this feedback message? This cannot be undone.')) return;
|
||||
const snapshot = feedback;
|
||||
feedback = feedback.filter((x) => x.id !== f.id); // optimistic
|
||||
try {
|
||||
await delJSON(`/api/admin/feedback/${f.id}`);
|
||||
} catch {
|
||||
feedback = snapshot; // revert on failure
|
||||
}
|
||||
}
|
||||
|
||||
const SHARE_LABEL = {
|
||||
share_ub: 'Copied UB link',
|
||||
@@ -95,7 +122,7 @@
|
||||
<nav class="tabs" aria-label="Dashboard sections">
|
||||
{#each TABS as t (t.key)}
|
||||
<a href={'?section=' + t.key} class:active={section === t.key}>
|
||||
{t.label}{#if t.key === 'feedback' && feedback.length}<span class="badge">{feedback.length}</span>{/if}
|
||||
{t.label}{#if t.key === 'feedback' && unreadCount}<span class="badge">{unreadCount}</span>{/if}
|
||||
</a>
|
||||
{/each}
|
||||
</nav>
|
||||
@@ -294,26 +321,34 @@
|
||||
</section>
|
||||
|
||||
{:else if section === 'feedback'}
|
||||
<h2>Feedback {#if feedback.length}<span class="count">({feedback.length})</span>{/if}</h2>
|
||||
<h2>Feedback {#if feedback.length}<span class="count">({unreadCount} unread / {feedback.length})</span>{/if}</h2>
|
||||
{#if feedback.length}
|
||||
<div class="filterchips">
|
||||
<button class="chip" class:on={fbCat === 'all'} onclick={() => (fbCat = 'all')}>All</button>
|
||||
<button class="chip" class:on={fbCat === 'unread'} onclick={() => (fbCat = 'unread')}>Unread{#if unreadCount} ({unreadCount}){/if}</button>
|
||||
{#each fbCats as cat (cat)}
|
||||
<button class="chip" class:on={fbCat === cat} onclick={() => (fbCat = cat)}>{cat}</button>
|
||||
{/each}
|
||||
</div>
|
||||
<ul class="fb">
|
||||
{#each shownFeedback as f (f.id)}
|
||||
<li>
|
||||
<div class="fhead">
|
||||
<span class="cat">{f.category}</span>
|
||||
<span class="when">{fdate(f.created_at)}</span>
|
||||
{#if f.contact_email}<a class="reply" href={'mailto:' + f.contact_email}>Reply ↩</a>{:else}<span class="anon">anonymous</span>{/if}
|
||||
</div>
|
||||
<p class="msg">{f.message}</p>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{#if shownFeedback.length}
|
||||
<ul class="fb">
|
||||
{#each shownFeedback as f (f.id)}
|
||||
<li class:unread={!f.read_at}>
|
||||
<div class="fhead">
|
||||
{#if !f.read_at}<span class="dot" title="Unread"></span>{/if}
|
||||
<span class="cat">{f.category}</span>
|
||||
<span class="when">{fdate(f.created_at)}</span>
|
||||
{#if f.contact_email}<a class="reply" href={'mailto:' + f.contact_email}>Reply ↩</a>{:else}<span class="anon">anonymous</span>{/if}
|
||||
</div>
|
||||
<p class="msg">{f.message}</p>
|
||||
<div class="factions">
|
||||
<button class="act" onclick={() => markRead(f, !f.read_at)}>{f.read_at ? 'Mark unread' : 'Mark read'}</button>
|
||||
<button class="act del" onclick={() => removeFeedback(f)}>Delete</button>
|
||||
</div>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{:else}<p class="muted">Nothing in this filter.</p>{/if}
|
||||
{:else}<p class="muted">No feedback yet.</p>{/if}
|
||||
{/if}
|
||||
{/if}
|
||||
@@ -419,10 +454,19 @@
|
||||
.count { color: var(--muted); font-weight: 400; font-size: 0.9rem; }
|
||||
ul.fb { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 10px; }
|
||||
ul.fb li { background: var(--surface); border: 1px solid var(--line); border-radius: 12px; padding: 12px 14px; }
|
||||
ul.fb li.unread { border-color: var(--accent); box-shadow: inset 3px 0 0 var(--accent); }
|
||||
.fhead { display: flex; align-items: center; gap: 10px; margin-bottom: 5px; font-size: 0.78rem; }
|
||||
.fhead .dot { width: 7px; height: 7px; border-radius: 50%; background: var(--accent); flex-shrink: 0; }
|
||||
.fhead .cat { background: var(--accent-soft); color: var(--accent-deep); border-radius: 999px; padding: 2px 9px; text-transform: capitalize; }
|
||||
.fhead .when { color: var(--muted); }
|
||||
.fhead .reply { color: var(--accent-deep); margin-left: auto; }
|
||||
.fhead .anon { color: var(--muted); margin-left: auto; font-style: italic; }
|
||||
.msg { margin: 0; color: var(--ink); font-size: 0.92rem; white-space: pre-wrap; }
|
||||
.factions { display: flex; gap: 14px; margin-top: 9px; }
|
||||
.factions .act {
|
||||
background: none; border: none; padding: 0; cursor: pointer;
|
||||
color: var(--muted); font-size: 0.76rem; border-bottom: 1px dotted var(--line);
|
||||
}
|
||||
.factions .act:hover { color: var(--accent-deep); border-bottom-color: var(--accent); }
|
||||
.factions .act.del:hover { color: #9a3b3b; border-bottom-color: #9a3b3b; }
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user