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:
8
k-tv-frontend/app/(main)/dashboard/page.tsx
Normal file
8
k-tv-frontend/app/(main)/dashboard/page.tsx
Normal file
@@ -0,0 +1,8 @@
|
||||
export default function DashboardPage() {
|
||||
return (
|
||||
<div className="flex flex-1 flex-col items-center justify-center gap-4 p-8">
|
||||
<h1 className="text-2xl font-semibold tracking-tight">Dashboard</h1>
|
||||
<p className="text-sm text-zinc-500">Channel management and user settings go here.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
8
k-tv-frontend/app/(main)/docs/page.tsx
Normal file
8
k-tv-frontend/app/(main)/docs/page.tsx
Normal file
@@ -0,0 +1,8 @@
|
||||
export default function DocsPage() {
|
||||
return (
|
||||
<div className="flex flex-1 flex-col items-center justify-center gap-4 p-8">
|
||||
<h1 className="text-2xl font-semibold tracking-tight">Docs</h1>
|
||||
<p className="text-sm text-zinc-500">API reference and usage documentation go here.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
35
k-tv-frontend/app/(main)/layout.tsx
Normal file
35
k-tv-frontend/app/(main)/layout.tsx
Normal file
@@ -0,0 +1,35 @@
|
||||
import Link from "next/link";
|
||||
import { type ReactNode } from "react";
|
||||
|
||||
const NAV_LINKS = [
|
||||
{ href: "/tv", label: "TV" },
|
||||
{ href: "/dashboard", label: "Dashboard" },
|
||||
{ href: "/docs", label: "Docs" },
|
||||
];
|
||||
|
||||
export default function MainLayout({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<div className="flex min-h-screen flex-col bg-zinc-950 text-zinc-100">
|
||||
<header className="sticky top-0 z-50 border-b border-zinc-800 bg-zinc-950/80 backdrop-blur">
|
||||
<nav className="mx-auto flex h-14 max-w-7xl items-center justify-between px-6">
|
||||
<Link href="/tv" className="text-sm font-semibold tracking-widest text-zinc-100 uppercase">
|
||||
K-TV
|
||||
</Link>
|
||||
<ul className="flex items-center gap-1">
|
||||
{NAV_LINKS.map(({ href, label }) => (
|
||||
<li key={href}>
|
||||
<Link
|
||||
href={href}
|
||||
className="rounded-md px-3 py-1.5 text-sm text-zinc-400 transition-colors hover:bg-zinc-800 hover:text-zinc-100"
|
||||
>
|
||||
{label}
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</nav>
|
||||
</header>
|
||||
<main className="flex flex-1 flex-col">{children}</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
46
k-tv-frontend/app/(main)/tv/components/channel-controls.tsx
Normal file
46
k-tv-frontend/app/(main)/tv/components/channel-controls.tsx
Normal 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 };
|
||||
62
k-tv-frontend/app/(main)/tv/components/channel-info.tsx
Normal file
62
k-tv-frontend/app/(main)/tv/components/channel-info.tsx
Normal 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, 0–100 */
|
||||
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 };
|
||||
17
k-tv-frontend/app/(main)/tv/components/index.ts
Normal file
17
k-tv-frontend/app/(main)/tv/components/index.ts
Normal 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";
|
||||
59
k-tv-frontend/app/(main)/tv/components/no-signal.tsx
Normal file
59
k-tv-frontend/app/(main)/tv/components/no-signal.tsx
Normal 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 };
|
||||
64
k-tv-frontend/app/(main)/tv/components/schedule-overlay.tsx
Normal file
64
k-tv-frontend/app/(main)/tv/components/schedule-overlay.tsx
Normal 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 };
|
||||
42
k-tv-frontend/app/(main)/tv/components/up-next-banner.tsx
Normal file
42
k-tv-frontend/app/(main)/tv/components/up-next-banner.tsx
Normal 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 };
|
||||
29
k-tv-frontend/app/(main)/tv/components/video-player.tsx
Normal file
29
k-tv-frontend/app/(main)/tv/components/video-player.tsx
Normal 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 };
|
||||
246
k-tv-frontend/app/(main)/tv/page.tsx
Normal file
246
k-tv-frontend/app/(main)/tv/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
BIN
k-tv-frontend/app/favicon.ico
Normal file
BIN
k-tv-frontend/app/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 25 KiB |
129
k-tv-frontend/app/globals.css
Normal file
129
k-tv-frontend/app/globals.css
Normal file
@@ -0,0 +1,129 @@
|
||||
@import "tailwindcss";
|
||||
@import "tw-animate-css";
|
||||
@import "shadcn/tailwind.css";
|
||||
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
|
||||
@theme inline {
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
--font-sans: var(--font-sans);
|
||||
--font-mono: var(--font-geist-mono);
|
||||
--color-sidebar-ring: var(--sidebar-ring);
|
||||
--color-sidebar-border: var(--sidebar-border);
|
||||
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||
--color-sidebar-accent: var(--sidebar-accent);
|
||||
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
|
||||
--color-sidebar-primary: var(--sidebar-primary);
|
||||
--color-sidebar-foreground: var(--sidebar-foreground);
|
||||
--color-sidebar: var(--sidebar);
|
||||
--color-chart-5: var(--chart-5);
|
||||
--color-chart-4: var(--chart-4);
|
||||
--color-chart-3: var(--chart-3);
|
||||
--color-chart-2: var(--chart-2);
|
||||
--color-chart-1: var(--chart-1);
|
||||
--color-ring: var(--ring);
|
||||
--color-input: var(--input);
|
||||
--color-border: var(--border);
|
||||
--color-destructive: var(--destructive);
|
||||
--color-accent-foreground: var(--accent-foreground);
|
||||
--color-accent: var(--accent);
|
||||
--color-muted-foreground: var(--muted-foreground);
|
||||
--color-muted: var(--muted);
|
||||
--color-secondary-foreground: var(--secondary-foreground);
|
||||
--color-secondary: var(--secondary);
|
||||
--color-primary-foreground: var(--primary-foreground);
|
||||
--color-primary: var(--primary);
|
||||
--color-popover-foreground: var(--popover-foreground);
|
||||
--color-popover: var(--popover);
|
||||
--color-card-foreground: var(--card-foreground);
|
||||
--color-card: var(--card);
|
||||
--radius-sm: calc(var(--radius) * 0.6);
|
||||
--radius-md: calc(var(--radius) * 0.8);
|
||||
--radius-lg: var(--radius);
|
||||
--radius-xl: calc(var(--radius) * 1.4);
|
||||
--radius-2xl: calc(var(--radius) * 1.8);
|
||||
--radius-3xl: calc(var(--radius) * 2.2);
|
||||
--radius-4xl: calc(var(--radius) * 2.6);
|
||||
}
|
||||
|
||||
:root {
|
||||
--background: oklch(1 0 0);
|
||||
--foreground: oklch(0.145 0 0);
|
||||
--card: oklch(1 0 0);
|
||||
--card-foreground: oklch(0.145 0 0);
|
||||
--popover: oklch(1 0 0);
|
||||
--popover-foreground: oklch(0.145 0 0);
|
||||
--primary: oklch(0.205 0 0);
|
||||
--primary-foreground: oklch(0.985 0 0);
|
||||
--secondary: oklch(0.97 0 0);
|
||||
--secondary-foreground: oklch(0.205 0 0);
|
||||
--muted: oklch(0.97 0 0);
|
||||
--muted-foreground: oklch(0.556 0 0);
|
||||
--accent: oklch(0.97 0 0);
|
||||
--accent-foreground: oklch(0.205 0 0);
|
||||
--destructive: oklch(0.577 0.245 27.325);
|
||||
--border: oklch(0.922 0 0);
|
||||
--input: oklch(0.922 0 0);
|
||||
--ring: oklch(0.708 0 0);
|
||||
--chart-1: oklch(0.809 0.105 251.813);
|
||||
--chart-2: oklch(0.623 0.214 259.815);
|
||||
--chart-3: oklch(0.546 0.245 262.881);
|
||||
--chart-4: oklch(0.488 0.243 264.376);
|
||||
--chart-5: oklch(0.424 0.199 265.638);
|
||||
--radius: 0.625rem;
|
||||
--sidebar: oklch(0.985 0 0);
|
||||
--sidebar-foreground: oklch(0.145 0 0);
|
||||
--sidebar-primary: oklch(0.205 0 0);
|
||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||
--sidebar-accent: oklch(0.97 0 0);
|
||||
--sidebar-accent-foreground: oklch(0.205 0 0);
|
||||
--sidebar-border: oklch(0.922 0 0);
|
||||
--sidebar-ring: oklch(0.708 0 0);
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: oklch(0.145 0 0);
|
||||
--foreground: oklch(0.985 0 0);
|
||||
--card: oklch(0.205 0 0);
|
||||
--card-foreground: oklch(0.985 0 0);
|
||||
--popover: oklch(0.205 0 0);
|
||||
--popover-foreground: oklch(0.985 0 0);
|
||||
--primary: oklch(0.922 0 0);
|
||||
--primary-foreground: oklch(0.205 0 0);
|
||||
--secondary: oklch(0.269 0 0);
|
||||
--secondary-foreground: oklch(0.985 0 0);
|
||||
--muted: oklch(0.269 0 0);
|
||||
--muted-foreground: oklch(0.708 0 0);
|
||||
--accent: oklch(0.269 0 0);
|
||||
--accent-foreground: oklch(0.985 0 0);
|
||||
--destructive: oklch(0.704 0.191 22.216);
|
||||
--border: oklch(1 0 0 / 10%);
|
||||
--input: oklch(1 0 0 / 15%);
|
||||
--ring: oklch(0.556 0 0);
|
||||
--chart-1: oklch(0.809 0.105 251.813);
|
||||
--chart-2: oklch(0.623 0.214 259.815);
|
||||
--chart-3: oklch(0.546 0.245 262.881);
|
||||
--chart-4: oklch(0.488 0.243 264.376);
|
||||
--chart-5: oklch(0.424 0.199 265.638);
|
||||
--sidebar: oklch(0.205 0 0);
|
||||
--sidebar-foreground: oklch(0.985 0 0);
|
||||
--sidebar-primary: oklch(0.488 0.243 264.376);
|
||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||
--sidebar-accent: oklch(0.269 0 0);
|
||||
--sidebar-accent-foreground: oklch(0.985 0 0);
|
||||
--sidebar-border: oklch(1 0 0 / 10%);
|
||||
--sidebar-ring: oklch(0.556 0 0);
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border outline-ring/50;
|
||||
}
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
}
|
||||
html {
|
||||
@apply font-sans;
|
||||
}
|
||||
}
|
||||
35
k-tv-frontend/app/layout.tsx
Normal file
35
k-tv-frontend/app/layout.tsx
Normal file
@@ -0,0 +1,35 @@
|
||||
import type { Metadata } from "next";
|
||||
import { Geist, Geist_Mono } from "next/font/google";
|
||||
import "./globals.css";
|
||||
import { Providers } from "./providers";
|
||||
|
||||
const geistSans = Geist({
|
||||
variable: "--font-geist-sans",
|
||||
subsets: ["latin"],
|
||||
});
|
||||
|
||||
const geistMono = Geist_Mono({
|
||||
variable: "--font-geist-mono",
|
||||
subsets: ["latin"],
|
||||
});
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Create Next App",
|
||||
description: "Generated by create next app",
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: Readonly<{
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<body
|
||||
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
|
||||
>
|
||||
<Providers>{children}</Providers>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
5
k-tv-frontend/app/page.tsx
Normal file
5
k-tv-frontend/app/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
export default function Home() {
|
||||
redirect("/tv");
|
||||
}
|
||||
25
k-tv-frontend/app/providers.tsx
Normal file
25
k-tv-frontend/app/providers.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
"use client";
|
||||
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
|
||||
import { useState } from "react";
|
||||
|
||||
export function Providers({ children }: { children: React.ReactNode }) {
|
||||
const [queryClient] = useState(
|
||||
() =>
|
||||
new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
staleTime: 60 * 1000,
|
||||
},
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
{children}
|
||||
<ReactQueryDevtools initialIsOpen={false} />
|
||||
</QueryClientProvider>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user