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:
464
src/client/components/admin-gallery.tsx
Normal file
464
src/client/components/admin-gallery.tsx
Normal 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>
|
||||
);
|
||||
}
|
Reference in New Issue
Block a user