Files
k-tv/k-tv-frontend/app/(main)/dashboard/components/edit-channel-sheet.tsx
Gabriel Kaszewski 8ed8da2d90 refactor(frontend): extract logic to hooks, split inline components
Area 1 (tv/page.tsx 964→423 lines):
- hooks: use-fullscreen, use-idle, use-volume, use-quality, use-subtitles,
  use-channel-input, use-channel-passwords, use-tv-keyboard
- components: SubtitlePicker, VolumeControl, QualityPicker, TopControlBar,
  LogoWatermark, AutoplayPrompt, ChannelNumberOverlay, TvBaseLayer

Area 2 (edit-channel-sheet.tsx 1244→678 lines):
- hooks: use-channel-form (all form state + reset logic)
- lib/schemas.ts: extracted Zod schemas + extractErrors
- components: AlgorithmicFilterEditor, RecyclePolicyEditor, WebhookEditor,
  AccessSettingsEditor, LogoEditor

Area 3 (dashboard/page.tsx 406→261 lines):
- hooks: use-channel-order, use-import-channel, use-regenerate-all
- lib/channel-export.ts: pure export utility
- components: DashboardHeader
2026-03-17 02:25:02 +01:00

679 lines
24 KiB
TypeScript

"use client";
import { useState } from "react";
import { Trash2, Plus } from "lucide-react";
import {
Sheet,
SheetContent,
SheetHeader,
SheetTitle,
} from "@/components/ui/sheet";
import { Button } from "@/components/ui/button";
import { BlockTimeline, BLOCK_COLORS, minsToTime } from "./block-timeline";
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,
LogoPosition,
ProgrammingBlock,
BlockContent,
FillStrategy,
MediaFilter,
ProviderInfo,
RecyclePolicy,
} from "@/lib/types";
// ---------------------------------------------------------------------------
// Local shared primitives (only used inside this file)
// ---------------------------------------------------------------------------
function Field({
label,
hint,
error,
children,
}: {
label: string;
hint?: string;
error?: string;
children: React.ReactNode;
}) {
return (
<div className="space-y-1.5">
<label className="block text-xs font-medium text-zinc-400">{label}</label>
{children}
{error ? (
<p className="text-[11px] text-red-400">{error}</p>
) : hint ? (
<p className="text-[11px] text-zinc-600">{hint}</p>
) : null}
</div>
);
}
function TextInput({
value,
onChange,
placeholder,
required,
error,
}: {
value: string;
onChange: (v: string) => void;
placeholder?: string;
required?: boolean;
error?: boolean;
}) {
return (
<input
required={required}
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder={placeholder}
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"}`}
/>
);
}
function NumberInput({
value,
onChange,
min,
error,
}: {
value: number | "";
onChange: (v: number | "") => void;
min?: number;
error?: boolean;
}) {
return (
<input
type="number"
min={min}
value={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"}`}
/>
);
}
function NativeSelect({
value,
onChange,
children,
}: {
value: string;
onChange: (v: string) => void;
children: React.ReactNode;
}) {
return (
<select
value={value}
onChange={(e) => onChange(e.target.value)}
className="w-full rounded-md border border-zinc-700 bg-zinc-800 px-3 py-2 text-sm text-zinc-100 focus:border-zinc-500 focus:outline-none"
>
{children}
</select>
);
}
function defaultFilter(): MediaFilter {
return {
content_type: null,
genres: [],
decade: null,
tags: [],
min_duration_secs: null,
max_duration_secs: null,
collections: [],
series_names: [],
search_term: null,
};
}
// ---------------------------------------------------------------------------
// BlockEditor — inline because it's only used here and depends on local types
// ---------------------------------------------------------------------------
interface BlockEditorProps {
block: ProgrammingBlock;
index: number;
errors: FieldErrors;
providers: ProviderInfo[];
onChange: (block: ProgrammingBlock) => void;
}
function BlockEditor({ block, index, errors, providers, onChange }: BlockEditorProps) {
const setField = <K extends keyof ProgrammingBlock>(key: K, value: ProgrammingBlock[K]) =>
onChange({ ...block, [key]: value });
const content = block.content;
const pfx = `blocks.${index}`;
const setContentType = (type: "algorithmic" | "manual") => {
const pid = content.provider_id ?? "";
onChange({
...block,
content:
type === "algorithmic"
? { type: "algorithmic", filter: defaultFilter(), strategy: "random", provider_id: pid }
: { type: "manual", items: [], provider_id: pid },
});
};
const setFilter = (patch: Partial<MediaFilter>) => {
if (content.type !== "algorithmic") return;
onChange({ ...block, content: { ...content, filter: { ...content.filter, ...patch } } });
};
const setStrategy = (strategy: FillStrategy) => {
if (content.type !== "algorithmic") return;
onChange({ ...block, content: { ...content, strategy } });
};
const setProviderId = (id: string) =>
onChange({ ...block, content: { ...content, provider_id: id } });
return (
<div className="space-y-3">
<div className="grid grid-cols-2 gap-3">
<Field label="Block name" error={errors[`${pfx}.name`]}>
<TextInput
value={block.name}
onChange={(v) => setField("name", v)}
placeholder="Evening Sitcoms"
error={!!errors[`${pfx}.name`]}
/>
</Field>
<Field label="Content type">
<NativeSelect
value={content.type}
onChange={(v) => setContentType(v as "algorithmic" | "manual")}
>
<option value="algorithmic">Algorithmic</option>
<option value="manual">Manual</option>
</NativeSelect>
</Field>
</div>
<div className="grid grid-cols-2 gap-3">
<Field label="Start time">
<input
type="time"
value={block.start_time.slice(0, 5)}
onChange={(e) => setField("start_time", e.target.value + ":00")}
className="w-full rounded-md border border-zinc-700 bg-zinc-800 px-3 py-2 text-sm text-zinc-100 focus:border-zinc-500 focus:outline-none"
/>
</Field>
<Field label="Duration (minutes)" error={errors[`${pfx}.duration_mins`]}>
<NumberInput
value={block.duration_mins}
onChange={(v) => setField("duration_mins", v === "" ? 60 : v)}
min={1}
error={!!errors[`${pfx}.duration_mins`]}
/>
</Field>
</div>
{content.type === "algorithmic" && (
<>
<AlgorithmicFilterEditor
content={content}
pfx={pfx}
errors={errors}
providers={providers}
setFilter={setFilter}
setStrategy={setStrategy}
setProviderId={setProviderId}
/>
{content.strategy === "sequential" && (
<div className="space-y-2 rounded-md border border-zinc-700/50 bg-zinc-800 p-3">
<p className="text-[11px] font-medium uppercase tracking-wider text-zinc-500">
Sequential options
</p>
<label className="flex cursor-pointer items-center gap-2">
<input
type="checkbox"
checked={block.loop_on_finish ?? true}
onChange={(e) =>
onChange({ ...block, loop_on_finish: e.target.checked })
}
className="accent-zinc-400"
/>
<span className="text-sm text-zinc-300">Loop series</span>
<span className="text-[11px] text-zinc-600">
Restart from episode 1 after the final episode
</span>
</label>
<label className="flex cursor-pointer items-center gap-2">
<input
type="checkbox"
checked={block.ignore_recycle_policy ?? false}
onChange={(e) =>
onChange({ ...block, ignore_recycle_policy: e.target.checked })
}
className="accent-zinc-400"
/>
<span className="text-sm text-zinc-300">Independent scheduling</span>
<span className="text-[11px] text-zinc-600">
Play episodes in order even if they aired in another block today
</span>
</label>
</div>
)}
</>
)}
{content.type === "manual" && (
<div className="space-y-2 rounded-md border border-zinc-700/50 bg-zinc-800 p-3">
<p className="text-[11px] font-medium uppercase tracking-wider text-zinc-500">
Item IDs
</p>
<textarea
rows={3}
value={content.items.join("\n")}
onChange={(e) =>
onChange({
...block,
content: {
type: "manual",
items: e.target.value
.split("\n")
.map((s) => s.trim())
.filter(Boolean),
},
})
}
placeholder={"abc123\ndef456\nghi789"}
className="w-full resize-none rounded-md border border-zinc-700 bg-zinc-800 px-3 py-2 font-mono text-xs text-zinc-100 placeholder:text-zinc-600 focus:border-zinc-500 focus:outline-none"
/>
<p className="text-[11px] text-zinc-600">
One Jellyfin item ID per line, played in order.
</p>
</div>
)}
<div className="space-y-2 rounded-md border border-zinc-700/50 bg-zinc-800 p-3">
<p className="text-[11px] font-medium uppercase tracking-wider text-zinc-500">
Block access
</p>
<AccessSettingsEditor
accessMode={(block.access_mode as AccessMode) ?? "public"}
accessPassword={block.access_password ?? ""}
onAccessModeChange={(m) => onChange({ ...block, access_mode: m })}
onAccessPasswordChange={(pw) => onChange({ ...block, access_password: pw })}
label="Access mode"
passwordLabel="Block password"
passwordHint="Leave blank to keep existing"
/>
</div>
</div>
);
}
// ---------------------------------------------------------------------------
// EditChannelSheet
// ---------------------------------------------------------------------------
interface EditChannelSheetProps {
channel: ChannelResponse | null;
open: boolean;
onOpenChange: (open: boolean) => void;
onSubmit: (
id: string,
data: {
name: string;
description: string;
timezone: string;
schedule_config: { blocks: ProgrammingBlock[] };
recycle_policy: RecyclePolicy;
auto_schedule: boolean;
access_mode?: AccessMode;
access_password?: string;
logo?: string | null;
logo_position?: LogoPosition;
logo_opacity?: number;
webhook_url?: string | null;
webhook_poll_interval_secs?: number;
webhook_body_template?: string | null;
webhook_headers?: string | null;
},
) => void;
isPending: boolean;
error?: string | null;
providers?: ProviderInfo[];
}
export function EditChannelSheet({
channel,
open,
onOpenChange,
onSubmit,
isPending,
error,
providers = [],
}: EditChannelSheetProps) {
const form = useChannelForm(channel);
const [fieldErrors, setFieldErrors] = useState<FieldErrors>({});
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (!channel) return;
const result = channelFormSchema.safeParse({
name: form.name,
description: form.description,
timezone: form.timezone,
blocks: form.blocks,
recycle_policy: form.recyclePolicy,
auto_schedule: form.autoSchedule,
access_mode: form.accessMode,
access_password: form.accessPassword,
});
if (!result.success) {
setFieldErrors(extractErrors(result.error));
return;
}
setFieldErrors({});
onSubmit(channel.id, {
name: form.name,
description: form.description,
timezone: form.timezone,
schedule_config: { blocks: form.blocks },
recycle_policy: form.recyclePolicy,
auto_schedule: form.autoSchedule,
access_mode: form.accessMode !== "public" ? form.accessMode : "public",
access_password: form.accessPassword || "",
logo: form.logo,
logo_position: form.logoPosition,
logo_opacity: form.logoOpacity / 100,
webhook_url: form.webhookUrl || null,
...(form.webhookUrl
? {
webhook_poll_interval_secs:
form.webhookPollInterval === "" ? 5 : form.webhookPollInterval,
}
: {}),
webhook_body_template: form.webhookBodyTemplate || null,
webhook_headers: form.webhookHeaders || null,
});
};
return (
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent
side="right"
className="flex w-full flex-col gap-0 border-zinc-800 bg-zinc-900 p-0 text-zinc-100 sm:!max-w-[min(1100px,95vw)]"
>
<SheetHeader className="border-b border-zinc-800 px-6 py-4">
<SheetTitle className="text-zinc-100">Edit channel</SheetTitle>
</SheetHeader>
<form onSubmit={handleSubmit} className="flex flex-1 flex-col overflow-hidden">
<div className="flex flex-1 overflow-hidden">
{/* Left sidebar */}
<div className="w-[300px] shrink-0 space-y-6 overflow-y-auto border-r border-zinc-800 px-5 py-4">
<section className="space-y-3">
<h3 className="text-xs font-semibold uppercase tracking-wider text-zinc-500">
Basic info
</h3>
<Field label="Name" error={fieldErrors["name"]}>
<TextInput
required
value={form.name}
onChange={form.setName}
placeholder="90s Sitcom Network"
error={!!fieldErrors["name"]}
/>
</Field>
<Field
label="Timezone"
hint="IANA timezone, e.g. America/New_York"
error={fieldErrors["timezone"]}
>
<TextInput
required
value={form.timezone}
onChange={form.setTimezone}
placeholder="UTC"
error={!!fieldErrors["timezone"]}
/>
</Field>
<Field label="Description">
<textarea
value={form.description}
onChange={(e) => form.setDescription(e.target.value)}
rows={2}
placeholder="Nothing but classic sitcoms, all day"
className="w-full resize-none 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"
/>
</Field>
<label className="flex cursor-pointer items-center justify-between gap-3 rounded-md border border-zinc-700 bg-zinc-800/50 px-3 py-2.5">
<div>
<p className="text-sm text-zinc-200">Auto-generate schedule</p>
<p className="text-[11px] text-zinc-600">
Automatically regenerate when the schedule is about to expire
</p>
</div>
<button
type="button"
role="switch"
aria-checked={form.autoSchedule}
onClick={() => form.setAutoSchedule((v) => !v)}
className={`relative inline-flex h-5 w-9 shrink-0 rounded-full border-2 border-transparent transition-colors focus:outline-none ${form.autoSchedule ? "bg-zinc-300" : "bg-zinc-700"}`}
>
<span
className={`pointer-events-none inline-block h-4 w-4 rounded-full bg-white shadow-sm transition-transform ${form.autoSchedule ? "translate-x-4" : "translate-x-0"}`}
/>
</button>
</label>
<Field label="Channel access">
<AccessSettingsEditor
accessMode={form.accessMode}
accessPassword={form.accessPassword}
onAccessModeChange={form.setAccessMode}
onAccessPasswordChange={form.setAccessPassword}
label="Access mode"
passwordLabel="Channel password"
passwordHint="Leave blank to keep existing password"
/>
</Field>
</section>
<section className="space-y-3">
<h3 className="text-xs font-semibold uppercase tracking-wider text-zinc-500">
Logo
</h3>
<LogoEditor
logo={form.logo}
logoPosition={form.logoPosition}
logoOpacity={form.logoOpacity}
onLogoChange={form.setLogo}
onPositionChange={form.setLogoPosition}
onOpacityChange={form.setLogoOpacity}
/>
</section>
<section className="space-y-3">
<h3 className="text-xs font-semibold uppercase tracking-wider text-zinc-500">
Recycle policy
</h3>
<RecyclePolicyEditor
policy={form.recyclePolicy}
errors={fieldErrors}
onChange={form.setRecyclePolicy}
/>
</section>
<section className="space-y-3">
<h3 className="text-xs font-semibold uppercase tracking-wider text-zinc-500">
Webhook
</h3>
<WebhookEditor
webhookUrl={form.webhookUrl}
webhookPollInterval={form.webhookPollInterval}
webhookFormat={form.webhookFormat}
webhookBodyTemplate={form.webhookBodyTemplate}
webhookHeaders={form.webhookHeaders}
onWebhookUrlChange={form.setWebhookUrl}
onWebhookPollIntervalChange={form.setWebhookPollInterval}
onWebhookFormatChange={form.setWebhookFormat}
onWebhookBodyTemplateChange={form.setWebhookBodyTemplate}
onWebhookHeadersChange={form.setWebhookHeaders}
/>
</section>
</div>
{/* Right: block editor */}
<div className="flex flex-1 flex-col overflow-hidden">
<div className="shrink-0 space-y-3 border-b border-zinc-800 px-5 py-4">
<div className="flex items-center justify-between">
<h3 className="text-xs font-semibold uppercase tracking-wider text-zinc-500">
Programming blocks
</h3>
<Button
type="button"
variant="outline"
size="xs"
onClick={() => form.addBlock()}
className="border-zinc-700 text-zinc-300 hover:text-zinc-100"
>
<Plus className="size-3" />
Add block
</Button>
</div>
<BlockTimeline
blocks={form.blocks}
selectedId={form.selectedBlockId}
onSelect={form.setSelectedBlockId}
onChange={form.setBlocks}
onCreateBlock={(startMins, durationMins) =>
form.addBlock(startMins, durationMins)
}
/>
{form.blocks.length === 0 ? (
<p className="rounded-md border border-dashed border-zinc-700 px-4 py-4 text-center text-xs text-zinc-600">
No blocks yet. Drag on the timeline or click Add block.
</p>
) : (
<div className="max-h-48 space-y-1 overflow-y-auto">
{form.blocks.map((block, idx) => (
<button
key={block.id}
type="button"
onClick={() => form.setSelectedBlockId(block.id)}
className={`flex w-full items-center gap-2 rounded-md px-3 py-2 text-left transition-colors ${
block.id === form.selectedBlockId
? "border border-zinc-600 bg-zinc-700/60"
: "border border-transparent hover:bg-zinc-800/60"
}`}
>
<div
className="h-2.5 w-2.5 shrink-0 rounded-full"
style={{
backgroundColor:
BLOCK_COLORS[idx % BLOCK_COLORS.length],
}}
/>
<span className="flex-1 truncate text-sm text-zinc-200">
{block.name || "Unnamed block"}
</span>
<span className="shrink-0 font-mono text-[11px] text-zinc-500">
{block.start_time.slice(0, 5)} · {block.duration_mins}m
</span>
<span
role="button"
onClick={(e) => {
e.stopPropagation();
form.removeBlock(idx);
}}
className="rounded p-1 text-zinc-600 hover:bg-zinc-700 hover:text-red-400"
>
<Trash2 className="size-3.5" />
</span>
</button>
))}
</div>
)}
</div>
<div className="flex-1 overflow-y-auto px-5 py-4">
{(() => {
if (form.selectedBlockId === null) {
return (
<div className="flex h-full items-center justify-center text-sm text-zinc-600">
Select a block or create one
</div>
);
}
const selectedIdx = form.blocks.findIndex(
(b) => b.id === form.selectedBlockId,
);
const selectedBlock =
selectedIdx >= 0 ? form.blocks[selectedIdx] : null;
if (!selectedBlock) {
return (
<div className="flex h-full items-center justify-center text-sm text-zinc-600">
Select a block or create one
</div>
);
}
return (
<BlockEditor
block={selectedBlock}
index={selectedIdx}
errors={fieldErrors}
providers={providers}
onChange={(b) => form.updateBlock(selectedIdx, b)}
/>
);
})()}
</div>
</div>
</div>
<div className="flex items-center justify-between border-t border-zinc-800 px-6 py-4">
{(error || Object.keys(fieldErrors).length > 0) && (
<p className="text-xs text-red-400">
{error ?? "Please fix the errors above"}
</p>
)}
<div className="ml-auto flex gap-2">
<Button
type="button"
variant="ghost"
onClick={() => onOpenChange(false)}
disabled={isPending}
>
Cancel
</Button>
<Button type="submit" disabled={isPending}>
{isPending ? "Saving…" : "Save changes"}
</Button>
</div>
</div>
</form>
</SheetContent>
</Sheet>
);
}