Files
binouz/components/admin/admin-dashboard.tsx
Puechberty Arthur b7010a1704 feat: add authentication and user management features
- Implemented AuthButton component for Discord sign-in and sign-out functionality.
- Created CopyButton component for copying server IP addresses.
- Developed EventCard and GradeCard components for displaying events and grades.
- Added Footer and Navbar components for site navigation and information.
- Introduced PurchaseButton for handling grade purchases with Stripe integration.
- Created SectionHeader component for consistent section titles.
- Implemented session management with SessionProvider for NextAuth.
- Set up PostgreSQL database with Docker and Prisma for data management.
- Added admin guard functionality to restrict access to certain routes.
- Configured NextAuth with Discord provider for user authentication.
- Defined Prisma schema for user, admin, grade, event, and purchase models.
- Seeded database with initial grades and events data.
- Added SVG hero image for the landing page.
- Extended NextAuth types to include additional user properties.
2026-04-28 21:09:55 +02:00

536 lines
16 KiB
TypeScript

"use client";
import { useState, useTransition } from "react";
type AdminUser = {
id: string;
discordId: string;
user?: {
name?: string | null;
email?: string | null;
discordUsername?: string | null;
} | null;
};
type Grade = {
id: string;
name: string;
price: number;
description: string;
};
type Event = {
id: string;
title: string;
description: string;
eventDate: string;
};
type AdminDashboardProps = {
initialAdmins: AdminUser[];
initialGrades: Grade[];
initialEvents: Event[];
};
const requestJson = async <T,>(
url: string,
options: RequestInit
): Promise<T> => {
const res = await fetch(url, {
headers: { "Content-Type": "application/json" },
...options,
});
const data = (await res.json().catch(() => ({}))) as T & {
error?: string;
};
if (!res.ok) {
throw new Error(data.error ?? "Request failed");
}
return data;
};
const toInputDate = (iso: string) => {
return new Date(iso).toISOString().slice(0, 16);
};
export default function AdminDashboard({
initialAdmins,
initialGrades,
initialEvents,
}: AdminDashboardProps) {
const [admins, setAdmins] = useState<AdminUser[]>(initialAdmins);
const [grades, setGrades] = useState<Grade[]>(initialGrades);
const [events, setEvents] = useState<Event[]>(initialEvents);
const [notice, setNotice] = useState<string | null>(null);
const [isPending, startTransition] = useTransition();
const handleAddAdmin = (form: FormData) => {
const discordId = form.get("discordId")?.toString().trim();
if (!discordId) return;
startTransition(async () => {
try {
const admin = await requestJson<AdminUser>("/api/admin/admins", {
method: "POST",
body: JSON.stringify({ discordId }),
});
setAdmins((prev) => [admin, ...prev]);
setNotice("Admin added");
} catch (error) {
setNotice((error as Error).message);
}
});
};
const handleAddGrade = (form: FormData) => {
const name = form.get("name")?.toString().trim();
const price = Number(form.get("price"));
const description = form.get("description")?.toString().trim();
if (!name || Number.isNaN(price) || !description) return;
startTransition(async () => {
try {
const grade = await requestJson<Grade>("/api/admin/grades", {
method: "POST",
body: JSON.stringify({ name, price, description }),
});
setGrades((prev) => [...prev, grade]);
setNotice("Grade added");
} catch (error) {
setNotice((error as Error).message);
}
});
};
const handleAddEvent = (form: FormData) => {
const title = form.get("title")?.toString().trim();
const description = form.get("description")?.toString().trim();
const eventDate = form.get("eventDate")?.toString();
if (!title || !description || !eventDate) return;
startTransition(async () => {
try {
const event = await requestJson<Event>("/api/admin/events", {
method: "POST",
body: JSON.stringify({
title,
description,
eventDate: new Date(eventDate).toISOString(),
}),
});
setEvents((prev) => [...prev, event]);
setNotice("Event added");
} catch (error) {
setNotice((error as Error).message);
}
});
};
return (
<div className="space-y-10">
<div className="rounded-3xl border border-white/10 bg-white/5 p-6 backdrop-blur">
<h2 className="text-lg font-semibold text-white">Admin access</h2>
<p className="mt-2 text-sm text-slate-300">
Manage admins, grades, and events. Changes are applied live.
</p>
{notice ? (
<div className="mt-4 rounded-2xl border border-white/10 bg-white/10 px-4 py-3 text-xs text-cyan-200">
{notice}
</div>
) : null}
</div>
<section className="grid gap-6 lg:grid-cols-[1.1fr_0.9fr]">
<div className="rounded-3xl border border-white/10 bg-white/5 p-6 backdrop-blur">
<h3 className="text-base font-semibold text-white">Admins</h3>
<form
onSubmit={(event) => {
event.preventDefault();
handleAddAdmin(new FormData(event.currentTarget));
event.currentTarget.reset();
}}
className="mt-4 flex flex-col gap-3 md:flex-row"
>
<input
name="discordId"
placeholder="Discord ID"
className="flex-1 rounded-2xl border border-white/10 bg-slate-950/60 px-4 py-2 text-sm text-white"
/>
<button
type="submit"
className="rounded-2xl border border-white/10 bg-white/10 px-4 py-2 text-xs font-semibold uppercase tracking-[0.2em] text-white"
disabled={isPending}
>
Add admin
</button>
</form>
<div className="mt-4 space-y-3">
{admins.map((admin) => (
<AdminRow
key={admin.id}
admin={admin}
onDelete={(id) =>
setAdmins((prev) => prev.filter((item) => item.id !== id))
}
/>
))}
</div>
</div>
<div className="rounded-3xl border border-white/10 bg-white/5 p-6 backdrop-blur">
<h3 className="text-base font-semibold text-white">Create grade</h3>
<form
onSubmit={(event) => {
event.preventDefault();
handleAddGrade(new FormData(event.currentTarget));
event.currentTarget.reset();
}}
className="mt-4 space-y-3"
>
<input
name="name"
placeholder="Name"
className="w-full rounded-2xl border border-white/10 bg-slate-950/60 px-4 py-2 text-sm text-white"
/>
<input
name="price"
type="number"
placeholder="Price"
className="w-full rounded-2xl border border-white/10 bg-slate-950/60 px-4 py-2 text-sm text-white"
/>
<textarea
name="description"
placeholder="Description"
className="min-h-30 w-full rounded-2xl border border-white/10 bg-slate-950/60 px-4 py-2 text-sm text-white"
/>
<button
type="submit"
className="w-full rounded-2xl border border-white/10 bg-white/10 px-4 py-2 text-xs font-semibold uppercase tracking-[0.2em] text-white"
disabled={isPending}
>
Add grade
</button>
</form>
</div>
</section>
<section className="rounded-3xl border border-white/10 bg-white/5 p-6 backdrop-blur">
<h3 className="text-base font-semibold text-white">Grades</h3>
<div className="mt-4 space-y-4">
{grades.map((grade) => (
<GradeRow
key={grade.id}
grade={grade}
onUpdate={(updated) =>
setGrades((prev) =>
prev.map((item) =>
item.id === updated.id ? updated : item
)
)
}
onDelete={(id) =>
setGrades((prev) => prev.filter((item) => item.id !== id))
}
/>
))}
</div>
</section>
<section className="grid gap-6 lg:grid-cols-[1fr_1.1fr]">
<div className="rounded-3xl border border-white/10 bg-white/5 p-6 backdrop-blur">
<h3 className="text-base font-semibold text-white">Create event</h3>
<form
onSubmit={(event) => {
event.preventDefault();
handleAddEvent(new FormData(event.currentTarget));
event.currentTarget.reset();
}}
className="mt-4 space-y-3"
>
<input
name="title"
placeholder="Title"
className="w-full rounded-2xl border border-white/10 bg-slate-950/60 px-4 py-2 text-sm text-white"
/>
<input
name="eventDate"
type="datetime-local"
className="w-full rounded-2xl border border-white/10 bg-slate-950/60 px-4 py-2 text-sm text-white"
/>
<textarea
name="description"
placeholder="Description"
className="min-h-30 w-full rounded-2xl border border-white/10 bg-slate-950/60 px-4 py-2 text-sm text-white"
/>
<button
type="submit"
className="w-full rounded-2xl border border-white/10 bg-white/10 px-4 py-2 text-xs font-semibold uppercase tracking-[0.2em] text-white"
disabled={isPending}
>
Add event
</button>
</form>
</div>
<div className="rounded-3xl border border-white/10 bg-white/5 p-6 backdrop-blur">
<h3 className="text-base font-semibold text-white">Events</h3>
<div className="mt-4 space-y-4">
{events.map((event) => (
<EventRow
key={event.id}
event={event}
onUpdate={(updated) =>
setEvents((prev) =>
prev.map((item) =>
item.id === updated.id ? updated : item
)
)
}
onDelete={(id) =>
setEvents((prev) => prev.filter((item) => item.id !== id))
}
/>
))}
</div>
</div>
</section>
</div>
);
}
function AdminRow({
admin,
onDelete,
}: {
admin: AdminUser;
onDelete: (id: string) => void;
}) {
const [isPending, startTransition] = useTransition();
const handleDelete = () => {
startTransition(async () => {
try {
await requestJson<{ ok: boolean }>(`/api/admin/admins/${admin.id}`, {
method: "DELETE",
});
onDelete(admin.id);
} catch {
// no-op
}
});
};
return (
<div className="flex flex-col gap-3 rounded-2xl border border-white/10 bg-slate-950/40 p-4 md:flex-row md:items-center md:justify-between">
<div>
<p className="text-sm font-semibold text-white">
{admin.user?.discordUsername ?? admin.user?.name ?? "Unknown"}
</p>
<p className="text-xs text-slate-400">{admin.discordId}</p>
</div>
<button
type="button"
onClick={handleDelete}
className="rounded-full border border-white/10 px-3 py-1 text-[10px] uppercase tracking-[0.3em] text-white/80"
disabled={isPending}
>
Remove
</button>
</div>
);
}
function GradeRow({
grade,
onUpdate,
onDelete,
}: {
grade: Grade;
onUpdate: (grade: Grade) => void;
onDelete: (id: string) => void;
}) {
const [draft, setDraft] = useState(grade);
const [isPending, startTransition] = useTransition();
const handleSave = () => {
startTransition(async () => {
try {
const updated = await requestJson<Grade>(
`/api/admin/grades/${grade.id}`,
{
method: "PATCH",
body: JSON.stringify({
name: draft.name,
price: draft.price,
description: draft.description,
}),
}
);
onUpdate(updated);
} catch {
// no-op
}
});
};
const handleDelete = () => {
startTransition(async () => {
try {
await requestJson<{ ok: boolean }>(`/api/admin/grades/${grade.id}`, {
method: "DELETE",
});
onDelete(grade.id);
} catch {
// no-op
}
});
};
return (
<div className="rounded-2xl border border-white/10 bg-slate-950/40 p-4">
<div className="grid gap-3 md:grid-cols-[1fr_160px]">
<input
value={draft.name}
onChange={(event) =>
setDraft((prev) => ({ ...prev, name: event.target.value }))
}
className="rounded-2xl border border-white/10 bg-slate-950/60 px-4 py-2 text-sm text-white"
/>
<input
type="number"
value={draft.price}
onChange={(event) =>
setDraft((prev) => ({
...prev,
price: Number(event.target.value),
}))
}
className="rounded-2xl border border-white/10 bg-slate-950/60 px-4 py-2 text-sm text-white"
/>
</div>
<textarea
value={draft.description}
onChange={(event) =>
setDraft((prev) => ({ ...prev, description: event.target.value }))
}
className="mt-3 min-h-25 w-full rounded-2xl border border-white/10 bg-slate-950/60 px-4 py-2 text-sm text-white"
/>
<div className="mt-3 flex flex-wrap gap-3">
<button
type="button"
onClick={handleSave}
className="rounded-full border border-white/10 px-3 py-1 text-[10px] uppercase tracking-[0.3em] text-white/80"
disabled={isPending}
>
Save
</button>
<button
type="button"
onClick={handleDelete}
className="rounded-full border border-white/10 px-3 py-1 text-[10px] uppercase tracking-[0.3em] text-white/80"
disabled={isPending}
>
Delete
</button>
</div>
</div>
);
}
function EventRow({
event,
onUpdate,
onDelete,
}: {
event: Event;
onUpdate: (event: Event) => void;
onDelete: (id: string) => void;
}) {
const [draft, setDraft] = useState({
...event,
eventDate: toInputDate(event.eventDate),
});
const [isPending, startTransition] = useTransition();
const handleSave = () => {
startTransition(async () => {
try {
const updated = await requestJson<Event>(
`/api/admin/events/${event.id}`,
{
method: "PATCH",
body: JSON.stringify({
title: draft.title,
description: draft.description,
eventDate: new Date(draft.eventDate).toISOString(),
}),
}
);
onUpdate(updated);
} catch {
// no-op
}
});
};
const handleDelete = () => {
startTransition(async () => {
try {
await requestJson<{ ok: boolean }>(`/api/admin/events/${event.id}`, {
method: "DELETE",
});
onDelete(event.id);
} catch {
// no-op
}
});
};
return (
<div className="rounded-2xl border border-white/10 bg-slate-950/40 p-4">
<input
value={draft.title}
onChange={(eventValue) =>
setDraft((prev) => ({ ...prev, title: eventValue.target.value }))
}
className="w-full rounded-2xl border border-white/10 bg-slate-950/60 px-4 py-2 text-sm text-white"
/>
<input
type="datetime-local"
value={draft.eventDate}
onChange={(eventValue) =>
setDraft((prev) => ({ ...prev, eventDate: eventValue.target.value }))
}
className="mt-3 w-full rounded-2xl border border-white/10 bg-slate-950/60 px-4 py-2 text-sm text-white"
/>
<textarea
value={draft.description}
onChange={(eventValue) =>
setDraft((prev) => ({ ...prev, description: eventValue.target.value }))
}
className="mt-3 min-h-25 w-full rounded-2xl border border-white/10 bg-slate-950/60 px-4 py-2 text-sm text-white"
/>
<div className="mt-3 flex flex-wrap gap-3">
<button
type="button"
onClick={handleSave}
className="rounded-full border border-white/10 px-3 py-1 text-[10px] uppercase tracking-[0.3em] text-white/80"
disabled={isPending}
>
Save
</button>
<button
type="button"
onClick={handleDelete}
className="rounded-full border border-white/10 px-3 py-1 text-[10px] uppercase tracking-[0.3em] text-white/80"
disabled={isPending}
>
Delete
</button>
</div>
</div>
);
}