Archilyzer · Source

archilyzer

Archilyzer
git clone https://archilyzer.pages.dev/source/archilyzer.git
Log | Files | Refs | README | LICENSE

commit bffaa0cf7928908ca5eab0a63805fde3cb174a47
parent 7811b9d555af81729d0021919ceac30c9394b534
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date:   Sun, 13 Sep 2026 11:13:02 -0400

channels: one selection deck instead of two bulk bars

The page carried two selection mechanisms for one selection. ChannelBulkBar
(tier + focus) floated sticky at the viewport edge; ChannelStorageBulkBar
(move media) was a plain block below 67 rows and could only be reached by
scrolling to the very bottom. Both said "N selected" and both offered Clear,
and the storage bar folded the destination root into its button label, which
truncated to "Move media to...".

ChannelSelectionDeck is one docked panel carrying all three verb groups at
once - Tier, Focus, Media - named by the mono micro-caps eyebrow the nav
groups and lane strips already use. The root moves into its own box beside a
button that just reads "Move media". The root element keeps the
"channel priority bulk" accessible name the suite scopes Apply tier and
Focus these to, and every other pinned label is unchanged.

ChannelBulkBar.tsx becomes ChannelFocusBar.tsx (the focus line is all that
was left in it) and exports useBarAction, which the deck shares.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

Diffstat:
Deditor/app/channels/components/ChannelBulkBar.tsx | 202-------------------------------------------------------------------------------
Aeditor/app/channels/components/ChannelFocusBar.tsx | 120+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aeditor/app/channels/components/ChannelSelectionDeck.tsx | 233+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Deditor/app/channels/components/ChannelStorageBulkBar.tsx | 123-------------------------------------------------------------------------------
Meditor/app/channels/components/ChannelsTable.tsx | 14+++-----------
Meditor/app/channels/components/EndFocusButton.tsx | 2+-
6 files changed, 357 insertions(+), 337 deletions(-)

