feat(frontend): review-status filter, badge, and review toggles
deploy / deploy (push) Successful in 1m28s
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:
@@ -11,6 +11,7 @@
|
|||||||
--color-danger: #db6060;
|
--color-danger: #db6060;
|
||||||
--color-info: #4dc7ed;
|
--color-info: #4dc7ed;
|
||||||
--color-warning: #f5e872;
|
--color-warning: #f5e872;
|
||||||
|
--color-success: #5fb87a;
|
||||||
--color-tag-default: #444455;
|
--color-tag-default: #444455;
|
||||||
--color-nav-bg: rgba(0, 0, 0, 0.45);
|
--color-nav-bg: rgba(0, 0, 0, 0.45);
|
||||||
--color-nav-active: rgba(52, 50, 73, 0.72);
|
--color-nav-active: rgba(52, 50, 73, 0.72);
|
||||||
|
|||||||
@@ -132,6 +132,9 @@
|
|||||||
<div class="placeholder loading" aria-label="Loading"></div>
|
<div class="placeholder loading" aria-label="Loading"></div>
|
||||||
{/if}
|
{/if}
|
||||||
<div class="overlay"></div>
|
<div class="overlay"></div>
|
||||||
|
{#if file.needs_review}
|
||||||
|
<div class="review-dot" title="Needs review" aria-label="Needs review"></div>
|
||||||
|
{/if}
|
||||||
{#if selected}
|
{#if selected}
|
||||||
<div class="check" aria-hidden="true">
|
<div class="check" aria-hidden="true">
|
||||||
<svg width="18" height="18" viewBox="0 0 18 18" fill="none">
|
<svg width="18" height="18" viewBox="0 0 18 18" fill="none">
|
||||||
@@ -236,6 +239,19 @@
|
|||||||
pointer-events: none;
|
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 {
|
@keyframes shimmer {
|
||||||
0% {
|
0% {
|
||||||
background-position: 200% 0;
|
background-position: 200% 0;
|
||||||
|
|||||||
@@ -17,9 +17,12 @@
|
|||||||
onNavigate: (id: string) => void;
|
onNavigate: (id: string) => void;
|
||||||
/** Close the viewer. */
|
/** Close the viewer. */
|
||||||
onClose: () => void;
|
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 file = $state<File | null>(null);
|
||||||
let fileTags = $state<Tag[]>([]);
|
let fileTags = $state<Tag[]>([]);
|
||||||
@@ -187,6 +190,20 @@
|
|||||||
fileTags = fileTags.filter((t) => t.id !== tagId);
|
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 ----
|
// ---- Save ----
|
||||||
async function save() {
|
async function save() {
|
||||||
if (!file || saving) return;
|
if (!file || saving) return;
|
||||||
@@ -301,6 +318,24 @@
|
|||||||
</button>
|
</button>
|
||||||
<span class="filename">{file?.original_name ?? ''}</span>
|
<span class="filename">{file?.original_name ?? ''}</span>
|
||||||
{#if file}
|
{#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
|
<button
|
||||||
class="pool-btn"
|
class="pool-btn"
|
||||||
onclick={() => (poolPickerOpen = true)}
|
onclick={() => (poolPickerOpen = true)}
|
||||||
@@ -522,8 +557,32 @@
|
|||||||
white-space: nowrap;
|
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;
|
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;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
|
|||||||
@@ -43,6 +43,14 @@
|
|||||||
tokens = tokens.filter((_, idx) => idx !== i);
|
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() {
|
function apply() {
|
||||||
onApply(buildDslFilter(tokens));
|
onApply(buildDslFilter(tokens));
|
||||||
}
|
}
|
||||||
@@ -189,6 +197,17 @@
|
|||||||
{/each}
|
{/each}
|
||||||
</div>
|
</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 -->
|
<!-- Tag search -->
|
||||||
<input
|
<input
|
||||||
class="search"
|
class="search"
|
||||||
@@ -262,6 +281,37 @@
|
|||||||
gap: 4px;
|
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 {
|
.token {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
@@ -4,10 +4,11 @@
|
|||||||
interface Props {
|
interface Props {
|
||||||
onEditTags: () => void;
|
onEditTags: () => void;
|
||||||
onAddToPool: () => void;
|
onAddToPool: () => void;
|
||||||
|
onMarkReviewed: () => void;
|
||||||
onDelete: () => void;
|
onDelete: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
let { onEditTags, onAddToPool, onDelete }: Props = $props();
|
let { onEditTags, onAddToPool, onMarkReviewed, onDelete }: Props = $props();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="bar" role="toolbar" aria-label="Selection actions">
|
<div class="bar" role="toolbar" aria-label="Selection actions">
|
||||||
@@ -37,6 +38,7 @@
|
|||||||
|
|
||||||
<button class="action edit-tags" onclick={onEditTags}>Edit tags</button>
|
<button class="action edit-tags" onclick={onEditTags}>Edit tags</button>
|
||||||
<button class="action add-pool" onclick={onAddToPool}>Add to pool</button>
|
<button class="action add-pool" onclick={onAddToPool}>Add to pool</button>
|
||||||
|
<button class="action mark-reviewed" onclick={onMarkReviewed}>Mark reviewed</button>
|
||||||
<button class="action delete" onclick={onDelete}>Delete</button>
|
<button class="action delete" onclick={onDelete}>Delete</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -140,6 +142,14 @@
|
|||||||
background-color: color-mix(in srgb, var(--color-warning) 15%, transparent);
|
background-color: color-mix(in srgb, var(--color-warning) 15%, transparent);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.mark-reviewed {
|
||||||
|
color: var(--color-success);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mark-reviewed:hover {
|
||||||
|
background-color: color-mix(in srgb, var(--color-success) 15%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
.delete {
|
.delete {
|
||||||
color: var(--color-danger);
|
color: var(--color-danger);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
* t=<uuid> — has tag
|
* t=<uuid> — has tag
|
||||||
* m=<mime> — exact MIME
|
* m=<mime> — exact MIME
|
||||||
* m~<pattern> — MIME LIKE pattern
|
* m~<pattern> — MIME LIKE pattern
|
||||||
|
* r=1 / r=0 — needs review / review done
|
||||||
* ( ) & | ! — grouping / boolean operators
|
* ( ) & | ! — grouping / boolean operators
|
||||||
*
|
*
|
||||||
* Example: {t=uuid1,&,!,t=uuid2} → has tag1 AND NOT tag2
|
* Example: {t=uuid1,&,!,t=uuid2} → has tag1 AND NOT tag2
|
||||||
@@ -31,6 +32,8 @@ export function tokenLabel(token: string, tagNames: Map<string, string>): string
|
|||||||
if (token === '!') return 'NOT';
|
if (token === '!') return 'NOT';
|
||||||
if (token === '(') return '(';
|
if (token === '(') return '(';
|
||||||
if (token === ')') return ')';
|
if (token === ')') return ')';
|
||||||
|
if (token === 'r=1') return 'Needs review';
|
||||||
|
if (token === 'r=0') return 'Reviewed';
|
||||||
if (token.startsWith('t=')) {
|
if (token.startsWith('t=')) {
|
||||||
const id = token.slice(2);
|
const id = token.slice(2);
|
||||||
return tagNames.get(id) ?? token;
|
return tagNames.get(id) ?? token;
|
||||||
|
|||||||
@@ -114,6 +114,22 @@
|
|||||||
void tick().then(() => document.querySelector<HTMLInputElement>('.tag-sheet input')?.focus());
|
void tick().then(() => document.querySelector<HTMLInputElement>('.tag-sheet input')?.focus());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Mark the current selection as review-done (tagging finished). Best-effort
|
||||||
|
// optimistic update of the local list so the "needs review" badges clear.
|
||||||
|
async function markSelectionReviewed() {
|
||||||
|
const ids = [...$selectionStore.ids];
|
||||||
|
if (ids.length === 0) return;
|
||||||
|
selectionStore.exit();
|
||||||
|
try {
|
||||||
|
await api.post('/files/bulk/review', { file_ids: ids, needs_review: false });
|
||||||
|
files = files.map((f) =>
|
||||||
|
ids.includes(f.id ?? '') ? { ...f, needs_review: false } : f
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
// ignore — list already reflects the intended state optimistically
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function openFilterAndFocus() {
|
function openFilterAndFocus() {
|
||||||
filterOpen = true;
|
filterOpen = true;
|
||||||
void tick().then(() => document.querySelector<HTMLInputElement>('.bar .search')?.focus());
|
void tick().then(() => document.querySelector<HTMLInputElement>('.bar .search')?.focus());
|
||||||
@@ -835,6 +851,8 @@
|
|||||||
nextId={viewerNextId}
|
nextId={viewerNextId}
|
||||||
onNavigate={pageTo}
|
onNavigate={pageTo}
|
||||||
onClose={closeViewer}
|
onClose={closeViewer}
|
||||||
|
onReviewChange={(id, nr) =>
|
||||||
|
(files = files.map((f) => (f.id === id ? { ...f, needs_review: nr } : f)))}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -843,6 +861,7 @@
|
|||||||
<SelectionBar
|
<SelectionBar
|
||||||
onEditTags={openTagEditor}
|
onEditTags={openTagEditor}
|
||||||
onAddToPool={openPoolPicker}
|
onAddToPool={openPoolPicker}
|
||||||
|
onMarkReviewed={markSelectionReviewed}
|
||||||
onDelete={() => (confirmDeleteFiles = true)}
|
onDelete={() => (confirmDeleteFiles = true)}
|
||||||
/>
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
Reference in New Issue
Block a user