Email: Review-Link auf /review/:token umgestellt; Token-Erzeugung konsolidiert. Reviews: Client-Validation hinzugefügt. Verfügbarkeiten: Auto-Update nach Regelanlage. Galerie: Cover-Foto-Flag + Setzen im Admin, sofortige Aktualisierung nach Upload/Löschen/Reihenfolge-Änderung. Startseite: Featured-Foto = Reihenfolge 0, Seitenverhältnis beibehalten, Texte aktualisiert.

This commit is contained in:
2025-10-05 20:09:12 +02:00
parent 6d7e8eceba
commit 53aca01131
13 changed files with 1807 additions and 23 deletions

View File

@@ -0,0 +1,464 @@
import { useState, useEffect, useMemo } from "react";
import { useMutation, useQuery } from "@tanstack/react-query";
import { queryClient } from "@/client/rpc-client";
export function AdminGallery() {
// Component state
const [photoTitle, setPhotoTitle] = useState<string>("");
const [photoPreview, setPhotoPreview] = useState<string>("");
const [photoBase64, setPhotoBase64] = useState<string>("");
const [errorMsg, setErrorMsg] = useState<string>("");
const [successMsg, setSuccessMsg] = useState<string>("");
const [draggedPhotoId, setDraggedPhotoId] = useState<string | null>(null);
// Data fetching with live query
const { data: photos, refetch: refetchPhotos } = useQuery(
queryClient.gallery.live.adminListPhotos.experimental_liveOptions({
input: { sessionId: localStorage.getItem("sessionId") || "" }
})
);
// Memoized sorted photos to avoid re-sorting on every render and prevent cache mutation
const sortedPhotos = useMemo(() => [...(photos || [])].sort((a, b) => a.order - b.order), [photos]);
// Mutations
const { mutate: uploadPhoto, isPending: isUploading } = useMutation(
queryClient.gallery.uploadPhoto.mutationOptions()
);
const { mutate: deletePhoto } = useMutation(
queryClient.gallery.deletePhoto.mutationOptions()
);
const { mutate: updatePhotoOrder } = useMutation(
queryClient.gallery.updatePhotoOrder.mutationOptions()
);
const { mutate: setCoverPhoto } = useMutation(
queryClient.gallery.setCoverPhoto.mutationOptions()
);
// Auto-clear messages after 5 seconds
useEffect(() => {
if (errorMsg) {
const timer = setTimeout(() => setErrorMsg(""), 5000);
return () => clearTimeout(timer);
}
}, [errorMsg]);
useEffect(() => {
if (successMsg) {
const timer = setTimeout(() => setSuccessMsg(""), 5000);
return () => clearTimeout(timer);
}
}, [successMsg]);
// Image compression function (adapted from booking-form.tsx)
const compressImage = (file: File, maxWidth: number = 800, quality: number = 0.8): Promise<string> => {
return new Promise((resolve, reject) => {
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
const img = new Image();
img.onload = () => {
// Calculate new dimensions
let { width, height } = img;
if (width > maxWidth) {
height = (height * maxWidth) / width;
width = maxWidth;
}
canvas.width = width;
canvas.height = height;
// Draw and compress
ctx?.drawImage(img, 0, 0, width, height);
const compressedDataUrl = canvas.toDataURL('image/jpeg', quality);
resolve(compressedDataUrl);
};
img.onerror = reject;
img.src = URL.createObjectURL(file);
});
};
// File upload handler
const handlePhotoUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
// Validate file type
if (!file.type.startsWith('image/')) {
setErrorMsg("Bitte wähle nur Bilddateien aus.");
return;
}
// Check file size (max 1MB)
if (file.size > 1 * 1024 * 1024) {
setErrorMsg("Das Foto ist zu groß. Bitte wähle ein Bild unter 1MB.");
return;
}
try {
// Helper: measure DataURL size via Blob
const getDataUrlSizeBytes = async (dataUrl: string): Promise<number> => {
const res = await fetch(dataUrl);
const blob = await res.blob();
return blob.size;
};
// Try compressing with decreasing quality until <= 1MB or give up
const qualitySteps = [0.8, 0.6, 0.4];
let finalDataUrl = "";
for (const q of qualitySteps) {
const candidate = await compressImage(file, 800, q);
const sizeBytes = await getDataUrlSizeBytes(candidate);
if (sizeBytes <= 1 * 1024 * 1024) {
finalDataUrl = candidate;
break;
}
}
if (!finalDataUrl) {
setErrorMsg("Das komprimierte Bild ist weiterhin größer als 1MB. Bitte wähle ein kleineres Bild.");
return;
}
setPhotoBase64(finalDataUrl);
setPhotoPreview(finalDataUrl);
setErrorMsg("");
} catch (error) {
console.error('Photo compression failed:', error);
setErrorMsg('Fehler beim Verarbeiten des Bildes. Bitte versuche es mit einem anderen Bild.');
return;
}
};
const removePhoto = () => {
setPhotoBase64("");
setPhotoPreview("");
// Reset file input
const fileInput = document.getElementById('gallery-photo-upload') as HTMLInputElement;
if (fileInput) fileInput.value = '';
};
// Drag and drop handlers
const handleDragStart = (e: React.DragEvent, photoId: string) => {
setDraggedPhotoId(photoId);
e.dataTransfer.effectAllowed = 'move';
};
const handleDragOver = (e: React.DragEvent) => {
e.preventDefault();
e.dataTransfer.dropEffect = 'move';
};
const handleDrop = (e: React.DragEvent, targetPhotoId: string) => {
e.preventDefault();
if (!draggedPhotoId || draggedPhotoId === targetPhotoId) {
setDraggedPhotoId(null);
return;
}
const draggedPhoto = photos?.find(p => p.id === draggedPhotoId);
const targetPhoto = photos?.find(p => p.id === targetPhotoId);
if (!draggedPhoto || !targetPhoto) {
setDraggedPhotoId(null);
return;
}
const sessionId = localStorage.getItem("sessionId");
if (!sessionId) {
setErrorMsg("Nicht eingeloggt. Bitte als Inhaber anmelden.");
setDraggedPhotoId(null);
return;
}
// Build a fully reordered list based on the current sorted order
const sorted = [...(photos || [])].sort((a, b) => a.order - b.order);
const fromIndex = sorted.findIndex(p => p.id === draggedPhotoId);
const toIndex = sorted.findIndex(p => p.id === targetPhotoId);
if (fromIndex === -1 || toIndex === -1) {
setDraggedPhotoId(null);
return;
}
const reordered = [...sorted];
const [moved] = reordered.splice(fromIndex, 1);
reordered.splice(toIndex, 0, moved);
// Reindex orders 0..n-1
const photoOrders = reordered.map((p, idx) => ({ id: p.id, order: idx }));
updatePhotoOrder(
{
sessionId,
photoOrders
},
{
onSuccess: () => {
setSuccessMsg("Foto-Reihenfolge aktualisiert.");
// Sofortige Aktualisierung der Thumbnails in korrekter Reihenfolge
refetchPhotos();
},
onError: (err: any) => {
setErrorMsg(err?.message || "Fehler beim Aktualisieren der Reihenfolge.");
}
}
);
setDraggedPhotoId(null);
};
const handleDragEnd = () => {
setDraggedPhotoId(null);
};
return (
<div className="max-w-6xl mx-auto space-y-6">
{/* Error and Success Messages */}
{errorMsg && (
<div className="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded-md">
<div className="flex items-center">
<svg className="w-5 h-5 mr-2 flex-shrink-0" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clipRule="evenodd" />
</svg>
<span className="font-medium">{errorMsg}</span>
</div>
</div>
)}
{successMsg && (
<div className="bg-green-50 border border-green-200 text-green-700 px-4 py-3 rounded-md">
<div className="flex items-center">
<svg className="w-5 h-5 mr-2 flex-shrink-0" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clipRule="evenodd" />
</svg>
<span className="font-medium">{successMsg}</span>
</div>
</div>
)}
{/* Upload Form Section */}
<div className="bg-white rounded-lg shadow p-6">
<h3 className="text-lg font-semibold mb-4">Neues Foto hochladen</h3>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Foto auswählen *
</label>
<input
id="gallery-photo-upload"
type="file"
accept="image/*"
onChange={handlePhotoUpload}
className="block w-full text-sm text-gray-500 file:mr-4 file:py-2 file:px-4 file:rounded-md file:border-0 file:text-sm file:font-medium file:bg-pink-50 file:text-pink-700 hover:file:bg-pink-100"
/>
<p className="text-xs text-gray-500 mt-1">
Max. 1MB, alle Bildformate erlaubt
</p>
</div>
{photoPreview && (
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Vorschau
</label>
<div className="relative inline-block">
<img
src={photoPreview}
alt="Foto Vorschau"
className="w-32 h-32 object-cover rounded-lg border border-gray-200"
/>
<button
type="button"
onClick={removePhoto}
className="absolute -top-2 -right-2 bg-red-500 text-white rounded-full w-6 h-6 flex items-center justify-center text-sm hover:bg-red-600"
>
×
</button>
</div>
</div>
)}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Titel (optional)
</label>
<input
type="text"
value={photoTitle}
onChange={(e) => setPhotoTitle(e.target.value)}
placeholder="z.B. French Manicure, Gel Nails..."
className="w-full border border-gray-300 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-pink-500"
/>
</div>
<button
onClick={() => {
setErrorMsg("");
setSuccessMsg("");
if (!photoBase64) {
setErrorMsg("Bitte wähle zuerst ein Foto aus.");
return;
}
const sessionId = localStorage.getItem("sessionId") || "";
if (!sessionId) {
setErrorMsg("Nicht eingeloggt. Bitte als Inhaber anmelden.");
return;
}
uploadPhoto(
{
sessionId,
base64Data: photoBase64,
title: photoTitle || undefined
},
{
onSuccess: () => {
setSuccessMsg("Foto erfolgreich hochgeladen.");
setPhotoTitle("");
setPhotoPreview("");
setPhotoBase64("");
// Reset file input
const fileInput = document.getElementById('gallery-photo-upload') as HTMLInputElement;
if (fileInput) fileInput.value = '';
// Sofortige Aktualisierung der Liste
refetchPhotos();
},
onError: (err: any) => {
setErrorMsg(err?.message || "Fehler beim Hochladen des Fotos.");
}
}
);
}}
disabled={isUploading}
className={`bg-pink-600 text-white px-4 py-2 rounded-md font-medium transition-colors ${isUploading ? 'opacity-60 cursor-not-allowed' : 'hover:bg-pink-700'}`}
>
{isUploading ? 'Lädt hoch…' : 'Foto hochladen'}
</button>
</div>
</div>
{/* Photo Grid Section */}
<div className="bg-white rounded-lg shadow">
<div className="p-4 border-b">
<h3 className="text-lg font-semibold">Foto-Galerie verwalten</h3>
<p className="text-sm text-gray-600 mt-1">
Ziehe Fotos per Drag & Drop, um die Reihenfolge zu ändern. Klicke auf das X, um ein Foto zu löschen.
</p>
</div>
<div className="p-6">
{photos?.length === 0 && (
<div className="text-center text-gray-500 py-8">
<div className="text-lg font-medium mb-2">Noch keine Fotos hochgeladen</div>
<div className="text-sm">Lade dein erstes Foto hoch, um deine Galerie zu starten.</div>
</div>
)}
{sortedPhotos && sortedPhotos.length > 0 && (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{sortedPhotos.map((photo) => (
<div
key={photo.id}
draggable={true}
onDragStart={(e) => handleDragStart(e, photo.id)}
onDragOver={handleDragOver}
onDrop={(e) => handleDrop(e, photo.id)}
onDragEnd={handleDragEnd}
className={`relative bg-gray-50 rounded-lg overflow-hidden border-2 transition-all duration-200 hover:shadow-md cursor-move ${
draggedPhotoId === photo.id ? 'opacity-50 border-pink-300' : 'border-transparent'
}`}
>
<img
src={photo.base64Data}
alt={photo.title || "Galerie Foto"}
className="w-full h-48 object-cover"
/>
<div className="p-3">
<div className="flex items-center justify-between">
<div className="flex-1">
{photo.title && (
<div className="font-medium text-gray-900 text-sm mb-1">
{photo.title}
</div>
)}
<div className="text-xs text-gray-500">
Reihenfolge: {photo.order}
</div>
<div className="text-xs text-gray-400">
{new Date(photo.createdAt).toLocaleDateString('de-DE')}
</div>
</div>
<button
onClick={() => {
if (confirm("Möchtest du dieses Foto wirklich löschen?")) {
const sessionId = localStorage.getItem("sessionId");
if (!sessionId) {
setErrorMsg("Nicht eingeloggt. Bitte als Inhaber anmelden.");
return;
}
deletePhoto(
{ sessionId, id: photo.id },
{
onSuccess: () => {
setSuccessMsg("Foto gelöscht.");
// Sofortige Aktualisierung der Liste
refetchPhotos();
},
onError: (err: any) => {
setErrorMsg(err?.message || "Fehler beim Löschen des Fotos.");
}
}
);
}
}}
className="ml-2 text-red-600 hover:text-red-800 hover:bg-red-50 rounded-full p-1 transition-colors"
title="Foto löschen"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
<button
onClick={() => {
const sessionId = localStorage.getItem("sessionId");
if (!sessionId) {
setErrorMsg("Nicht eingeloggt. Bitte als Inhaber anmelden.");
return;
}
setCoverPhoto(
{ sessionId, id: photo.id },
{
onSuccess: () => setSuccessMsg("Als Cover-Bild gesetzt."),
onError: (err: any) => setErrorMsg(err?.message || "Fehler beim Setzen des Cover-Bildes."),
}
);
}}
className={`ml-2 ${photo.cover ? 'text-green-700 hover:text-green-800 hover:bg-green-50' : 'text-gray-600 hover:text-gray-800 hover:bg-gray-50'} rounded-full p-1 transition-colors`}
title={photo.cover ? "Cover-Bild (aktiv)" : "Als Cover-Bild setzen"}
>
{photo.cover ? (
<svg className="w-4 h-4" viewBox="0 0 20 20" fill="currentColor">
<path fillRule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-7.25 7.25a1 1 0 01-1.414 0l-3-3a1 1 0 111.414-1.414L8.5 11.086l6.543-6.543a1 1 0 011.414 0z" clipRule="evenodd" />
</svg>
) : (
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16l6-6 4 4 6-6" />
</svg>
)}
</button>
</div>
</div>
</div>
))}
</div>
)}
</div>
</div>
</div>
);
}