Compare commits
12
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f18f2dc058 | ||
|
|
2e58fa1815 | ||
|
|
7ee6e9870c | ||
|
|
ecf6cf7513 | ||
|
|
15c6cdd086 | ||
|
|
39d76080de | ||
|
|
4ffd777b49 | ||
|
|
e6197d63ff | ||
|
|
6a9f1e41a4 | ||
|
|
a6056f73d8 | ||
|
|
da7636fdad | ||
|
|
cc6ffcb8c4 |
@@ -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
File diff suppressed because it is too large
Load Diff
+1311
-151
File diff suppressed because it is too large
Load Diff
+169
-1
@@ -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
@@ -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;
|
||||
|
||||
@@ -2,4 +2,5 @@ export * from './auth';
|
||||
export * from './channels';
|
||||
export * from './client';
|
||||
export * from './dashboard';
|
||||
export * from './schedules';
|
||||
export * from './useChannelsQuery';
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user