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
timothyandClaude Fable 5 e6197d63ff fix(web): dashboard review fixes — real health status contract, dedupe version fetch, refresh guard (#109)
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 4m3s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 4m53s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
- Match the backend's exact health status contract ('pass'|'fail'|'warn'|'info')
  instead of fictional strings; 'info' now renders as a neutral/idle presentation
  and is excluded from failing/warning counts in summarizeHealth.
- Drop the redundant /api/version fetch from getDashboardData/DashboardData;
  SidebarVersion's useDashboardVersionQuery remains the single source.
- Disable "Refresh health" (via Button's loading prop) while a health request
  is in flight to prevent concurrent double-click requests.
- Add the active-flag unmount guard to useDashboardHealthQuery for consistency
  with useDashboardQuery/useChannelsQuery.
- Remove dead .ctv-activity-*/.ctv-release-* CSS left over from the removed
  activity feed/release notes UI.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-04 21:14:44 +02:00
timothy 6a9f1e41a4 fix(web): satisfy dashboard lint checks
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 4m14s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 4m48s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
refs #109
2026-07-04 20:55:38 +02:00
timothy a6056f73d8 feat(web): wire dashboard to real API data
Build ErsatzTV Image / Build & test (.NET) (pull_request) Failing after 41s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 3m41s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
refs #109
2026-07-04 20:52:32 +02:00
timothy da7636fdad docs: update ChicoryTV queue after PR 121
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 4m11s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 5m4s
Build ErsatzTV Image / Build & push image (amd64) (push) Successful in 3m46s
2026-07-04 20:37:29 +02:00
timothy cc6ffcb8c4 Merge pull request 'feat(core): track active sessions for direct streaming modes (#99)' (#121) from feat/99-session-tracking into main
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 4m21s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 5m0s
Build ErsatzTV Image / Build & push image (amd64) (push) Successful in 3m35s
Merge pull request 'feat(core): track active sessions for direct streaming modes (#99)' (#121) from feat/99-session-tracking into main
2026-07-04 18:24:15 +00:00
8 changed files with 3733 additions and 339 deletions
+90 -80
View File
@@ -8,35 +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-merge-pass)**: main = 1b6de047 (PR #120 "ChicoryTV SPA
foundation (#59 stack)"): design-system/ + web/ SPA foundation (#78–#83) + backend #96/#98/#97
all on main; #97 closed (its universal-onAir remainder = #99). Reconciliation was ONE merge
commit (06355b75) + regen (7bd69439) + nullable-DTO fix (8d89ab16); Fable review: no dropped
work, no duplicated routes/tests. Test baselines on main: ErsatzTV.Tests **364**, Core.Tests
**488** (+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`).
- **Main image build**: post-#120 run 482 FAILED in "Build & push image" only —
`node:22-noble` doesn't exist (tests+migrations green). Hotfix **PR #122**
(node:22-bookworm-slim, one line) is CI-green and awaiting "merge" consent. After merging,
confirm the next main run is fully green INCLUDING the image job before deploying anything.
- **PR #121 (#99, Codex, direct-stream session tracking)**: OPEN, CI green, base main
(pre-#120 — needs a main merge/rebase, confirmed conflict-free with #120). Fable review =
FIX-FIRST: (1) Register/Remove race in DirectStreamSessionTracker can orphan a live
session's registration; (2) HEAD probes register phantom viewers on ts-legacy. Round-2
Codex prompt (findings verbatim) was handed to the user; full text also in the PR #121
review comment. #99 stays open (final /api/channels/state wiring comes after #121 merges).
- Worktrees: .worktrees/issue-99-session-tracking (Codex, keep); .worktrees/merge-pass
(stack landed — REMOVE after this doc lands); 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>`.
@@ -49,90 +52,97 @@ gitignored; SPA types = web/src/api/generated/v1.d.ts, regen via `npm run genera
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: full backend read API + SPA foundation. Baselines: ErsatzTV.Tests
364, Core.Tests 488(+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 segmenter-session-based until #99/PR #121 lands —
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): merge PR #122 if still open (one-word consent),
verify main run fully green incl. image job; remove .worktrees/merge-pass; Codex round-2
on PR #121 (#99) — prompt already with the user — then Fable re-review of the delta.
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/PR #121 in flight (see state). Done this pass: MERGE PASS → PR #120 (closed
#97; #78–#83/#96/#98 validated on main), hotfix PR #122 (Dockerfile node tag). 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.
+1083 -26
View File
File diff suppressed because it is too large Load Diff
+1311 -151
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';
}
+119 -16
View File
@@ -1,36 +1,58 @@
import { useEffect, useState } from 'react';
import { useCallback, useEffect, useRef, useState } from 'react';
import { ApiError, request } from './client';
import type { components } from './generated/v1';
export type DashboardChannel = components['schemas']['ChannelResponseModel'];
type DashboardCollection = components['schemas']['MediaCollectionViewModel'];
type DashboardSchedule = components['schemas']['ProgramScheduleViewModel'];
type DashboardSession = components['schemas']['HlsSessionModel'];
type DashboardVersion = components['schemas']['CombinedVersion'];
export type DashboardChannelState = components['schemas']['ChannelStateResponseModel'];
type DashboardHealthCheck = components['schemas']['HealthCheckResponseModel'];
type DashboardMediaSource = components['schemas']['MediaSourceResponseModel'];
type DashboardPlayouts = components['schemas']['PagedPlayoutsResponseModel'];
export type DashboardVersion = components['schemas']['CombinedVersion'];
export interface DashboardData {
channels: DashboardChannel[];
collections: DashboardCollection[];
schedules: DashboardSchedule[];
sessions: DashboardSession[];
version: DashboardVersion;
channelStates: DashboardChannelState[];
mediaSources: DashboardMediaSource[];
playouts: DashboardPlayouts;
}
type DashboardQueryState =
export type DashboardQueryState =
| { data: DashboardData; error: null; status: 'success' }
| { data: null; error: string; status: 'error' }
| { data: null; error: null; status: 'loading' };
export type DashboardHealthQueryState =
| { checks: DashboardHealthCheck[]; error: null; refresh: () => void; status: 'success' }
| { checks: null; error: string; refresh: () => void; status: 'error' }
| { checks: null; error: null; refresh: () => void; status: 'loading' };
export type DashboardVersionQueryState =
| { error: null; status: 'success'; version: DashboardVersion }
| { error: string; status: 'error'; version: null }
| { error: null; status: 'loading'; version: null };
type DashboardHealthState =
| { checks: DashboardHealthCheck[]; error: null; status: 'success' }
| { checks: null; error: string; status: 'error' }
| { checks: null; error: null; status: 'loading' };
export async function getDashboardData(): Promise<DashboardData> {
const [channels, collections, schedules, sessions, version] = await Promise.all([
const [channels, channelStates, mediaSources, playouts] = await Promise.all([
request<DashboardChannel[]>('/api/channels'),
request<DashboardCollection[]>('/api/collections'),
request<DashboardSchedule[]>('/api/schedules'),
request<DashboardSession[]>('/api/sessions'),
request<DashboardVersion>('/api/version')
request<DashboardChannelState[]>('/api/channels/state'),
request<DashboardMediaSource[]>('/api/media-sources'),
request<DashboardPlayouts>('/api/playouts')
]);
return { channels, collections, schedules, sessions, version };
return { channels, channelStates, mediaSources, playouts };
}
export function getDashboardHealth(): Promise<DashboardHealthCheck[]> {
return request<DashboardHealthCheck[]>('/api/health');
}
export function getDashboardVersion(): Promise<DashboardVersion> {
return request<DashboardVersion>('/api/version');
}
export function useDashboardQuery(): DashboardQueryState {
@@ -63,6 +85,87 @@ export function useDashboardQuery(): DashboardQueryState {
return state;
}
export function useDashboardHealthQuery(): DashboardHealthQueryState {
const [state, setState] = useState<DashboardHealthState>({
checks: null,
error: null,
status: 'loading'
});
const activeRef = useRef(true);
useEffect(() => {
activeRef.current = true;
return () => {
activeRef.current = false;
};
}, []);
const loadHealth = useCallback(() => {
getDashboardHealth()
.then((checks) => {
if (activeRef.current) {
setState({ checks, error: null, status: 'success' });
}
})
.catch((error: unknown) => {
if (activeRef.current) {
setState({ checks: null, error: messageFromError(error), status: 'error' });
}
});
}, []);
const refresh = useCallback(() => {
setState({ checks: null, error: null, status: 'loading' });
loadHealth();
}, [loadHealth]);
useEffect(() => {
loadHealth();
}, [loadHealth]);
if (state.status === 'success') {
return { checks: state.checks, error: null, refresh, status: 'success' };
}
if (state.status === 'error') {
return { checks: null, error: state.error, refresh, status: 'error' };
}
return { checks: null, error: null, refresh, status: 'loading' };
}
export function useDashboardVersionQuery(): DashboardVersionQueryState {
const [state, setState] = useState<DashboardVersionQueryState>({
error: null,
status: 'loading',
version: null
});
useEffect(() => {
let active = true;
getDashboardVersion()
.then((version) => {
if (active) {
setState({ error: null, status: 'success', version });
}
})
.catch((error: unknown) => {
if (active) {
setState({ error: messageFromError(error), status: 'error', version: null });
}
});
return () => {
active = false;
};
}, []);
return state;
}
function messageFromError(error: unknown): string {
if (error instanceof ApiError) {
return error.detail ?? error.message;
+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;
}
+656 -65
View File
@@ -442,7 +442,6 @@
}
.ctv-onair-head code,
.ctv-activity-list code,
.ctv-live-channel-row code {
color: var(--text-secondary);
font-family: var(--font-mono, ui-monospace, monospace);
@@ -516,8 +515,7 @@
gap: var(--space-7, 16px);
}
.ctv-health-panel,
.ctv-activity-feed {
.ctv-health-panel {
display: grid;
}
@@ -535,8 +533,7 @@
border-top: 0;
}
.ctv-health-icon,
.ctv-activity-icon {
.ctv-health-icon {
width: 24px;
height: 24px;
display: inline-flex;
@@ -555,6 +552,14 @@
color: var(--status-warn);
}
.ctv-health-icon-error {
color: var(--status-error);
}
.ctv-health-icon-idle {
color: var(--text-secondary);
}
.ctv-health-icon-live {
color: var(--status-live);
}
@@ -571,67 +576,11 @@
white-space: nowrap;
}
.ctv-activity-row {
min-height: 44px;
display: grid;
grid-template-columns: auto auto minmax(0, 1fr) auto;
align-items: center;
gap: var(--space-4, 8px);
.ctv-health-summary {
display: flex;
justify-content: flex-end;
border-top: 1px solid var(--border-hairline);
padding: 0 var(--space-6, 12px);
}
.ctv-activity-row:first-child {
border-top: 0;
}
.ctv-activity-row > span:nth-child(3) {
overflow: hidden;
color: var(--text-primary);
text-overflow: ellipsis;
white-space: nowrap;
}
.ctv-activity-row code,
.ctv-release-toggle code {
color: var(--text-disabled);
font-family: var(--font-mono, ui-monospace, monospace);
font-size: var(--text-2xs, 11px);
}
.ctv-release-toggle {
width: 100%;
display: grid;
grid-template-columns: auto auto minmax(0, 1fr) auto;
align-items: center;
gap: var(--space-4, 8px);
border: 0;
background: transparent;
color: var(--text-primary);
cursor: pointer;
padding: 0;
text-align: left;
}
.ctv-release-toggle svg:first-child {
color: var(--action-primary);
}
.ctv-release-toggle code {
justify-self: end;
}
.ctv-release-body {
display: grid;
gap: var(--space-3, 6px);
margin-top: var(--space-6, 12px);
color: var(--text-secondary);
font-size: var(--text-xs, 12px);
line-height: 1.45;
}
.ctv-release-body p {
margin: 0;
padding: var(--space-4, 8px) var(--space-6, 12px);
}
.ctv-live-channel-body {
@@ -754,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;
@@ -797,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;
}
}