video sekcija: strani /video/<slug> z 20s napovednikom (predvajljiv na strani, VideoObject) in gumbom na YouTube; API /api/yt-videos; ingest skripta za FOLX YouTube kanal

This commit is contained in:
folx bot
2026-08-23 10:08:17 +02:00
parent 3c9da492a2
commit c8b7def69c
7 changed files with 1812 additions and 1 deletions
+165
View File
@@ -0,0 +1,165 @@
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>
);
}