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:
1 parent
9a79acebe7
commit
e96b606e1e
27 files changed
+2400
No files matched your search
@@ -0,0 +1,26 @@
|
||||
"use server";
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { requireStaff } from "@/lib/admin/guard";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
export async function dismissApplication(formData: FormData): Promise<void> {
|
||||
await requireStaff();
|
||||
const raw = String(formData.get("id") ?? "").trim();
|
||||
if (!raw) return;
|
||||
|
||||
let id: bigint;
|
||||
try {
|
||||
id = BigInt(raw);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await prisma.websiteStaffApplications.delete({ where: { id } });
|
||||
} catch {
|
||||
// already gone / no DB — nothing to do
|
||||
}
|
||||
|
||||
revalidatePath("/admin/applications");
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
"use server";
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { requireStaff } from "@/lib/admin/guard";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
function parseIp(formData: FormData): string {
|
||||
return String(formData.get("ipAddress") ?? "").trim().slice(0, 255);
|
||||
}
|
||||
|
||||
function parseAsn(formData: FormData): string | null {
|
||||
const asn = String(formData.get("asn") ?? "").trim().slice(0, 255);
|
||||
return asn || null;
|
||||
}
|
||||
|
||||
export async function addWhitelist(formData: FormData): Promise<void> {
|
||||
await requireStaff();
|
||||
const ipAddress = parseIp(formData);
|
||||
if (!ipAddress) return;
|
||||
const asn = parseAsn(formData);
|
||||
await prisma.websiteIpWhitelist.create({
|
||||
data: { ipAddress, asn, whitelistAsn: asn != null },
|
||||
});
|
||||
revalidatePath("/admin/ip");
|
||||
}
|
||||
|
||||
export async function deleteWhitelist(formData: FormData): Promise<void> {
|
||||
await requireStaff();
|
||||
const raw = String(formData.get("id") ?? "").trim();
|
||||
if (!raw) return;
|
||||
await prisma.websiteIpWhitelist.delete({ where: { id: BigInt(raw) } });
|
||||
revalidatePath("/admin/ip");
|
||||
}
|
||||
|
||||
export async function addBlacklist(formData: FormData): Promise<void> {
|
||||
await requireStaff();
|
||||
const ipAddress = parseIp(formData);
|
||||
if (!ipAddress) return;
|
||||
const asn = parseAsn(formData);
|
||||
await prisma.websiteIpBlacklist.create({
|
||||
data: { ipAddress, asn, blacklistAsn: asn != null },
|
||||
});
|
||||
revalidatePath("/admin/ip");
|
||||
}
|
||||
|
||||
export async function deleteBlacklist(formData: FormData): Promise<void> {
|
||||
await requireStaff();
|
||||
const raw = String(formData.get("id") ?? "").trim();
|
||||
if (!raw) return;
|
||||
await prisma.websiteIpBlacklist.delete({ where: { id: BigInt(raw) } });
|
||||
revalidatePath("/admin/ip");
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
'use server';
|
||||
|
||||
import { revalidatePath } from 'next/cache';
|
||||
import { prisma } from '@/lib/prisma';
|
||||
import { requireStaff } from '@/lib/admin/guard';
|
||||
|
||||
export async function deleteShout(formData: FormData): Promise<void> {
|
||||
await requireStaff();
|
||||
|
||||
const raw = formData.get('id');
|
||||
if (typeof raw !== 'string' || raw.trim() === '') return;
|
||||
|
||||
let id: bigint;
|
||||
try {
|
||||
id = BigInt(raw);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await prisma.radioShouts.delete({ where: { id } });
|
||||
} catch {
|
||||
// Row may already be gone; ignore so the action does not throw.
|
||||
}
|
||||
|
||||
revalidatePath('/admin/radio');
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
"use server";
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { requireStaff } from "@/lib/admin/guard";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
export async function createTeam(formData: FormData): Promise<void> {
|
||||
await requireStaff();
|
||||
|
||||
const rankName = String(formData.get("rankName") ?? "").trim();
|
||||
if (!rankName) return;
|
||||
|
||||
const badge = String(formData.get("badge") ?? "").trim();
|
||||
const jobDescription = String(formData.get("jobDescription") ?? "").trim();
|
||||
const staffColor = String(formData.get("staffColor") ?? "").trim() || "#327fa8";
|
||||
const hiddenRank = formData.get("hiddenRank") === "on";
|
||||
|
||||
const now = new Date();
|
||||
await prisma.websiteTeams.create({
|
||||
data: {
|
||||
rankName: rankName.slice(0, 255),
|
||||
badge: badge ? badge.slice(0, 255) : null,
|
||||
jobDescription: jobDescription ? jobDescription.slice(0, 255) : null,
|
||||
staffColor: staffColor.slice(0, 255),
|
||||
hiddenRank,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
},
|
||||
});
|
||||
|
||||
revalidatePath("/admin/teams");
|
||||
}
|
||||
|
||||
export async function deleteTeam(formData: FormData): Promise<void> {
|
||||
await requireStaff();
|
||||
|
||||
const id = BigInt(String(formData.get("id")));
|
||||
await prisma.websiteTeams.delete({ where: { id } });
|
||||
|
||||
revalidatePath("/admin/teams");
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
"use server";
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { requireStaff } from "@/lib/admin/guard";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { rcon } from "@/lib/services/rcon";
|
||||
|
||||
export async function addWord(formData: FormData): Promise<void> {
|
||||
await requireStaff();
|
||||
const word = String(formData.get("word") ?? "").trim().slice(0, 255);
|
||||
if (!word) return;
|
||||
|
||||
try {
|
||||
await prisma.websiteWordfilter.create({ data: { word } });
|
||||
await rcon.updateWordFilter();
|
||||
} catch {
|
||||
// ignore (e.g. duplicate word) — page re-renders current state
|
||||
}
|
||||
revalidatePath("/admin/wordfilter");
|
||||
}
|
||||
|
||||
export async function deleteWord(formData: FormData): Promise<void> {
|
||||
await requireStaff();
|
||||
const raw = String(formData.get("id") ?? "");
|
||||
if (!raw) return;
|
||||
|
||||
try {
|
||||
await prisma.websiteWordfilter.delete({ where: { id: BigInt(raw) } });
|
||||
await rcon.updateWordFilter();
|
||||
} catch {
|
||||
// ignore (e.g. already removed)
|
||||
}
|
||||
revalidatePath("/admin/wordfilter");
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
"use server";
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
export async function createTicket(formData: FormData): Promise<void> {
|
||||
// Re-read the session user id server-side; never trust a form-supplied id.
|
||||
const session = await auth();
|
||||
const userId = Number(session?.user?.id);
|
||||
if (!Number.isInteger(userId) || userId <= 0) return;
|
||||
|
||||
const title = String(formData.get("title") ?? "").trim().slice(0, 255);
|
||||
const content = String(formData.get("content") ?? "").trim().slice(0, 5000);
|
||||
if (!title || !content) return;
|
||||
|
||||
const now = new Date();
|
||||
await prisma.websiteHelpCenterTickets.create({
|
||||
data: {
|
||||
userId,
|
||||
title,
|
||||
content,
|
||||
open: true,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
},
|
||||
});
|
||||
|
||||
revalidatePath("/help/tickets");
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
"use server";
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { rcon } from "@/lib/services/rcon";
|
||||
|
||||
// Emulator motto column is VARCHAR(127); keep the CMS-side write within bounds.
|
||||
const MOTTO_MAX = 127;
|
||||
|
||||
/**
|
||||
* Update the SIGNED-IN user's motto. The id is taken from the session
|
||||
* (re-fetched via auth()), never from the submitted FormData, so a crafted
|
||||
* form cannot mutate another account. Mirrors AtomCMS: persist + RCON setmotto
|
||||
* so an online user sees the change live.
|
||||
*/
|
||||
export async function updateMotto(formData: FormData): Promise<void> {
|
||||
const session = await auth();
|
||||
if (!session?.user?.id) return;
|
||||
|
||||
const id = Number(session.user.id);
|
||||
if (!Number.isFinite(id)) return;
|
||||
|
||||
const motto = String(formData.get("motto") ?? "").slice(0, MOTTO_MAX);
|
||||
|
||||
try {
|
||||
await prisma.user.update({ where: { id }, data: { motto } });
|
||||
} catch {
|
||||
// DB unavailable — fail soft; nothing to persist.
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await rcon.setMotto(id, motto);
|
||||
} catch {
|
||||
// RCON is best-effort; the change is already persisted.
|
||||
}
|
||||
|
||||
revalidatePath("/settings");
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import Link from "next/link";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
const LINKS = [
|
||||
{
|
||||
href: "/rankings",
|
||||
title: "Rankings",
|
||||
text: "See the wealthiest players in the hotel, ranked by credits.",
|
||||
},
|
||||
{
|
||||
href: "/staff",
|
||||
title: "Staff",
|
||||
text: "Meet the team that keeps the hotel running every day.",
|
||||
},
|
||||
{
|
||||
href: "/news",
|
||||
title: "News",
|
||||
text: "The latest announcements, events and updates.",
|
||||
},
|
||||
];
|
||||
|
||||
export default function CommunityPage() {
|
||||
return (
|
||||
<main>
|
||||
<div className="hero">
|
||||
<h1 style={{ margin: "0 0 0.35rem" }}>Community</h1>
|
||||
<p className="muted" style={{ margin: 0 }}>
|
||||
Everything happening around the hotel in one place.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid cols-3">
|
||||
{LINKS.map((l) => (
|
||||
<Link key={l.href} href={l.href} className="card hover" style={{ color: "inherit" }}>
|
||||
<h3 style={{ margin: "0 0 0.35rem" }}>{l.title}</h3>
|
||||
<p className="muted" style={{ margin: "0 0 0.75rem" }}>
|
||||
{l.text}
|
||||
</p>
|
||||
<span className="btn btn-outline">Open →</span>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,208 @@
|
||||
import Link from "next/link";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
type HelpCategory = {
|
||||
id: bigint;
|
||||
name: string;
|
||||
content: string;
|
||||
imageUrl: string | null;
|
||||
buttonText: string | null;
|
||||
buttonUrl: string | null;
|
||||
buttonColor: string;
|
||||
buttonBorderColor: string;
|
||||
smallBox: boolean;
|
||||
};
|
||||
|
||||
type RuleCategory = {
|
||||
id: bigint;
|
||||
name: string;
|
||||
description: string;
|
||||
badge: string;
|
||||
};
|
||||
|
||||
type Rule = {
|
||||
id: bigint;
|
||||
categoryId: bigint | null;
|
||||
paragraph: string;
|
||||
rule: string;
|
||||
};
|
||||
|
||||
function isExternal(url: string): boolean {
|
||||
return /^https?:\/\//i.test(url);
|
||||
}
|
||||
|
||||
export default async function HelpCenterPage() {
|
||||
let categories: HelpCategory[] = [];
|
||||
try {
|
||||
categories = await prisma.websiteHelpCenterCategories.findMany({
|
||||
orderBy: [{ position: "asc" }, { id: "asc" }],
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
content: true,
|
||||
imageUrl: true,
|
||||
buttonText: true,
|
||||
buttonUrl: true,
|
||||
buttonColor: true,
|
||||
buttonBorderColor: true,
|
||||
smallBox: true,
|
||||
},
|
||||
});
|
||||
} catch {
|
||||
// DB unavailable — render the shell without help categories.
|
||||
}
|
||||
|
||||
let ruleCategories: RuleCategory[] = [];
|
||||
try {
|
||||
ruleCategories = await prisma.websiteRuleCategories.findMany({
|
||||
orderBy: { id: "asc" },
|
||||
select: { id: true, name: true, description: true, badge: true },
|
||||
});
|
||||
} catch {
|
||||
// DB unavailable — render the shell without rule categories.
|
||||
}
|
||||
|
||||
let rules: Rule[] = [];
|
||||
try {
|
||||
rules = await prisma.websiteRules.findMany({
|
||||
orderBy: [{ categoryId: "asc" }, { paragraph: "asc" }, { id: "asc" }],
|
||||
select: { id: true, categoryId: true, paragraph: true, rule: true },
|
||||
});
|
||||
} catch {
|
||||
// DB unavailable — render the shell without rules.
|
||||
}
|
||||
|
||||
// Group the rules under their categories.
|
||||
const rulesByCategory = new Map<string, Rule[]>();
|
||||
for (const r of rules) {
|
||||
const key = r.categoryId === null ? "" : String(r.categoryId);
|
||||
const list = rulesByCategory.get(key);
|
||||
if (list) {
|
||||
list.push(r);
|
||||
} else {
|
||||
rulesByCategory.set(key, [r]);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<main>
|
||||
<section className="hero">
|
||||
<h1 style={{ marginTop: 0 }}>Help Center</h1>
|
||||
<p className="muted" style={{ fontSize: 16, margin: 0 }}>
|
||||
Find guides, support topics, and the hotel rules — everything you need to get help.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<h2>Support topics</h2>
|
||||
{categories.length === 0 ? (
|
||||
<p className="muted">No help topics yet.</p>
|
||||
) : (
|
||||
<div className="grid cols-2">
|
||||
{categories.map((c) => (
|
||||
<article key={String(c.id)} className="card hover">
|
||||
<div style={{ display: "flex", gap: "0.85rem", alignItems: "flex-start" }}>
|
||||
{c.imageUrl ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={c.imageUrl}
|
||||
alt=""
|
||||
width={48}
|
||||
height={48}
|
||||
style={{ flexShrink: 0, objectFit: "contain" }}
|
||||
/>
|
||||
) : null}
|
||||
<div style={{ flex: 1 }}>
|
||||
<h3 style={{ margin: "0 0 0.35rem" }}>{c.name}</h3>
|
||||
<p className="muted" style={{ margin: 0 }}>
|
||||
{c.content}
|
||||
</p>
|
||||
{c.buttonUrl && c.buttonText ? (
|
||||
<div style={{ marginTop: "0.85rem" }}>
|
||||
{isExternal(c.buttonUrl) ? (
|
||||
<a
|
||||
className="btn btn-outline"
|
||||
href={c.buttonUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
style={{
|
||||
background: c.buttonColor,
|
||||
borderColor: c.buttonBorderColor,
|
||||
color: "#3a2c05",
|
||||
}}
|
||||
>
|
||||
{c.buttonText}
|
||||
</a>
|
||||
) : (
|
||||
<Link
|
||||
className="btn btn-outline"
|
||||
href={c.buttonUrl}
|
||||
style={{
|
||||
background: c.buttonColor,
|
||||
borderColor: c.buttonBorderColor,
|
||||
color: "#3a2c05",
|
||||
}}
|
||||
>
|
||||
{c.buttonText}
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<h2 style={{ marginTop: "2rem" }}>Hotel rules</h2>
|
||||
{ruleCategories.length === 0 ? (
|
||||
<p className="muted">No rules published yet.</p>
|
||||
) : (
|
||||
<div className="grid">
|
||||
{ruleCategories.map((cat) => {
|
||||
const catRules = rulesByCategory.get(String(cat.id)) ?? [];
|
||||
return (
|
||||
<article key={String(cat.id)} className="card">
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "baseline",
|
||||
gap: "0.6rem",
|
||||
flexWrap: "wrap",
|
||||
}}
|
||||
>
|
||||
<h3 style={{ margin: 0 }}>{cat.name}</h3>
|
||||
{cat.badge ? <span className="muted">{cat.badge}</span> : null}
|
||||
</div>
|
||||
{cat.description ? (
|
||||
<p className="muted" style={{ margin: "0.35rem 0 0" }}>
|
||||
{cat.description}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{catRules.length === 0 ? (
|
||||
<p className="muted" style={{ marginBottom: 0 }}>
|
||||
No rules in this category.
|
||||
</p>
|
||||
) : (
|
||||
<ul style={{ margin: "0.85rem 0 0", paddingLeft: "1.2rem" }}>
|
||||
{catRules.map((r) => (
|
||||
<li key={String(r.id)} style={{ marginBottom: "0.5rem" }}>
|
||||
{r.paragraph ? (
|
||||
<strong style={{ marginRight: "0.4rem" }}>{r.paragraph}</strong>
|
||||
) : null}
|
||||
{r.rule}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</article>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { createTicket } from "@/actions/help-tickets";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
function formatDate(d: Date | null): string {
|
||||
return d ? d.toISOString().slice(0, 16).replace("T", " ") : "";
|
||||
}
|
||||
|
||||
export default async function HelpTicketsPage() {
|
||||
const session = await auth();
|
||||
if (!session?.user?.id) redirect("/login");
|
||||
|
||||
const userId = Number(session.user.id);
|
||||
|
||||
let tickets: Awaited<ReturnType<typeof prisma.websiteHelpCenterTickets.findMany>> = [];
|
||||
try {
|
||||
tickets = await prisma.websiteHelpCenterTickets.findMany({
|
||||
where: { userId },
|
||||
orderBy: { createdAt: "desc" },
|
||||
take: 100,
|
||||
});
|
||||
} catch {
|
||||
tickets = [];
|
||||
}
|
||||
|
||||
return (
|
||||
<main>
|
||||
<h1>Help Tickets</h1>
|
||||
<p className="muted">Submit a ticket to staff and track your open requests.</p>
|
||||
|
||||
<form action={createTicket} className="card" style={{ marginBottom: "1.5rem" }}>
|
||||
<h3 style={{ marginTop: 0 }}>New ticket</h3>
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: "0.6rem" }}>
|
||||
<input name="title" placeholder="Subject" maxLength={255} required />
|
||||
<textarea
|
||||
name="content"
|
||||
placeholder="Describe your issue…"
|
||||
rows={5}
|
||||
maxLength={5000}
|
||||
required
|
||||
/>
|
||||
<div>
|
||||
<button type="submit" className="btn btn-primary">
|
||||
Submit ticket
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>Subject</th>
|
||||
<th>Status</th>
|
||||
<th>Created</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{tickets.map((t) => (
|
||||
<tr key={String(t.id)}>
|
||||
<td>{String(t.id)}</td>
|
||||
<td>{t.title}</td>
|
||||
<td>{t.open ? "Open" : "Closed"}</td>
|
||||
<td className="muted">{formatDate(t.createdAt)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
{tickets.length === 0 ? <p className="muted">You have no tickets yet.</p> : null}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
type Photo = {
|
||||
id: number;
|
||||
userId: number;
|
||||
roomId: number;
|
||||
timestamp: number;
|
||||
url: string;
|
||||
};
|
||||
|
||||
function formatDate(timestamp: number): string {
|
||||
return new Date(timestamp * 1000)
|
||||
.toISOString()
|
||||
.slice(0, 16)
|
||||
.replace("T", " ");
|
||||
}
|
||||
|
||||
export default async function PhotosPage() {
|
||||
let photos: Photo[] = [];
|
||||
try {
|
||||
photos = await prisma.cameraWeb.findMany({
|
||||
orderBy: { timestamp: "desc" },
|
||||
take: 48,
|
||||
});
|
||||
} catch {
|
||||
photos = [];
|
||||
}
|
||||
|
||||
return (
|
||||
<main>
|
||||
<div className="hero">
|
||||
<h1>Photos</h1>
|
||||
<p className="muted" style={{ marginTop: 0 }}>
|
||||
The latest snapshots taken in the hotel.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{photos.length === 0 ? (
|
||||
<p className="muted">No photos yet.</p>
|
||||
) : (
|
||||
<div className="grid cols-3">
|
||||
{photos.map((p) => (
|
||||
<article key={String(p.id)} className="card">
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={p.url}
|
||||
alt={`Photo by user #${p.userId}`}
|
||||
style={{
|
||||
width: "100%",
|
||||
height: "auto",
|
||||
borderRadius: 4,
|
||||
display: "block",
|
||||
}}
|
||||
/>
|
||||
<p className="muted" style={{ margin: "0.5rem 0 0" }}>
|
||||
{formatDate(p.timestamp)}
|
||||
</p>
|
||||
<p className="muted" style={{ margin: 0 }}>
|
||||
by user #{p.userId}
|
||||
</p>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import Link from "next/link";
|
||||
import { avatarImageUrl } from "@/lib/format";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { siteSettings } from "@/lib/services/site-settings";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
type TopUser = {
|
||||
username: string;
|
||||
look: string;
|
||||
credits: number;
|
||||
rank: number;
|
||||
};
|
||||
|
||||
export default async function RankingsPage() {
|
||||
let users: TopUser[] = [];
|
||||
try {
|
||||
users = await prisma.user.findMany({
|
||||
select: { username: true, look: true, credits: true, rank: true },
|
||||
orderBy: { credits: "desc" },
|
||||
take: 12,
|
||||
});
|
||||
} catch {
|
||||
users = [];
|
||||
}
|
||||
|
||||
const imagerBase = await siteSettings.get(
|
||||
"habbo_imaging_url",
|
||||
"https://www.habbo.com/habbo-imaging/avatarimage",
|
||||
);
|
||||
|
||||
return (
|
||||
<main>
|
||||
<div className="hero">
|
||||
<h1 style={{ margin: "0 0 0.35rem" }}>Rankings</h1>
|
||||
<p className="muted" style={{ margin: 0 }}>
|
||||
The top 12 players ranked by credits.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{users.length === 0 ? (
|
||||
<p className="muted">No players to rank yet.</p>
|
||||
) : (
|
||||
<div className="grid cols-3">
|
||||
{users.map((u, i) => {
|
||||
const avatar = avatarImageUrl(imagerBase ?? "", u.look, { size: "m" });
|
||||
return (
|
||||
<div
|
||||
key={u.username}
|
||||
className="card hover"
|
||||
style={{ display: "flex", gap: "1rem", alignItems: "center" }}
|
||||
>
|
||||
<span style={{ fontSize: "1.5rem", fontWeight: 800, minWidth: "2rem" }}>
|
||||
#{i + 1}
|
||||
</span>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
className="avatar"
|
||||
src={avatar}
|
||||
alt={`${u.username} avatar`}
|
||||
width={64}
|
||||
height={110}
|
||||
/>
|
||||
<div style={{ minWidth: 0 }}>
|
||||
<h3 style={{ margin: "0 0 0.2rem" }}>
|
||||
<Link href={`/u/${u.username}`}>{u.username}</Link>
|
||||
</h3>
|
||||
<p className="currency" style={{ margin: 0 }}>
|
||||
<span className="coin credits">c</span>
|
||||
{u.credits.toLocaleString()}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { updateMotto } from "@/actions/user-settings";
|
||||
import { avatarImageUrl } from "@/lib/format";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { siteSettings } from "@/lib/services/site-settings";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function SettingsPage() {
|
||||
const session = await auth();
|
||||
if (!session?.user?.id) redirect("/login");
|
||||
|
||||
const id = Number(session.user.id);
|
||||
|
||||
let user: {
|
||||
username: string;
|
||||
motto: string;
|
||||
look: string;
|
||||
mail: string | null;
|
||||
} | null = null;
|
||||
try {
|
||||
user = await prisma.user.findUnique({
|
||||
where: { id },
|
||||
select: { username: true, motto: true, look: true, mail: true },
|
||||
});
|
||||
} catch {
|
||||
user = null;
|
||||
}
|
||||
|
||||
if (!user) redirect("/login");
|
||||
|
||||
let imagerBase = "https://www.habbo.com/habbo-imaging/avatarimage";
|
||||
try {
|
||||
imagerBase =
|
||||
(await siteSettings.get("habbo_imaging_url", imagerBase)) ?? imagerBase;
|
||||
} catch {
|
||||
// fall back to the default imager base
|
||||
}
|
||||
|
||||
const avatar = avatarImageUrl(imagerBase, user.look, { size: "l" });
|
||||
|
||||
return (
|
||||
<main>
|
||||
<h1>Account settings</h1>
|
||||
|
||||
<div
|
||||
className="card"
|
||||
style={{ display: "flex", gap: "1.5rem", alignItems: "center", marginBottom: "1.5rem" }}
|
||||
>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img className="avatar" src={avatar} alt={`${user.username} avatar`} width={100} height={150} />
|
||||
<div>
|
||||
<h2 style={{ margin: "0 0 0.25rem" }}>{user.username}</h2>
|
||||
<p style={{ margin: "0 0 0.5rem" }}>
|
||||
{user.motto || <span className="muted">No motto</span>}
|
||||
</p>
|
||||
<p className="muted" style={{ margin: 0 }}>
|
||||
{user.mail || "No e-mail on file"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card" style={{ maxWidth: 520 }}>
|
||||
<h3 style={{ marginTop: 0 }}>Change motto</h3>
|
||||
<form action={updateMotto} style={{ display: "flex", flexDirection: "column", gap: "0.75rem" }}>
|
||||
<input
|
||||
name="motto"
|
||||
defaultValue={user.motto}
|
||||
maxLength={127}
|
||||
placeholder="Your motto"
|
||||
aria-label="Motto"
|
||||
/>
|
||||
<div>
|
||||
<button type="submit" className="btn btn-primary">
|
||||
Save motto
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
<p className="muted" style={{ marginBottom: 0 }}>
|
||||
Updates instantly in-game if you are online.
|
||||
</p>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,240 @@
|
||||
import Link from "next/link";
|
||||
import { excerpt } from "@/lib/format";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
// Faithful to AtomCMS WebsiteShopArticle::price(): costs are stored in cents,
|
||||
// the displayed price is costs/100 with a floor of 1.
|
||||
function priceLabel(costs: number): string {
|
||||
const price = costs < 100 ? 1 : costs / 100;
|
||||
return `$${price.toFixed(2)}`;
|
||||
}
|
||||
|
||||
type ShopArticle = {
|
||||
id: bigint;
|
||||
name: string;
|
||||
info: string;
|
||||
icon: string;
|
||||
color: string;
|
||||
costs: number;
|
||||
credits: number | null;
|
||||
duckets: number | null;
|
||||
diamonds: number | null;
|
||||
badges: string | null;
|
||||
position: number;
|
||||
};
|
||||
|
||||
type ShopCategory = {
|
||||
id: bigint;
|
||||
name: string;
|
||||
slug: string;
|
||||
icon: string | null;
|
||||
};
|
||||
|
||||
const ARTICLE_SELECT = {
|
||||
id: true,
|
||||
name: true,
|
||||
info: true,
|
||||
icon: true,
|
||||
color: true,
|
||||
costs: true,
|
||||
credits: true,
|
||||
duckets: true,
|
||||
diamonds: true,
|
||||
badges: true,
|
||||
position: true,
|
||||
} as const;
|
||||
|
||||
export default async function ShopPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<{ category?: string }>;
|
||||
}) {
|
||||
const sp = await searchParams;
|
||||
const rawCategory = (sp.category ?? "").trim();
|
||||
const categoryId = /^\d+$/.test(rawCategory) ? rawCategory : "";
|
||||
|
||||
// Categories for the filter bar.
|
||||
let categories: ShopCategory[] = [];
|
||||
try {
|
||||
categories = await prisma.websiteShopCategories.findMany({
|
||||
select: { id: true, name: true, slug: true, icon: true },
|
||||
orderBy: { name: "asc" },
|
||||
});
|
||||
} catch {
|
||||
categories = [];
|
||||
}
|
||||
|
||||
// Articles. The category foreign key (website_shop_category_id) is not modelled
|
||||
// in the typed Prisma client, so when filtering we resolve the matching ids via
|
||||
// a guarded raw query, then load the rows through the typed client.
|
||||
let articles: ShopArticle[] = [];
|
||||
try {
|
||||
if (categoryId) {
|
||||
let ids: bigint[] = [];
|
||||
try {
|
||||
const rows = await prisma.$queryRawUnsafe<{ id: bigint }[]>(
|
||||
"SELECT id FROM website_shop_articles WHERE website_shop_category_id = ?",
|
||||
BigInt(categoryId),
|
||||
);
|
||||
ids = rows.map((r) => r.id);
|
||||
} catch {
|
||||
ids = [];
|
||||
}
|
||||
|
||||
articles = ids.length
|
||||
? await prisma.websiteShopArticles.findMany({
|
||||
where: { id: { in: ids } },
|
||||
select: ARTICLE_SELECT,
|
||||
orderBy: { position: "asc" },
|
||||
})
|
||||
: [];
|
||||
} else {
|
||||
articles = await prisma.websiteShopArticles.findMany({
|
||||
select: ARTICLE_SELECT,
|
||||
orderBy: { position: "asc" },
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
articles = [];
|
||||
}
|
||||
|
||||
const activeCategory = categoryId
|
||||
? categories.find((c) => String(c.id) === categoryId)
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<main>
|
||||
<div className="hero">
|
||||
<h1 style={{ marginBottom: "0.25rem" }}>Shop</h1>
|
||||
<p className="muted" style={{ margin: 0 }}>
|
||||
{activeCategory
|
||||
? `Browsing the ${activeCategory.name} packages.`
|
||||
: "Top up your account and grab exclusive packages, currency and badges."}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{categories.length > 0 ? (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexWrap: "wrap",
|
||||
gap: "0.5rem",
|
||||
marginBottom: "1.5rem",
|
||||
}}
|
||||
>
|
||||
<Link
|
||||
href="/shop"
|
||||
className={`btn ${categoryId ? "btn-outline" : "btn-primary"}`}
|
||||
>
|
||||
All
|
||||
</Link>
|
||||
{categories.map((c) => (
|
||||
<Link
|
||||
key={String(c.id)}
|
||||
href={`/shop?category=${String(c.id)}`}
|
||||
className={`btn ${categoryId === String(c.id) ? "btn-primary" : "btn-outline"}`}
|
||||
>
|
||||
{c.name}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{articles.length === 0 ? (
|
||||
<p className="muted">No packages available right now.</p>
|
||||
) : (
|
||||
<div className="grid cols-3">
|
||||
{articles.map((a) => (
|
||||
<article key={String(a.id)} className="card hover">
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "0.75rem",
|
||||
marginBottom: "0.6rem",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: 56,
|
||||
height: 56,
|
||||
borderRadius: "var(--radius-sm)",
|
||||
display: "grid",
|
||||
placeItems: "center",
|
||||
background: "#f8fafc",
|
||||
border: `2px solid ${a.color || "var(--color-golden)"}`,
|
||||
flexShrink: 0,
|
||||
overflow: "hidden",
|
||||
}}
|
||||
>
|
||||
{a.icon ? (
|
||||
// Article icons are arbitrary external/Imgur URLs from the
|
||||
// emulator data, so a plain <img> is correct here.
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={a.icon}
|
||||
alt=""
|
||||
width={48}
|
||||
height={48}
|
||||
style={{ objectFit: "contain", display: "block" }}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
<div style={{ minWidth: 0 }}>
|
||||
<h3 style={{ margin: 0 }}>{a.name}</h3>
|
||||
<span className="muted">{priceLabel(a.costs)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p style={{ margin: "0 0 0.75rem" }}>{excerpt(a.info, 160)}</p>
|
||||
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexWrap: "wrap",
|
||||
gap: "0.4rem",
|
||||
marginBottom: "0.85rem",
|
||||
}}
|
||||
>
|
||||
{a.credits ? (
|
||||
<span className="currency">
|
||||
<span className="coin credits">cr</span>
|
||||
{a.credits.toLocaleString()}
|
||||
</span>
|
||||
) : null}
|
||||
{a.duckets ? (
|
||||
<span className="currency">
|
||||
<span className="coin duckets">du</span>
|
||||
{a.duckets.toLocaleString()}
|
||||
</span>
|
||||
) : null}
|
||||
{a.diamonds ? (
|
||||
<span className="currency">
|
||||
<span className="coin diamonds">di</span>
|
||||
{a.diamonds.toLocaleString()}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
gap: "0.5rem",
|
||||
}}
|
||||
>
|
||||
<span style={{ fontWeight: 800 }}>{priceLabel(a.costs)}</span>
|
||||
<span className="btn btn-primary" aria-disabled="true">
|
||||
Buy
|
||||
</span>
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
import Link from "next/link";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { siteSettings } from "@/lib/services/site-settings";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
type StaffMember = {
|
||||
username: string;
|
||||
look: string;
|
||||
rank: number;
|
||||
motto: string;
|
||||
};
|
||||
|
||||
type Team = {
|
||||
id: string;
|
||||
rankName: string;
|
||||
badge: string | null;
|
||||
jobDescription: string | null;
|
||||
staffColor: string;
|
||||
};
|
||||
|
||||
export default async function StaffPage() {
|
||||
const minStaffRank = Number(await siteSettings.get("min_staff_rank", "7")) || 7;
|
||||
|
||||
let staff: StaffMember[] = [];
|
||||
try {
|
||||
staff = await prisma.user.findMany({
|
||||
where: { rank: { gte: minStaffRank } },
|
||||
select: { username: true, look: true, rank: true, motto: true },
|
||||
orderBy: [{ rank: "desc" }, { username: "asc" }],
|
||||
take: 100,
|
||||
});
|
||||
} catch {
|
||||
staff = [];
|
||||
}
|
||||
|
||||
let teams: Team[] = [];
|
||||
try {
|
||||
const rows = await prisma.websiteTeams.findMany({
|
||||
where: { hiddenRank: false },
|
||||
select: {
|
||||
id: true,
|
||||
rankName: true,
|
||||
badge: true,
|
||||
jobDescription: true,
|
||||
staffColor: true,
|
||||
},
|
||||
orderBy: { id: "asc" },
|
||||
});
|
||||
teams = rows.map((t) => ({ ...t, id: String(t.id) }));
|
||||
} catch {
|
||||
teams = [];
|
||||
}
|
||||
|
||||
return (
|
||||
<main>
|
||||
<div className="hero">
|
||||
<h1 style={{ margin: "0 0 0.35rem" }}>Staff</h1>
|
||||
<p className="muted" style={{ margin: 0 }}>
|
||||
The team behind the hotel.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{teams.length > 0 ? (
|
||||
<div className="grid cols-3" style={{ marginBottom: "1.5rem" }}>
|
||||
{teams.map((t) => (
|
||||
<div key={t.id} className="card">
|
||||
<h3 style={{ margin: "0 0 0.25rem", color: t.staffColor }}>{t.rankName}</h3>
|
||||
{t.jobDescription ? (
|
||||
<p className="muted" style={{ margin: 0 }}>
|
||||
{t.jobDescription}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{staff.length === 0 ? (
|
||||
<p className="muted">No staff members to show.</p>
|
||||
) : (
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Username</th>
|
||||
<th>Rank</th>
|
||||
<th>Motto</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{staff.map((u) => (
|
||||
<tr key={u.username}>
|
||||
<td>
|
||||
<Link href={`/u/${u.username}`}>{u.username}</Link>
|
||||
</td>
|
||||
<td>{u.rank}</td>
|
||||
<td className="muted">{u.motto || "—"}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -45,6 +45,18 @@ export async function SiteHeader() {
|
||||
<Link className="nav-item" href="/news">
|
||||
News
|
||||
</Link>
|
||||
<Link className="nav-item" href="/shop">
|
||||
Shop
|
||||
</Link>
|
||||
<Link className="nav-item" href="/community">
|
||||
Community
|
||||
</Link>
|
||||
<Link className="nav-item" href="/photos">
|
||||
Photos
|
||||
</Link>
|
||||
<Link className="nav-item" href="/help">
|
||||
Help
|
||||
</Link>
|
||||
{session?.user ? (
|
||||
<Link className="nav-item" href="/client">
|
||||
Hotel
|
||||
@@ -73,6 +85,9 @@ export async function SiteHeader() {
|
||||
{diamonds}
|
||||
</span>
|
||||
</div>
|
||||
<Link className="nav-item" href="/settings">
|
||||
Settings
|
||||
</Link>
|
||||
<Link className="nav-item" href={`/u/${session.user.name}`}>
|
||||
{session.user.name}
|
||||
</Link>
|
||||
|
||||
Reference in new issue
Block a user