From 9d8d29208a08dcb2bd06eff5dd2ffd19cd5fafd4 Mon Sep 17 00:00:00 2001 From: sebastjanartic <45803536-sebastjanartic@users.noreply.replit.com> Date: Fri, 6 Mar 2026 10:48:43 +0000 Subject: [PATCH] Add an admin page for managing gallery images and artist names Introduce a new admin route at `/admin/gallery` enabling users to set focal points and artist names for gallery images, with corresponding API endpoints and data management. Replit-Commit-Author: Agent Replit-Commit-Session-Id: 23852c00-4779-460a-9e0c-d09fee4b6c92 Replit-Commit-Checkpoint-Type: full_checkpoint Replit-Commit-Event-Id: 70b6b9df-7da9-46f3-afd7-dbcd9aac1855 Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/f209e72a-0939-48fa-84fc-57854de71967/23852c00-4779-460a-9e0c-d09fee4b6c92/ncMMRQ9 Replit-Helium-Checkpoint-Created: true --- client/src/App.tsx | 2 + client/src/components/photo-gallery.tsx | 47 +-- client/src/pages/admin-gallery.tsx | 367 ++++++++++++++++++++++++ replit.md | 18 +- server/gallery-artist-overrides.json | 1 + server/gallery-focal-points.json | 6 + server/routes.ts | 98 +++++++ 7 files changed, 514 insertions(+), 25 deletions(-) create mode 100644 client/src/pages/admin-gallery.tsx create mode 100644 server/gallery-artist-overrides.json create mode 100644 server/gallery-focal-points.json diff --git a/client/src/App.tsx b/client/src/App.tsx index 277c643..f4083b7 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -16,6 +16,7 @@ import EmpfangPage from "@/pages/empfang"; import AboutPage from "@/pages/about"; import ImpressumPage from "@/pages/impressum"; import DatenschutzPage from "@/pages/datenschutz"; +import AdminGalleryPage from "@/pages/admin-gallery"; import CookieConsent from "@/components/cookie-consent"; function Router() { @@ -34,6 +35,7 @@ function Router() { + ); diff --git a/client/src/components/photo-gallery.tsx b/client/src/components/photo-gallery.tsx index 73e2429..f5c6580 100644 --- a/client/src/components/photo-gallery.tsx +++ b/client/src/components/photo-gallery.tsx @@ -2,34 +2,24 @@ import { useQuery } from "@tanstack/react-query"; import { useState, useEffect, useCallback, useRef } from "react"; import { ChevronLeft, ChevronRight, X, Images, Maximize2 } from "lucide-react"; -interface GalleryImage { +export interface GalleryImage { folder: string; fileName: string; thumb: string; large: string; mobile?: string; + full?: string; artist?: string; } +export type FocalPointMap = Record; + const PAGE_SIZE = 24; -function useIsMobile() { - const [isMobile, setIsMobile] = useState(window.innerWidth < 768); - useEffect(() => { - const handler = () => setIsMobile(window.innerWidth < 768); - window.addEventListener("resize", handler); - return () => window.removeEventListener("resize", handler); - }, []); - return isMobile; -} - -function cloudinaryTransform(src: string, transform: string) { - if (!src.includes("res.cloudinary.com")) return src; - return src.replace(/\/upload\/[^/]+\//, `/upload/${transform}/`); -} - -function thumbUrl(src: string) { - return src; +export function getObjectPosition(fileName: string, focalPoints: FocalPointMap, fallback = "center 15%"): string { + const fp = focalPoints[fileName]; + if (fp) return `${fp.x}% ${fp.y}%`; + return fallback; } function LazyImage({ src, alt, className, onClick, objectPosition }: { src: string; alt: string; className?: string; onClick?: () => void; objectPosition?: string }) { @@ -72,10 +62,12 @@ function Lightbox({ images, startIndex, onClose, + focalPoints, }: { images: GalleryImage[]; startIndex: number; onClose: () => void; + focalPoints: FocalPointMap; }) { const [index, setIndex] = useState(startIndex); @@ -148,11 +140,13 @@ function SingleImageCarousel({ autoPlay = true, interval = 5000, onExpand, + focalPoints, }: { images: GalleryImage[]; autoPlay?: boolean; interval?: number; onExpand?: (index: number) => void; + focalPoints: FocalPointMap; }) { const [index, setIndex] = useState(0); const [paused, setPaused] = useState(false); @@ -181,7 +175,7 @@ function SingleImageCarousel({ src={current.large || current.thumb} alt={current.artist || current.fileName} className="absolute inset-0 w-full h-full object-cover transition-opacity duration-500" - style={{ objectPosition: "center 35%" }} + style={{ objectPosition: getObjectPosition(current.fileName, focalPoints, "center 35%") }} loading="lazy" data-testid="img-gallery-current" /> @@ -245,6 +239,9 @@ export function PhotoGalleryWidget({ reverseOrder = false }: { reverseOrder?: bo queryKey: ["/api/gallery", "widget"], queryFn: () => fetch("/api/gallery?limit=30").then((r) => r.json()), }); + const { data: focalPoints } = useQuery({ + queryKey: ["/api/gallery/focal-points"], + }); const images = reverseOrder && rawImages ? [...rawImages].reverse() : rawImages; const [lightboxIndex, setLightboxIndex] = useState(null); @@ -275,6 +272,7 @@ export function PhotoGalleryWidget({ reverseOrder = false }: { reverseOrder?: bo autoPlay={true} interval={10000} onExpand={(i) => setLightboxIndex(i)} + focalPoints={focalPoints || {}} /> @@ -283,6 +281,7 @@ export function PhotoGalleryWidget({ reverseOrder = false }: { reverseOrder?: bo images={images} startIndex={lightboxIndex} onClose={() => setLightboxIndex(null)} + focalPoints={focalPoints || {}} /> )} @@ -290,15 +289,18 @@ export function PhotoGalleryWidget({ reverseOrder = false }: { reverseOrder?: bo } export default function GalleryPage() { - const isMobile = useIsMobile(); const { data: images, isLoading } = useQuery({ queryKey: ["/api/gallery"], }); + const { data: focalPoints } = useQuery({ + queryKey: ["/api/gallery/focal-points"], + }); const [lightboxIndex, setLightboxIndex] = useState(null); const [visibleCount, setVisibleCount] = useState(PAGE_SIZE); const sentinelRef = useRef(null); + const fp = focalPoints || {}; const totalCount = images?.length || 0; const visibleImages = images?.slice(0, visibleCount) || []; const hasMore = visibleCount < totalCount; @@ -341,10 +343,10 @@ export default function GalleryPage() { >
{img.artist && ( @@ -387,6 +389,7 @@ export default function GalleryPage() { images={images} startIndex={lightboxIndex} onClose={() => setLightboxIndex(null)} + focalPoints={fp} /> )}
diff --git a/client/src/pages/admin-gallery.tsx b/client/src/pages/admin-gallery.tsx new file mode 100644 index 0000000..278846d --- /dev/null +++ b/client/src/pages/admin-gallery.tsx @@ -0,0 +1,367 @@ +import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { useState, useRef } from "react"; +import { Link } from "wouter"; +import { ArrowLeft, Crosshair, X, Check, RotateCcw, User, Search, Filter } from "lucide-react"; +import { apiRequest } from "@/lib/queryClient"; +import type { GalleryImage, FocalPointMap } from "@/components/photo-gallery"; +import { getObjectPosition } from "@/components/photo-gallery"; + +type ArtistOverrides = Record; + +function FocalPointEditor({ + image, + currentFocalPoint, + currentArtist, + onSaveFocal, + onResetFocal, + onSaveArtist, + onClose, +}: { + image: GalleryImage; + currentFocalPoint?: { x: number; y: number }; + currentArtist: string; + onSaveFocal: (x: number, y: number) => Promise; + onResetFocal: () => Promise; + onSaveArtist: (artist: string) => Promise; + onClose: () => void; +}) { + const [point, setPoint] = useState<{ x: number; y: number } | null>(currentFocalPoint || null); + const [artistName, setArtistName] = useState(currentArtist); + const [saving, setSaving] = useState(false); + const imgRef = useRef(null); + + const handleClick = (e: React.MouseEvent) => { + const img = imgRef.current; + if (!img) return; + const rect = img.getBoundingClientRect(); + const x = Math.round(((e.clientX - rect.left) / rect.width) * 100); + const y = Math.round(((e.clientY - rect.top) / rect.height) * 100); + setPoint({ x: Math.max(0, Math.min(100, x)), y: Math.max(0, Math.min(100, y)) }); + }; + + const handleSaveAll = async () => { + setSaving(true); + try { + if (point) { + await onSaveFocal(point.x, point.y); + } + if (artistName !== currentArtist) { + await onSaveArtist(artistName); + } + } finally { + setSaving(false); + } + onClose(); + }; + + const handleResetFocal = async () => { + setSaving(true); + await onResetFocal(); + setPoint(null); + setSaving(false); + }; + + return ( +
+
+
+ + + {image.fileName} + +
+ +
+ +
+
+ + setArtistName(e.target.value)} + placeholder="Interpret / Künstlername" + className="flex-1 bg-white/10 border border-white/20 rounded px-3 py-2 text-white text-sm placeholder:text-white/30 focus:outline-none focus:border-yellow-400/60" + data-testid="input-artist-name" + /> +
+ +

+ Klicken Sie auf das Bild, um den Fokuspunkt zu setzen +

+ +
+ {image.artist + {point && ( +
+
+
+
+
+
+
+ )} +
+ +
+
+ 16:9 + 16:9 +
+
+ 9:16 + 9:16 +
+
+ 1:1 + 1:1 +
+
+ + {point && ( +

Fokus: {point.x}% / {point.y}%

+ )} + +
+ + +
+
+
+ ); +} + +export default function AdminGalleryPage() { + const queryClient = useQueryClient(); + const { data: images, isLoading } = useQuery({ + queryKey: ["/api/gallery"], + }); + const { data: focalPoints } = useQuery({ + queryKey: ["/api/gallery/focal-points"], + }); + const { data: artistOverrides } = useQuery({ + queryKey: ["/api/gallery/artists"], + }); + + const [editingImage, setEditingImage] = useState(null); + const [searchQuery, setSearchQuery] = useState(""); + const [filterMode, setFilterMode] = useState<"all" | "no-artist" | "no-focal" | "has-focal">("all"); + + const fp = focalPoints || {}; + const ao = artistOverrides || {}; + + const filteredImages = (images || []).filter((img) => { + if (searchQuery) { + const q = searchQuery.toLowerCase(); + const matches = img.fileName.toLowerCase().includes(q) || (img.artist || "").toLowerCase().includes(q); + if (!matches) return false; + } + if (filterMode === "no-artist") return !img.artist; + if (filterMode === "no-focal") return !fp[img.fileName]; + if (filterMode === "has-focal") return !!fp[img.fileName]; + return true; + }); + + const handleSaveFocalPoint = async (x: number, y: number) => { + if (!editingImage) return; + await apiRequest("PUT", `/api/gallery/focal-points/${encodeURIComponent(editingImage.fileName)}`, { x, y }); + queryClient.invalidateQueries({ queryKey: ["/api/gallery/focal-points"] }); + }; + + const handleResetFocalPoint = async () => { + if (!editingImage) return; + await apiRequest("DELETE", `/api/gallery/focal-points/${encodeURIComponent(editingImage.fileName)}`); + queryClient.invalidateQueries({ queryKey: ["/api/gallery/focal-points"] }); + }; + + const handleSaveArtist = async (artist: string) => { + if (!editingImage) return; + await apiRequest("PUT", `/api/gallery/artists/${encodeURIComponent(editingImage.fileName)}`, { artist }); + queryClient.invalidateQueries({ queryKey: ["/api/gallery/artists"] }); + queryClient.invalidateQueries({ queryKey: ["/api/gallery"] }); + }; + + const totalImages = images?.length || 0; + const withFocal = Object.keys(fp).length; + const withoutArtist = (images || []).filter((img) => !img.artist).length; + + return ( +
+
+
+
+ + + +
+

Galerie Admin

+
+
+ {totalImages} Bilder + {withFocal} mit Fokus + 0 ? "text-yellow-400" : ""}>{withoutArtist} ohne Name +
+
+
+ +
+
+
+ + setSearchQuery(e.target.value)} + placeholder="Suche nach Dateiname oder Künstler..." + className="w-full pl-9 pr-3 py-2 bg-white/5 border border-white/10 rounded-lg text-sm text-white placeholder:text-white/30 focus:outline-none focus:border-yellow-400/50" + data-testid="input-admin-search" + /> +
+
+ + {(["all", "no-artist", "no-focal", "has-focal"] as const).map((mode) => ( + + ))} +
+
+ + {isLoading ? ( +
+ {Array.from({ length: 20 }).map((_, i) => ( +
+ ))} +
+ ) : ( + <> +

{filteredImages.length} Ergebnisse

+
+ {filteredImages.map((img) => ( + + ))} +
+ + )} +
+ + {editingImage && ( + setEditingImage(null)} + /> + )} +
+ ); +} diff --git a/replit.md b/replit.md index bbd030d..4e77324 100644 --- a/replit.md +++ b/replit.md @@ -35,7 +35,13 @@ The official website for Folx Music Television (folx.tv). Dark-themed bento grid - `PATCH /api/articles/:id` - Update article - `DELETE /api/articles/:id` - Delete article - `POST /api/upload` - Upload image file -- `GET /api/gallery` - Shuffled Dropbox gallery images (with artist names from filenames) +- `GET /api/gallery` - Shuffled gallery images from Cloudinary (with artist names from filenames + overrides) +- `GET /api/gallery/focal-points` - Gallery image focal points (JSON) +- `PUT /api/gallery/focal-points/:fileName` - Set focal point for gallery image +- `DELETE /api/gallery/focal-points/:fileName` - Reset focal point for gallery image +- `GET /api/gallery/artists` - Artist name overrides (JSON) +- `PUT /api/gallery/artists/:fileName` - Set/update artist name override +- `POST /api/gallery/migrate-to-cloudinary` - Migrate remaining Dropbox images to Cloudinary - `GET /api/gallery/thumb` - Proxy endpoint for Dropbox thumbnail resizing (sharp, 400x400, 30-min cache) - `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) @@ -48,7 +54,11 @@ The official website for Folx Music Television (folx.tv). Dark-themed bento grid - `server/storage.ts` - Storage interface + DatabaseStorage - `server/routes.ts` - API routes + gallery + news feed - `server/seed.ts` - Hardcoded seed data (articles) -- `server/gallery-data.json` - 547 Dropbox gallery images +- `server/gallery-data.json` - Fallback gallery data (used when Dropbox/Cloudinary unavailable) +- `server/cloudinary-gallery-map.json` - Cloudinary public ID map for 176 migrated images +- `server/gallery-focal-points.json` - Manual focal point overrides for gallery images +- `server/gallery-artist-overrides.json` - Manual artist name overrides for gallery images +- `server/cloudinary.ts` - Cloudinary upload, URL generation, compression logic - `client/src/pages/home.tsx` - MSN-style bento grid homepage - `client/src/pages/article.tsx` - Article detail page - `client/src/pages/category.tsx` - Category listing page @@ -60,6 +70,7 @@ The official website for Folx Music Television (folx.tv). Dark-themed bento grid - `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/photo-gallery.tsx` - Gallery widget + lightbox carousel + paginated gallery page (24/batch infinite scroll) + artist name display +- `client/src/pages/admin-gallery.tsx` - Admin page for gallery management (focal points + artist names) at /admin/gallery - `client/src/components/horoscope-widget.tsx` - Horoscope widget with element colors - `client/src/components/recipe-widget.tsx` - Recipe widget with modal - `client/src/components/news-widget.tsx` - Google News RSS widget @@ -82,7 +93,8 @@ The official website for Folx Music Television (folx.tv). Dark-themed bento grid ## External Services - Bunny.net: Library 476412, CDN vz-7982dfc4-cc8.b-cdn.net (NO autoplay) - Google AdSense: ca-pub-4465464714854276 -- Dropbox: Gallery image thumbnails (547 images from 16 folders) +- Cloudinary: Gallery images (cloud_name: djqxt0pf3, 176 images migrated from Dropbox, face-aware cropping) +- Dropbox: Original gallery source (fallback if Cloudinary map empty) - Google News RSS: Volksmusik/Schlager news feed ## Publishing Workflow diff --git a/server/gallery-artist-overrides.json b/server/gallery-artist-overrides.json new file mode 100644 index 0000000..0967ef4 --- /dev/null +++ b/server/gallery-artist-overrides.json @@ -0,0 +1 @@ +{} diff --git a/server/gallery-focal-points.json b/server/gallery-focal-points.json new file mode 100644 index 0000000..180cb17 --- /dev/null +++ b/server/gallery-focal-points.json @@ -0,0 +1,6 @@ +{ + "DSC07135.jpg": { + "x": 50, + "y": 30 + } +} \ No newline at end of file diff --git a/server/routes.ts b/server/routes.ts index 1085912..449ed92 100644 --- a/server/routes.ts +++ b/server/routes.ts @@ -362,6 +362,92 @@ export async function registerRoutes( } }); + app.get("/api/gallery/focal-points", (_req, res) => { + try { + const fpPath = path.join(process.cwd(), "server/gallery-focal-points.json"); + if (fs.existsSync(fpPath)) { + res.json(JSON.parse(fs.readFileSync(fpPath, "utf-8"))); + } else { + res.json({}); + } + } catch { + res.json({}); + } + }); + + app.put("/api/gallery/focal-points/:fileName", (req, res) => { + try { + const { fileName } = req.params; + const { x, y } = req.body; + if (typeof x !== "number" || typeof y !== "number") { + return res.status(400).json({ message: "x and y must be numbers (0-100)" }); + } + const fpPath = path.join(process.cwd(), "server/gallery-focal-points.json"); + let data: Record = {}; + if (fs.existsSync(fpPath)) { + data = JSON.parse(fs.readFileSync(fpPath, "utf-8")); + } + data[fileName] = { x: Math.round(x), y: Math.round(y) }; + fs.writeFileSync(fpPath, JSON.stringify(data, null, 2)); + res.json({ ok: true, fileName, x: data[fileName].x, y: data[fileName].y }); + } catch (err: any) { + res.status(500).json({ message: err.message }); + } + }); + + app.delete("/api/gallery/focal-points/:fileName", (req, res) => { + try { + const { fileName } = req.params; + const fpPath = path.join(process.cwd(), "server/gallery-focal-points.json"); + let data: Record = {}; + if (fs.existsSync(fpPath)) { + data = JSON.parse(fs.readFileSync(fpPath, "utf-8")); + } + delete data[fileName]; + fs.writeFileSync(fpPath, JSON.stringify(data, null, 2)); + res.json({ ok: true }); + } catch (err: any) { + res.status(500).json({ message: err.message }); + } + }); + + app.get("/api/gallery/artists", (_req, res) => { + try { + const aPath = path.join(process.cwd(), "server/gallery-artist-overrides.json"); + if (fs.existsSync(aPath)) { + res.json(JSON.parse(fs.readFileSync(aPath, "utf-8"))); + } else { + res.json({}); + } + } catch { + res.json({}); + } + }); + + app.put("/api/gallery/artists/:fileName", (req, res) => { + try { + const { fileName } = req.params; + const { artist } = req.body; + if (typeof artist !== "string") { + return res.status(400).json({ message: "artist must be a string" }); + } + const aPath = path.join(process.cwd(), "server/gallery-artist-overrides.json"); + let data: Record = {}; + if (fs.existsSync(aPath)) { + data = JSON.parse(fs.readFileSync(aPath, "utf-8")); + } + if (artist.trim() === "") { + delete data[fileName]; + } else { + data[fileName] = artist.trim(); + } + fs.writeFileSync(aPath, JSON.stringify(data, null, 2)); + res.json({ ok: true, fileName, artist: artist.trim() }); + } catch (err: any) { + res.status(500).json({ message: err.message }); + } + }); + // Gallery API - serves optimized photos from Dropbox/Cloudinary app.get("/api/gallery", async (req, res) => { try { @@ -376,6 +462,18 @@ export async function registerRoutes( data = JSON.parse(fs.readFileSync(galleryPath, "utf-8")); } + const aPath = path.join(process.cwd(), "server/gallery-artist-overrides.json"); + let artistOverrides: Record = {}; + if (fs.existsSync(aPath)) { + artistOverrides = JSON.parse(fs.readFileSync(aPath, "utf-8")); + } + data = data.map((img: any) => { + if (artistOverrides[img.fileName]) { + return { ...img, artist: artistOverrides[img.fileName] }; + } + return img; + }); + const shuffled = [...data]; for (let i = shuffled.length - 1; i > 0; i--) { const j = Math.floor(Math.random() * (i + 1));