feat: initialize k-tv-frontend with Next.js and Tailwind CSS

- Added package.json with dependencies and scripts for development, build, and linting.
- Created postcss.config.mjs for Tailwind CSS integration.
- Added SVG assets for UI components including file, globe, next, vercel, and window icons.
- Configured TypeScript with tsconfig.json for strict type checking and module resolution.
This commit is contained in:
2026-03-11 19:13:21 +01:00
commit 01108aa23e
130 changed files with 29949 additions and 0 deletions

View File

@@ -0,0 +1,46 @@
import { ChevronUp, ChevronDown } from "lucide-react";
interface ChannelControlsProps {
channelNumber: number;
channelName: string;
onPrevChannel: () => void;
onNextChannel: () => void;
}
export function ChannelControls({
channelNumber,
channelName,
onPrevChannel,
onNextChannel,
}: ChannelControlsProps) {
return (
<div className="flex flex-col items-center gap-1 rounded-lg bg-black/60 p-3 backdrop-blur-md">
<button
onClick={onNextChannel}
aria-label="Next channel"
className="flex h-10 w-10 items-center justify-center rounded-md text-zinc-400 transition-colors hover:bg-zinc-700 hover:text-white active:scale-95"
>
<ChevronUp className="h-5 w-5" />
</button>
<div className="flex flex-col items-center px-2 py-1 text-center">
<span className="font-mono text-2xl font-bold tabular-nums text-white leading-none">
{channelNumber}
</span>
<span className="mt-0.5 max-w-20 truncate text-[10px] text-zinc-400">
{channelName}
</span>
</div>
<button
onClick={onPrevChannel}
aria-label="Previous channel"
className="flex h-10 w-10 items-center justify-center rounded-md text-zinc-400 transition-colors hover:bg-zinc-700 hover:text-white active:scale-95"
>
<ChevronDown className="h-5 w-5" />
</button>
</div>
);
}
export type { ChannelControlsProps };

View File

@@ -0,0 +1,62 @@
interface ChannelInfoProps {
channelNumber: number;
channelName: string;
showTitle: string;
showStartTime: string; // "HH:MM"
showEndTime: string; // "HH:MM"
/** Progress through the current show, 0100 */
progress: number;
description?: string;
}
export function ChannelInfo({
channelNumber,
channelName,
showTitle,
showStartTime,
showEndTime,
progress,
description,
}: ChannelInfoProps) {
const clampedProgress = Math.min(100, Math.max(0, progress));
return (
<div className="flex flex-col gap-2 rounded-lg bg-black/60 p-4 backdrop-blur-md w-80">
{/* Channel badge */}
<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">
{channelNumber}
</span>
<span className="truncate text-sm font-medium text-zinc-300">
{channelName}
</span>
</div>
{/* Show title */}
<p className="text-base font-semibold leading-tight text-white">
{showTitle}
</p>
{/* Description */}
{description && (
<p className="line-clamp-2 text-xs text-zinc-400">{description}</p>
)}
{/* Progress bar */}
<div className="flex flex-col gap-1">
<div className="h-1 w-full overflow-hidden rounded-full bg-zinc-700">
<div
className="h-full rounded-full bg-white transition-all duration-500"
style={{ width: `${clampedProgress}%` }}
/>
</div>
<div className="flex justify-between font-mono text-[10px] text-zinc-500">
<span>{showStartTime}</span>
<span>{showEndTime}</span>
</div>
</div>
</div>
);
}
export type { ChannelInfoProps };

View File

@@ -0,0 +1,17 @@
export { VideoPlayer } from "./video-player";
export type { VideoPlayerProps } from "./video-player";
export { ChannelInfo } from "./channel-info";
export type { ChannelInfoProps } from "./channel-info";
export { ChannelControls } from "./channel-controls";
export type { ChannelControlsProps } from "./channel-controls";
export { ScheduleOverlay } from "./schedule-overlay";
export type { ScheduleOverlayProps, ScheduleSlot } from "./schedule-overlay";
export { UpNextBanner } from "./up-next-banner";
export type { UpNextBannerProps } from "./up-next-banner";
export { NoSignal } from "./no-signal";
export type { NoSignalProps, NoSignalVariant } from "./no-signal";

View File

