feat: make ignoring an event undoable
Ignoring made an event vanish with no way back except knowing the reveal toggle existed — and the row you would click to undo is the row that just disappeared. Now: an undo appears the moment you ignore something, and revealed ignored rows are labelled and carry a restore control where the done checkbox normally sits, which is the most direct place for it. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 5
parent
5ea5d8292f
commit
6fad016f66
+27
-1
@@ -8,6 +8,7 @@ import { Timeline } from "./components/Timeline.tsx";
|
|||||||
import { Welcome } from "./components/Welcome.tsx";
|
import { Welcome } from "./components/Welcome.tsx";
|
||||||
import { Colophon } from "./components/Colophon.tsx";
|
import { Colophon } from "./components/Colophon.tsx";
|
||||||
import { Legend } from "./components/Legend.tsx";
|
import { Legend } from "./components/Legend.tsx";
|
||||||
|
import { Toast } from "./components/Toast.tsx";
|
||||||
import { KEYS } from "./state/storage.ts";
|
import { KEYS } from "./state/storage.ts";
|
||||||
import { useMarkSet } from "./state/useMarkSet.ts";
|
import { useMarkSet } from "./state/useMarkSet.ts";
|
||||||
import { usePrefs } from "./state/usePrefs.ts";
|
import { usePrefs } from "./state/usePrefs.ts";
|
||||||
@@ -52,11 +53,20 @@ export function App() {
|
|||||||
const [state, setState] = useState<FeedState>({ status: "loading" });
|
const [state, setState] = useState<FeedState>({ status: "loading" });
|
||||||
const [view, setView] = useState<View>("soon");
|
const [view, setView] = useState<View>("soon");
|
||||||
const [openId, setOpenId] = useState<string | null>(null);
|
const [openId, setOpenId] = useState<string | null>(null);
|
||||||
|
// The event most recently ignored, so it can be put back without hunting for
|
||||||
|
// a row that just disappeared.
|
||||||
|
const [lastIgnored, setLastIgnored] = useState<{ id: string; title: string } | null>(null);
|
||||||
const now = useNow();
|
const now = useNow();
|
||||||
const online = useOnline();
|
const online = useOnline();
|
||||||
const { prefs, update, toggleGame } = usePrefs();
|
const { prefs, update, toggleGame } = usePrefs();
|
||||||
const completed = useMarkSet(KEYS.completions);
|
const completed = useMarkSet(KEYS.completions);
|
||||||
const ignored = useMarkSet(KEYS.ignored);
|
const ignored = useMarkSet(KEYS.ignored);
|
||||||
|
|
||||||
|
const toggleIgnored = (id: string, title: string) => {
|
||||||
|
const wasIgnored = ignored.marks[id] !== undefined;
|
||||||
|
ignored.toggle(id);
|
||||||
|
setLastIgnored(wasIgnored ? null : { id, title });
|
||||||
|
};
|
||||||
const completions = completed.marks;
|
const completions = completed.marks;
|
||||||
const toggle = completed.toggle;
|
const toggle = completed.toggle;
|
||||||
|
|
||||||
@@ -212,7 +222,9 @@ export function App() {
|
|||||||
key={row.event.id}
|
key={row.event.id}
|
||||||
row={row}
|
row={row}
|
||||||
completed={completions[row.event.id] !== undefined}
|
completed={completions[row.event.id] !== undefined}
|
||||||
|
ignored={ignored.marks[row.event.id] !== undefined}
|
||||||
onToggle={toggle}
|
onToggle={toggle}
|
||||||
|
onRestore={(id) => ignored.toggle(id)}
|
||||||
onOpen={setOpenId}
|
onOpen={setOpenId}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
@@ -226,7 +238,9 @@ export function App() {
|
|||||||
key={row.event.id}
|
key={row.event.id}
|
||||||
row={row}
|
row={row}
|
||||||
completed={completions[row.event.id] !== undefined}
|
completed={completions[row.event.id] !== undefined}
|
||||||
|
ignored={ignored.marks[row.event.id] !== undefined}
|
||||||
onToggle={toggle}
|
onToggle={toggle}
|
||||||
|
onRestore={(id) => ignored.toggle(id)}
|
||||||
onOpen={setOpenId}
|
onOpen={setOpenId}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
@@ -273,12 +287,24 @@ export function App() {
|
|||||||
|
|
||||||
<Colophon sources={state.feed.sources} staleCount={staleSources.length} />
|
<Colophon sources={state.feed.sources} staleCount={staleSources.length} />
|
||||||
|
|
||||||
|
{lastIgnored !== null && (
|
||||||
|
<Toast
|
||||||
|
message={`Ignored "${lastIgnored.title}"`}
|
||||||
|
actionLabel="Undo"
|
||||||
|
onAction={() => {
|
||||||
|
ignored.toggle(lastIgnored.id);
|
||||||
|
setLastIgnored(null);
|
||||||
|
}}
|
||||||
|
onDismiss={() => setLastIgnored(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{openRow !== null && (
|
{openRow !== null && (
|
||||||
<EventDetail
|
<EventDetail
|
||||||
row={openRow}
|
row={openRow}
|
||||||
completed={completions[openRow.event.id] !== undefined}
|
completed={completions[openRow.event.id] !== undefined}
|
||||||
ignored={ignored.marks[openRow.event.id] !== undefined}
|
ignored={ignored.marks[openRow.event.id] !== undefined}
|
||||||
onIgnore={ignored.toggle}
|
onIgnore={(id) => toggleIgnored(id, openRow.event.title)}
|
||||||
onToggle={toggle}
|
onToggle={toggle}
|
||||||
onClose={() => setOpenId(null)}
|
onClose={() => setOpenId(null)}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -15,11 +15,21 @@ export interface RowEvent {
|
|||||||
interface EventRowProps {
|
interface EventRowProps {
|
||||||
row: RowEvent;
|
row: RowEvent;
|
||||||
completed: boolean;
|
completed: boolean;
|
||||||
|
/** Only ever true when the reader has chosen to reveal ignored events. */
|
||||||
|
ignored?: boolean | undefined;
|
||||||
onToggle: (id: string) => void;
|
onToggle: (id: string) => void;
|
||||||
|
onRestore?: ((id: string) => void) | undefined;
|
||||||
onOpen: (id: string) => void;
|
onOpen: (id: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function EventRow({ row, completed, onToggle, onOpen }: EventRowProps) {
|
export function EventRow({
|
||||||
|
row,
|
||||||
|
completed,
|
||||||
|
ignored = false,
|
||||||
|
onToggle,
|
||||||
|
onRestore,
|
||||||
|
onOpen,
|
||||||
|
}: EventRowProps) {
|
||||||
const { event, clock } = row;
|
const { event, clock } = row;
|
||||||
const game = gameMeta(event.game);
|
const game = gameMeta(event.game);
|
||||||
const heat = URGENCY_COLOR[clock.urgency];
|
const heat = URGENCY_COLOR[clock.urgency];
|
||||||
@@ -58,8 +68,13 @@ export function EventRow({ row, completed, onToggle, onOpen }: EventRowProps) {
|
|||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<div className="flex items-baseline justify-between gap-3">
|
<div className="flex items-baseline justify-between gap-3">
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<span className="eyebrow block truncate" style={{ color: game.hue }}>
|
<span className="eyebrow flex items-center gap-1.5 truncate">
|
||||||
{game.short}
|
<span style={{ color: game.hue }}>{game.short}</span>
|
||||||
|
{ignored && (
|
||||||
|
<span className="rounded-[3px] bg-hairline px-1 py-px text-[0.5625rem] tracking-normal text-muted">
|
||||||
|
ignored
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</span>
|
</span>
|
||||||
<span
|
<span
|
||||||
className={`row-title block truncate text-[0.9375rem] font-medium leading-snug ${
|
className={`row-title block truncate text-[0.9375rem] font-medium leading-snug ${
|
||||||
@@ -100,7 +115,28 @@ export function EventRow({ row, completed, onToggle, onOpen }: EventRowProps) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Sits above the row target so ticking done never opens the sheet. */}
|
{/* Sits above the row target so ticking done never opens the sheet.
|
||||||
|
On a revealed ignored row this becomes the undo, which is the most
|
||||||
|
direct place to put it. */}
|
||||||
|
{ignored && onRestore !== undefined ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onRestore(event.id)}
|
||||||
|
aria-label={`Stop ignoring ${event.title}`}
|
||||||
|
className="row-check pointer-events-auto relative z-20 grid size-7 shrink-0 cursor-pointer place-items-center self-center rounded-md border border-hairline text-faint"
|
||||||
|
>
|
||||||
|
<svg viewBox="0 0 16 16" className="size-3.5" aria-hidden>
|
||||||
|
<path
|
||||||
|
d="M2.5 8a5.5 5.5 0 1 0 1.7-4M2.5 2.5V6H6"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="1.8"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => onToggle(event.id)}
|
onClick={() => onToggle(event.id)}
|
||||||
@@ -125,6 +161,7 @@ export function EventRow({ row, completed, onToggle, onOpen }: EventRowProps) {
|
|||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import { useEffect } from "react";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A short-lived confirmation with an undo.
|
||||||
|
*
|
||||||
|
* Ignoring an event makes it vanish, which is exactly what was asked for and
|
||||||
|
* also the moment a mistake is least recoverable — the row you would click to
|
||||||
|
* undo is the row that just disappeared. The undo lives here instead.
|
||||||
|
*/
|
||||||
|
export function Toast({
|
||||||
|
message,
|
||||||
|
actionLabel,
|
||||||
|
onAction,
|
||||||
|
onDismiss,
|
||||||
|
ms = 7000,
|
||||||
|
}: {
|
||||||
|
message: string;
|
||||||
|
actionLabel: string;
|
||||||
|
onAction: () => void;
|
||||||
|
onDismiss: () => void;
|
||||||
|
ms?: number;
|
||||||
|
}) {
|
||||||
|
useEffect(() => {
|
||||||
|
const id = setTimeout(onDismiss, ms);
|
||||||
|
return () => clearTimeout(id);
|
||||||
|
}, [onDismiss, ms, message]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
role="status"
|
||||||
|
aria-live="polite"
|
||||||
|
className="pointer-events-none fixed inset-x-0 bottom-4 z-50 flex justify-center px-4"
|
||||||
|
>
|
||||||
|
<div className="pointer-events-auto flex max-w-md items-center gap-3 rounded-xl border border-hairline bg-raised px-4 py-3 shadow-lg">
|
||||||
|
<p className="min-w-0 flex-1 truncate text-xs text-muted">{message}</p>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onAction}
|
||||||
|
className="shrink-0 rounded-md px-2 py-1 text-xs font-semibold text-near transition-colors duration-150 hover:text-ink"
|
||||||
|
>
|
||||||
|
{actionLabel}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onDismiss}
|
||||||
|
aria-label="Dismiss"
|
||||||
|
className="shrink-0 text-faint transition-colors duration-150 hover:text-muted"
|
||||||
|
>
|
||||||
|
<svg viewBox="0 0 16 16" className="size-3.5" aria-hidden>
|
||||||
|
<path
|
||||||
|
d="M4 4l8 8M12 4l-8 8"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user