465 lines
18 KiB
TypeScript
465 lines
18 KiB
TypeScript
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>
|
||
);
|
||
}
|