mirror of
https://github.com/arthur-pbty/binouz.git
synced 2026-06-10 02:43:53 +02:00
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.
This commit is contained in:
@@ -0,0 +1,535 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user