This commit is contained in:
Marc Wieland 2025-05-19 10:30:39 +02:00
commit 6e50ae9247
31 changed files with 336 additions and 64 deletions

40
package-lock.json generated
View File

@ -37,7 +37,9 @@
"@radix-ui/react-toggle-group": "^1.1.0",
"@radix-ui/react-tooltip": "^1.1.4",
"@react-pdf/renderer": "^4.3.0",
"@tailwindcss/aspect-ratio": "^0.4.2",
"@tanstack/react-query": "^5.56.2",
"aos": "^2.3.4",
"axios": "^1.9.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
@ -3152,6 +3154,15 @@
"@swc/counter": "^0.1.3"
}
},
"node_modules/@tailwindcss/aspect-ratio": {
"version": "0.4.2",
"resolved": "https://registry.npmjs.org/@tailwindcss/aspect-ratio/-/aspect-ratio-0.4.2.tgz",
"integrity": "sha512-8QPrypskfBa7QIMuKHg2TA7BqES6vhBrDLOv8Unb6FcFyd3TjKbc6lcmb9UPQHxfl24sXoJ41ux/H7qQQvfaSQ==",
"license": "MIT",
"peerDependencies": {
"tailwindcss": ">=2.0.0 || >=3.0.0 || >=3.0.0-alpha.1"
}
},
"node_modules/@tailwindcss/line-clamp": {
"version": "0.4.4",
"resolved": "https://registry.npmjs.org/@tailwindcss/line-clamp/-/line-clamp-0.4.4.tgz",
@ -3733,6 +3744,17 @@
"node": ">= 8"
}
},
"node_modules/aos": {
"version": "2.3.4",
"resolved": "https://registry.npmjs.org/aos/-/aos-2.3.4.tgz",
"integrity": "sha512-zh/ahtR2yME4I51z8IttIt4lC1Nw0ktsFtmeDzID1m9naJnWXhCoARaCgNOGXb5CLy3zm+wqmRAEgMYB5E2HUw==",
"license": "MIT",
"dependencies": {
"classlist-polyfill": "^1.0.3",
"lodash.debounce": "^4.0.6",
"lodash.throttle": "^4.0.1"
}
},
"node_modules/arg": {
"version": "5.0.2",
"resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz",
@ -4204,6 +4226,12 @@
"url": "https://polar.sh/cva"
}
},
"node_modules/classlist-polyfill": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/classlist-polyfill/-/classlist-polyfill-1.2.0.tgz",
"integrity": "sha512-GzIjNdcEtH4ieA2S8NmrSxv7DfEV5fmixQeyTmqmRmRJPGpRBaSnA2a0VrCjyT8iW8JjEdMbKzDotAJf+ajgaQ==",
"license": "Unlicense"
},
"node_modules/classnames": {
"version": "2.5.1",
"resolved": "https://registry.npmjs.org/classnames/-/classnames-2.5.1.tgz",
@ -6470,6 +6498,12 @@
"integrity": "sha512-aVx8ztPv7/2ULbArGJ2Y42bG1mEQ5mGjpdvrbJcJFU3TbYybe+QlLS4pst9zV52ymy2in1KpFPiZnAOATxD4+Q==",
"dev": true
},
"node_modules/lodash.debounce": {
"version": "4.0.8",
"resolved": "https://registry.npmjs.org/lodash.debounce/-/lodash.debounce-4.0.8.tgz",
"integrity": "sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==",
"license": "MIT"
},
"node_modules/lodash.isplainobject": {
"version": "4.0.6",
"resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz",
@ -6483,6 +6517,12 @@
"dev": true,
"license": "MIT"
},
"node_modules/lodash.throttle": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/lodash.throttle/-/lodash.throttle-4.1.1.tgz",
"integrity": "sha512-wIkUCfVKpVsWo3JSZlc+8MB5it+2AN5W8J7YVMST30UrvcQNZ1Okbj+rbVniijTWE6FGYy4XJq/rHkas8qJMLQ==",
"license": "MIT"
},
"node_modules/longest-streak": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/longest-streak/-/longest-streak-3.1.0.tgz",

View File