@@ -0,0 +1,59 @@
import { WifiOff, AlertTriangle, Loader2 } from "lucide-react";
type NoSignalVariant = "no-signal" | "error" | "loading";
interface NoSignalProps {
variant?: NoSignalVariant;
message?: string;
}
const VARIANTS: Record<
NoSignalVariant,
{ icon: React.ReactNode; heading: string; defaultMessage: string }
> = {
"no-signal": {
icon: <WifiOff className="h-10 w-10 text-zinc-600" />,
heading: "No Signal",
defaultMessage: "Nothing is scheduled to play right now.",
},
error: {
icon: <AlertTriangle className="h-10 w-10 text-zinc-600" />,
heading: "Playback Error",
defaultMessage: "Something went wrong. Try switching channels.",
},
loading: {
icon: <Loader2 className="h-10 w-10 animate-spin text-zinc-600" />,
heading: "Loading",
defaultMessage: "Tuning in…",
},
};
export function NoSignal({ variant = "no-signal", message }: NoSignalProps) {
const { icon, heading, defaultMessage } = VARIANTS[variant];
return (
<div className="relative flex h-full w-full flex-col items-center justify-center gap-4 bg-zinc-950 select-none overflow-hidden">
{/* Static noise texture */}
<div
aria-hidden
className="pointer-events-none absolute inset-0 opacity-[0.03]"
style={{
backgroundImage:
"url(\"data:image/svg+xml,%3Csvg viewBox='0 0 200 200' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='noise'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23noise)'/%3E%3C/svg%3E\")",
backgroundSize: "200px 200px",
}}
/>
{icon}
<div className="flex flex-col items-center gap-1 text-center">
<p className="text-sm font-semibold uppercase tracking-widest text-zinc-500">
{heading}
</p>
<p className="max-w-xs text-xs text-zinc-700">{message ?? defaultMessage}</p>
</div>
</div>
);
}
export type { NoSignalProps, NoSignalVariant };

View File

@@ -0,0 +1,64 @@
import { CalendarClock } from "lucide-react";
import { cn } from "@/lib/utils";
export interface ScheduleSlot {
id: string;
title: string;
startTime: string; // "HH:MM"
endTime: string; // "HH:MM"
isCurrent?: boolean;
}
interface ScheduleOverlayProps {
channelName: string;
slots: ScheduleSlot[];
}
export function ScheduleOverlay({ channelName, slots }: ScheduleOverlayProps) {
return (
<div className="flex h-full w-80 flex-col rounded-lg bg-black/70 backdrop-blur-md overflow-hidden">
{/* Header */}
<div className="flex items-center gap-2 border-b border-zinc-700/60 px-4 py-3">
<CalendarClock className="h-4 w-4 text-zinc-400 shrink-0" />
<span className="truncate text-sm font-medium text-zinc-200">{channelName}</span>
</div>
{/* Slots */}
<ul className="flex-1 overflow-y-auto">
{slots.map((slot) => (
<li
key={slot.id}
className={cn(
"flex items-start gap-3 border-b border-zinc-800/60 px-4 py-3 transition-colors last:border-0",
slot.isCurrent && "bg-white/5"
)}
>
{/* Current indicator */}
<span
className={cn(
"mt-0.5 h-2 w-2 shrink-0 rounded-full",
slot.isCurrent ? "bg-white" : "bg-transparent"
)}
/>
<div className="min-w-0 flex-1">
<p
className={cn(
"truncate text-sm font-medium leading-snug",
slot.isCurrent ? "text-white" : "text-zinc-400"
)}
>
{slot.title}
</p>
<p className="mt-0.5 font-mono text-[10px] text-zinc-600">
{slot.startTime} {slot.endTime}
</p>
</div>
</li>
))}
</ul>
</div>
);
}
export type { ScheduleOverlayProps };

View File

