Add remaining public + admin pages (parallel build, 26 files)

Built via two parallel agent workflows reading the real Prisma schema, then
integrated + verified.

Public: /shop (categories + storefront), /community hub, /rankings (top by
credits), /staff, /photos (camera_web gallery), /help (categories + rules),
/help/tickets (auth-gated user tickets + create), /settings (auth-gated, update
motto via RCON).

Admin: /admin/catalog (+[id] items), /admin/radio (shouts/apps/schedules +
delete shout), /admin/teams (CRUD), /admin/permissions (read), /admin/wordfilter
(CRUD + RCON push), /admin/ip (whitelist/blacklist CRUD), /admin/applications
(list + dismiss), /admin/logs (chat/command/alert read), /admin/achievements
(read). Server actions all staff-gated.

Header + admin nav extended. Verified: tsc exit 0, vitest 48/48, next build
exit 0 (33 routes); curl-probed every route — public 200/<main>, auth+admin
correctly 307-redirect when unauthorized.
This commit is contained in:
Simo committed 2026-06-28 13:24:55 +02:00
1 parent 9a79acebe7
commit e96b606e1e
27 files changed
+2400

No files matched your search

+87
View File
@@ -0,0 +1,87 @@
import { prisma } from "@/lib/prisma";
export const dynamic = "force-dynamic";
type Achievement = {
name: string;
category: string;
level: number;
rewardAmount: number;
rewardType: number;
points: number | null;
progressNeeded: number;
};
export default async function AdminAchievements() {
let achievements: Achievement[] = [];
try {
achievements = await prisma.achievements.findMany({
select: {
name: true,
category: true,
level: true,
rewardAmount: true,
rewardType: true,
points: true,
progressNeeded: true,
},
orderBy: [{ category: "asc" }, { name: "asc" }, { level: "asc" }],
});
} catch {
achievements = [];
}
// Group by category (already sorted by category, then name, then level).
const groups = new Map<string, Achievement[]>();
for (const a of achievements) {
const list = groups.get(a.category) ?? [];
list.push(a);
groups.set(a.category, list);
}
return (
<main>
<h1>Achievements</h1>
<p className="muted" style={{ marginTop: 0 }}>
Read-only · {achievements.length} rows across {groups.size} categories.
</p>
{groups.size === 0 ? (
<p className="muted">No achievements found.</p>
) : (
[...groups.entries()].map(([category, rows]) => (
<div key={category} className="card" style={{ marginBottom: "1.5rem" }}>
<h3 style={{ marginTop: 0 }}>{category}</h3>
<table style={{ width: "100%", borderCollapse: "collapse" }}>
<thead>
<tr style={{ textAlign: "left", color: "var(--muted)" }}>
<th>Name</th>
<th>Level</th>
<th>Progress needed</th>
<th>Reward type</th>
<th>Reward amount</th>
<th>Points</th>
</tr>
</thead>
<tbody>
{rows.map((a) => (
<tr
key={`${a.name}-${a.level}`}
style={{ borderTop: "1px solid var(--border)" }}
>
<td>{a.name}</td>
<td>{a.level}</td>
<td>{a.progressNeeded}</td>
<td>{a.rewardType}</td>
<td>{a.rewardAmount}</td>
<td>{a.points ?? 0}</td>
</tr>
))}
</tbody>
</table>
</div>
))
)}
</main>
);
}
+91
View File
@@ -0,0 +1,91 @@
import Link from "next/link";
import { dismissApplication } from "@/actions/admin-applications";
import { prisma } from "@/lib/prisma";
export const dynamic = "force-dynamic";
type ApplicationRow = {
id: bigint;
userId: number;
rankId: number;
content: string;
createdAt: Date | null;
};
function formatDate(d: Date | null): string {
return d ? d.toISOString().slice(0, 16).replace("T", " ") : "";
}
export default async function AdminApplications() {
let applications: ApplicationRow[] = [];
try {
applications = await prisma.websiteStaffApplications.findMany({
orderBy: { createdAt: "desc" },
take: 100,
});
} catch {
applications = [];
}
// Resolve applicant usernames (no relation defined on the model).
const userMap = new Map<number, string>();
if (applications.length > 0) {
try {
const ids = Array.from(new Set(applications.map((a) => a.userId)));
const users = await prisma.user.findMany({
where: { id: { in: ids } },
select: { id: true, username: true },
});
for (const u of users) userMap.set(u.id, u.username);
} catch {
// no DB — fall back to raw ids
}
}
return (
<main>
<h1>Staff Applications</h1>
{applications.length === 0 ? (
<p className="muted">No pending applications.</p>
) : (
<div className="grid cols-2">
{applications.map((a) => {
const username = userMap.get(a.userId);
return (
<article key={String(a.id)} className="card">
<div
style={{
display: "flex",
justifyContent: "space-between",
alignItems: "baseline",
gap: "0.5rem",
}}
>
<h3 style={{ margin: "0 0 0.25rem" }}>
{username ? (
<Link href={`/admin/users/${a.userId}`}>{username}</Link>
) : (
<>User #{a.userId}</>
)}
</h3>
<span className="muted">Rank {a.rankId}</span>
</div>
<p className="muted" style={{ marginTop: 0 }}>
{formatDate(a.createdAt)}
</p>
<p style={{ whiteSpace: "pre-wrap", margin: "0 0 0.75rem" }}>{a.content}</p>
<form action={dismissApplication}>
<input type="hidden" name="id" value={String(a.id)} />
<button type="submit" className="btn btn-danger">
Dismiss
</button>
</form>
</article>
);
})}
</div>
)}
</main>
);
}
+161
View File
@@ -0,0 +1,161 @@
import Link from "next/link";
import { notFound } from "next/navigation";
import { prisma } from "@/lib/prisma";
export const dynamic = "force-dynamic";
type CatalogPageRow = {
id: number;
caption: string;
parentId: number;
pageLayout: string;
minRank: number;
visible: string;
enabled: string;
clubOnly: string;
vipOnly: string;
};
type CatalogItemRow = {
id: number;
catalogName: string;
itemIds: string;
amount: number;
costCredits: number;
costPoints: number;
pointsType: number;
limitedStack: number;
limitedSells: number;
orderNumber: number;
clubOnly: string;
};
export default async function AdminCatalogPage({
params,
}: {
params: Promise<{ id: string }>;
}) {
const { id } = await params;
const pageId = Number(id);
if (!Number.isInteger(pageId)) notFound();
let page: CatalogPageRow | null = null;
try {
page = await prisma.catalogPages.findUnique({
where: { id: pageId },
select: {
id: true,
caption: true,
parentId: true,
pageLayout: true,
minRank: true,
visible: true,
enabled: true,
clubOnly: true,
vipOnly: true,
},
});
} catch {
page = null;
}
if (!page) notFound();
let items: CatalogItemRow[] = [];
try {
items = await prisma.catalogItems.findMany({
where: { pageId },
select: {
id: true,
catalogName: true,
itemIds: true,
amount: true,
costCredits: true,
costPoints: true,
pointsType: true,
limitedStack: true,
limitedSells: true,
orderNumber: true,
clubOnly: true,
},
orderBy: [{ orderNumber: "asc" }, { id: "asc" }],
take: 500,
});
} catch {
items = [];
}
const hidden = page.visible !== "1" || page.enabled !== "1";
return (
<main>
<p className="muted">
<Link href="/admin/catalog">← Catalog</Link>
</p>
<h1>{page.caption}</h1>
<p className="muted">
Page #{page.id} · layout {page.pageLayout} · min rank {page.minRank} ·{" "}
{hidden ? "Hidden" : "Visible"}
{page.parentId > 0 ? ` · parent #${page.parentId}` : ""}
{page.clubOnly === "1" ? " · HC only" : ""}
{page.vipOnly === "1" ? " · VIP only" : ""}
</p>
<h2 style={{ marginTop: "1.5rem" }}>Items</h2>
{items.length === 0 ? (
<p className="muted">No items on this page.</p>
) : (
<table style={{ width: "100%", borderCollapse: "collapse" }}>
<thead>
<tr style={{ textAlign: "left", color: "var(--muted)" }}>
<th>ID</th>
<th>Catalog name</th>
<th>Item IDs</th>
<th>Amount</th>
<th>Credits</th>
<th>Points</th>
<th>Limited</th>
<th>Order</th>
</tr>
</thead>
<tbody>
{items.map((it) => (
<tr key={it.id} style={{ borderTop: "1px solid var(--border)" }}>
<td>{it.id}</td>
<td>
{it.catalogName}
{it.clubOnly === "1" ? (
<span className="muted"> · HC</span>
) : null}
</td>
<td className="muted">{it.itemIds}</td>
<td>{it.amount}</td>
<td>
{it.costCredits > 0 ? (
<span className="currency">{it.costCredits}</span>
) : (
<span className="muted">—</span>
)}
</td>
<td className="muted">
{it.costPoints > 0
? `${it.costPoints} (type ${it.pointsType})`
: "—"}
</td>
<td className="muted">
{it.limitedStack > 0
? `${it.limitedSells}/${it.limitedStack}`
: "—"}
</td>
<td>{it.orderNumber}</td>
</tr>
))}
</tbody>
</table>
)}
<p className="muted" style={{ marginTop: "1rem" }}>
{items.length} item{items.length === 1 ? "" : "s"}
</p>
</main>
);
}
+96
View File
@@ -0,0 +1,96 @@
import Link from "next/link";
import { prisma } from "@/lib/prisma";
export const dynamic = "force-dynamic";
type CatalogPageRow = {
id: number;
caption: string;
parentId: number;
pageLayout: string;
minRank: number;
orderNum: number;
visible: string;
enabled: string;
};
export default async function AdminCatalog() {
let pages: CatalogPageRow[] = [];
try {
pages = await prisma.catalogPages.findMany({
select: {
id: true,
caption: true,
parentId: true,
pageLayout: true,
minRank: true,
orderNum: true,
visible: true,
enabled: true,
},
orderBy: [{ parentId: "asc" }, { orderNum: "asc" }, { id: "asc" }],
take: 500,
});
} catch {
pages = [];
}
// Build a quick caption lookup so we can show parent names instead of raw ids.
const captionById = new Map<number, string>();
for (const p of pages) captionById.set(p.id, p.caption);
return (
<main>
<h1>Catalog</h1>
<p className="muted">Browse catalog pages and the items they contain. Read-only.</p>
{pages.length === 0 ? (
<p className="muted">No catalog pages found.</p>
) : (
<table style={{ width: "100%", borderCollapse: "collapse" }}>
<thead>
<tr style={{ textAlign: "left", color: "var(--muted)" }}>
<th>ID</th>
<th>Caption</th>
<th>Parent</th>
<th>Layout</th>
<th>Min rank</th>
<th>Order</th>
<th>Status</th>
</tr>
</thead>
<tbody>
{pages.map((p) => {
const parentName =
p.parentId > 0 ? captionById.get(p.parentId) : null;
const hidden = p.visible !== "1" || p.enabled !== "1";
return (
<tr key={p.id} style={{ borderTop: "1px solid var(--border)" }}>
<td>{p.id}</td>
<td>
<Link href={`/admin/catalog/${p.id}`}>{p.caption}</Link>
</td>
<td className="muted">
{p.parentId <= 0
? "—"
: parentName
? `${parentName} (#${p.parentId})`
: `#${p.parentId}`}
</td>
<td className="muted">{p.pageLayout}</td>
<td>{p.minRank}</td>
<td>{p.orderNum}</td>
<td className="muted">{hidden ? "Hidden" : "Visible"}</td>
</tr>
);
})}
</tbody>
</table>
)}
<p className="muted" style={{ marginTop: "1rem" }}>
{pages.length} page{pages.length === 1 ? "" : "s"}
</p>
</main>
);
}
+149
View File
@@ -0,0 +1,149 @@
import {
addBlacklist,
addWhitelist,
deleteBlacklist,
deleteWhitelist,
} from "@/actions/admin-ip";
import { prisma } from "@/lib/prisma";
export const dynamic = "force-dynamic";
type IpRow = {
id: bigint;
ipAddress: string;
asn: string | null;
createdAt: Date | null;
};
function formatDate(d: Date | null): string {
return d ? d.toISOString().slice(0, 16).replace("T", " ") : "";
}
export default async function AdminIp() {
let whitelist: IpRow[] = [];
let blacklist: IpRow[] = [];
try {
whitelist = await prisma.websiteIpWhitelist.findMany({
orderBy: { id: "desc" },
take: 200,
});
} catch {
whitelist = [];
}
try {
blacklist = await prisma.websiteIpBlacklist.findMany({
orderBy: { id: "desc" },
take: 200,
});
} catch {
blacklist = [];
}
return (
<main>
<h1>IP Management</h1>
<div className="grid cols-2">
{/* ── Whitelist ─────────────────────────────── */}
<section>
<h2>Whitelist</h2>
<form action={addWhitelist} className="card" style={{ marginBottom: "1rem" }}>
<div style={{ display: "flex", gap: "0.5rem", flexWrap: "wrap" }}>
<input
name="ipAddress"
placeholder="IP address"
required
style={{ flex: 1, minWidth: 140 }}
/>
<input name="asn" placeholder="ASN (optional)" style={{ width: 140 }} />
<button type="submit" className="btn btn-secondary">
Add
</button>
</div>
</form>
<table>
<thead>
<tr>
<th>IP</th>
<th>ASN</th>
<th>Added</th>
<th />
</tr>
</thead>
<tbody>
{whitelist.map((w) => (
<tr key={String(w.id)}>
<td>{w.ipAddress}</td>
<td className="muted">{w.asn ?? "—"}</td>
<td className="muted">{formatDate(w.createdAt)}</td>
<td>
<form action={deleteWhitelist}>
<input type="hidden" name="id" value={String(w.id)} />
<button type="submit" className="btn btn-danger">
Remove
</button>
</form>
</td>
</tr>
))}
</tbody>
</table>
{whitelist.length === 0 ? <p className="muted">No whitelisted IPs.</p> : null}
</section>
{/* ── Blacklist ─────────────────────────────── */}
<section>
<h2>Blacklist</h2>
<form action={addBlacklist} className="card" style={{ marginBottom: "1rem" }}>
<div style={{ display: "flex", gap: "0.5rem", flexWrap: "wrap" }}>
<input
name="ipAddress"
placeholder="IP address"
required
style={{ flex: 1, minWidth: 140 }}
/>
<input name="asn" placeholder="ASN (optional)" style={{ width: 140 }} />
<button type="submit" className="btn btn-danger">
Add
</button>
</div>
</form>
<table>
<thead>
<tr>
<th>IP</th>
<th>ASN</th>
<th>Added</th>
<th />
</tr>
</thead>
<tbody>
{blacklist.map((b) => (
<tr key={String(b.id)}>
<td>{b.ipAddress}</td>
<td className="muted">{b.asn ?? "—"}</td>
<td className="muted">{formatDate(b.createdAt)}</td>
<td>
<form action={deleteBlacklist}>
<input type="hidden" name="id" value={String(b.id)} />
<button type="submit" className="btn btn-danger">
Remove
</button>
</form>
</td>
</tr>
))}
</tbody>
</table>
{blacklist.length === 0 ? <p className="muted">No blacklisted IPs.</p> : null}
</section>
</div>
</main>
);
}
+9
View File
@@ -17,7 +17,16 @@ export default async function AdminLayout({ children }: { children: ReactNode })
<Link href="/admin">Dashboard</Link>
<Link href="/admin/users">Users</Link>
<Link href="/admin/articles">Articles</Link>
<Link href="/admin/catalog">Catalog</Link>
<Link href="/admin/radio">Radio</Link>
<Link href="/admin/achievements">Achievements</Link>
<Link href="/admin/bans">Bans</Link>
<Link href="/admin/applications">Applications</Link>
<Link href="/admin/wordfilter">Word Filter</Link>
<Link href="/admin/ip">IP Management</Link>
<Link href="/admin/logs">Logs</Link>
<Link href="/admin/teams">Teams</Link>
<Link href="/admin/permissions">Permissions</Link>
<Link href="/admin/settings">Settings</Link>
</nav>
</aside>
+176
View File
@@ -0,0 +1,176 @@
import { prisma } from "@/lib/prisma";
export const dynamic = "force-dynamic";
// chatlogs_room and commandlogs are @@ignore'd in the schema (no PK), so Prisma
// generates no delegate for them — they are read via $queryRaw against the real
// emulator columns. alert_logs has a proper delegate (prisma.alertLogs).
type ChatlogRoomRow = {
room_id: number;
user_from_id: number;
user_to_id: number;
message: string;
timestamp: number;
};
type CommandlogRow = {
user_id: number;
timestamp: number;
command: string;
params: string;
succes: string;
};
function fromUnix(ts: number | null | undefined): string {
if (!ts || Number(ts) <= 0) return "—";
return new Date(Number(ts) * 1000)
.toISOString()
.slice(0, 16)
.replace("T", " ");
}
function fromDate(d: Date | null | undefined): string {
return d ? d.toISOString().slice(0, 16).replace("T", " ") : "—";
}
export default async function AdminLogs() {
let chatlogs: ChatlogRoomRow[] = [];
try {
chatlogs = await prisma.$queryRaw<ChatlogRoomRow[]>`
SELECT room_id, user_from_id, user_to_id, message, timestamp
FROM chatlogs_room
ORDER BY timestamp DESC
LIMIT 50
`;
} catch {
chatlogs = [];
}
let commandlogs: CommandlogRow[] = [];
try {
commandlogs = await prisma.$queryRaw<CommandlogRow[]>`
SELECT user_id, timestamp, command, params, succes
FROM commandlogs
ORDER BY timestamp DESC
LIMIT 50
`;
} catch {
commandlogs = [];
}
let alertLogs: Awaited<ReturnType<typeof prisma.alertLogs.findMany>> = [];
try {
alertLogs = await prisma.alertLogs.findMany({
orderBy: { id: "desc" },
take: 50,
});
} catch {
alertLogs = [];
}
return (
<main>
<h1>Logs</h1>
<p className="muted" style={{ marginTop: "-0.4rem" }}>
Read-only · 50 most recent of each.
</p>
<section className="card" style={{ marginBottom: "1.5rem" }}>
<h3 style={{ marginTop: 0 }}>Room chat ({chatlogs.length})</h3>
{chatlogs.length === 0 ? (
<p className="muted">No room chat logs.</p>
) : (
<div style={{ overflowX: "auto" }}>
<table>
<thead>
<tr>
<th>When</th>
<th>Room</th>
<th>From</th>
<th>To</th>
<th>Message</th>
</tr>
</thead>
<tbody>
{chatlogs.map((c, i) => (
<tr key={`${c.timestamp}-${c.user_from_id}-${i}`}>
<td className="muted">{fromUnix(c.timestamp)}</td>
<td>{String(c.room_id)}</td>
<td>{String(c.user_from_id)}</td>
<td>{c.user_to_id ? String(c.user_to_id) : "—"}</td>
<td>{c.message}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</section>
<section className="card" style={{ marginBottom: "1.5rem" }}>
<h3 style={{ marginTop: 0 }}>Commands ({commandlogs.length})</h3>
{commandlogs.length === 0 ? (
<p className="muted">No command logs.</p>
) : (
<div style={{ overflowX: "auto" }}>
<table>
<thead>
<tr>
<th>When</th>
<th>User</th>
<th>Command</th>
<th>Params</th>
<th>OK</th>
</tr>
</thead>
<tbody>
{commandlogs.map((c, i) => (
<tr key={`${c.timestamp}-${c.user_id}-${i}`}>
<td className="muted">{fromUnix(c.timestamp)}</td>
<td>{String(c.user_id)}</td>
<td>{c.command}</td>
<td className="muted">{c.params}</td>
<td>{c.succes === "yes" ? "✓" : "✗"}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</section>
<section className="card">
<h3 style={{ marginTop: 0 }}>Alerts ({alertLogs.length})</h3>
{alertLogs.length === 0 ? (
<p className="muted">No alert logs.</p>
) : (
<div style={{ overflowX: "auto" }}>
<table>
<thead>
<tr>
<th>When</th>
<th>Type</th>
<th>Severity</th>
<th>Message</th>
<th>Read</th>
</tr>
</thead>
<tbody>
{alertLogs.map((a) => (
<tr key={String(a.id)}>
<td className="muted">{fromDate(a.createdAt)}</td>
<td>{a.type}</td>
<td>{a.severity}</td>
<td>{a.message}</td>
<td>{a.isRead ? "✓" : "○"}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</section>
</main>
);
}
+62
View File
@@ -0,0 +1,62 @@
import { prisma } from "@/lib/prisma";
export const dynamic = "force-dynamic";
// NOTE: The emulator's standalone `permissions` table is not present in the
// Prisma schema (there is no `prisma.permissions` accessor). The real,
// queryable rank-permission table in this schema is `website_housekeeping_permissions`
// (model WebsiteHousekeepingPermissions), which gates each permission by the
// minimum rank that holds it — exactly the rank-permission data this page lists.
type PermissionRow = {
id: bigint;
permission: string;
minRank: number;
description: string | null;
};
export default async function AdminPermissions() {
let permissions: PermissionRow[] = [];
try {
permissions = await prisma.websiteHousekeepingPermissions.findMany({
select: { id: true, permission: true, minRank: true, description: true },
orderBy: [{ minRank: "asc" }, { permission: "asc" }],
});
} catch {
permissions = [];
}
return (
<main>
<h1>Permissions</h1>
<p className="muted">
Read-only view of rank permissions. Each entry is the minimum rank that
grants the permission.
</p>
{permissions.length === 0 ? (
<p className="muted">No permissions found.</p>
) : (
<table>
<thead>
<tr>
<th>ID</th>
<th>Permission</th>
<th>Min rank</th>
<th>Description</th>
</tr>
</thead>
<tbody>
{permissions.map((p) => (
<tr key={String(p.id)}>
<td>{String(p.id)}</td>
<td>{p.permission}</td>
<td>{p.minRank}</td>
<td className="muted">{p.description ?? "—"}</td>
</tr>
))}
</tbody>
</table>
)}
</main>
);
}
+206
View File
@@ -0,0 +1,206 @@
import { prisma } from '@/lib/prisma';
import { deleteShout } from '@/actions/admin-radio';
export const dynamic = 'force-dynamic';
type Shout = {
id: bigint;
userId: bigint;
message: string;
createdAt: Date | null;
};
type Application = {
id: bigint;
userId: bigint;
realName: string;
age: number;
status: string;
musicStyle: string | null;
discordUsername: string | null;
createdAt: Date | null;
};
type Schedule = {
id: bigint;
userId: bigint;
day: string;
startTime: Date;
endTime: Date;
showName: string | null;
isActive: boolean;
};
function formatDate(d: Date | null): string {
return d ? d.toISOString().slice(0, 16).replace('T', ' ') : '—';
}
function formatTime(d: Date | null): string {
return d ? d.toISOString().slice(11, 16) : '—';
}
export default async function AdminRadioPage() {
let shouts: Shout[] = [];
let applications: Application[] = [];
let schedules: Schedule[] = [];
try {
shouts = await prisma.radioShouts.findMany({
select: { id: true, userId: true, message: true, createdAt: true },
orderBy: { id: 'desc' },
take: 25,
});
} catch {
shouts = [];
}
try {
applications = await prisma.radioApplications.findMany({
select: {
id: true,
userId: true,
realName: true,
age: true,
status: true,
musicStyle: true,
discordUsername: true,
createdAt: true,
},
orderBy: { id: 'desc' },
take: 25,
});
} catch {
applications = [];
}
try {
schedules = await prisma.radioSchedules.findMany({
select: {
id: true,
userId: true,
day: true,
startTime: true,
endTime: true,
showName: true,
isActive: true,
},
orderBy: { id: 'desc' },
take: 25,
});
} catch {
schedules = [];
}
return (
<main>
<h1>Radio Moderation</h1>
<section style={{ marginBottom: '2rem' }}>
<h2>Recent Shouts</h2>
{shouts.length === 0 ? (
<p className="muted">No shouts.</p>
) : (
<table>
<thead>
<tr>
<th>ID</th>
<th>User</th>
<th>Message</th>
<th>Posted</th>
<th />
</tr>
</thead>
<tbody>
{shouts.map((s) => (
<tr key={String(s.id)}>
<td>{String(s.id)}</td>
<td>{String(s.userId)}</td>
<td>{s.message}</td>
<td className="muted">{formatDate(s.createdAt)}</td>
<td>
<form action={deleteShout}>
<input type="hidden" name="id" value={String(s.id)} />
<button type="submit" className="btn btn-danger">
Delete
</button>
</form>
</td>
</tr>
))}
</tbody>
</table>
)}
</section>
<section style={{ marginBottom: '2rem' }}>
<h2>DJ Applications</h2>
{applications.length === 0 ? (
<p className="muted">No applications.</p>
) : (
<table>
<thead>
<tr>
<th>ID</th>
<th>User</th>
<th>Real Name</th>
<th>Age</th>
<th>Style</th>
<th>Discord</th>
<th>Status</th>
<th>Applied</th>
</tr>
</thead>
<tbody>
{applications.map((a) => (
<tr key={String(a.id)}>
<td>{String(a.id)}</td>
<td>{String(a.userId)}</td>
<td>{a.realName}</td>
<td>{a.age}</td>
<td>{a.musicStyle ?? '—'}</td>
<td>{a.discordUsername ?? '—'}</td>
<td>{a.status}</td>
<td className="muted">{formatDate(a.createdAt)}</td>
</tr>
))}
</tbody>
</table>
)}
</section>
<section>
<h2>Schedules</h2>
{schedules.length === 0 ? (
<p className="muted">No schedules.</p>
) : (
<table>
<thead>
<tr>
<th>ID</th>
<th>User</th>
<th>Day</th>
<th>Start</th>
<th>End</th>
<th>Show</th>
<th>Active</th>
</tr>
</thead>
<tbody>
{schedules.map((sc) => (
<tr key={String(sc.id)}>
<td>{String(sc.id)}</td>
<td>{String(sc.userId)}</td>
<td>{sc.day}</td>
<td>{formatTime(sc.startTime)}</td>
<td>{formatTime(sc.endTime)}</td>
<td>{sc.showName ?? '—'}</td>
<td>{sc.isActive ? '●' : '○'}</td>
</tr>
))}
</tbody>
</table>
)}
</section>
</main>
);
}
+129
View File
@@ -0,0 +1,129 @@
import { createTeam, deleteTeam } from "@/actions/admin-teams";
import { prisma } from "@/lib/prisma";
export const dynamic = "force-dynamic";
type TeamRow = {
id: bigint;
rankName: string;
badge: string | null;
jobDescription: string | null;
staffColor: string;
hiddenRank: boolean;
};
export default async function AdminTeams() {
let teams: TeamRow[] = [];
try {
teams = await prisma.websiteTeams.findMany({
select: {
id: true,
rankName: true,
badge: true,
jobDescription: true,
staffColor: true,
hiddenRank: true,
},
orderBy: { id: "asc" },
});
} catch {
teams = [];
}
return (
<main>
<h1>Teams</h1>
<div className="card" style={{ marginBottom: "1.25rem" }}>
<h3 style={{ marginTop: 0 }}>New team rank</h3>
<form action={createTeam} style={{ display: "grid", gap: "0.6rem" }}>
<div className="grid cols-2">
<label style={{ display: "grid", gap: "0.25rem" }}>
<span className="muted">Rank name</span>
<input name="rankName" placeholder="e.g. Moderator" required />
</label>
<label style={{ display: "grid", gap: "0.25rem" }}>
<span className="muted">Badge code</span>
<input name="badge" placeholder="e.g. ADM" />
</label>
<label style={{ display: "grid", gap: "0.25rem" }}>
<span className="muted">Job description</span>
<input name="jobDescription" placeholder="Short description" />
</label>
<label style={{ display: "grid", gap: "0.25rem" }}>
<span className="muted">Staff color</span>
<input name="staffColor" defaultValue="#327fa8" placeholder="#327fa8" />
</label>
</div>
<label style={{ display: "flex", alignItems: "center", gap: "0.4rem" }}>
<input type="checkbox" name="hiddenRank" style={{ width: "auto" }} />
<span className="muted">Hidden rank</span>
</label>
<div>
<button type="submit" className="btn btn-primary">
Create team
</button>
</div>
</form>
</div>
{teams.length === 0 ? (
<p className="muted">No teams yet.</p>
) : (
<table>
<thead>
<tr>
<th>ID</th>
<th>Rank name</th>
<th>Badge</th>
<th>Job description</th>
<th>Color</th>
<th>Hidden</th>
<th></th>
</tr>
</thead>
<tbody>
{teams.map((t) => (
<tr key={String(t.id)}>
<td>{String(t.id)}</td>
<td>{t.rankName}</td>
<td className="muted">{t.badge ?? "—"}</td>
<td className="muted">{t.jobDescription ?? "—"}</td>
<td>
<span
style={{
display: "inline-flex",
alignItems: "center",
gap: "0.4rem",
}}
>
<span
aria-hidden
style={{
width: 14,
height: 14,
borderRadius: 4,
border: "1px solid var(--border-color)",
background: t.staffColor,
}}
/>
{t.staffColor}
</span>
</td>
<td className="muted">{t.hiddenRank ? "Yes" : "No"}</td>
<td>
<form action={deleteTeam}>
<input type="hidden" name="id" value={String(t.id)} />
<button type="submit" className="btn btn-danger">
Delete
</button>
</form>
</td>
</tr>
))}
</tbody>
</table>
)}
</main>
);
}
+59
View File
@@ -0,0 +1,59 @@
import { addWord, deleteWord } from "@/actions/admin-wordfilter";
import { prisma } from "@/lib/prisma";
export const dynamic = "force-dynamic";
type WordRow = { id: bigint; word: string };
export default async function AdminWordFilter() {
let words: WordRow[] = [];
try {
words = await prisma.websiteWordfilter.findMany({
select: { id: true, word: true },
orderBy: { word: "asc" },
take: 500,
});
} catch {
words = [];
}
return (
<main>
<h1>Word Filter</h1>
<p className="muted">Words added here are blocked in-game. Saving pushes the list to the emulator.</p>
<form action={addWord} className="card" style={{ marginBottom: "1.5rem" }}>
<h3 style={{ marginTop: 0 }}>Add word</h3>
<div style={{ display: "flex", gap: "0.5rem", flexWrap: "wrap" }}>
<input name="word" placeholder="Word to filter" required maxLength={255} style={{ flex: 1, minWidth: 200 }} />
<button type="submit" className="btn btn-primary">Add</button>
</div>
</form>
<table style={{ width: "100%", borderCollapse: "collapse" }}>
<thead>
<tr style={{ textAlign: "left", color: "var(--muted)" }}>
<th>ID</th>
<th>Word</th>
<th />
</tr>
</thead>
<tbody>
{words.map((w) => (
<tr key={String(w.id)} style={{ borderTop: "1px solid var(--border)" }}>
<td>{String(w.id)}</td>
<td>{w.word}</td>
<td>
<form action={deleteWord}>
<input type="hidden" name="id" value={String(w.id)} />
<button type="submit" className="btn btn-danger">Delete</button>
</form>
</td>
</tr>
))}
</tbody>
</table>
{words.length === 0 ? <p className="muted">No filtered words.</p> : null}
</main>
);
}