@ -40,7 +40,9 @@
"@radix-ui/react-toggle-group": "^1.1.0",
"@radix-ui/react-tooltip": "^1.1.4",
"@react-pdf/renderer": "^4.3.0",
"@tailwindcss/aspect-ratio": "^0.4.2",
"@tanstack/react-query": "^5.56.2",
"aos": "^2.3.4",
"axios": "^1.9.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.0 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.2 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1005 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 MiB

BIN
public/images/tgl-ball.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

View File

@ -6,6 +6,8 @@ import { BrowserRouter, Routes, Route } from "react-router-dom";
import Index from "./pages/Index";
import NotFound from "./pages/NotFound";
import ScrollToTop from "./components/ScrollTop";
import AOS from "aos";
import "aos/dist/aos.css";
const queryClient = new QueryClient();

View File

@ -3,7 +3,7 @@ import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { Button } from "@/components/ui/button";
import { Switch } from "@/components/ui/switch";
import axios from "axios";
import api from "@/lib/axios";
type Event = {
id?: number;
@ -31,38 +31,40 @@ const EventsAdmin = () => {
const fetchEvents = async () => {
try {
const res = await axios.get("/api/events?showPrivate=true");
if (Array.isArray(res.data)) {
setEvents(res.data);
} else {
console.warn("⚠️ Events-API hat kein Array geliefert:", res.data);
setEvents([]);
}
const res = await api.get("/api/events?showPrivate=true");
setEvents(Array.isArray(res.data) ? res.data : []);
} catch (error) {
console.error("❌ Fehler beim Laden der Events:", error);
setEvents([]);
}
};
useEffect(() => {
fetchEvents();
}, []);
const handleSubmit = async () => {
try {
if (isEditing && form.id) {
await axios.put(`/api/events/${form.id}`, form);
await api.put(`/api/events/${form.id}`, form);
} else {
await axios.post("/api/events", form);
await api.post("/api/events", form);
}
setForm(defaultEvent);
setIsEditing(false);
fetchEvents();
} catch (error) {
console.error("❌ Fehler beim Speichern:", error);
}
};
const handleDelete = async (id: number) => {
await axios.delete(`/api/events/${id}`);
try {
await api.delete(`/api/events/${id}`);
fetchEvents();
} catch (error) {
console.error("❌ Fehler beim Löschen:", error);
}
};
const handleEdit = (event: Event) => {
@ -89,14 +91,18 @@ const EventsAdmin = () => {
type="number"
placeholder="Max. Teilnehmer"
value={form.max_participants ?? ""}
onChange={(e) => setForm({ ...form, max_participants: parseInt(e.target.value) || undefined })}
onChange={(e) =>
setForm({ ...form, max_participants: parseInt(e.target.value) || undefined })
}
/>
<Input
type="number"
step="0.01"
placeholder="Gebühr (€)"
value={form.fee ?? ""}
onChange={(e) => setForm({ ...form, fee: parseFloat(e.target.value) || undefined })}
onChange={(e) =>
setForm({ ...form, fee: parseFloat(e.target.value) || undefined })
}
/>
<Input
placeholder="Adresse"
@ -116,7 +122,13 @@ const EventsAdmin = () => {
{isEditing ? "Speichern" : "Erstellen"}
</Button>
{isEditing && (
<Button variant="ghost" onClick={() => { setForm(defaultEvent); setIsEditing(false); }}>
<Button
variant="ghost"
onClick={() => {
setForm(defaultEvent);
setIsEditing(false);
}}
>
Abbrechen
</Button>
)}
@ -126,17 +138,29 @@ const EventsAdmin = () => {
<hr className="my-6" />
<h2 className="text-xl font-semibold mb-2">Bestehende Events</h2>
{Array.isArray(events) && events.length > 0 ? (
{events.length > 0 ? (
<ul className="space-y-2">
{events.map((ev) => (
<li key={ev.id} className="p-3 border rounded-md shadow-sm flex justify-between items-center">
<li
key={ev.id}
className="p-3 border rounded-md shadow-sm flex justify-between items-center"
>
<div>
<div className="font-medium">{ev.title}</div>
<div className="text-sm text-gray-500">{ev.description?.slice(0, 60)}...</div>
<div className="text-sm text-gray-500">
{ev.description?.slice(0, 60)}...
</div>
</div>
<div className="space-x-2">
<Button variant="outline" onClick={() => handleEdit(ev)}>Bearbeiten</Button>
<Button variant="destructive" onClick={() => handleDelete(ev.id!)}>Löschen</Button>
<Button variant="outline" onClick={() => handleEdit(ev)}>
Bearbeiten
</Button>
<Button
variant="destructive"
onClick={() => handleDelete(ev.id!)}
>
Löschen
</Button>
</div>
</li>
))}
@ -144,7 +168,6 @@ const EventsAdmin = () => {
) : (
<p className="text-gray-500">Noch keine Events vorhanden.</p>
)}
</div>
);
};

View File

@ -7,12 +7,12 @@ const Hero = () => {
className="relative min-h-[600px] md:min-h-[700px] lg:min-h-[800px] bg-cover bg-center bg-no-repeat pt-28 pb-16"
style={{ backgroundImage: "url('/images/abteilung-bg.jpg')" }}
>
<div className="absolute inset-0 bg-black/15 z-0" />
<div className="absolute inset-0 bg-gradient-to-b from-black/50 via-black/30 to-transparent z-0" />
<div className="relative z-10 max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="flex flex-col md:flex-row items-center">
<div className="md:w-1/2 text-white">
<h1 className="text-4xl md:text-5xl lg:text-6xl font-bold">
<h1 className="text-4xl md:text-5xl lg:text-6xl font-bold drop-shadow-md">
Willkommen bei der <span className="text-white">TG Laudenbach - Abteilung Volleyball</span>
</h1>
<p className="mt-4 text-lg md:text-xl">

View File

@ -3,7 +3,7 @@ import { Button } from "@/components/ui/button";
import { Menu, X, Volleyball, ChevronDown, Users } from "lucide-react";
import { Link, useNavigate } from "react-router-dom";
import { useAuth } from '@/context/AuthContext';
import axios from 'axios';
import ThemeToggle from './ThemeToggle';
type Team = {
@ -94,6 +94,10 @@ const Navbar = () => {
</Button>
</Link>
<ThemeToggle />
{/* Account Menu */}
<div
className="relative ml-4"
onMouseEnter={() => {

View File

@ -18,6 +18,7 @@ type NewsItem = {
const NewsSection = () => {
const [news, setNews] = useState<NewsItem[]>([]);
const defaultImage = "/images/tgl-ball.png";
const fetchNews = async () => {
try{
@ -49,7 +50,7 @@ const NewsSection = () => {
src={
item.image_url
? `${import.meta.env.VITE_API_URL}${item.image_url}`
: "/images/default-news.jpg"
: defaultImage
}
alt={item.title}
className="w-full h-full object-cover"

View File

@ -27,6 +27,7 @@ const TeamSection = () => {
const [loading, setLoading] = useState(true);
const [sliderRef, slider] = useKeenSlider<HTMLDivElement>({
loop: true,
slides: {
perView: 1,
spacing: 16,
@ -60,6 +61,14 @@ const TeamSection = () => {
fetchTeams();
}, []);
useEffect(() => {
const interval = setInterval(() => {
slider.current?.next();
}, 3000);
return () => clearInterval(interval);
}, [slider]);
return (
<section id="team" className="py-16">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">

View File

@ -0,0 +1,22 @@
import { useTheme } from "@/context/ThemeContext";
import { Sun, Moon } from "lucide-react";
const ThemeToggle = () => {
const { theme, toggleTheme } = useTheme();
return (
<button
onClick={toggleTheme}
className="p-2 rounded-full bg-white dark:bg-gray-800 shadow-md hover:scale-105 transition flex items-center justify-center"
aria-label="Toggle Theme"
>
{theme === "dark" ? (
<Sun className="h-5 w-5 text-yellow-400" />
) : (
<Moon className="h-5 w-5 text-gray-600" />
)}
</button>
);
};
export default ThemeToggle;

View File

@ -0,0 +1,40 @@
import { createContext, useContext, useEffect, useState } from "react";
type Theme = "light" | "dark";
interface ThemeContextType {
theme: Theme;
toggleTheme: () => void;
}
const ThemeContext = createContext<ThemeContextType>({
theme: "light",
toggleTheme: () => {},
});
export const ThemeProvider = ({ children }: { children: React.ReactNode }) => {
const [theme, setTheme] = useState<Theme>("light");
useEffect(() => {
const stored = localStorage.getItem("theme") as Theme | null;
const system = window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
const initial = stored || system;
setTheme(initial);
document.documentElement.classList.toggle("dark", initial === "dark");
}, []);
const toggleTheme = () => {
const newTheme = theme === "dark" ? "light" : "dark";
setTheme(newTheme);
localStorage.setItem("theme", newTheme);
document.documentElement.classList.toggle("dark", newTheme === "dark");
};
return (
<ThemeContext.Provider value={{ theme, toggleTheme }}>
{children}
</ThemeContext.Provider>
);
};
export const useTheme = () => useContext(ThemeContext);

View File

@ -1,12 +1,23 @@
import { ReactNode, useEffect } from "react";
import Navbar from "@/components/Navbar";
import Footer from "@/components/Footer";
import { ReactNode } from "react";
import AOS from "aos";
import "aos/dist/aos.css";
type LayoutProps = {
children: ReactNode;
};
};
const Layout = ({ children }: LayoutProps) => {
useEffect(() => {
AOS.init({
duration: 800, // Dauer der Animationen
once: true, // nur einmal pro Element
offset: 100, // wie weit gescrollt werden muss
easing: "ease-out-cubic",
});
}, []);
const Layout = ({ children }: LayoutProps) => {
return (
<div className="min-h-screen bg-white flex flex-col">
<Navbar />
@ -14,6 +25,6 @@ type LayoutProps = {
<Footer />
</div>
);
};
};
export default Layout;
export default Layout;

7
src/lib/axios.ts Normal file
View File

@ -0,0 +1,7 @@
import axios from "axios";
const api = axios.create({
baseURL: import.meta.env.VITE_API_URL,
});
export default api;

View File

@ -33,10 +33,14 @@ import Impressum from "./pages/Impressum";
import Satzung from "./pages/Satzung";
import Beitraege from "./pages/Beitraege";
import { ThemeProvider } from "@/context/ThemeContext";
import VideosPage from "./pages/VideosPage";
ReactDOM.createRoot(document.getElementById("root")!).render(
<React.StrictMode>
<ThemeProvider>
<BrowserRouter>
<AuthProvider>
<Routes>
@ -72,6 +76,9 @@ ReactDOM.createRoot(document.getElementById("root")!).render(
<Route path="/satzung" element={<Layout><Satzung /></Layout>} />
<Route path="/impressum" element={<Layout><Impressum /></Layout>} />
<Route path="/beitraege" element={<Layout><Beitraege /></Layout>} />
<Route path="/videos" element={<Layout><VideosPage/></Layout>} />
<Route path="/admin" element={<PrivateRoute><Layout><AdminDashboard /></Layout></PrivateRoute>} />
<Route path="/admin/news" element={<PrivateRoute><Layout><NewsManager /></Layout></PrivateRoute>} />
<Route path="/admin/users" element={<PrivateRoute><Layout><UserManagementPage /></Layout></PrivateRoute>} />
@ -86,8 +93,10 @@ ReactDOM.createRoot(document.getElementById("root")!).render(
<Route path="/admin/gallery" element={<PrivateRoute><Layout><GalleryManager /></Layout></PrivateRoute>} />
<Route path="/admin/events" element={<PrivateRoute><Layout><EventsAdmin /></Layout></PrivateRoute>} />
</Routes>
</AuthProvider>
</BrowserRouter>
</ThemeProvider>
</React.StrictMode>
);

View File

@ -17,6 +17,25 @@ const AlleNeuigkeitenPage = () => {
const [news, setNews] = useState<NewsItem[]>([]);
const [expandedIds, setExpandedIds] = useState<number[]>([]);
const [activeCardId, setActiveCardId] = useState<number | null>(null);
const [selectedTeam, setSelectedTeam] = useState<string>("Alle Teams");
const defaultImage = "/images/tgl-ball.png";
//Filtern nach Teams
const teams = Array.from(new Set(news.map((n) => n.team).filter(Boolean)));
const filteredNews = selectedTeam === "Alle Teams" ? news : news.filter((n) => n.team === selectedTeam);
const groupedNews = filteredNews.reduce((acc: Record<string, NewsItem[]>, item) => {
const year = new Date(item.created_at).getFullYear().toString();
if (!acc[year]) acc[year] = [];
acc[year].push(item);
return acc;
}, {});
useEffect(() => {
fetch(`${apiBase}/api/news`)
@ -31,13 +50,6 @@ const AlleNeuigkeitenPage = () => {
};
// Gruppieren nach Jahren
const groupedNews = news.reduce((acc: Record<string, NewsItem[]>, item) => {
const year = new Date(item.created_at).getFullYear().toString();
if (!acc[year]) acc[year] = [];
acc[year].push(item);
return acc;
}, {});
//Toggle der Cards
const toggleExpand = (id: number) => {
@ -50,6 +62,22 @@ const AlleNeuigkeitenPage = () => {
<div className="max-w-5xl mx-auto px-4 py-12">
<h1 className="text-3xl font-bold text-center mb-8 text-frog-600">Alle Neuigkeiten</h1>
<div className="mb-8 flex justify-center">
<select
value={selectedTeam}
onChange={(e) => setSelectedTeam(e.target.value)}
className="border border-gray-300 rounded-md px-4 py-2 text-gray-700"
>
<option value="Alle Teams">Alle Teams</option>
{teams.map((team) => (
<option key={team} value={team}>
{team}
</option>
))}
</select>
</div>
{/* News nach Jahren gruppiert */}
{Object.entries(groupedNews).map(([year, items]) => (
<div key={year} className="mb-12">
@ -69,7 +97,7 @@ const AlleNeuigkeitenPage = () => {
src={
item.image_url
? `${apiBase}${item.image_url}`
: "https://via.placeholder.com/400x300?text=Kein+Bild"
: defaultImage
}
alt={item.title}
className="w-full h-full object-cover"

73
src/pages/VideosPage.tsx Normal file
View File

@ -0,0 +1,73 @@
import { useRef } from "react";
import { Rewind, FastForward, Pause, Play } from "lucide-react";
const VideosPage = () => {
const videoRef = useRef<HTMLVideoElement>(null);
const skip = (seconds: number) => {
if (videoRef.current) {
videoRef.current.currentTime += seconds;
}
};
const togglePlayPause = () => {
const video = videoRef.current;
if (!video) return;
if (video.paused) {
video.play();
} else {
video.pause();
}
};
return (
<div className="max-w-4xl mx-auto px-4 py-12">
<h1 className="text-3xl font-bold text-center text-frog-600 mb-8">
Trainingsvideo: Satz 1
</h1>
<div className="relative">
<video
ref={videoRef}
controls
controlsList="nodownload"
preload="none"
width="100%"
poster="https://volleycloud.marc-wieland.de/index.php/s/7F6Sz5DQyct32Xt/preview"
onContextMenu={(e) => e.preventDefault()}
className="rounded-md shadow-md outline-none"
>
<source
src="https://volleycloud.marc-wieland.de/index.php/s/7F6Sz5DQyct32Xt/download"
type="video/mp4"
/>
Dein Browser unterstützt kein eingebettetes Video.
</video>
{/* Custom Steuerung */}
<div className="mt-4 flex justify-center gap-4">
<button
onClick={() => skip(-5)}
className="bg-gray-100 dark:bg-gray-800 p-3 rounded-full hover:scale-105 transition shadow"
>
<Rewind className="h-5 w-5" />
</button>
<button
onClick={togglePlayPause}
className="bg-gray-100 dark:bg-gray-800 p-3 rounded-full hover:scale-105 transition shadow"
>
<Play className="h-5 w-5" />
</button>
<button
onClick={() => skip(5)}
className="bg-gray-100 dark:bg-gray-800 p-3 rounded-full hover:scale-105 transition shadow"
>
<FastForward className="h-5 w-5" />
</button>
</div>
</div>
</div>
);
};
export default VideosPage;

View File

@ -114,5 +114,6 @@ export default {
plugins: [
require("tailwindcss-animate"),
require("@tailwindcss/typography"),
require('@tailwindcss/aspect-ratio')
],
} satisfies Config;