@@ -0,0 +1,42 @@
import { ArrowRight } from "lucide-react";
interface UpNextBannerProps {
nextShowTitle: string;
/** Minutes remaining until the next show */
minutesUntil: number;
nextShowStartTime: string; // "HH:MM"
}
export function UpNextBanner({
nextShowTitle,
minutesUntil,
nextShowStartTime,
}: UpNextBannerProps) {
const timeLabel =
minutesUntil <= 1 ? "Starting now" : `In ${minutesUntil} min`;
return (
<div className="flex w-full items-center gap-3 rounded-lg bg-black/70 px-5 py-3 backdrop-blur-md">
{/* Label */}
<div className="flex shrink-0 flex-col">
<span className="text-[10px] font-semibold uppercase tracking-widest text-zinc-500">
Up Next
</span>
<span className="font-mono text-xs text-zinc-400">{timeLabel}</span>
</div>
<div className="h-8 w-px shrink-0 bg-zinc-700" />
{/* Show info */}
<div className="flex min-w-0 flex-1 items-center gap-2">
<ArrowRight className="h-4 w-4 shrink-0 text-zinc-500" />
<p className="truncate text-sm font-medium text-white">{nextShowTitle}</p>
<span className="ml-auto shrink-0 font-mono text-xs text-zinc-500">
{nextShowStartTime}
</span>
</div>
</div>
);
}
export type { UpNextBannerProps };

View File

@@ -0,0 +1,29 @@
import { forwardRef } from "react";
interface VideoPlayerProps {
src?: string;
poster?: string;
className?: string;
}
const VideoPlayer = forwardRef<HTMLVideoElement, VideoPlayerProps>(
({ src, poster, className }, ref) => {
return (
<div className={`relative h-full w-full bg-black ${className ?? ""}`}>
<video
ref={ref}
src={src}
poster={poster}
autoPlay
playsInline
className="h-full w-full object-contain"
/>
</div>
);
}
);
VideoPlayer.displayName = "VideoPlayer";
export { VideoPlayer };
export type { VideoPlayerProps };

View File

@@ -0,0 +1,246 @@
"use client";
import { useState, useEffect, useCallback, useRef } from "react";
import {
VideoPlayer,
ChannelInfo,
ChannelControls,
ScheduleOverlay,
UpNextBanner,
NoSignal,
} from "./components";
import type { ScheduleSlot } from "./components";
// ---------------------------------------------------------------------------
// 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 },
},
];
// ---------------------------------------------------------------------------
const IDLE_TIMEOUT_MS = 3500;
const BANNER_THRESHOLD = 80; // show "up next" banner when progress ≥ this
export default function TvPage() {
const [channelIdx, setChannelIdx] = useState(0);
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;
// ------------------------------------------------------------------
// Idle detection — hide overlays after inactivity
// ------------------------------------------------------------------
const resetIdle = useCallback(() => {
setShowOverlays(true);
if (idleTimer.current) clearTimeout(idleTimer.current);
idleTimer.current = setTimeout(() => setShowOverlays(false), IDLE_TIMEOUT_MS);
}, []);
useEffect(() => {
resetIdle();
return () => {
if (idleTimer.current) clearTimeout(idleTimer.current);
};
}, [resetIdle]);
// ------------------------------------------------------------------
// Channel switching
// ------------------------------------------------------------------
const prevChannel = useCallback(() => {
setChannelIdx((i) => (i - 1 + MOCK_CHANNELS.length) % MOCK_CHANNELS.length);
resetIdle();
}, [resetIdle]);
const nextChannel = useCallback(() => {
setChannelIdx((i) => (i + 1) % MOCK_CHANNELS.length);
resetIdle();
}, [resetIdle]);
const toggleSchedule = useCallback(() => {
setShowSchedule((s) => !s);
resetIdle();
}, [resetIdle]);
// ------------------------------------------------------------------
// 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;
switch (e.key) {
case "ArrowUp":
case "PageUp":
e.preventDefault();
nextChannel();
break;
case "ArrowDown":
case "PageDown":
e.preventDefault();
prevChannel();
break;
case "g":
case "G":
toggleSchedule();
break;
}
};
window.addEventListener("keydown", handleKey);
return () => window.removeEventListener("keydown", handleKey);
}, [nextChannel, prevChannel, toggleSchedule]);
// ------------------------------------------------------------------
// Render
// ------------------------------------------------------------------
return (
<div
className="relative flex flex-1 overflow-hidden bg-black"
style={{ cursor: showOverlays ? "default" : "none" }}
onMouseMove={resetIdle}
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>
)}
{/* ── 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}
>
{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}
/>
)}
<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>
</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>
)}
</div>
);
}