Added event pages
Some checks are pending
Deploy Volleyball Dev / deploy (push) Waiting to run

This commit is contained in:
Marc Wieland 2025-06-01 16:19:05 +02:00
parent d78ece0dfd
commit 692f9bdd2a
14 changed files with 454 additions and 82 deletions

166
package-lock.json generated
View File

@ -54,6 +54,7 @@
"next-themes": "^0.3.0", "next-themes": "^0.3.0",
"pdfjs-dist": "^5.2.133", "pdfjs-dist": "^5.2.133",
"react": "^18.3.1", "react": "^18.3.1",
"react-big-calendar": "^1.19.2",
"react-day-picker": "^8.10.1", "react-day-picker": "^8.10.1",
"react-dom": "^18.3.1", "react-dom": "^18.3.1",
"react-google-recaptcha": "^3.1.0", "react-google-recaptcha": "^3.1.0",
@ -1116,6 +1117,16 @@
"node": ">=14" "node": ">=14"
} }
}, },
"node_modules/@popperjs/core": {
"version": "2.11.8",
"resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz",
"integrity": "sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==",
"license": "MIT",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/popperjs"
}
},
"node_modules/@radix-ui/number": { "node_modules/@radix-ui/number": {
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/@radix-ui/number/-/number-1.1.0.tgz", "resolved": "https://registry.npmjs.org/@radix-ui/number/-/number-1.1.0.tgz",
@ -2695,6 +2706,18 @@
"node": ">=14.0.0" "node": ">=14.0.0"
} }
}, },
"node_modules/@restart/hooks": {
"version": "0.4.16",
"resolved": "https://registry.npmjs.org/@restart/hooks/-/hooks-0.4.16.tgz",
"integrity": "sha512-f7aCv7c+nU/3mF7NWLtVVr0Ra80RqsO89hO72r+Y/nvQr5+q0UFGkocElTH6MJApvReVh6JHUFYn2cw1WdHF3w==",
"license": "MIT",
"dependencies": {
"dequal": "^2.0.3"
},
"peerDependencies": {
"react": ">=16.8.0"
}
},
"node_modules/@rollup/rollup-android-arm-eabi": { "node_modules/@rollup/rollup-android-arm-eabi": {
"version": "4.24.0", "version": "4.24.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.24.0.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.24.0.tgz",
@ -3404,6 +3427,12 @@
"integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==", "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/@types/warning": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/@types/warning/-/warning-3.0.3.tgz",
"integrity": "sha512-D1XC7WK8K+zZEveUPY+cf4+kgauk8N4eHr/XIHXGlGYkHLud6hK9lYfZk1ry1TNh798cZUCgb6MqGEG8DkJt6Q==",
"license": "MIT"
},
"node_modules/@typescript-eslint/eslint-plugin": { "node_modules/@typescript-eslint/eslint-plugin": {
"version": "8.11.0", "version": "8.11.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.11.0.tgz", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.11.0.tgz",
@ -4858,6 +4887,12 @@
"node": ">=12" "node": ">=12"
} }
}, },
"node_modules/date-arithmetic": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/date-arithmetic/-/date-arithmetic-4.1.0.tgz",
"integrity": "sha512-QWxYLR5P/6GStZcdem+V1xoto6DMadYWpMXU82ES3/RfR3Wdwr3D0+be7mgOJ+Ov0G9D5Dmb9T17sNLQYj9XOg==",
"license": "MIT"
},
"node_modules/date-fns": { "node_modules/date-fns": {
"version": "3.6.0", "version": "3.6.0",
"resolved": "https://registry.npmjs.org/date-fns/-/date-fns-3.6.0.tgz", "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-3.6.0.tgz",
@ -4868,6 +4903,12 @@
"url": "https://github.com/sponsors/kossnocorp" "url": "https://github.com/sponsors/kossnocorp"
} }
}, },
"node_modules/dayjs": {
"version": "1.11.13",
"resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.13.tgz",
"integrity": "sha512-oaMBel6gjolK862uaPQOVTA7q3TZhuSvuMQAAglQDOWYO9A91IrAOUJEyKVlqJlHE0vq5p5UXxzdPfMH/x6xNg==",
"license": "MIT"
},
"node_modules/debug": { "node_modules/debug": {
"version": "4.3.7", "version": "4.3.7",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz", "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz",
@ -5847,6 +5888,11 @@
"url": "https://github.com/sponsors/isaacs" "url": "https://github.com/sponsors/isaacs"
} }
}, },
"node_modules/globalize": {
"version": "0.1.1",
"resolved": "https://registry.npmjs.org/globalize/-/globalize-0.1.1.tgz",
"integrity": "sha512-5e01v8eLGfuQSOvx2MsDMOWS0GFtCx1wPzQSmcHw4hkxFzrQDBO3Xwg/m8Hr/7qXMrHeOIE29qWVzyv06u1TZA=="
},
"node_modules/globals": { "node_modules/globals": {
"version": "15.11.0", "version": "15.11.0",
"resolved": "https://registry.npmjs.org/globals/-/globals-15.11.0.tgz", "resolved": "https://registry.npmjs.org/globals/-/globals-15.11.0.tgz",
@ -6492,6 +6538,12 @@
"integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/lodash-es": {
"version": "4.17.21",
"resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.17.21.tgz",
"integrity": "sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw==",
"license": "MIT"
},
"node_modules/lodash.castarray": { "node_modules/lodash.castarray": {
"version": "4.4.0", "version": "4.4.0",
"resolved": "https://registry.npmjs.org/lodash.castarray/-/lodash.castarray-4.4.0.tgz", "resolved": "https://registry.npmjs.org/lodash.castarray/-/lodash.castarray-4.4.0.tgz",
@ -7009,6 +7061,15 @@
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0-rc" "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0-rc"
} }
}, },
"node_modules/luxon": {
"version": "3.6.1",
"resolved": "https://registry.npmjs.org/luxon/-/luxon-3.6.1.tgz",
"integrity": "sha512-tJLxrKJhO2ukZ5z0gyjY1zPh3Rh88Ej9P7jNrZiHMUXHae1yvI2imgOZtL1TO8TW6biMMKfTtAOoEJANgtWBMQ==",
"license": "MIT",
"engines": {
"node": ">=12"
}
},
"node_modules/magic-string": { "node_modules/magic-string": {
"version": "0.30.12", "version": "0.30.12",
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.12.tgz", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.12.tgz",
@ -7205,6 +7266,12 @@
"integrity": "sha512-aa5tG6sDoK+k70B9iEX1NeyfT8ObCKhNDs6lJVpwF6r8vhUfuKMslIcirq6HIUYuuUYLefcEQOn9bSBOvawtwg==", "integrity": "sha512-aa5tG6sDoK+k70B9iEX1NeyfT8ObCKhNDs6lJVpwF6r8vhUfuKMslIcirq6HIUYuuUYLefcEQOn9bSBOvawtwg==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/memoize-one": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-6.0.0.tgz",
"integrity": "sha512-rkpe71W0N0c0Xz6QD0eJETuWAJGnJ9afsl1srmwPrI+yBCkge5EycXXbYRyvL29zZVUWQCY7InPRCv3GDXuZNw==",
"license": "MIT"
},
"node_modules/merge-refs": { "node_modules/merge-refs": {
"version": "1.3.0", "version": "1.3.0",
"resolved": "https://registry.npmjs.org/merge-refs/-/merge-refs-1.3.0.tgz", "resolved": "https://registry.npmjs.org/merge-refs/-/merge-refs-1.3.0.tgz",
@ -7759,6 +7826,27 @@
"license": "MIT", "license": "MIT",
"optional": true "optional": true
}, },
"node_modules/moment": {
"version": "2.30.1",
"resolved": "https://registry.npmjs.org/moment/-/moment-2.30.1.tgz",
"integrity": "sha512-uEmtNhbDOrWPFS+hdjFCBfy9f2YoyzRpwcl+DqpC6taX21FzsTLQVbMV/W7PzNSX6x/bhC1zA3c2UQ5NzH6how==",
"license": "MIT",
"engines": {
"node": "*"
}
},
"node_modules/moment-timezone": {
"version": "0.5.48",
"resolved": "https://registry.npmjs.org/moment-timezone/-/moment-timezone-0.5.48.tgz",
"integrity": "sha512-f22b8LV1gbTO2ms2j2z13MuPogNoh5UzxL3nzNAYKGraILnbGc9NEE6dyiiiLv46DGRb8A4kg8UKWLjPthxBHw==",
"license": "MIT",
"dependencies": {
"moment": "^2.29.4"
},
"engines": {
"node": "*"
}
},
"node_modules/ms": { "node_modules/ms": {
"version": "2.1.3", "version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
@ -8485,6 +8573,43 @@
"react": ">=16.4.1" "react": ">=16.4.1"
} }
}, },
"node_modules/react-big-calendar": {
"version": "1.19.2",
"resolved": "https://registry.npmjs.org/react-big-calendar/-/react-big-calendar-1.19.2.tgz",
"integrity": "sha512-2orH+TOXPJBlQGwSl9ZnTK2WZR9OfVf0r1s8mnbpjvtENZfmWHP6nXqxmten1vkvzOMqefVGjh5GurM27HHOZw==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.20.7",
"clsx": "^1.2.1",
"date-arithmetic": "^4.1.0",
"dayjs": "^1.11.7",
"dom-helpers": "^5.2.1",
"globalize": "^0.1.1",
"invariant": "^2.2.4",
"lodash": "^4.17.21",
"lodash-es": "^4.17.21",
"luxon": "^3.2.1",
"memoize-one": "^6.0.0",
"moment": "^2.29.4",
"moment-timezone": "^0.5.40",
"prop-types": "^15.8.1",
"react-overlays": "^5.2.1",
"uncontrollable": "^7.2.1"
},
"peerDependencies": {
"react": "^16.14.0 || ^17 || ^18 || ^19",
"react-dom": "^16.14.0 || ^17 || ^18 || ^19"
}
},
"node_modules/react-big-calendar/node_modules/clsx": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/clsx/-/clsx-1.2.1.tgz",
"integrity": "sha512-EcR6r5a8bj6pu3ycsa/E/cKVGuTgZJZdsyUYHOksG/UHIiKfjxzRxYJpyVBwYaQeOvghal9fcc4PidlgzugAQg==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/react-day-picker": { "node_modules/react-day-picker": {
"version": "8.10.1", "version": "8.10.1",
"resolved": "https://registry.npmjs.org/react-day-picker/-/react-day-picker-8.10.1.tgz", "resolved": "https://registry.npmjs.org/react-day-picker/-/react-day-picker-8.10.1.tgz",
@ -8559,6 +8684,12 @@
"integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/react-lifecycles-compat": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/react-lifecycles-compat/-/react-lifecycles-compat-3.0.4.tgz",
"integrity": "sha512-fBASbA6LnOU9dOU2eW7aQ8xmYBSXUIWr+UmF9b1efZBazGNO+rcXT/icdKnYm2pTwcRylVUYwW7H1PHfLekVzA==",
"license": "MIT"
},
"node_modules/react-loading-skeleton": { "node_modules/react-loading-skeleton": {
"version": "3.5.0", "version": "3.5.0",
"resolved": "https://registry.npmjs.org/react-loading-skeleton/-/react-loading-skeleton-3.5.0.tgz", "resolved": "https://registry.npmjs.org/react-loading-skeleton/-/react-loading-skeleton-3.5.0.tgz",
@ -8595,6 +8726,26 @@
"react": ">=18" "react": ">=18"
} }
}, },
"node_modules/react-overlays": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/react-overlays/-/react-overlays-5.2.1.tgz",
"integrity": "sha512-GLLSOLWr21CqtJn8geSwQfoJufdt3mfdsnIiQswouuQ2MMPns+ihZklxvsTDKD3cR2tF8ELbi5xUsvqVhR6WvA==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.13.8",
"@popperjs/core": "^2.11.6",
"@restart/hooks": "^0.4.7",
"@types/warning": "^3.0.0",
"dom-helpers": "^5.2.0",
"prop-types": "^15.7.2",
"uncontrollable": "^7.2.1",
"warning": "^4.0.3"
},
"peerDependencies": {
"react": ">=16.3.0",
"react-dom": ">=16.3.0"
}
},
"node_modules/react-pdf": { "node_modules/react-pdf": {
"version": "9.2.1", "version": "9.2.1",
"resolved": "https://registry.npmjs.org/react-pdf/-/react-pdf-9.2.1.tgz", "resolved": "https://registry.npmjs.org/react-pdf/-/react-pdf-9.2.1.tgz",
@ -9686,6 +9837,21 @@
} }
} }
}, },
"node_modules/uncontrollable": {
"version": "7.2.1",
"resolved": "https://registry.npmjs.org/uncontrollable/-/uncontrollable-7.2.1.tgz",
"integrity": "sha512-svtcfoTADIB0nT9nltgjujTi7BzVmwjZClOmskKu/E8FW9BXzg9os8OLr4f8Dlnk0rYWJIWr4wv9eKUXiQvQwQ==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.6.3",
"@types/react": ">=16.9.11",
"invariant": "^2.2.4",
"react-lifecycles-compat": "^3.0.4"
},
"peerDependencies": {
"react": ">=15.0.0"
}
},
"node_modules/undici-types": { "node_modules/undici-types": {
"version": "6.19.8", "version": "6.19.8",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.19.8.tgz", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.19.8.tgz",

View File

@ -57,6 +57,7 @@
"next-themes": "^0.3.0", "next-themes": "^0.3.0",
"pdfjs-dist": "^5.2.133", "pdfjs-dist": "^5.2.133",
"react": "^18.3.1", "react": "^18.3.1",
"react-big-calendar": "^1.19.2",
"react-day-picker": "^8.10.1", "react-day-picker": "^8.10.1",
"react-dom": "^18.3.1", "react-dom": "^18.3.1",
"react-google-recaptcha": "^3.1.0", "react-google-recaptcha": "^3.1.0",

View File

@ -11,6 +11,8 @@ import "aos/dist/aos.css";
const queryClient = new QueryClient(); const queryClient = new QueryClient();
const App = () => ( const App = () => (
<QueryClientProvider client={queryClient}> <QueryClientProvider client={queryClient}>
<TooltipProvider> <TooltipProvider>

View File

@ -6,6 +6,8 @@ import { useAuth } from "@/context/AuthContext";
const AdminDashboard = () => { const AdminDashboard = () => {
const { isAuthenticated, username, isAdmin, logout } = useAuth(); const { isAuthenticated, username, isAdmin, logout } = useAuth();
const defaultImage = "/images/tgl-ball.png";
return ( return (
<div className="max-w-6xl mx-auto py-12 px-4"> <div className="max-w-6xl mx-auto py-12 px-4">
<div className="flex justify-between items-center mb-8"> <div className="flex justify-between items-center mb-8">

View File

@ -5,6 +5,7 @@ import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Textarea } from "@/components/ui/textarea"; import { Textarea } from "@/components/ui/textarea";
import { toast } from "sonner"; import { toast } from "sonner";
import { set } from "date-fns";
const apiBase = import.meta.env.VITE_API_URL; const apiBase = import.meta.env.VITE_API_URL;
@ -115,6 +116,38 @@ const TeamDetail = () => {
} }
}; };
const handleCarouselUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
const files = e.target.files;
if(!files) return;
const uploadPaths: string[] = JSON.parse(carouselImages || "[]");
for (const file of files){
const formData = new FormData();
formData.append("image", file);
const res = await fetch(`${apiBase}/api/teams/${id}/carousel-upload`, {
method: "POST",
body: formData,
});
if(res.ok){
const {path} = await res.json();
uploadPaths.push(path);
}else{
toast.error("Fehler beim Hochladen des Bildes.");
}
}
setCarouselImages(JSON.stringify(uploadPaths));
};
const removeCarouselImage = (index: number) => {
const paths: string[] = JSON.parse(carouselImages || "[]");
paths.splice(index, 1);
setCarouselImages(JSON.stringify(paths));
};
if (loading) return <p className="text-center py-12">Lade Team...</p>; if (loading) return <p className="text-center py-12">Lade Team...</p>;
return ( return (
@ -144,11 +177,31 @@ const TeamDetail = () => {
<label className="text-gray-700">Neue Spieler gesucht?</label> <label className="text-gray-700">Neue Spieler gesucht?</label>
</div> </div>
<Textarea <div>
value={carouselImages} <label className="block mb-1 font-medium text-gray-700">Karussell-Bilder</label>
onChange={(e) => setCarouselImages(e.target.value)} <input
placeholder="Karussell-Bilder (JSON-Array z.B. [\"//uploads/x.jpg\"])" type="file"
/> accept="image/*"
multiple
onChange={handleCarouselUpload}
className="block"
/>
<div className="grid grid-cols-3 gap-4 mt-4">
{JSON.parse(carouselImages || "[]").map((img: string, idx: number) => (
<div key={idx} className="relative group">
<img src={`${apiBase}${img}`} className="rounded shadow" />
<button
type="button"
onClick={() => removeCarouselImage(idx)}
className="absolute top-1 right-1 bg-red-600 text-white rounded-full px-2 py-1 text-xs opacity-80 group-hover:opacity-100"
>
</button>
</div>
))}
</div>
</div>
<Button onClick={handleUpdateTeam} className="bg-frog-500 hover:bg-frog-600 text-white"> <Button onClick={handleUpdateTeam} className="bg-frog-500 hover:bg-frog-600 text-white">
Team speichern Team speichern

View File

@ -1,58 +1,23 @@
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
const AboutSection = () => { const AboutSection = () => {
return ( return (
<section id="about" className="py-16"> <section id="about" className="py-16">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8"> <div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="grid md:grid-cols-2 gap-12 items-center"> <div className="space-y-6 text-lg">
<div> <h2 className="text-3xl font-bold text-gray-900">Über TG Laudenbach</h2>
<h2 className="text-3xl font-bold text-gray-900 mb-6">Über TG Laudenbach</h2> <p>
<div className="space-y-4 text-lg"> Der Volleyballverein TG Laudenbach wurde 1974 gegründet und blickt auf eine erfolgreiche Geschichte zurück.
<p> Wir sind mehr als nur ein Sportverein wir sind eine Gemeinschaft aus begeisterten Volleyballern jeden Alters und Spielniveaus.
Der Volleyballverein TG Laudenbach wurde 1974 gegründet und blickt auf eine erfolgreiche Geschichte zurück. </p>
Wir sind mehr als nur ein Sportverein wir sind eine Gemeinschaft aus begeisterten Volleyballern jeden Alters und Spielniveaus. <p>
</p> Unsere Philosophie ist es, Volleyball für alle zugänglich zu machen. Ob Wettkampf oder Freizeit, jung oder alt,
<p> Anfänger oder Profi bei uns findet jeder seinen Platz.
Unsere Philosophie ist es, Volleyball für alle zugänglich zu machen. Ob Wettkampf oder Freizeit, jung oder alt, </p>
Anfänger oder Profi bei uns findet jeder seinen Platz. <p>
</p> Neben dem sportlichen Erfolg ist uns auch das Miteinander wichtig. Regelmäßige Vereinsfeste, gemeinsame Ausflüge und
<p> unser jährliches Beachvolleyball-Turnier sorgen für ein aktives Vereinsleben.
Neben dem sportlichen Erfolg ist uns auch das Miteinander wichtig. Regelmäßige Vereinsfeste, gemeinsame Ausflüge und </p>
unser jährliches Beachvolleyball-Turnier sorgen für ein aktives Vereinsleben.
</p>
<div className="pt-4">
<Button className="bg-frog-500 hover:bg-frog-600">
Mehr über uns erfahren
</Button>
</div>
</div>
</div>
<div className="space-y-6">
<div className="rounded-xl overflow-hidden shadow-md">
<img
src="https://images.unsplash.com/photo-1574271143515-5cddf8da19be?ixlib=rb-4.0.3&auto=format&fit=crop&w=800&q=80"
alt="Teamfoto TG Laudenbach"
className="w-full h-auto"
/>
</div>
<div className="grid grid-cols-2 gap-6">
<div className="rounded-xl overflow-hidden shadow-md">
<img
src="https://images.unsplash.com/photo-1529676468461-bd9955e9e4f8?ixlib=rb-4.0.3&auto=format&fit=crop&w=400&q=80"
alt="Volleyball Training"
className="w-full h-auto"
/>
</div>
<div className="rounded-xl overflow-hidden shadow-md">
<img
src="https://images.unsplash.com/photo-1612214070475-1e73f478188c?ixlib=rb-4.0.3&auto=format&fit=crop&w=400&q=80"
alt="Volleyballhalle"
className="w-full h-auto"
/>
</div>
</div>
</div>
</div> </div>
</div> </div>
</section> </section>

View File

@ -0,0 +1,82 @@
import { CalendarDays, Clock } from "lucide-react";
import { Link } from "react-router-dom";
const mockEvents = [
{
title: "Heimspiel Herren 1 vs. TSG Nußloch 5",
date: "2025-06-08",
location: "Bergstraßenhalle Laudenbach",
},
{
title: "Sommerfest der Abteilung",
date: "2025-06-15",
location: "Vereinsheim",
},
{
title: "Training Jugend U16",
date: "2025-06-17",
location: "Halle 2",
},
];
const EventsSection = () => {
const getDaysUntil = (dateStr: string) => {
const today = new Date();
const target = new Date(dateStr);
const diffTime = target.getTime() - today.getTime();
const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24));
return diffDays;
};
return (
<section className="bg-gray-50 py-12" id="events">
<div className="section-container text-center">
<h2 className="text-2xl md:text-3xl font-bold mb-4 flex justify-center items-center gap-2">
Kommende Events
</h2>
<p className="text-gray-600 mb-6 text-sm md:text-base">
Spieltage & Events im Überblick
</p>
<div className="flex flex-wrap justify-center gap-4">
{mockEvents.map((event, i) => {
const daysLeft = getDaysUntil(event.date);
return (
<div
key={i}
className="bg-white shadow-sm rounded-xl p-4 text-left border border-gray-200 text-sm w-[280px]"
>
<p className="text-gray-500 text-xs mb-1">
📅 {new Date(event.date).toLocaleDateString("de-DE")}
</p>
<h3 className="font-semibold text-base">{event.title}</h3>
<p className="text-gray-600 text-sm mb-1">{event.location}</p>
<div className="flex items-center gap-1 text-primary text-xs mt-2">
<Clock className="w-4 h-4" />
{daysLeft > 0
? `Noch ${daysLeft} Tag${daysLeft > 1 ? "e" : ""}`
: daysLeft === 0
? "Heute!"
: "Bereits vorbei"}
</div>
</div>
);
})}
</div>
<div className="mt-8">
<Link
to="/events"
className="inline-block bg-primary text-white text-sm py-2 px-4 rounded-lg hover:bg-primary/90 transition"
>
Zum Eventkalender
</Link>
</div>
</div>
</section>
);
};
export default EventsSection;

View File

@ -74,17 +74,18 @@ const Navbar = () => {
{isTeamsOpen && ( {isTeamsOpen && (
<div className="absolute left-0 mt-2 w-40 bg-white rounded-md shadow-lg ring-1 ring-black ring-opacity-5 z-10"> <div className="absolute left-0 mt-2 w-40 bg-white rounded-md shadow-lg ring-1 ring-black ring-opacity-5 z-10">
<Link to="/teams/damen1" className="block px-4 py-2 text-gray-700 hover:bg-frog-50 hover:text-frog-600">Damen 1</Link> <Link to="/teams/4" className="block px-3 py-1 text-gray-700 hover:text-frog-600">Damen 1</Link>
<Link to="/teams/damen2" className="block px-4 py-2 text-gray-700 hover:bg-frog-50 hover:text-frog-600">Damen 2</Link> <Link to="/teams/5" className="block px-3 py-1 text-gray-700 hover:text-frog-600">Damen 2</Link>
<Link to="/teams/herren1" className="block px-4 py-2 text-gray-700 hover:bg-frog-50 hover:text-frog-600">Herren 1</Link> <Link to="/teams/1" className="block px-3 py-1 text-gray-700 hover:text-frog-600">Herren 1</Link>
<Link to="/teams/herren2" className="block px-4 py-2 text-gray-700 hover:bg-frog-50 hover:text-frog-600">Herren 2</Link> <Link to="/teams/2" className="block px-3 py-1 text-gray-700 hover:text-frog-600">Herren 2</Link>
<Link to="/teams/9" className="block px-3 py-1 text-gray-700 hover:text-frog-600">Mixed</Link>
</div> </div>
)} )}
</div> </div>
<button onClick={() => navigateAndScroll("gallery")} className="text-gray-700 hover:text-frog-600 px-3 py-2 rounded-md font-medium">Galerie</button> <button onClick={() => navigateAndScroll("gallery")} className="text-gray-700 hover:text-frog-600 px-3 py-2 rounded-md font-medium">Galerie</button>
<button onClick={() => navigateAndScroll("about")} className="text-gray-700 hover:text-frog-600 px-3 py-2 rounded-md font-medium">Über uns</button> <button onClick={() => navigateAndScroll("about")} className="text-gray-700 hover:text-frog-600 px-3 py-2 rounded-md font-medium whitespace-nowrap">Über uns</button>
<button onClick={() => navigateAndScroll("contact")} className="text-gray-700 hover:text-frog-600 px-3 py-2 rounded-md font-medium">Kontakt</button> <button onClick={() => navigateAndScroll("contact")} className="text-gray-700 hover:text-frog-600 px-3 py-2 rounded-md font-medium">Kontakt</button>
<Link to="/mitglied-werden" className="w-full"> <Link to="/mitglied-werden" className="w-full">
@ -171,10 +172,11 @@ const Navbar = () => {
<div> <div>
<div className="block px-3 py-2 rounded-md text-base font-medium text-gray-700">Teams</div> <div className="block px-3 py-2 rounded-md text-base font-medium text-gray-700">Teams</div>
<div className="pl-6"> <div className="pl-6">
<Link to="/teams/damen1" className="block px-3 py-1 text-gray-700 hover:text-frog-600">Damen 1</Link> <Link to="/teams/4" className="block px-3 py-1 text-gray-700 hover:text-frog-600">Damen 1</Link>
<Link to="/teams/damen2" className="block px-3 py-1 text-gray-700 hover:text-frog-600">Damen 2</Link> <Link to="/teams/5" className="block px-3 py-1 text-gray-700 hover:text-frog-600">Damen 2</Link>
<Link to="/teams/herren1" className="block px-3 py-1 text-gray-700 hover:text-frog-600">Herren 1</Link> <Link to="/teams/1" className="block px-3 py-1 text-gray-700 hover:text-frog-600">Herren 1</Link>
<Link to="/teams/herren2" className="block px-3 py-1 text-gray-700 hover:text-frog-600">Herren 2</Link> <Link to="/teams/2" className="block px-3 py-1 text-gray-700 hover:text-frog-600">Herren 2</Link>
<Link to="/teams/9" className="block px-3 py-1 text-gray-700 hover:text-frog-600">Mixed</Link>
</div> </div>
</div> </div>

View File

@ -1,8 +1,12 @@
import { Navigate } from "react-router-dom"; import { Navigate } from "react-router-dom";
import { useAuth } from "@/context/AuthContext"; import { useAuth } from "@/context/AuthContext";
const PrivateRoute = ({ children }: { children: JSX.Element }) => { const ProtectedRoute = ({ children }: { children: JSX.Element }) => {
const { isAuthenticated } = useAuth(); const { isAuthenticated, isAuthLoading } = useAuth();
if (isAuthLoading) {
return <div className="text-center py-12">🔒 Authentifizierung wird geprüft...</div>;
}
if (!isAuthenticated) { if (!isAuthenticated) {
return <Navigate to="/admin/login" replace />; return <Navigate to="/admin/login" replace />;
@ -11,4 +15,4 @@ const PrivateRoute = ({ children }: { children: JSX.Element }) => {
return children; return children;
}; };
export default PrivateRoute; export default ProtectedRoute;

View File

@ -9,6 +9,7 @@ interface AuthContextType {
login: (token: string) => void; login: (token: string) => void;
logout: () => void; logout: () => void;
isAuthenticated: boolean; isAuthenticated: boolean;
isAuthLoading: boolean;
} }
const AuthContext = createContext<AuthContextType>({ const AuthContext = createContext<AuthContextType>({
@ -19,36 +20,47 @@ const AuthContext = createContext<AuthContextType>({
login: () => {}, login: () => {},
logout: () => {}, logout: () => {},
isAuthenticated: false, isAuthenticated: false,
isAuthLoading: true
}); });
export const AuthProvider = ({ children }: { children: ReactNode }) => { export const AuthProvider = ({ children }: { children: ReactNode }) => {
const [token, setToken] = useState<string | null>(null); const [token, setToken] = useState<string | null>(null);
const [username, setUsername] = useState<string | null>(null); const [username, setUsername] = useState<string | null>(null);
const [role, setRole] = useState<string | null>(null); const [role, setRole] = useState<string | null>(null);
const [isAuthLoading, setIsAuthLoading] = useState(true); // NEU
const isAdmin = role === "admin"; const isAdmin = role === "admin";
const isAuthenticated = !!token; const isAuthenticated = !!token;
useEffect(() => { useEffect(() => {
const storedToken = localStorage.getItem("token"); const storedToken = localStorage.getItem("token");
if (storedToken) { if (storedToken) {
setToken(storedToken);
try { try {
const decoded: any = jwtDecode(storedToken); const decoded: any = jwtDecode(storedToken);
setToken(storedToken);
setUsername(decoded.username); setUsername(decoded.username);
setRole(decoded.role); setRole(decoded.role);
} catch (error) { } catch (error) {
console.error("Token konnte nicht gelesen werden"); console.error("Token konnte nicht gelesen werden");
} }
} }
setIsAuthLoading(false); // Wichtig, auch wenn kein Token
}, []); }, []);
const login = (newToken: string) => { const login = (newToken: string) => {
localStorage.setItem("token", newToken); localStorage.setItem("token", newToken);
setToken(newToken); setToken(newToken);
try {
const decoded: any = jwtDecode(newToken);
setUsername(decoded.username);
setRole(decoded.role);
} catch (err) {
console.error("Token konnte nicht gelesen werden");
}
}; };
const logout = () => { const logout = () => {
localStorage.removeItem("token"); localStorage.removeItem("token");
setToken(null); setToken(null);
@ -57,10 +69,15 @@ export const AuthProvider = ({ children }: { children: ReactNode }) => {
}; };
return ( return (
<AuthContext.Provider value={{ token, username, role, isAdmin, login, logout, isAuthenticated }}> <AuthContext.Provider value={{
token, username, role, isAdmin,
login, logout, isAuthenticated,
isAuthLoading
}}>
{children} {children}
</AuthContext.Provider> </AuthContext.Provider>
); );
}; };
export const useAuth = () => useContext(AuthContext); export const useAuth = () => useContext(AuthContext);

View File

@ -35,6 +35,7 @@ import Beitraege from "./pages/Beitraege";
import { ThemeProvider } from "@/context/ThemeContext"; import { ThemeProvider } from "@/context/ThemeContext";
import VideosPage from "./pages/VideosPage"; import VideosPage from "./pages/VideosPage";
import EventsPage from "./pages/EventsPage";
@ -77,6 +78,7 @@ ReactDOM.createRoot(document.getElementById("root")!).render(
<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="/videos" element={<Layout><VideosPage/></Layout>} />
<Route path="/events" element={<Layout><EventsPage /></Layout>} />
<Route path="/admin" element={<PrivateRoute><Layout><AdminDashboard /></Layout></PrivateRoute>} /> <Route path="/admin" element={<PrivateRoute><Layout><AdminDashboard /></Layout></PrivateRoute>} />

71
src/pages/EventsPage.tsx Normal file
View File

@ -0,0 +1,71 @@
import { Calendar, dateFnsLocalizer } from "react-big-calendar";
import "react-big-calendar/lib/css/react-big-calendar.css";
import { parse, startOfWeek, format, getDay } from "date-fns";
import { de } from "d:/Projects/web/tg/volleyball-dev-frontend/node_modules/date-fns/locale/de";
import { useMemo } from "react";
const locales = {
de: de,
};
const localizer = dateFnsLocalizer({
format,
parse,
startOfWeek: () => startOfWeek(new Date(), { locale: de }),
getDay,
locales,
});
const events = [
{
title: "Heimspiel Herren 1",
start: new Date("2025-06-08T15:00:00"),
end: new Date("2025-06-08T17:00:00"),
allDay: false,
},
{
title: "Sommerfest",
start: new Date("2025-06-15"),
end: new Date("2025-06-15"),
allDay: true,
},
{
title: "Training Jugend U16",
start: new Date("2025-06-17T18:00:00"),
end: new Date("2025-06-17T19:30:00"),
allDay: false,
},
];
const EventsPage = () => {
return (
<div className="min-h-screen bg-white py-12 px-4 md:px-12">
<h1 className="text-3xl font-bold mb-6 text-center">Eventkalender</h1>
<div className="bg-white rounded-xl shadow-md p-4">
<Calendar
localizer={localizer}
events={events}
startAccessor="start"
endAccessor="end"
style={{ height: 600 }}
views={["month", "week", "agenda"]}
messages={{
today: "Heute",
previous: "Zurück",
next: "Weiter",
month: "Monat",
week: "Woche",
day: "Tag",
agenda: "Liste",
date: "Datum",
time: "Uhrzeit",
event: "Event",
noEventsInRange: "Keine Events im gewählten Zeitraum",
}}
/>
</div>
</div>
);
};
export default EventsPage;

View File

@ -7,6 +7,7 @@ import TeamSection from "@/components/TeamSection";
import GallerySection from "@/components/GallerySection"; import GallerySection from "@/components/GallerySection";
import AboutSection from "@/components/AboutSection"; import AboutSection from "@/components/AboutSection";
import ContactSection from "@/components/ContactSection"; import ContactSection from "@/components/ContactSection";
import EventsSection from "../components/EventsSection";
const Index = () => { const Index = () => {
const location = useLocation(); const location = useLocation();
@ -27,6 +28,8 @@ const Index = () => {
<Hero /> <Hero />
<div id="news" /> <div id="news" />
<NewsSection /> <NewsSection />
<div id="events" />
<EventsSection/>
<div id="team" /> <div id="team" />
<TeamSection /> <TeamSection />
<div id="gallery" /> <div id="gallery" />

View File

@ -3,26 +3,29 @@ import { useNavigate } from "react-router-dom";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { useAuth } from "@/context/AuthContext";
const apiBase = import.meta.env.VITE_API_URL; const apiBase = import.meta.env.VITE_API_URL;
const LoginPage = () => { const LoginPage = () => {
const [email, setEmail] = useState(""); // Wird eigentlich Username sein! const [email, setEmail] = useState("");
const [password, setPassword] = useState(""); const [password, setPassword] = useState("");
const [error, setError] = useState(""); const [error, setError] = useState("");
const navigate = useNavigate();
const [loginAttempts, setLoginAttempts] = useState(0); const [loginAttempts, setLoginAttempts] = useState(0);
const [captcha, setCaptcha] = useState(""); const [captcha, setCaptcha] = useState("");
const [showCaptcha, setShowCaptcha] = useState(false); const [showCaptcha, setShowCaptcha] = useState(false);
const navigate = useNavigate();
const { login } = useAuth(); // <-- Context-Funktion holen
const handleLogin = async (e: React.FormEvent) => { const handleLogin = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
setError(""); setError("");
try { try {
// Captcha check
if(showCaptcha && captcha.toLowerCase() !== "laudenbach") { if (showCaptcha && captcha.toLowerCase() !== "laudenbach") {
setError("Captcha falsch. Bitte versuche es erneut."); setError("Captcha falsch. Bitte versuche es erneut.");
return; return;
} }
@ -32,17 +35,18 @@ const LoginPage = () => {
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username: email, password }), body: JSON.stringify({ username: email, password }),
}); });
if (!res.ok) { if (!res.ok) {
if (res.status === 429) { if (res.status === 429) {
throw new Error("rate_limit"); throw new Error("rate_limit");
} }
throw new Error("login_failed"); throw new Error("login_failed");
} }
const data = await res.json(); const data = await res.json();
localStorage.setItem("token", data.token); localStorage.setItem("token", data.token);
navigate("/admin"); login(data.token);
navigate("/admin");
} catch (err: any) { } catch (err: any) {
console.error(err); console.error(err);
if (err.message === "rate_limit") { if (err.message === "rate_limit") {
@ -51,13 +55,11 @@ const LoginPage = () => {
setError("Login fehlgeschlagen. Bitte prüfe Benutzername und Passwort."); setError("Login fehlgeschlagen. Bitte prüfe Benutzername und Passwort.");
const newAttempts = loginAttempts + 1; const newAttempts = loginAttempts + 1;
setLoginAttempts(newAttempts); setLoginAttempts(newAttempts);
if (newAttempts >= 5) {
if(newAttempts >= 3) {
setShowCaptcha(true); setShowCaptcha(true);
} }
} }
} }
}; };
return ( return (