Merge branch 'main' of https://gitea.marc-wieland.de/mrcwlnd/volleyball-dev-frontend
40
package-lock.json
generated
@ -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",
|
||||
|
||||
@ -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",
|
||||
|
||||
|
Before Width: | Height: | Size: 1.7 MiB |
|
Before Width: | Height: | Size: 2.0 MiB |
|
Before Width: | Height: | Size: 1.3 MiB |
|
Before Width: | Height: | Size: 1.7 MiB |
|
Before Width: | Height: | Size: 2.2 MiB |
|
Before Width: | Height: | Size: 1.1 MiB |
|
Before Width: | Height: | Size: 1.1 MiB |
|
Before Width: | Height: | Size: 1.0 MiB |
|
Before Width: | Height: | Size: 1.0 MiB |
|
Before Width: | Height: | Size: 1.0 MiB |
|
Before Width: | Height: | Size: 1.1 MiB |
|
Before Width: | Height: | Size: 1005 KiB |
|
Before Width: | Height: | Size: 1.1 MiB |
|
Before Width: | Height: | Size: 1.1 MiB |
BIN
public/images/tgl-ball.png
Normal file
|
After Width: | Height: | Size: 1.7 MiB |
@ -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();
|
||||
|
||||
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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={() => {
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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">
|
||||
|
||||
22
src/components/ThemeToggle.tsx
Normal 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;
|
||||
40
src/context/ThemeContext.tsx
Normal 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);
|
||||
@ -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",
|
||||
});
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-white flex flex-col">
|
||||
<Navbar />
|
||||
|
||||
7
src/lib/axios.ts
Normal file
@ -0,0 +1,7 @@
|
||||
import axios from "axios";
|
||||
|
||||
const api = axios.create({
|
||||
baseURL: import.meta.env.VITE_API_URL,
|
||||
});
|
||||
|
||||
export default api;
|
||||
@ -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>
|
||||
);
|
||||
@ -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
@ -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;
|
||||
@ -114,5 +114,6 @@ export default {
|
||||
plugins: [
|
||||
require("tailwindcss-animate"),
|
||||
require("@tailwindcss/typography"),
|
||||
require('@tailwindcss/aspect-ratio')
|
||||
],
|
||||
} satisfies Config;
|
||||
|
||||