rail: bigger card thumbnails + a visible × on every card (cancel / soft-delete)
- Thumbnails grow to ~half the card width and a taller 4:5 crop (44% on mobile) so the render is actually legible in Up next and History. - Every card gets an always-visible × (fast path) beside the ⋯ overflow menu — ONE shared handler, no second mechanism: a queued run cancels immediately (POST /v1/queue/cancel); a finished result is a destructive soft-delete (POST /v1/library/status status=deleted) and asks first with a small inline confirm. History hides soft-deleted results (optimistic + reconciled from the org's deleted set via /v1/library) so the delete persists across reloads.
This commit is contained in:
Vendored
+14
@@ -124,6 +124,20 @@ export const rate = (key: string, patch: { stars?: number; notes?: string; path?
|
||||
body: JSON.stringify({ key, ...patch }),
|
||||
})
|
||||
|
||||
// Soft-delete a finished result — the library status lifecycle ('deleted'), the same
|
||||
// mechanism the assets view uses. Keyed by the run's output path.
|
||||
export const softDelete = (path: string) =>
|
||||
j<{ ok: boolean; path: string; status: string }>(`${BASE}/v1/library/status`, {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ path, status: 'deleted' }),
|
||||
})
|
||||
// The org's soft-deleted asset paths — so History hides results deleted here or in the
|
||||
// assets view, and the delete persists across reloads.
|
||||
export const getDeletedPaths = () =>
|
||||
j<{ assets: { path: string; status?: string }[] }>(`${BASE}/v1/library`)
|
||||
.then(r => (r.assets || []).filter(a => a.status === 'deleted').map(a => a.path))
|
||||
.catch(() => [] as string[])
|
||||
|
||||
// Favorites: the heart toggle, a per-org set keyed by run id. Separate concern from
|
||||
// ratings — a bookmark, not quality feedback.
|
||||
export const getFavorites = () =>
|
||||
|
||||
Vendored
+19
-2
@@ -54,7 +54,7 @@ export interface CardProps {
|
||||
favorite: boolean
|
||||
now: number
|
||||
version?: { pos: number; len: number }
|
||||
onCancel: (run: Run) => void
|
||||
onRemove: (run: Run) => void
|
||||
onAmend: (run: Run, files: File[]) => Promise<void>
|
||||
onRate: (run: Run, patch: { stars?: number; notes?: string; path?: string }) => void
|
||||
onFavorite: (run: Run) => void
|
||||
@@ -80,6 +80,7 @@ export function RunCard(p: CardProps) {
|
||||
const [files, setFiles] = useState<File[]>([])
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [fileOver, setFileOver] = useState(false)
|
||||
const [confirmDel, setConfirmDel] = useState(false)
|
||||
|
||||
const active = isActive(run)
|
||||
const src = runThumb(run)
|
||||
@@ -121,6 +122,12 @@ export function RunCard(p: CardProps) {
|
||||
p.onRate(run, { stars, notes, path: run.output || undefined })
|
||||
setPanel('none')
|
||||
}
|
||||
// The × (and the menu's Delete) share ONE handler: a queued run cancels straight
|
||||
// away; a finished result is a destructive soft-delete, so it asks first.
|
||||
function requestDelete() {
|
||||
if (active) p.onRemove(run)
|
||||
else setConfirmDel(true)
|
||||
}
|
||||
|
||||
return (
|
||||
<article
|
||||
@@ -136,6 +143,16 @@ export function RunCard(p: CardProps) {
|
||||
{fresh && <span className="rc-new">New</span>}
|
||||
</a>
|
||||
|
||||
<button className="rc-x" aria-label={active ? 'cancel' : 'delete'}
|
||||
title={active ? 'Cancel' : 'Delete'} onClick={requestDelete}>×</button>
|
||||
{confirmDel && (
|
||||
<div className="rc-confirm" role="alertdialog" aria-label="confirm delete">
|
||||
<span>Delete result?</span>
|
||||
<button className="danger" onClick={() => { setConfirmDel(false); p.onRemove(run) }}>Delete</button>
|
||||
<button onClick={() => setConfirmDel(false)}>Keep</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="rc-body">
|
||||
<div className="rc-title" title={run.prompt || run.kind}>{run.prompt || `(${run.kind})`}</div>
|
||||
<div className="rc-meta">{meta}</div>
|
||||
@@ -158,7 +175,7 @@ export function RunCard(p: CardProps) {
|
||||
|
||||
{menu && (
|
||||
<div className="rc-menu" role="menu" onMouseLeave={() => setMenu(false)}>
|
||||
{active && <button role="menuitem" onClick={() => { setMenu(false); p.onCancel(run) }}>Delete</button>}
|
||||
<button role="menuitem" onClick={() => { setMenu(false); requestDelete() }}>Delete</button>
|
||||
{active && <button role="menuitem" onClick={() => { setMenu(false); setPanel('refs') }}>Add references</button>}
|
||||
<button role="menuitem" onClick={() => { setMenu(false); setStars(rating?.stars ?? 0); setNotes(rating?.notes ?? ''); setPanel('rate') }}>Rate & note</button>
|
||||
<button role="menuitem" onClick={() => { setMenu(false); p.onFavorite(run) }}>{favorite ? 'Unfavorite' : 'Favorite'}</button>
|
||||
|
||||
Vendored
+22
-6
@@ -2,7 +2,7 @@ import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import {
|
||||
getWorklog, getQueueStatus, getRatings, getFavorites, getStacks, saveStacks,
|
||||
cancelRun, amendRun, uploadRef, rate, setFavorite, saveTemplate, getLineage,
|
||||
thumbURL, fileURL,
|
||||
softDelete, getDeletedPaths, thumbURL, fileURL,
|
||||
type Run, type QueueStatus, type QueueItem, type Rating, type Stack, type Lineage,
|
||||
} from './api'
|
||||
import { RunCard, runThumb, idLabel, relTime, fmtDur } from './card'
|
||||
@@ -68,6 +68,8 @@ export function Rail(props: RailProps) {
|
||||
const [ratings, setRatings] = useState<Record<string, Rating>>({})
|
||||
const [favorites, setFavorites] = useState<Record<string, { ts: number }>>({})
|
||||
const [stacks, setStacks] = useState<Stack[]>([])
|
||||
const [deletedPaths, setDeletedPaths] = useState<Set<string>>(new Set())
|
||||
const [hidden, setHidden] = useState<Set<string>>(new Set())
|
||||
const [query, setQuery] = useState('')
|
||||
|
||||
const [openStack, setOpenStack] = useState<string | null>(null)
|
||||
@@ -85,9 +87,10 @@ export function Rail(props: RailProps) {
|
||||
const loadRatings = useCallback(() => getRatings().then(setRatings), [])
|
||||
const loadFavorites = useCallback(() => getFavorites().then(setFavorites), [])
|
||||
const loadStacks = useCallback(() => getStacks().then(setStacks), [])
|
||||
const loadDeleted = useCallback(() => getDeletedPaths().then(ps => setDeletedPaths(new Set(ps))), [])
|
||||
|
||||
useEffect(() => { pollLive(); const t = setInterval(pollLive, 4000); return () => clearInterval(t) }, [pollLive])
|
||||
useEffect(() => { loadRatings(); loadFavorites(); loadStacks() }, [loadRatings, loadFavorites, loadStacks])
|
||||
useEffect(() => { loadRatings(); loadFavorites(); loadStacks(); loadDeleted() }, [loadRatings, loadFavorites, loadStacks, loadDeleted])
|
||||
useEffect(() => { if (!err) return; const t = setTimeout(() => setErr(''), 4000); return () => clearTimeout(t) }, [err])
|
||||
|
||||
// Active = the runs the server still counts as in-flight (present in queue/status),
|
||||
@@ -95,7 +98,8 @@ export function Rail(props: RailProps) {
|
||||
// linger in Up next forever (matches the queue's own 30-min active window).
|
||||
const active = rows.filter(r => (r.status === 'queued' || r.status === 'running') && (q.items[r.id] || now - r.ts <= 1800))
|
||||
.sort((a, b) => ((q.items[a.id]?.position ?? 999) - (q.items[b.id]?.position ?? 999)) || (a.ts - b.ts))
|
||||
const history = rows.filter(r => r.status === 'done' || r.status === 'failed' || r.status === 'cancelled')
|
||||
const history = rows.filter(r => (r.status === 'done' || r.status === 'failed' || r.status === 'cancelled')
|
||||
&& !hidden.has(r.id) && !(r.output && deletedPaths.has(r.output)))
|
||||
const favRuns = rows.filter(r => favorites[r.id])
|
||||
const nextRun = active[0]
|
||||
const chainMap = chains(rows)
|
||||
@@ -110,8 +114,20 @@ export function Rail(props: RailProps) {
|
||||
setErr('That run changed — refreshed, pick it again.')
|
||||
return true
|
||||
}
|
||||
async function onCancel(run: Run) {
|
||||
try { await cancelRun(run.id) } catch (e) { if (!heal(e as Error)) setErr((e as Error).message) }
|
||||
// The ONE remove handler behind both the card's × and its menu Delete: a queued run
|
||||
// cancels; a finished result soft-deletes its output (the library lifecycle) and is
|
||||
// hidden from History optimistically (reconciled from the server's deleted set).
|
||||
async function onRemove(run: Run) {
|
||||
if (run.status === 'queued' || run.status === 'running') {
|
||||
try { await cancelRun(run.id) } catch (e) { if (!heal(e as Error)) setErr((e as Error).message) }
|
||||
pollLive(); return
|
||||
}
|
||||
setHidden(h => new Set(h).add(run.id))
|
||||
const out = run.output
|
||||
if (out) {
|
||||
try { await softDelete(out); setDeletedPaths(d => new Set(d).add(out)) }
|
||||
catch (e) { if (!heal(e as Error)) setErr((e as Error).message) }
|
||||
}
|
||||
pollLive()
|
||||
}
|
||||
async function onAmend(run: Run, files: File[]) {
|
||||
@@ -192,7 +208,7 @@ export function Rail(props: RailProps) {
|
||||
<RunCard
|
||||
key={run.id} run={run} qitem={q.items[run.id]} rating={ratings[run.id]}
|
||||
favorite={!!favorites[run.id]} now={now} version={chainMap.get(run.id)}
|
||||
onCancel={onCancel} onAmend={onAmend} onRate={onRate} onFavorite={onFavorite}
|
||||
onRemove={onRemove} onAmend={onAmend} onRate={onRate} onFavorite={onFavorite}
|
||||
onAddToStack={r => setStackPick(r)} onOpenVersions={onOpenVersions}
|
||||
onUseRef={r => props.onUseRef(idLabel(r))} dnd={dndFor(run)}
|
||||
/>
|
||||
|
||||
Vendored
+8
-2
@@ -116,7 +116,7 @@ button { font: inherit; color: inherit; }
|
||||
.rc:hover { border-color: var(--dim); }
|
||||
.rc.stack-intent { border-color: var(--halo); box-shadow: 0 0 0 3px color-mix(in srgb, var(--halo) 35%, transparent); }
|
||||
.rc.file-over { border-color: var(--brand); border-style: dashed; }
|
||||
.rc-thumb { position: relative; flex: 0 0 40%; max-width: 40%; border-radius: 8px; overflow: hidden; background: var(--panel2); border: 1px solid var(--line); display: block; aspect-ratio: 1 / 1; text-decoration: none; }
|
||||
.rc-thumb { position: relative; flex: 0 0 48%; max-width: 48%; border-radius: 8px; overflow: hidden; background: var(--panel2); border: 1px solid var(--line); display: block; aspect-ratio: 4 / 5; text-decoration: none; }
|
||||
.rc-thumb img { width: 100%; height: 100%; object-fit: cover; display: block; }
|
||||
.rc-ph { display: grid; place-items: center; width: 100%; height: 100%; color: var(--dim); font-size: 12px; }
|
||||
.rc-badge { position: absolute; left: 5px; bottom: 5px; font-size: 11px; font-weight: 600; padding: 1px 6px; border-radius: 6px; background: color-mix(in srgb, #000 62%, transparent); color: #fff; }
|
||||
@@ -135,6 +135,12 @@ button { font: inherit; color: inherit; }
|
||||
.rc-fav { color: var(--bad); }
|
||||
.rc-more { position: absolute; right: 4px; top: 4px; }
|
||||
.rc-more:hover { background: var(--panel2); color: var(--text); }
|
||||
/* Always-visible fast-path delete/cancel (× top-left of the thumb) */
|
||||
.rc-x { position: absolute; left: 6px; top: 6px; z-index: 4; width: 22px; height: 22px; border-radius: 999px; border: 0; background: color-mix(in srgb, #000 55%, transparent); color: #fff; font-size: 16px; line-height: 1; cursor: pointer; display: grid; place-items: center; opacity: .9; }
|
||||
.rc-x:hover { background: var(--bad); opacity: 1; }
|
||||
.rc-confirm { position: absolute; left: 6px; top: 6px; z-index: 7; display: flex; align-items: center; gap: 6px; background: var(--panel); border: 1px solid var(--line); border-radius: 9px; padding: 5px 7px; box-shadow: 0 6px 20px rgba(0,0,0,.28); font-size: 12px; }
|
||||
.rc-confirm button { border: 1px solid var(--line); background: var(--panel2); color: var(--text); border-radius: 6px; padding: 3px 9px; font-size: 12px; cursor: pointer; }
|
||||
.rc-confirm .danger { background: var(--bad); color: #fff; border-color: transparent; }
|
||||
|
||||
.rc-menu { position: absolute; z-index: 20; right: 6px; top: 30px; background: var(--panel); border: 1px solid var(--line); border-radius: 10px; box-shadow: 0 8px 28px rgba(0,0,0,.22); padding: 5px; display: flex; flex-direction: column; min-width: 168px; }
|
||||
.rc-menu > button, .rc-menu > a { text-align: left; background: transparent; border: 0; border-radius: 7px; padding: 8px 10px; cursor: pointer; color: var(--text); text-decoration: none; font-size: 14px; }
|
||||
@@ -221,7 +227,7 @@ button { font: inherit; color: inherit; }
|
||||
.rail-main, .sections, .sec, .sec-body, .stacks-view { overflow: visible; min-height: 0; }
|
||||
.sec, .sec.col { flex: 0 0 auto !important; }
|
||||
.chips { position: sticky; top: 0; z-index: 5; background: var(--bg); }
|
||||
.rc-thumb { flex-basis: 34%; max-width: 34%; }
|
||||
.rc-thumb { flex-basis: 44%; max-width: 44%; }
|
||||
.stacks-view { grid-template-columns: repeat(2, 1fr); }
|
||||
}
|
||||
@media (max-width: 420px) {
|
||||
|
||||
Reference in New Issue
Block a user