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:
@@ -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<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) =>
|
||||
(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<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
|
||||
// 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) => <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",
|
||||
@@ -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<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 (
|
||||
<Tooltip hint={t("channels.row.priorityHint")}>
|
||||
<div className="inline-flex flex-col items-center cursor-help">
|
||||
<button
|
||||
onClick={() => onPriority(1)}
|
||||
className="text-muted hover:text-accent"
|
||||
aria-label={t("channels.row.raisePriority")}
|
||||
>
|
||||
<ArrowUp className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
<span className="text-xs text-muted tabular-nums">{c.priority}</span>
|
||||
<button
|
||||
onClick={() => onPriority(-1)}
|
||||
className="text-muted hover:text-accent"
|
||||
aria-label={t("channels.row.lowerPriority")}
|
||||
>
|
||||
<ArrowDown className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
<div className="inline-flex items-center gap-1.5 cursor-help">
|
||||
{/* 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. */}
|
||||
<span className="relative inline-block w-6 h-5 text-right leading-5">
|
||||
<button
|
||||
onClick={beginEdit}
|
||||
aria-label={t("channels.row.editPriority")}
|
||||
title={t("channels.row.editPriority")}
|
||||
className={`text-sm font-medium tabular-nums hover:text-accent ${editing ? "invisible" : ""}`}
|
||||
>
|
||||
{c.priority}
|
||||
</button>
|
||||
{editing && (
|
||||
<input
|
||||
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>
|
||||
</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.",
|
||||
"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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
Reference in New Issue
Block a user