feat(frontend): review-status filter, badge, and review toggles
deploy / deploy (push) Successful in 1m28s

FilterBar gains an Any/Needs review/Reviewed segment (r=1/r=0 token);
FileCard shows a "needs review" dot; FileViewer gets a header toggle that
propagates back to the grid; SelectionBar gains a bulk "Mark reviewed"
action. Adds a --color-success theme token.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-15 21:17:11 +03:00
parent 5bb53d7f9d
commit a864ca4f7b
7 changed files with 161 additions and 3 deletions
@@ -132,6 +132,9 @@
<div class="placeholder loading" aria-label="Loading"></div>
{/if}
<div class="overlay"></div>
{#if file.needs_review}
<div class="review-dot" title="Needs review" aria-label="Needs review"></div>
{/if}
{#if selected}
<div class="check" aria-hidden="true">
<svg width="18" height="18" viewBox="0 0 18 18" fill="none">
@@ -236,6 +239,19 @@
pointer-events: none;
}
/* "Needs review" marker — top-left so it never overlaps the selection check. */
.review-dot {
position: absolute;
top: 6px;
left: 6px;
width: 9px;
height: 9px;
border-radius: 50%;
background-color: var(--color-warning);
box-shadow: 0 0 0 1.5px rgba(0, 0, 0, 0.45);
pointer-events: none;
}
@keyframes shimmer {
0% {
background-position: 200% 0;
@@ -17,9 +17,12 @@
onNavigate: (id: string) => void;
/** Close the viewer. */
onClose: () => void;
/** Notify the parent when this file's review status is toggled here. */
onReviewChange?: (id: string, needsReview: boolean) => void;
}
let { fileId, prevId = null, nextId = null, onNavigate, onClose }: Props = $props();
let { fileId, prevId = null, nextId = null, onNavigate, onClose, onReviewChange }: Props =
$props();
let file = $state<File | null>(null);
let fileTags = $state<Tag[]>([]);
@@ -187,6 +190,20 @@
fileTags = fileTags.filter((t) => t.id !== tagId);
}
// ---- Review status ----
async function toggleReview() {
const id = file?.id;
if (!id) return;
const target = !file!.needs_review;
try {
await api.post('/files/bulk/review', { file_ids: [id], needs_review: target });
file = { ...file!, needs_review: target };
onReviewChange?.(id, target);
} catch {
// best-effort; leave the displayed state unchanged on failure
}
}
// ---- Save ----
async function save() {
if (!file || saving) return;
@@ -301,6 +318,24 @@
</button>
<span class="filename">{file?.original_name ?? ''}</span>
{#if file}
<button
class="review-btn"
class:needs={file.needs_review}
onclick={toggleReview}
aria-label={file.needs_review ? 'Mark as reviewed' : 'Mark as needs review'}
title={file.needs_review ? 'Tagging not done — mark reviewed' : 'Reviewed — mark as needs review'}
>
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" aria-hidden="true">
<circle cx="10" cy="10" r="7.5" stroke="currentColor" stroke-width="1.6" />
<path
d="M6.5 10l2.2 2.2L13.5 7.5"
stroke="currentColor"
stroke-width="1.6"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
</button>
<button
class="pool-btn"
onclick={() => (poolPickerOpen = true)}
@@ -522,8 +557,32 @@
white-space: nowrap;
}
.pool-btn {
/* First of the trailing header buttons carries the auto margin that pushes the
review + pool group to the right edge. */
.review-btn {
margin-left: auto;
display: flex;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
border-radius: 8px;
border: none;
background: none;
color: var(--color-success); /* reviewed: solid green check */
cursor: pointer;
flex-shrink: 0;
}
.review-btn.needs {
color: var(--color-text-muted); /* not yet reviewed: dim check */
}
.review-btn:hover {
background-color: color-mix(in srgb, var(--color-accent) 15%, transparent);
}
.pool-btn {
display: flex;
align-items: center;
justify-content: center;
@@ -43,6 +43,14 @@
tokens = tokens.filter((_, idx) => idx !== i);
}
// Review status is a single, mutually-exclusive r=1 / r=0 token; null = "any".
let reviewToken = $derived(tokens.find((t) => t === 'r=1' || t === 'r=0') ?? null);
function setReview(value: 'r=1' | 'r=0' | null) {
const rest = tokens.filter((t) => t !== 'r=1' && t !== 'r=0');
tokens = value ? [...rest, value] : rest;
}
function apply() {
onApply(buildDslFilter(tokens));
}
@@ -189,6 +197,17 @@
{/each}
</div>
<!-- Review status (mutually-exclusive r=1 / r=0) -->
<div class="review-seg" role="group" aria-label="Review status">
<button class="seg" class:on={reviewToken === null} onclick={() => setReview(null)}>Any</button>
<button class="seg" class:on={reviewToken === 'r=1'} onclick={() => setReview('r=1')}>
Needs review
</button>
<button class="seg" class:on={reviewToken === 'r=0'} onclick={() => setReview('r=0')}>
Reviewed
</button>
</div>
<!-- Tag search -->
<input
class="search"
@@ -262,6 +281,37 @@
gap: 4px;
}
.review-seg {
display: flex;
gap: 2px;
padding: 2px;
border-radius: 7px;
background-color: var(--color-bg-elevated);
align-self: flex-start;
}
.seg {
height: 24px;
padding: 0 10px;
border: none;
border-radius: 5px;
background: none;
color: var(--color-text-muted);
font-family: inherit;
font-size: 0.78rem;
font-weight: 600;
cursor: pointer;
}
.seg:hover {
color: var(--color-text-primary);
}
.seg.on {
background-color: var(--color-accent);
color: var(--color-bg-primary);
}
.token {
display: inline-flex;
align-items: center;