Files
folx-tv/client/src/pages/video-detail.tsx
T

203 lines
8.1 KiB
TypeScript

import { useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { Link, useRoute } from "wouter";
import { ArrowLeft, Play, Clock, Youtube, FileText } from "lucide-react";
import Header from "@/components/header";
import Footer from "@/components/footer";
import { PageSideAds, TopBannerAd, MultiplexAd } from "@/components/adsense";
import { Skeleton } from "@/components/ui/skeleton";
import { format } from "date-fns";
import { de } from "date-fns/locale";
export interface YtVideo {
ytId: string;
slug: string;
title: string;
shortTitle: string;
rubrik: string;
publishedAt: string;
durationS: number;
teaser: string;
description: string;
descriptionClean?: string;
articleUrl?: string | null;
chapters: { t: string; label: string }[];
tags: string[];
previewUrl: string | null;
previewS: number;
embedUrl?: string;
posterUrl: string;
youtubeUrl: string;
}
export function chapSecs(t: string) {
const p = String(t).split(":").map((x) => parseInt(x, 10) || 0);
return p.length === 3 ? p[0] * 3600 + p[1] * 60 + p[2] : p[0] * 60 + p[1];
}
export function dauer(s: number) {
const m = Math.floor(s / 60);
const r = s % 60;
return `${m}:${String(r).padStart(2, "0")}`;
}
function RelatedCard({ v }: { v: YtVideo }) {
return (
<Link href={`/video/${v.slug}`}>
<article className="group cursor-pointer bg-card rounded-md border border-card-border h-full flex flex-col" data-testid={`card-related-${v.ytId}`}>
<div className="relative overflow-hidden rounded-t-md">
<img src={v.posterUrl} alt={v.shortTitle} loading="lazy"
className="w-full aspect-video object-cover transition-transform duration-500 group-hover:scale-105" />
<span className="absolute bottom-2 right-2 rounded bg-black/75 px-1.5 py-0.5 text-[11px] font-medium text-white">
{dauer(v.durationS)}
</span>
</div>
<div className="p-3">
<h3 className="text-sm font-semibold leading-snug line-clamp-2 text-card-foreground group-hover:text-primary transition-colors">
{v.shortTitle}
</h3>
</div>
</article>
</Link>
);
}
export default function VideoDetailPage() {
const [, params] = useRoute("/video/:slug");
// ?t=<sekunde> odpre predvajalnik na tem mestu; enak parameter uporablja
// tudi Clip oznacba, iz katere Google naredi "Wichtige Momente".
const [startAt, setStartAt] = useState(() => {
const n = parseInt(new URLSearchParams(window.location.search).get("t") || "", 10);
return Number.isFinite(n) && n > 0 ? n : 0;
});
const slug = params?.slug || "";
const { data, isLoading } = useQuery<{ item: YtVideo; related: YtVideo[] }>({
queryKey: [`/api/yt-videos/${slug}`],
enabled: !!slug,
});
const v = data?.item;
return (
<div className="min-h-screen bg-background">
<Header />
<PageSideAds />
<main className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-4">
<TopBannerAd />
<Link href="/videos">
<button className="flex items-center gap-2 text-muted-foreground hover:text-foreground transition-colors mb-5 text-sm" data-testid="button-back-videos">
<ArrowLeft className="w-4 h-4" />
Alle Videos
</button>
</Link>
{isLoading || !v ? (
<div className="space-y-4">
<Skeleton className="w-full aspect-video rounded-lg" />
<Skeleton className="h-7 w-3/4" />
<Skeleton className="h-4 w-full" />
</div>
) : (
<>
{/* Pravi YouTube predvajalnik: Google zahteva predvajljiv video na
strani, oglasi pa tecejo enako kot na youtube.com. Navadna
vdelava s klikom na play — brez samodejnega predvajanja, ker
YouTube v tem primeru oglasov ne servira. */}
<div className="rounded-lg overflow-hidden bg-black aspect-video">
<iframe
key={startAt}
src={`${v.embedUrl || `https://www.youtube-nocookie.com/embed/${v.ytId}`}${startAt ? `?start=${startAt}` : ""}`}
title={v.shortTitle}
className="w-full h-full"
style={{ border: 0 }}
allow="accelerometer; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
allowFullScreen
data-testid="video-embed"
/>
</div>
<div className="mt-3 flex flex-wrap items-center gap-x-3 gap-y-2 text-xs text-muted-foreground">
<span className="rounded bg-primary/10 px-2 py-1 font-semibold text-primary">{v.rubrik}</span>
<span className="flex items-center gap-1"><Clock className="w-3.5 h-3.5" />{dauer(v.durationS)}</span>
<span>{format(new Date(v.publishedAt), "d. MMMM yyyy", { locale: de })}</span>
</div>
<h1 className="mt-3 text-2xl sm:text-3xl font-bold text-foreground leading-tight" data-testid="text-video-title">
{v.shortTitle}
</h1>
{v.teaser && (
<p className="mt-3 text-base text-card-foreground leading-relaxed" data-testid="text-video-teaser">
{v.teaser}
</p>
)}
<div className="mt-5 flex flex-col sm:flex-row sm:items-stretch gap-3">
<a
href={v.youtubeUrl}
target="_blank"
rel="noopener"
className="inline-flex w-full sm:w-auto items-center justify-center gap-2 rounded-md bg-primary px-6 py-3 text-base font-semibold text-primary-foreground hover:opacity-90"
data-testid="link-watch-youtube"
>
<Youtube className="w-5 h-5" />
Ganzes Video auf YouTube ansehen
</a>
{v.articleUrl && (
<a
href={v.articleUrl}
className="inline-flex w-full sm:w-auto items-center justify-center gap-2 rounded-md border border-border bg-transparent px-6 py-3 text-base font-semibold text-foreground hover:bg-muted"
data-testid="link-article"
>
<FileText className="w-5 h-5" />
Ganzen Artikel lesen
</a>
)}
</div>
{v.chapters?.length > 0 && (
<section className="mt-8">
<h2 className="text-lg font-bold text-foreground mb-3">Kapitel</h2>
<ul className="space-y-1.5">
{v.chapters.map((c) => (
<li key={c.t} className="text-sm">
<a
href={`?t=${chapSecs(c.t)}`}
onClick={(e) => { e.preventDefault(); setStartAt(chapSecs(c.t)); window.scrollTo({ top: 0, behavior: "smooth" }); }}
className="hover:underline"
>
<span className="inline-block w-14 font-mono text-primary">{c.t}</span>
<span className="text-card-foreground">{c.label}</span>
</a>
</li>
))}
</ul>
</section>
)}
{(v.descriptionClean || v.description) && (
<section className="mt-8">
<h2 className="text-lg font-bold text-foreground mb-2">Zum Video</h2>
<p className="whitespace-pre-line text-sm text-muted-foreground leading-relaxed">
{(v.descriptionClean || v.description).replace(/\n{3,}/g, "\n\n")}
</p>
</section>
)}
<MultiplexAd />
{data?.related?.length ? (
<section className="mt-10">
<h2 className="text-lg font-bold text-foreground mb-4">Mehr aus {v.rubrik}</h2>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
{data.related.map((r) => <RelatedCard key={r.ytId} v={r} />)}
</div>
</section>
) : null}
</>
)}
</main>
<Footer />
</div>
);
}