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

+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>
);
}