Login integriert

This commit is contained in:
Marc Wieland 2025-04-21 17:46:44 +02:00
parent 8f9cd73e50
commit 8a92201b74
10 changed files with 500 additions and 102 deletions

10
package-lock.json generated
View File

@ -43,6 +43,7 @@
"date-fns": "^3.6.0",
"embla-carousel-react": "^8.3.0",
"input-otp": "^1.2.4",
"jwt-decode": "^4.0.0",
"keen-slider": "^6.8.6",
"lucide-react": "^0.462.0",
"next-themes": "^0.3.0",
@ -4950,6 +4951,15 @@
"dev": true,
"license": "MIT"
},
"node_modules/jwt-decode": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/jwt-decode/-/jwt-decode-4.0.0.tgz",
"integrity": "sha512-+KJGIyHgkGuIq3IEBNftfhW/LfWhXUIY6OmyVWjliu5KH1y0fw7VQ8YndE2O4qZdMSd9SqbnC8GOcZEy0Om7sA==",
"license": "MIT",
"engines": {
"node": ">=18"
}
},
"node_modules/keen-slider": {
"version": "6.8.6",
"resolved": "https://registry.npmjs.org/keen-slider/-/keen-slider-6.8.6.tgz",

View File

@ -46,6 +46,7 @@
"date-fns": "^3.6.0",
"embla-carousel-react": "^8.3.0",
"input-otp": "^1.2.4",
"jwt-decode": "^4.0.0",
"keen-slider": "^6.8.6",
"lucide-react": "^0.462.0",
"next-themes": "^0.3.0",

View File

@ -0,0 +1,53 @@
import { Link, useNavigate } from "react-router-dom";
import { Card, CardContent, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { useAuth } from "@/context/AuthContext";
import { LogOut } from "lucide-react";
const AdminDashboard = () => {
const { logout, username } = useAuth();
const navigate = useNavigate();
const handleLogout = () => {
logout();
navigate("/");
};
return (
<div className="max-w-6xl mx-auto py-12 px-4">
<div className="flex justify-between items-center mb-8">
<h1 className="text-3xl font-bold text-frog-600">Willkommen, {username}!</h1>
<Button
onClick={handleLogout}
className="bg-frog-500 hover:bg-frog-600 text-white flex items-center gap-2"
>
<LogOut className="w-4 h-4" />
Logout
</Button>
</div>
<div className="grid md:grid-cols-2 gap-6">
<Link to="/admin/news">
<Card className="hover:shadow-lg transition-shadow cursor-pointer">
<CardContent className="p-6 text-center">
<CardTitle className="text-frog-600">News verwalten</CardTitle>
<p className="text-gray-600 mt-2 text-sm">News erstellen, bearbeiten und löschen</p>
</CardContent>
</Card>
</Link>
{/* Hier später weitere Admin-Bereiche */}
<Link to="/admin/users">
<Card className="hover:shadow-lg transition-shadow cursor-pointer">
<CardContent className="p-6 text-center">
<CardTitle className="text-frog-600">Benutzer verwalten</CardTitle>
<p className="text-gray-600 mt-2 text-sm">Admins erstellen, bearbeiten und löschen</p>
</CardContent>
</Card>
</Link>
</div>
</div>
);
};
export default AdminDashboard;

208
src/admin/NewsManager.tsx Normal file
View File

@ -0,0 +1,208 @@
import { useEffect, useState } from "react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
type NewsItem = {
id: number;
title: string;
description: string;
image_url: string;
team: string;
created_at: string;
};
const NewsManager = () => {
const [news, setNews] = useState<NewsItem[]>([]);
const [showForm, setShowForm] = useState(false);
const [editMode, setEditMode] = useState(false);
const [currentId, setCurrentId] = useState<number | null>(null);
const [newTitle, setNewTitle] = useState("");
const [newDescription, setNewDescription] = useState("");
const [newImageUrl, setNewImageUrl] = useState("");
const [newTeam, setNewTeam] = useState("");
const [showDeleteModal, setShowDeleteModal] = useState(false);
const [deleteId, setDeleteId] = useState<number | null>(null);
useEffect(() => {
loadNews();
}, []);
const loadNews = async () => {
try {
const res = await fetch("http://192.168.50.65:3000/api/news");
const data = await res.json();
setNews(data);
} catch (err) {
console.error("Fehler beim Laden der News:", err);
}
};
const handleCreateOrUpdateNews = async () => {
const method = editMode ? "PUT" : "POST";
const url = editMode
? `http://192.168.50.65:3000/api/news/${currentId}`
: `http://192.168.50.65:3000/api/news`;
try {
const res = await fetch(url, {
method,
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
title: newTitle,
description: newDescription,
image_url: newImageUrl,
team: newTeam,
}),
});
if (res.ok) {
await loadNews();
setShowForm(false);
setEditMode(false);
resetForm();
} else {
console.error("Fehler beim Speichern der News");
}
} catch (err) {
console.error("Fehler beim Speichern der News:", err);
}
};
const handleEdit = (item: NewsItem) => {
setNewTitle(item.title);
setNewDescription(item.description);
setNewImageUrl(item.image_url);
setNewTeam(item.team);
setCurrentId(item.id);
setEditMode(true);
setShowForm(true);
};
const confirmDelete = (id: number) => {
setDeleteId(id);
setShowDeleteModal(true);
};
const handleDelete = async () => {
if (!deleteId) return;
try {
const res = await fetch(`http://192.168.50.65:3000/api/news/${deleteId}`, {
method: "DELETE",
});
if (res.ok) {
await loadNews();
} else {
console.error("Fehler beim Löschen der News");
}
} catch (err) {
console.error("Fehler beim Löschen der News:", err);
} finally {
setShowDeleteModal(false);
setDeleteId(null);
}
};
const resetForm = () => {
setNewTitle("");
setNewDescription("");
setNewImageUrl("");
setNewTeam("");
setCurrentId(null);
};
return (
<div className="max-w-5xl mx-auto py-12 px-4">
<div className="flex justify-between items-center mb-8">
<h1 className="text-3xl font-bold text-frog-600">News verwalten</h1>
<Button
className="bg-frog-500 hover:bg-frog-600 text-white"
onClick={() => {
resetForm();
setEditMode(false);
setShowForm(!showForm);
}}
>
{showForm ? "Abbrechen" : "+ Neue News"}
</Button>
</div>
{showForm && (
<div className="mb-8 border p-4 rounded-md bg-gray-50">
<h2 className="text-xl font-bold text-frog-600 mb-4">
{editMode ? "News bearbeiten" : "Neue News anlegen"}
</h2>
<div className="space-y-4">
<Input placeholder="Titel" value={newTitle} onChange={(e) => setNewTitle(e.target.value)} />
<Textarea placeholder="Beschreibung" value={newDescription} onChange={(e) => setNewDescription(e.target.value)} />
<Input placeholder="Bild-URL" value={newImageUrl} onChange={(e) => setNewImageUrl(e.target.value)} />
<Input placeholder="Team" value={newTeam} onChange={(e) => setNewTeam(e.target.value)} />
<Button onClick={handleCreateOrUpdateNews} className="bg-frog-500 hover:bg-frog-600 text-white w-full">
{editMode ? "Änderungen speichern" : "Speichern"}
</Button>
</div>
</div>
)}
{showDeleteModal && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white p-6 rounded-lg shadow-lg max-w-sm w-full">
<h2 className="text-xl font-bold text-frog-600 mb-4 text-center">News wirklich löschen?</h2>
<div className="flex justify-center gap-4">
<Button
variant="outline"
className="border-gray-300 text-gray-600 hover:bg-gray-100"
onClick={() => setShowDeleteModal(false)}
>
Abbrechen
</Button>
<Button
variant="destructive"
onClick={handleDelete}
>
Ja, löschen
</Button>
</div>
</div>
</div>
)}
<div className="grid md:grid-cols-2 gap-6">
{news.map((item) => (
<Card key={item.id} className="overflow-hidden">
<CardHeader>
<CardTitle>{item.title}</CardTitle>
</CardHeader>
<CardContent>
<p className="text-gray-600 mb-2">{item.description}</p>
<p className="text-xs text-gray-400">Erstellt am {new Date(item.created_at).toLocaleDateString("de-DE")}</p>
<div className="flex gap-2 mt-4">
<Button
size="sm"
variant="outline"
className="border-frog-500 text-frog-600 hover:bg-frog-50"
onClick={() => handleEdit(item)}
>
Bearbeiten
</Button>
<Button
size="sm"
variant="destructive"
onClick={() => confirmDelete(item.id)}
>
Löschen
</Button>
</div>
</CardContent>
</Card>
))}
</div>
</div>
);
};
export default NewsManager;

