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-toggle-group": "^1.1.0",
"@radix-ui/react-tooltip": "^1.1.4", "@radix-ui/react-tooltip": "^1.1.4",
"@react-pdf/renderer": "^4.3.0", "@react-pdf/renderer": "^4.3.0",
"@tailwindcss/aspect-ratio": "^0.4.2",
"@tanstack/react-query": "^5.56.2", "@tanstack/react-query": "^5.56.2",
"aos": "^2.3.4",
"axios": "^1.9.0", "axios": "^1.9.0",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
@ -3152,6 +3154,15 @@
"@swc/counter": "^0.1.3" "@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": { "node_modules/@tailwindcss/line-clamp": {
"version": "0.4.4", "version": "0.4.4",
"resolved": "https://registry.npmjs.org/@tailwindcss/line-clamp/-/line-clamp-0.4.4.tgz", "resolved": "https://registry.npmjs.org/@tailwindcss/line-clamp/-/line-clamp-0.4.4.tgz",
@ -3733,6 +3744,17 @@
"node": ">= 8" "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": { "node_modules/arg": {
"version": "5.0.2", "version": "5.0.2",
"resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz", "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz",
@ -4204,6 +4226,12 @@
"url": "https://polar.sh/cva" "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": { "node_modules/classnames": {
"version": "2.5.1", "version": "2.5.1",
"resolved": "https://registry.npmjs.org/classnames/-/classnames-2.5.1.tgz", "resolved": "https://registry.npmjs.org/classnames/-/classnames-2.5.1.tgz",
@ -6470,6 +6498,12 @@
"integrity": "sha512-aVx8ztPv7/2ULbArGJ2Y42bG1mEQ5mGjpdvrbJcJFU3TbYybe+QlLS4pst9zV52ymy2in1KpFPiZnAOATxD4+Q==", "integrity": "sha512-aVx8ztPv7/2ULbArGJ2Y42bG1mEQ5mGjpdvrbJcJFU3TbYybe+QlLS4pst9zV52ymy2in1KpFPiZnAOATxD4+Q==",
"dev": true "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": { "node_modules/lodash.isplainobject": {
"version": "4.0.6", "version": "4.0.6",
"resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz",
@ -6483,6 +6517,12 @@
"dev": true, "dev": true,
"license": "MIT" "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": { "node_modules/longest-streak": {
"version": "3.1.0", "version": "3.1.0",
"resolved": "https://registry.npmjs.org/longest-streak/-/longest-streak-3.1.0.tgz", "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-toggle-group": "^1.1.0",
"@radix-ui/react-tooltip": "^1.1.4", "@radix-ui/react-tooltip": "^1.1.4",
"@react-pdf/renderer": "^4.3.0", "@react-pdf/renderer": "^4.3.0",
"@tailwindcss/aspect-ratio": "^0.4.2",
"@tanstack/react-query": "^5.56.2", "@tanstack/react-query": "^5.56.2",
"aos": "^2.3.4",
"axios": "^1.9.0", "axios": "^1.9.0",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.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 Index from "./pages/Index";
import NotFound from "./pages/NotFound"; import NotFound from "./pages/NotFound";
import ScrollToTop from "./components/ScrollTop"; import ScrollToTop from "./components/ScrollTop";
import AOS from "aos";
import "aos/dist/aos.css";
const queryClient = new QueryClient(); const queryClient = new QueryClient();

View File

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

View File

@ -7,13 +7,13 @@ 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" 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')" }} 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="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="flex flex-col md:flex-row items-center">
<div className="md:w-1/2 text-white"> <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> Willkommen bei der <span className="text-white">TG Laudenbach - Abteilung Volleyball</span>
</h1> </h1>
<p className="mt-4 text-lg md:text-xl"> <p className="mt-4 text-lg md:text-xl">
Volleyball mit Leidenschaft und Teamgeist werde Teil unserer Gemeinschaft! Volleyball mit Leidenschaft und Teamgeist werde Teil unserer Gemeinschaft!

View File

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

View File

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

View File

@ -27,6 +27,7 @@ const TeamSection = () => {
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [sliderRef, slider] = useKeenSlider<HTMLDivElement>({ const [sliderRef, slider] = useKeenSlider<HTMLDivElement>({
loop: true,
slides: { slides: {
perView: 1, perView: 1,
spacing: 16, spacing: 16,
@ -60,6 +61,14 @@ const TeamSection = () => {
fetchTeams(); fetchTeams();
}, []); }, []);
useEffect(() => {
const interval = setInterval(() => {
slider.current?.next();
}, 3000);
return () => clearInterval(interval);
}, [slider]);
return ( return (
<section id="team" className="py-16"> <section id="team" className="py-16">
<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">

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,19 +1,30 @@
import { ReactNode, useEffect } from "react";
import Navbar from "@/components/Navbar"; import Navbar from "@/components/Navbar";
import Footer from "@/components/Footer"; import Footer from "@/components/Footer";
import { ReactNode } from "react"; import AOS from "aos";
import "aos/dist/aos.css";
type LayoutProps = { type LayoutProps = {
children: ReactNode; children: ReactNode;
}; };
const Layout = ({ children }: LayoutProps) => { const Layout = ({ children }: LayoutProps) => {
return ( useEffect(() => {
<div className="min-h-screen bg-white flex flex-col"> AOS.init({
<Navbar /> duration: 800, // Dauer der Animationen
<main className="flex-1 pt-16">{children}</main> once: true, // nur einmal pro Element
<Footer /> offset: 100, // wie weit gescrollt werden muss
</div> easing: "ease-out-cubic",
); });
}; }, []);
export default Layout; return (
<div className="min-h-screen bg-white flex flex-col">
<Navbar />
<main className="flex-1 pt-16">{children}</main>
<Footer />
</div>
);
};
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 Satzung from "./pages/Satzung";
import Beitraege from "./pages/Beitraege"; import Beitraege from "./pages/Beitraege";
import { ThemeProvider } from "@/context/ThemeContext";
import VideosPage from "./pages/VideosPage";
ReactDOM.createRoot(document.getElementById("root")!).render( ReactDOM.createRoot(document.getElementById("root")!).render(
<React.StrictMode> <React.StrictMode>
<ThemeProvider>
<BrowserRouter> <BrowserRouter>
<AuthProvider> <AuthProvider>
<Routes> <Routes>
@ -72,6 +76,9 @@ ReactDOM.createRoot(document.getElementById("root")!).render(
<Route path="/satzung" element={<Layout><Satzung /></Layout>} /> <Route path="/satzung" element={<Layout><Satzung /></Layout>} />
<Route path="/impressum" element={<Layout><Impressum /></Layout>} /> <Route path="/impressum" element={<Layout><Impressum /></Layout>} />
<Route path="/beitraege" element={<Layout><Beitraege /></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" 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>} />
<Route path="/admin/users" element={<PrivateRoute><Layout><UserManagementPage /></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/gallery" element={<PrivateRoute><Layout><GalleryManager /></Layout></PrivateRoute>} />
<Route path="/admin/events" element={<PrivateRoute><Layout><EventsAdmin /></Layout></PrivateRoute>} /> <Route path="/admin/events" element={<PrivateRoute><Layout><EventsAdmin /></Layout></PrivateRoute>} />
</Routes> </Routes>
</AuthProvider> </AuthProvider>
</BrowserRouter> </BrowserRouter>
</ThemeProvider>
</React.StrictMode> </React.StrictMode>
); );

View File

@ -17,6 +17,25 @@ const AlleNeuigkeitenPage = () => {
const [news, setNews] = useState<NewsItem[]>([]); const [news, setNews] = useState<NewsItem[]>([]);
const [expandedIds, setExpandedIds] = useState<number[]>([]); const [expandedIds, setExpandedIds] = useState<number[]>([]);
const [activeCardId, setActiveCardId] = useState<number | null>(null); 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(() => { useEffect(() => {
fetch(`${apiBase}/api/news`) 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 //Toggle der Cards
const toggleExpand = (id: number) => { const toggleExpand = (id: number) => {
@ -47,8 +59,24 @@ const AlleNeuigkeitenPage = () => {
} }
return ( return (
<div className="max-w-5xl mx-auto px-4 py-12"> <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> <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 */} {/* News nach Jahren gruppiert */}
{Object.entries(groupedNews).map(([year, items]) => ( {Object.entries(groupedNews).map(([year, items]) => (
@ -69,7 +97,7 @@ const AlleNeuigkeitenPage = () => {
src={ src={
item.image_url item.image_url
? `${apiBase}${item.image_url}` ? `${apiBase}${item.image_url}`
: "https://via.placeholder.com/400x300?text=Kein+Bild" : defaultImage
} }
alt={item.title} alt={item.title}
className="w-full h-full object-cover" 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: [ plugins: [
require("tailwindcss-animate"), require("tailwindcss-animate"),
require("@tailwindcss/typography"), require("@tailwindcss/typography"),
require('@tailwindcss/aspect-ratio')
], ],
} satisfies Config; } satisfies Config;