diff --git a/editor/app/channels/components/ChannelBulkBar.tsx b/editor/app/channels/components/ChannelBulkBar.tsx @@ -1,202 +0,0 @@ -"use client"; - -// THE /channels BULK CONTROLS for channel priority — two bars, one writer. -// -// `ChannelFocusBar` is always on the page: the focus is ONE corpus-wide fact, -// so choosing a site to focus and ending a focus are not properties of a row -// selection and must not require one. `ChannelBulkBar` is the selection bar, -// copying the idiom `BulkCadenceBar`/`SyncConsole` already established on the -// sync console (a Set of slugs, a sticky footer, Apply + Clear) rather than -// inventing a second one. -// -// Both post through `saveChannelPriorityAction`'s named gestures, which is the -// only function that writes `settings.channelPriority` and the only one that -// recompiles the four lane trees. - -import { useState, useTransition } from "react"; -import { - STORED_CHANNEL_TIERS, - type StoredChannelTier, -} from "yt-dlp-transcript-common/lib/channelPriority"; -import { - endFocusAction, - focusChannelsAction, - focusSiteAction, - setChannelTierAction, - type ActionResult, -} from "../actions"; - -// A FAILED WRITE MUST SAY SO — the writer returns `{error}` before it -// revalidates, so a swallowed result reads as "the click did nothing". One -// runner per bar, one alert line, the shape SyncAllChannelsButton already uses. -function useBarAction(): { - pending: boolean; - error: string | null; - run: (action: () => Promise<ActionResult>, after?: () => void) => void; -} { - const [pending, startTransition] = useTransition(); - const [error, setError] = useState<string | null>(null); - return { - pending, - error, - run: (action, after) => - startTransition(async () => { - try { - const result = await action(); - setError(result?.error ?? null); - if (!result?.error) after?.(); - } catch (e) { - setError((e as Error).message); - } - }), - }; -} - -export type FocusSite = { siteId: string; title: string }; - -const TIER_LABEL: Record<StoredChannelTier, string> = { - normal: "Normal", - low: "Low", - paused: "Paused", -}; - -export function ChannelFocusBar({ - sites, - focusLabel, -}: { - sites: FocusSite[]; - // What is focused right now, already resolved by the page ("site Foo", - // "2 channels"), or null when nothing is. The full banner with the per-lane - // pending numbers is S4's FocusBanner; this bar is only the control. - focusLabel: string | null; -}) { - const { pending, error, run } = useBarAction(); - const [siteId, setSiteId] = useState(sites[0]?.siteId ?? ""); - - if (sites.length === 0 && !focusLabel) return null; - - return ( - <div - aria-label="channel focus" - className="flex flex-wrap items-center gap-2 rounded border border-border bg-card px-3 py-2 text-sm" - > - <span className="text-muted-foreground"> - {focusLabel ? `Focus: ${focusLabel}` : "No focus"} - </span> - {sites.length > 0 && ( - <> - <select - aria-label="focus site" - value={siteId} - disabled={pending} - onChange={(e) => setSiteId(e.target.value)} - className="rounded-md border border-border bg-card px-2 py-1 text-xs disabled:opacity-50" - > - {sites.map((s) => ( - <option key={s.siteId} value={s.siteId}> - {s.title} - </option> - ))} - </select> - <button - type="button" - disabled={pending || !siteId} - onClick={() => run(() => focusSiteAction(siteId))} - className="rounded-md border border-border px-2 py-1 text-xs hover:bg-muted disabled:opacity-50" - > - Focus site - </button> - </> - )} - {focusLabel && ( - <button - type="button" - disabled={pending} - onClick={() => run(() => endFocusAction())} - className="rounded-md border border-border px-2 py-1 text-xs hover:bg-muted disabled:opacity-50" - > - End focus - </button> - )} - {error && ( - <span - role="alert" - aria-label="focus error" - className="text-xs text-destructive" - > - {error} - </span> - )} - </div> - ); -} - -export function ChannelBulkBar({ - slugs, - onClear, -}: { - slugs: string[]; - onClear: () => void; -}) { - const { pending, error, run } = useBarAction(); - const [tier, setTier] = useState<StoredChannelTier>("normal"); - - if (slugs.length === 0) return null; - - return ( - <div - aria-label="channel priority bulk" - className="sticky bottom-0 z-10 flex flex-wrap items-center gap-2 rounded border border-border bg-card p-3 text-sm shadow-lg motion-safe:animate-in motion-safe:fade-in motion-safe:slide-in-from-bottom-2" - > - <span className="font-medium">{slugs.length} selected</span> - <label className="flex items-center gap-1 text-xs text-muted-foreground"> - Set tier - <select - aria-label="bulk tier" - value={tier} - disabled={pending} - onChange={(e) => setTier(e.target.value as StoredChannelTier)} - className="rounded-md border border-border bg-card px-2 py-1 text-xs disabled:opacity-50" - > - {STORED_CHANNEL_TIERS.map((t) => ( - <option key={t} value={t}> - {TIER_LABEL[t]} - </option> - ))} - </select> - </label> - <button - type="button" - disabled={pending} - onClick={() => run(() => setChannelTierAction(slugs, tier), onClear)} - className="rounded-md bg-primary px-3 py-1.5 text-xs font-medium text-primary-foreground hover:opacity-90 disabled:opacity-50" - > - Apply tier - </button> - <button - type="button" - disabled={pending} - onClick={() => run(() => focusChannelsAction(slugs), onClear)} - className="rounded-md border border-border px-3 py-1.5 text-xs hover:bg-muted disabled:opacity-50" - > - Focus these - </button> - <button - type="button" - onClick={onClear} - className="rounded-md border border-border px-3 py-1.5 text-xs hover:bg-muted" - > - Clear - </button> - {error && ( - <span - role="alert" - aria-label="priority bulk error" - className="text-xs text-destructive" - > - {error} - </span> - )} - </div> - ); -} diff --git a/editor/app/channels/components/ChannelFocusBar.tsx b/editor/app/channels/components/ChannelFocusBar.tsx @@ -0,0 +1,120 @@ +"use client"; + +// THE /channels FOCUS LINE — the corpus-wide focus selector. +// +// It is ALWAYS on the page, with or without a selection: the focus is ONE +// corpus-wide fact, so choosing a site to focus and ending a focus are not +// properties of a row selection and must not require one. The selection verbs +// live in `ChannelSelectionDeck`, which docks below the rack and unmounts when +// nothing is ticked. +// +// Both post through `saveChannelPriorityAction`'s named gestures, which is the +// only function that writes `settings.channelPriority` and the only one that +// recompiles the four lane trees. + +import { useState, useTransition } from "react"; +import { + endFocusAction, + focusSiteAction, + type ActionResult, +} from "../actions"; + +// A FAILED WRITE MUST SAY SO — the writer returns `{error}` before it +// revalidates, so a swallowed result reads as "the click did nothing". One +// runner per bar, one alert line, the shape SyncAllChannelsButton already uses. +export function useBarAction(): { + pending: boolean; + error: string | null; + run: (action: () => Promise<ActionResult>, after?: () => void) => void; +} { + const [pending, startTransition] = useTransition(); + const [error, setError] = useState<string | null>(null); + return { + pending, + error, + run: (action, after) => + startTransition(async () => { + try { + const result = await action(); + setError(result?.error ?? null); + if (!result?.error) after?.(); + } catch (e) { + setError((e as Error).message); + } + }), + }; +} + +export type FocusSite = { siteId: string; title: string }; + + +export function ChannelFocusBar({ + sites, + focusLabel, +}: { + sites: FocusSite[]; + // What is focused right now, already resolved by the page ("site Foo", + // "2 channels"), or null when nothing is. The full banner with the per-lane + // pending numbers is S4's FocusBanner; this bar is only the control. + focusLabel: string | null; +}) { + const { pending, error, run } = useBarAction(); + const [siteId, setSiteId] = useState(sites[0]?.siteId ?? ""); + + if (sites.length === 0 && !focusLabel) return null; + + return ( + <div + aria-label="channel focus" + className="flex flex-wrap items-center gap-2 rounded border border-border bg-card px-3 py-2 text-sm" + > + <span className="text-muted-foreground"> + {focusLabel ? `Focus: ${focusLabel}` : "No focus"} + </span> + {sites.length > 0 && ( + <> + <select + aria-label="focus site" + value={siteId} + disabled={pending} + onChange={(e) => setSiteId(e.target.value)} + className="rounded-md border border-border bg-card px-2 py-1 text-xs disabled:opacity-50" + > + {sites.map((s) => ( + <option key={s.siteId} value={s.siteId}> + {s.title} + </option> + ))} + </select> + <button + type="button" + disabled={pending || !siteId} + onClick={() => run(() => focusSiteAction(siteId))} + className="rounded-md border border-border px-2 py-1 text-xs hover:bg-muted disabled:opacity-50" + > + Focus site + </button> + </> + )} + {focusLabel && ( + <button + type="button" + disabled={pending} + onClick={() => run(() => endFocusAction())} + className="rounded-md border border-border px-2 py-1 text-xs hover:bg-muted disabled:opacity-50" + > + End focus + </button> + )} + {error && ( + <span + role="alert" + aria-label="focus error" + className="text-xs text-destructive" + > + {error} + </span> + )} + </div> + ); +} diff --git a/editor/app/channels/components/ChannelSelectionDeck.tsx b/editor/app/channels/components/ChannelSelectionDeck.tsx @@ -0,0 +1,233 @@ +"use client"; + +// THE /channels SELECTION DECK — one docked panel for every bulk verb. +// +// It replaces TWO bars that both said "N selected" and both offered Clear: +// `ChannelBulkBar` (tier + focus), which floated `sticky bottom-0`, and +// `ChannelStorageBulkBar` (move media), which was a plain block after 67 rows +// and could only be reached by scrolling to the very bottom. One selection with +// two places to act on it is a mode the operator has to discover; the deck is +// the console's master section instead — it appears with a selection, carries +// every verb group at once, and unmounts when nothing is ticked. +// +// The verb groups are named by eyebrows (TIER · FOCUS · MEDIA) rather than +// separated by rules alone, because they encode something true: these are +// groups of controls over the same selection, not a toolbar of peers. +// +// ONE ACCESSIBLE HANDLE, deliberately kept: the root is still +// `aria-label="channel priority bulk"`, which is what channel-priority.spec.ts +// scopes `Apply tier` and `Focus these` to. The storage controls inside it are +// addressed by their own labels, exactly as they were on the bar they came +// from. +// +// NO PREVIEW GATE on the media move, unlike the channel's own Storage panel, +// and the asymmetry is deliberate: a preview measures ONE channel's tree +// against one volume, and the useful answer for a batch ("will all of these +// fit") is not the sum of the previews — the root fills up as the jobs run. +// Each job therefore re-checks space when it STARTS, and because every +// relocation in the process shares one queue key the jobs run ONE AT A TIME — +// so that check measures a root no other move is writing to, and a root that +// fills partway refuses the remainder one job at a time with the reason in that +// job's log. What the deck owes the operator is the count and the skips, not a +// number that would be stale before the second job. + +import Link from "next/link"; +import { useState, useTransition } from "react"; +import { + STORED_CHANNEL_TIERS, + type StoredChannelTier, +} from "yt-dlp-transcript-common/lib/channelPriority"; +import { focusChannelsAction, setChannelTierAction } from "../actions"; +import { + bulkRelocateChannelMediaAction, + type BulkRelocateResult, +} from "../bulkStorageActions"; +import { useBarAction } from "./ChannelFocusBar"; + +const TIER_LABEL: Record<StoredChannelTier, string> = { + normal: "Normal", + low: "Low", + paused: "Paused", +}; + +// The page's structural device, shared with the nav groups and the lane strip: +// a micro-caps mono eyebrow naming what the controls beside it do. +const EYEBROW = + "font-mono text-[10px] uppercase tracking-[0.16em] text-muted-foreground"; + +// Each verb group after the first is fenced with a rule rather than a gap, so +// the eye reads three groups and not eight loose buttons. +const GROUP = "flex items-center gap-2 pl-4 border-l border-border"; + +export function ChannelSelectionDeck({ + slugs, + onClear, + defaultRoot, +}: { + slugs: string[]; + onClear: () => void; + // settings.storage.mediaRoot, read on the server. "" when no cold root is + // configured, which leaves the box empty and the action refusing with that as + // the reason. + defaultRoot: string; +}) { + // Two runners, because they are two writers: the priority actions clear the + // selection on success, the relocate queues jobs and keeps it. + const { pending: priorityPending, error: priorityError, run } = useBarAction(); + const [tier, setTier] = useState<StoredChannelTier>("normal"); + const [movePending, startMove] = useTransition(); + const [root, setRoot] = useState(defaultRoot); + const [result, setResult] = useState<BulkRelocateResult | null>(null); + const [moveError, setMoveError] = useState<string | null>(null); + + if (slugs.length === 0) return null; + const trimmed = root.trim(); + const busy = priorityPending || movePending; + + return ( + <div + aria-label="channel priority bulk" + className={ + // Below md the document scrolls and the deck pins to the screen; on md+ + // it is the last item of the page's flex column and is already docked. + "sticky bottom-0 z-20 md:static md:shrink-0 " + + "border-t md:border md:rounded-md border-border bg-card/95 backdrop-blur " + + "px-3 py-2 text-sm shadow-lg " + + "motion-safe:animate-in motion-safe:fade-in motion-safe:slide-in-from-bottom-2" + } + > + <div className="flex flex-wrap items-center gap-x-4 gap-y-2"> + <span className="font-medium tabular-nums"> + {slugs.length} selected + </span> + + <div className={GROUP}> + <span className={EYEBROW}>Tier</span> + <select + aria-label="bulk tier" + value={tier} + disabled={busy} + onChange={(e) => setTier(e.target.value as StoredChannelTier)} + className="rounded-md border border-border bg-card px-2 py-1 text-xs disabled:opacity-50" + > + {STORED_CHANNEL_TIERS.map((t) => ( + <option key={t} value={t}> + {TIER_LABEL[t]} + </option> + ))} + </select> + <button + type="button" + disabled={busy} + onClick={() => run(() => setChannelTierAction(slugs, tier), onClear)} + className="rounded-md bg-primary px-3 py-1.5 text-xs font-medium text-primary-foreground hover:opacity-90 disabled:opacity-50" + > + Apply tier + </button> + </div> + + <div className={GROUP}> + <span className={EYEBROW}>Focus</span> + <button + type="button" + disabled={busy} + onClick={() => run(() => focusChannelsAction(slugs), onClear)} + className="rounded-md border border-border px-3 py-1.5 text-xs hover:bg-muted disabled:opacity-50" + > + Focus these + </button> + </div> + + <div className={GROUP}> + <span className={EYEBROW}>Media</span> + {/* ONE root for the whole batch: a per-row destination is a per-row + decision, and that is what the channel's own Storage panel is for. + The root lives in the box rather than in the button's label — + `Move media to /mnt/platter/archilyzer-media` is what used to + truncate to "Move media to…" and leave the operator unable to read + where the files were going. */} + <input + type="text" + aria-label="bulk media root" + value={root} + disabled={busy} + onChange={(e) => setRoot(e.target.value)} + placeholder="/mnt/platter/archilyzer-media" + className="w-72 md:w-80 rounded-md border border-border bg-card px-2 py-1 text-xs font-mono disabled:opacity-50" + /> + <button + type="button" + disabled={busy || trimmed === ""} + aria-label="move media for selected channels" + title="One relocate job per channel, on that channel's own queue. Channels that are already relocated, mid-move, social, or busy are skipped with a reason." + onClick={() => + startMove(async () => { + setMoveError(null); + setResult(null); + try { + setResult( + await bulkRelocateChannelMediaAction(slugs, trimmed), + ); + } catch (e) { + setMoveError((e as Error).message); + } + }) + } + className="rounded-md border border-border px-3 py-1.5 text-xs hover:bg-muted disabled:opacity-50" + > + {movePending ? "Queueing…" : "Move media"} + </button> + </div> + + <button + type="button" + onClick={onClear} + className="ml-auto rounded-md border border-border px-3 py-1.5 text-xs hover:bg-muted" + > + Clear + </button> + </div> + + {(result || moveError || priorityError) && ( + <div className="flex flex-wrap items-center gap-x-4 gap-y-1 pt-1.5 text-xs"> + {result && ( + <span + aria-label="bulk media move result" + className="text-muted-foreground" + title={ + result.skipped.length === 0 + ? undefined + : result.skipped + .map((s) => `${s.slug}: ${s.reason}`) + .join("\n") + } + > + Queued {result.queued.length} · skipped {result.skipped.length} ·{" "} + <Link href="/jobs" className="underline hover:text-foreground"> + view jobs + </Link> + </span> + )} + {moveError && ( + <span + role="alert" + aria-label="bulk media move error" + className="text-destructive" + > + {moveError} + </span> + )} + {priorityError && ( + <span + role="alert" + aria-label="priority bulk error" + className="text-destructive" + > + {priorityError} + </span> + )} + </div> + )} + </div> + ); +} diff --git a/editor/app/channels/components/ChannelStorageBulkBar.tsx b/editor/app/channels/components/ChannelStorageBulkBar.tsx @@ -1,123 +0,0 @@ -"use client"; - -// THE SELECTION BAR for moving several channels' media to the cold root. -// -// Appears only with a selection, the idiom SyncConsole's bulk bar established. -// The root box is seeded from settings.storage.mediaRoot and is the only thing -// on this bar that is not a button: one root for the whole batch, because a -// per-row destination is a per-row decision and that is what the channel's own -// Storage panel is for. -// -// NO PREVIEW GATE HERE, unlike that panel, and the asymmetry is deliberate: a -// preview measures ONE channel's tree against one volume, and the useful answer -// for a batch ("will all of these fit") is not the sum of the previews — the -// root fills up as the jobs run. Each job therefore re-checks space when it -// STARTS, and because every relocation in the process shares one queue key the -// jobs run ONE AT A TIME — so that check measures a root no other move is -// writing to, and a root that fills partway refuses the remainder one job at a -// time with the reason in that job's log. What the bar owes the operator is the -// count and the skips, not a number that would be stale before the second job. - -import { useState, useTransition } from "react"; -import Link from "next/link"; -import { - bulkRelocateChannelMediaAction, - type BulkRelocateResult, -} from "../bulkStorageActions"; - -export function ChannelStorageBulkBar({ - slugs, - onClear, - defaultRoot, -}: { - slugs: string[]; - onClear: () => void; - // settings.storage.mediaRoot, read on the server. "" when none is configured, - // which leaves the box empty and the action refusing with that as the reason. - defaultRoot: string; -}) { - const [pending, startTransition] = useTransition(); - const [root, setRoot] = useState(defaultRoot); - const [result, setResult] = useState<BulkRelocateResult | null>(null); - const [error, setError] = useState<string | null>(null); - - if (slugs.length === 0) return null; - const trimmed = root.trim(); - - return ( - <div - aria-label="channel media bulk" - className="flex flex-wrap items-center gap-2 rounded border border-border bg-card px-3 py-2 text-sm" - > - <span className="text-muted-foreground"> - {slugs.length} selected - </span> - <input - type="text" - aria-label="bulk media root" - value={root} - disabled={pending} - onChange={(e) => setRoot(e.target.value)} - placeholder="/mnt/platter/archilyzer-media" - className="rounded-md border border-border bg-card px-2 py-1 text-xs font-mono disabled:opacity-50" - /> - <button - type="button" - disabled={pending || trimmed === ""} - aria-label="move media for selected channels" - title="One relocate job per channel, on that channel's own queue. Channels that are already relocated, mid-move, social, or busy are skipped with a reason." - onClick={() => - startTransition(async () => { - setError(null); - setResult(null); - try { - setResult(await bulkRelocateChannelMediaAction(slugs, trimmed)); - } catch (e) { - setError((e as Error).message); - } - }) - } - className="rounded-md border border-border px-2 py-1 text-xs hover:bg-muted disabled:opacity-50" - > - {pending - ? "Queueing…" - : `Move media to ${trimmed === "" ? "…" : trimmed}`} - </button> - <button - type="button" - disabled={pending} - onClick={onClear} - className="rounded-md border border-border px-2 py-1 text-xs hover:bg-muted disabled:opacity-50" - > - Clear - </button> - {result && ( - <span - aria-label="bulk media move result" - className="text-xs text-muted-foreground" - title={ - result.skipped.length === 0 - ? undefined - : result.skipped - .map((s) => `${s.slug}: ${s.reason}`) - .join("\n") - } - > - Queued {result.queued.length} · skipped {result.skipped.length} ·{" "} - <Link href="/jobs" className="underline hover:text-foreground"> - view jobs - </Link> - </span> - )} - {error && ( - <span - role="alert" - aria-label="bulk media move error" - className="text-xs text-destructive" - > - {error} - </span> - )} - </div> - ); -} diff --git a/editor/app/channels/components/ChannelsTable.tsx b/editor/app/channels/components/ChannelsTable.tsx @@ -17,17 +17,13 @@ import { ChannelAvailabilityButton } from "./ChannelAvailabilityButton"; import { ChannelBuildToggle } from "./ChannelBuildToggle"; import { ChannelSyncButton } from "./ChannelSyncButton"; import ChannelTierSelect from "./ChannelTierSelect"; -import { - ChannelBulkBar, - ChannelFocusBar, - type FocusSite, -} from "./ChannelBulkBar"; +import { ChannelFocusBar, type FocusSite } from "./ChannelFocusBar"; import { InlineActionButton } from "../../components/actions/InlineActionButton"; import { MediaLocationBadge, type MediaBadgeInput, } from "../../components/MediaLocationBadge"; -import { ChannelStorageBulkBar } from "./ChannelStorageBulkBar"; +import { ChannelSelectionDeck } from "./ChannelSelectionDeck"; import { tierOrder, type PriorityOperation, @@ -443,15 +439,11 @@ export function ChannelsTable({ </tbody> )} </table> - <ChannelStorageBulkBar + <ChannelSelectionDeck slugs={selectedSlugs} onClear={() => setSelected(new Set())} defaultRoot={defaultMediaRoot} /> - <ChannelBulkBar - slugs={selectedSlugs} - onClear={() => setSelected(new Set())} - /> <div className="flex flex-col gap-1 px-3 py-2 md:px-0"> <BandLegend /> {columns.some((c) => c.id.startsWith("attribution-")) && ( diff --git a/editor/app/channels/components/EndFocusButton.tsx b/editor/app/channels/components/EndFocusButton.tsx @@ -14,7 +14,7 @@ // // A FAILED WRITE MUST SAY SO. The writer returns `{error}` before it // revalidates, so a swallowed result would read as "the click did nothing" — -// the same rule, and the same shape, as `useBarAction` in ChannelBulkBar. +// the same rule, and the same shape, as `useBarAction` in ChannelFocusBar. import { useState, useTransition } from "react"; import { endFocusAction } from "../actions";