mirror of
https://github.com/arthur-pbty/binouz.git
synced 2026-06-03 15:07:17 +02:00
b7010a1704
- 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.
536 lines
16 KiB
TypeScript
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>
|
|
);
|
|
}
|