Improve photo gallery with artist names and infinite scrolling

Refactor photo gallery to include artist names extracted from filenames, implement pagination with infinite scrolling, and optimize image loading.

Replit-Commit-Author: Agent
Replit-Commit-Session-Id: 517dfa7b-26ac-463d-a6e1-a58c6df97188
Replit-Commit-Checkpoint-Type: full_checkpoint
Replit-Commit-Event-Id: 79e0a082-1752-4a36-8483-997b7269c4f3
Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/f209e72a-0939-48fa-84fc-57854de71967/517dfa7b-26ac-463d-a6e1-a58c6df97188/nFw7xof
Replit-Helium-Checkpoint-Created: true
This commit is contained in:
sebastjanartic 2026-03-02 16:39:45 +00:00
parent 553d20d8cb
commit 4b5b3e5d97
8 changed files with 892 additions and 639 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 210 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 297 KiB

View File

@ -7,8 +7,11 @@ interface GalleryImage {
fileName: string; fileName: string;
thumb: string; thumb: string;
large: string; large: string;
artist?: string;
} }
const PAGE_SIZE = 24;
function thumbUrl(src: string) { function thumbUrl(src: string) {
return `/api/gallery/thumb?src=${encodeURIComponent(src)}`; return `/api/gallery/thumb?src=${encodeURIComponent(src)}`;
} }
@ -72,6 +75,8 @@ function Lightbox({
return () => window.removeEventListener("keydown", handler); return () => window.removeEventListener("keydown", handler);
}, [onClose, prev, next]); }, [onClose, prev, next]);
const current = images[index];
return ( return (
<div <div
className="fixed inset-0 z-50 bg-black/95 flex items-center justify-center" className="fixed inset-0 z-50 bg-black/95 flex items-center justify-center"
@ -104,15 +109,18 @@ function Lightbox({
<div className="w-[85vh] h-[85vh] max-w-[95vw] max-h-[95vw] flex items-center justify-center" onClick={(e) => e.stopPropagation()}> <div className="w-[85vh] h-[85vh] max-w-[95vw] max-h-[95vw] flex items-center justify-center" onClick={(e) => e.stopPropagation()}>
<img <img
src={images[index].large} src={current.large}
alt={images[index].fileName} alt={current.artist || current.fileName}
className="w-full h-full object-cover rounded-lg" className="w-full h-full object-cover rounded-lg"
data-testid="img-lightbox" data-testid="img-lightbox"
/> />
</div> </div>
<div className="absolute bottom-4 text-white/60 text-sm" data-testid="text-lightbox-counter"> <div className="absolute bottom-4 flex flex-col items-center gap-1" data-testid="text-lightbox-counter">
{index + 1} / {images.length} {current.artist && (
<span className="text-white text-sm font-medium" data-testid="text-lightbox-artist">{current.artist}</span>
)}
<span className="text-white/60 text-sm">{index + 1} / {images.length}</span>
</div> </div>
</div> </div>
); );
@ -143,6 +151,8 @@ function SingleImageCarousel({
if (images.length === 0) return null; if (images.length === 0) return null;
const current = images[index];
return ( return (
<div <div
className="relative w-full h-full flex flex-col" className="relative w-full h-full flex flex-col"
@ -151,15 +161,23 @@ function SingleImageCarousel({
> >
<div className="relative w-full overflow-hidden rounded-b-lg flex-1 min-h-[200px]"> <div className="relative w-full overflow-hidden rounded-b-lg flex-1 min-h-[200px]">
<img <img
src={images[index].large || images[index].thumb} src={current.large || current.thumb}
alt={images[index].fileName} alt={current.artist || current.fileName}
className="absolute inset-0 w-full h-full object-cover transition-opacity duration-500" className="absolute inset-0 w-full h-full object-cover transition-opacity duration-500"
style={{ objectPosition: "center 35%" }} style={{ objectPosition: "center 35%" }}
loading="lazy" loading="lazy"
data-testid="img-gallery-current" data-testid="img-gallery-current"
/> />
<div className="absolute inset-0 bg-gradient-to-t from-black/40 via-transparent to-transparent" /> <div className="absolute inset-0 bg-gradient-to-t from-black/60 via-transparent to-transparent" />
{current.artist && (
<div className="absolute bottom-8 left-0 right-0 text-center px-2">
<span className="text-white text-xs font-medium drop-shadow-lg" data-testid="text-gallery-artist">
{current.artist}
</span>
</div>
)}
<button <button
onClick={prev} onClick={prev}
@ -259,6 +277,27 @@ export default function GalleryPage() {
}); });
const [lightboxIndex, setLightboxIndex] = useState<number | null>(null); const [lightboxIndex, setLightboxIndex] = useState<number | null>(null);
const [visibleCount, setVisibleCount] = useState(PAGE_SIZE);
const sentinelRef = useRef<HTMLDivElement>(null);
const totalCount = images?.length || 0;
const visibleImages = images?.slice(0, visibleCount) || [];
const hasMore = visibleCount < totalCount;
useEffect(() => {
const el = sentinelRef.current;
if (!el || !hasMore) return;
const obs = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) {
setVisibleCount((c) => Math.min(c + PAGE_SIZE, totalCount));
}
},
{ rootMargin: "400px" }
);
obs.observe(el);
return () => obs.disconnect();
}, [hasMore, totalCount]);
return ( return (
<div data-testid="page-gallery"> <div data-testid="page-gallery">
@ -271,28 +310,53 @@ export default function GalleryPage() {
) : images && images.length > 0 ? ( ) : images && images.length > 0 ? (
<> <>
<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">
{images.length} 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"> <div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-3">
{images.map((img, i) => ( {visibleImages.map((img, i) => (
<button <button
key={`${img.folder}-${img.fileName}`} key={`${img.folder}-${img.fileName}`}
onClick={() => setLightboxIndex(i)} onClick={() => setLightboxIndex(i)}
className="group relative aspect-square rounded-lg overflow-hidden bg-card border border-card-border cursor-pointer" className="group relative rounded-lg overflow-hidden bg-card border border-card-border cursor-pointer flex flex-col"
data-testid={`button-gallery-image-${i}`} data-testid={`button-gallery-image-${i}`}
> >
<LazyImage <div className="relative aspect-square w-full">
src={thumbUrl(img.thumb)} <LazyImage
alt={img.fileName} src={thumbUrl(img.thumb)}
className="absolute inset-0 w-full h-full transition-transform duration-500 group-hover:scale-110" alt={img.artist || img.fileName}
/> className="absolute inset-0 w-full h-full transition-transform duration-500 group-hover:scale-110"
<div className="absolute inset-0 bg-black/0 group-hover:bg-black/20 transition-colors" /> />
<div className="absolute bottom-0 left-0 right-0 p-2 opacity-0 group-hover:opacity-100 transition-opacity"> <div className="absolute inset-0 bg-black/0 group-hover:bg-black/20 transition-colors" />
<Maximize2 className="w-4 h-4 text-white ml-auto" /> {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-${i}`}>
{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> </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-${i}`}>
{img.artist}
</span>
</div>
)}
</button> </button>
))} ))}
</div> </div>
{hasMore && (
<div ref={sentinelRef} className="flex justify-center py-8" data-testid="gallery-load-more">
<div className="flex items-center gap-2 text-muted-foreground text-sm">
<div className="w-4 h-4 border-2 border-primary border-t-transparent rounded-full animate-spin" />
Weitere Fotos werden geladen...
</div>
</div>
)}
</> </>
) : ( ) : (
<p className="text-center text-muted-foreground">Keine Fotos vorhanden</p> <p className="text-center text-muted-foreground">Keine Fotos vorhanden</p>

View File

@ -35,9 +35,11 @@ A professional MSN-style news portal for Folx Music Television (news.folx.tv). D
- `PATCH /api/articles/:id` - Update article - `PATCH /api/articles/:id` - Update article
- `DELETE /api/articles/:id` - Delete article - `DELETE /api/articles/:id` - Delete article
- `POST /api/upload` - Upload image file - `POST /api/upload` - Upload image file
- `GET /api/gallery` - Shuffled Dropbox gallery images - `GET /api/gallery` - Shuffled Dropbox gallery images (with artist names from filenames)
- `GET /api/news-feed` - Google News RSS feed for Volksmusik/Schlager - `GET /api/gallery/thumb` - Proxy endpoint for Dropbox thumbnail resizing (sharp, 400x400, 30-min cache)
- `GET /api/videos` - BunnyCDN video list - `GET /api/news-feed` - Google News RSS feed for Volksmusik/Schlager (15-min cache, stale-while-error)
- `GET /api/breaking-news` - Google News RSS feed for general news (15-min cache, stale-while-error)
- `GET /api/videos` - BunnyCDN video list (30-min cache, stale-while-error)
- `GET /api/videos/:guid` - BunnyCDN video details - `GET /api/videos/:guid` - BunnyCDN video details
## File Structure ## File Structure
@ -57,7 +59,7 @@ A professional MSN-style news portal for Folx Music Television (news.folx.tv). D
- `client/src/lib/horoscope-data.ts` - Shared horoscope data (signs, texts, helpers) - `client/src/lib/horoscope-data.ts` - Shared horoscope data (signs, texts, helpers)
- `client/src/components/header.tsx` - Header with nav (Start, News, Video, Galerie, Horoskop, Rezepte) - `client/src/components/header.tsx` - Header with nav (Start, News, Video, Galerie, Horoskop, Rezepte)
- `client/src/components/footer.tsx` - Footer with links - `client/src/components/footer.tsx` - Footer with links
- `client/src/components/photo-gallery.tsx` - Gallery widget + lightbox carousel - `client/src/components/photo-gallery.tsx` - Gallery widget + lightbox carousel + paginated gallery page (24/batch infinite scroll) + artist name display
- `client/src/components/horoscope-widget.tsx` - Horoscope widget with element colors - `client/src/components/horoscope-widget.tsx` - Horoscope widget with element colors
- `client/src/components/recipe-widget.tsx` - Recipe widget with modal - `client/src/components/recipe-widget.tsx` - Recipe widget with modal
- `client/src/components/news-widget.tsx` - Google News RSS widget - `client/src/components/news-widget.tsx` - Google News RSS widget

View File

@ -1,7 +1,7 @@
{ {
"access_token": "sl.u.AGVAMk1isWjEfoERFxA5-4d0aQJgK5q3BvH4nQq7XKhUIV2q4YDpbYok3TQf7z2fF7ZBDZQJ2of2rnz4vzrfnkTrgRIirNckfsMkSfZvAXF8E2VK3fLrtXs5uy3g-A7CibB2I_TFehxQgiGJh77788vCDnGuwt-rYLSEm238-SS9OiDWUXNPvESABp2LHwJmKOnZg_Z6I9b6fy-BnppE_IOMPDkIhxy8T3a98-jgZGlR7SKEOPbAv5O9cQsmSi9hfh3pV53IV6lhfSpvPFXCxUCIUB990Cga1Cv70JlNSWpcB4pY_5mV-03ncRhxSBe-DjFAwY8DQskIsFyS5bcNloqk-nei3LxAXlk-PqTEpCMGcIjM5sVCazBPh1m3u169M5v_qV598nxc7S7uZcVT2jvT01bLIN9HqOvF3fpYeEY0v2ORQNMzr8AuZEGmpJbIkT0zpXSNatgVPzOtvmdTjoxfb0wjMjM1VEjGgX1v6UHVxLnyvSyvW7UZ2QNo4wmqOJQnq32l5sBFukHDImPDW3nKoATHJcn6dLLk0sjbl3-8UH9sZpezOxJIGmcla34DFbt2n_NymArdSA-m7ywGHqdR8Eqr-scWQi14WKcaHXnCQF_R_puhKiTdzcW4UFb5qmBfk78fDJtPhsrIagc31YYXx5co--qkTsdGDdhVKtE7fvDucn63uF79YNoZ087rD5P-gbDgSf8j90ijry4gbMsL0vxtWiMJ9nZxjT-k43ZDhmz_h5h0Fg6-1sAkRHq_cPQTjffn7rAWnb7zxOJueIMDWMTy2UXPERBuwuWa66QVqiyNpi_6URR2suyXZCK7xvqbHUy3fVP_k5xjZ84YcQXcBVUfZoxso1rlzsuTFEMrhZF_vUJqYBcQyw0VRNVWcPkz6_9ZcmGSsibS-112M8516EI-tl60id3Rd_KwH36nfqT0pkCUbkcAhreDzzU_ci0p3hB2WhfBDrsqolAW3Dc9S_pC9CuJn_dECjrngMbegARCXsychREPYx60h9Kg8ZyEmacrUklURVkXHT2y2HshrLxSbPL8CbHyXU4-3_8qquKrpTncLSxRuZCuPociVUxsNF4iyvUfSgi2ZjhuggJJteZvdfQ--aKt0s8aTdfThRQ6DyJTe9MoDlCUzW8byP7Z9JZ2AWFehqO1xKyWLD7wU6g7LV48G5n_S5kJUYpuPXmWDgpoj1-VehZGkVWEoQiW0deboadOT-MOvL1flV0gLU6nZv9D2F7jWWVTe26m1qC9iixHRyTmupMrL-P4tOEvTD41krapeFstuP61mzpl2FxrQge8P2NDfOY61olilQ", "access_token": "sl.u.AGX9zz5bOXeksP1HwtNLhE2wp5D_ZEeWQENGjBtrX4ULFQ6tdf4VS2VwWXzcrEeimhBSV04oLEuAnYz0ZkWBk5EYV46DApGDgOfdCNUN_6BMyXWretbipidUNVSogh_FfB-MebUT_jXXoTp5ZBoIx7vomeXRs_kawxNNsdF2LBGBM940EWyVAYi9dYaLIm0uzr-Hf1ucLnJ7dbiUd00ZxfmZ-sUwJJE1e0-Ga0m0WtSj4Yic7goNYwvhSu0TFp9sDZC7LeOBvE-Cs9-xw05_ZryLJe1VHdU0LkB8TJaYJ6K4SRlCa6jdARFm9bGDoDVO95EffW1c9wQsyH4vIQ9d57rKtKTHYXZU-rCz8ZdHfgl6lbxijfZBfxBSB0-Jr4nTR2OIV3yhqs88pAuarw2ug-uzqVdIdcYuygs_MYotBnasD2HvT6qpm0Ixrj3l9ApgYlyd9czfgGoG4eZ_LPK_0VVX_FuqHPC-pbImwrFDrjsMn_kbSynoaGXhFf2IB6vfhMTa3isuOol8oXIT_ScM3dzKMoCC6wzZEihwPhb2BCcl_jIO1YXcXuPU-dDqvSj1f6dx9os8ycH3m-dnKGLt9U-li64ybET_kXUjkdxZqrMJAQcvmSz1h1aGm7ObdELzgtifU3UD8z_R3OWg3KGY5wjBbxv1599NwqxwyD3uW77JRiiuGZ95cTRZQ9gRSaVSeir01D1_yz7CV7DpZvKmgiKAOGuNcfOXLnaJiGubK-797Qz4zblBxFeGjskN85MsxM5_oiRylNpqR0lI29XwQjRKbPl6n7kL7ibrrV9z2xnDyXxhY7wLpQKXUr7Sfja3QOUjBaenYILksKp_W1SMPtOER8hdDOsoKOXuQs4pC30zu0KmI7Aqss9X-jrA13QSy5vhBvxNlLVq2WK9VeSJZWb85qoMATFcenM0UbCa4Of34pF0fMPdGMdX98akKuUSLOy_2vPBuFHNFrARe3aDMYN_PXR1lDlcFjXXriMQT-xHF93MRav-A05TUXlxFpSeNkkc0Fu04RqLqPK9eZrjEj4ddnVRJxbGimlzeHcSNRAgN2vjWnOaIyB8swP5AimCvcQZznwNNyUv9lSED2-1qW-ueyykL8HGvg9oWjoqb9Lr6CiQvNnM0OMPS1V5LDEbRGKm3iOC4Ldcl2zxT8zJk7GBQJmW-OPz6-kKT_6et70dxeYRL71-fBL0ECOzefp1yFjNB7zDQ_n1SssAg7C7WIU7ftpZg6d3nyhOmewNGpKkCldL5B858vkiGrJQLd2mXOzrT3BLFss0rgl46w9EYGmbz0r6Nw0b7gzyYxKVE2uB5Q",
"refresh_token": "8-fiK0Io8Z8AAAAAAAAAAXn1QIGTwFTVWKF47COXY2bjqYlWyd4aRnFtQJ7usZ0y", "refresh_token": "8-fiK0Io8Z8AAAAAAAAAAXn1QIGTwFTVWKF47COXY2bjqYlWyd4aRnFtQJ7usZ0y",
"expires_at": 1772403823104, "expires_at": 1772474222442,
"app_key": "sjwprgka82p8tpv", "app_key": "sjwprgka82p8tpv",
"app_secret": "g3vuczqo0rx3crz" "app_secret": "g3vuczqo0rx3crz"
} }

View File

@ -18,6 +18,17 @@ interface GalleryImage {
thumb: string; thumb: string;
large: string; large: string;
full: string; full: string;
artist: string;
}
function extractArtistFromFileName(fileName: string): string {
if (/^DSC\d/i.test(fileName) || /^IMG[\s_]\d/i.test(fileName)) return "";
const withoutExt = fileName.replace(/\.\w+$/, "");
const cleaned = withoutExt
.replace(/\s*\(\d+\)\s*$/, "")
.replace(/\s*\d+\s*$/, "")
.replace(/\s*\(\d+\)\s*$/, "");
return cleaned.trim();
} }
function getAppKey(): string { function getAppKey(): string {
@ -237,6 +248,7 @@ export async function fetchGalleryFromDropbox(): Promise<GalleryImage[]> {
thumb: link, thumb: link,
large: link, large: link,
full: link, full: link,
artist: extractArtistFromFileName(file.name),
}); });
} }
} }

