Files
k-tv/k-tv-frontend/app/(main)/guide/page.tsx

199 lines
7.2 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import Link from "next/link";
import { useQuery } from "@tanstack/react-query";
import { Tv } from "lucide-react";
import { api, ApiRequestError } from "@/lib/api";
import { useChannels } from "@/hooks/use-channels";
import { useAuthContext } from "@/context/auth-context";
import type { ChannelResponse, ScheduledSlotResponse } from "@/lib/types";
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function fmtTime(iso: string) {
return new Date(iso).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
}
function fmtDuration(secs: number) {
const h = Math.floor(secs / 3600);
const m = Math.floor((secs % 3600) / 60);
return h > 0 ? `${h}h ${m}m` : `${m}m`;
}
function slotLabel(slot: ScheduledSlotResponse) {
const { item } = slot;
if (item.content_type === "episode" && item.series_name) {
const ep = [
item.season_number != null ? `S${item.season_number}` : "",
item.episode_number != null ? `E${String(item.episode_number).padStart(2, "0")}` : "",
]
.filter(Boolean)
.join("");
return ep
? `${item.series_name} ${ep} ${item.title}`
: `${item.series_name} ${item.title}`;
}
return item.year ? `${item.title} (${item.year})` : item.title;
}
// ---------------------------------------------------------------------------
// ChannelRow — fetches its own EPG slice and renders current + upcoming
// ---------------------------------------------------------------------------
function ChannelRow({ channel }: { channel: ChannelResponse }) {
const { token, isLoaded } = useAuthContext();
const { data: slots, isError, error, isPending, isFetching } = useQuery({
queryKey: ["guide-epg", channel.id, token],
queryFn: () => {
const now = new Date();
const from = now.toISOString();
const until = new Date(now.getTime() + 4 * 60 * 60 * 1000).toISOString();
return api.schedule.getEpg(channel.id, token ?? "", from, until);
},
enabled: isLoaded,
refetchInterval: 30_000,
retry: false,
});
const now = Date.now();
const current = slots?.find(
(s) =>
new Date(s.start_at).getTime() <= now && now < new Date(s.end_at).getTime(),
);
const upcoming = slots?.filter((s) => new Date(s.start_at).getTime() > now).slice(0, 3) ?? [];
const progress = current
? (now - new Date(current.start_at).getTime()) /
(new Date(current.end_at).getTime() - new Date(current.start_at).getTime())
: 0;
const remaining = current
? Math.ceil((new Date(current.end_at).getTime() - now) / 60_000)
: 0;
return (
<div className="overflow-hidden rounded-xl border border-zinc-800 bg-zinc-900">
{/* Channel header */}
<div className="flex items-center justify-between border-b border-zinc-800 px-4 py-3">
<div className="flex min-w-0 items-center gap-2">
<Tv className="size-3.5 shrink-0 text-zinc-600" />
<span className="truncate text-sm font-semibold text-zinc-100">{channel.name}</span>
{channel.description && (
<span className="hidden truncate text-xs text-zinc-600 sm:block">
{channel.description}
</span>
)}
</div>
<Link
href={`/tv?channel=${channel.id}`}
className="ml-4 shrink-0 rounded-md px-2.5 py-1 text-xs text-zinc-400 transition-colors hover:bg-zinc-800 hover:text-zinc-100"
>
Watch
</Link>
</div>
<div className="space-y-3 px-4 py-3">
{/* Currently airing */}
{current ? (
<div className="space-y-1.5">
<div className="flex items-baseline justify-between gap-4">
<div className="flex min-w-0 items-baseline gap-2">
<span className="shrink-0 rounded bg-red-600/20 px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-wider text-red-400">
Now
</span>
<span className="truncate text-sm font-medium text-zinc-100">
{slotLabel(current)}
</span>
</div>
<span className="shrink-0 text-xs text-zinc-500">{remaining}m left</span>
</div>
<div className="h-1 w-full overflow-hidden rounded-full bg-zinc-800">
<div
className="h-full rounded-full bg-red-600 transition-all duration-1000"
style={{ width: `${Math.round(progress * 100)}%` }}
/>
</div>
</div>
) : isPending && isFetching ? (
<p className="text-xs italic text-zinc-600">Loading</p>
) : isError && error instanceof ApiRequestError && error.status === 401 ? (
<p className="text-xs italic text-zinc-600">Sign in to view this channel</p>
) : (
<p className="text-xs italic text-zinc-600">
{isError || !slots?.length
? "No signal — schedule may not be generated yet"
: "Nothing airing right now"}
</p>
)}
{/* Upcoming */}
{upcoming.length > 0 && (
<ul className="space-y-1.5 border-t border-zinc-800 pt-2.5">
{upcoming.map((slot) => (
<li key={slot.id} className="flex items-baseline justify-between gap-4">
<div className="flex min-w-0 items-baseline gap-2">
<span className="shrink-0 font-mono text-[11px] text-zinc-500">
{fmtTime(slot.start_at)}
</span>
<span className="truncate text-xs text-zinc-400">{slotLabel(slot)}</span>
</div>
<span className="shrink-0 font-mono text-[11px] text-zinc-600">
{fmtDuration(slot.item.duration_secs)}
</span>
</li>
))}
</ul>
)}
</div>
</div>
);
}
// ---------------------------------------------------------------------------
// Page
// ---------------------------------------------------------------------------
export default function GuidePage() {
const { data: channels, isLoading } = useChannels();
const now = new Date();
const timeLabel = now.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
const dateLabel = now.toLocaleDateString([], {
weekday: "long",
month: "long",
day: "numeric",
});
return (
<div className="mx-auto w-full max-w-4xl space-y-6 px-6 py-8">
<div className="flex items-baseline gap-3">
<h1 className="text-xl font-semibold text-zinc-100">Channel Guide</h1>
<span className="text-sm text-zinc-500">
{dateLabel} · {timeLabel}
</span>
</div>
{isLoading && <p className="text-sm text-zinc-600">Loading channels</p>}
{!isLoading && channels?.length === 0 && (
<p className="text-sm text-zinc-600">
No channels yet.{" "}
<Link href="/dashboard" className="text-zinc-400 underline">
Create one in the dashboard.
</Link>
</p>
)}
<div className="space-y-3">
{channels?.map((channel) => (
<ChannelRow key={channel.id} channel={channel} />
))}
</div>
</div>
);
}