Aktueller Stand
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import Pagination from "./Pagination";
|
||||
|
||||
type EventItem = {
|
||||
id: string;
|
||||
@@ -14,6 +15,7 @@ type EventItem = {
|
||||
locationLng?: number | null;
|
||||
description?: string | null;
|
||||
category?: { id: string; name: string } | null;
|
||||
createdBy?: { name?: string | null; email?: string | null } | null;
|
||||
};
|
||||
|
||||
export default function AdminPanel() {
|
||||
@@ -33,6 +35,12 @@ export default function AdminPanel() {
|
||||
const [editStatus, setEditStatus] = useState<string | null>(null);
|
||||
const [editError, setEditError] = useState<string | null>(null);
|
||||
const [isEditOpen, setIsEditOpen] = useState(false);
|
||||
const [page, setPage] = useState(1);
|
||||
const [pageSize, setPageSize] = useState(20);
|
||||
const [sortKey, setSortKey] = useState<"startAt" | "title" | "category" | "status">(
|
||||
"startAt"
|
||||
);
|
||||
const [sortDir, setSortDir] = useState<"asc" | "desc">("asc");
|
||||
const [importFile, setImportFile] = useState<File | null>(null);
|
||||
const [importCategoryId, setImportCategoryId] = useState("");
|
||||
const [importStatus, setImportStatus] = useState<string | null>(null);
|
||||
@@ -80,6 +88,43 @@ export default function AdminPanel() {
|
||||
loadAllEvents();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
setPage(1);
|
||||
}, [allEvents.length]);
|
||||
|
||||
useEffect(() => {
|
||||
setPage(1);
|
||||
}, [pageSize]);
|
||||
|
||||
const totalPages = Math.max(1, Math.ceil(allEvents.length / pageSize));
|
||||
|
||||
const sortedEvents = [...allEvents].sort((a, b) => {
|
||||
const dir = sortDir === "asc" ? 1 : -1;
|
||||
if (sortKey === "title") {
|
||||
return a.title.localeCompare(b.title) * dir;
|
||||
}
|
||||
if (sortKey === "category") {
|
||||
const aCat = a.category?.name || "Ohne Kategorie";
|
||||
const bCat = b.category?.name || "Ohne Kategorie";
|
||||
return aCat.localeCompare(bCat) * dir;
|
||||
}
|
||||
if (sortKey === "status") {
|
||||
return a.status.localeCompare(b.status) * dir;
|
||||
}
|
||||
const aDate = new Date(a.startAt).getTime();
|
||||
const bDate = new Date(b.startAt).getTime();
|
||||
return (aDate - bDate) * dir;
|
||||
});
|
||||
|
||||
const toggleSort = (nextKey: "startAt" | "title" | "category" | "status") => {
|
||||
if (sortKey === nextKey) {
|
||||
setSortDir((prev) => (prev === "asc" ? "desc" : "asc"));
|
||||
return;
|
||||
}
|
||||
setSortKey(nextKey);
|
||||
setSortDir("asc");
|
||||
};
|
||||
|
||||
const updateStatus = async (id: string, status: "APPROVED" | "REJECTED") => {
|
||||
await fetch(`/api/events/${id}`, {
|
||||
method: "PATCH",
|
||||
@@ -298,53 +343,60 @@ export default function AdminPanel() {
|
||||
|
||||
return (
|
||||
<section className="space-y-4 fade-up">
|
||||
<div>
|
||||
<p className="text-xs uppercase tracking-[0.2em] text-slate-500">Admin</p>
|
||||
<h1 className="text-2xl font-semibold">Offene Vorschläge</h1>
|
||||
</div>
|
||||
{error && <p className="text-sm text-red-600">{error}</p>}
|
||||
{events.length === 0 ? (
|
||||
<div className="card-muted">
|
||||
<section className="card space-y-4">
|
||||
<div>
|
||||
<p className="text-xs uppercase tracking-[0.2em] text-slate-500">
|
||||
Vorschläge
|
||||
</p>
|
||||
<h2 className="text-lg font-semibold">Terminvorschläge</h2>
|
||||
</div>
|
||||
{error && <p className="text-sm text-red-600">{error}</p>}
|
||||
{events.length === 0 ? (
|
||||
<p className="text-slate-600">Keine offenen Vorschläge.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{events.map((event) => (
|
||||
<div key={event.id} className="card">
|
||||
<div className="flex flex-col gap-2 md:flex-row md:items-center md:justify-between">
|
||||
<div>
|
||||
<h2 className="text-lg font-medium">{event.title}</h2>
|
||||
<p className="text-sm text-slate-600">
|
||||
{new Date(event.startAt).toLocaleString()} - {new Date(event.endAt).toLocaleString()}
|
||||
</p>
|
||||
{event.location && (
|
||||
<p className="text-sm text-slate-600">Ort: {event.location}</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => updateStatus(event.id, "APPROVED")}
|
||||
className="rounded-full bg-emerald-600 px-3 py-1.5 text-sm font-semibold text-white"
|
||||
>
|
||||
Freigeben
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => updateStatus(event.id, "REJECTED")}
|
||||
className="rounded-full bg-red-600 px-3 py-1.5 text-sm font-semibold text-white"
|
||||
>
|
||||
Ablehnen
|
||||
</button>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{events.map((event) => (
|
||||
<div key={event.id} className="card-muted">
|
||||
<div className="flex flex-col gap-2 md:flex-row md:items-center md:justify-between">
|
||||
<div>
|
||||
<h3 className="text-lg font-medium">{event.title}</h3>
|
||||
<p className="text-sm text-slate-600">
|
||||
{new Date(event.startAt).toLocaleString()} - {new Date(event.endAt).toLocaleString()}
|
||||
</p>
|
||||
{event.createdBy && (
|
||||
<p className="text-sm text-slate-600">
|
||||
Vorschlag von {event.createdBy.name || event.createdBy.email || "Unbekannt"}
|
||||
</p>
|
||||
)}
|
||||
{event.location && (
|
||||
<p className="text-sm text-slate-600">Ort: {event.location}</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => updateStatus(event.id, "APPROVED")}
|
||||
className="rounded-full bg-emerald-600 px-3 py-1.5 text-sm font-semibold text-white"
|
||||
>
|
||||
Freigeben
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => updateStatus(event.id, "REJECTED")}
|
||||
className="rounded-full bg-red-600 px-3 py-1.5 text-sm font-semibold text-white"
|
||||
>
|
||||
Ablehnen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{event.description && (
|
||||
<p className="mt-2 text-sm text-slate-700">{event.description}</p>
|
||||
)}
|
||||
</div>
|
||||
{event.description && (
|
||||
<p className="mt-2 text-sm text-slate-700">{event.description}</p>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
<section className="card space-y-4">
|
||||
<div>
|
||||
<p className="text-xs uppercase tracking-[0.2em] text-slate-500">
|
||||
@@ -375,30 +427,56 @@ export default function AdminPanel() {
|
||||
Noch keine Kategorien.
|
||||
</span>
|
||||
) : (
|
||||
categories.map((category) => (
|
||||
<div
|
||||
key={category.id}
|
||||
className="flex items-center justify-between rounded-xl border border-slate-200 px-3 py-2 text-sm text-slate-700"
|
||||
>
|
||||
<span>{category.name}</span>
|
||||
<div className="flex gap-2">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{categories.map((category) => (
|
||||
<div
|
||||
key={category.id}
|
||||
className="category-pill flex items-center gap-2 rounded-full border border-slate-200 bg-slate-50 px-3 py-1 text-sm text-slate-700"
|
||||
>
|
||||
<span className="font-medium">{category.name}</span>
|
||||
<button
|
||||
type="button"
|
||||
className="rounded-full border border-slate-200 px-3 py-1 text-xs text-slate-700"
|
||||
className="rounded-full border border-slate-200 p-1 text-slate-600"
|
||||
onClick={() => openCategoryModal(category)}
|
||||
aria-label="Kategorie bearbeiten"
|
||||
>
|
||||
Bearbeiten
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
className="h-4 w-4"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
>
|
||||
<path
|
||||
d="M4 20h4l10-10-4-4L4 16v4z"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path d="M13 7l4 4" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="rounded-full border border-red-200 px-3 py-1 text-xs text-red-600"
|
||||
className="rounded-full border border-red-200 p-1 text-red-600"
|
||||
onClick={() => deleteCategory(category.id)}
|
||||
aria-label="Kategorie löschen"
|
||||
>
|
||||
Löschen
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
className="h-4 w-4"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
>
|
||||
<path d="M3 6h18" strokeLinecap="round" />
|
||||
<path d="M8 6V4h8v2" strokeLinecap="round" />
|
||||
<path d="M19 6l-1 14H6L5 6" strokeLinecap="round" />
|
||||
<path d="M10 11v6M14 11v6" strokeLinecap="round" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
@@ -447,14 +525,20 @@ export default function AdminPanel() {
|
||||
</div>
|
||||
<form onSubmit={importIcal} className="space-y-3">
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<input
|
||||
type="file"
|
||||
accept=".ics,text/calendar"
|
||||
onChange={(event) =>
|
||||
setImportFile(event.currentTarget.files?.[0] || null)
|
||||
}
|
||||
className="block text-sm text-slate-600"
|
||||
/>
|
||||
<label className="btn-ghost cursor-pointer">
|
||||
Datei auswählen
|
||||
<input
|
||||
type="file"
|
||||
accept=".ics,text/calendar"
|
||||
onChange={(event) =>
|
||||
setImportFile(event.currentTarget.files?.[0] || null)
|
||||
}
|
||||
className="sr-only"
|
||||
/>
|
||||
</label>
|
||||
<span className="text-sm text-slate-600">
|
||||
{importFile ? importFile.name : "Keine Datei ausgewählt"}
|
||||
</span>
|
||||
<select
|
||||
value={importCategoryId}
|
||||
onChange={(event) => setImportCategoryId(event.target.value)}
|
||||
@@ -485,6 +569,17 @@ export default function AdminPanel() {
|
||||
</p>
|
||||
<h2 className="text-lg font-semibold">Alle Termine verwalten</h2>
|
||||
</div>
|
||||
{allEvents.length > 0 ? (
|
||||
<Pagination
|
||||
page={page}
|
||||
totalPages={totalPages}
|
||||
pageSize={pageSize}
|
||||
onPageChange={setPage}
|
||||
onPageSizeChange={setPageSize}
|
||||
/>
|
||||
) : (
|
||||
<p className="text-sm text-slate-600">Keine Termine vorhanden.</p>
|
||||
)}
|
||||
{isEditOpen && editEvent && (
|
||||
<div className="fixed inset-0 z-30 flex items-center justify-center bg-black/40 px-4 py-6">
|
||||
<div className="card w-full max-w-2xl max-h-[90vh] overflow-y-auto">
|
||||
@@ -581,10 +676,38 @@ export default function AdminPanel() {
|
||||
<table className="min-w-full text-left text-sm">
|
||||
<thead className="text-xs uppercase tracking-[0.2em] text-slate-500">
|
||||
<tr>
|
||||
<th className="pb-2">Datum</th>
|
||||
<th className="pb-2">Titel</th>
|
||||
<th className="pb-2">Kategorie</th>
|
||||
<th className="pb-2">Status</th>
|
||||
<th className="pb-2">
|
||||
<SortButton
|
||||
label="Datum"
|
||||
active={sortKey === "startAt"}
|
||||
direction={sortDir}
|
||||
onClick={() => toggleSort("startAt")}
|
||||
/>
|
||||
</th>
|
||||
<th className="pb-2">
|
||||
<SortButton
|
||||
label="Titel"
|
||||
active={sortKey === "title"}
|
||||
direction={sortDir}
|
||||
onClick={() => toggleSort("title")}
|
||||
/>
|
||||
</th>
|
||||
<th className="pb-2">
|
||||
<SortButton
|
||||
label="Kategorie"
|
||||
active={sortKey === "category"}
|
||||
direction={sortDir}
|
||||
onClick={() => toggleSort("category")}
|
||||
/>
|
||||
</th>
|
||||
<th className="pb-2">
|
||||
<SortButton
|
||||
label="Status"
|
||||
active={sortKey === "status"}
|
||||
direction={sortDir}
|
||||
onClick={() => toggleSort("status")}
|
||||
/>
|
||||
</th>
|
||||
<th className="pb-2">Aktion</th>
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -596,7 +719,9 @@ export default function AdminPanel() {
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
allEvents.map((event) => (
|
||||
sortedEvents
|
||||
.slice((page - 1) * pageSize, page * pageSize)
|
||||
.map((event) => (
|
||||
<tr key={event.id} className="border-t border-slate-200">
|
||||
<td className="py-3 pr-3">
|
||||
{new Date(event.startAt).toLocaleString("de-DE", {
|
||||
@@ -617,20 +742,46 @@ export default function AdminPanel() {
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<button
|
||||
type="button"
|
||||
className="rounded-full border border-slate-200 px-3 py-1 text-xs text-slate-700"
|
||||
className="rounded-full border border-slate-200 p-2 text-slate-600"
|
||||
onClick={() => {
|
||||
setEditEvent(event);
|
||||
setIsEditOpen(true);
|
||||
}}
|
||||
aria-label="Termin bearbeiten"
|
||||
>
|
||||
Bearbeiten
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
className="h-4 w-4"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
>
|
||||
<path
|
||||
d="M4 20h4l10-10-4-4L4 16v4z"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path d="M13 7l4 4" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="rounded-full border border-red-200 px-3 py-1 text-xs text-red-600"
|
||||
className="rounded-full border border-red-200 p-2 text-red-600"
|
||||
onClick={() => deleteEvent(event.id)}
|
||||
aria-label="Termin löschen"
|
||||
>
|
||||
Löschen
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
className="h-4 w-4"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
>
|
||||
<path d="M3 6h18" strokeLinecap="round" />
|
||||
<path d="M8 6V4h8v2" strokeLinecap="round" />
|
||||
<path d="M19 6l-1 14H6L5 6" strokeLinecap="round" />
|
||||
<path d="M10 11v6M14 11v6" strokeLinecap="round" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
@@ -645,6 +796,34 @@ export default function AdminPanel() {
|
||||
);
|
||||
}
|
||||
|
||||
function SortButton({
|
||||
label,
|
||||
active,
|
||||
direction,
|
||||
onClick
|
||||
}: {
|
||||
label: string;
|
||||
active: boolean;
|
||||
direction: "asc" | "desc";
|
||||
onClick: () => void;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className={`inline-flex items-center gap-2 text-xs uppercase tracking-[0.2em] ${
|
||||
active ? "text-slate-700" : "text-slate-500"
|
||||
}`}
|
||||
>
|
||||
<span>{label}</span>
|
||||
{active && (
|
||||
<span aria-hidden="true" className="text-[10px]">
|
||||
{direction === "asc" ? "▲" : "▼"}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
function StatusIcon({ status }: { status: string }) {
|
||||
if (status === "APPROVED") {
|
||||
return (
|
||||
|
||||
@@ -6,6 +6,7 @@ export default function AdminSystemSettings() {
|
||||
const [apiKey, setApiKey] = useState("");
|
||||
const [provider, setProvider] = useState("osm");
|
||||
const [registrationEnabled, setRegistrationEnabled] = useState(true);
|
||||
const [appName, setAppName] = useState("Vereinskalender");
|
||||
const [logoFile, setLogoFile] = useState<File | null>(null);
|
||||
const [logoVersion, setLogoVersion] = useState(() => Date.now());
|
||||
const [hasLogo, setHasLogo] = useState<boolean | null>(null);
|
||||
@@ -14,14 +15,21 @@ export default function AdminSystemSettings() {
|
||||
|
||||
const load = async () => {
|
||||
try {
|
||||
const response = await fetch("/api/settings/google-places");
|
||||
if (!response.ok) {
|
||||
const [placesResponse, appNameResponse] = await Promise.all([
|
||||
fetch("/api/settings/system"),
|
||||
fetch("/api/settings/app-name")
|
||||
]);
|
||||
if (!placesResponse.ok) {
|
||||
throw new Error("Einstellungen konnten nicht geladen werden.");
|
||||
}
|
||||
const payload = await response.json();
|
||||
const payload = await placesResponse.json();
|
||||
setApiKey(payload.apiKey || "");
|
||||
setProvider(payload.provider || "osm");
|
||||
setRegistrationEnabled(payload.registrationEnabled !== false);
|
||||
if (appNameResponse.ok) {
|
||||
const appPayload = await appNameResponse.json();
|
||||
setAppName(appPayload.name || "Vereinskalender");
|
||||
}
|
||||
} catch (err) {
|
||||
setError((err as Error).message);
|
||||
}
|
||||
@@ -49,18 +57,31 @@ export default function AdminSystemSettings() {
|
||||
setStatus(null);
|
||||
setError(null);
|
||||
|
||||
const response = await fetch("/api/settings/google-places", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ apiKey, provider, registrationEnabled })
|
||||
});
|
||||
const [settingsResponse, appNameResponse] = await Promise.all([
|
||||
fetch("/api/settings/system", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ apiKey, provider, registrationEnabled })
|
||||
}),
|
||||
fetch("/api/settings/app-name", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ name: appName })
|
||||
})
|
||||
]);
|
||||
|
||||
if (!response.ok) {
|
||||
const data = await response.json();
|
||||
if (!settingsResponse.ok) {
|
||||
const data = await settingsResponse.json();
|
||||
setError(data.error || "Speichern fehlgeschlagen.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!appNameResponse.ok) {
|
||||
const data = await appNameResponse.json();
|
||||
setError(data.error || "App-Name konnte nicht gespeichert werden.");
|
||||
return;
|
||||
}
|
||||
|
||||
setStatus("Gespeichert.");
|
||||
};
|
||||
|
||||
@@ -141,20 +162,40 @@ export default function AdminSystemSettings() {
|
||||
<p className="text-sm text-slate-500">Kein Logo hinterlegt.</p>
|
||||
)}
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<input
|
||||
type="file"
|
||||
accept="image/png,image/jpeg,image/webp,image/svg+xml"
|
||||
onChange={(event) =>
|
||||
setLogoFile(event.currentTarget.files?.[0] || null)
|
||||
}
|
||||
className="block text-sm text-slate-600"
|
||||
/>
|
||||
<label className="btn-ghost cursor-pointer">
|
||||
Datei auswählen
|
||||
<input
|
||||
type="file"
|
||||
accept="image/png,image/jpeg,image/webp,image/svg+xml"
|
||||
onChange={(event) =>
|
||||
setLogoFile(event.currentTarget.files?.[0] || null)
|
||||
}
|
||||
className="sr-only"
|
||||
/>
|
||||
</label>
|
||||
<span className="text-sm text-slate-600">
|
||||
{logoFile ? logoFile.name : "Keine Datei ausgewählt"}
|
||||
</span>
|
||||
<button type="submit" className="btn-accent">
|
||||
Logo hochladen
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
<form onSubmit={onSubmit} className="space-y-3">
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-slate-700">
|
||||
App-Name
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={appName}
|
||||
onChange={(event) => setAppName(event.target.value)}
|
||||
className="w-full rounded-xl border border-slate-300 px-3 py-2"
|
||||
placeholder="Vereinskalender"
|
||||
required
|
||||
maxLength={60}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-slate-700">
|
||||
Ortsanbieter
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -84,7 +84,7 @@ export default function EventForm({
|
||||
useEffect(() => {
|
||||
const loadKey = async () => {
|
||||
try {
|
||||
const response = await fetch("/api/settings/google-places");
|
||||
const response = await fetch("/api/settings/system");
|
||||
if (!response.ok) return;
|
||||
const payload = await response.json();
|
||||
setPlacesKey(payload.apiKey || "");
|
||||
|
||||
@@ -11,10 +11,15 @@ export default function NavBar() {
|
||||
const isAdmin = data?.user?.role === "ADMIN" || data?.user?.role === "SUPERADMIN";
|
||||
const isSuperAdmin = data?.user?.role === "SUPERADMIN";
|
||||
const [logoUrl, setLogoUrl] = useState<string | null>(null);
|
||||
const [logoBrightness, setLogoBrightness] = useState<number | null>(null);
|
||||
const [isDarkTheme, setIsDarkTheme] = useState(false);
|
||||
const [isScrolled, setIsScrolled] = useState(false);
|
||||
const [mobileOpen, setMobileOpen] = useState(false);
|
||||
const [appName, setAppName] = useState("Vereinskalender");
|
||||
const linkClass = (href: string) =>
|
||||
pathname === href
|
||||
? "rounded-full bg-slate-900 px-3 py-1 text-white"
|
||||
: "rounded-full px-3 py-1 text-slate-700 hover:bg-slate-100";
|
||||
? "nav-link-active rounded-full px-3 py-1"
|
||||
: "nav-link rounded-full px-3 py-1";
|
||||
|
||||
useEffect(() => {
|
||||
const loadLogo = async () => {
|
||||
@@ -35,21 +40,113 @@ export default function NavBar() {
|
||||
loadLogo();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const loadAppName = async () => {
|
||||
try {
|
||||
const response = await fetch("/api/settings/app-name");
|
||||
if (!response.ok) return;
|
||||
const payload = await response.json();
|
||||
setAppName(payload.name || "Vereinskalender");
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
};
|
||||
loadAppName();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof document === "undefined") return;
|
||||
const root = document.documentElement;
|
||||
const updateTheme = () => {
|
||||
setIsDarkTheme(root.dataset.theme === "dark");
|
||||
};
|
||||
updateTheme();
|
||||
const observer = new MutationObserver(updateTheme);
|
||||
observer.observe(root, { attributes: true, attributeFilter: ["data-theme"] });
|
||||
return () => observer.disconnect();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const onScroll = () => {
|
||||
setIsScrolled(window.scrollY > 12);
|
||||
};
|
||||
onScroll();
|
||||
window.addEventListener("scroll", onScroll, { passive: true });
|
||||
return () => window.removeEventListener("scroll", onScroll);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
setMobileOpen(false);
|
||||
}, [pathname]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!logoUrl) return;
|
||||
let cancelled = false;
|
||||
const img = new Image();
|
||||
img.crossOrigin = "anonymous";
|
||||
img.src = logoUrl;
|
||||
img.onload = () => {
|
||||
if (cancelled) return;
|
||||
const canvas = document.createElement("canvas");
|
||||
const size = 32;
|
||||
canvas.width = size;
|
||||
canvas.height = size;
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (!ctx) return;
|
||||
ctx.drawImage(img, 0, 0, size, size);
|
||||
const data = ctx.getImageData(0, 0, size, size).data;
|
||||
let total = 0;
|
||||
let count = 0;
|
||||
for (let i = 0; i < data.length; i += 4) {
|
||||
const alpha = data[i + 3];
|
||||
if (alpha === 0) continue;
|
||||
const r = data[i];
|
||||
const g = data[i + 1];
|
||||
const b = data[i + 2];
|
||||
total += 0.2126 * r + 0.7152 * g + 0.0722 * b;
|
||||
count += 1;
|
||||
}
|
||||
if (count > 0) {
|
||||
setLogoBrightness(total / count);
|
||||
}
|
||||
};
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [logoUrl]);
|
||||
|
||||
const shouldInvertLogo =
|
||||
logoBrightness !== null &&
|
||||
((isDarkTheme && logoBrightness > 140) ||
|
||||
(!isDarkTheme && logoBrightness < 200));
|
||||
|
||||
return (
|
||||
<header className="sticky top-0 z-20 border-b border-slate-200/70 bg-white/70 backdrop-blur">
|
||||
<div className="mx-auto flex max-w-6xl items-center justify-between px-4 py-4">
|
||||
<Link href="/" className="flex items-center gap-3 text-lg font-semibold tracking-tight text-slate-900">
|
||||
<header className="sticky top-0 z-20 border-b border-slate-200/70 bg-white/70 backdrop-blur transition-all duration-300">
|
||||
<div
|
||||
className={`mx-auto flex max-w-6xl items-center justify-between px-4 transition-all duration-300 ${
|
||||
isScrolled ? "py-2" : "py-4"
|
||||
}`}
|
||||
>
|
||||
<Link
|
||||
href="/"
|
||||
className={`brand-title flex items-center gap-3 font-semibold tracking-tight text-slate-900 transition-all duration-300 ${
|
||||
isScrolled ? "text-base" : "text-lg"
|
||||
}`}
|
||||
>
|
||||
{logoUrl && (
|
||||
<img
|
||||
src={logoUrl}
|
||||
alt="Vereinskalender Logo"
|
||||
className="h-8 w-auto max-w-[140px] object-contain"
|
||||
className={`w-auto object-contain transition-all duration-300 ${
|
||||
isScrolled ? "h-7 max-w-[140px]" : "h-12 max-w-[210px]"
|
||||
}`}
|
||||
style={shouldInvertLogo ? { filter: "invert(1)" } : undefined}
|
||||
onError={() => setLogoUrl(null)}
|
||||
/>
|
||||
)}
|
||||
<span>Vereinskalender</span>
|
||||
<span>{appName}</span>
|
||||
</Link>
|
||||
<nav className="flex items-center gap-3 text-sm">
|
||||
<nav className="hidden items-center gap-3 text-sm md:flex">
|
||||
{data?.user && (
|
||||
<>
|
||||
{isAdmin && (
|
||||
@@ -71,8 +168,18 @@ export default function NavBar() {
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => signOut()}
|
||||
className="btn-primary"
|
||||
className="btn-primary inline-flex items-center gap-2"
|
||||
>
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
className="h-4 w-4"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
>
|
||||
<path d="M10 6h8a2 2 0 012 2v8a2 2 0 01-2 2h-8" strokeLinecap="round" />
|
||||
<path d="M14 12H4m0 0l3-3M4 12l3 3" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
Logout
|
||||
</button>
|
||||
) : (
|
||||
@@ -85,6 +192,99 @@ export default function NavBar() {
|
||||
</button>
|
||||
)}
|
||||
</nav>
|
||||
<button
|
||||
type="button"
|
||||
className="rounded-full border border-slate-200 p-2 text-slate-600 md:hidden"
|
||||
onClick={() => setMobileOpen((prev) => !prev)}
|
||||
aria-label={mobileOpen ? "Menü schließen" : "Menü öffnen"}
|
||||
aria-expanded={mobileOpen}
|
||||
>
|
||||
<span className="relative block h-5 w-5">
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
className={`absolute inset-0 h-5 w-5 transition-all duration-300 ${
|
||||
mobileOpen ? "rotate-90 opacity-0" : "rotate-0 opacity-100"
|
||||
}`}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
>
|
||||
<path d="M3 6h18M3 12h18M3 18h18" strokeLinecap="round" />
|
||||
</svg>
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
className={`absolute inset-0 h-5 w-5 transition-all duration-300 ${
|
||||
mobileOpen ? "rotate-0 opacity-100" : "-rotate-90 opacity-0"
|
||||
}`}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
>
|
||||
<path d="M6 6l12 12M18 6l-12 12" strokeLinecap="round" />
|
||||
</svg>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
{mobileOpen && (
|
||||
<div
|
||||
className="fixed inset-0 z-10 bg-black/30 md:hidden"
|
||||
onClick={() => setMobileOpen(false)}
|
||||
/>
|
||||
)}
|
||||
<div
|
||||
className={`relative z-20 overflow-hidden transition-all duration-300 md:hidden ${
|
||||
mobileOpen ? "max-h-96 opacity-100" : "max-h-0 opacity-0"
|
||||
}`}
|
||||
>
|
||||
<div className="mx-auto max-w-6xl space-y-2 px-4 pb-4">
|
||||
{data?.user && (
|
||||
<>
|
||||
{isAdmin && (
|
||||
<>
|
||||
<Link href="/admin" className="nav-link block rounded-xl px-3 py-2 text-sm">
|
||||
Admin
|
||||
</Link>
|
||||
<Link
|
||||
href="/admin/users"
|
||||
className="nav-link block rounded-xl px-3 py-2 text-sm"
|
||||
>
|
||||
Registrierungen
|
||||
</Link>
|
||||
</>
|
||||
)}
|
||||
<Link href="/settings" className="nav-link block rounded-xl px-3 py-2 text-sm">
|
||||
Einstellungen
|
||||
</Link>
|
||||
</>
|
||||
)}
|
||||
{data?.user ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => signOut()}
|
||||
className="btn-primary inline-flex w-full items-center justify-center gap-2"
|
||||
>
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
className="h-4 w-4"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
>
|
||||
<path d="M10 6h8a2 2 0 012 2v8a2 2 0 01-2 2h-8" strokeLinecap="round" />
|
||||
<path d="M14 12H4m0 0l3-3M4 12l3 3" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
Logout
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => signIn()}
|
||||
className="btn-accent w-full"
|
||||
>
|
||||
Login
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
|
||||
91
components/Pagination.tsx
Normal file
91
components/Pagination.tsx
Normal file
@@ -0,0 +1,91 @@
|
||||
"use client";
|
||||
|
||||
type PaginationProps = {
|
||||
page: number;
|
||||
totalPages: number;
|
||||
onPageChange: (page: number) => void;
|
||||
pageSize: number;
|
||||
onPageSizeChange: (pageSize: number) => void;
|
||||
pageSizeOptions?: number[];
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export default function Pagination({
|
||||
page,
|
||||
totalPages,
|
||||
onPageChange,
|
||||
pageSize,
|
||||
onPageSizeChange,
|
||||
pageSizeOptions = [20, 50, 100],
|
||||
className
|
||||
}: PaginationProps) {
|
||||
const windowPages = Array.from({ length: totalPages }, (_, i) => i + 1).filter(
|
||||
(pageNumber) =>
|
||||
pageNumber === 1 ||
|
||||
pageNumber === totalPages ||
|
||||
Math.abs(pageNumber - page) <= 2
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`flex flex-wrap items-center justify-between gap-2 text-sm text-slate-600 ${
|
||||
className || ""
|
||||
}`}
|
||||
>
|
||||
<span>Seite {page} von {totalPages}</span>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<select
|
||||
value={pageSize}
|
||||
onChange={(event) => onPageSizeChange(Number(event.target.value))}
|
||||
className="rounded-full border border-slate-200 bg-white px-2 py-1 text-xs text-slate-700"
|
||||
aria-label="Einträge pro Seite"
|
||||
>
|
||||
{pageSizeOptions.map((option) => (
|
||||
<option key={option} value={option}>
|
||||
{option} pro Seite
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<button
|
||||
type="button"
|
||||
className="rounded-full border border-slate-200 px-3 py-1 text-xs text-slate-700"
|
||||
onClick={() => onPageChange(Math.max(1, page - 1))}
|
||||
disabled={page <= 1}
|
||||
>
|
||||
Zurück
|
||||
</button>
|
||||
<div className="flex flex-wrap items-center gap-1">
|
||||
{windowPages.map((pageNumber, index) => {
|
||||
const previous = windowPages[index - 1];
|
||||
const gap = previous && pageNumber - previous > 1;
|
||||
return (
|
||||
<span key={pageNumber} className="flex items-center gap-1">
|
||||
{gap && <span className="px-1 text-xs text-slate-400">…</span>}
|
||||
<button
|
||||
type="button"
|
||||
className={`rounded-full border px-2 py-1 text-xs ${
|
||||
pageNumber === page
|
||||
? "border-slate-900 bg-slate-900 text-white"
|
||||
: "border-slate-200 text-slate-700"
|
||||
}`}
|
||||
onClick={() => onPageChange(pageNumber)}
|
||||
aria-current={pageNumber === page ? "page" : undefined}
|
||||
>
|
||||
{pageNumber}
|
||||
</button>
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="rounded-full border border-slate-200 px-3 py-1 text-xs text-slate-700"
|
||||
onClick={() => onPageChange(Math.min(totalPages, page + 1))}
|
||||
disabled={page >= totalPages}
|
||||
>
|
||||
Weiter
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -3,10 +3,14 @@
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
export default function ViewManager() {
|
||||
const [view, setView] = useState<{ id: string; name: string; token: string } | null>(
|
||||
null
|
||||
);
|
||||
const [view, setView] = useState<{
|
||||
id: string;
|
||||
name: string;
|
||||
token: string;
|
||||
icalPastDays?: number;
|
||||
} | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [appName, setAppName] = useState("Vereinskalender");
|
||||
|
||||
const load = async () => {
|
||||
try {
|
||||
@@ -20,8 +24,21 @@ export default function ViewManager() {
|
||||
}
|
||||
};
|
||||
|
||||
const loadAppName = async () => {
|
||||
try {
|
||||
const nameResponse = await fetch("/api/settings/app-name");
|
||||
if (nameResponse.ok) {
|
||||
const payload = await nameResponse.json();
|
||||
setAppName(payload.name || "Vereinskalender");
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
loadAppName();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -30,6 +47,13 @@ export default function ViewManager() {
|
||||
return () => window.removeEventListener("views-updated", handler);
|
||||
}, []);
|
||||
|
||||
const toFilename = (value: string) =>
|
||||
value
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/(^-|-$)/g, "") || "kalender";
|
||||
|
||||
const icalBase = typeof window === "undefined" ? "" : window.location.origin;
|
||||
|
||||
return (
|
||||
@@ -53,7 +77,11 @@ export default function ViewManager() {
|
||||
<div className="rounded-xl border border-slate-200 bg-slate-50 p-3 text-sm">
|
||||
<p className="font-medium">iCal URL</p>
|
||||
<p className="break-all text-slate-700">
|
||||
{icalBase}/api/ical/{view.token}
|
||||
{icalBase}/api/ical/{view.token}/{toFilename(appName)}.ical{
|
||||
view.icalPastDays && view.icalPastDays > 0
|
||||
? `?pastDays=${view.icalPastDays}`
|
||||
: ""
|
||||
}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user