Files
k-tv/k-tv-frontend/app/(main)/dashboard/components/edit-channel-sheet.tsx
Gabriel Kaszewski 8d8d320a02 feat: implement authentication context and hooks for user management
- Add AuthContext to manage user authentication state and token storage.
- Create hooks for login, registration, and logout functionalities.
- Implement dashboard layout with authentication check and loading state.
- Enhance dashboard page with channel management features including create, edit, and delete channels.
- Integrate API calls for channel operations and current broadcast retrieval.
- Add stream URL resolution via server-side API route to handle redirects.
- Update TV page to utilize new hooks for channel and broadcast management.
- Refactor components for better organization and user experience.
- Update application metadata for improved branding.
2026-03-11 19:32:49 +01:00

644 lines
20 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import { useState, useEffect } from "react";
import { Trash2, Plus, ChevronDown, ChevronUp } from "lucide-react";
import {
Sheet,
SheetContent,
SheetHeader,
SheetTitle,
} from "@/components/ui/sheet";
import { Button } from "@/components/ui/button";
import type {
ChannelResponse,
ProgrammingBlock,
BlockContent,
FillStrategy,
ContentType,
MediaFilter,
RecyclePolicy,
} from "@/lib/types";
// ---------------------------------------------------------------------------
// Sub-components (all dumb, no hooks)
// ---------------------------------------------------------------------------
interface FieldProps {
label: string;
hint?: string;
children: React.ReactNode;
}
function Field({ label, hint, children }: FieldProps) {
return (
<div className="space-y-1.5">
<label className="block text-xs font-medium text-zinc-400">{label}</label>
{children}
{hint && <p className="text-[11px] text-zinc-600">{hint}</p>}
</div>
);
}
function TextInput({
value,
onChange,
placeholder,
required,
}: {
value: string;
onChange: (v: string) => void;
placeholder?: string;
required?: boolean;
}) {
return (
<input
required={required}
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder={placeholder}
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"
/>
);
}
function NumberInput({
value,
onChange,
min,
max,
placeholder,
}: {
value: number | "";
onChange: (v: number | "") => void;
min?: number;
max?: number;
placeholder?: string;
}) {
return (
<input
type="number"
min={min}
max={max}
value={value}
placeholder={placeholder}
onChange={(e) =>
onChange(e.target.value === "" ? "" : Number(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"
/>
);
}
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>
);
}
// ---------------------------------------------------------------------------
// Block editor
// ---------------------------------------------------------------------------
function defaultFilter(): MediaFilter {
return {
content_type: null,
genres: [],
decade: null,
tags: [],
min_duration_secs: null,
max_duration_secs: null,
collections: [],
};
}
function defaultBlock(): ProgrammingBlock {
return {
id: crypto.randomUUID(),
name: "",
start_time: "20:00:00",
duration_mins: 60,
content: {
type: "algorithmic",
filter: defaultFilter(),
strategy: "random",
},
};
}
interface BlockEditorProps {
block: ProgrammingBlock;
onChange: (block: ProgrammingBlock) => void;
onRemove: () => void;
}
function BlockEditor({ block, onChange, onRemove }: BlockEditorProps) {
const [expanded, setExpanded] = useState(true);
const setField = <K extends keyof ProgrammingBlock>(
key: K,
value: ProgrammingBlock[K],
) => onChange({ ...block, [key]: value });
const content = block.content;
const setContentType = (type: "algorithmic" | "manual") => {
if (type === "algorithmic") {
onChange({
...block,
content: { type: "algorithmic", filter: defaultFilter(), strategy: "random" },
});
} else {
onChange({ ...block, content: { type: "manual", items: [] } });
}
};
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 } });
};
return (
<div className="rounded-lg border border-zinc-700 bg-zinc-800/50">
{/* Block header */}
<div className="flex items-center gap-2 px-3 py-2">
<button
type="button"
onClick={() => setExpanded((v) => !v)}
className="flex flex-1 items-center gap-2 text-left text-sm font-medium text-zinc-200"
>
{expanded ? (
<ChevronUp className="size-3.5 shrink-0 text-zinc-500" />
) : (
<ChevronDown className="size-3.5 shrink-0 text-zinc-500" />
)}
<span className="truncate">{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>
</button>
<button
type="button"
onClick={onRemove}
className="rounded p-1 text-zinc-600 hover:bg-zinc-700 hover:text-red-400"
>
<Trash2 className="size-3.5" />
</button>
</div>
{expanded && (
<div className="space-y-3 border-t border-zinc-700 px-3 py-3">
<div className="grid grid-cols-2 gap-3">
<Field label="Block name">
<TextInput
value={block.name}
onChange={(v) => setField("name", v)}
placeholder="Evening Sitcoms"
/>
</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" hint="24-hour format HH:MM">
<TextInput
value={block.start_time.slice(0, 5)}
onChange={(v) => setField("start_time", v + ":00")}
placeholder="20:00"
/>
</Field>
<Field label="Duration (minutes)">
<NumberInput
value={block.duration_mins}
onChange={(v) => setField("duration_mins", v === "" ? 60 : v)}
min={1}
/>
</Field>
</div>
{content.type === "algorithmic" && (
<div className="space-y-3 rounded-md border border-zinc-700/50 bg-zinc-800 p-3">
<p className="text-[11px] font-medium uppercase tracking-wider text-zinc-500">
Filter
</p>
<div className="grid grid-cols-2 gap-3">
<Field label="Media type">
<NativeSelect
value={content.filter.content_type ?? ""}
onChange={(v) =>
setFilter({
content_type: v === "" ? null : (v as ContentType),
})
}
>
<option value="">Any</option>
<option value="movie">Movie</option>
<option value="episode">Episode</option>
<option value="short">Short</option>
</NativeSelect>
</Field>
<Field label="Strategy">
<NativeSelect
value={content.strategy}
onChange={(v) => setStrategy(v as FillStrategy)}
>
<option value="random">Random</option>
<option value="best_fit">Best fit</option>
<option value="sequential">Sequential</option>
</NativeSelect>
</Field>
</div>
<Field
label="Genres"
hint="Comma-separated, e.g. Comedy, Action"
>
<TextInput
value={content.filter.genres.join(", ")}
onChange={(v) =>
setFilter({
genres: v
.split(",")
.map((s) => s.trim())
.filter(Boolean),
})
}
placeholder="Comedy, Sci-Fi"
/>
</Field>
<div className="grid grid-cols-3 gap-3">
<Field label="Decade" hint="e.g. 1990">
<NumberInput
value={content.filter.decade ?? ""}
onChange={(v) =>
setFilter({ decade: v === "" ? null : (v as number) })
}
placeholder="1990"
/>
</Field>
<Field label="Min duration (s)">
<NumberInput
value={content.filter.min_duration_secs ?? ""}
onChange={(v) =>
setFilter({
min_duration_secs: v === "" ? null : (v as number),
})
}
placeholder="1200"
/>
</Field>
<Field label="Max duration (s)">
<NumberInput
value={content.filter.max_duration_secs ?? ""}
onChange={(v) =>
setFilter({
max_duration_secs: v === "" ? null : (v as number),
})
}
placeholder="3600"
/>
</Field>
</div>
<Field
label="Collections"
hint="Jellyfin library IDs, comma-separated"
>
<TextInput
value={content.filter.collections.join(", ")}
onChange={(v) =>
setFilter({
collections: v
.split(",")
.map((s) => s.trim())
.filter(Boolean),
})
}
placeholder="abc123"
/>
</Field>
</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>
)}
</div>
);
}
// ---------------------------------------------------------------------------
// Recycle policy editor
// ---------------------------------------------------------------------------
interface RecyclePolicyEditorProps {
policy: RecyclePolicy;
onChange: (policy: RecyclePolicy) => void;
}
function RecyclePolicyEditor({ policy, onChange }: RecyclePolicyEditorProps) {
return (
<div className="space-y-3">
<div className="grid grid-cols-2 gap-3">
<Field label="Cooldown (days)" hint="Don't replay within N days">
<NumberInput
value={policy.cooldown_days ?? ""}
onChange={(v) =>
onChange({
...policy,
cooldown_days: v === "" ? null : (v as number),
})
}
min={0}
placeholder="7"
/>
</Field>
<Field
label="Cooldown (generations)"
hint="Don't replay within N schedules"
>
<NumberInput
value={policy.cooldown_generations ?? ""}
onChange={(v) =>
onChange({
...policy,
cooldown_generations: v === "" ? null : (v as number),
})
}
min={0}
placeholder="3"
/>
</Field>
</div>
<Field
label="Min available ratio"
hint="0.01.0. Keep at least this fraction of the pool selectable even if cooldown hasn't expired"
>
<NumberInput
value={policy.min_available_ratio}
onChange={(v) =>
onChange({
...policy,
min_available_ratio: v === "" ? 0.1 : (v as number),
})
}
min={0}
max={1}
placeholder="0.1"
/>
</Field>
</div>
);
}
// ---------------------------------------------------------------------------
// Main sheet
// ---------------------------------------------------------------------------
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;
},
) => void;
isPending: boolean;
error?: string | null;
}
export function EditChannelSheet({
channel,
open,
onOpenChange,
onSubmit,
isPending,
error,
}: EditChannelSheetProps) {
const [name, setName] = useState("");
const [description, setDescription] = useState("");
const [timezone, setTimezone] = useState("UTC");
const [blocks, setBlocks] = useState<ProgrammingBlock[]>([]);
const [recyclePolicy, setRecyclePolicy] = useState<RecyclePolicy>({
cooldown_days: null,
cooldown_generations: null,
min_available_ratio: 0.1,
});
// Sync from channel whenever it changes or sheet opens
useEffect(() => {
if (channel) {
setName(channel.name);
setDescription(channel.description ?? "");
setTimezone(channel.timezone);
setBlocks(channel.schedule_config.blocks);
setRecyclePolicy(channel.recycle_policy);
}
}, [channel]);
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (!channel) return;
onSubmit(channel.id, {
name,
description,
timezone,
schedule_config: { blocks },
recycle_policy: recyclePolicy,
});
};
const addBlock = () => setBlocks((prev) => [...prev, defaultBlock()]);
const updateBlock = (idx: number, block: ProgrammingBlock) =>
setBlocks((prev) => prev.map((b, i) => (i === idx ? block : b)));
const removeBlock = (idx: number) =>
setBlocks((prev) => prev.filter((_, i) => i !== idx));
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-xl"
>
<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-1 space-y-6 overflow-y-auto px-6 py-4">
{/* Basic info */}
<section className="space-y-3">
<h3 className="text-xs font-semibold uppercase tracking-wider text-zinc-500">
Basic info
</h3>
<Field label="Name">
<TextInput
required
value={name}
onChange={setName}
placeholder="90s Sitcom Network"
/>
</Field>
<Field label="Timezone" hint="IANA timezone, e.g. America/New_York">
<TextInput
required
value={timezone}
onChange={setTimezone}
placeholder="UTC"
/>
</Field>
<Field label="Description">
<textarea
value={description}
onChange={(e) => 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>
</section>
{/* Programming blocks */}
<section className="space-y-3">
<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={addBlock}
className="border-zinc-700 text-zinc-300 hover:text-zinc-100"
>
<Plus className="size-3" />
Add block
</Button>
</div>
{blocks.length === 0 && (
<p className="rounded-md border border-dashed border-zinc-700 px-4 py-6 text-center text-xs text-zinc-600">
No blocks yet. Gaps between blocks show no-signal.
</p>
)}
<div className="space-y-2">
{blocks.map((block, idx) => (
<BlockEditor
key={block.id}
block={block}
onChange={(b) => updateBlock(idx, b)}
onRemove={() => removeBlock(idx)}
/>
))}
</div>
</section>
{/* Recycle policy */}
<section className="space-y-3">
<h3 className="text-xs font-semibold uppercase tracking-wider text-zinc-500">
Recycle policy
</h3>
<RecyclePolicyEditor
policy={recyclePolicy}
onChange={setRecyclePolicy}
/>
</section>
</div>
{/* Footer */}
<div className="flex items-center justify-between border-t border-zinc-800 px-6 py-4">
{error && <p className="text-xs text-red-400">{error}</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>
);
}