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.
This commit is contained in:
@@ -0,0 +1,96 @@
|
||||
import Link from "next/link";
|
||||
import { Pencil, Trash2, RefreshCw, Tv2 } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import type { ChannelResponse } from "@/lib/types";
|
||||
|
||||
interface ChannelCardProps {
|
||||
channel: ChannelResponse;
|
||||
isGenerating: boolean;
|
||||
onEdit: () => void;
|
||||
onDelete: () => void;
|
||||
onGenerateSchedule: () => void;
|
||||
}
|
||||
|
||||
export function ChannelCard({
|
||||
channel,
|
||||
isGenerating,
|
||||
onEdit,
|
||||
onDelete,
|
||||
onGenerateSchedule,
|
||||
}: ChannelCardProps) {
|
||||
const blockCount = channel.schedule_config.blocks.length;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4 rounded-xl border border-zinc-800 bg-zinc-900 p-5 transition-colors hover:border-zinc-700">
|
||||
{/* Top row */}
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0 space-y-1">
|
||||
<h2 className="truncate text-base font-semibold text-zinc-100">
|
||||
{channel.name}
|
||||
</h2>
|
||||
{channel.description && (
|
||||
<p className="line-clamp-2 text-sm text-zinc-500">
|
||||
{channel.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
onClick={onEdit}
|
||||
title="Edit channel"
|
||||
>
|
||||
<Pencil className="size-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
onClick={onDelete}
|
||||
title="Delete channel"
|
||||
className="text-zinc-600 hover:text-red-400"
|
||||
>
|
||||
<Trash2 className="size-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Meta */}
|
||||
<div className="flex flex-wrap gap-x-4 gap-y-1 text-xs text-zinc-500">
|
||||
<span>
|
||||
<span className="text-zinc-400">{channel.timezone}</span>
|
||||
</span>
|
||||
<span>
|
||||
{blockCount} {blockCount === 1 ? "block" : "blocks"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={onGenerateSchedule}
|
||||
disabled={isGenerating}
|
||||
className="flex-1"
|
||||
>
|
||||
<RefreshCw
|
||||
className={`size-3.5 ${isGenerating ? "animate-spin" : ""}`}
|
||||
/>
|
||||
{isGenerating ? "Generating…" : "Generate schedule"}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon-sm"
|
||||
asChild
|
||||
title="Watch on TV"
|
||||
className="border-zinc-700 text-zinc-400 hover:text-zinc-100"
|
||||
>
|
||||
<Link href="/tv">
|
||||
<Tv2 className="size-3.5" />
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogFooter,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
interface CreateChannelDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onSubmit: (data: {
|
||||
name: string;
|
||||
timezone: string;
|
||||
description: string;
|
||||
}) => void;
|
||||
isPending: boolean;
|
||||
error?: string | null;
|
||||
}
|
||||
|
||||
export function CreateChannelDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
onSubmit,
|
||||
isPending,
|
||||
error,
|
||||
}: CreateChannelDialogProps) {
|
||||
const [name, setName] = useState("");
|
||||
const [timezone, setTimezone] = useState("UTC");
|
||||
const [description, setDescription] = useState("");
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
onSubmit({ name, timezone, description });
|
||||
};
|
||||
|
||||
const handleOpenChange = (next: boolean) => {
|
||||
if (!isPending) {
|
||||
onOpenChange(next);
|
||||
if (!next) {
|
||||
setName("");
|
||||
setTimezone("UTC");
|
||||
setDescription("");
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||||
<DialogContent className="bg-zinc-900 border-zinc-800 text-zinc-100 sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>New channel</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4 py-2">
|
||||
<div className="space-y-1.5">
|
||||
<label className="block text-xs font-medium text-zinc-400">
|
||||
Name <span className="text-red-400">*</span>
|
||||
</label>
|
||||
<input
|
||||
required
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="90s Sitcom Network"
|
||||
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"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<label className="block text-xs font-medium text-zinc-400">
|
||||
Timezone <span className="text-red-400">*</span>
|
||||
</label>
|
||||
<input
|
||||
required
|
||||
value={timezone}
|
||||
onChange={(e) => setTimezone(e.target.value)}
|
||||
placeholder="America/New_York"
|
||||
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"
|
||||
/>
|
||||
<p className="text-[11px] text-zinc-600">
|
||||
IANA timezone, e.g. America/New_York, Europe/London, UTC
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<label className="block text-xs font-medium text-zinc-400">
|
||||
Description
|
||||
</label>
|
||||
<textarea
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
placeholder="Nothing but classic sitcoms, all day"
|
||||
rows={2}
|
||||
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"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && <p className="text-xs text-red-400">{error}</p>}
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
onClick={() => handleOpenChange(false)}
|
||||
disabled={isPending}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" disabled={isPending}>
|
||||
{isPending ? "Creating…" : "Create channel"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogContent,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogCancel,
|
||||
AlertDialogAction,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
|
||||
interface DeleteChannelDialogProps {
|
||||
channelName: string;
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onConfirm: () => void;
|
||||
isPending: boolean;
|
||||
}
|
||||
|
||||
export function DeleteChannelDialog({
|
||||
channelName,
|
||||
open,
|
||||
onOpenChange,
|
||||
onConfirm,
|
||||
isPending,
|
||||
}: DeleteChannelDialogProps) {
|
||||
return (
|
||||
<AlertDialog open={open} onOpenChange={onOpenChange}>
|
||||
<AlertDialogContent className="bg-zinc-900 border-zinc-800 text-zinc-100">
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Delete channel?</AlertDialogTitle>
|
||||
<AlertDialogDescription className="text-zinc-400">
|
||||
<span className="font-medium text-zinc-200">{channelName}</span> and
|
||||
all its schedules will be permanently deleted. This cannot be undone.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel
|
||||
disabled={isPending}
|
||||
className="border-zinc-700 bg-transparent text-zinc-300 hover:bg-zinc-800 hover:text-zinc-100"
|
||||
>
|
||||
Cancel
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={onConfirm}
|
||||
disabled={isPending}
|
||||
className="bg-red-600 text-white hover:bg-red-700"
|
||||
>
|
||||
{isPending ? "Deleting…" : "Delete"}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,643 @@
|
||||
"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.0–1.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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user