This commit is contained in:
parent
55be7e0210
commit
0d4b6b8e1a
@ -19,7 +19,6 @@ const AdminDashboard = () => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid md:grid-cols-2 lg:grid-cols-3 gap-6">
|
<div className="grid md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||||
{/* Jeder Benutzer darf News verwalten */}
|
|
||||||
<Link to="/admin/news">
|
<Link to="/admin/news">
|
||||||
<Card className="hover:shadow-lg transition-shadow cursor-pointer">
|
<Card className="hover:shadow-lg transition-shadow cursor-pointer">
|
||||||
<CardContent className="p-6 text-center">
|
<CardContent className="p-6 text-center">
|
||||||
@ -29,7 +28,6 @@ const AdminDashboard = () => {
|
|||||||
</Card>
|
</Card>
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
{/* Nur Admins sehen diese Card */}
|
|
||||||
{isAdmin && (
|
{isAdmin && (
|
||||||
<Link to="/admin/users">
|
<Link to="/admin/users">
|
||||||
<Card className="hover:shadow-lg transition-shadow cursor-pointer">
|
<Card className="hover:shadow-lg transition-shadow cursor-pointer">
|
||||||
@ -58,6 +56,16 @@ const AdminDashboard = () => {
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
|
{/* 🎉 Neue Galerie-Kachel */}
|
||||||
|
<Link to="/admin/gallery">
|
||||||
|
<Card className="hover:shadow-lg transition-shadow cursor-pointer">
|
||||||
|
<CardContent className="p-6 text-center">
|
||||||
|
<CardTitle className="text-frog-600">Galerie verwalten</CardTitle>
|
||||||
|
<p className="text-gray-600 mt-2 text-sm">Bilder hochladen & löschen</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
104
src/admin/GalleryManager.tsx
Normal file
104
src/admin/GalleryManager.tsx
Normal file
@ -0,0 +1,104 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
|
const apiBase = import.meta.env.VITE_API_URL;
|
||||||
|
|
||||||
|
type GalleryImage = {
|
||||||
|
id: number;
|
||||||
|
image_url: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const GalleryManager = () => {
|
||||||
|
const [images, setImages] = useState<GalleryImage[]>([]);
|
||||||
|
const [uploading, setUploading] = useState(false);
|
||||||
|
|
||||||
|
const fetchImages = async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${apiBase}/api/gallery`);
|
||||||
|
const data = await res.json();
|
||||||
|
setImages(data);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Fehler beim Laden der Galerie:", err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchImages();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const file = e.target.files?.[0];
|
||||||
|
if (!file) return;
|
||||||
|
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append("image", file);
|
||||||
|
|
||||||
|
setUploading(true);
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${apiBase}/api/gallery`, {
|
||||||
|
method: "POST",
|
||||||
|
body: formData,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) throw new Error("Fehler beim Hochladen");
|
||||||
|
toast.success("Bild erfolgreich hochgeladen!");
|
||||||
|
await fetchImages();
|
||||||
|
} catch (err) {
|
||||||
|
toast.error("Fehler beim Hochladen des Bildes");
|
||||||
|
} finally {
|
||||||
|
setUploading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = async (id: number) => {
|
||||||
|
if (!confirm("Bist du sicher, dass du dieses Bild löschen willst?")) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${apiBase}/api/gallery/${id}`, {
|
||||||
|
method: "DELETE",
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) throw new Error("Fehler beim Löschen");
|
||||||
|
toast.success("Bild gelöscht");
|
||||||
|
await fetchImages();
|
||||||
|
} catch (err) {
|
||||||
|
toast.error("Fehler beim Löschen des Bildes");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-6xl mx-auto py-12 px-4">
|
||||||
|
<h1 className="text-3xl font-bold text-frog-600 mb-6">Galerie verwalten</h1>
|
||||||
|
|
||||||
|
<div className="mb-8">
|
||||||
|
<input type="file" accept="image/*" onChange={handleUpload} />
|
||||||
|
{uploading && <p className="text-sm text-gray-500 mt-2">Bild wird hochgeladen...</p>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-6">
|
||||||
|
{images.map((img) => (
|
||||||
|
<Card key={img.id}>
|
||||||
|
<CardContent className="p-2">
|
||||||
|
<img
|
||||||
|
src={`${apiBase}${img.image_url}`}
|
||||||
|
alt="Galeriebild"
|
||||||
|
className="w-full h-48 object-cover rounded-md mb-2"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
className="w-full"
|
||||||
|
onClick={() => handleDelete(img.id)}
|
||||||
|
>
|
||||||
|
Löschen
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default GalleryManager;
|
||||||
@ -1,19 +1,66 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
import { useState } from "react";
|
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { ChevronLeft, ChevronRight, X } from "lucide-react";
|
import { X } from "lucide-react";
|
||||||
|
import { Link } from "react-router-dom";
|
||||||
|
|
||||||
|
const apiBase = import.meta.env.VITE_API_URL;
|
||||||
|
|
||||||
|
type GalleryImage = {
|
||||||
|
id: number;
|
||||||
|
image_url: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type AnimatedImage = GalleryImage & { fading?: boolean };
|
||||||
|
|
||||||
const GallerySection = () => {
|
const GallerySection = () => {
|
||||||
|
const [allImages, setAllImages] = useState<GalleryImage[]>([]);
|
||||||
|
const [displayImages, setDisplayImages] = useState<AnimatedImage[]>([]);
|
||||||
const [selectedImage, setSelectedImage] = useState<string | null>(null);
|
const [selectedImage, setSelectedImage] = useState<string | null>(null);
|
||||||
|
|
||||||
const images = [
|
useEffect(() => {
|
||||||
"https://images.unsplash.com/photo-1553005746-5be7d6d48a81?ixlib=rb-4.0.3&auto=format&fit=crop&w=800&q=80",
|
const fetchImages = async () => {
|
||||||
"https://images.unsplash.com/photo-1588286840104-8957b019727f?ixlib=rb-4.0.3&auto=format&fit=crop&w=800&q=80",
|
try {
|
||||||
"https://images.unsplash.com/photo-1599586120429-48281b6f0ece?ixlib=rb-4.0.3&auto=format&fit=crop&w=800&q=80",
|
const res = await fetch(`${apiBase}/api/gallery`);
|
||||||
"https://images.unsplash.com/photo-1592656094267-764a45160876?ixlib=rb-4.0.3&auto=format&fit=crop&w=800&q=80",
|
const data = await res.json();
|
||||||
"https://images.unsplash.com/photo-1547347298-4074fc3086f0?ixlib=rb-4.0.3&auto=format&fit=crop&w=800&q=80",
|
setAllImages(data);
|
||||||
"https://images.unsplash.com/photo-1544213456-e1c259fecc4f?ixlib=rb-4.0.3&auto=format&fit=crop&w=800&q=80",
|
|
||||||
];
|
const shuffled = [...data].sort(() => 0.5 - Math.random());
|
||||||
|
setDisplayImages(shuffled.slice(0, 6));
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Fehler beim Laden der Galerie:", err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
fetchImages();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Bild zufällig austauschen mit Animation
|
||||||
|
useEffect(() => {
|
||||||
|
const interval = setInterval(() => {
|
||||||
|
if (allImages.length <= 6) return;
|
||||||
|
|
||||||
|
setDisplayImages((prev) => {
|
||||||
|
const currentIds = prev.map((img) => img.id);
|
||||||
|
const remaining = allImages.filter((img) => !currentIds.includes(img.id));
|
||||||
|
if (remaining.length === 0) return prev;
|
||||||
|
|
||||||
|
const newImage = remaining[Math.floor(Math.random() * remaining.length)];
|
||||||
|
const indexToReplace = Math.floor(Math.random() * prev.length);
|
||||||
|
|
||||||
|
const updated = [...prev];
|
||||||
|
updated[indexToReplace] = { ...updated[indexToReplace], fading: true };
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
updated[indexToReplace] = { ...newImage, fading: false };
|
||||||
|
setDisplayImages([...updated]);
|
||||||
|
}, 400); // passt zur fade-out duration
|
||||||
|
|
||||||
|
return [...updated];
|
||||||
|
});
|
||||||
|
}, 5000);
|
||||||
|
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
}, [allImages]);
|
||||||
|
|
||||||
const openLightbox = (image: string) => {
|
const openLightbox = (image: string) => {
|
||||||
setSelectedImage(image);
|
setSelectedImage(image);
|
||||||
@ -27,6 +74,33 @@ const GallerySection = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<section id="gallery" className="py-16 bg-gray-50">
|
<section id="gallery" className="py-16 bg-gray-50">
|
||||||
|
<style>
|
||||||
|
{`
|
||||||
|
.fade-img {
|
||||||
|
transition: opacity 0.4s ease, transform 0.4s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fade-out {
|
||||||
|
opacity: 0;
|
||||||
|
transform: scale(0.95);
|
||||||
|
}
|
||||||
|
|
||||||
|
.fade-in {
|
||||||
|
opacity: 1;
|
||||||
|
transform: scale(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.gallery-tile {
|
||||||
|
transition: transform 0.3s ease, box-shadow 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gallery-tile:hover {
|
||||||
|
transform: scale(1.03);
|
||||||
|
box-shadow: 0 8px 20px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
`}
|
||||||
|
</style>
|
||||||
|
|
||||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||||
<div className="text-center mb-12">
|
<div className="text-center mb-12">
|
||||||
<h2 className="text-3xl font-bold text-gray-900">Galerie</h2>
|
<h2 className="text-3xl font-bold text-gray-900">Galerie</h2>
|
||||||
@ -34,31 +108,38 @@ const GallerySection = () => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-2 md:grid-cols-3 gap-4">
|
<div className="grid grid-cols-2 md:grid-cols-3 gap-4">
|
||||||
{images.map((image, index) => (
|
{displayImages.map((image) => (
|
||||||
<div
|
<div
|
||||||
key={index}
|
key={image.id}
|
||||||
className="aspect-square overflow-hidden rounded-lg cursor-pointer hover:opacity-90 transition-opacity"
|
className="aspect-square overflow-hidden rounded-lg cursor-pointer gallery-tile"
|
||||||
onClick={() => openLightbox(image)}
|
onClick={() => openLightbox(`${apiBase}${image.image_url}`)}
|
||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
src={image}
|
src={`${apiBase}${image.image_url}`}
|
||||||
alt={`Galeriebild ${index + 1}`}
|
alt="Galeriebild"
|
||||||
className="w-full h-full object-cover"
|
className={`w-full h-full object-cover fade-img ${
|
||||||
|
image.fading ? "fade-out" : "fade-in"
|
||||||
|
}`}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="text-center mt-12">
|
<div className="mt-12 flex justify-center">
|
||||||
<Button className="bg-frog-500 hover:bg-frog-600">
|
<Link to="/gallery">
|
||||||
Mehr Bilder anzeigen
|
<Button className="bg-frog-500 hover:bg-frog-600">
|
||||||
</Button>
|
Mehr Bilder anzeigen
|
||||||
</div>
|
</Button>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Lightbox */}
|
|
||||||
{selectedImage && (
|
{selectedImage && (
|
||||||
<div className="fixed inset-0 bg-black/90 z-50 flex items-center justify-center" onClick={closeLightbox}>
|
<div
|
||||||
|
className="fixed inset-0 bg-black/90 z-50 flex items-center justify-center"
|
||||||
|
onClick={closeLightbox}
|
||||||
|
>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
@ -71,7 +152,10 @@ const GallerySection = () => {
|
|||||||
<X className="h-8 w-8" />
|
<X className="h-8 w-8" />
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<div className="relative max-w-4xl max-h-[80vh]" onClick={(e) => e.stopPropagation()}>
|
<div
|
||||||
|
className="relative max-w-4xl max-h-[80vh]"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
<img
|
<img
|
||||||
src={selectedImage}
|
src={selectedImage}
|
||||||
alt="Vergrößertes Bild"
|
alt="Vergrößertes Bild"
|
||||||
|
|||||||
@ -25,6 +25,8 @@ import TeamDetail from "./admin/TeamDetail";
|
|||||||
import TeamAddPlayer from "./admin/TeamAddPlayer";
|
import TeamAddPlayer from "./admin/TeamAddPlayer";
|
||||||
import PlayerEdit from "./admin/PlayerEdit";
|
import PlayerEdit from "./admin/PlayerEdit";
|
||||||
import PlayerManagementPage from "./admin/PlayerManagementPage";
|
import PlayerManagementPage from "./admin/PlayerManagementPage";
|
||||||
|
import GalleryManager from "./admin/GalleryManager";
|
||||||
|
import GalleryPage from "./pages/GalleryPage";
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@ -59,6 +61,7 @@ ReactDOM.createRoot(document.getElementById("root")!).render(
|
|||||||
/>
|
/>
|
||||||
<Route path="/teams/:id" element={<Layout><TeamDetailPage /></Layout>} />
|
<Route path="/teams/:id" element={<Layout><TeamDetailPage /></Layout>} />
|
||||||
<Route path="/login" element={<Layout><LoginPage /></Layout>}/>
|
<Route path="/login" element={<Layout><LoginPage /></Layout>}/>
|
||||||
|
<Route path="/gallery" element={<Layout><GalleryPage /></Layout>}/>
|
||||||
<Route path="/admin/login" element={<LoginPage />} />
|
<Route path="/admin/login" element={<LoginPage />} />
|
||||||
<Route path="/admin" element={<PrivateRoute><Layout><AdminDashboard /></Layout></PrivateRoute>} />
|
<Route path="/admin" element={<PrivateRoute><Layout><AdminDashboard /></Layout></PrivateRoute>} />
|
||||||
<Route path="/admin/news" element={<PrivateRoute><Layout><NewsManager /></Layout></PrivateRoute>} />
|
<Route path="/admin/news" element={<PrivateRoute><Layout><NewsManager /></Layout></PrivateRoute>} />
|
||||||
@ -71,6 +74,7 @@ ReactDOM.createRoot(document.getElementById("root")!).render(
|
|||||||
<Route path="/admin/teams/:id/add-player" element={<PrivateRoute><Layout><TeamAddPlayer /></Layout></PrivateRoute>} />
|
<Route path="/admin/teams/:id/add-player" element={<PrivateRoute><Layout><TeamAddPlayer /></Layout></PrivateRoute>} />
|
||||||
<Route path="/admin/players/:id/edit" element={<PrivateRoute><Layout><PlayerEdit /></Layout></PrivateRoute>} />
|
<Route path="/admin/players/:id/edit" element={<PrivateRoute><Layout><PlayerEdit /></Layout></PrivateRoute>} />
|
||||||
<Route path="/admin/players" element={<PrivateRoute><Layout><PlayerManagementPage /></Layout></PrivateRoute>} />
|
<Route path="/admin/players" element={<PrivateRoute><Layout><PlayerManagementPage /></Layout></PrivateRoute>} />
|
||||||
|
<Route path="/admin/gallery" element={<PrivateRoute><Layout><GalleryManager /></Layout></PrivateRoute>} />
|
||||||
|
|
||||||
</Routes>
|
</Routes>
|
||||||
</AuthProvider>
|
</AuthProvider>
|
||||||
|
|||||||
98
src/pages/GalleryPage.tsx
Normal file
98
src/pages/GalleryPage.tsx
Normal file
@ -0,0 +1,98 @@
|
|||||||
|
// src/pages/GalleryPage.tsx
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { X } from "lucide-react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
|
||||||
|
const apiBase = import.meta.env.VITE_API_URL;
|
||||||
|
|
||||||
|
type GalleryImage = {
|
||||||
|
id: number;
|
||||||
|
image_url: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const GalleryPage = () => {
|
||||||
|
const [images, setImages] = useState<GalleryImage[]>([]);
|
||||||
|
const [selectedImage, setSelectedImage] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchImages = async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${apiBase}/api/gallery`);
|
||||||
|
const data = await res.json();
|
||||||
|
setImages(data);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Fehler beim Laden der Galerie:", err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
fetchImages();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const openLightbox = (image: string) => {
|
||||||
|
setSelectedImage(image);
|
||||||
|
document.body.style.overflow = "hidden";
|
||||||
|
};
|
||||||
|
|
||||||
|
const closeLightbox = () => {
|
||||||
|
setSelectedImage(null);
|
||||||
|
document.body.style.overflow = "auto";
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-7xl mx-auto py-12 px-4">
|
||||||
|
<h1 className="text-4xl font-bold text-center text-frog-600 mb-8">
|
||||||
|
Bildergalerie 📸
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-4">
|
||||||
|
{images.map((image) => (
|
||||||
|
<div
|
||||||
|
key={image.id}
|
||||||
|
onClick={() => openLightbox(`${apiBase}${image.image_url}`)}
|
||||||
|
className="aspect-square overflow-hidden rounded-md shadow-md cursor-pointer transform hover:scale-105 transition-all duration-300"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src={`${apiBase}${image.image_url}`}
|
||||||
|
alt="Galeriebild"
|
||||||
|
className="w-full h-full object-cover"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Lightbox */}
|
||||||
|
{selectedImage && (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 bg-black/90 z-50 flex items-center justify-center"
|
||||||
|
onClick={closeLightbox}
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="absolute top-4 right-4 text-white hover:bg-white/10"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
closeLightbox();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<X className="h-8 w-8" />
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className="relative max-w-4xl max-h-[80vh]"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src={selectedImage}
|
||||||
|
alt="Vergrößertes Bild"
|
||||||
|
className="max-w-full max-h-[80vh] object-contain rounded-md shadow-lg"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default GalleryPage;
|
||||||
Loading…
Reference in New Issue
Block a user