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

+26
View File
@@ -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");
}
+52
View File
@@ -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");
}
+27
View File
@@ -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');
}
+41
View File
@@ -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");
}
+34
View File
@@ -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");
}
+30
View File
@@ -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");
}
+40
View File
@@ -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");
}
+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>
);
}
+46
View File
@@ -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>
);
}
+208
View File
@@ -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>
);
}
+76
View File
@@ -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>
);
}
+69
View File
@@ -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>
);
}
+80
View File
@@ -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>
);
}
+86
View File
@@ -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>
);
}
+240
View File
@@ -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>
);
}
+105
View File
@@ -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>
);
}
+15
View File
@@ -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>