fix(#238): wire TopBar primary-action on create screens, drop dead buttons #324
@@ -1680,3 +1680,41 @@ worried about was already correct (each handler loads/validates → 404 before `
|
||||
first-party SPA only ever echoes the single canonical strong tag we emit, so no shipped client changes behavior;
|
||||
the change only makes a hand-written/tooling `If-Match` get the RFC-correct status. Docs: `api-conventions.md`
|
||||
§7a. Refs #265 #253 #197.
|
||||
|
||||
## 2026-07-12 — TopBar primary-action button: wire creates, drop the rest (#238)
|
||||
|
||||
The shell TopBar rendered a prominent top-right primary-action button (Plus icon) for **every** screen, but
|
||||
only `SchedulesScreen` had ever subscribed to its `ctv:primary-action` event — so on every other screen the
|
||||
button was **dead** (either a labelled no-op like "Save Changes"/"Add Channel", or, for the ~10 routes whose
|
||||
`primaryAction` was `''`, a bare labelless "+"; the button rendered unconditionally). Issue #238 (from a #229
|
||||
live-E2E finding, pre-existing since the TopBar's introduction).
|
||||
|
||||
**Decision — the Plus-icon button is a "create new item" affordance; keep+wire it only where that fits:**
|
||||
|
||||
- **TopBar renders the button only when the active route declares a non-empty `primaryAction`** (was:
|
||||
unconditional). This alone removes every empty-`''` dead "+".
|
||||
- **WIRE** (8 list screens with a single unambiguous create flow) via a shared `usePrimaryAction(routeId,
|
||||
handler)` hook (`web/src/primaryAction.ts`), each delegating to the same top-level create handler its in-body
|
||||
control uses (`navigateToPath('/app/new-channel')`, `setEditing({kind:'new'})`, `navigateToPath('…/add')`,
|
||||
etc.): channels, schedules (refactored onto the hook), multiCollections, rerunCollections, traktLists,
|
||||
fillerPresets, ffmpegProfiles, watermarks.
|
||||
- **DROP** (`primaryAction: ''`, no button) everywhere else, for one of four reasons: (1) the action isn't a
|
||||
create so the "+" is wrong and a correct in-body control already exists — editChannel (savebar), settings
|
||||
(savebar), guide (Jump to now), playouts (Reset all), logs/troubleshooting/blockPlayoutTroubleshooting
|
||||
(Refresh), playbackTroubleshooting (Play), yamlValidator (Validate); (2) ambiguous — collections (two create
|
||||
types behind tabs); (3) a silent no-op — builder ("Create Channel" is disabled until the form is valid),
|
||||
playlists (needs a group first); (4) semantically misplaced — dashboard (status page), libraries ("Scan" is
|
||||
per-library-row, no global target).
|
||||
|
||||
**Why not wire everything** (the SchedulesScreen precedent): the recon found every screen already carries a
|
||||
correct, disabled-state-aware, context-aware in-body control, and several banner actions are unreachable
|
||||
without refactoring handlers out from under early returns, or would render a silent no-op — reintroducing the
|
||||
very dead-button class #238 fixes. Wiring only the single-create-flow screens gives one explainable rule
|
||||
("primary button = create a new item on a list screen") and needs no risky refactors.
|
||||
|
||||
**Also fixed here:** the `apiKey` route's `primaryAction: 'Save key'` + its description/comment were stale
|
||||
post-#295 (the screen displays the machine key + changes the local password; there is nothing to "save") —
|
||||
relabelled to reflect the #295 reality. Convention + hook documented in `spa-conventions.md` §10. The
|
||||
implicit route-label ⟺ screen-subscription coupling is the kind of thing #247 (shell extraction) will
|
||||
formalize; #238 keeps it a documented convention guarded by a data-driven `App.test.tsx` test over the URL-navigating
|
||||
create screens (a typo'd route id → the banner navigates nowhere → red). Refs #238 #247.
|
||||
|
||||
@@ -339,3 +339,31 @@ Keep the guard predicate reading a **ref** (`dirtyRef`), not the `dirty` state v
|
||||
copying it.** #221 came from copying a fetch model that keeps stale results visible onto screens
|
||||
that had gained mutation surfaces — the exemplar was safe read-only, the copy was not. Copying a
|
||||
pattern copies its *assumptions*; confirm they still hold in the new context.
|
||||
|
||||
## 10. TopBar primary-action button (`usePrimaryAction`)
|
||||
|
||||
The shell TopBar (`App.tsx`) renders at most **one** primary-action button (top-right, Plus icon) for the
|
||||
active screen. The TopBar has no reference to the screen component, so the click is delivered as a window
|
||||
`CustomEvent` keyed on the active route id; the seam is `web/src/primaryAction.ts`:
|
||||
|
||||
- **A screen opts in** by calling `usePrimaryAction(routeId, handler)` at the **top of the component, before
|
||||
any early return** (it's a hook). The handler must be reachable there — a create/navigate handler or a
|
||||
`useState` setter, not something defined below a loading/error `return`. Reference: `SchedulesScreen`
|
||||
(`usePrimaryAction('schedules', () => setForm('create'))`).
|
||||
- **The route must ALSO declare a matching non-empty `primaryAction` label** in the `routes` table. The
|
||||
TopBar renders the button **only when `route.primaryAction` is non-empty** — so declaring a label without
|
||||
subscribing renders a dead button, and subscribing without a label renders nothing. Keep the two in lockstep.
|
||||
The `#238` tests in `App.test.tsx` guard this: a data-driven `it.each` asserts each URL-navigating create
|
||||
screen's banner actually navigates (so a typo'd route id → a button that navigates nowhere → red), plus a
|
||||
drop test that an action screen shows no banner button. The dialog/editor create screens (schedules, multi/
|
||||
rerun collections, trakt) are exercised by their own create-flow tests.
|
||||
- **When to WIRE vs DROP (issue #238).** The Plus icon makes the button semantically a *"create new item"*
|
||||
affordance. **Keep + wire it only on list screens with a single, unambiguous create flow** ("Add Channel",
|
||||
"Add Schedule", "Add Multi-Collection", "Add Rerun Collection", "Add Trakt List", "Add Filler Preset", "Add
|
||||
Profile", "Add Watermark"). **Drop it** (`primaryAction: ''`) where the primary action is **not** a create
|
||||
(Save / Refresh / Play / Validate / Reset / Scan — the "+" is wrong, and those screens already carry the
|
||||
correct in-body control), or where a single banner action would be **ambiguous** (Collections has two create
|
||||
types behind tabs), **a silent no-op** (the Channel builder's Create is disabled until the form is valid),
|
||||
or **semantically misplaced** (Dashboard is a status page; Libraries "Scan" is per-row). Coexisting with an
|
||||
in-body create control is fine (Schedules has both) — the banner is a convenience, not the sole entry point.
|
||||
See `docs/decisions.md` 2026-07-12 for the full rationale.
|
||||
|
||||
+31
-1
@@ -31,11 +31,41 @@ describe('ChicoryTV SPA scaffold', () => {
|
||||
expect(screen.getByRole('banner')).toBeInTheDocument();
|
||||
expect(screen.getByRole('searchbox', { name: 'Search' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'Connect' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'Add Channel' })).toBeInTheDocument();
|
||||
// The Dashboard declares no primaryAction, so the TopBar renders no primary-action button
|
||||
// here (issue #238 — the old code rendered a dead "Add Channel" button on every screen).
|
||||
expect(screen.queryByRole('button', { name: 'Add Channel' })).not.toBeInTheDocument();
|
||||
expect(await screen.findAllByText('Healthy')).toHaveLength(2);
|
||||
expect(designSystemStylesheet).toBe('../../design-system/styles.css');
|
||||
});
|
||||
|
||||
// Data-driven over every WIRED create screen whose primary action navigates to a URL — this
|
||||
// catches a typo'd route-id in usePrimaryAction on any of them (the button would render but do
|
||||
// nothing). The button lives in the shell header (role="banner"), scoped so an in-body Add
|
||||
// control can't satisfy the assertion. (#238)
|
||||
it.each([
|
||||
['/app/channels', 'Add Channel', '/app/new-channel'],
|
||||
['/app/filler-presets', 'Add Filler Preset', '/app/filler-presets/add'],
|
||||
['/app/ffmpeg-profiles', 'Add Profile', '/app/ffmpeg-profiles/add'],
|
||||
['/app/watermarks', 'Add Watermark', '/app/watermarks/add']
|
||||
])('wires the TopBar create button on %s → %s (#238)', async (path, label, target) => {
|
||||
window.history.pushState(null, '', path);
|
||||
render(<App />);
|
||||
|
||||
const banner = await screen.findByRole('banner');
|
||||
fireEvent.click(within(banner).getByRole('button', { name: label }));
|
||||
expect(window.location.pathname).toBe(target);
|
||||
});
|
||||
|
||||
it('drops the TopBar primary-action button on action screens (#238)', async () => {
|
||||
render(<App />);
|
||||
|
||||
// An "action" screen (Logs) declares no primaryAction → no TopBar button at all
|
||||
// (its Refresh control lives in-body, where the "+" primary styling would be wrong).
|
||||
fireEvent.click(screen.getByRole('link', { name: 'Logs' }));
|
||||
expect(await screen.findByRole('heading', { name: 'Logs' })).toBeInTheDocument();
|
||||
expect(within(screen.getByRole('banner')).queryByRole('button', { name: /Refresh/ })).toBeNull();
|
||||
});
|
||||
|
||||
it('routes between shell screen slots without a page reload', async () => {
|
||||
render(<App />);
|
||||
|
||||
|
||||
+32
-31
@@ -73,6 +73,7 @@ import { RemoteLibrariesEditScreen } from './screens/RemoteLibrariesEditScreen';
|
||||
import { PathReplacementsEditScreen } from './screens/PathReplacementsEditScreen';
|
||||
import { SchedulesScreen } from './screens/SchedulesScreen';
|
||||
import { canLeaveCurrentScreen } from './navigationGuard';
|
||||
import { dispatchPrimaryAction } from './primaryAction';
|
||||
import {
|
||||
ArtistDetailScreen,
|
||||
MovieDetailScreen,
|
||||
@@ -223,7 +224,7 @@ const routes: ScreenRoute[] = [
|
||||
kicker: 'Overview',
|
||||
description: 'On-air status, health checks, and recent server activity.',
|
||||
icon: <LayoutDashboard aria-hidden="true" size={16} />,
|
||||
primaryAction: 'Add Channel',
|
||||
primaryAction: '',
|
||||
placeholder: 'Dashboard workspace'
|
||||
},
|
||||
{
|
||||
@@ -245,7 +246,7 @@ const routes: ScreenRoute[] = [
|
||||
kicker: 'Builder',
|
||||
description: 'Library-to-lineup channel creation flow with template-backed defaults.',
|
||||
icon: <Plus aria-hidden="true" size={16} />,
|
||||
primaryAction: 'Create Channel',
|
||||
primaryAction: '',
|
||||
placeholder: 'Channel builder workspace'
|
||||
},
|
||||
{
|
||||
@@ -259,7 +260,7 @@ const routes: ScreenRoute[] = [
|
||||
kicker: 'Channel',
|
||||
description: 'Full channel editor: identity, playout, streaming, selection and branding.',
|
||||
icon: <Tv aria-hidden="true" size={16} />,
|
||||
primaryAction: 'Save Changes',
|
||||
primaryAction: '',
|
||||
placeholder: 'Channel editor workspace',
|
||||
allowSubPaths: true
|
||||
},
|
||||
@@ -271,7 +272,7 @@ const routes: ScreenRoute[] = [
|
||||
kicker: 'EPG',
|
||||
description: 'Timeline grid with channel rows, programme blocks, and a live now marker.',
|
||||
icon: <LayoutGrid aria-hidden="true" size={16} />,
|
||||
primaryAction: 'Jump To Now',
|
||||
primaryAction: '',
|
||||
placeholder: 'EPG grid workspace'
|
||||
},
|
||||
{
|
||||
@@ -347,7 +348,7 @@ const routes: ScreenRoute[] = [
|
||||
kicker: 'Runtime',
|
||||
description: 'Playout state, reset controls, timeline diagnostics, and build warnings.',
|
||||
icon: <ListVideo aria-hidden="true" size={16} />,
|
||||
primaryAction: 'Reset All',
|
||||
primaryAction: '',
|
||||
placeholder: 'Playouts workspace',
|
||||
allowSubPaths: true
|
||||
},
|
||||
@@ -394,7 +395,7 @@ const routes: ScreenRoute[] = [
|
||||
kicker: 'Media',
|
||||
description: 'Manual, smart, multi, playlist, search, and rerun collection management.',
|
||||
icon: <FolderTree aria-hidden="true" size={16} />,
|
||||
primaryAction: 'Add Collection',
|
||||
primaryAction: '',
|
||||
placeholder: 'Collections workspace'
|
||||
},
|
||||
{
|
||||
@@ -427,7 +428,7 @@ const routes: ScreenRoute[] = [
|
||||
kicker: 'Media',
|
||||
description: 'Group, order, and preview playlist items scheduled into channels.',
|
||||
icon: <ListMusic aria-hidden="true" size={16} />,
|
||||
primaryAction: 'Add Playlist',
|
||||
primaryAction: '',
|
||||
placeholder: 'Playlists workspace'
|
||||
},
|
||||
{
|
||||
@@ -455,7 +456,7 @@ const routes: ScreenRoute[] = [
|
||||
kicker: 'Sources',
|
||||
description: 'Local, Plex, Jellyfin, and Emby media source monitoring.',
|
||||
icon: <Library aria-hidden="true" size={16} />,
|
||||
primaryAction: 'Scan',
|
||||
primaryAction: '',
|
||||
placeholder: 'Libraries workspace',
|
||||
allowSubPaths: true
|
||||
},
|
||||
@@ -505,21 +506,22 @@ const routes: ScreenRoute[] = [
|
||||
kicker: 'System',
|
||||
description: 'Server configuration, access keys, FFmpeg profiles, and UI preferences.',
|
||||
icon: <Settings aria-hidden="true" size={16} />,
|
||||
primaryAction: 'Save Changes',
|
||||
primaryAction: '',
|
||||
placeholder: 'Settings workspace',
|
||||
allowSubPaths: true
|
||||
},
|
||||
{
|
||||
// Keyless by design: this screen reads/writes only localStorage (auth.ts) and never calls
|
||||
// `/api`, so it works on a fresh install where every `/api` read 401s (#280 / #197).
|
||||
// Since #295 the browser authenticates by session cookie; this screen only *displays* the
|
||||
// server machine key (for MCP / external REST clients) and, for local accounts, changes the
|
||||
// admin password — there is no key to "save" here (see spa-conventions §5e).
|
||||
id: 'apiKey',
|
||||
path: '/app/api-key',
|
||||
label: 'API Key',
|
||||
title: 'API Key',
|
||||
kicker: 'System',
|
||||
description: 'Store the API key this server requires on every request.',
|
||||
description: 'View the machine key for MCP and external REST clients, and change the local admin password.',
|
||||
icon: <KeyRound aria-hidden="true" size={16} />,
|
||||
primaryAction: 'Save key',
|
||||
primaryAction: '',
|
||||
placeholder: 'API key workspace'
|
||||
},
|
||||
{
|
||||
@@ -530,7 +532,7 @@ const routes: ScreenRoute[] = [
|
||||
kicker: 'System',
|
||||
description: 'Recent server log entries with level and free-text filtering.',
|
||||
icon: <ScrollText aria-hidden="true" size={16} />,
|
||||
primaryAction: 'Refresh',
|
||||
primaryAction: '',
|
||||
placeholder: 'Logs workspace'
|
||||
},
|
||||
{
|
||||
@@ -541,7 +543,7 @@ const routes: ScreenRoute[] = [
|
||||
kicker: 'System',
|
||||
description: 'Version, environment, hardware and FFmpeg capability diagnostics.',
|
||||
icon: <Stethoscope aria-hidden="true" size={16} />,
|
||||
primaryAction: 'Refresh',
|
||||
primaryAction: '',
|
||||
placeholder: 'Troubleshooting workspace'
|
||||
},
|
||||
{
|
||||
@@ -552,7 +554,7 @@ const routes: ScreenRoute[] = [
|
||||
kicker: 'System',
|
||||
description: 'Inspect a block playout, its blocks, and each block’s scheduling history.',
|
||||
icon: <Boxes aria-hidden="true" size={16} />,
|
||||
primaryAction: 'Refresh',
|
||||
primaryAction: '',
|
||||
placeholder: 'Block playout troubleshooting workspace'
|
||||
},
|
||||
{
|
||||
@@ -563,7 +565,7 @@ const routes: ScreenRoute[] = [
|
||||
kicker: 'System',
|
||||
description: 'Preview channel or media-item playback with a chosen FFmpeg profile and inspect the transcode logs.',
|
||||
icon: <Stethoscope aria-hidden="true" size={16} />,
|
||||
primaryAction: 'Play',
|
||||
primaryAction: '',
|
||||
placeholder: 'Playback troubleshooting workspace'
|
||||
},
|
||||
{
|
||||
@@ -574,7 +576,7 @@ const routes: ScreenRoute[] = [
|
||||
kicker: 'System',
|
||||
description: 'Validate a sequential-schedule YAML document against the schema.',
|
||||
icon: <ListChecks aria-hidden="true" size={16} />,
|
||||
primaryAction: 'Validate',
|
||||
primaryAction: '',
|
||||
placeholder: 'Sequential schedule validator workspace'
|
||||
}
|
||||
];
|
||||
@@ -867,7 +869,10 @@ function ConnectMenu() {
|
||||
function TopBar({ route }: { route: ScreenRoute | null }) {
|
||||
const title = route?.title ?? 'Page not found';
|
||||
const kicker = route?.kicker ?? 'Unknown route';
|
||||
const primaryAction = route?.primaryAction ?? 'Go To Dashboard';
|
||||
// Screens opt into a TopBar primary-action button by declaring a non-empty `primaryAction`
|
||||
// AND subscribing via usePrimaryAction(route.id, …). A screen that declares no action gets
|
||||
// no button (issue #238 — the old code rendered a dead button for every unwired route).
|
||||
const primaryAction = route?.primaryAction ?? '';
|
||||
const [searchValue, setSearchValue] = useState('');
|
||||
|
||||
return (
|
||||
@@ -913,18 +918,14 @@ function TopBar({ route }: { route: ScreenRoute | null }) {
|
||||
<span className="ctv-avatar">TB</span>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
startIcon={<Plus aria-hidden="true" size={15} />}
|
||||
onClick={() => {
|
||||
// Screens opt in to the TopBar primary action by listening for this event keyed on their
|
||||
// route id (e.g. SchedulesScreen opens its create dialog on 'schedules').
|
||||
if (route) {
|
||||
window.dispatchEvent(new CustomEvent('ctv:primary-action', { detail: route.id }));
|
||||
}
|
||||
}}
|
||||
>
|
||||
{primaryAction}
|
||||
</Button>
|
||||
{route && primaryAction ? (
|
||||
<Button
|
||||
startIcon={<Plus aria-hidden="true" size={15} />}
|
||||
onClick={() => dispatchPrimaryAction(route.id)}
|
||||
>
|
||||
{primaryAction}
|
||||
</Button>
|
||||
) : null}
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
|
||||
// The TopBar renders one "primary action" button per screen (top-right). Because the
|
||||
// TopBar and the screens are decoupled (the TopBar has no reference to the active
|
||||
// screen component), the click is delivered as a window CustomEvent keyed on the
|
||||
// active route id; a screen opts in with `usePrimaryAction(routeId, handler)`.
|
||||
//
|
||||
// A screen that does NOT call usePrimaryAction gets NO button — App.tsx's route table
|
||||
// declares an empty `primaryAction` for such screens and the TopBar suppresses the
|
||||
// button (see issue #238: the old code rendered a dead button for every unwired route).
|
||||
export const PRIMARY_ACTION_EVENT = 'ctv:primary-action';
|
||||
|
||||
export function dispatchPrimaryAction(routeId: string): void {
|
||||
window.dispatchEvent(new CustomEvent(PRIMARY_ACTION_EVENT, { detail: routeId }));
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe a screen to its TopBar primary-action button. The handler runs whenever the
|
||||
* TopBar dispatches `ctv:primary-action` with a detail matching `routeId`. The latest
|
||||
* handler is held in a ref so re-renders don't churn the window listener.
|
||||
*/
|
||||
export function usePrimaryAction(routeId: string, handler: () => void): void {
|
||||
const handlerRef = useRef(handler);
|
||||
// Keep the ref pointing at the latest handler without re-subscribing the window listener
|
||||
// on every render. Updating a ref during render trips react-hooks/refs, so do it in an effect.
|
||||
useEffect(() => {
|
||||
handlerRef.current = handler;
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const listener = (event: Event) => {
|
||||
if ((event as CustomEvent<string>).detail === routeId) {
|
||||
handlerRef.current();
|
||||
}
|
||||
};
|
||||
window.addEventListener(PRIMARY_ACTION_EVENT, listener);
|
||||
return () => window.removeEventListener(PRIMARY_ACTION_EVENT, listener);
|
||||
}, [routeId]);
|
||||
}
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
type CreateChannelRequest
|
||||
} from '../api';
|
||||
import { navigateToPath } from '../routing';
|
||||
import { usePrimaryAction } from '../primaryAction';
|
||||
|
||||
type ChannelViewFilter = 'all' | 'onair' | 'disabled';
|
||||
|
||||
@@ -177,6 +178,9 @@ export function ChannelsScreen() {
|
||||
const [mutating, setMutating] = useState(false);
|
||||
const [creatingBlank, setCreatingBlank] = useState(false);
|
||||
|
||||
// TopBar "Add Channel" primary action → the library-to-lineup builder.
|
||||
usePrimaryAction('channels', () => navigateToPath('/app/new-channel'));
|
||||
|
||||
if (query.status === 'loading') {
|
||||
return <ChannelsLoadingState />;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useCallback, useEffect, useRef, useState, type ReactNode } from 'react';
|
||||
import { ArrowLeft, Check, Copy, Plus, SlidersHorizontal, Trash2, TriangleAlert } from 'lucide-react';
|
||||
import { navigateToPath } from '../routing';
|
||||
import { usePrimaryAction } from '../primaryAction';
|
||||
import { Badge, Button, Card, Checkbox, ConfirmDialog, IconButton, Input, Select, Spinner } from '../components';
|
||||
import {
|
||||
createFFmpegProfile,
|
||||
@@ -342,6 +343,9 @@ export function FFmpegProfilesScreen() {
|
||||
return () => window.removeEventListener('popstate', onPop);
|
||||
}, []);
|
||||
|
||||
// TopBar "Add Profile" primary action → the add editor route.
|
||||
usePrimaryAction('ffmpegProfiles', () => navigateToPath(`${BASE_PATH}/add`));
|
||||
|
||||
const mode = modeFromPath(window.location.pathname);
|
||||
|
||||
if (mode.kind === 'list') {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useCallback, useEffect, useRef, useState, type ReactNode } from 'react';
|
||||
import { ArrowLeft, Check, Film, Plus, Trash2, TriangleAlert } from 'lucide-react';
|
||||
import { navigateToPath } from '../routing';
|
||||
import { usePrimaryAction } from '../primaryAction';
|
||||
import { Badge, Button, Card, Checkbox, ConfirmDialog, IconButton, Input, Select, Spinner } from '../components';
|
||||
import {
|
||||
createFillerPreset,
|
||||
@@ -285,6 +286,9 @@ export function FillerPresetsScreen() {
|
||||
return () => window.removeEventListener('popstate', onPop);
|
||||
}, []);
|
||||
|
||||
// TopBar "Add Filler Preset" primary action → the add editor route.
|
||||
usePrimaryAction('fillerPresets', () => navigateToPath(`${BASE_PATH}/add`));
|
||||
|
||||
const mode = modeFromPath(pathname);
|
||||
|
||||
if (mode.kind === 'list') {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { ArrowLeft, Check, Layers, Plus, Trash2, TriangleAlert } from 'lucide-react';
|
||||
import { Badge, Button, Card, ConfirmDialog, IconButton, Input, Select, Spinner, Switch } from '../components';
|
||||
import { usePrimaryAction } from '../primaryAction';
|
||||
import {
|
||||
ApiError,
|
||||
createMultiCollection,
|
||||
@@ -368,6 +369,9 @@ export function MultiCollectionsScreen() {
|
||||
const [deleteBusy, setDeleteBusy] = useState(false);
|
||||
const [deleteError, setDeleteError] = useState<string | null>(null);
|
||||
|
||||
// TopBar "Add Multi-Collection" primary action.
|
||||
usePrimaryAction('multiCollections', () => setEditing({ kind: 'new' }));
|
||||
|
||||
if (editing) {
|
||||
return (
|
||||
<MultiCollectionEditor
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { ArrowLeft, Check, Plus, Repeat, Trash2, TriangleAlert } from 'lucide-react';
|
||||
import { Badge, Button, Card, ConfirmDialog, IconButton, Input, Select, Spinner } from '../components';
|
||||
import type { SelectOption } from '../components';
|
||||
import { usePrimaryAction } from '../primaryAction';
|
||||
import {
|
||||
ApiError,
|
||||
createRerunCollection,
|
||||
@@ -448,6 +449,9 @@ export function RerunCollectionsScreen() {
|
||||
const [deleteBusy, setDeleteBusy] = useState(false);
|
||||
const [deleteError, setDeleteError] = useState<string | null>(null);
|
||||
|
||||
// TopBar "Add Rerun Collection" primary action.
|
||||
usePrimaryAction('rerunCollections', () => setEditing({ kind: 'new' }));
|
||||
|
||||
if (editing) {
|
||||
return (
|
||||
<RerunCollectionEditor
|
||||
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
replaceScheduleItems
|
||||
} from '../api';
|
||||
import { registerNavigationGuard } from '../navigationGuard';
|
||||
import { usePrimaryAction } from '../primaryAction';
|
||||
import {
|
||||
copyDraftItem,
|
||||
type DraftItem,
|
||||
@@ -188,15 +189,7 @@ export function SchedulesScreen() {
|
||||
}, [dirty]);
|
||||
|
||||
// ---- TopBar "Add Schedule" primary action ------------------------------
|
||||
useEffect(() => {
|
||||
const handler = (event: Event) => {
|
||||
if ((event as CustomEvent<string>).detail === 'schedules') {
|
||||
setForm('create');
|
||||
}
|
||||
};
|
||||
window.addEventListener('ctv:primary-action', handler);
|
||||
return () => window.removeEventListener('ctv:primary-action', handler);
|
||||
}, []);
|
||||
usePrimaryAction('schedules', () => setForm('create'));
|
||||
|
||||
if (boot.status === 'loading') {
|
||||
return (
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { ArrowLeft, Pencil, Plus, RefreshCw, Search, Trash2, TriangleAlert } from 'lucide-react';
|
||||
import { navigateToPath } from '../routing';
|
||||
import { usePrimaryAction } from '../primaryAction';
|
||||
import { Badge, Button, Card, ConfirmDialog, Dialog, IconButton, Input, Spinner, Switch } from '../components';
|
||||
import {
|
||||
addTraktList,
|
||||
@@ -276,6 +277,18 @@ export function TraktListsScreen() {
|
||||
const [rowError, setRowError] = useState<string | null>(null);
|
||||
const activeRef = useRef(true);
|
||||
|
||||
// TopBar "Add Trakt List" primary action. On the list, open the add dialog. On the
|
||||
// detail sub-route (/app/trakt-lists/{id}) the add dialog isn't mounted and this component
|
||||
// is keyed by pathname (so state wouldn't survive a navigate), so route back to the list
|
||||
// instead of flipping a state nobody renders — avoids a silent no-op button (#238 review).
|
||||
usePrimaryAction('traktLists', () => {
|
||||
if (editingId != null) {
|
||||
navigateToPath(TRAKT_BASE_PATH);
|
||||
} else {
|
||||
setAddOpen(true);
|
||||
}
|
||||
});
|
||||
|
||||
// Fetch only; state updates happen in the async callbacks (never synchronously in the
|
||||
// effect body). `loading` starts true and flips false in `finally`, so the mount effect
|
||||
// shows the spinner; later quiet reloads (e.g. after a busy -> idle transition) never
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useCallback, useEffect, useRef, useState, type ReactNode } from 'react';
|
||||
import { ArrowLeft, Check, Copy, Image as ImageIcon, Plus, Trash2, TriangleAlert, Upload } from 'lucide-react';
|
||||
import { navigateToPath } from '../routing';
|
||||
import { usePrimaryAction } from '../primaryAction';
|
||||
import { Badge, Button, Card, Checkbox, ConfirmDialog, IconButton, Input, Select, Spinner } from '../components';
|
||||
import {
|
||||
createWatermark,
|
||||
@@ -188,6 +189,9 @@ export function WatermarksScreen() {
|
||||
return () => window.removeEventListener('popstate', onPop);
|
||||
}, []);
|
||||
|
||||
// TopBar "Add Watermark" primary action → the add editor route.
|
||||
usePrimaryAction('watermarks', () => navigateToPath(`${BASE_PATH}/add`));
|
||||
|
||||
const mode = modeFromPath(pathname);
|
||||
|
||||
if (mode.kind === 'list') {
|
||||
|
||||
Reference in New Issue
Block a user