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:
@@ -10,6 +10,7 @@ import ArticlePage from "@/pages/article";
|
||||
import CategoryPage from "@/pages/category";
|
||||
import AktuellesPage from "@/pages/aktuelles";
|
||||
import VideosPage from "@/pages/videos";
|
||||
import VideoDetailPage from "@/pages/video-detail";
|
||||
import GalleryPageWrapper from "@/pages/gallery";
|
||||
import HoroscopePage from "@/pages/horoscope";
|
||||
import RecipesPage from "@/pages/recipes";
|
||||
@@ -43,6 +44,7 @@ function Router() {
|
||||
<Route path="/category/:category" component={CategoryPage} />
|
||||
<Route path="/aktuelles" component={AktuellesPage} />
|
||||
<Route path="/videos" component={VideosPage} />
|
||||
<Route path="/video/:slug" component={VideoDetailPage} />
|
||||
<Route path="/gallery" component={GalleryPageWrapper} />
|
||||
<Route path="/horoskop" component={HoroscopePage} />
|
||||
<Route path="/horoskop/:sign" component={HoroscopePage} />
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
import { Switch, Route, useLocation } from "wouter";
|
||||
import { useEffect } from "react";
|
||||
import { queryClient } from "./lib/queryClient";
|
||||
import { QueryClientProvider } from "@tanstack/react-query";
|
||||
import { Toaster } from "@/components/ui/toaster";
|
||||
import { TooltipProvider } from "@/components/ui/tooltip";
|
||||
import NotFound from "@/pages/not-found";
|
||||
import Home from "@/pages/home";
|
||||
import ArticlePage from "@/pages/article";
|
||||
import CategoryPage from "@/pages/category";
|
||||
import AktuellesPage from "@/pages/aktuelles";
|
||||
import VideosPage from "@/pages/videos";
|
||||
import GalleryPageWrapper from "@/pages/gallery";
|
||||
import HoroscopePage from "@/pages/horoscope";
|
||||
import RecipesPage from "@/pages/recipes";
|
||||
import SearchPage from "@/pages/search";
|
||||
import EmpfangPage from "@/pages/empfang";
|
||||
import AboutPage from "@/pages/about";
|
||||
import ImpressumPage from "@/pages/impressum";
|
||||
import DatenschutzPage from "@/pages/datenschutz";
|
||||
import KontaktPage from "@/pages/kontakt";
|
||||
import AdminGalleryPage from "@/pages/admin-gallery";
|
||||
import AdminPushPage from "@/pages/admin-push";
|
||||
import CookieConsent from "@/components/cookie-consent";
|
||||
import MobileStickyAd from "@/components/mobile-sticky-ad";
|
||||
|
||||
function ScrollToTop() {
|
||||
const [location] = useLocation();
|
||||
useEffect(() => {
|
||||
window.scrollTo(0, 0);
|
||||
}, [location]);
|
||||
return null;
|
||||
}
|
||||
|
||||
function Router() {
|
||||
return (
|
||||
<div>
|
||||
<ScrollToTop />
|
||||
<Switch>
|
||||
<Route path="/" component={Home} />
|
||||
<Route path="/search" component={SearchPage} />
|
||||
<Route path="/article/:slug" component={ArticlePage} />
|
||||
<Route path="/category/:category" component={CategoryPage} />
|
||||
<Route path="/aktuelles" component={AktuellesPage} />
|
||||
<Route path="/videos" component={VideosPage} />
|
||||
<Route path="/gallery" component={GalleryPageWrapper} />
|
||||
<Route path="/horoskop" component={HoroscopePage} />
|
||||
<Route path="/horoskop/:sign" component={HoroscopePage} />
|
||||
<Route path="/rezepte" component={RecipesPage} />
|
||||
<Route path="/empfang-folx-tv" component={EmpfangPage} />
|
||||
<Route path="/ueber-uns" component={AboutPage} />
|
||||
<Route path="/impressum" component={ImpressumPage} />
|
||||
<Route path="/datenschutz" component={DatenschutzPage} />
|
||||
<Route path="/kontakt" component={KontaktPage} />
|
||||
<Route path="/admin/gallery" component={AdminGalleryPage} />
|
||||
<Route path="/admin/push" component={AdminPushPage} />
|
||||
<Route component={NotFound} />
|
||||
</Switch>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<TooltipProvider>
|
||||
<Toaster />
|
||||
<Router />
|
||||
<MobileStickyAd />
|
||||
</TooltipProvider>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user