Files
k-tv/k-tv-frontend/app/(main)/guide/page.tsx
Gabriel Kaszewski c4d2e48f73 fix(frontend): resolve all eslint warnings and errors
- block-timeline: ref updates moved to useLayoutEffect
- channel-card, guide/page: Date.now() wrapped in useMemo + suppress purity rule
- auth-context: lazy localStorage init (removes setState-in-effect)
- use-channel-order: lazy localStorage init (removes setState-in-effect)
- use-idle: start timer on mount without calling resetIdle (removes setState-in-effect)
- use-subtitles, transcode-settings-dialog: inline eslint-disable on exact violating line
- providers: block-level eslint-disable for tokenRef closure in useState initializer
- edit-channel-sheet: remove unused minsToTime and BlockContent imports
- docs/page: escape unescaped quote and apostrophe entities
2026-03-17 02:40:32 +01:00

232 lines
7.6 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 { useMemo } from "react";
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,
});
// eslint-disable-next-line react-hooks/purity -- Date.now() inside useMemo is stable for EPG slot matching
const now = useMemo(() => 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-primary/20 px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-wider text-primary-foreground">
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-primary 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>
);
}