File diff suppressed because it is too large Load Diff

View File

@ -12,6 +12,20 @@ import path from "path";
import fs from "fs"; import fs from "fs";
import https from "https"; import https from "https";
const apiCache = new Map<string, { data: any; timestamp: number }>();
function getCached<T>(key: string, ttlMs: number): T | null {
const entry = apiCache.get(key);
if (entry && Date.now() - entry.timestamp < ttlMs) return entry.data as T;
return null;
}
function setCache(key: string, data: any) {
apiCache.set(key, { data, timestamp: Date.now() });
}
function getStale<T>(key: string): T | null {
const entry = apiCache.get(key);
return entry ? (entry.data as T) : null;
}
const uploadDir = path.join(process.cwd(), "client/public/uploads"); const uploadDir = path.join(process.cwd(), "client/public/uploads");
if (!fs.existsSync(uploadDir)) { if (!fs.existsSync(uploadDir)) {
fs.mkdirSync(uploadDir, { recursive: true }); fs.mkdirSync(uploadDir, { recursive: true });
@ -159,6 +173,10 @@ export async function registerRoutes(
const page = parseInt(req.query.page as string) || 1; const page = parseInt(req.query.page as string) || 1;
const perPage = parseInt(req.query.perPage as string) || 20; const perPage = parseInt(req.query.perPage as string) || 20;
const search = req.query.search as string || ""; const search = req.query.search as string || "";
const cacheKey = `videos_${page}_${perPage}_${search}`;
const cached = getCached<any>(cacheKey, 30 * 60 * 1000);
if (cached) return res.json(cached);
let path = `/library/${LIBRARY_ID}/videos?page=${page}&itemsPerPage=${perPage}&orderBy=date`; let path = `/library/${LIBRARY_ID}/videos?page=${page}&itemsPerPage=${perPage}&orderBy=date`;
if (search) path += `&search=${encodeURIComponent(search)}`; if (search) path += `&search=${encodeURIComponent(search)}`;
const data = await bunnyFetch(path); const data = await bunnyFetch(path);
@ -177,8 +195,12 @@ export async function registerRoutes(
category: v.category || "", category: v.category || "",
}; };
}); });
res.json({ items: videos, totalItems: data.totalItems, currentPage: data.currentPage }); const result = { items: videos, totalItems: data.totalItems, currentPage: data.currentPage };
setCache(cacheKey, result);
res.json(result);
} catch (err: any) { } catch (err: any) {
const stale = getStale<any>(`videos_${req.query.page || 1}_${req.query.perPage || 20}_${req.query.search || ""}`);
if (stale) return res.json(stale);
res.status(500).json({ message: err.message }); res.status(500).json({ message: err.message });
} }
}); });
@ -342,98 +364,75 @@ export async function registerRoutes(
} }
}); });
// News feed - Volksmusik/Schlager news from Google News RSS function parseRssItems(xml: string): { title: string; link: string; source: string; pubDate: string }[] {
const items: { title: string; link: string; source: string; pubDate: string }[] = [];
const itemRegex = /<item>([\s\S]*?)<\/item>/g;
let match;
while ((match = itemRegex.exec(xml)) !== null && items.length < 10) {
const block = match[1];
const title = block.match(/<title>(.*?)<\/title>/)?.[1]?.replace(/<!\[CDATA\[|\]\]>/g, "") || "";
const link = block.match(/<link>(.*?)<\/link>/)?.[1] || "";
const source = block.match(/<source[^>]*>(.*?)<\/source>/)?.[1]?.replace(/<!\[CDATA\[|\]\]>/g, "") || "";
const pubDateRaw = block.match(/<pubDate>(.*?)<\/pubDate>/)?.[1] || "";
let pubDate = "";
try {
const d = new Date(pubDateRaw);
const diffH = Math.floor((Date.now() - d.getTime()) / 3600000);
if (diffH < 1) pubDate = "Gerade eben";
else if (diffH < 24) pubDate = `vor ${diffH} Std.`;
else pubDate = `vor ${Math.floor(diffH / 24)} T.`;
} catch { pubDate = ""; }
if (title && link) items.push({ title, link, source, pubDate });
}
return items;
}
async function fetchRssFeed(rssUrl: string): Promise<string> {
const resp = await fetch(rssUrl, {
headers: { "User-Agent": "Mozilla/5.0 (compatible; FolxTV/1.0)" },
redirect: "follow",
});
if (!resp.ok) throw new Error(`RSS fetch failed: ${resp.status}`);
return await resp.text();
}
app.get("/api/news-feed", async (_req, res) => { app.get("/api/news-feed", async (_req, res) => {
const cacheKey = "news-feed";
const cached = getCached<any[]>(cacheKey, 15 * 60 * 1000);
if (cached) return res.json(cached);
try { try {
const topics = ["Volksmusik", "Schlager+Musik", "Oberkrainer"]; const topics = ["Volksmusik", "Schlager+Musik", "Oberkrainer"];
const topic = topics[Math.floor(Date.now() / 3600000) % topics.length]; const topic = topics[Math.floor(Date.now() / 3600000) % topics.length];
const rssUrl = `https://news.google.com/rss/search?q=${topic}&hl=de&gl=DE&ceid=DE:de`; const rssUrl = `https://news.google.com/rss/search?q=${topic}&hl=de&gl=DE&ceid=DE:de`;
const xml = await fetchRssFeed(rssUrl);
const response = await new Promise<string>((resolve, reject) => { const items = parseRssItems(xml);
https.get(rssUrl, (resp) => { if (items.length > 0) setCache(cacheKey, items);
let data = "";
resp.on("data", (chunk: Buffer) => (data += chunk.toString()));
resp.on("end", () => resolve(data));
resp.on("error", reject);
}).on("error", reject);
});
const items: { title: string; link: string; source: string; pubDate: string }[] = [];
const itemRegex = /<item>([\s\S]*?)<\/item>/g;
let match;
while ((match = itemRegex.exec(response)) !== null && items.length < 10) {
const block = match[1];
const title = block.match(/<title>(.*?)<\/title>/)?.[1]?.replace(/<!\[CDATA\[|\]\]>/g, "") || "";
const link = block.match(/<link>(.*?)<\/link>/)?.[1] || "";
const source = block.match(/<source[^>]*>(.*?)<\/source>/)?.[1]?.replace(/<!\[CDATA\[|\]\]>/g, "") || "";
const pubDateRaw = block.match(/<pubDate>(.*?)<\/pubDate>/)?.[1] || "";
let pubDate = "";
try {
const d = new Date(pubDateRaw);
const diffH = Math.floor((Date.now() - d.getTime()) / 3600000);
if (diffH < 1) pubDate = "Gerade eben";
else if (diffH < 24) pubDate = `vor ${diffH} Std.`;
else pubDate = `vor ${Math.floor(diffH / 24)} T.`;
} catch {
pubDate = "";
}
if (title && link) {
items.push({ title, link, source, pubDate });
}
}
res.json(items); res.json(items);
} catch (err: any) { } catch (err: any) {
res.json([]); console.log("[news-feed] RSS fetch error:", err.message);
const stale = getStale<any[]>(cacheKey);
res.json(stale || []);
} }
}); });
app.get("/api/breaking-news", async (_req, res) => { app.get("/api/breaking-news", async (_req, res) => {
const cacheKey = "breaking-news";
const cached = getCached<any[]>(cacheKey, 15 * 60 * 1000);
if (cached) return res.json(cached);
try { try {
const topics = ["Nachrichten+Deutschland", "Nachrichten+Oesterreich", "Nachrichten+Europa", "Wirtschaft+aktuell", "Sport+aktuell"]; const topics = ["Nachrichten+Deutschland", "Nachrichten+Oesterreich", "Nachrichten+Europa", "Wirtschaft+aktuell", "Sport+aktuell"];
const topic = topics[Math.floor(Date.now() / 3600000) % topics.length]; const topic = topics[Math.floor(Date.now() / 3600000) % topics.length];
const rssUrl = `https://news.google.com/rss/search?q=${topic}&hl=de&gl=DE&ceid=DE:de`; const rssUrl = `https://news.google.com/rss/search?q=${topic}&hl=de&gl=DE&ceid=DE:de`;
const xml = await fetchRssFeed(rssUrl);
const response = await new Promise<string>((resolve, reject) => { const items = parseRssItems(xml);
https.get(rssUrl, (resp) => { if (items.length > 0) setCache(cacheKey, items);
let data = "";
resp.on("data", (chunk: Buffer) => (data += chunk.toString()));
resp.on("end", () => resolve(data));
resp.on("error", reject);
}).on("error", reject);
});
const items: { title: string; link: string; source: string; pubDate: string }[] = [];
const itemRegex = /<item>([\s\S]*?)<\/item>/g;
let match;
while ((match = itemRegex.exec(response)) !== null && items.length < 10) {
const block = match[1];
const title = block.match(/<title>(.*?)<\/title>/)?.[1]?.replace(/<!\[CDATA\[|\]\]>/g, "") || "";
const link = block.match(/<link>(.*?)<\/link>/)?.[1] || "";
const source = block.match(/<source[^>]*>(.*?)<\/source>/)?.[1]?.replace(/<!\[CDATA\[|\]\]>/g, "") || "";
const pubDateRaw = block.match(/<pubDate>(.*?)<\/pubDate>/)?.[1] || "";
let pubDate = "";
try {
const d = new Date(pubDateRaw);
const diffH = Math.floor((Date.now() - d.getTime()) / 3600000);
if (diffH < 1) pubDate = "Gerade eben";
else if (diffH < 24) pubDate = `vor ${diffH} Std.`;
else pubDate = `vor ${Math.floor(diffH / 24)} T.`;
} catch {
pubDate = "";
}
if (title && link) {
items.push({ title, link, source, pubDate });
}
}
res.json(items); res.json(items);
} catch (err: any) { } catch (err: any) {
res.json([]); console.log("[breaking-news] RSS fetch error:", err.message);
const stale = getStale<any[]>(cacheKey);
res.json(stale || []);
} }
}); });