166 lines
6.0 KiB
TypeScript
166 lines
6.0 KiB
TypeScript
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;
|
|
chapters: { t: string; label: string }[];
|
|
tags: string[];
|
|
previewUrl: string;
|
|
previewS: number;
|
|
posterUrl: string;
|
|
youtubeUrl: string;
|
|
}
|
|
|
|
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");
|
|
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>
|
|
) : (
|
|
<>
|
|
<div className="rounded-lg overflow-hidden bg-black">
|
|
<video
|
|
controls
|
|
playsInline
|
|
preload="metadata"
|
|
poster={v.posterUrl}
|
|
src={v.previewUrl}
|
|
className="w-full aspect-video"
|
|
data-testid="video-preview"
|
|
/>
|
|
</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>
|
|
<span className="text-muted-foreground/70">Vorschau {v.previewS} Sek.</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">
|
|
<span className="inline-block w-14 font-mono text-primary">{c.t}</span>
|
|
<span className="text-card-foreground">{c.label}</span>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
</section>
|
|
)}
|
|
|
|
{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.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>
|
|
);
|
|
}
|