feat: implement configuration management and enhance user registration flow

This commit is contained in:
2026-03-11 22:26:16 +01:00
parent 62549faffa
commit 0f1b9c11fe
12 changed files with 370 additions and 95 deletions

View File

@@ -3,11 +3,13 @@
import Link from "next/link";
import { useState } from "react";
import { useLogin } from "@/hooks/use-auth";
import { useConfig } from "@/hooks/use-channels";
export default function LoginPage() {
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const { mutate: login, isPending, error } = useLogin();
const { data: config } = useConfig();
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
@@ -63,12 +65,14 @@ export default function LoginPage() {
</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>
{config?.allow_registration !== false && (
<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>
);
}

View File

@@ -3,11 +3,27 @@
import Link from "next/link";
import { useState } from "react";
import { useRegister } from "@/hooks/use-auth";
import { useConfig } from "@/hooks/use-channels";
export default function RegisterPage() {
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const { mutate: register, isPending, error } = useRegister();
const { data: config } = useConfig();
if (config && !config.allow_registration) {
return (
<div className="w-full max-w-sm space-y-4 text-center">
<h1 className="text-xl font-semibold text-zinc-100">Registration disabled</h1>
<p className="text-sm text-zinc-500">
The administrator has disabled new account registration.
</p>
<Link href="/login" className="inline-block text-sm text-zinc-300 hover:text-white">
Sign in instead
</Link>
</div>
);
}
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();

View File

@@ -1,28 +1,64 @@
"use client";
import Link from "next/link";
import { Pencil, Trash2, RefreshCw, Tv2, CalendarDays, Download } from "lucide-react";
import { Pencil, Trash2, RefreshCw, Tv2, CalendarDays, Download, ChevronUp, ChevronDown } from "lucide-react";
import { Button } from "@/components/ui/button";
import { useActiveSchedule } from "@/hooks/use-channels";
import type { ChannelResponse } from "@/lib/types";
interface ChannelCardProps {
channel: ChannelResponse;
isGenerating: boolean;
isFirst: boolean;
isLast: boolean;
onEdit: () => void;
onDelete: () => void;
onGenerateSchedule: () => void;
onViewSchedule: () => void;
onExport: () => void;
onMoveUp: () => void;
onMoveDown: () => void;
}
function useScheduleStatus(channelId: string) {
const { data: schedule } = useActiveSchedule(channelId);
if (!schedule) return { status: "none" as const, label: null };
const expiresAt = new Date(schedule.valid_until);
const hoursLeft = (expiresAt.getTime() - Date.now()) / (1000 * 60 * 60);
if (hoursLeft < 0) {
return { status: "expired" as const, label: "Schedule expired" };
}
if (hoursLeft < 6) {
const h = Math.ceil(hoursLeft);
return { status: "expiring" as const, label: `Expires in ${h}h` };
}
const fmt = expiresAt.toLocaleDateString(undefined, { weekday: "short", hour: "2-digit", minute: "2-digit", hour12: false });
return { status: "ok" as const, label: `Until ${fmt}` };
}
export function ChannelCard({
channel,
isGenerating,
isFirst,
isLast,
onEdit,
onDelete,
onGenerateSchedule,
onViewSchedule,
onExport,
onMoveUp,
onMoveDown,
}: ChannelCardProps) {
const blockCount = channel.schedule_config.blocks.length;
const { status, label } = useScheduleStatus(channel.id);
const scheduleColor =
status === "expired" ? "text-red-400" :
status === "expiring" ? "text-amber-400" :
status === "ok" ? "text-zinc-500" :
"text-zinc-600";
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">
@@ -40,6 +76,26 @@ export function ChannelCard({
</div>
<div className="flex shrink-0 items-center gap-1">
{/* Order controls */}
<div className="flex flex-col">
<button
onClick={onMoveUp}
disabled={isFirst}
title="Move up"
className="rounded p-0.5 text-zinc-600 transition-colors hover:text-zinc-300 disabled:opacity-20 disabled:cursor-not-allowed"
>
<ChevronUp className="size-3.5" />
</button>
<button
onClick={onMoveDown}
disabled={isLast}
title="Move down"
className="rounded p-0.5 text-zinc-600 transition-colors hover:text-zinc-300 disabled:opacity-20 disabled:cursor-not-allowed"
>
<ChevronDown className="size-3.5" />
</button>
</div>
<Button
variant="ghost"
size="icon-sm"
@@ -71,12 +127,13 @@ export function ChannelCard({
{/* 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 className="text-zinc-400">{channel.timezone}</span>
<span>
{blockCount} {blockCount === 1 ? "block" : "blocks"}
</span>
{label && (
<span className={scheduleColor}>{label}</span>
)}
</div>
{/* Actions */}
@@ -85,7 +142,7 @@ export function ChannelCard({
size="sm"
onClick={onGenerateSchedule}
disabled={isGenerating}
className="flex-1"
className={`flex-1 ${status === "expired" ? "border border-red-800/50 bg-red-950/30 text-red-300 hover:bg-red-900/40" : ""}`}
>
<RefreshCw className={`size-3.5 ${isGenerating ? "animate-spin" : ""}`} />
{isGenerating ? "Generating…" : "Generate schedule"}

View File

@@ -1,5 +1,6 @@
"use client";
import { useEffect, useState } from "react";
import { Sheet, SheetContent, SheetHeader, SheetTitle } from "@/components/ui/sheet";
import { useActiveSchedule } from "@/hooks/use-channels";
import type { ChannelResponse, ScheduledSlotResponse } from "@/lib/types";
@@ -21,11 +22,13 @@ interface DayRowProps {
dayStart: Date;
slots: ScheduledSlotResponse[];
colorMap: Map<string, string>;
now: Date;
}
function DayRow({ label, dayStart, slots, colorMap }: DayRowProps) {
function DayRow({ label, dayStart, slots, colorMap, now }: DayRowProps) {
const DAY_MS = 24 * 60 * 60 * 1000;
const dayEnd = new Date(dayStart.getTime() + DAY_MS);
const nowPct = ((now.getTime() - dayStart.getTime()) / DAY_MS) * 100;
// Only include slots that overlap this day
const daySlots = slots.filter((s) => {
@@ -46,6 +49,15 @@ function DayRow({ label, dayStart, slots, colorMap }: DayRowProps) {
style={{ left: `${(i / 24) * 100}%` }}
/>
))}
{/* Current time marker */}
{nowPct >= 0 && nowPct <= 100 && (
<div
className="absolute inset-y-0 z-10 w-0.5 bg-red-500"
style={{ left: `${nowPct}%` }}
>
<div className="absolute -top-0.5 left-1/2 h-1.5 w-1.5 -translate-x-1/2 rounded-full bg-red-500" />
</div>
)}
{daySlots.map((slot) => {
const slotStart = new Date(slot.start_at);
const slotEnd = new Date(slot.end_at);
@@ -102,6 +114,13 @@ interface ScheduleSheetProps {
export function ScheduleSheet({ channel, open, onOpenChange }: ScheduleSheetProps) {
const { data: schedule, isLoading, error } = useActiveSchedule(channel?.id ?? "");
// Live clock for the current-time marker — updates every minute
const [now, setNow] = useState(() => new Date());
useEffect(() => {
const id = setInterval(() => setNow(new Date()), 60_000);
return () => clearInterval(id);
}, []);
const colorMap = schedule ? makeColorMap(schedule.slots) : new Map();
// Build day rows from valid_from to valid_until
@@ -172,6 +191,7 @@ export function ScheduleSheet({ channel, open, onOpenChange }: ScheduleSheetProp
dayStart={dayStart}
slots={schedule.slots}
colorMap={colorMap}
now={now}
/>
))}
</div>

View File

@@ -1,7 +1,7 @@
"use client";
import { useState } from "react";
import { Plus, Upload } from "lucide-react";
import { useState, useEffect } from "react";
import { Plus, Upload, RefreshCw } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
useChannels,
@@ -12,6 +12,7 @@ import {
} from "@/hooks/use-channels";
import { useAuthContext } from "@/context/auth-context";
import { api } from "@/lib/api";
import { toast } from "sonner";
import { useQueryClient } from "@tanstack/react-query";
import { ChannelCard } from "./components/channel-card";
import { CreateChannelDialog } from "./components/create-channel-dialog";
@@ -31,6 +32,69 @@ export default function DashboardPage() {
const deleteChannel = useDeleteChannel();
const generateSchedule = useGenerateSchedule();
// Channel ordering — persisted to localStorage
const [channelOrder, setChannelOrder] = useState<string[]>([]);
useEffect(() => {
try {
const stored = localStorage.getItem("k-tv-channel-order");
if (stored) setChannelOrder(JSON.parse(stored));
} catch {}
}, []);
const saveOrder = (order: string[]) => {
setChannelOrder(order);
try { localStorage.setItem("k-tv-channel-order", JSON.stringify(order)); } catch {}
};
// Sort channels by stored order; new channels appear at the end
const sortedChannels = channels
? [...channels].sort((a, b) => {
const ai = channelOrder.indexOf(a.id);
const bi = channelOrder.indexOf(b.id);
if (ai === -1 && bi === -1) return 0;
if (ai === -1) return 1;
if (bi === -1) return -1;
return ai - bi;
})
: [];
const handleMoveUp = (channelId: string) => {
const ids = sortedChannels.map((c) => c.id);
const idx = ids.indexOf(channelId);
if (idx <= 0) return;
const next = [...ids];
[next[idx - 1], next[idx]] = [next[idx], next[idx - 1]];
saveOrder(next);
};
const handleMoveDown = (channelId: string) => {
const ids = sortedChannels.map((c) => c.id);
const idx = ids.indexOf(channelId);
if (idx === -1 || idx >= ids.length - 1) return;
const next = [...ids];
[next[idx], next[idx + 1]] = [next[idx + 1], next[idx]];
saveOrder(next);
};
// Regenerate all channels
const [isRegeneratingAll, setIsRegeneratingAll] = useState(false);
const handleRegenerateAll = async () => {
if (!token || !channels || channels.length === 0) return;
setIsRegeneratingAll(true);
let failed = 0;
for (const ch of channels) {
try {
await api.schedule.generate(ch.id, token);
queryClient.invalidateQueries({ queryKey: ["schedule", ch.id] });
} catch {
failed++;
}
}
setIsRegeneratingAll(false);
if (failed === 0) toast.success(`All ${channels.length} schedules regenerated`);
else toast.error(`${failed} schedule(s) failed to generate`);
};
const [createOpen, setCreateOpen] = useState(false);
const [importOpen, setImportOpen] = useState(false);
const [importPending, setImportPending] = useState(false);
@@ -124,6 +188,18 @@ export default function DashboardPage() {
</p>
</div>
<div className="flex gap-2">
{channels && channels.length > 0 && (
<Button
variant="outline"
onClick={handleRegenerateAll}
disabled={isRegeneratingAll}
title="Regenerate schedules for all channels"
className="border-zinc-700 text-zinc-400 hover:text-zinc-100"
>
<RefreshCw className={`size-4 ${isRegeneratingAll ? "animate-spin" : ""}`} />
Regenerate all
</Button>
)}
<Button variant="outline" onClick={() => setImportOpen(true)} className="border-zinc-700 text-zinc-300 hover:text-zinc-100">
<Upload className="size-4" />
Import
@@ -148,7 +224,7 @@ export default function DashboardPage() {
</div>
)}
{channels && channels.length === 0 && (
{!isLoading && 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)}>
@@ -158,9 +234,9 @@ export default function DashboardPage() {
</div>
)}
{channels && channels.length > 0 && (
{sortedChannels.length > 0 && (
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{channels.map((channel) => (
{sortedChannels.map((channel, idx) => (
<ChannelCard
key={channel.id}
channel={channel}
@@ -168,11 +244,15 @@ export default function DashboardPage() {
generateSchedule.isPending &&
generateSchedule.variables === channel.id
}
isFirst={idx === 0}
isLast={idx === sortedChannels.length - 1}
onEdit={() => setEditChannel(channel)}
onDelete={() => setDeleteTarget(channel)}
onGenerateSchedule={() => generateSchedule.mutate(channel.id)}
onViewSchedule={() => setScheduleChannel(channel)}
onExport={() => handleExport(channel)}
onMoveUp={() => handleMoveUp(channel.id)}
onMoveDown={() => handleMoveDown(channel.id)}
/>
))}
</div>

View File

@@ -758,7 +758,10 @@ Output only valid JSON matching this structure:
rows={[
["Arrow Up / Page Up", "Next channel"],
["Arrow Down / Page Down", "Previous channel"],
["09", "Type a channel number and jump to it after 1.5 s (e.g. press 1 then 4 → channel 14)"],
["G", "Toggle the program guide"],
["M", "Mute / unmute"],
["F", "Toggle fullscreen"],
]}
/>

View File

@@ -11,6 +11,7 @@ import {
NoSignal,
} from "./components";
import type { SubtitleTrack } from "./components/video-player";
import { Maximize2, Minimize2, Volume2, VolumeX } from "lucide-react";
import { useAuthContext } from "@/context/auth-context";
import { useChannels, useCurrentBroadcast, useEpg } from "@/hooks/use-channels";
import {
@@ -59,6 +60,36 @@ export default function TvPage() {
const [subtitleTracks, setSubtitleTracks] = useState<SubtitleTrack[]>([]);
const [activeSubtitleTrack, setActiveSubtitleTrack] = useState(-1);
const [showSubtitlePicker, setShowSubtitlePicker] = useState(false);
// Fullscreen
const [isFullscreen, setIsFullscreen] = useState(false);
useEffect(() => {
const handler = () => setIsFullscreen(!!document.fullscreenElement);
document.addEventListener("fullscreenchange", handler);
return () => document.removeEventListener("fullscreenchange", handler);
}, []);
const toggleFullscreen = useCallback(() => {
if (!document.fullscreenElement) {
document.documentElement.requestFullscreen().catch(() => {});
} else {
document.exitFullscreen().catch(() => {});
}
}, []);
// Volume / mute
const [isMuted, setIsMuted] = useState(false);
useEffect(() => {
if (videoRef.current) videoRef.current.muted = isMuted;
}, [isMuted]);
const toggleMute = useCallback(() => setIsMuted((m) => !m), []);
// Channel jump by number (e.g. press "1","4" → jump to ch 14 after 1.5 s)
const [channelInput, setChannelInput] = useState("");
const channelInputTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
// Touch-swipe state
const touchStartY = useRef<number | null>(null);
const queryClient = useQueryClient();
// Tick for live progress calculation (every 30 s is fine for the progress bar)
@@ -169,12 +200,59 @@ export default function TvPage() {
case "G":
toggleSchedule();
break;
case "f":
case "F":
toggleFullscreen();
break;
case "m":
case "M":
toggleMute();
break;
default: {
if (e.key >= "0" && e.key <= "9") {
setChannelInput((prev) => {
const next = prev + e.key;
if (channelInputTimer.current) clearTimeout(channelInputTimer.current);
channelInputTimer.current = setTimeout(() => {
const num = parseInt(next, 10);
if (num >= 1 && num <= Math.max(channelCount, 1)) {
setChannelIdx(num - 1);
resetIdle();
}
setChannelInput("");
}, 1500);
return next;
});
}
}
}
};
window.addEventListener("keydown", handleKey);
return () => window.removeEventListener("keydown", handleKey);
}, [nextChannel, prevChannel, toggleSchedule]);
return () => {
window.removeEventListener("keydown", handleKey);
if (channelInputTimer.current) clearTimeout(channelInputTimer.current);
};
}, [nextChannel, prevChannel, toggleSchedule, toggleFullscreen, toggleMute, channelCount, resetIdle]);
// ------------------------------------------------------------------
// Touch swipe (swipe up = next channel, swipe down = prev channel)
// ------------------------------------------------------------------
const handleTouchStart = useCallback((e: React.TouchEvent) => {
touchStartY.current = e.touches[0].clientY;
resetIdle();
}, [resetIdle]);
const handleTouchEnd = useCallback((e: React.TouchEvent) => {
if (touchStartY.current === null) return;
const dy = touchStartY.current - e.changedTouches[0].clientY;
touchStartY.current = null;
if (Math.abs(dy) > 60) {
if (dy > 0) nextChannel();
else prevChannel();
}
}, [nextChannel, prevChannel]);
// ------------------------------------------------------------------
// Stream error recovery
@@ -258,6 +336,8 @@ export default function TvPage() {
style={{ cursor: showOverlays ? "default" : "none" }}
onMouseMove={resetIdle}
onClick={resetIdle}
onTouchStart={handleTouchStart}
onTouchEnd={handleTouchEnd}
>
{/* ── Base layer ─────────────────────────────────────────────── */}
<div className="absolute inset-0">{renderBase()}</div>
@@ -315,6 +395,26 @@ export default function TvPage() {
</div>
)}
<button
className="pointer-events-auto rounded-md bg-black/50 p-1.5 text-zinc-400 backdrop-blur transition-colors hover:bg-black/70 hover:text-white"
onClick={toggleMute}
title={isMuted ? "Unmute [M]" : "Mute [M]"}
>
{isMuted
? <VolumeX className="h-4 w-4" />
: <Volume2 className="h-4 w-4" />}
</button>
<button
className="pointer-events-auto rounded-md bg-black/50 p-1.5 text-zinc-400 backdrop-blur transition-colors hover:bg-black/70 hover:text-white"
onClick={toggleFullscreen}
title={isFullscreen ? "Exit fullscreen [F]" : "Fullscreen [F]"}
>
{isFullscreen
? <Minimize2 className="h-4 w-4" />
: <Maximize2 className="h-4 w-4" />}
</button>
<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}
@@ -369,6 +469,14 @@ export default function TvPage() {
</div>
</div>
{/* Channel number input overlay */}
{channelInput && (
<div className="pointer-events-none absolute left-1/2 top-1/2 z-30 -translate-x-1/2 -translate-y-1/2 rounded-xl bg-black/80 px-8 py-5 text-center backdrop-blur">
<p className="mb-1 text-[10px] uppercase tracking-widest text-zinc-500">Channel</p>
<p className="font-mono text-5xl font-bold text-white">{channelInput}</p>
</div>
)}
{/* Schedule overlay — outside the fading div so it has its own visibility */}
{showOverlays && showSchedule && (
<div className="absolute bottom-4 right-4 top-14 z-20 w-80">

View File

@@ -4,6 +4,7 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
import { useState } from "react";
import { AuthProvider } from "@/context/auth-context";
import { Toaster } from "@/components/ui/sonner";
export function Providers({ children }: { children: React.ReactNode }) {
const [queryClient] = useState(
@@ -21,6 +22,7 @@ export function Providers({ children }: { children: React.ReactNode }) {
<AuthProvider>
<QueryClientProvider client={queryClient}>
{children}
<Toaster position="bottom-right" richColors />
<ReactQueryDevtools initialIsOpen={false} />
</QueryClientProvider>
</AuthProvider>