Add exclusive photos from recordings to the gallery and improve site metadata

Integrate AdSense ads into the photo gallery, introduce SSR meta tags for the gallery page, and improve layout stability by reserving space for ads and dynamic content.

Replit-Commit-Author: Agent
Replit-Commit-Session-Id: 23852c00-4779-460a-9e0c-d09fee4b6c92
Replit-Commit-Checkpoint-Type: full_checkpoint
Replit-Commit-Event-Id: 81f3d8bc-2328-4374-a06e-57257dbb713b
Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/f209e72a-0939-48fa-84fc-57854de71967/23852c00-4779-460a-9e0c-d09fee4b6c92/OPD8Ro3
Replit-Helium-Checkpoint-Created: true
This commit is contained in:
sebastjanartic 2026-03-08 07:33:00 +00:00
parent 8a263dee0f
commit 4ece9e4ccd
8 changed files with 133 additions and 38 deletions

View File

@ -96,7 +96,7 @@ export function ArticleCardAd() {
export function InArticleAd() { export function InArticleAd() {
return ( return (
<div className="my-6 rounded-md overflow-hidden bg-card border border-card-border"> <div className="my-6 rounded-md overflow-hidden bg-card border border-card-border" style={{ minHeight: "106px" }}>
<div className="text-[9px] text-muted-foreground/40 text-center pt-1 uppercase tracking-widest">Anzeige</div> <div className="text-[9px] text-muted-foreground/40 text-center pt-1 uppercase tracking-widest">Anzeige</div>
<AdSense <AdSense
slot="4154017639" slot="4154017639"

View File

@ -1,6 +1,7 @@
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { useState, useEffect, useCallback, useRef } from "react"; import { useState, useEffect, useCallback, useRef } from "react";
import { ChevronLeft, ChevronRight, X, Images, Maximize2 } from "lucide-react"; import { ChevronLeft, ChevronRight, X, Images, Maximize2 } from "lucide-react";
import { InArticleAd } from "./adsense";
export interface GalleryImage { export interface GalleryImage {
folder: string; folder: string;
@ -340,43 +341,58 @@ export default function GalleryPage() {
<p className="text-muted-foreground text-sm mb-4" data-testid="text-gallery-count"> <p className="text-muted-foreground text-sm mb-4" data-testid="text-gallery-count">
{Math.min(visibleCount, totalCount)} von {totalCount} Fotos {Math.min(visibleCount, totalCount)} von {totalCount} Fotos
</p> </p>
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-3"> {(() => {
{visibleImages.map((img, i) => ( const AD_EVERY = 12;
<button const chunks: GalleryImage[][] = [];
key={`${img.folder}-${img.fileName}`} for (let i = 0; i < visibleImages.length; i += AD_EVERY) {
onClick={() => setLightboxIndex(i)} chunks.push(visibleImages.slice(i, i + AD_EVERY));
className="group relative rounded-lg overflow-hidden bg-card border border-card-border cursor-pointer flex flex-col" }
data-testid={`button-gallery-image-${i}`} return chunks.map((chunk, ci) => (
> <div key={ci}>
<div className="relative w-full aspect-[16/9]"> {ci > 0 && <InArticleAd />}
<LazyImage <div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-3">
src={img.mobile || img.thumb} {chunk.map((img, j) => {
alt={img.artist || img.fileName} const globalIdx = ci * AD_EVERY + j;
className="absolute inset-0 w-full h-full transition-transform duration-500 group-hover:scale-110" return (
objectPosition={getObjectPosition(img.fileName, fp)} <button
/> key={`${img.folder}-${img.fileName}`}
<div className="absolute inset-0 bg-black/0 group-hover:bg-black/20 transition-colors" /> onClick={() => setLightboxIndex(globalIdx)}
{img.artist && ( className="group relative rounded-lg overflow-hidden bg-card border border-card-border cursor-pointer flex flex-col"
<div className="absolute bottom-0 left-0 right-0 bg-gradient-to-t from-black/70 to-transparent p-2 hidden md:block opacity-0 group-hover:opacity-100 transition-opacity"> data-testid={`button-gallery-image-${globalIdx}`}
<span className="text-white text-xs font-medium drop-shadow-lg" data-testid={`text-artist-desktop-${i}`}> >
{img.artist} <div className="relative w-full aspect-[16/9]">
</span> <LazyImage
</div> src={img.mobile || img.thumb}
)} alt={img.artist || img.fileName}
<div className="absolute bottom-0 right-0 p-2 opacity-0 group-hover:opacity-100 transition-opacity"> className="absolute inset-0 w-full h-full transition-transform duration-500 group-hover:scale-110"
<Maximize2 className="w-4 h-4 text-white" /> objectPosition={getObjectPosition(img.fileName, fp)}
</div> />
<div className="absolute inset-0 bg-black/0 group-hover:bg-black/20 transition-colors" />
{img.artist && (
<div className="absolute bottom-0 left-0 right-0 bg-gradient-to-t from-black/70 to-transparent p-2 hidden md:block opacity-0 group-hover:opacity-100 transition-opacity">
<span className="text-white text-xs font-medium drop-shadow-lg" data-testid={`text-artist-desktop-${globalIdx}`}>
{img.artist}
</span>
</div>
)}
<div className="absolute bottom-0 right-0 p-2 opacity-0 group-hover:opacity-100 transition-opacity">
<Maximize2 className="w-4 h-4 text-white" />
</div>
</div>
{img.artist && (
<div className="md:hidden px-2 py-1.5 bg-card border-t border-card-border">
<span className="text-[11px] text-card-foreground font-medium line-clamp-1" data-testid={`text-artist-mobile-${globalIdx}`}>
{img.artist}
</span>
</div>
)}
</button>
);
})}
</div> </div>
{img.artist && ( </div>
<div className="md:hidden px-2 py-1.5 bg-card border-t border-card-border"> ));
<span className="text-[11px] text-card-foreground font-medium line-clamp-1" data-testid={`text-artist-mobile-${i}`}> })()}
{img.artist}
</span>
</div>
)}
</button>
))}
</div>
{hasMore && ( {hasMore && (
<div ref={sentinelRef} className="flex justify-center py-8" data-testid="gallery-load-more"> <div ref={sentinelRef} className="flex justify-center py-8" data-testid="gallery-load-more">

View File

@ -303,3 +303,12 @@ div[class^="fc-"] {
inset: -1px; inset: -1px;
} }
} }
.ad-container {
contain: layout style;
overflow: hidden;
}
.adsbygoogle {
contain: layout style;
}

View File

@ -7,7 +7,7 @@ import { usePageMeta } from "@/hooks/use-page-meta";
import { InArticleAd, PageSideAds } from "@/components/adsense"; import { InArticleAd, PageSideAds } from "@/components/adsense";
export default function GalleryPageWrapper() { export default function GalleryPageWrapper() {
usePageMeta("Fotogalerie - Volksmusik & Schlager Bilder", "Exklusive Fotos und Bilder von Volksmusik- und Schlager-Stars, Konzerten und Events. Die Volksmusik-Fotogalerie von FOLX TV."); usePageMeta("Fotogalerie Exklusive Backstage- & Konzertfotos | FOLX TV", "Einzigartige Fotos direkt von unseren Aufzeichnungen und Sendungen: Backstage-Momente, Konzertbilder und exklusive Aufnahmen der Volksmusik- und Schlager-Stars bei FOLX TV.");
return ( return (
<div className="min-h-screen bg-background"> <div className="min-h-screen bg-background">
<Header /> <Header />

View File

@ -276,6 +276,7 @@ function SignDetail({ signIndex, onNavigate, aiHoroscopes }: { signIndex: number
))} ))}
</div> </div>
<div style={{ minHeight: "180px" }}>
{tab === "daily" && ( {tab === "daily" && (
<div className="space-y-5" data-testid="section-horoscope-daily"> <div className="space-y-5" data-testid="section-horoscope-daily">
<div> <div>
@ -310,6 +311,7 @@ function SignDetail({ signIndex, onNavigate, aiHoroscopes }: { signIndex: number
<p className="text-sm text-foreground/80 leading-relaxed" data-testid="text-horoscope-monthly">{horoscope.monthly}</p> <p className="text-sm text-foreground/80 leading-relaxed" data-testid="text-horoscope-monthly">{horoscope.monthly}</p>
</div> </div>
)} )}
</div>
</div> </div>
<InArticleAd /> <InArticleAd />

View File

@ -7,6 +7,7 @@ The official website for Folx Music Television (folx.tv). Dark-themed bento grid
- Primary keyword: "Volksmusik" — used across all page titles, meta descriptions, OG tags, and structured data - Primary keyword: "Volksmusik" — used across all page titles, meta descriptions, OG tags, and structured data
- Dynamic canonical URLs via `usePageMeta` hook (updates `<link rel="canonical">` per page) - Dynamic canonical URLs via `usePageMeta` hook (updates `<link rel="canonical">` per page)
- SSR article pages: server-side meta tags (OG, Twitter, description, keywords, canonical) in both `server/vite.ts` (dev) and `server/static.ts` (prod) - SSR article pages: server-side meta tags (OG, Twitter, description, keywords, canonical) in both `server/vite.ts` (dev) and `server/static.ts` (prod)
- SSR gallery page (`/gallery`): OG meta, Twitter card, description, canonical — emphasizes exclusive backstage/concert photos from recordings
- `stripExistingMeta()` removes duplicate meta/canonical from base HTML before injecting article-specific ones - `stripExistingMeta()` removes duplicate meta/canonical from base HTML before injecting article-specific ones
- JSON-LD structured data: WebSite (home) with SearchAction, NewsArticle + BreadcrumbList (articles) - JSON-LD structured data: WebSite (home) with SearchAction, NewsArticle + BreadcrumbList (articles)
- Sitemap at `/sitemap.xml` — includes all static pages, categories, horoscope signs, and articles - Sitemap at `/sitemap.xml` — includes all static pages, categories, horoscope signs, and articles

View File

@ -95,6 +95,41 @@ export function serveStatic(app: Express) {
} }
} }
if (url.match(/^\/gallery(\/|$|\?)/)) {
let template = await fs.promises.readFile(indexPath, "utf-8");
template = stripExistingMeta(template);
const galTitle = "Fotogalerie \u2013 Exklusive Backstage- & Konzertfotos | FOLX TV";
const galDesc = "Einzigartige Fotos direkt von unseren Aufzeichnungen und Sendungen: Backstage-Momente, Konzertbilder und exklusive Aufnahmen der Volksmusik- und Schlager-Stars bei FOLX TV.";
const galUrl = `${canonicalBase}/gallery`;
const galImage = `${canonicalBase}/images/og-image.jpg`;
const galTags = [
`<meta property="og:title" content="${escapeHtml(galTitle)}" />`,
`<meta property="og:description" content="${escapeHtml(galDesc)}" />`,
`<meta property="og:type" content="website" />`,
`<meta property="og:url" content="${escapeHtml(galUrl)}" />`,
`<meta property="og:image" content="${escapeHtml(galImage)}" />`,
`<meta property="og:image:secure_url" content="${escapeHtml(galImage)}" />`,
`<meta property="og:image:width" content="1200" />`,
`<meta property="og:image:height" content="630" />`,
`<meta property="og:image:type" content="image/jpeg" />`,
`<meta property="og:site_name" content="Folx Music Television" />`,
`<meta property="og:locale" content="de_DE" />`,
`<meta name="twitter:card" content="summary_large_image" />`,
`<meta name="twitter:title" content="${escapeHtml(galTitle)}" />`,
`<meta name="twitter:description" content="${escapeHtml(galDesc)}" />`,
`<meta name="twitter:image" content="${escapeHtml(galImage)}" />`,
`<meta name="description" content="${escapeHtml(galDesc)}" />`,
`<meta name="keywords" content="Fotogalerie, Volksmusik Fotos, Schlager Bilder, Konzertfotos, Backstage, FOLX TV, Volksmusik Stars" />`,
`<link rel="canonical" href="${escapeHtml(galUrl)}" />`,
`<title>${escapeHtml(galTitle)}</title>`,
].join("\n ");
template = template.replace(/<title>[^<]*<\/title>/, galTags);
res.status(200).set({ "Content-Type": "text/html" }).end(template);
return;
}
if (url.match(/^\/horoskop(\/|$|\?)/)) { if (url.match(/^\/horoskop(\/|$|\?)/)) {
let template = await fs.promises.readFile(indexPath, "utf-8"); let template = await fs.promises.readFile(indexPath, "utf-8");
template = stripExistingMeta(template); template = stripExistingMeta(template);

View File

@ -113,6 +113,38 @@ export async function setupVite(server: Server, app: Express) {
} }
} }
if (url.match(/^\/gallery(\/|$|\?)/)) {
template = stripExistingMeta(template);
const galTitle = "Fotogalerie \u2013 Exklusive Backstage- & Konzertfotos | FOLX TV";
const galDesc = "Einzigartige Fotos direkt von unseren Aufzeichnungen und Sendungen: Backstage-Momente, Konzertbilder und exklusive Aufnahmen der Volksmusik- und Schlager-Stars bei FOLX TV.";
const galUrl = `${canonicalBase}/gallery`;
const galImage = `${canonicalBase}/images/og-image.jpg`;
const galTags = [
`<meta property="og:title" content="${escapeHtml(galTitle)}" />`,
`<meta property="og:description" content="${escapeHtml(galDesc)}" />`,
`<meta property="og:type" content="website" />`,
`<meta property="og:url" content="${escapeHtml(galUrl)}" />`,
`<meta property="og:image" content="${escapeHtml(galImage)}" />`,
`<meta property="og:image:secure_url" content="${escapeHtml(galImage)}" />`,
`<meta property="og:image:width" content="1200" />`,
`<meta property="og:image:height" content="630" />`,
`<meta property="og:image:type" content="image/jpeg" />`,
`<meta property="og:site_name" content="Folx Music Television" />`,
`<meta property="og:locale" content="de_DE" />`,
`<meta name="twitter:card" content="summary_large_image" />`,
`<meta name="twitter:title" content="${escapeHtml(galTitle)}" />`,
`<meta name="twitter:description" content="${escapeHtml(galDesc)}" />`,
`<meta name="twitter:image" content="${escapeHtml(galImage)}" />`,
`<meta name="description" content="${escapeHtml(galDesc)}" />`,
`<meta name="keywords" content="Fotogalerie, Volksmusik Fotos, Schlager Bilder, Konzertfotos, Backstage, FOLX TV, Volksmusik Stars" />`,
`<link rel="canonical" href="${escapeHtml(galUrl)}" />`,
`<title>${escapeHtml(galTitle)}</title>`,
].join("\n ");
template = template.replace(/<title>[^<]*<\/title>/, galTags);
}
if (url.match(/^\/horoskop(\/|$|\?)/)) { if (url.match(/^\/horoskop(\/|$|\?)/)) {
template = stripExistingMeta(template); template = stripExistingMeta(template);
const signMatch = url.match(/^\/horoskop\/([^?#]+)/); const signMatch = url.match(/^\/horoskop\/([^?#]+)/);