Files
ersatztv/web/src/app/ScreenContent.tsx
T
timothyandClaude Opus 4.8 33ba3a0492
Build ErsatzTV Image / Docs update reminder (pull_request) Successful in 9s
Build ErsatzTV Image / decisions.md append-only (pull_request) Successful in 9s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 4m52s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 12s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 8s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 9m0s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
Build ErsatzTV Image / Functional E2E (curl contracts) (pull_request) Successful in 8m37s
feat(69): Auto-Tune SPA wizard — Configure → Preview → Create (PR2)
New `/app/auto-tune` screen driving the PR1 endpoints: pick metadata axes
(TV Shows / TV Genres / Movie Genres) + defaults, preview proposed channels
grouped by axis with per-row/per-group selection and already-exists dedup,
then bulk-create with a Created/Skipped/Failed summary. Additive/non-destructive.

The per-channel DetailPanel from the design iteration is deferred to #383
(new endpoints backlogged) — this ships the 3-step wizard only.

- web/src/api/autoTune.ts (+test), web/src/screens/AutoTuneScreen.tsx (+test)
- route/nav (routes.tsx, ScreenContent.tsx), api barrel, App nav-smoke test
- docs: domain-model (route), blazor-route-parity (net-new SPA screen)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-17 00:04:42 +02:00

229 lines
8.6 KiB
TypeScript

