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:
10
k-tv-backend/Cargo.lock
generated
10
k-tv-backend/Cargo.lock
generated
@@ -1464,8 +1464,8 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "k-core"
|
name = "k-core"
|
||||||
version = "0.1.11"
|
version = "0.1.12"
|
||||||
source = "git+https://git.gabrielkaszewski.dev/GKaszewski/k-core#0ea9aa7870d73b5f665241a4183ffd899e628b9c"
|
source = "git+https://git.gabrielkaszewski.dev/GKaszewski/k-core#f17622306353b8b339d95bf37d43854f023c93da"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"async-nats",
|
"async-nats",
|
||||||
@@ -1727,7 +1727,7 @@ version = "5.0.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "51e219e79014df21a225b1860a479e2dcd7cbd9130f4defd4bd0e191ea31d67d"
|
checksum = "51e219e79014df21a225b1860a479e2dcd7cbd9130f4defd4bd0e191ea31d67d"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"base64 0.22.1",
|
"base64 0.21.7",
|
||||||
"chrono",
|
"chrono",
|
||||||
"getrandom 0.2.16",
|
"getrandom 0.2.16",
|
||||||
"http",
|
"http",
|
||||||
@@ -2384,7 +2384,7 @@ dependencies = [
|
|||||||
"errno",
|
"errno",
|
||||||
"libc",
|
"libc",
|
||||||
"linux-raw-sys",
|
"linux-raw-sys",
|
||||||
"windows-sys 0.52.0",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -3102,7 +3102,7 @@ dependencies = [
|
|||||||
"getrandom 0.3.4",
|
"getrandom 0.3.4",
|
||||||
"once_cell",
|
"once_cell",
|
||||||
"rustix",
|
"rustix",
|
||||||
"windows-sys 0.52.0",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|||||||
12
k-tv-frontend/app/(auth)/layout.tsx
Normal file
12
k-tv-frontend/app/(auth)/layout.tsx
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import type { ReactNode } from "react";
|
||||||
|
|
||||||
|
export default function AuthLayout({ children }: { children: ReactNode }) {
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-screen flex-col items-center justify-center bg-zinc-950 px-4">
|
||||||
|
<div className="mb-8 text-sm font-semibold tracking-widest text-zinc-100 uppercase">
|
||||||
|
K-TV
|
||||||
|
</div>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
74
k-tv-frontend/app/(auth)/login/page.tsx
Normal file
74
k-tv-frontend/app/(auth)/login/page.tsx
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import Link from "next/link";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useLogin } from "@/hooks/use-auth";
|
||||||
|
|
||||||
|
export default function LoginPage() {
|
||||||
|
const [email, setEmail] = useState("");
|
||||||
|
const [password, setPassword] = useState("");
|
||||||
|
const { mutate: login, isPending, error } = useLogin();
|
||||||
|
|
||||||
|
const handleSubmit = (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
login({ email, password });
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="w-full max-w-sm space-y-6">
|
||||||
|
<div className="space-y-1 text-center">
|
||||||
|
<h1 className="text-xl font-semibold text-zinc-100">Sign in</h1>
|
||||||
|
<p className="text-sm text-zinc-500">to manage your channels</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<label className="block text-xs font-medium text-zinc-400">
|
||||||
|
Email
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
required
|
||||||
|
autoComplete="email"
|
||||||
|
value={email}
|
||||||
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
|
placeholder="you@example.com"
|
||||||
|
className="w-full rounded-md border border-zinc-700 bg-zinc-900 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">
|
||||||
|
Password
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
required
|
||||||
|
autoComplete="current-password"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
placeholder="••••••••"
|
||||||
|
className="w-full rounded-md border border-zinc-700 bg-zinc-900 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.message}</p>}
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={isPending}
|
||||||
|
className="w-full rounded-md bg-white px-3 py-2 text-sm font-medium text-zinc-900 transition-colors hover:bg-zinc-200 disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{isPending ? "Signing in…" : "Sign in"}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<p className="text-center text-xs text-zinc-500">
|
||||||
|
No account?{" "}
|
||||||
|
<Link href="/register" className="text-zinc-300 hover:text-white">
|
||||||
|
Create one
|
||||||
|
</Link>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
74
k-tv-frontend/app/(auth)/register/page.tsx
Normal file
74
k-tv-frontend/app/(auth)/register/page.tsx
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import Link from "next/link";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useRegister } from "@/hooks/use-auth";
|
||||||
|
|
||||||
|
export default function RegisterPage() {
|
||||||
|
const [email, setEmail] = useState("");
|
||||||
|
const [password, setPassword] = useState("");
|
||||||
|
const { mutate: register, isPending, error } = useRegister();
|
||||||
|
|
||||||
|
const handleSubmit = (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
register({ email, password });
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="w-full max-w-sm space-y-6">
|
||||||
|
<div className="space-y-1 text-center">
|
||||||
|
<h1 className="text-xl font-semibold text-zinc-100">Create account</h1>
|
||||||
|
<p className="text-sm text-zinc-500">start building your channels</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<label className="block text-xs font-medium text-zinc-400">
|
||||||
|
Email
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
required
|
||||||
|
autoComplete="email"
|
||||||
|
value={email}
|
||||||
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
|
placeholder="you@example.com"
|
||||||
|
className="w-full rounded-md border border-zinc-700 bg-zinc-900 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">
|
||||||
|
Password
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
required
|
||||||
|
autoComplete="new-password"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
placeholder="••••••••"
|
||||||
|
className="w-full rounded-md border border-zinc-700 bg-zinc-900 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.message}</p>}
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={isPending}
|
||||||
|
className="w-full rounded-md bg-white px-3 py-2 text-sm font-medium text-zinc-900 transition-colors hover:bg-zinc-200 disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{isPending ? "Creating account…" : "Create account"}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<p className="text-center text-xs text-zinc-500">
|
||||||
|
Already have an account?{" "}
|
||||||
|
<Link href="/login" className="text-zinc-300 hover:text-white">
|
||||||
|
Sign in
|
||||||
|
</Link>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
41
k-tv-frontend/app/(main)/components/nav-auth.tsx
Normal file
41
k-tv-frontend/app/(main)/components/nav-auth.tsx
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import Link from "next/link";
|
||||||
|
import { useCurrentUser, useLogout } from "@/hooks/use-auth";
|
||||||
|
import { useAuthContext } from "@/context/auth-context";
|
||||||
|
|
||||||
|
export function NavAuth() {
|
||||||
|
const { token, isLoaded } = useAuthContext();
|
||||||
|
const { data: user } = useCurrentUser();
|
||||||
|
const { mutate: logout, isPending } = useLogout();
|
||||||
|
|
||||||
|
if (!isLoaded) return null;
|
||||||
|
|
||||||
|
if (!token) {
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
href="/login"
|
||||||
|
className="rounded-md px-3 py-1.5 text-sm text-zinc-400 transition-colors hover:bg-zinc-800 hover:text-zinc-100"
|
||||||
|
>
|
||||||
|
Sign in
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{user && (
|
||||||
|
<span className="hidden text-xs text-zinc-500 sm:block">
|
||||||
|
{user.email}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={() => logout()}
|
||||||
|
disabled={isPending}
|
||||||
|
className="rounded-md px-3 py-1.5 text-sm text-zinc-400 transition-colors hover:bg-zinc-800 hover:text-zinc-100 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
Sign out
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
28
k-tv-frontend/app/(main)/dashboard/layout.tsx
Normal file
28
k-tv-frontend/app/(main)/dashboard/layout.tsx
Normal 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}</>;
|
||||||
|
}
|
||||||
@@ -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() {
|
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 (
|
return (
|
||||||
<div className="flex flex-1 flex-col items-center justify-center gap-4 p-8">
|
<div className="mx-auto w-full max-w-5xl space-y-6 px-6 py-8">
|
||||||
<h1 className="text-2xl font-semibold tracking-tight">Dashboard</h1>
|
{/* Header */}
|
||||||
<p className="text-sm text-zinc-500">Channel management and user settings go here.</p>
|
<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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { type ReactNode } from "react";
|
import { type ReactNode } from "react";
|
||||||
|
import { NavAuth } from "./components/nav-auth";
|
||||||
|
|
||||||
const NAV_LINKS = [
|
const NAV_LINKS = [
|
||||||
{ href: "/tv", label: "TV" },
|
{ href: "/tv", label: "TV" },
|
||||||
{ href: "/dashboard", label: "Dashboard" },
|
{ href: "/dashboard", label: "Dashboard" },
|
||||||
{ href: "/docs", label: "Docs" },
|
|
||||||
];
|
];
|
||||||
|
|
||||||
export default function MainLayout({ children }: { children: ReactNode }) {
|
export default function MainLayout({ children }: { children: ReactNode }) {
|
||||||
@@ -12,21 +12,29 @@ export default function MainLayout({ children }: { children: ReactNode }) {
|
|||||||
<div className="flex min-h-screen flex-col bg-zinc-950 text-zinc-100">
|
<div className="flex min-h-screen flex-col bg-zinc-950 text-zinc-100">
|
||||||
<header className="sticky top-0 z-50 border-b border-zinc-800 bg-zinc-950/80 backdrop-blur">
|
<header className="sticky top-0 z-50 border-b border-zinc-800 bg-zinc-950/80 backdrop-blur">
|
||||||
<nav className="mx-auto flex h-14 max-w-7xl items-center justify-between px-6">
|
<nav className="mx-auto flex h-14 max-w-7xl items-center justify-between px-6">
|
||||||
<Link href="/tv" className="text-sm font-semibold tracking-widest text-zinc-100 uppercase">
|
<Link
|
||||||
|
href="/tv"
|
||||||
|
className="text-sm font-semibold tracking-widest text-zinc-100 uppercase"
|
||||||
|
>
|
||||||
K-TV
|
K-TV
|
||||||
</Link>
|
</Link>
|
||||||
<ul className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
{NAV_LINKS.map(({ href, label }) => (
|
<ul className="flex items-center gap-1">
|
||||||
<li key={href}>
|
{NAV_LINKS.map(({ href, label }) => (
|
||||||
<Link
|
<li key={href}>
|
||||||
href={href}
|
<Link
|
||||||
className="rounded-md px-3 py-1.5 text-sm text-zinc-400 transition-colors hover:bg-zinc-800 hover:text-zinc-100"
|
href={href}
|
||||||
>
|
className="rounded-md px-3 py-1.5 text-sm text-zinc-400 transition-colors hover:bg-zinc-800 hover:text-zinc-100"
|
||||||
{label}
|
>
|
||||||
</Link>
|
{label}
|
||||||
</li>
|
</Link>
|
||||||
))}
|
</li>
|
||||||
</ul>
|
))}
|
||||||
|
</ul>
|
||||||
|
<div className="ml-2 border-l border-zinc-800 pl-2">
|
||||||
|
<NavAuth />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
</header>
|
</header>
|
||||||
<main className="flex flex-1 flex-col">{children}</main>
|
<main className="flex flex-1 flex-col">{children}</main>
|
||||||
|
|||||||
@@ -9,109 +9,80 @@ import {
|
|||||||
UpNextBanner,
|
UpNextBanner,
|
||||||
NoSignal,
|
NoSignal,
|
||||||
} from "./components";
|
} from "./components";
|
||||||
import type { ScheduleSlot } from "./components";
|
import { useAuthContext } from "@/context/auth-context";
|
||||||
|
import { useChannels, useCurrentBroadcast, useEpg } from "@/hooks/use-channels";
|
||||||
|
import {
|
||||||
|
useStreamUrl,
|
||||||
|
fmtTime,
|
||||||
|
calcProgress,
|
||||||
|
minutesUntil,
|
||||||
|
toScheduleSlots,
|
||||||
|
findNextSlot,
|
||||||
|
} from "@/hooks/use-tv";
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Mock data — replace with TanStack Query hooks once the API is ready
|
// Constants
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
interface MockChannel {
|
|
||||||
number: number;
|
|
||||||
name: string;
|
|
||||||
src?: string;
|
|
||||||
schedule: ScheduleSlot[];
|
|
||||||
current: {
|
|
||||||
title: string;
|
|
||||||
startTime: string;
|
|
||||||
endTime: string;
|
|
||||||
progress: number;
|
|
||||||
description?: string;
|
|
||||||
};
|
|
||||||
next: {
|
|
||||||
title: string;
|
|
||||||
startTime: string;
|
|
||||||
minutesUntil: number;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const MOCK_CHANNELS: MockChannel[] = [
|
|
||||||
{
|
|
||||||
number: 1,
|
|
||||||
name: "Cinema Classic",
|
|
||||||
schedule: [
|
|
||||||
{ id: "c1-1", title: "The Maltese Falcon", startTime: "17:00", endTime: "18:45" },
|
|
||||||
{ id: "c1-2", title: "Casablanca", startTime: "18:45", endTime: "20:30", isCurrent: true },
|
|
||||||
{ id: "c1-3", title: "Sunset Boulevard", startTime: "20:30", endTime: "22:15" },
|
|
||||||
{ id: "c1-4", title: "Rear Window", startTime: "22:15", endTime: "00:00" },
|
|
||||||
],
|
|
||||||
current: {
|
|
||||||
title: "Casablanca",
|
|
||||||
startTime: "18:45",
|
|
||||||
endTime: "20:30",
|
|
||||||
progress: 72,
|
|
||||||
description:
|
|
||||||
"A cynical American expatriate struggles to decide whether or not he should help his former lover and her fugitive husband escape French Morocco.",
|
|
||||||
},
|
|
||||||
next: { title: "Sunset Boulevard", startTime: "20:30", minutesUntil: 23 },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
number: 2,
|
|
||||||
name: "Nature & Wild",
|
|
||||||
schedule: [
|
|
||||||
{ id: "c2-1", title: "Planet Earth II", startTime: "19:00", endTime: "20:00", isCurrent: true },
|
|
||||||
{ id: "c2-2", title: "Blue Planet", startTime: "20:00", endTime: "21:00" },
|
|
||||||
{ id: "c2-3", title: "Africa", startTime: "21:00", endTime: "22:00" },
|
|
||||||
],
|
|
||||||
current: {
|
|
||||||
title: "Planet Earth II",
|
|
||||||
startTime: "19:00",
|
|
||||||
endTime: "20:00",
|
|
||||||
progress: 85,
|
|
||||||
description:
|
|
||||||
"David Attenborough explores the world's most iconic landscapes and the remarkable animals that inhabit them.",
|
|
||||||
},
|
|
||||||
next: { title: "Blue Planet", startTime: "20:00", minutesUntil: 9 },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
number: 3,
|
|
||||||
name: "Sci-Fi Zone",
|
|
||||||
schedule: [
|
|
||||||
{ id: "c3-1", title: "2001: A Space Odyssey", startTime: "19:30", endTime: "22:10", isCurrent: true },
|
|
||||||
{ id: "c3-2", title: "Blade Runner", startTime: "22:10", endTime: "00:17" },
|
|
||||||
],
|
|
||||||
current: {
|
|
||||||
title: "2001: A Space Odyssey",
|
|
||||||
startTime: "19:30",
|
|
||||||
endTime: "22:10",
|
|
||||||
progress: 40,
|
|
||||||
description:
|
|
||||||
"After discovering a mysterious artifact, mankind sets off on a quest to find its origins with help from intelligent supercomputer H.A.L. 9000.",
|
|
||||||
},
|
|
||||||
next: { title: "Blade Runner", startTime: "22:10", minutesUntil: 96 },
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
const IDLE_TIMEOUT_MS = 3500;
|
const IDLE_TIMEOUT_MS = 3500;
|
||||||
const BANNER_THRESHOLD = 80; // show "up next" banner when progress ≥ this
|
const BANNER_THRESHOLD = 80; // show "up next" when progress ≥ this %
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Page
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
export default function TvPage() {
|
export default function TvPage() {
|
||||||
|
const { token } = useAuthContext();
|
||||||
|
|
||||||
|
// Channel list
|
||||||
|
const { data: channels, isLoading: isLoadingChannels } = useChannels();
|
||||||
|
|
||||||
|
// Channel navigation
|
||||||
const [channelIdx, setChannelIdx] = useState(0);
|
const [channelIdx, setChannelIdx] = useState(0);
|
||||||
|
const channel = channels?.[channelIdx];
|
||||||
|
|
||||||
|
// Overlay / idle state
|
||||||
const [showOverlays, setShowOverlays] = useState(true);
|
const [showOverlays, setShowOverlays] = useState(true);
|
||||||
const [showSchedule, setShowSchedule] = useState(false);
|
const [showSchedule, setShowSchedule] = useState(false);
|
||||||
const idleTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
const idleTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
|
||||||
const channel = MOCK_CHANNELS[channelIdx];
|
// Tick for live progress calculation (every 30 s is fine for the progress bar)
|
||||||
const showBanner = channel.current.progress >= BANNER_THRESHOLD;
|
const [, setTick] = useState(0);
|
||||||
|
useEffect(() => {
|
||||||
|
const id = setInterval(() => setTick((n) => n + 1), 30_000);
|
||||||
|
return () => clearInterval(id);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Per-channel data
|
||||||
|
const { data: broadcast, isLoading: isLoadingBroadcast } =
|
||||||
|
useCurrentBroadcast(channel?.id ?? "");
|
||||||
|
const { data: epgSlots } = useEpg(channel?.id ?? "");
|
||||||
|
const { data: streamUrl } = useStreamUrl(channel?.id, token);
|
||||||
|
|
||||||
// ------------------------------------------------------------------
|
// ------------------------------------------------------------------
|
||||||
// Idle detection — hide overlays after inactivity
|
// Derived display values
|
||||||
// ------------------------------------------------------------------
|
// ------------------------------------------------------------------
|
||||||
|
|
||||||
|
const hasBroadcast = !!broadcast;
|
||||||
|
const progress = hasBroadcast
|
||||||
|
? calcProgress(broadcast.slot.start_at, broadcast.slot.item.duration_secs)
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
const scheduleSlots = toScheduleSlots(epgSlots ?? [], broadcast?.slot.id);
|
||||||
|
const nextSlot = findNextSlot(epgSlots ?? [], broadcast?.slot.id);
|
||||||
|
const showBanner = hasBroadcast && progress >= BANNER_THRESHOLD && !!nextSlot;
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
// Idle detection
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
|
||||||
const resetIdle = useCallback(() => {
|
const resetIdle = useCallback(() => {
|
||||||
setShowOverlays(true);
|
setShowOverlays(true);
|
||||||
if (idleTimer.current) clearTimeout(idleTimer.current);
|
if (idleTimer.current) clearTimeout(idleTimer.current);
|
||||||
idleTimer.current = setTimeout(() => setShowOverlays(false), IDLE_TIMEOUT_MS);
|
idleTimer.current = setTimeout(
|
||||||
|
() => setShowOverlays(false),
|
||||||
|
IDLE_TIMEOUT_MS,
|
||||||
|
);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -124,15 +95,18 @@ export default function TvPage() {
|
|||||||
// ------------------------------------------------------------------
|
// ------------------------------------------------------------------
|
||||||
// Channel switching
|
// Channel switching
|
||||||
// ------------------------------------------------------------------
|
// ------------------------------------------------------------------
|
||||||
|
|
||||||
|
const channelCount = channels?.length ?? 0;
|
||||||
|
|
||||||
const prevChannel = useCallback(() => {
|
const prevChannel = useCallback(() => {
|
||||||
setChannelIdx((i) => (i - 1 + MOCK_CHANNELS.length) % MOCK_CHANNELS.length);
|
setChannelIdx((i) => (i - 1 + Math.max(channelCount, 1)) % Math.max(channelCount, 1));
|
||||||
resetIdle();
|
resetIdle();
|
||||||
}, [resetIdle]);
|
}, [channelCount, resetIdle]);
|
||||||
|
|
||||||
const nextChannel = useCallback(() => {
|
const nextChannel = useCallback(() => {
|
||||||
setChannelIdx((i) => (i + 1) % MOCK_CHANNELS.length);
|
setChannelIdx((i) => (i + 1) % Math.max(channelCount, 1));
|
||||||
resetIdle();
|
resetIdle();
|
||||||
}, [resetIdle]);
|
}, [channelCount, resetIdle]);
|
||||||
|
|
||||||
const toggleSchedule = useCallback(() => {
|
const toggleSchedule = useCallback(() => {
|
||||||
setShowSchedule((s) => !s);
|
setShowSchedule((s) => !s);
|
||||||
@@ -142,10 +116,14 @@ export default function TvPage() {
|
|||||||
// ------------------------------------------------------------------
|
// ------------------------------------------------------------------
|
||||||
// Keyboard shortcuts
|
// Keyboard shortcuts
|
||||||
// ------------------------------------------------------------------
|
// ------------------------------------------------------------------
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleKey = (e: KeyboardEvent) => {
|
const handleKey = (e: KeyboardEvent) => {
|
||||||
// Don't steal input from focused form elements
|
if (
|
||||||
if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) return;
|
e.target instanceof HTMLInputElement ||
|
||||||
|
e.target instanceof HTMLTextAreaElement
|
||||||
|
)
|
||||||
|
return;
|
||||||
|
|
||||||
switch (e.key) {
|
switch (e.key) {
|
||||||
case "ArrowUp":
|
case "ArrowUp":
|
||||||
@@ -169,9 +147,46 @@ export default function TvPage() {
|
|||||||
return () => window.removeEventListener("keydown", handleKey);
|
return () => window.removeEventListener("keydown", handleKey);
|
||||||
}, [nextChannel, prevChannel, toggleSchedule]);
|
}, [nextChannel, prevChannel, toggleSchedule]);
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
// Render helpers
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
|
||||||
|
const renderBase = () => {
|
||||||
|
if (isLoadingChannels) {
|
||||||
|
return <NoSignal variant="loading" message="Tuning in…" />;
|
||||||
|
}
|
||||||
|
if (!channels || channels.length === 0) {
|
||||||
|
return (
|
||||||
|
<NoSignal
|
||||||
|
variant="no-signal"
|
||||||
|
message="No channels configured. Visit the Dashboard to create one."
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (isLoadingBroadcast) {
|
||||||
|
return <NoSignal variant="loading" message="Tuning in…" />;
|
||||||
|
}
|
||||||
|
if (!hasBroadcast) {
|
||||||
|
return (
|
||||||
|
<NoSignal
|
||||||
|
variant="no-signal"
|
||||||
|
message="Nothing is scheduled right now. Check back later."
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (streamUrl) {
|
||||||
|
return (
|
||||||
|
<VideoPlayer src={streamUrl} className="absolute inset-0 h-full w-full" />
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// Broadcast exists but stream URL resolving — show no-signal until ready
|
||||||
|
return <NoSignal variant="loading" message="Loading stream…" />;
|
||||||
|
};
|
||||||
|
|
||||||
// ------------------------------------------------------------------
|
// ------------------------------------------------------------------
|
||||||
// Render
|
// Render
|
||||||
// ------------------------------------------------------------------
|
// ------------------------------------------------------------------
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="relative flex flex-1 overflow-hidden bg-black"
|
className="relative flex flex-1 overflow-hidden bg-black"
|
||||||
@@ -180,66 +195,82 @@ export default function TvPage() {
|
|||||||
onClick={resetIdle}
|
onClick={resetIdle}
|
||||||
>
|
>
|
||||||
{/* ── Base layer ─────────────────────────────────────────────── */}
|
{/* ── Base layer ─────────────────────────────────────────────── */}
|
||||||
{channel.src ? (
|
<div className="absolute inset-0">{renderBase()}</div>
|
||||||
<VideoPlayer src={channel.src} className="absolute inset-0 h-full w-full" />
|
|
||||||
) : (
|
|
||||||
<div className="absolute inset-0">
|
|
||||||
<NoSignal variant="no-signal" />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* ── Overlays ───────────────────────────────────────────────── */}
|
{/* ── Overlays — only when channels available ─────────────────── */}
|
||||||
<div
|
{channel && (
|
||||||
className="pointer-events-none absolute inset-0 z-10 flex flex-col justify-between transition-opacity duration-300"
|
<>
|
||||||
style={{ opacity: showOverlays ? 1 : 0 }}
|
<div
|
||||||
>
|
className="pointer-events-none absolute inset-0 z-10 flex flex-col justify-between transition-opacity duration-300"
|
||||||
{/* Top-right: guide toggle */}
|
style={{ opacity: showOverlays ? 1 : 0 }}
|
||||||
<div className="flex justify-end p-4">
|
|
||||||
<button
|
|
||||||
className="pointer-events-auto rounded-md bg-black/50 px-3 py-1.5 text-xs text-zinc-400 backdrop-blur transition-colors hover:bg-black/70 hover:text-white"
|
|
||||||
onClick={toggleSchedule}
|
|
||||||
>
|
>
|
||||||
{showSchedule ? "Hide guide" : "Guide [G]"}
|
{/* Top-right: guide toggle */}
|
||||||
</button>
|
<div className="flex justify-end p-4">
|
||||||
</div>
|
<button
|
||||||
|
className="pointer-events-auto rounded-md bg-black/50 px-3 py-1.5 text-xs text-zinc-400 backdrop-blur transition-colors hover:bg-black/70 hover:text-white"
|
||||||
|
onClick={toggleSchedule}
|
||||||
|
>
|
||||||
|
{showSchedule ? "Hide guide" : "Guide [G]"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Bottom: banner + info row */}
|
{/* Bottom: banner + info row */}
|
||||||
<div className="flex flex-col gap-3 bg-gradient-to-t from-black/80 via-black/40 to-transparent p-5 pt-20">
|
<div className="flex flex-col gap-3 bg-gradient-to-t from-black/80 via-black/40 to-transparent p-5 pt-20">
|
||||||
{showBanner && (
|
{showBanner && nextSlot && (
|
||||||
<UpNextBanner
|
<UpNextBanner
|
||||||
nextShowTitle={channel.next.title}
|
nextShowTitle={nextSlot.item.title}
|
||||||
minutesUntil={channel.next.minutesUntil}
|
minutesUntil={minutesUntil(nextSlot.start_at)}
|
||||||
nextShowStartTime={channel.next.startTime}
|
nextShowStartTime={fmtTime(nextSlot.start_at)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="flex items-end justify-between gap-4">
|
<div className="flex items-end justify-between gap-4">
|
||||||
<ChannelInfo
|
{hasBroadcast ? (
|
||||||
channelNumber={channel.number}
|
<ChannelInfo
|
||||||
channelName={channel.name}
|
channelNumber={channelIdx + 1}
|
||||||
showTitle={channel.current.title}
|
channelName={channel.name}
|
||||||
showStartTime={channel.current.startTime}
|
showTitle={broadcast.slot.item.title}
|
||||||
showEndTime={channel.current.endTime}
|
showStartTime={fmtTime(broadcast.slot.start_at)}
|
||||||
progress={channel.current.progress}
|
showEndTime={fmtTime(broadcast.slot.end_at)}
|
||||||
description={channel.current.description}
|
progress={progress}
|
||||||
/>
|
description={broadcast.slot.item.description ?? undefined}
|
||||||
<div className="pointer-events-auto">
|
/>
|
||||||
<ChannelControls
|
) : (
|
||||||
channelNumber={channel.number}
|
/* Minimal channel badge when no broadcast */
|
||||||
channelName={channel.name}
|
<div className="rounded-lg bg-black/60 px-4 py-3 backdrop-blur-md">
|
||||||
onPrevChannel={prevChannel}
|
<div className="flex items-center gap-2">
|
||||||
onNextChannel={nextChannel}
|
<span className="flex h-7 min-w-9 items-center justify-center rounded bg-white px-1.5 font-mono text-xs font-bold text-black">
|
||||||
/>
|
{channelIdx + 1}
|
||||||
|
</span>
|
||||||
|
<span className="text-sm font-medium text-zinc-300">
|
||||||
|
{channel.name}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="pointer-events-auto">
|
||||||
|
<ChannelControls
|
||||||
|
channelNumber={channelIdx + 1}
|
||||||
|
channelName={channel.name}
|
||||||
|
onPrevChannel={prevChannel}
|
||||||
|
onNextChannel={nextChannel}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Schedule overlay — outside the fading div so it has its own visibility */}
|
{/* Schedule overlay — outside the fading div so it has its own visibility */}
|
||||||
{showOverlays && showSchedule && (
|
{showOverlays && showSchedule && (
|
||||||
<div className="absolute bottom-4 right-4 top-14 z-20 w-80">
|
<div className="absolute bottom-4 right-4 top-14 z-20 w-80">
|
||||||
<ScheduleOverlay channelName={channel.name} slots={channel.schedule} />
|
<ScheduleOverlay
|
||||||
</div>
|
channelName={channel.name}
|
||||||
|
slots={scheduleSlots}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
56
k-tv-frontend/app/api/stream/[channelId]/route.ts
Normal file
56
k-tv-frontend/app/api/stream/[channelId]/route.ts
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
import { NextRequest } from "next/server";
|
||||||
|
|
||||||
|
// Server-side URL of the K-TV backend (never exposed to the browser).
|
||||||
|
// Falls back to the public URL if the internal one isn't set.
|
||||||
|
const API_URL =
|
||||||
|
process.env.API_URL ??
|
||||||
|
process.env.NEXT_PUBLIC_API_URL ??
|
||||||
|
"http://localhost:4000/api/v1";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/stream/[channelId]?token=<bearer>
|
||||||
|
*
|
||||||
|
* Resolves the backend's 307 stream redirect and returns the final
|
||||||
|
* Jellyfin URL as JSON. Browsers can't read the Location header from a
|
||||||
|
* redirected fetch, so this server-side route does it for them.
|
||||||
|
*
|
||||||
|
* Returns:
|
||||||
|
* 200 { url: string } — stream URL ready to use as <video src>
|
||||||
|
* 204 — channel is in a gap (no-signal)
|
||||||
|
* 401 — missing token
|
||||||
|
* 502 — backend error
|
||||||
|
*/
|
||||||
|
export async function GET(
|
||||||
|
request: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ channelId: string }> },
|
||||||
|
) {
|
||||||
|
const { channelId } = await params;
|
||||||
|
const token = request.nextUrl.searchParams.get("token");
|
||||||
|
|
||||||
|
if (!token) {
|
||||||
|
return new Response(null, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
let res: Response;
|
||||||
|
try {
|
||||||
|
res = await fetch(`${API_URL}/channels/${channelId}/stream`, {
|
||||||
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
|
redirect: "manual",
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
return new Response(null, { status: 502 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (res.status === 204) {
|
||||||
|
return new Response(null, { status: 204 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (res.status === 307 || res.status === 302 || res.status === 301) {
|
||||||
|
const location = res.headers.get("Location");
|
||||||
|
if (location) {
|
||||||
|
return Response.json({ url: location });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Response(null, { status: 502 });
|
||||||
|
}
|
||||||
@@ -14,8 +14,8 @@ const geistMono = Geist_Mono({
|
|||||||
});
|
});
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: "Create Next App",
|
title: "K-TV",
|
||||||
description: "Generated by create next app",
|
description: "Self-hosted linear TV channel orchestration for your media library",
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function RootLayout({
|
export default function RootLayout({
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
|
import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
import { AuthProvider } from "@/context/auth-context";
|
||||||
|
|
||||||
export function Providers({ children }: { children: React.ReactNode }) {
|
export function Providers({ children }: { children: React.ReactNode }) {
|
||||||
const [queryClient] = useState(
|
const [queryClient] = useState(
|
||||||
@@ -13,13 +14,15 @@ export function Providers({ children }: { children: React.ReactNode }) {
|
|||||||
staleTime: 60 * 1000,
|
staleTime: 60 * 1000,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
})
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<QueryClientProvider client={queryClient}>
|
<AuthProvider>
|
||||||
{children}
|
<QueryClientProvider client={queryClient}>
|
||||||
<ReactQueryDevtools initialIsOpen={false} />
|
{children}
|
||||||
</QueryClientProvider>
|
<ReactQueryDevtools initialIsOpen={false} />
|
||||||
|
</QueryClientProvider>
|
||||||
|
</AuthProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
52
k-tv-frontend/context/auth-context.tsx
Normal file
52
k-tv-frontend/context/auth-context.tsx
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import {
|
||||||
|
createContext,
|
||||||
|
useContext,
|
||||||
|
useState,
|
||||||
|
useEffect,
|
||||||
|
type ReactNode,
|
||||||
|
} from "react";
|
||||||
|
|
||||||
|
const TOKEN_KEY = "k-tv-token";
|
||||||
|
|
||||||
|
interface AuthContextValue {
|
||||||
|
token: string | null;
|
||||||
|
/** True once the initial localStorage read has completed */
|
||||||
|
isLoaded: boolean;
|
||||||
|
setToken: (token: string | null) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const AuthContext = createContext<AuthContextValue | null>(null);
|
||||||
|
|
||||||
|
export function AuthProvider({ children }: { children: ReactNode }) {
|
||||||
|
const [token, setTokenState] = useState<string | null>(null);
|
||||||
|
const [isLoaded, setIsLoaded] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const stored = localStorage.getItem(TOKEN_KEY);
|
||||||
|
if (stored) setTokenState(stored);
|
||||||
|
setIsLoaded(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const setToken = (t: string | null) => {
|
||||||
|
setTokenState(t);
|
||||||
|
if (t) {
|
||||||
|
localStorage.setItem(TOKEN_KEY, t);
|
||||||
|
} else {
|
||||||
|
localStorage.removeItem(TOKEN_KEY);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AuthContext.Provider value={{ token, isLoaded, setToken }}>
|
||||||
|
{children}
|
||||||
|
</AuthContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAuthContext() {
|
||||||
|
const ctx = useContext(AuthContext);
|
||||||
|
if (!ctx) throw new Error("useAuthContext must be used within AuthProvider");
|
||||||
|
return ctx;
|
||||||
|
}
|
||||||
60
k-tv-frontend/hooks/use-auth.ts
Normal file
60
k-tv-frontend/hooks/use-auth.ts
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { api } from "@/lib/api";
|
||||||
|
import { useAuthContext } from "@/context/auth-context";
|
||||||
|
|
||||||
|
export function useCurrentUser() {
|
||||||
|
const { token } = useAuthContext();
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ["me"],
|
||||||
|
queryFn: () => api.auth.me(token!),
|
||||||
|
enabled: !!token,
|
||||||
|
retry: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useLogin() {
|
||||||
|
const { setToken } = useAuthContext();
|
||||||
|
const router = useRouter();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: ({ email, password }: { email: string; password: string }) =>
|
||||||
|
api.auth.login(email, password),
|
||||||
|
onSuccess: (data) => {
|
||||||
|
setToken(data.access_token);
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["me"] });
|
||||||
|
router.push("/dashboard");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useRegister() {
|
||||||
|
const { setToken } = useAuthContext();
|
||||||
|
const router = useRouter();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: ({ email, password }: { email: string; password: string }) =>
|
||||||
|
api.auth.register(email, password),
|
||||||
|
onSuccess: (data) => {
|
||||||
|
setToken(data.access_token);
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["me"] });
|
||||||
|
router.push("/dashboard");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useLogout() {
|
||||||
|
const { token, setToken } = useAuthContext();
|
||||||
|
const router = useRouter();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: () => (token ? api.auth.logout(token) : Promise.resolve()),
|
||||||
|
onSettled: () => {
|
||||||
|
setToken(null);
|
||||||
|
queryClient.clear();
|
||||||
|
router.push("/login");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
102
k-tv-frontend/hooks/use-channels.ts
Normal file
102
k-tv-frontend/hooks/use-channels.ts
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { api } from "@/lib/api";
|
||||||
|
import { useAuthContext } from "@/context/auth-context";
|
||||||
|
import type { CreateChannelRequest, UpdateChannelRequest } from "@/lib/types";
|
||||||
|
|
||||||
|
export function useChannels() {
|
||||||
|
const { token } = useAuthContext();
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ["channels"],
|
||||||
|
queryFn: () => api.channels.list(token!),
|
||||||
|
enabled: !!token,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useChannel(id: string) {
|
||||||
|
const { token } = useAuthContext();
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ["channel", id],
|
||||||
|
queryFn: () => api.channels.get(id, token!),
|
||||||
|
enabled: !!token && !!id,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useCreateChannel() {
|
||||||
|
const { token } = useAuthContext();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (data: CreateChannelRequest) =>
|
||||||
|
api.channels.create(data, token!),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["channels"] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useUpdateChannel() {
|
||||||
|
const { token } = useAuthContext();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: ({ id, data }: { id: string; data: UpdateChannelRequest }) =>
|
||||||
|
api.channels.update(id, data, token!),
|
||||||
|
onSuccess: (updated) => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["channels"] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["channel", updated.id] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useDeleteChannel() {
|
||||||
|
const { token } = useAuthContext();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (id: string) => api.channels.delete(id, token!),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["channels"] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useGenerateSchedule() {
|
||||||
|
const { token } = useAuthContext();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (channelId: string) =>
|
||||||
|
api.schedule.generate(channelId, token!),
|
||||||
|
onSuccess: (_, channelId) => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["schedule", channelId] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useActiveSchedule(channelId: string) {
|
||||||
|
const { token } = useAuthContext();
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ["schedule", channelId],
|
||||||
|
queryFn: () => api.schedule.getActive(channelId, token!),
|
||||||
|
enabled: !!token && !!channelId,
|
||||||
|
retry: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useCurrentBroadcast(channelId: string) {
|
||||||
|
const { token } = useAuthContext();
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ["broadcast", channelId],
|
||||||
|
queryFn: () => api.schedule.getCurrentBroadcast(channelId, token!),
|
||||||
|
enabled: !!token && !!channelId,
|
||||||
|
refetchInterval: 30_000,
|
||||||
|
retry: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useEpg(channelId: string, from?: string, until?: string) {
|
||||||
|
const { token } = useAuthContext();
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ["epg", channelId, from, until],
|
||||||
|
queryFn: () => api.schedule.getEpg(channelId, token!, from, until),
|
||||||
|
enabled: !!token && !!channelId,
|
||||||
|
});
|
||||||
|
}
|
||||||
94
k-tv-frontend/hooks/use-tv.ts
Normal file
94
k-tv-frontend/hooks/use-tv.ts
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import type { ScheduleSlot } from "@/app/(main)/tv/components";
|
||||||
|
import type { ScheduledSlotResponse } from "@/lib/types";
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Pure transformation utilities
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** Format an ISO-8601 string to "HH:MM" in the user's local timezone. */
|
||||||
|
export function fmtTime(iso: string): string {
|
||||||
|
return new Date(iso).toLocaleTimeString([], {
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
hour12: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Progress percentage through a slot based on wall-clock time. */
|
||||||
|
export function calcProgress(startAt: string, durationSecs: number): number {
|
||||||
|
if (durationSecs <= 0) return 0;
|
||||||
|
const elapsedSecs = (Date.now() - new Date(startAt).getTime()) / 1000;
|
||||||
|
return Math.min(100, Math.max(0, Math.round((elapsedSecs / durationSecs) * 100)));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Minutes until a future timestamp (rounded, minimum 0). */
|
||||||
|
export function minutesUntil(iso: string): number {
|
||||||
|
return Math.max(0, Math.round((new Date(iso).getTime() - Date.now()) / 60_000));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Map EPG slots to the shape expected by ScheduleOverlay.
|
||||||
|
* Marks the slot matching currentSlotId as current.
|
||||||
|
*/
|
||||||
|
export function toScheduleSlots(
|
||||||
|
slots: ScheduledSlotResponse[],
|
||||||
|
currentSlotId?: string,
|
||||||
|
): ScheduleSlot[] {
|
||||||
|
return slots.map((slot) => ({
|
||||||
|
id: slot.id,
|
||||||
|
title: slot.item.title,
|
||||||
|
startTime: fmtTime(slot.start_at),
|
||||||
|
endTime: fmtTime(slot.end_at),
|
||||||
|
isCurrent: slot.id === currentSlotId,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find the slot immediately after the current one.
|
||||||
|
* Returns null if current slot is last or not found.
|
||||||
|
*/
|
||||||
|
export function findNextSlot(
|
||||||
|
slots: ScheduledSlotResponse[],
|
||||||
|
currentSlotId?: string,
|
||||||
|
): ScheduledSlotResponse | null {
|
||||||
|
if (!currentSlotId || slots.length === 0) return null;
|
||||||
|
const idx = slots.findIndex((s) => s.id === currentSlotId);
|
||||||
|
if (idx === -1 || idx === slots.length - 1) return null;
|
||||||
|
return slots[idx + 1];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// useStreamUrl — resolves the 307 stream redirect via a Next.js API route
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolves the live stream URL for a channel.
|
||||||
|
*
|
||||||
|
* The backend's GET /channels/:id/stream endpoint returns a 307 redirect to
|
||||||
|
* the Jellyfin stream URL. Since browsers can't read redirect Location headers
|
||||||
|
* from fetch(), we proxy through /api/stream/[channelId] (a Next.js route that
|
||||||
|
* runs server-side) and return the final URL as JSON.
|
||||||
|
*
|
||||||
|
* Returns null when the channel is in a gap (no-signal / 204).
|
||||||
|
*/
|
||||||
|
export function useStreamUrl(channelId: string | undefined, token: string | null) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ["stream-url", channelId],
|
||||||
|
queryFn: async (): Promise<string | null> => {
|
||||||
|
const res = await fetch(
|
||||||
|
`/api/stream/${channelId}?token=${encodeURIComponent(token!)}`,
|
||||||
|
{ cache: "no-store" },
|
||||||
|
);
|
||||||
|
if (res.status === 204) return null;
|
||||||
|
if (!res.ok) throw new Error(`Stream resolve failed: ${res.status}`);
|
||||||
|
const { url } = await res.json();
|
||||||
|
return url as string;
|
||||||
|
},
|
||||||
|
enabled: !!channelId && !!token,
|
||||||
|
refetchInterval: 30_000,
|
||||||
|
retry: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
132
k-tv-frontend/lib/api.ts
Normal file
132
k-tv-frontend/lib/api.ts
Normal file
@@ -0,0 +1,132 @@
|
|||||||
|
import type {
|
||||||
|
TokenResponse,
|
||||||
|
UserResponse,
|
||||||
|
ChannelResponse,
|
||||||
|
CreateChannelRequest,
|
||||||
|
UpdateChannelRequest,
|
||||||
|
ScheduleResponse,
|
||||||
|
ScheduledSlotResponse,
|
||||||
|
CurrentBroadcastResponse,
|
||||||
|
} from "@/lib/types";
|
||||||
|
|
||||||
|
const API_BASE =
|
||||||
|
process.env.NEXT_PUBLIC_API_URL ?? "http://localhost:4000/api/v1";
|
||||||
|
|
||||||
|
export class ApiRequestError extends Error {
|
||||||
|
constructor(
|
||||||
|
public readonly status: number,
|
||||||
|
message: string,
|
||||||
|
) {
|
||||||
|
super(message);
|
||||||
|
this.name = "ApiRequestError";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function request<T>(
|
||||||
|
path: string,
|
||||||
|
options: RequestInit & { token?: string } = {},
|
||||||
|
): Promise<T> {
|
||||||
|
const { token, ...init } = options;
|
||||||
|
const headers = new Headers(init.headers);
|
||||||
|
|
||||||
|
if (token) {
|
||||||
|
headers.set("Authorization", `Bearer ${token}`);
|
||||||
|
}
|
||||||
|
if (init.body && !headers.has("Content-Type")) {
|
||||||
|
headers.set("Content-Type", "application/json");
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await fetch(`${API_BASE}${path}`, { ...init, headers });
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
let message = res.statusText;
|
||||||
|
try {
|
||||||
|
const body = await res.json();
|
||||||
|
message = body.message ?? body.error ?? message;
|
||||||
|
} catch {
|
||||||
|
// ignore parse error, use statusText
|
||||||
|
}
|
||||||
|
throw new ApiRequestError(res.status, message);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (res.status === 204) return null as T;
|
||||||
|
return res.json() as Promise<T>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const api = {
|
||||||
|
auth: {
|
||||||
|
register: (email: string, password: string) =>
|
||||||
|
request<TokenResponse>("/auth/register", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ email, password }),
|
||||||
|
}),
|
||||||
|
|
||||||
|
login: (email: string, password: string) =>
|
||||||
|
request<TokenResponse>("/auth/login", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ email, password }),
|
||||||
|
}),
|
||||||
|
|
||||||
|
logout: (token: string) =>
|
||||||
|
request<void>("/auth/logout", { method: "POST", token }),
|
||||||
|
|
||||||
|
me: (token: string) => request<UserResponse>("/auth/me", { token }),
|
||||||
|
},
|
||||||
|
|
||||||
|
channels: {
|
||||||
|
list: (token: string) =>
|
||||||
|
request<ChannelResponse[]>("/channels", { token }),
|
||||||
|
|
||||||
|
get: (id: string, token: string) =>
|
||||||
|
request<ChannelResponse>(`/channels/${id}`, { token }),
|
||||||
|
|
||||||
|
create: (data: CreateChannelRequest, token: string) =>
|
||||||
|
request<ChannelResponse>("/channels", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
token,
|
||||||
|
}),
|
||||||
|
|
||||||
|
update: (id: string, data: UpdateChannelRequest, token: string) =>
|
||||||
|
request<ChannelResponse>(`/channels/${id}`, {
|
||||||
|
method: "PUT",
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
token,
|
||||||
|
}),
|
||||||
|
|
||||||
|
delete: (id: string, token: string) =>
|
||||||
|
request<void>(`/channels/${id}`, { method: "DELETE", token }),
|
||||||
|
},
|
||||||
|
|
||||||
|
schedule: {
|
||||||
|
generate: (channelId: string, token: string) =>
|
||||||
|
request<ScheduleResponse>(`/channels/${channelId}/schedule`, {
|
||||||
|
method: "POST",
|
||||||
|
token,
|
||||||
|
}),
|
||||||
|
|
||||||
|
getActive: (channelId: string, token: string) =>
|
||||||
|
request<ScheduleResponse>(`/channels/${channelId}/schedule`, { token }),
|
||||||
|
|
||||||
|
getCurrentBroadcast: (channelId: string, token: string) =>
|
||||||
|
request<CurrentBroadcastResponse | null>(`/channels/${channelId}/now`, {
|
||||||
|
token,
|
||||||
|
}),
|
||||||
|
|
||||||
|
getEpg: (
|
||||||
|
channelId: string,
|
||||||
|
token: string,
|
||||||
|
from?: string,
|
||||||
|
until?: string,
|
||||||
|
) => {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (from) params.set("from", from);
|
||||||
|
if (until) params.set("until", until);
|
||||||
|
const qs = params.toString();
|
||||||
|
return request<ScheduledSlotResponse[]>(
|
||||||
|
`/channels/${channelId}/epg${qs ? `?${qs}` : ""}`,
|
||||||
|
{ token },
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
118
k-tv-frontend/lib/types.ts
Normal file
118
k-tv-frontend/lib/types.ts
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
// API response and request types matching the backend DTOs
|
||||||
|
|
||||||
|
export type ContentType = "movie" | "episode" | "short";
|
||||||
|
|
||||||
|
export type FillStrategy = "best_fit" | "sequential" | "random";
|
||||||
|
|
||||||
|
export interface MediaFilter {
|
||||||
|
content_type?: ContentType | null;
|
||||||
|
genres: string[];
|
||||||
|
decade?: number | null;
|
||||||
|
tags: string[];
|
||||||
|
min_duration_secs?: number | null;
|
||||||
|
max_duration_secs?: number | null;
|
||||||
|
collections: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RecyclePolicy {
|
||||||
|
cooldown_days?: number | null;
|
||||||
|
cooldown_generations?: number | null;
|
||||||
|
min_available_ratio: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type BlockContent =
|
||||||
|
| { type: "algorithmic"; filter: MediaFilter; strategy: FillStrategy }
|
||||||
|
| { type: "manual"; items: string[] };
|
||||||
|
|
||||||
|
export interface ProgrammingBlock {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
/** "HH:MM:SS" */
|
||||||
|
start_time: string;
|
||||||
|
duration_mins: number;
|
||||||
|
content: BlockContent;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ScheduleConfig {
|
||||||
|
blocks: ProgrammingBlock[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auth
|
||||||
|
|
||||||
|
export interface TokenResponse {
|
||||||
|
access_token: string;
|
||||||
|
token_type: string;
|
||||||
|
expires_in: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UserResponse {
|
||||||
|
id: string;
|
||||||
|
email: string;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Channels
|
||||||
|
|
||||||
|
export interface ChannelResponse {
|
||||||
|
id: string;
|
||||||
|
owner_id: string;
|
||||||
|
name: string;
|
||||||
|
description?: string | null;
|
||||||
|
timezone: string;
|
||||||
|
schedule_config: ScheduleConfig;
|
||||||
|
recycle_policy: RecyclePolicy;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateChannelRequest {
|
||||||
|
name: string;
|
||||||
|
timezone: string;
|
||||||
|
description?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateChannelRequest {
|
||||||
|
name?: string;
|
||||||
|
description?: string;
|
||||||
|
timezone?: string;
|
||||||
|
schedule_config?: ScheduleConfig;
|
||||||
|
recycle_policy?: RecyclePolicy;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Media & Schedule
|
||||||
|
|
||||||
|
export interface MediaItemResponse {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
content_type: ContentType;
|
||||||
|
duration_secs: number;
|
||||||
|
description?: string | null;
|
||||||
|
genres: string[];
|
||||||
|
tags: string[];
|
||||||
|
year?: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ScheduledSlotResponse {
|
||||||
|
id: string;
|
||||||
|
block_id: string;
|
||||||
|
item: MediaItemResponse;
|
||||||
|
/** RFC3339 */
|
||||||
|
start_at: string;
|
||||||
|
/** RFC3339 */
|
||||||
|
end_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ScheduleResponse {
|
||||||
|
id: string;
|
||||||
|
channel_id: string;
|
||||||
|
generation: number;
|
||||||
|
generated_at: string;
|
||||||
|
valid_from: string;
|
||||||
|
valid_until: string;
|
||||||
|
slots: ScheduledSlotResponse[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CurrentBroadcastResponse {
|
||||||
|
slot: ScheduledSlotResponse;
|
||||||
|
offset_secs: number;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user