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, Cloud, HardDrive, Image, Trash2, ZoomIn, ZoomOut, MousePointer } from "lucide-react"; import { apiRequest } from "@/lib/queryClient"; import type { FocalPointMap } from "@/components/photo-gallery"; import { getObjectPosition } from "@/components/photo-gallery"; interface CloudinaryImage { publicId: string; fileName: string; artist: string; artistOverridden: boolean; width: number; height: number; format: string; bytes: number; createdAt: string; thumb: string; large: string; mobile: string; full: string; } function formatBytes(bytes: number): string { if (bytes < 1024) return bytes + " B"; if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + " KB"; return (bytes / (1024 * 1024)).toFixed(1) + " MB"; } function RatioPreview({ image, point, ratio, label, width, height, active, onClickPreview, onSelect, }: { image: CloudinaryImage; point: { x: number; y: number } | null; ratio: string; label: string; width: string; height: string; active: boolean; onClickPreview: (e: React.MouseEvent) => void; onSelect: () => void; }) { return (
{label} {point && (
)} {active && (
)}
); } function FocalPointEditor({ image, currentFocalPoint, currentArtist, allArtistNames, onSaveFocal, onResetFocal, onSaveArtist, onDelete, onClose, }: { image: CloudinaryImage; currentFocalPoint?: { x: number; y: number }; currentArtist: string; allArtistNames: string[]; onSaveFocal: (x: number, y: number) => Promise; onResetFocal: () => Promise; onSaveArtist: (artist: string) => Promise; onDelete: () => 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 [showDeleteConfirm, setShowDeleteConfirm] = useState(false); const [deleting, setDeleting] = useState(false); const [showSuggestions, setShowSuggestions] = useState(false); const [zoom, setZoom] = useState(1); const [activeRatio, setActiveRatio] = useState<"main" | "16:9" | "9:16" | "1:1">("main"); const imgRef = useRef(null); const suggestionsRef = useRef(null); const mainContainerRef = useRef(null); const filteredSuggestions = artistName.length >= 1 ? allArtistNames.filter( (name) => name.toLowerCase().includes(artistName.toLowerCase()) && name.toLowerCase() !== artistName.toLowerCase() ).slice(0, 8) : []; const handleMainClick = (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 handlePreviewClick = (e: React.MouseEvent) => { const rect = e.currentTarget.getBoundingClientRect(); const contW = rect.width; const contH = rect.height; const cx = e.clientX - rect.left; const cy = e.clientY - rect.top; const imgW = image.width; const imgH = image.height; const px = (point?.x ?? 50) / 100; const py = (point?.y ?? 15) / 100; const scale = Math.max(contW / imgW, contH / imgH); const scaledW = imgW * scale; const scaledH = imgH * scale; const overflowX = scaledW - contW; const overflowY = scaledH - contH; const origX = (cx + overflowX * px) / scaledW * 100; const origY = (cy + overflowY * py) / scaledH * 100; setPoint({ x: Math.round(Math.max(0, Math.min(100, origX))), y: Math.round(Math.max(0, Math.min(100, origY))), }); }; const handleZoomIn = () => setZoom((z) => Math.min(z + 0.5, 4)); const handleZoomOut = () => setZoom((z) => Math.max(z - 0.5, 1)); const handleWheel = (e: React.WheelEvent) => { e.preventDefault(); if (e.deltaY < 0) handleZoomIn(); else handleZoomOut(); }; 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.artist || image.fileName} {image.publicId} · {image.width}x{image.height} · {formatBytes(image.bytes)}
{ setArtistName(e.target.value); setShowSuggestions(true); }} onFocus={() => setShowSuggestions(true)} onBlur={() => setTimeout(() => setShowSuggestions(false), 200)} placeholder="Interpret / Künstlername eingeben..." className="w-full 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" /> {showSuggestions && filteredSuggestions.length > 0 && (
{filteredSuggestions.map((name) => ( ))}
)}
{zoom.toFixed(1)}x
1 ? "20px" : "0" }}>
{image.artist {point && (
)}
Klick = Fokuspunkt · Scroll = Zoom
{point && (
{point.x}% / {point.y}%
)}
Vorschau — Klick auf Vorschau zum Feintuning
setActiveRatio("16:9")} />
setActiveRatio("9:16")} /> setActiveRatio("1:1")} />
{showDeleteConfirm && (

Bild löschen?

Aus Cloudinary entfernen

{image.artist || image.fileName}

{image.publicId}

Achtung: Diese Aktion kann nicht rückgängig gemacht werden!

)}
); } export default function AdminGalleryPage() { const queryClient = useQueryClient(); const { data: images, isLoading, error } = useQuery({ queryKey: ["/api/admin/cloudinary-gallery"], }); const { data: focalPoints } = useQuery({ queryKey: ["/api/gallery/focal-points"], }); const [editingImage, setEditingImage] = useState(null); const [searchQuery, setSearchQuery] = useState(""); const [filterMode, setFilterMode] = useState<"all" | "no-artist" | "no-focal" | "has-focal">("all"); const [sortMode, setSortMode] = useState<"name" | "size" | "date">("name"); const fp = focalPoints || {}; const sortedImages = [...(images || [])].sort((a, b) => { if (sortMode === "size") return b.bytes - a.bytes; if (sortMode === "date") return new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(); return a.fileName.localeCompare(b.fileName); }); const filteredImages = sortedImages.filter((img) => { if (searchQuery) { const q = searchQuery.toLowerCase(); const matches = img.fileName.toLowerCase().includes(q) || img.artist.toLowerCase().includes(q) || img.publicId.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"] }); queryClient.invalidateQueries({ queryKey: ["/api/admin/cloudinary-gallery"] }); }; const handleDeleteImage = async () => { if (!editingImage) return; await apiRequest("POST", `/api/admin/cloudinary-gallery/delete`, { publicId: editingImage.publicId }); queryClient.invalidateQueries({ queryKey: ["/api/admin/cloudinary-gallery"] }); queryClient.invalidateQueries({ queryKey: ["/api/gallery"] }); }; const totalImages = images?.length || 0; const totalSize = (images || []).reduce((sum, img) => sum + img.bytes, 0); const withFocal = Object.keys(fp).length; const withoutArtist = (images || []).filter((img) => !img.artist).length; const allArtistNames = [...new Set((images || []).map((img) => img.artist).filter(Boolean))].sort(); return (

Cloudinary Galerie

{totalImages} Bilder {formatBytes(totalSize)} {withFocal} mit Fokus 0 ? "text-yellow-400" : ""}`}> {withoutArtist} ohne Name
setSearchQuery(e.target.value)} placeholder="Suche nach Name, Künstler, Public ID..." 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-blue-400/50" data-testid="input-admin-search" />
{(["all", "no-artist", "no-focal", "has-focal"] as const).map((mode) => ( ))}
{(["name", "size", "date"] as const).map((s) => ( ))}
{error ? (

Cloudinary Verbindung fehlgeschlagen

{(error as Error).message}

) : isLoading ? (

Lade Bilder von Cloudinary...

) : ( <>

{filteredImages.length} Ergebnisse

{filteredImages.map((img) => ( ))}
)}
{editingImage && ( setEditingImage(null)} /> )}
); }