Poglavja se zdaj generirajo iz YouTube podnapisov (chapters.py, dnevno ob 9:20) in gredo tudi v opis videa na YouTubu. Na strani so poglavja klikljiva (?t=<sekunde>, predvajalnik se odpre na tem mestu), v VideoObject pa gresta hasPart/Clip in SeekToAction, iz katerih Google naredi Wichtige Momente.
201 lines
7.8 KiB
TypeScript
201 lines
7.8 KiB
TypeScript
import { useState } from "react";
|
|
import { useQuery } from "@tanstack/react-query";
|
|
import { Link, useRoute } from "wouter";
|
|
import { ArrowLeft, Play, Clock, Youtube } 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>
|
|
)}
|
|
|
|
<a
|
|
href={v.youtubeUrl}
|
|
target="_blank"
|
|
rel="noopener"
|
|
className="mt-5 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.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.articleUrl && (
|
|
<a
|
|
href={v.articleUrl}
|
|
className="mt-3 inline-flex items-center gap-2 text-sm font-semibold text-primary hover:underline"
|
|
data-testid="link-article"
|
|
>
|
|
Ganzer Artikel auf folx.tv
|
|
</a>
|
|
)}
|
|
|
|
{(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>
|
|
);
|
|
}
|