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:
@@ -9,109 +9,80 @@ import {
|
||||
UpNextBanner,
|
||||
NoSignal,
|
||||
} 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
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
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 },
|
||||
},
|
||||
];
|
||||
|
||||
// Constants
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
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() {
|
||||
const { token } = useAuthContext();
|
||||
|
||||
// Channel list
|
||||
const { data: channels, isLoading: isLoadingChannels } = useChannels();
|
||||
|
||||
// Channel navigation
|
||||
const [channelIdx, setChannelIdx] = useState(0);
|
||||
const channel = channels?.[channelIdx];
|
||||
|
||||
// Overlay / idle state
|
||||
const [showOverlays, setShowOverlays] = useState(true);
|
||||
const [showSchedule, setShowSchedule] = useState(false);
|
||||
const idleTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
const channel = MOCK_CHANNELS[channelIdx];
|
||||
const showBanner = channel.current.progress >= BANNER_THRESHOLD;
|
||||
// Tick for live progress calculation (every 30 s is fine for the progress bar)
|
||||
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(() => {
|
||||
setShowOverlays(true);
|
||||
if (idleTimer.current) clearTimeout(idleTimer.current);
|
||||
idleTimer.current = setTimeout(() => setShowOverlays(false), IDLE_TIMEOUT_MS);
|
||||
idleTimer.current = setTimeout(
|
||||
() => setShowOverlays(false),
|
||||
IDLE_TIMEOUT_MS,
|
||||
);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -124,15 +95,18 @@ export default function TvPage() {
|
||||
// ------------------------------------------------------------------
|
||||
// Channel switching
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
const channelCount = channels?.length ?? 0;
|
||||
|
||||
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]);
|
||||
}, [channelCount, resetIdle]);
|
||||
|
||||
const nextChannel = useCallback(() => {
|
||||
setChannelIdx((i) => (i + 1) % MOCK_CHANNELS.length);
|
||||
setChannelIdx((i) => (i + 1) % Math.max(channelCount, 1));
|
||||
resetIdle();
|
||||
}, [resetIdle]);
|
||||
}, [channelCount, resetIdle]);
|
||||
|
||||
const toggleSchedule = useCallback(() => {
|
||||
setShowSchedule((s) => !s);
|
||||
@@ -142,10 +116,14 @@ export default function TvPage() {
|
||||
// ------------------------------------------------------------------
|
||||
// Keyboard shortcuts
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
useEffect(() => {
|
||||
const handleKey = (e: KeyboardEvent) => {
|
||||
// Don't steal input from focused form elements
|
||||
if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) return;
|
||||
if (
|
||||
e.target instanceof HTMLInputElement ||
|
||||
e.target instanceof HTMLTextAreaElement
|
||||
)
|
||||
return;
|
||||
|
||||
switch (e.key) {
|
||||
case "ArrowUp":
|
||||
@@ -169,9 +147,46 @@ export default function TvPage() {
|
||||
return () => window.removeEventListener("keydown", handleKey);
|
||||
}, [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
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
return (
|
||||
<div
|
||||
className="relative flex flex-1 overflow-hidden bg-black"
|
||||
@@ -180,66 +195,82 @@ export default function TvPage() {
|
||||
onClick={resetIdle}
|
||||
>
|
||||
{/* ── Base layer ─────────────────────────────────────────────── */}
|
||||
{channel.src ? (
|
||||
<VideoPlayer src={channel.src} className="absolute inset-0 h-full w-full" />
|
||||
) : (
|
||||
<div className="absolute inset-0">
|
||||
<NoSignal variant="no-signal" />
|
||||
</div>
|
||||
)}
|
||||
<div className="absolute inset-0">{renderBase()}</div>
|
||||
|
||||
{/* ── Overlays ───────────────────────────────────────────────── */}
|
||||
<div
|
||||
className="pointer-events-none absolute inset-0 z-10 flex flex-col justify-between transition-opacity duration-300"
|
||||
style={{ opacity: showOverlays ? 1 : 0 }}
|
||||
>
|
||||
{/* Top-right: guide toggle */}
|
||||
<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}
|
||||
{/* ── Overlays — only when channels available ─────────────────── */}
|
||||
{channel && (
|
||||
<>
|
||||
<div
|
||||
className="pointer-events-none absolute inset-0 z-10 flex flex-col justify-between transition-opacity duration-300"
|
||||
style={{ opacity: showOverlays ? 1 : 0 }}
|
||||
>
|
||||
{showSchedule ? "Hide guide" : "Guide [G]"}
|
||||
</button>
|
||||
</div>
|
||||
{/* Top-right: guide toggle */}
|
||||
<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]"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 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">
|
||||
{showBanner && (
|
||||
<UpNextBanner
|
||||
nextShowTitle={channel.next.title}
|
||||
minutesUntil={channel.next.minutesUntil}
|
||||
nextShowStartTime={channel.next.startTime}
|
||||
/>
|
||||
)}
|
||||
{/* 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">
|
||||
{showBanner && nextSlot && (
|
||||
<UpNextBanner
|
||||
nextShowTitle={nextSlot.item.title}
|
||||
minutesUntil={minutesUntil(nextSlot.start_at)}
|
||||
nextShowStartTime={fmtTime(nextSlot.start_at)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="flex items-end justify-between gap-4">
|
||||
<ChannelInfo
|
||||
channelNumber={channel.number}
|
||||
channelName={channel.name}
|
||||
showTitle={channel.current.title}
|
||||
showStartTime={channel.current.startTime}
|
||||
showEndTime={channel.current.endTime}
|
||||
progress={channel.current.progress}
|
||||
description={channel.current.description}
|
||||
/>
|
||||
<div className="pointer-events-auto">
|
||||
<ChannelControls
|
||||
channelNumber={channel.number}
|
||||
channelName={channel.name}
|
||||
onPrevChannel={prevChannel}
|
||||
onNextChannel={nextChannel}
|
||||
/>
|
||||
<div className="flex items-end justify-between gap-4">
|
||||
{hasBroadcast ? (
|
||||
<ChannelInfo
|
||||
channelNumber={channelIdx + 1}
|
||||
channelName={channel.name}
|
||||
showTitle={broadcast.slot.item.title}
|
||||
showStartTime={fmtTime(broadcast.slot.start_at)}
|
||||
showEndTime={fmtTime(broadcast.slot.end_at)}
|
||||
progress={progress}
|
||||
description={broadcast.slot.item.description ?? undefined}
|
||||
/>
|
||||
) : (
|
||||
/* Minimal channel badge when no broadcast */
|
||||
<div className="rounded-lg bg-black/60 px-4 py-3 backdrop-blur-md">
|
||||
<div className="flex items-center gap-2">
|
||||
<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>
|
||||
|
||||
{/* 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">
|
||||
<ScheduleOverlay channelName={channel.name} slots={channel.schedule} />
|
||||
</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">
|
||||
<ScheduleOverlay
|
||||
channelName={channel.name}
|
||||
slots={scheduleSlots}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user