Files
vod-system/frontend/src/pages/Home.tsx
Dylan De Faoite eab3f8be20
All checks were successful
CI / Maven unit tests (push) Successful in 14s
CI / Build and push images (push) Has been skipped
CI / Deploy to server (push) Has been skipped
restyle the rest of the pages
2026-08-02 16:32:00 +01:00

313 lines
12 KiB
TypeScript

import {useAuth} from "../utils/AuthContext.tsx";
import Landing from "./Landing.tsx";
import {Link, useNavigate} from "react-router-dom";
import Card from "../components/Card.tsx";
import {useEffect, useState} from "react";
import IsLivePulse from "../components/IsLivePulse.tsx";
import type {Clip, Marker, StreamStatus, Vod} from "../utils/types.ts";
import {getCurrentStream} from "../utils/api/stream.ts";
import VideoCard from "../components/video/VideoCard.tsx";
import {createMarker, getMarkers} from "../utils/api/markers.ts";
import clsx from "clsx";
import {getVods} from "../utils/api/vods.ts";
import {getClips} from "../utils/api/clips.ts";
import Button from "../components/buttons/Button.tsx";
type TimeOfDay = "morning" | "afternoon" | "evening" | "night";
function getTimeOfDay(date: Date = new Date()): TimeOfDay {
const hour = date.getHours();
if (hour >= 5 && hour < 12) return "morning";
if (hour >= 12 && hour < 17) return "afternoon";
if (hour >= 17 && hour < 22) return "evening";
return "night";
}
function getGreetingSubtext(date: Date = new Date()): string {
const timeOfDay = getTimeOfDay(date);
const messages: Record<TimeOfDay, string[]> = {
morning: [
"Good morning.",
"Coffee first, then clips.",
"Here's what's waiting for you.",
],
afternoon: [
"Good afternoon.",
"A quiet moment for your clips.",
"Catching up?",
],
evening: [
"Good evening.",
"Settle in.",
"Time to unwind.",
],
night: [
"Still up?",
"Quiet night for it.",
"Good night, when you're ready.",
]
}
const options = messages[timeOfDay];
return options[Math.floor(Math.random() * options.length)];
}
function getTimeFromISOString(date: string | null): string {
if (date == null) {
return "--:--";
}
const d = new Date(date);
return new Intl.DateTimeFormat('en-GB', {
hour: '2-digit',
minute: '2-digit',
hour12: false
}).format(d);
}
type SectionHeaderProps = { title: string; to: string; count: number };
const SectionHeader = ({ title, to, count }: SectionHeaderProps) => (
<div className={"flex flex-row items-baseline justify-between gap-4 pb-4"}>
<h2 className={"font-heading text-2xl"}>{title}</h2>
{count > 0 && (
<Link
to={to}
className={"font-body text-[13.5px] text-accent hover:text-accent-hover transition-colors duration-150"}
>
All {count} <span aria-hidden={true}>→</span>
</Link>
)}
</div>
);
const Home = () => {
const now = new Date();
const formatted: string = new Intl.DateTimeFormat('en-IE', {
weekday: 'long',
day: 'numeric',
month: 'long',
}).format(now);
const { user } = useAuth();
const [ streamStatus, setStreamStatus ] = useState<StreamStatus | null>(null)
const [ elapsed, setElapsed ] = useState('00:00:00');
const [ markers, setMarkers ] = useState<Marker[]>([])
const [ clips, setClips ] = useState<Clip[]>([]);
const [ vods, setVods ] = useState<Vod[]>([]);
const [ counts, setCounts ] = useState({ vods: 0, clips: 0 });
const [ markerBusy, setMarkerBusy ] = useState(false);
const [ markerNote, setMarkerNote ] = useState<string | null>(null);
const isStreaming = streamStatus?.isStreaming ?? false;
const [ greeting ] = useState<string>(getGreetingSubtext(new Date()));
const navigate = useNavigate();
// Fetch Markers & Stream information
useEffect(() => {
getCurrentStream()
.then(setStreamStatus)
getMarkers()
.then(data => {
setMarkers(data);
})
// Get Top 4 Clips
getClips()
.then(data => {
const topSix = [...data]
.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime())
.slice(0, 4);
setClips(topSix);
setCounts(prev => ({ ...prev, clips: data.length }));
})
// Get top 3 vods
getVods()
.then(data => {
const topSix = [...data]
.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime())
.slice(0, 3);
setVods(topSix);
setCounts(prev => ({ ...prev, vods: data.length }));
})
}, []);
const handleDropMarker = async () => {
if (markerBusy) return;
setMarkerBusy(true);
try {
await createMarker(`Marker at ${elapsed}`);
setMarkers(await getMarkers());
setMarkerNote(`dropped at ${elapsed}`);
} catch (err) {
setMarkerNote(err instanceof Error ? err.message : "Couldn't drop that marker");
} finally {
setMarkerBusy(false);
}
};
useEffect(() => {
if (streamStatus?.startDate == null) {
setElapsed('00:00:00');
return;
}
const startMs = new Date(streamStatus?.startDate).getTime();
const tick = () => {
const diffSec = Math.floor((Date.now() - startMs) / 1000);
const hh = String(Math.floor(diffSec / 3600)).padStart(2, '0');
const mm = String(Math.floor((diffSec % 3600) / 60)).padStart(2, '0');
const ss = String(diffSec % 60).padStart(2, '0');
setElapsed(`${hh}:${mm}:${ss}`);
};
tick();
const interval = setInterval(tick, 1000);
return () => clearInterval(interval);
}, [streamStatus?.startDate]);
if (user == null) {
return (<Landing />)
}
return (
<div className={"w-10/12 m-auto pt-10"}>
<div className={"flex flex-row justify-between items-start pb-8"}>
<div>
<p className={"font-data text-faint-mono text-sm tracking-widest"}>{formatted.toLocaleUpperCase()}</p>
<h1 className={"font-heading font text-5xl text-text-strong tracking-[-0.015em]"}>{greeting}</h1>
</div>
<Link to={"/stream"} className={"bg-accent rounded-md text-hover text-sm p-2 hover:bg-accent-hover shadow-md"}>
Open Stream Panel
</Link>
</div>
{/* Live status — always present, so the page has the same shape either way. */}
<Card className={clsx("p-8 mb-8", isStreaming ? "bg-live-card" : "bg-card")}>
<div className={"flex flex-row items-center gap-2"}>
{isStreaming
? <IsLivePulse />
: <span className={"h-2 w-2 rounded-full bg-inactive-strong"} />}
<p className={clsx(
"text-xs font-data tracking-[0.12em]",
isStreaming ? "text-live-red" : "text-faint-mono"
)}>
{isStreaming ? "LIVE NOW" : "OFF AIR"}
</p>
</div>
<div className={"p-1"}></div>
{isStreaming ? (
<>
<h2 className={"font-heading text-3xl"}>
Session {String(streamStatus?.id).padStart(5, '0')} {' '}
recording since {getTimeFromISOString(streamStatus?.startDate ?? null)}.</h2>
<div className={"flex flex-row items-center gap-10 pt-4"}>
<div className={"flex flex-col"}>
<h2 className={"font-data text-xl"}>{elapsed}</h2>
<p className={"text-xs text-muted"}>elapsed</p>
</div>
<div className={"flex flex-col"}>
<h2 className={"font-data text-xl"}>{markers.length}</h2>
<p className={"text-xs text-muted"}>markers dropped</p>
</div>
</div>
<div className={"mt-4 flex items-center gap-4"}>
<Button onClick={handleDropMarker} disabled={markerBusy}>+ Drop marker</Button>
{markerNote && <p className={"font-data text-[12px] text-muted"}>{markerNote}</p>}
</div>
</>
) : (
<>
<h2 className={"font-heading text-3xl"}>Nothing is streaming right now.</h2>
<p className={"pt-2 font-body text-[14.5px] leading-[1.65] text-text-body max-w-[62ch] text-pretty"}>
Point OBS at the ingest URL with your stream key and this block turns over to
elapsed time and markers.
</p>
<div className={"flex flex-row items-center gap-10 pt-4"}>
<div className={"flex flex-col"}>
<h2 className={"font-data text-xl"}>{counts.vods}</h2>
<p className={"text-xs text-muted"}>vods saved</p>
</div>
<div className={"flex flex-col"}>
<h2 className={"font-data text-xl"}>{counts.clips}</h2>
<p className={"text-xs text-muted"}>clips cut</p>
</div>
</div>
<div className={"mt-4"}>
<Link to={"/stream"}>
<Button variant={"secondary"}>Open the stream panel</Button>
</Link>
</div>
</>
)}
</Card>
<div className={"flex flex-col"}>
<SectionHeader title={"Recent VoDs"} to={"/my-vods"} count={counts.vods} />
<div className={"grid grid-cols-3 gap-5.5"}>
{vods.map((vod) => (
<VideoCard
key={vod.id}
id={vod.id}
title={vod.title}
duration={vod.duration}
createdAt={vod.createdAt}
width={vod.width}
height={vod.height}
fps={vod.fps}
fileSize={vod.fileSize}
mediaApiPath="/api/v1/vods"
playerPath="/vod"
itemLabel="vod"
onEdit={() => navigate(`/vods/${vod.id}/edit`)}
/>
))}
</div>
</div>
<div className={"flex flex-col mt-9"}>
<SectionHeader title={"Recent Clips"} to={"/my-clips"} count={counts.clips} />
<div className={"grid grid-cols-4 gap-4.5"}>
{clips.map((clip) => (
<VideoCard
key={clip.id}
id={clip.id}
title={clip.title}
duration={clip.duration}
createdAt={clip.createdAt}
width={clip.width}
height={clip.height}
fps={clip.fps}
fileSize={clip.fileSize}
mediaApiPath="/api/v1/clips"
playerPath="/clip"
itemLabel="clip"
onEdit={() => navigate(`/clip/${clip.id}/edit`)}
/>
))}
</div>
</div>
</div>
);
};
export default Home;