diff --git a/k-tv-frontend/app/(main)/dashboard/components/access-settings-editor.tsx b/k-tv-frontend/app/(main)/dashboard/components/access-settings-editor.tsx new file mode 100644 index 0000000..948cda6 --- /dev/null +++ b/k-tv-frontend/app/(main)/dashboard/components/access-settings-editor.tsx @@ -0,0 +1,57 @@ +import type { AccessMode } from "@/lib/types"; + +interface AccessSettingsEditorProps { + accessMode: AccessMode; + accessPassword: string; + onAccessModeChange: (mode: AccessMode) => void; + onAccessPasswordChange: (pw: string) => void; + label?: string; + passwordLabel?: string; + passwordHint?: string; +} + +export function AccessSettingsEditor({ + accessMode, + accessPassword, + onAccessModeChange, + onAccessPasswordChange, + label = "Access", + passwordLabel = "Password", + passwordHint = "Leave blank to keep existing password", +}: AccessSettingsEditorProps) { + return ( +
+
+ + +
+ + {accessMode === "password_protected" && ( +
+ + onAccessPasswordChange(e.target.value)} + className="w-full rounded-md border border-zinc-700 bg-zinc-800 px-3 py-2 text-sm text-zinc-100 placeholder:text-zinc-600 focus:border-zinc-500 focus:outline-none" + /> +
+ )} +
+ ); +} diff --git a/k-tv-frontend/app/(main)/dashboard/components/algorithmic-filter-editor.tsx b/k-tv-frontend/app/(main)/dashboard/components/algorithmic-filter-editor.tsx new file mode 100644 index 0000000..a32a135 --- /dev/null +++ b/k-tv-frontend/app/(main)/dashboard/components/algorithmic-filter-editor.tsx @@ -0,0 +1,342 @@ +"use client"; + +import { useState } from "react"; +import { TagInput } from "./tag-input"; +import { SeriesPicker } from "./series-picker"; +import { FilterPreview } from "./filter-preview"; +import { useCollections, useSeries, useGenres } from "@/hooks/use-library"; +import type { + BlockContent, + ContentType, + FillStrategy, + MediaFilter, + ProviderInfo, +} from "@/lib/types"; +import type { FieldErrors } from "@/lib/schemas"; + +function Field({ + label, + hint, + error, + children, +}: { + label: string; + hint?: string; + error?: string; + children: React.ReactNode; +}) { + return ( +
+ + {children} + {error ? ( +

{error}

+ ) : hint ? ( +

{hint}

+ ) : null} +
+ ); +} + +function NativeSelect({ + value, + onChange, + children, +}: { + value: string; + onChange: (v: string) => void; + children: React.ReactNode; +}) { + return ( + + ); +} + +function NumberInput({ + value, + onChange, + min, + placeholder, + error, +}: { + value: number | ""; + onChange: (v: number | "") => void; + min?: number; + placeholder?: string; + error?: boolean; +}) { + return ( + + onChange(e.target.value === "" ? "" : Number(e.target.value)) + } + className={`w-full rounded-md border bg-zinc-800 px-3 py-2 text-sm text-zinc-100 placeholder:text-zinc-600 focus:outline-none ${error ? "border-red-500 focus:border-red-400" : "border-zinc-700 focus:border-zinc-500"}`} + /> + ); +} + +interface AlgorithmicFilterEditorProps { + content: Extract; + pfx: string; + errors: FieldErrors; + providers: ProviderInfo[]; + setFilter: (patch: Partial) => void; + setStrategy: (strategy: FillStrategy) => void; + setProviderId: (id: string) => void; +} + +export function AlgorithmicFilterEditor({ + content, + pfx, + errors, + providers, + setFilter, + setStrategy, + setProviderId, +}: AlgorithmicFilterEditorProps) { + const [showGenres, setShowGenres] = useState(false); + + const providerId = content.provider_id ?? ""; + const capabilities = + providers.find((p) => p.id === providerId)?.capabilities ?? + providers[0]?.capabilities; + + const { data: collections, isLoading: loadingCollections } = useCollections( + providerId || undefined, + ); + const { data: series, isLoading: loadingSeries } = useSeries(undefined, { + enabled: capabilities?.series !== false, + provider: providerId || undefined, + }); + const { data: genreOptions } = useGenres( + content.filter.content_type ?? undefined, + { + enabled: capabilities?.genres !== false, + provider: providerId || undefined, + }, + ); + + const isEpisode = content.filter.content_type === "episode"; + const collectionLabel = + capabilities?.collections && !capabilities?.series && !capabilities?.genres + ? "Directory" + : "Library"; + + return ( +
+

+ Filter +

+ + {providers.length > 1 && ( + + + {providers.map((p) => ( + + ))} + + + )} + +
+ + + setFilter({ + content_type: v === "" ? null : (v as ContentType), + series_names: + v !== "episode" ? [] : content.filter.series_names, + }) + } + > + + + + + + + + setStrategy(v as FillStrategy)} + > + + + + + +
+ + {isEpisode && capabilities?.series !== false && ( + + setFilter({ series_names: v })} + series={series ?? []} + isLoading={loadingSeries} + /> + + )} + + + {collections && collections.length > 0 ? ( + setFilter({ collections: v ? [v] : [] })} + > + + {collections.map((c) => ( + + ))} + + ) : ( + setFilter({ collections: v })} + placeholder="Library ID…" + /> + )} + + + {capabilities?.genres !== false && ( + + setFilter({ genres: v })} + placeholder="Comedy, Animation…" + /> + {genreOptions && genreOptions.length > 0 && ( +
+ + {showGenres && ( +
+ {genreOptions + .filter((g) => !content.filter.genres.includes(g)) + .map((g) => ( + + ))} +
+ )} +
+ )} +
+ )} + + + setFilter({ tags: v })} + placeholder="classic, family…" + /> + + +
+ + + setFilter({ decade: v === "" ? null : (v as number) }) + } + placeholder="1990" + error={!!errors[`${pfx}.content.filter.decade`]} + /> + + + + setFilter({ + min_duration_secs: v === "" ? null : (v as number) * 60, + }) + } + placeholder="30" + error={!!errors[`${pfx}.content.filter.min_duration_secs`]} + /> + + + + setFilter({ + max_duration_secs: v === "" ? null : (v as number) * 60, + }) + } + placeholder="120" + error={!!errors[`${pfx}.content.filter.max_duration_secs`]} + /> + +
+ + +
+ ); +} diff --git a/k-tv-frontend/app/(main)/dashboard/components/dashboard-header.tsx b/k-tv-frontend/app/(main)/dashboard/components/dashboard-header.tsx new file mode 100644 index 0000000..956aa21 --- /dev/null +++ b/k-tv-frontend/app/(main)/dashboard/components/dashboard-header.tsx @@ -0,0 +1,85 @@ +import { Plus, Upload, RefreshCw, Antenna, Settings2 } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import type { ProviderCapabilities } from "@/lib/types"; + +interface DashboardHeaderProps { + hasChannels: boolean; + canTranscode: boolean; + canRescan: boolean; + isRegeneratingAll: boolean; + isRescanPending: boolean; + capabilities: ProviderCapabilities | undefined; + onTranscodeOpen: () => void; + onRescan: () => void; + onRegenerateAll: () => void; + onIptvOpen: () => void; + onImportOpen: () => void; + onCreateOpen: () => void; +} + +export function DashboardHeader({ + hasChannels, + canTranscode, + canRescan, + isRegeneratingAll, + isRescanPending, + onTranscodeOpen, + onRescan, + onRegenerateAll, + onIptvOpen, + onImportOpen, + onCreateOpen, +}: DashboardHeaderProps) { + return ( +
+
+

My Channels

+

Build your broadcast lineup

+
+
+ {canTranscode && ( + + )} + {canRescan && ( + + )} + {hasChannels && ( + + )} + + + +
+
+ ); +} diff --git a/k-tv-frontend/app/(main)/dashboard/components/edit-channel-sheet.tsx b/k-tv-frontend/app/(main)/dashboard/components/edit-channel-sheet.tsx index 85d6083..dcd4b6a 100644 --- a/k-tv-frontend/app/(main)/dashboard/components/edit-channel-sheet.tsx +++ b/k-tv-frontend/app/(main)/dashboard/components/edit-channel-sheet.tsx @@ -1,15 +1,23 @@ "use client"; -import { useState, useEffect, useRef } from "react"; -import { z } from "zod"; +import { useState } from "react"; import { Trash2, Plus } from "lucide-react"; -import { Sheet, SheetContent, SheetHeader, SheetTitle } from "@/components/ui/sheet"; +import { + Sheet, + SheetContent, + SheetHeader, + SheetTitle, +} from "@/components/ui/sheet"; import { Button } from "@/components/ui/button"; -import { TagInput } from "./tag-input"; import { BlockTimeline, BLOCK_COLORS, minsToTime } from "./block-timeline"; -import { SeriesPicker } from "./series-picker"; -import { FilterPreview } from "./filter-preview"; -import { useCollections, useSeries, useGenres } from "@/hooks/use-library"; +import { AlgorithmicFilterEditor } from "./algorithmic-filter-editor"; +import { RecyclePolicyEditor } from "./recycle-policy-editor"; +import { WebhookEditor } from "./webhook-editor"; +import { AccessSettingsEditor } from "./access-settings-editor"; +import { LogoEditor } from "./logo-editor"; +import { useChannelForm } from "@/hooks/use-channel-form"; +import { channelFormSchema, extractErrors } from "@/lib/schemas"; +import type { FieldErrors } from "@/lib/schemas"; import type { AccessMode, ChannelResponse, @@ -17,108 +25,13 @@ import type { ProgrammingBlock, BlockContent, FillStrategy, - ContentType, MediaFilter, ProviderInfo, RecyclePolicy, } from "@/lib/types"; // --------------------------------------------------------------------------- -// Webhook preset templates (frontend-only, zero backend changes needed) -// --------------------------------------------------------------------------- - -const WEBHOOK_PRESETS = { - discord: `{ - "embeds": [{ - "title": "πŸ“Ί {{event}}", - "description": "{{#if data.item.title}}Now playing: **{{data.item.title}}**{{else}}No signal{{/if}}", - "color": 3447003, - "timestamp": "{{timestamp}}" - }] -}`, - slack: `{ - "text": "πŸ“Ί *{{event}}*{{#if data.item.title}} β€” {{data.item.title}}{{/if}}" -}`, -} as const; - -type WebhookFormat = "default" | "discord" | "slack" | "custom"; - -// --------------------------------------------------------------------------- -// Zod schemas -// --------------------------------------------------------------------------- - -const mediaFilterSchema = z.object({ - content_type: z.enum(["movie", "episode", "short"]).nullable().optional(), - genres: z.array(z.string()), - decade: z - .number() - .int() - .min(1900, "Decade must be β‰₯ 1900") - .max(2099, "Decade must be ≀ 2090") - .nullable() - .optional(), - tags: z.array(z.string()), - min_duration_secs: z.number().min(0, "Must be β‰₯ 0").nullable().optional(), - max_duration_secs: z.number().min(0, "Must be β‰₯ 0").nullable().optional(), - collections: z.array(z.string()), - series_names: z.array(z.string()), - search_term: z.string().nullable().optional(), -}); - -const accessModeSchema = z.enum(["public", "password_protected", "account_required", "owner_only"]); - -const blockSchema = z.object({ - id: z.string(), - name: z.string().min(1, "Block name is required"), - start_time: z.string(), - duration_mins: z.number().int().min(1, "Must be at least 1 minute"), - content: z.discriminatedUnion("type", [ - z.object({ - type: z.literal("algorithmic"), - filter: mediaFilterSchema, - strategy: z.enum(["best_fit", "sequential", "random"]), - provider_id: z.string().optional(), - }), - z.object({ - type: z.literal("manual"), - items: z.array(z.string()), - provider_id: z.string().optional(), - }), - ]), - loop_on_finish: z.boolean().optional(), - ignore_recycle_policy: z.boolean().optional(), - access_mode: accessModeSchema.optional(), - access_password: z.string().optional(), -}); - -const channelFormSchema = z.object({ - name: z.string().min(1, "Name is required"), - timezone: z.string().min(1, "Timezone is required"), - description: z.string().optional(), - blocks: z.array(blockSchema), - recycle_policy: z.object({ - cooldown_days: z.number().int().min(0).nullable().optional(), - cooldown_generations: z.number().int().min(0).nullable().optional(), - min_available_ratio: z.number().min(0, "Must be β‰₯ 0").max(1, "Must be ≀ 1"), - }), - auto_schedule: z.boolean(), - access_mode: accessModeSchema.optional(), - access_password: z.string().optional(), -}); - -type FieldErrors = Record; - -function extractErrors(err: z.ZodError): FieldErrors { - const map: FieldErrors = {}; - for (const issue of err.issues) { - const key = issue.path.join("."); - if (!map[key]) map[key] = issue.message; - } - return map; -} - -// --------------------------------------------------------------------------- -// Field wrapper +// Local shared primitives (only used inside this file) // --------------------------------------------------------------------------- function Field({ @@ -173,28 +86,21 @@ function NumberInput({ value, onChange, min, - max, - step, - placeholder, error, }: { value: number | ""; onChange: (v: number | "") => void; min?: number; - max?: number; - step?: number | "any"; - placeholder?: string; error?: boolean; }) { return ( onChange(e.target.value === "" ? "" : Number(e.target.value))} + onChange={(e) => + onChange(e.target.value === "" ? "" : Number(e.target.value)) + } className={`w-full rounded-md border bg-zinc-800 px-3 py-2 text-sm text-zinc-100 placeholder:text-zinc-600 focus:outline-none ${error ? "border-red-500 focus:border-red-400" : "border-zinc-700 focus:border-zinc-500"}`} /> ); @@ -220,10 +126,6 @@ function NativeSelect({ ); } -// --------------------------------------------------------------------------- -// Defaults -// --------------------------------------------------------------------------- - function defaultFilter(): MediaFilter { return { content_type: null, @@ -238,256 +140,19 @@ function defaultFilter(): MediaFilter { }; } -function defaultBlock(startMins = 20 * 60, durationMins = 60): ProgrammingBlock { - return { - id: crypto.randomUUID(), - name: "", - start_time: minsToTime(startMins), - duration_mins: durationMins, - content: { type: "algorithmic", filter: defaultFilter(), strategy: "random" }, - loop_on_finish: true, - ignore_recycle_policy: false, - access_mode: "public", - }; -} - // --------------------------------------------------------------------------- -// AlgorithmicFilterEditor β€” filter section with live library data -// --------------------------------------------------------------------------- - -interface AlgorithmicFilterEditorProps { - content: Extract; - pfx: string; - errors: FieldErrors; - setFilter: (patch: Partial) => void; - setStrategy: (strategy: FillStrategy) => void; - setProviderId: (id: string) => void; - providers: ProviderInfo[]; -} - -function AlgorithmicFilterEditor({ - content, - pfx, - errors, - setFilter, - setStrategy, - setProviderId, - providers, -}: AlgorithmicFilterEditorProps) { - const [showGenres, setShowGenres] = useState(false); - - const providerId = content.provider_id ?? ""; - const capabilities = providers.find((p) => p.id === providerId)?.capabilities - ?? providers[0]?.capabilities; - - const { data: collections, isLoading: loadingCollections } = useCollections(providerId || undefined); - const { data: series, isLoading: loadingSeries } = useSeries(undefined, { - enabled: capabilities?.series !== false, - provider: providerId || undefined, - }); - const { data: genreOptions } = useGenres(content.filter.content_type ?? undefined, { - enabled: capabilities?.genres !== false, - provider: providerId || undefined, - }); - - const isEpisode = content.filter.content_type === "episode"; - const collectionLabel = - capabilities?.collections && !capabilities?.series && !capabilities?.genres - ? "Directory" - : "Library"; - - return ( -
-

Filter

- - {providers.length > 1 && ( - - setProviderId(v)}> - {providers.map((p) => ( - - ))} - - - )} - -
- - - setFilter({ - content_type: v === "" ? null : (v as ContentType), - // clear series names if switching away from episode - series_names: v !== "episode" ? [] : content.filter.series_names, - }) - } - > - - - - - - - - setStrategy(v as FillStrategy)} - > - - - - - -
- - {/* Series β€” only meaningful for episodes when provider supports it */} - {isEpisode && capabilities?.series !== false && ( - - setFilter({ series_names: v })} - series={series ?? []} - isLoading={loadingSeries} - /> - - )} - - {/* Library/Directory β€” real collection names when the provider supports it */} - - {collections && collections.length > 0 ? ( - setFilter({ collections: v ? [v] : [] })} - > - - {collections.map((c) => ( - - ))} - - ) : ( - setFilter({ collections: v })} - placeholder="Library ID…" - /> - )} - - - {/* Genres β€” only shown when provider supports it */} - {capabilities?.genres !== false && ( - - setFilter({ genres: v })} - placeholder="Comedy, Animation…" - /> - {genreOptions && genreOptions.length > 0 && ( -
- - {showGenres && ( -
- {genreOptions - .filter((g) => !content.filter.genres.includes(g)) - .map((g) => ( - - ))} -
- )} -
- )} -
- )} - - - setFilter({ tags: v })} - placeholder="classic, family…" - /> - - -
- - setFilter({ decade: v === "" ? null : (v as number) })} - placeholder="1990" - error={!!errors[`${pfx}.content.filter.decade`]} - /> - - - - setFilter({ min_duration_secs: v === "" ? null : (v as number) * 60 }) - } - placeholder="30" - error={!!errors[`${pfx}.content.filter.min_duration_secs`]} - /> - - - - setFilter({ max_duration_secs: v === "" ? null : (v as number) * 60 }) - } - placeholder="120" - error={!!errors[`${pfx}.content.filter.max_duration_secs`]} - /> - -
- - {/* Preview β€” snapshot of current filter+strategy, only fetches on explicit click */} - -
- ); -} - -// --------------------------------------------------------------------------- -// BlockEditor (detail form for a single block) +// BlockEditor β€” inline because it's only used here and depends on local types // --------------------------------------------------------------------------- interface BlockEditorProps { block: ProgrammingBlock; index: number; errors: FieldErrors; - onChange: (block: ProgrammingBlock) => void; providers: ProviderInfo[]; + onChange: (block: ProgrammingBlock) => void; } -function BlockEditor({ block, index, errors, onChange, providers }: BlockEditorProps) { +function BlockEditor({ block, index, errors, providers, onChange }: BlockEditorProps) { const setField = (key: K, value: ProgrammingBlock[K]) => onChange({ ...block, [key]: value }); @@ -515,9 +180,8 @@ function BlockEditor({ block, index, errors, onChange, providers }: BlockEditorP onChange({ ...block, content: { ...content, strategy } }); }; - const setProviderId = (id: string) => { + const setProviderId = (id: string) => onChange({ ...block, content: { ...content, provider_id: id } }); - }; return (
@@ -566,10 +230,10 @@ function BlockEditor({ block, index, errors, onChange, providers }: BlockEditorP content={content} pfx={pfx} errors={errors} + providers={providers} setFilter={setFilter} setStrategy={setStrategy} setProviderId={setProviderId} - providers={providers} /> {content.strategy === "sequential" && ( @@ -577,11 +241,13 @@ function BlockEditor({ block, index, errors, onChange, providers }: BlockEditorP

Sequential options

-