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:
2026-03-11 19:32:49 +01:00
parent 01108aa23e
commit 8d8d320a02
22 changed files with 2118 additions and 173 deletions

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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.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>
);
}

View File

@@ -0,0 +1,28 @@
"use client";
import { useEffect, type ReactNode } from "react";
import { useRouter } from "next/navigation";
import { useAuthContext } from "@/context/auth-context";
export default function DashboardLayout({ children }: { children: ReactNode }) {
const { token, isLoaded } = useAuthContext();
const router = useRouter();
useEffect(() => {
if (isLoaded && !token) {
router.push("/login");
}
}, [isLoaded, token, router]);
if (!isLoaded) {
return (
<div className="flex flex-1 items-center justify-center">
<div className="h-5 w-5 animate-spin rounded-full border-2 border-zinc-700 border-t-zinc-300" />
</div>
);
}
if (!token) return null;
return <>{children}</>;
}

View File

@@ -1,8 +1,151 @@
"use client";
import { useState } from "react";
import { Plus } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
useChannels,
useCreateChannel,
useUpdateChannel,
useDeleteChannel,
useGenerateSchedule,
} from "@/hooks/use-channels";
import { ChannelCard } from "./components/channel-card";
import { CreateChannelDialog } from "./components/create-channel-dialog";
import { DeleteChannelDialog } from "./components/delete-channel-dialog";
import { EditChannelSheet } from "./components/edit-channel-sheet";
import type { ChannelResponse, ProgrammingBlock, RecyclePolicy } from "@/lib/types";
export default function DashboardPage() {
const { data: channels, isLoading, error } = useChannels();
const createChannel = useCreateChannel();
const updateChannel = useUpdateChannel();
const deleteChannel = useDeleteChannel();
const generateSchedule = useGenerateSchedule();
const [createOpen, setCreateOpen] = useState(false);
const [editChannel, setEditChannel] = useState<ChannelResponse | null>(null);
const [deleteTarget, setDeleteTarget] = useState<ChannelResponse | null>(null);
const handleCreate = (data: {
name: string;
timezone: string;
description: string;
}) => {
createChannel.mutate(
{ name: data.name, timezone: data.timezone, description: data.description || undefined },
{ onSuccess: () => setCreateOpen(false) },
);
};
const handleEdit = (
id: string,
data: {
name: string;
description: string;
timezone: string;
schedule_config: { blocks: ProgrammingBlock[] };
recycle_policy: RecyclePolicy;
},
) => {
updateChannel.mutate(
{ id, data },
{ onSuccess: () => setEditChannel(null) },
);
};
const handleDelete = () => {
if (!deleteTarget) return;
deleteChannel.mutate(deleteTarget.id, {
onSuccess: () => setDeleteTarget(null),
});
};
return (
<div className="flex flex-1 flex-col items-center justify-center gap-4 p-8">
<h1 className="text-2xl font-semibold tracking-tight">Dashboard</h1>
<p className="text-sm text-zinc-500">Channel management and user settings go here.</p>
<div className="mx-auto w-full max-w-5xl space-y-6 px-6 py-8">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-xl font-semibold text-zinc-100">My Channels</h1>
<p className="mt-0.5 text-sm text-zinc-500">
Build your broadcast lineup
</p>
</div>
<Button onClick={() => setCreateOpen(true)}>
<Plus className="size-4" />
New channel
</Button>
</div>
{/* Content */}
{isLoading && (
<div className="flex items-center justify-center py-20">
<div className="h-5 w-5 animate-spin rounded-full border-2 border-zinc-700 border-t-zinc-300" />
</div>
)}
{error && (
<div className="rounded-lg border border-red-900/50 bg-red-950/20 px-4 py-3 text-sm text-red-400">
{error.message}
</div>
)}
{channels && channels.length === 0 && (
<div className="flex flex-col items-center justify-center gap-3 rounded-xl border border-dashed border-zinc-800 py-20 text-center">
<p className="text-sm text-zinc-500">No channels yet</p>
<Button variant="outline" onClick={() => setCreateOpen(true)}>
<Plus className="size-4" />
Create your first channel
</Button>
</div>
)}
{channels && channels.length > 0 && (
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{channels.map((channel) => (
<ChannelCard
key={channel.id}
channel={channel}
isGenerating={
generateSchedule.isPending &&
generateSchedule.variables === channel.id
}
onEdit={() => setEditChannel(channel)}
onDelete={() => setDeleteTarget(channel)}
onGenerateSchedule={() => generateSchedule.mutate(channel.id)}
/>
))}
</div>
)}
{/* Dialogs / sheets */}
<CreateChannelDialog
open={createOpen}
onOpenChange={setCreateOpen}
onSubmit={handleCreate}
isPending={createChannel.isPending}
error={createChannel.error?.message}
/>
<EditChannelSheet
channel={editChannel}
open={!!editChannel}
onOpenChange={(open) => { if (!open) setEditChannel(null); }}
onSubmit={handleEdit}
isPending={updateChannel.isPending}
error={updateChannel.error?.message}
/>
{deleteTarget && (
<DeleteChannelDialog
channelName={deleteTarget.name}
open={!!deleteTarget}
onOpenChange={(open) => { if (!open) setDeleteTarget(null); }}
onConfirm={handleDelete}
isPending={deleteChannel.isPending}
/>
)}
</div>
);
}