Compare commits

...
Author SHA1 Message Date
timothyandClaude Fable 5 f18f2dc058 fix(web): schedule editor review fixes for #86
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 4m33s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 5m16s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
- Add a schedule selector (previously only the first schedule was ever
  shown); switching loads items only, with a pane-level spinner
- Fix duplicated mount effect that fetched the whole screen twice
- Add a collection picker to the add-item flow (was silently using the
  alphabetically-first collection with no way to correct it)
- Reorder now reconciles from the PUT response; add/delete refetch items
  only — pickers are no longer refetched on every mutation
- Guard reorder against double-submit via a ref; remove the inert Save
  button; document the widened item type (OpenAPI gap → #126)
- Inspector fidelity: Tail filler row + Keep multi-part together
- a11y: aria-current for the active item, draggable role description
- CSS: correct --text-md fallbacks; replace nonexistent --text-faint
  with --text-disabled in new blocks
- Tests: single-fetch-on-mount assertion, chosen-collection POST body,
  picker-failure path, collection() fixture with all required fields;
  drop unreachable mock branch (52 tests)

Review: PR #125 findings; refs #126

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 12:16:53 +02:00
timothy 2e58fa1815 feat(web): add schedule editor screen
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 4m7s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 4m44s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
2026-07-05 11:43:04 +02:00
timothy 7ee6e9870c Merge pull request 'feat(web): Channels screen (#84)' (#124) from feat/84-channels-screen into main
Build ErsatzTV Image / Build & push image (amd64) (push) Has been cancelled
Build ErsatzTV Image / Build & test (.NET) (push) Has been cancelled
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Has been cancelled
2026-07-05 09:26:22 +00:00
timothyandClaude Fable 5 ecf6cf7513 fix(web): scope channel bulk ops to visible rows, review fixes for #84
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 4m6s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 4m58s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
- Derive selectedChannels from visibleChannels and clear selection on
  filter change so bulk operations can never act on rows hidden by the
  active filter (reachable via the 30s state poll under the on-air filter)
- Disable the per-row delete button while a mutation is in flight
- Correct the --pad-cell-x fallback (token resolves to 16px)
- Add tests: bulk renumber request body + selection clearing, and
  selection reset across filter changes

Review: PR #124 findings 1, 2, 4, 8

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 11:20:32 +02:00
timothy 15c6cdd086 feat(web): add Channels screen
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 4m26s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 4m55s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
refs #84
2026-07-05 10:41:29 +02:00
timothyandClaude Fable 5 39d76080de docs: advance ChicoryTV issue queue past #109 (PR #123); next prompt = #84 Channels screen (Codex)
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 4m15s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 4m56s
Build ErsatzTV Image / Build & push image (amd64) (push) Successful in 3m21s
Records: main=4ffd777b, review-fix cycle lesson (fixture values must come from
backend serializers), health no-poll policy, baselines 368/493 + 31 web tests.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-04 21:23:24 +02:00
timothy 4ffd777b49 Merge pull request 'feat(web): Dashboard real data sources (#109)' (#123) from feat/109-dashboard-data into main
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 4m42s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 5m21s
Build ErsatzTV Image / Build & push image (amd64) (push) Successful in 3m31s
2026-07-04 19:20:38 +00:00
7 changed files with 3233 additions and 86 deletions
+90 -77
View File
@@ -8,30 +8,38 @@ builds are limited to 2–3 concurrent, never wide fan-outs. Backend gaps all la
2026-07-04 (#105/#108/#100+#101+#107+#110/#103+#106/#104/#111/#102 → PRs #113–#119). The
MERGE PASS (2026-07-04) then landed the whole SPA stack on main via PR #120.
**Session state (2026-07-04, post-#121 merge)**: main = cc6ffcb8 (PR #121 "#99 direct
stream session tracking"): design-system/ + web/ SPA foundation (#78–#83), backend
#96/#98/#97, Dockerfile hotfix #122, and direct-stream session tracker #99 support are all
on main. #97 is closed; #99 remains open for the final `/api/channels/state` wiring only
(combine `IFFmpegSegmenterService.IsActive` with `IDirectStreamSessionTracker`). Test
baselines on main after #121: ErsatzTV.Tests **368**, Core.Tests **493** (+1 skip); web/:
`npm run typecheck` + `build` clean (outputs to ErsatzTV/wwwroot/app/, gitignored; SPA
types = web/src/api/generated/v1.d.ts, regen via `npm run generate:api`). Post-merge main
run 224 is fully green, including `Build & push image (amd64)`.
- **#99 / PR #121 state**: merged after Fable APPROVE. Round-2 fixes removed
`DirectStreamSessionTracker` empty-dictionary pruning to avoid orphaned live sessions and
bypassed direct-session tracking for HEAD probes in `TrackedFileStreamResult`.
- Worktrees: .worktrees/issue-99-session-tracking can be removed after this doc update;
issue-97/feat-102 removed. The main checkout still sits on docs/59-ui-redesign-brief —
fully merged now, safe to switch to main.
**Session state (2026-07-04, post-#109)**: main = 4ffd777b (PR #123 "Dashboard real data
sources"): the Dashboard is the first SPA screen running fully on live API data. On main:
design-system/ + web/ foundation (#78–#83), backend #96/#98/#97, Dockerfile hotfix #122,
direct-stream session tracker (#99 seam, PR #121), Dashboard data (#109, PR #123). #109
closed. #99 remains open ONLY for the final `/api/channels/state` wiring (combine
`IFFmpegSegmenterService.IsActive` with `IDirectStreamSessionTracker`). Baselines:
ErsatzTV.Tests **368**, Core.Tests **493** (+1 skip); web tests **31** (App.test.tsx 20);
web lint/typecheck/build clean (build output ErsatzTV/wwwroot/app/, gitignored; SPA types
web/src/api/generated/v1.d.ts, regen `npm run generate:api`).
- **#109 review cycle (pattern that worked)**: Codex implemented → Fable review caught 1
substantial bug (health widget matched status strings the API never emits —
`error/failed/unhealthy`+`warning/degraded` vs the REAL `pass|fail|warn|info` from
Health/Mapper.cs; fictional test fixtures masked it) + 4 nits → all fixed in-session by a
sonnet subagent (e6197d63) → CI green → merged. LESSON: frontend tests must use fixture
values copied from the backend serializer, not invented ones; reviewers should diff
fixtures against the producing C# code.
- Dashboard health policy (binding for future screens): /api/health is fetched on mount +
guarded manual refresh ONLY (no intervals) — it re-runs ~14 checks per request. A backend
TTL cache is the unfiled prerequisite for any live health display.
- Worktrees: remove .worktrees/issue-99-session-tracking and .worktrees/issue-109-dashboard
(both merged). The main checkout still sits on docs/59-ui-redesign-brief — fully merged,
safe to switch to main.
**Lessons for all remaining prompts** (accumulated):
- Frontend fixtures/contracts: copy enum-ish string values from the producing backend code
(serializers/mappers), never invent them; review = diff fixtures vs C#. (#109's bug.)
- The image-build job runs ONLY on main pushes — PR CI cannot catch Dockerfile breakage.
Any Dockerfile change: verify base tags exist (hub.docker.com API) and, where docker is
available, `docker build --target <stage>` locally before merging. (`node:22-noble` cost
a red main run; node official images have no `-noble` variant.)
- DTO records in ErsatzTV.Core/Api MUST get file-scoped `#nullable enable` — else the spec
emits `["null","string"]` unions and SPA types get needless `| null`. #96/#98 DTOs were
retrofitted in 8d89ab16; ChannelStateResponseModel complied from the start. NOTE:
emits `["null","string"]` unions and SPA types get needless `| null`. NOTE:
ErsatzTV.Application has NO nullable context — `string?` there trips CS8632.
- NSubstitute + ConfigElementKey: `ConfigElementKey.X` is a fresh instance per access —
stub with `Arg.Any<ConfigElementKey>()` disambiguated by the generic `<T>`.
@@ -44,92 +52,97 @@ run 224 is fully green, including `Build & push image (amd64)`.
Create/Delete lack route Name=; PlayoutController.GetItems existence pre-check is heavy;
>30 MB uploads return bare Kestrel 413; artwork content-type trusted from client (#66);
schedule duration estimator: expression/zero Count → null estimate, materializes each
referenced collection per GET. From #102 (also on #85): GET /api/guide runs the full
21-include eager-load per channel per request — trim projection or add caching before the
EPG grid polls it; `fillerKind` is almost always `None` (projector merges filler into
adjacent programmes) — discrete filler entries need a JSON-only projection mode.
referenced collection per GET; /api/health TTL cache (prereq for live health UI). From
#102 (also on #85): GET /api/guide runs the full 21-include eager-load per channel per
request — trim projection or add caching before the EPG grid polls it; `fillerKind` is
almost always `None` (projector merges filler into adjacent programmes) — discrete filler
entries need a JSON-only projection mode.
---
# PROMPT FOR CODEX — #109: Dashboard follow-up — replace stubbed data with real sources
# PROMPT FOR CODEX — #84: Channels screen (management table + live treatment)
You are Codex working solo in /Users/timothy/ersatztv (ErsatzTV fork; the React SPA
"ChicoryTV" lives in web/ — Vite + TS, typed client in web/src/api/, generated types
web/src/api/generated/v1.d.ts). Read CLAUDE.md and docs/contributing.md first; follow the
SPA foundation's existing patterns (#78–#83) — match, don't invent.
SPA foundation's existing patterns (#78–#83) and the Dashboard's data-layer idioms from
PR #123 (web/src/api/dashboard.ts hooks with the `active` unmount guard) — match, don't
invent.
HARD CONSTRAINTS:
- Work in a NEW git worktree: `git worktree add .worktrees/issue-109-dashboard -b
feat/109-dashboard-data origin/main` (branch from origin/main; never touch the main
- Work in a NEW git worktree: `git worktree add .worktrees/issue-84-channels -b
feat/84-channels-screen origin/main` (branch from origin/main; never touch the main
checkout or other .worktrees/*).
- Max 2–3 concurrent builds machine-wide; keep to ONE dotnet build at a time here.
- NEVER set ETV_UPDATE_GOLDENS. A golden-file diff means your code is wrong.
- Do NOT merge anything. Open the PR, get CI green, and stop.
- Backend scope guard: this is a FRONTEND issue. If a widget needs data no endpoint
provides, stub that widget's source cleanly and note it in the PR — do not add endpoints.
- Backend scope guard: FRONTEND issue. All needed endpoints exist (#96/#98/#97 below).
If something is missing, note it in the PR and stub cleanly — do not add endpoints.
- CONTRACT RULE (#109 lesson): any string value you branch on or use in test fixtures must
be copied from the producing backend code (mapper/serializer) or the generated v1.d.ts —
never invented.
## Context
- main = post-#120/#122/#121: full backend read API + SPA foundation + direct-stream
session tracking seam. Baselines: ErsatzTV.Tests 368, Core.Tests 493(+1 skip); web
`npm run typecheck` + `npm run build` clean.
- main = 4ffd777b (post-#123). Baselines: ErsatzTV.Tests 368, Core.Tests 493(+1 skip);
web tests 31, lint/typecheck/build clean.
Gitea: http://192.168.1.95:3000/timothy/ersatztv (basic auth timothy:ded89Lm4).
- Issue #109: the Dashboard screen (#83) shipped with stubbed data. Replace the stubs with
the real sources now on main: /api/health (system health), /api/channels +
/api/channels/state (channel counts / on-air), /api/playouts (playout status),
/api/version. Read the issue body for the agreed widget list, and locate the stub layer
from #83 in web/src/ before writing anything.
- CRITICAL (#108 lesson): GET /api/health re-runs all ~14 checks per request (only the
warn/error summary is cached) — fetch health ON DEMAND (mount + manual refresh), NOT on a
fast poll. If live health is wanted, that's a backend TTL-cache follow-up issue, not this
PR. Other widgets may poll gently (≥30s) if the design calls for it.
- Note: /api/channels/state `onAir` is still segmenter-session-based until the final #99
wiring combines it with `IDirectStreamSessionTracker` — render it as-is; no workarounds.
- Issue #84: the Channels screen — read its body AND the #59 epic's "Definition of Ready"
for screen issues. Visual reference: design-system/templates/chicorytv-admin/Channels.jsx
(static prototype) + the design brief; reuse web/src/components/* primitives.
- Available data (all on main):
- GET /api/channels — enriched list (#96): group, enabled, EPG visibility, sort number…
- Bulk ops (#98): POST /api/channels/bulk/renumber | /bulk/group | /bulk/delete
(204 on success, 404/422 with ProblemDetails — see OpenApiErrorResponseContractTests).
- GET /api/channels/state (#97): onAir + nowPlaying (nullable!) for live badges/rows.
onAir is still segmenter-session-based until the final #99 wiring — render as-is.
- Channel CRUD: POST/PUT/DELETE /api/channels{,/id} for create/edit/delete flows if the
issue's scope includes them (check the issue body; the full Channel Builder is #89 —
do NOT build it here).
- Polling: channel state may poll gently (≥30s) if the design calls for live updates;
/api/health is NOT this screen's concern.
## Process
1. Comment on issue #109 with findings + approach before coding.
2. Implement: swap stubs for typed client calls (follow #81's client/query patterns);
loading/error/empty states per the design-system components. No `any` casts around
generated types.
3. Verify: `cd web && npm ci && npm run typecheck && npm run build`; plus
`dotnet build ErsatzTV.sln` and TZ=UTC dotnet test of ErsatzTV.Tests + Core.Tests
(sequentially) to prove no backend regression (expect 364 / 488+1skip). If feasible, run
the SPA against a live backend (dotnet run + vite dev proxy) and sanity-check the
Dashboard renders real data.
4. Push, open PR → main: `feat(web): Dashboard real data sources (#109)`, body lists each
widget → endpoint mapping and the health fetch policy; `closes #109`. Poll CI by head SHA
(/api/v1/repos/timothy/ersatztv/commits/<sha>/status) until green. Do not merge.
5. Comment progress on #109 as you go.
1. Comment on issue #84 with findings + approach (table/widget → endpoint mapping,
which CRUD flows are in/out of scope) before coding.
2. Implement with loading/error/empty states per design-system; bulk selection UX per the
prototype; prefer refetch-after-mutate over optimistic updates unless the foundation
already has an optimistic idiom (it does not, as of #123).
3. Verify: `cd web && npm ci && npm run lint && npm run typecheck && npm test -- --run &&
npm run build`; plus `dotnet build ErsatzTV.sln` + TZ=UTC dotnet test ErsatzTV.Tests +
Core.Tests sequentially (expect 368 / 493+1skip — no backend regression).
4. Push, open PR → main: `feat(web): Channels screen (#84)`, body lists the mapping and
scope decisions; `closes #84`. Poll CI by head SHA until green. Do not merge.
5. Comment progress on #84 as you go.
## On completion — REQUIRED final output
Print a fenced handoff prompt addressed to Claude (Fable) asking it to:
- Review the PR diff READ-ONLY in one Fable pass (endpoint usage correctness, health
polling discipline, type safety, design-system adherence, scope).
- Classify findings: NITS Fable may fix directly on the branch; SUBSTANTIAL issues (wrong
data contracts, polling violations, state-management flaws) go back to Codex — Fable
composes a follow-up Codex prompt with the findings verbatim.
- After review: comment the verdict on the PR and #109; on approval + user "merge" consent,
merge, verify main's post-merge run (image job included!), then update THIS handoff file
(pop #109, next prompt = #84 Channels screen for Codex, record PR number + main SHA +
- Review the PR diff READ-ONLY in one Fable pass (endpoint/contract correctness incl.
fixture values vs backend serializers, bulk-op error handling, polling discipline,
type safety, design-system adherence, scope).
- Classify findings: NITS Fable may fix directly on the branch; SUBSTANTIAL issues either
go back to Codex as a verbatim prompt or get fixed in-session by subagents — the user
decides which at review time.
- After review: comment the verdict on the PR and #84; on approval + user merge consent,
merge, verify main's post-merge run (image job included), then update THIS handoff file
(pop #84, next prompt = #86 Schedule editor for Codex, record PR number + main SHA +
baselines) and push it to main.
Include: PR number, branch, head SHA, files changed, widget→endpoint mapping, test/web
results, anything deferred or uncertain.
Include: PR number, branch, head SHA, files changed, mapping table, test/web results,
anything deferred or uncertain.
---
## Issue queue (work top-down)
0. HOUSEKEEPING (carry into next session): remove .worktrees/issue-99-session-tracking when
no longer needed; #99 remains open for the final `/api/channels/state` onAir wiring.
1. #109 Dashboard follow-up ← CODEX PROMPT above (review/merge/doc-update falls to the
Fable session that Codex's end-of-run handoff spawns).
2. #84 Channels screen (deps #96/#98/#97 on main; live treatment via /api/channels/state —
note its onAir is segmenter-only until #99/#121 lands and gets wired).
3. UX conversion order: #86 Schedule editor (#111 durations) → #87 Playouts → #88 Libraries
0. HOUSEKEEPING (carry into next session): remove .worktrees/issue-99-session-tracking and
.worktrees/issue-109-dashboard if still present; #99 stays open for the final
`/api/channels/state` onAir wiring (good small backend slot-filler between screens).
1. #84 Channels screen ← CODEX PROMPT above (review/merge/doc-update falls to the Fable
session that Codex's end-of-run handoff spawns).
2. UX conversion order: #86 Schedule editor (#111 durations) → #87 Playouts → #88 Libraries
→ #85 EPG (#102 JSON guide; mind fillerKind + per-request-cost notes above) → #89 Channel
Builder (#104 artwork upload; also needs #62: #63/#64/#65) → #93 Settings → #90 rebrand
→ #91 cutover.
Cross-refs: #99 direct-session tracking seam landed via PR #121; final channel-state wiring
still open on #99. Done this pass: MERGE PASS → PR #120 (closed #97; #78–#83/#96/#98
validated on main), hotfix PR #122 (Dockerfile node tag), PR #121 (direct-stream session
tracking). Languages-list endpoint from #105 still unimplemented — open a follow-up when
#86/#89 need it;
filler/watermark lists return DB order — SPA should client-sort.
Cross-refs: #99 seam landed (PR #121), final wiring open. Done this pass: MERGE PASS →
PR #120 (closed #97; validated #78–#83/#96/#98), hotfix PR #122, PR #121 (#99 seam),
PR #123 (#109 Dashboard — first live-data screen). Languages-list endpoint from #105 still
unimplemented — open a follow-up when #86/#89 need it; filler/watermark lists return DB
order — SPA should client-sort.
+930 -5
View File
File diff suppressed because it is too large Load Diff
+1097 -3
View File
File diff suppressed because it is too large Load Diff
+169 -1
View File
@@ -1,8 +1,176 @@
import { request } from './client';
import { useCallback, useEffect, useRef, useState } from 'react';
import { ApiError, request } from './client';
import type { components } from './generated/v1';
export type ChannelSummary = components['schemas']['ChannelResponseModel'];
export type ChannelState = components['schemas']['ChannelStateResponseModel'];
export type BulkRenumberChannelsRequest = components['schemas']['BulkRenumberChannelsRequest'];
export type BulkMoveChannelsToGroupRequest = components['schemas']['BulkMoveChannelsToGroupRequest'];
export type BulkDeleteChannelsRequest = components['schemas']['BulkDeleteChannelsRequest'];
export interface ChannelsScreenData {
channels: ChannelSummary[];
channelStates: ChannelState[];
}
export type ChannelsScreenQueryState =
| { data: ChannelsScreenData; error: null; refresh: () => void; status: 'success' }
| { data: null; error: string; refresh: () => void; status: 'error' }
| { data: null; error: null; refresh: () => void; status: 'loading' };
type ChannelsScreenState =
| { data: ChannelsScreenData; error: null; status: 'success' }
| { data: null; error: string; status: 'error' }
| { data: null; error: null; status: 'loading' };
export function getChannels(): Promise<ChannelSummary[]> {
return request<ChannelSummary[]>('/api/channels');
}
export function getChannelStates(): Promise<ChannelState[]> {
return request<ChannelState[]>('/api/channels/state');
}
export async function getChannelsScreenData(): Promise<ChannelsScreenData> {
const [channels, channelStates] = await Promise.all([
getChannels(),
getChannelStates()
]);
return { channels, channelStates };
}
export function bulkRenumberChannels(body: BulkRenumberChannelsRequest): Promise<void> {
return request<void>('/api/channels/bulk/renumber', {
body,
method: 'POST'
});
}
export function bulkMoveChannelsToGroup(body: BulkMoveChannelsToGroupRequest): Promise<void> {
return request<void>('/api/channels/bulk/group', {
body,
method: 'POST'
});
}
export function bulkDeleteChannels(body: BulkDeleteChannelsRequest): Promise<void> {
return request<void>('/api/channels/bulk/delete', {
body,
method: 'POST'
});
}
export function deleteChannel(channelId: number): Promise<void> {
return request<void>(`/api/channels/${channelId}`, {
method: 'DELETE'
});
}
export function useChannelsScreenQuery(pollMs = 30000): ChannelsScreenQueryState {
const [state, setState] = useState<ChannelsScreenState>({
data: null,
error: null,
status: 'loading'
});
const activeRef = useRef(true);
useEffect(() => {
activeRef.current = true;
return () => {
activeRef.current = false;
};
}, []);
const load = useCallback((showLoading = true) => {
if (showLoading) {
setState({ data: null, error: null, status: 'loading' });
}
getChannelsScreenData()
.then((data) => {
if (activeRef.current) {
setState({ data, error: null, status: 'success' });
}
})
.catch((error: unknown) => {
if (activeRef.current) {
setState({ data: null, error: messageFromError(error), status: 'error' });
}
});
}, []);
const loadChannelStates = useCallback(() => {
getChannelStates()
.then((channelStates) => {
if (activeRef.current) {
setState((current) => {
if (current.status !== 'success') {
return current;
}
return {
data: { ...current.data, channelStates },
error: null,
status: 'success'
};
});
}
})
.catch(() => {
// Keep the current table visible on background polling failures.
});
}, []);
useEffect(() => {
const loadForEffect = () => {
getChannelsScreenData()
.then((data) => {
if (activeRef.current) {
setState({ data, error: null, status: 'success' });
}
})
.catch((error: unknown) => {
if (activeRef.current) {
setState({ data: null, error: messageFromError(error), status: 'error' });
}
});
};
loadForEffect();
const intervalId = window.setInterval(() => {
loadChannelStates();
}, pollMs);
return () => {
window.clearInterval(intervalId);
};
}, [loadChannelStates, pollMs]);
const refresh = useCallback(() => {
load();
}, [load]);
if (state.status === 'success') {
return { data: state.data, error: null, refresh, status: 'success' };
}
if (state.status === 'error') {
return { data: null, error: state.error, refresh, status: 'error' };
}
return { data: null, error: null, refresh, status: 'loading' };
}
export function messageFromError(error: unknown): string {
if (error instanceof ApiError) {
return error.detail ?? error.message;
}
if (error instanceof Error) {
return error.message;
}
return 'Unable to load channels';
}
+1
View File
@@ -2,4 +2,5 @@ export * from './auth';
export * from './channels';
export * from './client';
export * from './dashboard';
export * from './schedules';
export * from './useChannelsQuery';
+304
View File
@@ -0,0 +1,304 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { ApiError, request } from './client';
import type { components } from './generated/v1';
export type ProgramSchedule = components['schemas']['ProgramScheduleViewModel'];
// These fields are real on the wire (the backend serializes ProgramScheduleItemViewModel
// subtypes with Newtonsoft using the runtime type), but the OpenAPI schema doesn't declare
// them: ProgramScheduleItemViewModel is an abstract base with no polymorphism annotation, so
// the generator only sees the base shape. Tracked in Gitea issue #126 — remove this widening
// once the schema is fixed to describe the concrete subtypes.
export type ProgramScheduleItem = components['schemas']['ProgramScheduleItemViewModel'] & {
count?: null | string;
discardToFillAttempts?: null | number;
multipleCount?: null | string;
multipleMode?: components['schemas']['MultipleMode'];
playoutDuration?: null | string;
tailMode?: components['schemas']['TailMode'];
};
export type ProgramScheduleItemsWithDuration = components['schemas']['ProgramScheduleItemsWithDurationViewModel'];
export type ScheduleItemRequest = components['schemas']['ScheduleItemRequest'];
export type ReplaceScheduleItemsRequest = components['schemas']['ReplaceScheduleItemsRequest'];
export type MediaCollection = components['schemas']['MediaCollectionViewModel'];
export type SmartCollection = components['schemas']['SmartCollectionViewModel'];
export type FillerPreset = components['schemas']['FillerPresetResponseModel'];
export type Watermark = components['schemas']['WatermarkResponseModel'];
export interface SchedulePickerData {
collections: MediaCollection[];
fillerPresets: FillerPreset[];
smartCollections: SmartCollection[];
watermarks: Watermark[];
}
export interface ScheduleScreenData {
activeSchedule: ProgramSchedule | null;
items: ProgramScheduleItem[];
pickers: SchedulePickerData;
schedules: ProgramSchedule[];
totalDurationEstimate: string | null;
}
export type ScheduleScreenQueryState =
| {
data: ScheduleScreenData;
error: null;
itemsLoading: boolean;
refresh: () => void;
setActiveSchedule: (scheduleId: number) => void;
setItems: (items: ProgramScheduleItem[]) => void;
status: 'success';
}
| { data: null; error: string; refresh: () => void; status: 'error' }
| { data: null; error: null; refresh: () => void; status: 'loading' };
type ScheduleScreenState =
| { data: ScheduleScreenData; error: null; itemsLoading: boolean; status: 'success' }
| { data: null; error: string; status: 'error' }
| { data: null; error: null; status: 'loading' };
export function getSchedules(): Promise<ProgramSchedule[]> {
return request<ProgramSchedule[]>('/api/schedules');
}
export function getScheduleItems(scheduleId: number): Promise<ProgramScheduleItemsWithDuration> {
return request<ProgramScheduleItemsWithDuration>(`/api/schedules/${scheduleId}/items`);
}
export function getCollections(): Promise<MediaCollection[]> {
return request<MediaCollection[]>('/api/collections');
}
export function getSmartCollections(): Promise<SmartCollection[]> {
return request<SmartCollection[]>('/api/smart-collections');
}
export function getFillerPresets(): Promise<FillerPreset[]> {
return request<FillerPreset[]>('/api/filler-presets');
}
export function getWatermarks(): Promise<Watermark[]> {
return request<Watermark[]>('/api/watermarks');
}
export function addScheduleItem(scheduleId: number, body: ScheduleItemRequest): Promise<ProgramScheduleItem> {
return request<ProgramScheduleItem>(`/api/schedules/${scheduleId}/items`, {
body,
method: 'POST'
});
}
export function replaceScheduleItems(
scheduleId: number,
body: ReplaceScheduleItemsRequest
): Promise<ProgramScheduleItem[]> {
return request<ProgramScheduleItem[]>(`/api/schedules/${scheduleId}/items`, {
body,
method: 'PUT'
});
}
export function deleteScheduleItem(scheduleId: number, itemId: number): Promise<void> {
return request<void>(`/api/schedules/${scheduleId}/items/${itemId}`, {
method: 'DELETE'
});
}
export async function getScheduleScreenData(): Promise<ScheduleScreenData> {
const schedules = await getSchedules();
const activeSchedule = schedules[0] ?? null;
if (!activeSchedule) {
return {
activeSchedule: null,
items: [],
pickers: emptyPickerData(),
schedules,
totalDurationEstimate: null
};
}
const [itemsEnvelope, pickers] = await Promise.all([
getScheduleItems(activeSchedule.id),
getSchedulePickerData()
]);
return {
activeSchedule,
items: (itemsEnvelope.items ?? []) as ProgramScheduleItem[],
pickers,
schedules,
totalDurationEstimate: itemsEnvelope.totalDurationEstimate
};
}
export function useScheduleScreenQuery(): ScheduleScreenQueryState {
const [state, setState] = useState<ScheduleScreenState>({
data: null,
error: null,
status: 'loading'
});
const activeRef = useRef(true);
const activeScheduleIdRef = useRef<number | null>(null);
useEffect(() => {
activeRef.current = true;
return () => {
activeRef.current = false;
};
}, []);
const load = useCallback(() => {
getScheduleScreenData()
.then((data) => {
if (activeRef.current) {
activeScheduleIdRef.current = data.activeSchedule?.id ?? null;
setState({ data, error: null, itemsLoading: false, status: 'success' });
}
})
.catch((error: unknown) => {
if (activeRef.current) {
setState({ data: null, error: messageFromScheduleError(error, 'Unable to load schedules'), status: 'error' });
}
});
}, []);
useEffect(() => {
load();
}, [load]);
const refresh = useCallback(() => {
setState({ data: null, error: null, status: 'loading' });
load();
}, [load]);
const setActiveSchedule = useCallback((scheduleId: number) => {
activeScheduleIdRef.current = scheduleId;
setState((current) => {
if (current.status !== 'success') {
return current;
}
const nextActiveSchedule = current.data.schedules.find((schedule) => schedule.id === scheduleId)
?? current.data.activeSchedule;
return {
data: { ...current.data, activeSchedule: nextActiveSchedule },
error: null,
itemsLoading: true,
status: 'success'
};
});
getScheduleItems(scheduleId)
.then((itemsEnvelope) => {
if (!activeRef.current || activeScheduleIdRef.current !== scheduleId) {
return;
}
setState((current) => {
if (current.status !== 'success') {
return current;
}
return {
data: {
...current.data,
items: (itemsEnvelope.items ?? []) as ProgramScheduleItem[],
totalDurationEstimate: itemsEnvelope.totalDurationEstimate
},
error: null,
itemsLoading: false,
status: 'success'
};
});
})
.catch((error: unknown) => {
if (!activeRef.current || activeScheduleIdRef.current !== scheduleId) {
return;
}
setState({
data: null,
error: messageFromScheduleError(error, 'Unable to load schedule items'),
status: 'error'
});
});
}, []);
const setItems = useCallback((items: ProgramScheduleItem[]) => {
setState((current) => {
if (current.status !== 'success') {
return current;
}
return {
data: { ...current.data, items },
error: null,
itemsLoading: false,
status: 'success'
};
});
}, []);
if (state.status === 'success') {
return {
data: state.data,
error: null,
itemsLoading: state.itemsLoading,
refresh,
setActiveSchedule,
setItems,
status: 'success'
};
}
if (state.status === 'error') {
return { data: null, error: state.error, refresh, status: 'error' };
}
return { data: null, error: null, refresh, status: 'loading' };
}
async function getSchedulePickerData(): Promise<SchedulePickerData> {
const [collections, smartCollections, fillerPresets, watermarks] = await Promise.all([
getCollections(),
getSmartCollections(),
getFillerPresets(),
getWatermarks()
]);
return {
collections,
fillerPresets: sortByName(fillerPresets),
smartCollections,
watermarks: sortByName(watermarks)
};
}
function emptyPickerData(): SchedulePickerData {
return {
collections: [],
fillerPresets: [],
smartCollections: [],
watermarks: []
};
}
function sortByName<T extends { name: null | string }>(items: T[]): T[] {
return [...items].sort((left, right) => (left.name ?? '').localeCompare(right.name ?? ''));
}
function messageFromScheduleError(error: unknown, fallback = 'Unable to load schedules'): string {
if (error instanceof ApiError) {
return error.detail ?? error.message;
}
if (error instanceof Error) {
return error.message;
}
return fallback;
}
+642
View File
@@ -703,6 +703,611 @@
text-transform: uppercase;
}
.ctv-channels-screen {
display: grid;
gap: var(--space-5, 10px);
}
.ctv-channels-actionbar {
min-height: 58px;
display: flex;
align-items: center;
gap: var(--space-5, 10px);
border: 1px solid var(--border-hairline);
border-radius: var(--radius-md, 7px);
background: var(--surface-card);
padding: var(--space-5, 10px) var(--space-7, 16px);
}
.ctv-segmented {
display: inline-flex;
gap: 2px;
border: 1px solid var(--border-hairline);
border-radius: var(--radius-sm, 5px);
background: var(--ctv-bg-sunken);
padding: 2px;
}
.ctv-segmented button {
height: 26px;
display: inline-flex;
align-items: center;
gap: var(--space-3, 6px);
border: 0;
border-radius: var(--radius-xs, 3px);
background: transparent;
color: var(--text-secondary);
cursor: pointer;
padding: 0 11px;
font: inherit;
font-size: var(--text-xs, 12px);
}
.ctv-segmented button[aria-pressed="true"] {
background: var(--ctv-surface-3);
color: var(--text-primary);
font-weight: var(--weight-medium, 500);
}
.ctv-segmented code,
.ctv-channels-selected code,
.ctv-channels-live code,
.ctv-channels-footer code,
.ctv-channel-group-row code,
.ctv-channel-number code {
font-family: var(--font-mono, ui-monospace, monospace);
font-variant-numeric: tabular-nums;
}
.ctv-channels-selected,
.ctv-channels-live {
display: inline-flex;
align-items: center;
gap: var(--space-3, 6px);
color: var(--text-secondary);
font-size: var(--text-xs, 12px);
}
.ctv-channels-selected {
color: var(--text-primary);
font-size: var(--text-sm, 13px);
font-weight: var(--weight-medium, 500);
}
.ctv-channels-spacer {
flex: 1;
}
.ctv-channels-error {
display: flex;
align-items: center;
gap: var(--space-4, 8px);
border: 1px solid color-mix(in srgb, var(--status-error) 32%, transparent);
border-radius: var(--radius-md, 7px);
background: var(--ctv-error-soft);
color: var(--status-error);
padding: var(--space-5, 10px) var(--space-6, 12px);
font-size: var(--text-sm, 13px);
font-weight: var(--weight-medium, 500);
}
.ctv-channels-table-frame {
overflow: hidden;
border: 1px solid var(--border-hairline);
border-radius: var(--radius-md, 7px);
background: var(--surface-card);
}
.ctv-channels-table-scroll {
overflow: auto;
}
.ctv-channels-table {
width: 100%;
min-width: 920px;
border-collapse: collapse;
}
.ctv-channels-table th {
position: sticky;
top: 0;
z-index: 2;
height: 34px;
border-bottom: 1px solid var(--border-hairline);
background: var(--surface-card);
color: var(--text-disabled);
padding: 0 var(--pad-cell-x, 16px);
text-align: left;
white-space: nowrap;
font-size: var(--text-2xs, 11px);
font-weight: var(--weight-medium, 500);
letter-spacing: var(--tracking-caps, 0.06em);
text-transform: uppercase;
}
.ctv-channels-table td {
height: 56px;
border-top: 1px solid var(--border-hairline);
color: var(--text-primary);
padding: 0 var(--pad-cell-x, 16px);
vertical-align: middle;
}
.ctv-channel-check {
width: 38px;
padding-left: 14px !important;
}
.ctv-channel-group-row td {
height: 32px;
border-top: 1px solid var(--border-hairline);
border-bottom: 1px solid var(--border-hairline);
background: var(--ctv-bg-sunken);
}
.ctv-channel-group-row span {
display: inline-flex;
align-items: center;
gap: var(--space-4, 8px);
color: var(--text-secondary);
font-size: var(--text-2xs, 11px);
font-weight: var(--weight-semibold, 600);
letter-spacing: var(--tracking-caps, 0.06em);
text-transform: uppercase;
}
.ctv-channel-group-row svg {
color: var(--text-disabled);
}
.ctv-channel-row-live {
background: var(--ctv-live-soft);
box-shadow: inset 2px 0 0 var(--status-live);
}
.ctv-channel-row-selected {
background: var(--ctv-accent-soft);
}
.ctv-channel-row-dim td:not(:last-child) {
opacity: 0.58;
}
.ctv-channel-number {
width: 74px;
}
.ctv-channel-number span {
display: inline-flex;
align-items: center;
gap: var(--space-3, 6px);
color: var(--status-live);
}
.ctv-channel-identity {
min-width: 220px;
display: flex;
align-items: center;
gap: 11px;
}
.ctv-channel-identity > div {
min-width: 0;
display: grid;
grid-template-columns: auto auto;
align-items: center;
gap: 3px var(--space-4, 8px);
}
.ctv-channel-identity strong {
overflow: hidden;
color: var(--text-primary);
font-weight: var(--weight-medium, 500);
line-height: 1.2;
text-overflow: ellipsis;
white-space: nowrap;
}
.ctv-channel-identity small {
grid-column: 1 / -1;
color: var(--text-disabled);
font-size: var(--text-2xs, 11px);
line-height: 1;
}
.ctv-channel-markers {
display: inline-flex;
gap: var(--space-2, 4px);
}
.ctv-channel-marker {
min-width: 15px;
height: 15px;
display: inline-flex;
align-items: center;
justify-content: center;
border: 1px solid var(--border-control);
border-radius: var(--radius-xs, 3px);
background: var(--ctv-surface-3);
color: var(--text-disabled);
padding: 0 3px;
font-family: var(--font-mono, ui-monospace, monospace);
font-size: 9px;
font-weight: var(--weight-semibold, 600);
line-height: 1;
}
.ctv-channel-now {
width: 280px;
}
.ctv-channel-now > div {
max-width: 250px;
display: grid;
gap: var(--space-3, 6px);
}
.ctv-channel-now span {
overflow: hidden;
color: var(--text-secondary);
font-size: var(--text-xs, 12px);
line-height: 1.2;
text-overflow: ellipsis;
white-space: nowrap;
}
.ctv-channel-now > span {
color: var(--text-faint);
}
.ctv-channel-ffmpeg {
width: 140px;
color: var(--text-secondary) !important;
font-family: var(--font-mono, ui-monospace, monospace);
font-size: var(--text-xs, 12px);
}
.ctv-channel-actions {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 2px;
}
.ctv-channels-footer {
display: flex;
align-items: center;
justify-content: space-between;
border-top: 1px solid var(--border-hairline);
color: var(--text-secondary);
padding: var(--space-5, 10px) var(--space-7, 16px);
font-size: var(--text-xs, 12px);
}
.ctv-schedule-screen {
display: flex;
flex-direction: column;
min-height: 0;
height: 100%;
gap: var(--space-6, 12px);
}
.ctv-schedule-header {
display: flex;
align-items: center;
gap: var(--space-6, 12px);
border: 1px solid var(--border-hairline);
border-radius: var(--radius-md, 7px);
background: var(--surface-card);
padding: var(--space-6, 12px) var(--space-8, 20px);
}
.ctv-schedule-header-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 34px;
height: 34px;
border-radius: var(--radius-sm, 5px);
background: var(--ctv-accent-soft);
color: var(--ctv-accent);
}
.ctv-schedule-header > div {
flex: 1;
min-width: 0;
}
.ctv-schedule-header h2,
.ctv-schedule-inspector-head strong {
margin: 0;
color: var(--text-primary);
font-size: var(--text-md, 14px);
font-weight: var(--weight-semibold, 600);
line-height: 1.2;
}
.ctv-schedule-header p,
.ctv-schedule-inspector-head span {
margin: var(--space-2, 4px) 0 0;
color: var(--text-secondary);
font-size: var(--text-xs, 12px);
line-height: 1.2;
}
.ctv-schedule-header code,
.ctv-schedule-block-duration,
.ctv-schedule-rail code,
.ctv-schedule-fill-chip code {
font-family: var(--font-mono, ui-monospace, monospace);
font-variant-numeric: tabular-nums;
}
.ctv-schedule-grid {
display: grid;
grid-template-columns: minmax(420px, 1fr) minmax(360px, 460px);
gap: var(--space-8, 20px);
min-height: 0;
flex: 1;
}
.ctv-schedule-lineup-panel,
.ctv-schedule-inspector {
min-height: 0;
border: 1px solid var(--border-hairline);
border-radius: var(--radius-md, 7px);
background: var(--surface-card);
}
.ctv-schedule-lineup-panel {
display: flex;
flex-direction: column;
}
.ctv-schedule-panel-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-5, 10px);
border-bottom: 1px solid var(--border-hairline);
padding: var(--space-5, 10px) var(--space-7, 16px);
}
.ctv-schedule-panel-head > span {
color: var(--text-disabled);
font-size: var(--text-2xs, 11px);
font-weight: var(--weight-semibold, 600);
line-height: 1;
text-transform: uppercase;
}
.ctv-schedule-lineup {
flex: 1;
min-height: 0;
overflow: auto;
padding: var(--space-6, 12px);
}
.ctv-schedule-lineup-row {
display: grid;
grid-template-columns: 58px minmax(0, 1fr);
gap: var(--space-5, 10px);
}
.ctv-schedule-rail {
display: flex;
flex-direction: column;
align-items: center;
gap: var(--space-2, 4px);
padding-top: var(--space-7, 16px);
color: var(--text-disabled);
font-size: 9px;
}
.ctv-schedule-rail-dot,
.ctv-schedule-rail-dot-fixed {
width: 9px;
height: 9px;
border: 2px solid var(--border-control);
border-radius: var(--radius-pill, 999px);
}
.ctv-schedule-rail-dot-fixed {
border-color: var(--ctv-accent);
background: var(--ctv-accent);
}
.ctv-schedule-block {
position: relative;
display: grid;
gap: var(--space-4, 8px);
margin-bottom: var(--space-6, 12px);
border: 1px solid var(--border-hairline);
border-radius: var(--radius-md, 7px);
background: var(--ctv-surface-1);
padding: var(--space-5, 10px);
transition:
background var(--dur-fast) var(--ease-standard),
border-color var(--dur-fast) var(--ease-standard),
opacity var(--dur-fast) var(--ease-standard);
}
.ctv-schedule-block-active {
border-color: var(--action-primary);
background: var(--ctv-accent-soft);
}
.ctv-schedule-block-dragging {
opacity: 0.45;
}
.ctv-schedule-block-over {
box-shadow: inset 0 2px 0 var(--action-primary);
}
.ctv-schedule-block-main {
display: grid;
grid-template-columns: 16px 38px minmax(0, 1fr) auto;
align-items: center;
gap: var(--space-5, 10px);
width: 100%;
border: 0;
background: transparent;
color: inherit;
cursor: pointer;
padding: 0;
text-align: left;
}
.ctv-schedule-block-main > svg {
color: var(--text-disabled);
cursor: grab;
}
.ctv-schedule-block-title {
display: grid;
min-width: 0;
gap: var(--space-2, 4px);
}
.ctv-schedule-block-title strong {
overflow: hidden;
color: var(--text-primary);
font-size: var(--text-md, 14px);
font-weight: var(--weight-semibold, 600);
line-height: 1.2;
text-overflow: ellipsis;
white-space: nowrap;
}
.ctv-schedule-block-title small,
.ctv-schedule-block-meta {
color: var(--text-secondary);
font-size: var(--text-xs, 12px);
line-height: 1;
}
.ctv-schedule-fill-chip {
display: inline-flex;
align-items: center;
gap: var(--space-3, 6px);
border: 1px solid var(--border-hairline);
border-radius: var(--radius-sm, 5px);
background: var(--ctv-bg-sunken);
color: var(--text-primary);
padding: var(--space-3, 6px) var(--space-4, 8px);
white-space: nowrap;
}
.ctv-schedule-fill-chip svg,
.ctv-schedule-block-meta svg,
.ctv-schedule-note svg {
color: var(--text-secondary);
}
.ctv-schedule-block-meta {
display: flex;
align-items: center;
gap: var(--space-4, 8px);
flex-wrap: wrap;
padding-left: 64px;
}
.ctv-schedule-block-meta > span {
display: inline-flex;
align-items: center;
gap: var(--space-2, 4px);
}
.ctv-schedule-block-duration {
margin-left: auto;
color: var(--text-disabled);
font-size: var(--text-2xs, 11px);
}
.ctv-schedule-block-actions {
position: absolute;
right: var(--space-3, 6px);
bottom: var(--space-3, 6px);
display: flex;
align-items: center;
gap: 2px;
}
.ctv-schedule-up-icon {
transform: rotate(180deg);
}
.ctv-schedule-empty {
display: flex;
align-items: center;
justify-content: center;
min-height: 180px;
color: var(--text-secondary);
font-size: var(--text-sm, 13px);
}
.ctv-schedule-inspector {
display: flex;
flex-direction: column;
overflow: hidden;
}
.ctv-schedule-inspector-head {
display: flex;
align-items: center;
gap: var(--space-5, 10px);
border-bottom: 1px solid var(--border-hairline);
padding: var(--space-7, 16px);
}
.ctv-schedule-inspector-head > div {
display: grid;
flex: 1;
min-width: 0;
gap: var(--space-2, 4px);
}
.ctv-schedule-inspector-badges {
display: flex;
gap: var(--space-3, 6px);
flex-wrap: wrap;
padding: var(--space-5, 10px) var(--space-7, 16px);
}
.ctv-schedule-inspector .ctv-tabs {
padding: 0 var(--space-7, 16px);
}
.ctv-schedule-inspector-body {
display: flex;
flex: 1;
min-height: 0;
flex-direction: column;
gap: var(--space-6, 12px);
overflow: auto;
padding: var(--space-7, 16px);
}
.ctv-schedule-form-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: var(--space-6, 12px);
}
.ctv-schedule-note {
display: flex;
align-items: center;
gap: var(--space-5, 10px);
border: 1px solid var(--border-hairline);
border-radius: var(--radius-sm, 5px);
background: var(--ctv-bg-sunken);
color: var(--text-secondary);
padding: var(--space-6, 12px);
font-size: var(--text-xs, 12px);
line-height: 1.4;
}
@media (max-width: 980px) {
.ctv-app-shell {
grid-template-columns: 1fr;
@@ -746,4 +1351,41 @@
.ctv-slot-grid {
grid-template-columns: 1fr;
}
.ctv-channels-actionbar {
align-items: flex-start;
flex-wrap: wrap;
}
.ctv-channels-spacer {
display: none;
}
.ctv-segmented {
width: 100%;
}
.ctv-segmented button {
flex: 1;
justify-content: center;
}
.ctv-schedule-header {
align-items: flex-start;
flex-wrap: wrap;
}
.ctv-schedule-grid,
.ctv-schedule-form-grid {
grid-template-columns: 1fr;
}
.ctv-schedule-block-main {
grid-template-columns: 16px 38px minmax(0, 1fr);
}
.ctv-schedule-fill-chip {
grid-column: 2 / -1;
justify-self: start;
}
}