Files
beauty-bookings/src/client/components/admin-gallery.tsx

465 lines
18 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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>
);
}