View File

@ -1,11 +1,14 @@
import { useState } from 'react';
import { Button } from "@/components/ui/button";
import { Menu, X, Volleyball, ChevronDown, Users } from "lucide-react";
import { Link } from "react-router-dom";
import { Link, useNavigate } from "react-router-dom";
import { useAuth } from '@/context/AuthContext';
const Navbar = () => {
const [isMenuOpen, setIsMenuOpen] = useState(false);
const [isTeamsOpen, setIsTeamsOpen] = useState(false);
const { isAuthenticated, username, logout } = useAuth();
const navigate = useNavigate();
const toggleMenu = () => {
setIsMenuOpen(!isMenuOpen);
@ -25,7 +28,6 @@ const Navbar = () => {
<a href="#" className="text-gray-700 hover:text-frog-600 px-3 py-2 rounded-md font-medium">Startseite</a>
<a href="#news" className="text-gray-700 hover:text-frog-600 px-3 py-2 rounded-md font-medium">Aktuelles</a>
{/* Gemeinsames Container-Div für Hover */}
<div
className="relative"
onMouseEnter={() => setIsTeamsOpen(true)}
@ -40,9 +42,7 @@ const Navbar = () => {
{/* Dropdown */}
{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/damen2" className="block px-4 py-2 text-gray-700 hover:bg-frog-50 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>
@ -54,14 +54,52 @@ const Navbar = () => {
<a href="#gallery" className="text-gray-700 hover:text-frog-600 px-3 py-2 rounded-md font-medium">Galerie</a>
<a href="#about" className="text-gray-700 hover:text-frog-600 px-3 py-2 rounded-md font-medium">Über uns</a>
<a href="#contact" className="text-gray-700 hover:text-frog-600 px-3 py-2 rounded-md font-medium">Kontakt</a>
<Link to="/mitglied-werden" className="w-full">
<Button className="w-full bg-frog-500 hover:bg-frog-600 text-white">
Mitglied werden
</Button>
</Link>
<Link to="/login" className="ml-4 text-frog-600 hover:text-frog-800">
<Users className="h-6 w-6" />
</Link>
{/* USER ICON + DROPDOWN */}
<div className="relative group ml-4">
<Button
variant="ghost"
size="icon"
onClick={() => {
if (isAuthenticated) {
navigate("/admin");
} else {
navigate("/admin/login");
}
}}
>
<Users className="h-6 w-6 text-frog-600 hover:text-frog-800" />
</Button>
{isAuthenticated && (
<>
<span className="text-sm text-frog-600 ml-2 hidden md:inline">
{username}
</span>
{/* Dropdown */}
<div className="absolute right-0 mt-2 hidden group-hover:block bg-white rounded-md shadow-lg z-20 p-2">
<Button
variant="ghost"
className="w-full text-left text-gray-700 hover:bg-frog-50 hover:text-frog-600"
onClick={() => {
logout();
navigate("/admin/login");
}}
>
Logout
</Button>
</div>
</>
)}
</div>
</div>
{/* Mobile menu button */}

View File

@ -0,0 +1,14 @@
import { Navigate } from "react-router-dom";
import { useAuth } from "@/context/AuthContext";
const PrivateRoute = ({ children }: { children: JSX.Element }) => {
const { isAuthenticated } = useAuth();
if (!isAuthenticated) {
return <Navigate to="/admin/login" replace />;
}
return children;
};
export default PrivateRoute;

View File

@ -0,0 +1,58 @@
import { createContext, useContext, useState, useEffect, ReactNode } from "react";
import {jwtDecode} from "jwt-decode";
interface AuthContextType {
token: string | null;
username: string | null;
login: (token: string) => void;
logout: () => void;
isAuthenticated: boolean;
}
const AuthContext = createContext<AuthContextType>({
token: null,
login: () => {},
logout: () => {},
isAuthenticated: false,
username: null
});
export const AuthProvider = ({ children }: { children: ReactNode }) => {
const [token, setToken] = useState<string | null>(null);
const [username, setUsername] = useState<string | null>(null);
useEffect(() => {
const storedToken = localStorage.getItem("token");
if (storedToken) {
setToken(storedToken);
try {
const decoded: any = jwtDecode(storedToken);
setUsername(decoded.username); // <-- Username speichern
} catch (error) {
console.error("Token konnte nicht gelesen werden");
}
}
}, []);
const login = (newToken: string) => {
localStorage.setItem("token", newToken);
setToken(newToken);
};
const logout = () => {
localStorage.removeItem("token");
setToken(null);
};
const isAuthenticated = !!token;
return (
<AuthContext.Provider value={{ token, username, login, logout, isAuthenticated }}>
{children}
</AuthContext.Provider>
);
};
export const useAuth = () => useContext(AuthContext);

View File

@ -12,40 +12,49 @@ import TeamDetailPage from "./pages/TeamDetailPage";
import "./index.css"
import LoginPage from "./pages/LoginPage";
import AdminDashboard from "./admin/AdminDashboard";
import NewsManager from "./admin/NewsManager";
import { AuthProvider } from "./context/AuthContext";
import PrivateRoute from "./components/PrivateRoute";
ReactDOM.createRoot(document.getElementById("root")!).render(
<React.StrictMode>
<BrowserRouter>
<Routes>
<Route
path="/"
element={
<Layout>
<Index />
</Layout>
}
/>
<Route
path="/mitglied-werden"
element={
<Layout>
<MitgliedWerdenPage />
</Layout>
}
/>
<Route
path="/alle-neuigkeiten"
element={
<Layout>
<AlleNeuigkeitenPage />
</Layout>
}
/>
<Route path="/teams/:id" element={<Layout><TeamDetailPage /></Layout>} />
<Route path="/login" element={<Layout><LoginPage /></Layout>}/>
</Routes>
<AuthProvider>
<Routes>
<Route
path="/"
element={
<Layout>
<Index />
</Layout>
}
/>
<Route
path="/mitglied-werden"
element={
<Layout>
<MitgliedWerdenPage />
</Layout>
}
/>
<Route
path="/alle-neuigkeiten"
element={
<Layout>
<AlleNeuigkeitenPage />
</Layout>
}
/>
<Route path="/teams/:id" element={<Layout><TeamDetailPage /></Layout>} />
<Route path="/login" element={<Layout><LoginPage /></Layout>}/>
<Route path="/admin/login" element={<LoginPage />} />
<Route path="/admin" element={<PrivateRoute><Layout><AdminDashboard /></Layout></PrivateRoute>} />
<Route path="/admin/news" element={<PrivateRoute><Layout><NewsManager /></Layout></PrivateRoute>} />
</Routes>
</AuthProvider>
</BrowserRouter>
</React.StrictMode>
);

View File

@ -1,54 +1,28 @@
import { useEffect, useState } from "react";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { useState } from "react";
import { Button } from "@/components/ui/button";
import { Select, SelectItem } from "@/components/ui/select"; // Falls du ein Select UI-Element hast
const news = [
{
id: 1,
title: "Saisonstart 2023/24",
date: "2023-09-15",
description: "Die neue Saison beginnt mit einem Heimspiel gegen VfB Mosbach. Kommt vorbei und unterstützt unser Team!",
image: "https://images.unsplash.com/photo-1612872087720-bb876e2e67d1?ixlib=rb-4.0.3&auto=format&fit=crop&w=500&q=80",
team: "Herren I"
},
{
id: 2,
title: "Beachvolleyball-Turnier",
date: "2023-07-03",
description: "Unser jährliches Beachvolleyball-Turnier war ein voller Erfolg! 24 Teams kämpften um den Sieg.",
image: "https://images.unsplash.com/photo-1583514555852-6f77e7c9e081?ixlib=rb-4.0.3&auto=format&fit=crop&w=500&q=80",
team: "Mixed-Team"
},
{
id: 3,
title: "Neuer Trainer für die Jugendmannschaft",
date: "2023-05-21",
description: "Wir freuen uns, Marc Schneider als neuen Trainer für unsere U16-Mannschaft begrüßen zu dürfen.",
image: "https://images.unsplash.com/photo-1547347298-4074fc3086f0?ixlib=rb-4.0.3&auto=format&fit=crop&w=500&q=80",
team: "Jugend U16"
},
];
type NewsItem = {
id: number;
title: string;
description: string;
image_url: string;
team: string;
created_at: string;
};
const AlleNeuigkeitenPage = () => {
const [selectedTeam, setSelectedTeam] = useState("Alle");
const [news, setNews] = useState<NewsItem[]>([]);
// Teams dynamisch auslesen
const teams = ["Alle", ...Array.from(new Set(news.map((item) => item.team)))];
// Nach Datum absteigend sortieren
const sortedNews = [...news].sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime());
// Nach Team filtern
const filteredNews = sortedNews.filter((item) =>
selectedTeam === "Alle" ? true : item.team === selectedTeam
);
useEffect(() => {
fetch("http://192.168.50.65:3000/api/news")
.then((res) => res.json())
.then((data) => setNews(data))
.catch((err) => console.error("Fehler beim Laden der News:", err));
}, []);
// Gruppieren nach Jahren
const groupedNews = filteredNews.reduce((acc: any, item) => {
const year = new Date(item.date).getFullYear();
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;
@ -58,34 +32,23 @@ 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>
{/* Team-Filter */}
<div className="flex justify-center mb-8">
<select
value={selectedTeam}
onChange={(e) => setSelectedTeam(e.target.value)}
className="border border-gray-300 rounded-md p-2"
>
{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">
<h2 className="text-2xl font-bold text-frog-500 mb-4">{year}</h2>
<div className="grid md:grid-cols-2 gap-6">
{(items as typeof news).map((item) => (
{items.map((item) => (
<Card key={item.id} className="overflow-hidden">
<div className="h-48 overflow-hidden">
<img src={item.image} alt={item.title} className="w-full h-full object-cover" />
<img
src={item.image_url || "https://via.placeholder.com/400x300?text=Kein+Bild"}
alt={item.title}
className="w-full h-full object-cover"
/>
</div>
<CardHeader>
<CardTitle>{item.title}</CardTitle>
<CardDescription>{new Date(item.date).toLocaleDateString("de-DE")}</CardDescription>
<CardDescription>{new Date(item.created_at).toLocaleDateString("de-DE")}</CardDescription>
</CardHeader>
<CardContent>
<p>{item.description}</p>
@ -95,7 +58,6 @@ const AlleNeuigkeitenPage = () => {
</div>
</div>
))}
</div>
);
};

View File

@ -1,8 +1,40 @@
import { useState } from "react";
import { useNavigate } from "react-router-dom";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
const LoginPage = () => {
const [email, setEmail] = useState(""); // Wird eigentlich Username sein!
const [password, setPassword] = useState("");
const [error, setError] = useState("");
const navigate = useNavigate();
const handleLogin = async (e: React.FormEvent) => {
e.preventDefault();
setError("");
try {
const res = await fetch("http://192.168.50.65:3000/api/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username: email, password }),
});
if (!res.ok) {
throw new Error("Login fehlgeschlagen");
}
const data = await res.json();
localStorage.setItem("token", data.token);
navigate("/admin");
} catch (err) {
console.error(err);
setError("Login fehlgeschlagen. Bitte prüfe Benutzername und Passwort.");
}
};
return (
<div className="flex justify-center items-center min-h-screen bg-gray-50">
<Card className="w-full max-w-md">
@ -10,9 +42,22 @@ const LoginPage = () => {
<CardTitle className="text-center text-frog-600">Login</CardTitle>
</CardHeader>
<CardContent>
<form className="space-y-4">
<Input placeholder="E-Mail-Adresse" type="email" required />
<Input placeholder="Passwort" type="password" required />
<form className="space-y-4" onSubmit={handleLogin}>
<Input
placeholder="Benutzername"
type="text"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
/>
<Input
placeholder="Passwort"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
/>
{error && <p className="text-red-500 text-sm">{error}</p>}
<Button type="submit" className="w-full bg-frog-500 hover:bg-frog-600 text-white">
Einloggen
</Button>