feat: implement configuration management and enhance user registration flow
This commit is contained in:
@@ -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"}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -758,7 +758,10 @@ Output only valid JSON matching this structure:
|
||||
rows={[
|
||||
["Arrow Up / Page Up", "Next channel"],
|
||||
["Arrow Down / Page Down", "Previous channel"],
|
||||
["0–9", "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"],
|
||||
]}
|
||||
/>
|
||||
|
||||
|
||||
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user