import { useEffect, useState } from 'react';
import { Info } from 'lucide-react';
import type { DashboardHealthQueryState } from '../api';
import { ChannelBuilderScreen } from '../builder/ChannelBuilder';
import { Card } from '../components';
import {
parseLibrariesSubRoute,
parseMediaSubRoute
} from '../routing';
import { ApiKeyScreen } from '../screens/ApiKeyScreen';
import { AutoTuneScreen } from '../screens/AutoTuneScreen';
import { BlockPlayoutTroubleshootingScreen } from '../screens/BlockPlayoutTroubleshootingScreen';
import { BlocksScreen } from '../screens/BlocksScreen';
import { ChannelEditScreen } from '../screens/ChannelEditScreen';
import { ChannelsScreen } from '../screens/ChannelsScreen';
import { CollectionsScreen } from '../screens/CollectionsScreen';
import { DashboardScreen } from '../screens/DashboardScreen';
import { DecoTemplatesScreen } from '../screens/DecoTemplatesScreen';
import { DecosScreen } from '../screens/DecosScreen';
import { FFmpegProfilesScreen } from '../screens/FFmpegProfilesScreen';
import { FillerPresetsScreen } from '../screens/FillerPresetsScreen';
import { GuideScreen } from '../screens/GuideScreen';
import { ImageBrowserScreen } from '../screens/ImageBrowserScreen';
import { LibrariesScreen } from '../screens/LibrariesScreen';
import { LocalLibraryEditScreen } from '../screens/LocalLibraryEditScreen';
import { LogsScreen } from '../screens/LogsScreen';
import { MediaBrowseScreen } from '../screens/MediaBrowseScreen';
import {
ArtistDetailScreen,
MovieDetailScreen,
SeasonDetailScreen,
ShowDetailScreen
} from '../screens/MediaDetailScreen';
import { MultiCollectionsScreen } from '../screens/MultiCollectionsScreen';
import { PathReplacementsEditScreen } from '../screens/PathReplacementsEditScreen';
import { PlaybackTroubleshootingScreen } from '../screens/PlaybackTroubleshootingScreen';
import { PlexSourceScreen } from '../screens/PlexSourceScreen';
import { PlaylistsScreen } from '../screens/PlaylistsScreen';
import { PlayoutsRouteScreen } from '../screens/PlayoutsScreen';
import { RemoteConnectionEditScreen } from '../screens/RemoteConnectionEditScreen';
import { RemoteLibrariesEditScreen } from '../screens/RemoteLibrariesEditScreen';
import { RemoteSourceScreen } from '../screens/RemoteSourceScreen';
import { RerunCollectionsScreen } from '../screens/RerunCollectionsScreen';
import { SchedulesScreen } from '../screens/SchedulesScreen';
import { SearchScreen } from '../screens/SearchScreen';
import { SettingsScreen } from '../screens/SettingsScreen';
import { TemplatesScreen } from '../screens/TemplatesScreen';
import { TraktListsScreen } from '../screens/TraktListsScreen';
import { TrashScreen } from '../screens/TrashScreen';
import { TroubleshootingScreen } from '../screens/TroubleshootingScreen';
import { WatermarksScreen } from '../screens/WatermarksScreen';
import { YamlValidatorScreen } from '../screens/YamlValidatorScreen';
import {
normalizeAppPath,
type ScreenId,
type ScreenRoute
} from './routes';
function NotFoundScreen() {
const pathname = normalizeAppPath(window.location.pathname);
return (
<div className="ctv-screen-stack">
<Card title={<h2>Unknown app route</h2>} subtitle="This URL does not match a ChicoryTV screen.">
<div className="ctv-placeholder-layout">
<div className="ctv-placeholder-icon"><Info aria-hidden="true" size={20} /></div>
<div>
<h3>Requested path</h3>
<p>{pathname}</p>
</div>
</div>
</Card>
</div>
);
}
// The Media nav entry owns detail sub-pages (/app/media/{movies|shows|seasons|artists}/{id}) and the
// image folder browser (/app/media/images/browser). Like PlayoutsRouteScreen, this wrapper tracks
// pathname locally + listens for popstate, because routeFromLocation() returns the SAME 'media'
// ScreenRoute object for the base grid and every sub-path (Object.is bails App's setActiveRoute).
export function MediaRouteScreen() {
const [pathname, setPathname] = useState(() => window.location.pathname);
const [search, setSearch] = useState(() => window.location.search);
useEffect(() => {
const onPopState = () => {
setPathname(window.location.pathname);
setSearch(window.location.search);
};
window.addEventListener('popstate', onPopState);
return () => window.removeEventListener('popstate', onPopState);
}, []);
const sub = parseMediaSubRoute(pathname);
if (sub?.kind === 'movie') {
return <MovieDetailScreen key={pathname} id={sub.id} />;
}
if (sub?.kind === 'show') {
return <ShowDetailScreen key={pathname} id={sub.id} />;
}
if (sub?.kind === 'season') {
return <SeasonDetailScreen key={pathname} id={sub.id} />;
}
if (sub?.kind === 'artist') {
return <ArtistDetailScreen key={pathname} id={sub.id} />;
}
if (sub?.kind === 'images') {
return <ImageBrowserScreen key={pathname} />;
}
return <MediaBrowseScreen key={search} />;
}
// The Libraries nav entry owns the media-source editor sub-paths. CRITICAL (design §D.2, finding 4):
// unlike PlayoutsRouteScreen/MediaRouteScreen this wrapper does NOT register its own `popstate`
// listener. Its editor sub-screens register a dirty guard, so App must approve the path first and
// pass it down. This wrapper never reads a raw navigation event.
export function LibrariesRouteScreen({ subPath }: { subPath: string }) {
const sub = parseLibrariesSubRoute(subPath);
if (sub === null) {
return <LibrariesScreen />;
}
switch (sub.kind) {
case 'local-new':
case 'local-edit':
return <LocalLibraryEditScreen key={subPath} />;
case 'remote-source':
return sub.family === 'plex'
? <PlexSourceScreen key={subPath} />
: <RemoteSourceScreen key={subPath} family={sub.family} />;
case 'remote-connection':
return <RemoteConnectionEditScreen key={subPath} family={sub.family} />;
case 'remote-libraries':
return <RemoteLibrariesEditScreen key={subPath} family={sub.family} sourceId={sub.id} />;
case 'remote-path-replacements':
return <PathReplacementsEditScreen key={subPath} family={sub.family} sourceId={sub.id} />;
}
}
function assertNeverScreen(screenId: never): never {
throw new Error(`Unhandled screen route: ${String(screenId)}`);
}
export function ScreenContent({
healthState,
librariesSubPath,
route
}: {
healthState: DashboardHealthQueryState;
librariesSubPath: string;
route: ScreenRoute | null;
}) {
if (route === null) {
return <NotFoundScreen />;
}
const routeId: ScreenId = route.id;
switch (routeId) {
case 'dashboard':
return <DashboardScreen healthState={healthState} />;
case 'channels':
return <ChannelsScreen />;
case 'builder':
return <ChannelBuilderScreen />;
case 'autoTune':
return <AutoTuneScreen />;
case 'editChannel':
return <ChannelEditScreen key={window.location.pathname} />;
case 'guide':
return <GuideScreen />;
case 'schedules':
return <SchedulesScreen />;
case 'blocks':
return <BlocksScreen key={window.location.pathname} />;
case 'templates':
return <TemplatesScreen key={window.location.pathname} />;
case 'decos':
return <DecosScreen key={window.location.pathname} />;
case 'decoTemplates':
return <DecoTemplatesScreen key={window.location.pathname} />;
case 'playouts':
return <PlayoutsRouteScreen />;
case 'libraries':
return <LibrariesRouteScreen subPath={librariesSubPath} />;
case 'settings':
return <SettingsScreen />;
case 'apiKey':
return <ApiKeyScreen />;
case 'media':
return <MediaRouteScreen />;
case 'search':
return <SearchScreen key={window.location.search} />;
case 'trash':
return <TrashScreen />;
case 'collections':
return <CollectionsScreen />;
case 'multiCollections':
return <MultiCollectionsScreen />;
case 'rerunCollections':
return <RerunCollectionsScreen />;
case 'playlists':
return <PlaylistsScreen />;
case 'traktLists':
return <TraktListsScreen key={window.location.pathname} />;
case 'fillerPresets':
return <FillerPresetsScreen />;
case 'ffmpegProfiles':
return <FFmpegProfilesScreen />;
case 'watermarks':
return <WatermarksScreen />;
case 'logs':
return <LogsScreen />;
case 'troubleshooting':
return <TroubleshootingScreen />;
case 'blockPlayoutTroubleshooting':
return <BlockPlayoutTroubleshootingScreen />;
case 'playbackTroubleshooting':
return <PlaybackTroubleshootingScreen key={window.location.search} />;
case 'yamlValidator':
return <YamlValidatorScreen />;
default:
return assertNeverScreen(routeId);
}
}