From 9d3d69f0e947d496dcefabffd89c4451b35574f9 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Tue, 28 Jul 2026 02:22:36 +0200 Subject: [PATCH] feat(channels): press-and-hold priority stepper + inline-editable value MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- frontend/src/components/Channels.tsx | 187 ++++++++++++++++++--- frontend/src/i18n/locales/en/channels.json | 1 + frontend/src/i18n/locales/hu/channels.json | 1 + 3 files changed, 167 insertions(+), 22 deletions(-) diff --git a/frontend/src/components/Channels.tsx b/frontend/src/components/Channels.tsx index 054befc..1064f56 100644 --- a/frontend/src/components/Channels.tsx +++ b/frontend/src/components/Channels.tsx @@ -149,15 +149,53 @@ export default function Channels() { // 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 // lose your scroll position). The new order takes effect next time the page loads. - const bumpPriority = (id: string, current: number, delta: number) => { - const next = current + delta; + // Press-and-hold fires many bumps, so the PATCH is DEBOUNCED per channel: each bump reads the + // 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>>(new Map()); + const flushPriority = (id: string) => { + const latest = qc + .getQueryData(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(qk.channels(), (old) => (old ?? []).map((ch) => (ch.id === id ? { ...ch, priority: next } : ch)), ); - api - .updateChannel(id, { priority: next }) - .catch(() => qc.invalidateQueries({ queryKey: qk.channels() })); + const timers = prioFlush.current; + const pending = timers.get(id); + if (pending) clearTimeout(pending); + timers.set( + id, + setTimeout(() => { + timers.delete(id); + flushPriority(id); + }, 400), + ); }; + const bumpPriority = (id: string, delta: number) => { + const cur = + qc.getQueryData(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 // 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, sortValue: (c) => c.priority, filter: { kind: "select", options: prioOptions, test: (c, v) => String(c.priority) === v }, - render: (c) => bumpPriority(c.id, c.priority, d)} />, + render: (c) => ( + bumpPriority(c.id, d)} + onSet={(v) => setPriority(c.id, v)} + /> + ), }, { key: "actions", @@ -778,29 +822,128 @@ function SyncBadge({ ok, label, hint }: { ok: boolean; label: string; hint?: str function PriorityCell({ c, onPriority, + onSet, }: { c: ManagedChannel; onPriority: (delta: number) => void; + onSet: (value: number) => void; }) { const { t } = useTranslation(); + const [editing, setEditing] = useState(false); + const [draft, setDraft] = useState(""); + const holdDelay = useRef | null>(null); + const holdRepeat = useRef | 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; + }) => ( + + ); + + 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 ( -
- - {c.priority} - +
+ {/* The number keeps its footprint (w-6) whether displayed or editing; the input is absolutely + positioned OVER it (out of flow) so opening the editor never changes the row height/width — + it just overlays, extending left for a longer number. */} + + + {editing && ( + 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" + /> + )} + + + + +
); diff --git a/frontend/src/i18n/locales/en/channels.json b/frontend/src/i18n/locales/en/channels.json index bfedd36..e924912 100644 --- a/frontend/src/i18n/locales/en/channels.json +++ b/frontend/src/i18n/locales/en/channels.json @@ -90,6 +90,7 @@ "priorityHint": "Your ranking for this channel. Sort the feed by “Channel priority” to bring higher-priority channels to the top.", "raisePriority": "Raise priority", "lowerPriority": "Lower priority", + "editPriority": "Click to type a priority (negatives allowed)", "recent": "recent", "recentSyncedHint": "Recent uploads synced.", "recentNotSyncedHint": "Recent uploads not fetched yet.", diff --git a/frontend/src/i18n/locales/hu/channels.json b/frontend/src/i18n/locales/hu/channels.json index 5a236e6..55e3a11 100644 --- a/frontend/src/i18n/locales/hu/channels.json +++ b/frontend/src/i18n/locales/hu/channels.json @@ -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.", "raisePriority": "Prioritás növelése", "lowerPriority": "Prioritás csökkentése", + "editPriority": "Kattints a prioritás beírásához (negatív is lehet)", "recent": "legutóbbi", "recentSyncedHint": "Legutóbbi feltöltések szinkronizálva.", "recentNotSyncedHint": "A legutóbbi feltöltések még nincsenek letöltve.",