Admin: inline flag-reason popover (replaces prompt())

Per Codex's polish note: flagging a source now opens a small inline popover for
the optional reason (consistent with the calm admin UI) instead of a native
prompt(). Clearing a flag stays immediate. Backdrop/Escape/Cancel close it;
Enter confirms. Optimistic with revert-on-failure, like the other actions.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
jay
2026-06-08 15:16:46 -04:00
parent 245b415163
commit fd4cd2ac9c
+63 -13
View File
@@ -84,18 +84,24 @@
try { await postJSON(`/api/admin/sources/${s.id}/active`, { active: next }); }
catch { s.active = next ? 0 : 1; }
}
async function toggleReview(s) {
if (s.review_flag) {
const prevR = s.review_reason;
s.review_flag = 0; s.review_reason = null;
try { await postJSON(`/api/admin/sources/${s.id}/review`, { flag: false }); }
catch { s.review_flag = 1; s.review_reason = prevR; }
} else {
const reason = (prompt('Flag this source for review — optional note:') || '').trim();
s.review_flag = 1; s.review_reason = reason || null;
try { await postJSON(`/api/admin/sources/${s.id}/review`, { flag: true, reason: reason || null }); }
catch { s.review_flag = 0; s.review_reason = null; }
}
// Flagging opens a small inline popover for the reason; clearing is immediate.
let flagging = $state(null); // the source being flagged
let flagReason = $state('');
function openFlag(s) { flagging = s; flagReason = ''; }
function cancelFlag() { flagging = null; flagReason = ''; }
async function confirmFlag() {
const s = flagging;
const reason = flagReason.trim() || null;
s.review_flag = 1; s.review_reason = reason; // optimistic
flagging = null; flagReason = '';
try { await postJSON(`/api/admin/sources/${s.id}/review`, { flag: true, reason }); }
catch { s.review_flag = 0; s.review_reason = null; }
}
async function clearReview(s) {
const prevR = s.review_reason;
s.review_flag = 0; s.review_reason = null; // optimistic
try { await postJSON(`/api/admin/sources/${s.id}/review`, { flag: false }); }
catch { s.review_flag = 1; s.review_reason = prevR; }
}
// Feedback inbox: filter + read/unread + delete.
@@ -322,7 +328,7 @@
</td>
<td class="rowactions">
<button class="act" onclick={() => toggleActive(s)}>{s.active ? 'Pause' : 'Resume'}</button>
<button class="act" onclick={() => toggleReview(s)}>{s.review_flag ? 'Clear' : 'Flag'}</button>
<button class="act" onclick={() => (s.review_flag ? clearReview(s) : openFlag(s))}>{s.review_flag ? 'Clear' : 'Flag'}</button>
</td>
</tr>
{/each}
@@ -465,6 +471,27 @@
{/if}
</main>
{#if flagging}
<!-- Flag-reason popover: backdrop closes; Escape/Cancel too (keyboard path). -->
<!-- svelte-ignore a11y_click_events_have_key_events -->
<div class="modal-overlay" role="presentation" onclick={(e) => e.target === e.currentTarget && cancelFlag()}>
<div class="modal" role="dialog" aria-modal="true" aria-label="Flag source for review" tabindex="-1">
<h3>Flag “{flagging.name}” for review</h3>
<p class="msub">An optional note on why — it shows under the source in the table.</p>
<input
type="text"
bind:value={flagReason}
placeholder="e.g. acceptance dropping, off-topic lately"
onkeydown={(e) => (e.key === 'Enter' ? confirmFlag() : e.key === 'Escape' ? cancelFlag() : null)}
/>
<div class="mbtns">
<button class="msend" onclick={confirmFlag}>Flag for review</button>
<button class="act" onclick={cancelFlag}>Cancel</button>
</div>
</div>
</div>
{/if}
<style>
header.bar { background: var(--surface); border-bottom: 1px solid var(--line); position: sticky; top: 0; z-index: 20; }
.inner { display: flex; align-items: center; justify-content: space-between; height: 64px; }
@@ -614,6 +641,29 @@
.csend:hover { background: var(--accent-deep); }
.csend:disabled { opacity: 0.55; cursor: default; }
.noreply { color: var(--muted); font-size: 0.76rem; font-style: italic; }
/* Flag-reason popover */
.modal-overlay {
position: fixed; inset: 0; background: rgba(10, 22, 38, 0.32);
display: flex; align-items: center; justify-content: center; padding: 20px; z-index: 60;
}
.modal {
background: var(--surface); border: 1px solid var(--line); border-radius: 16px;
box-shadow: 0 12px 34px rgba(40, 38, 28, 0.16); width: 100%; max-width: 420px; padding: 22px 24px;
}
.modal h3 { margin: 0 0 6px; font-size: 1.2rem; }
.modal .msub { margin: 0 0 14px; color: var(--muted); font-size: 0.85rem; }
.modal input {
width: 100%; box-sizing: border-box; font: inherit; font-size: 0.92rem;
padding: 10px 12px; border: 1px solid var(--line); border-radius: 10px; background: var(--bg); color: var(--ink);
}
.modal input:focus { outline: none; border-color: var(--accent); }
.mbtns { display: flex; align-items: center; gap: 12px; margin-top: 14px; }
.msend {
font: inherit; font-size: 0.85rem; font-weight: 600; background: var(--accent); color: #fff;
border: none; border-radius: 999px; padding: 8px 18px; cursor: pointer;
}
.msend:hover { background: var(--accent-deep); }
.factions { display: flex; gap: 14px; margin-top: 9px; }
.factions .act {
background: none; border: none; padding: 0; cursor: pointer;