feat(channels): press-and-hold priority stepper + inline-editable value

The priority arrows changed one step per click, so setting a large value meant many clicks. Now a
press-and-hold auto-repeats at an accelerating rate (a quick tap still steps once; keyboard path
kept), and the PATCH is debounced per channel so a hold sends one request with the final value, not
one per notch (flushed on unmount). The value also sits LEFT of the arrows (cursor never covers it)
and is click-to-edit — type any integer directly, negatives allowed — via an absolutely-positioned
input that overlays without disturbing the table row layout.
This commit is contained in:
2026-07-28 02:22:36 +02:00
parent eb922cb589
commit 9d3d69f0e9
3 changed files with 167 additions and 22 deletions
+165 -22
View File
@@ -149,15 +149,53 @@ export default function Channels() {
// Priority is updated optimistically in place and NOT re-sorted/refetched, so the list // Priority is updated optimistically in place and NOT re-sorted/refetched, so the list
// doesn't jump (the server list is priority-sorted; refetching would reorder rows and // doesn't jump (the server list is priority-sorted; refetching would reorder rows and
// lose your scroll position). The new order takes effect next time the page loads. // lose your scroll position). The new order takes effect next time the page loads.
const bumpPriority = (id: string, current: number, delta: number) => { // Press-and-hold fires many bumps, so the PATCH is DEBOUNCED per channel: each bump reads the
const next = current + delta; // accumulated value from the cache, and the server call flushes once ~400ms after the last step
// (instead of one request per notch). Pending flushes are fired on unmount so a last-moment bump
// isn't lost.
const prioFlush = useRef<Map<string, ReturnType<typeof setTimeout>>>(new Map());
const flushPriority = (id: string) => {
const latest = qc
.getQueryData<ManagedChannel[]>(qk.channels())
?.find((c) => c.id === id)?.priority;
if (latest == null) return;
api
.updateChannel(id, { priority: latest })
.catch(() => qc.invalidateQueries({ queryKey: qk.channels() }));
};
// Set an absolute priority (used by both the arrows — via bumpPriority — and the inline edit),
// optimistically + a debounced flush.
const setPriority = (id: string, next: number) => {
qc.setQueryData<ManagedChannel[]>(qk.channels(), (old) => qc.setQueryData<ManagedChannel[]>(qk.channels(), (old) =>
(old ?? []).map((ch) => (ch.id === id ? { ...ch, priority: next } : ch)), (old ?? []).map((ch) => (ch.id === id ? { ...ch, priority: next } : ch)),
); );
api const timers = prioFlush.current;
.updateChannel(id, { priority: next }) const pending = timers.get(id);
.catch(() => qc.invalidateQueries({ queryKey: qk.channels() })); if (pending) clearTimeout(pending);
timers.set(
id,
setTimeout(() => {
timers.delete(id);
flushPriority(id);
}, 400),
);
}; };
const bumpPriority = (id: string, delta: number) => {
const cur =
qc.getQueryData<ManagedChannel[]>(qk.channels())?.find((c) => c.id === id)?.priority ?? 0;
setPriority(id, cur + delta);
};
useEffect(() => {
const timers = prioFlush.current;
return () => {
timers.forEach((timer, id) => {
clearTimeout(timer);
flushPriority(id);
});
timers.clear();
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// Tagging is updated optimistically in place (no refetch of the whole channel list, which // Tagging is updated optimistically in place (no refetch of the whole channel list, which
// made the toggle feel ~2s slow); only revert by refetching if the server call fails. // made the toggle feel ~2s slow); only revert by refetching if the server call fails.
@@ -291,7 +329,13 @@ export default function Channels() {
hideInCard: true, hideInCard: true,
sortValue: (c) => c.priority, sortValue: (c) => c.priority,
filter: { kind: "select", options: prioOptions, test: (c, v) => String(c.priority) === v }, filter: { kind: "select", options: prioOptions, test: (c, v) => String(c.priority) === v },
render: (c) => <PriorityCell c={c} onPriority={(d) => bumpPriority(c.id, c.priority, d)} />, render: (c) => (
<PriorityCell
c={c}
onPriority={(d) => bumpPriority(c.id, d)}
onSet={(v) => setPriority(c.id, v)}
/>
),
}, },
{ {
key: "actions", key: "actions",
@@ -778,29 +822,128 @@ function SyncBadge({ ok, label, hint }: { ok: boolean; label: string; hint?: str
function PriorityCell({ function PriorityCell({
c, c,
onPriority, onPriority,
onSet,
}: { }: {
c: ManagedChannel; c: ManagedChannel;
onPriority: (delta: number) => void; onPriority: (delta: number) => void;
onSet: (value: number) => void;
}) { }) {
const { t } = useTranslation(); const { t } = useTranslation();
const [editing, setEditing] = useState(false);
const [draft, setDraft] = useState("");
const holdDelay = useRef<ReturnType<typeof setTimeout> | null>(null);
const holdRepeat = useRef<ReturnType<typeof setTimeout> | null>(null);
const suppressClick = useRef(false);
const stop = useCallback(() => {
if (holdDelay.current) clearTimeout(holdDelay.current);
if (holdRepeat.current) clearTimeout(holdRepeat.current);
holdDelay.current = holdRepeat.current = null;
}, []);
useEffect(() => stop, [stop]); // clear any running timers on unmount
// Hold past a threshold → auto-repeat at an accelerating rate, and suppress the trailing click so
// a hold doesn't double-count its final notch. A quick tap never crosses the threshold, so it
// falls through to onClick — which also keeps the keyboard path (Enter/Space) working.
const start = (delta: number) => {
suppressClick.current = false;
holdDelay.current = setTimeout(() => {
suppressClick.current = true;
let rate = 140;
const tick = () => {
onPriority(delta);
rate = Math.max(40, rate - 12);
holdRepeat.current = setTimeout(tick, rate);
};
tick();
}, 350);
};
const onClick = (delta: number) => {
if (suppressClick.current) {
suppressClick.current = false;
return;
}
onPriority(delta);
};
const StepBtn = ({
delta,
Icon,
label,
}: {
delta: number;
Icon: typeof ArrowUp;
label: string;
}) => (
<button
onPointerDown={(e) => {
// Keep receiving pointer events even if the pointer drifts off the small button mid-hold.
try {
e.currentTarget.setPointerCapture(e.pointerId);
} catch {
/* invalid pointer id (e.g. synthetic events) — capture is a nicety, not required */
}
start(delta);
}}
onPointerUp={stop}
onPointerCancel={stop}
onClick={() => onClick(delta)}
className="text-muted hover:text-accent leading-none"
aria-label={label}
>
<Icon className="w-3.5 h-3.5" />
</button>
);
const commitEdit = () => {
const parsed = parseInt(draft, 10);
if (!Number.isNaN(parsed) && parsed !== c.priority) onSet(parsed);
setEditing(false);
};
const beginEdit = () => {
setDraft(String(c.priority));
setEditing(true);
};
// The value sits to the LEFT of the arrows (not between them) so the cursor — which rests on the
// arrows while you press-and-hold — never covers the live value you're setting. Clicking the value
// opens an inline edit to type any integer directly (negatives allowed).
return ( return (
<Tooltip hint={t("channels.row.priorityHint")}> <Tooltip hint={t("channels.row.priorityHint")}>
<div className="inline-flex flex-col items-center cursor-help"> <div className="inline-flex items-center gap-1.5 cursor-help">
<button {/* The number keeps its footprint (w-6) whether displayed or editing; the input is absolutely
onClick={() => onPriority(1)} positioned OVER it (out of flow) so opening the editor never changes the row height/width —
className="text-muted hover:text-accent" it just overlays, extending left for a longer number. */}
aria-label={t("channels.row.raisePriority")} <span className="relative inline-block w-6 h-5 text-right leading-5">
> <button
<ArrowUp className="w-3.5 h-3.5" /> onClick={beginEdit}
</button> aria-label={t("channels.row.editPriority")}
<span className="text-xs text-muted tabular-nums">{c.priority}</span> title={t("channels.row.editPriority")}
<button className={`text-sm font-medium tabular-nums hover:text-accent ${editing ? "invisible" : ""}`}
onClick={() => onPriority(-1)} >
className="text-muted hover:text-accent" {c.priority}
aria-label={t("channels.row.lowerPriority")} </button>
> {editing && (
<ArrowDown className="w-3.5 h-3.5" /> <input
</button> type="number"
value={draft}
autoFocus
onFocus={(e) => e.currentTarget.select()}
onChange={(e) => setDraft(e.target.value)}
onBlur={commitEdit}
onKeyDown={(e) => {
if (e.key === "Enter") commitEdit();
else if (e.key === "Escape") setEditing(false);
}}
aria-label={t("channels.row.priorityHint")}
className="absolute top-1/2 right-0 -translate-y-1/2 w-14 h-6 text-sm font-medium tabular-nums text-right bg-card border border-accent rounded px-1 outline-none z-popover [appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none"
/>
)}
</span>
<span className="inline-flex flex-col">
<StepBtn delta={1} Icon={ArrowUp} label={t("channels.row.raisePriority")} />
<StepBtn delta={-1} Icon={ArrowDown} label={t("channels.row.lowerPriority")} />
</span>
</div> </div>
</Tooltip> </Tooltip>
); );
@@ -90,6 +90,7 @@
"priorityHint": "Your ranking for this channel. Sort the feed by “Channel priority” to bring higher-priority channels to the top.", "priorityHint": "Your ranking for this channel. Sort the feed by “Channel priority” to bring higher-priority channels to the top.",
"raisePriority": "Raise priority", "raisePriority": "Raise priority",
"lowerPriority": "Lower priority", "lowerPriority": "Lower priority",
"editPriority": "Click to type a priority (negatives allowed)",
"recent": "recent", "recent": "recent",
"recentSyncedHint": "Recent uploads synced.", "recentSyncedHint": "Recent uploads synced.",
"recentNotSyncedHint": "Recent uploads not fetched yet.", "recentNotSyncedHint": "Recent uploads not fetched yet.",
@@ -90,6 +90,7 @@
"priorityHint": "A te rangsorod ehhez a csatornához. Rendezd a hírfolyamot „Csatorna prioritás” szerint, hogy a magasabb prioritású csatornák felülre kerüljenek.", "priorityHint": "A te rangsorod ehhez a csatornához. Rendezd a hírfolyamot „Csatorna prioritás” szerint, hogy a magasabb prioritású csatornák felülre kerüljenek.",
"raisePriority": "Prioritás növelése", "raisePriority": "Prioritás növelése",
"lowerPriority": "Prioritás csökkentése", "lowerPriority": "Prioritás csökkentése",
"editPriority": "Kattints a prioritás beírásához (negatív is lehet)",
"recent": "legutóbbi", "recent": "legutóbbi",
"recentSyncedHint": "Legutóbbi feltöltések szinkronizálva.", "recentSyncedHint": "Legutóbbi feltöltések szinkronizálva.",
"recentNotSyncedHint": "A legutóbbi feltöltések még nincsenek letöltve.", "recentNotSyncedHint": "A legutóbbi feltöltések még nincsenek letöltve.",