Adapt + improve all admin pages to the new design system

Swept the whole housekeeping panel (34 pages) so it's a consistent,
improved adaptation of the old Filament admin rather than bare tables:
- Live status tiles (StatusCard) summarising each page's data — counts
  and stateful metrics (users online, active/permanent bans, pending
  applications, settings, radio shouts, etc.).
- Status pills (admin-badge ok/danger) replacing ad-hoc text: online/
  offline, account/ip ban types, permanent vs timed, command OK/FAIL,
  alert severity & read/new, enabled/disabled, open/closed.
- Sections wrapped in .admin-section with h2 headers; redundant inline
  table styling removed (the global .admin layer handles it).
- alerts/logs/emulator hand-built as the reference pattern; the rest
  via parallel agents that read the schema and preserved every server
  action, query and field name.

Behaviour unchanged (same actions, queries, field names). Verified in a
real authed admin session (prod, amx_test): pages render with live data
(Users tiles show 7 real accounts + online/offline pills), no errors.
tsc 0, vitest 49/49, next build 0.
This commit is contained in:
Simo committed 2026-06-28 19:07:48 +02:00
1 parent d419731566
commit 0de0ac6a3c
34 files changed
+1243 -282

No files matched your search

+24 -10
View File
@@ -1,3 +1,4 @@
import { StatusCard } from "@/components/admin/dashboard";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
@@ -39,22 +40,35 @@ export default async function AdminAchievements() {
groups.set(a.category, list); groups.set(a.category, list);
} }
// Distinct achievement names (each name can have multiple level rows).
const distinctNames = new Set(achievements.map((a) => a.name)).size;
return ( return (
<main> <main>
<h1>Achievements</h1> <h1>Achievements</h1>
<p className="muted" style={{ marginTop: 0 }}> <p className="muted" style={{ marginTop: "-0.4rem" }}>
Read-only · {achievements.length} rows across {groups.size} categories. Read-only · {achievements.length} rows across {groups.size} categories.
</p> </p>
<div className="admin-stats">
<StatusCard label="Achievement rows" value={achievements.length} icon="🏆" />
<StatusCard label="Distinct achievements" value={distinctNames} icon="🎖️" />
<StatusCard label="Categories" value={groups.size} icon="🗂️" />
</div>
{groups.size === 0 ? ( {groups.size === 0 ? (
<p className="muted">No achievements found.</p> <p className="muted">No achievements found.</p>
) : ( ) : (
[...groups.entries()].map(([category, rows]) => ( [...groups.entries()].map(([category, rows]) => (
<div key={category} className="card" style={{ marginBottom: "1.5rem" }}> <section key={category} className="admin-section">
<h3 style={{ marginTop: 0 }}>{category}</h3> <h2>
<table style={{ width: "100%", borderCollapse: "collapse" }}> {category}{" "}
<span className="admin-badge">{rows.length}</span>
</h2>
<div className="card" style={{ padding: 0, overflowX: "auto" }}>
<table>
<thead> <thead>
<tr style={{ textAlign: "left", color: "var(--muted)" }}> <tr>
<th>Name</th> <th>Name</th>
<th>Level</th> <th>Level</th>
<th>Progress needed</th> <th>Progress needed</th>
@@ -65,11 +79,10 @@ export default async function AdminAchievements() {
</thead> </thead>
<tbody> <tbody>
{rows.map((a) => ( {rows.map((a) => (
<tr <tr key={`${a.name}-${a.level}`}>
key={`${a.name}-${a.level}`} <td>
style={{ borderTop: "1px solid var(--border)" }} <strong>{a.name}</strong>
> </td>
<td>{a.name}</td>
<td>{a.level}</td> <td>{a.level}</td>
<td>{a.progressNeeded}</td> <td>{a.progressNeeded}</td>
<td>{a.rewardType}</td> <td>{a.rewardType}</td>
@@ -80,6 +93,7 @@ export default async function AdminAchievements() {
</tbody> </tbody>
</table> </table>
</div> </div>
</section>
)) ))
)} )}
</main> </main>
+23 -5
View File
@@ -1,4 +1,5 @@
import Link from "next/link"; import Link from "next/link";
import { StatusCard } from "@/components/admin/dashboard";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
@@ -14,17 +15,33 @@ export default async function AdminAds() {
ads = []; ads = [];
} }
const latest = ads[0]?.createdAt
? ads[0].createdAt.toISOString().slice(0, 10)
: "—";
return ( return (
<main> <main>
<div style={{ display: "flex", alignItems: "center", gap: "1rem" }}> <div style={{ display: "flex", alignItems: "center", gap: "1rem" }}>
<h1 style={{ flex: 1 }}>Advertisements</h1> <h1 style={{ flex: 1 }}>Advertisements</h1>
<Link href="/admin/ads/new">+ New ad</Link> <Link href="/admin/ads/new" className="btn btn-primary">
+ New ad
</Link>
</div> </div>
<p className="muted"> <p className="muted" style={{ marginTop: "-0.4rem" }}>
Website advertisement banners (<code>website_ads</code>). Each ad is an image URL shown Website advertisement banners (<code>website_ads</code>). Each ad is an image URL shown
across the site. across the site.
</p> </p>
<div className="admin-stats">
<StatusCard label="Ads" value={ads.length} icon="🖼️" />
<StatusCard label="Latest" value={latest} hint="most recent ad" icon="🕒" />
</div>
<section className="admin-section">
<h2>All ads</h2>
{ads.length === 0 ? (
<p className="muted">No advertisements yet.</p>
) : (
<table> <table>
<thead> <thead>
<tr> <tr>
@@ -44,8 +61,8 @@ export default async function AdminAds() {
<td className="muted"> <td className="muted">
<code>{ad.image}</code> <code>{ad.image}</code>
</td> </td>
<td className="muted"> <td className="muted" style={{ whiteSpace: "nowrap" }}>
{ad.createdAt ? ad.createdAt.toISOString().slice(0, 10) : ""} {ad.createdAt ? ad.createdAt.toISOString().slice(0, 10) : "—"}
</td> </td>
<td> <td>
<Link href={`/admin/ads/${ad.id}`}>Edit</Link> <Link href={`/admin/ads/${ad.id}`}>Edit</Link>
@@ -54,7 +71,8 @@ export default async function AdminAds() {
))} ))}
</tbody> </tbody>
</table> </table>
{ads.length === 0 ? <p className="muted">No advertisements yet.</p> : null} )}
</section>
</main> </main>
); );
} }
+30
View File
@@ -1,8 +1,12 @@
import { sendHotelAlert } from "@/actions/admin-alerts"; import { sendHotelAlert } from "@/actions/admin-alerts";
import { StatusCard } from "@/components/admin/dashboard";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
const CRITICAL = new Set(["critical", "error", "danger"]);
const WARNING = new Set(["warning", "warn"]);
// Read-only feed of alert_logs (prisma.alertLogs delegate): the emulator and // Read-only feed of alert_logs (prisma.alertLogs delegate): the emulator and
// CMS write rows here when hotel/user alerts fire. Auto-gated by the admin // CMS write rows here when hotel/user alerts fire. Auto-gated by the admin
// layout (requireStaff); only the mutation re-asserts staff in the action. // layout (requireStaff); only the mutation re-asserts staff in the action.
@@ -64,6 +68,10 @@ export default async function AdminAlerts() {
alerts = []; alerts = [];
} }
const critical = alerts.filter((a) => CRITICAL.has((a.severity || "").toLowerCase())).length;
const warnings = alerts.filter((a) => WARNING.has((a.severity || "").toLowerCase())).length;
const unread = alerts.filter((a) => !a.isRead).length;
return ( return (
<main> <main>
<h1>Alerts</h1> <h1>Alerts</h1>
@@ -71,6 +79,28 @@ export default async function AdminAlerts() {
Read-only · 100 most recent alert_logs (newest first). Read-only · 100 most recent alert_logs (newest first).
</p> </p>
<div className="admin-stats">
<StatusCard label="Alerts shown" value={alerts.length} icon="🔔" />
<StatusCard
label="Critical"
value={critical}
state={critical > 0 ? "danger" : "ok"}
icon="🚨"
/>
<StatusCard
label="Warnings"
value={warnings}
state={warnings > 0 ? "warn" : "neutral"}
icon="⚠️"
/>
<StatusCard
label="Unread"
value={unread}
state={unread > 0 ? "warn" : "ok"}
icon="📨"
/>
</div>
<form action={sendHotelAlert} className="card" style={{ marginBottom: "1.5rem" }}> <form action={sendHotelAlert} className="card" style={{ marginBottom: "1.5rem" }}>
<h3 style={{ marginTop: 0 }}>Send hotel alert</h3> <h3 style={{ marginTop: 0 }}>Send hotel alert</h3>
<p className="muted" style={{ margin: "0 0 0.75rem" }}> <p className="muted" style={{ margin: "0 0 0.75rem" }}>
+18 -2
View File
@@ -1,5 +1,6 @@
import Link from "next/link"; import Link from "next/link";
import { dismissApplication } from "@/actions/admin-applications"; import { dismissApplication } from "@/actions/admin-applications";
import { StatusCard } from "@/components/admin/dashboard";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
@@ -13,7 +14,7 @@ type ApplicationRow = {
}; };
function formatDate(d: Date | null): string { function formatDate(d: Date | null): string {
return d ? d.toISOString().slice(0, 16).replace("T", " ") : ""; return d ? d.toISOString().slice(0, 16).replace("T", " ") : "—";
} }
export default async function AdminApplications() { export default async function AdminApplications() {
@@ -45,7 +46,21 @@ export default async function AdminApplications() {
return ( return (
<main> <main>
<h1>Staff Applications</h1> <h1>Staff Applications</h1>
<p className="muted" style={{ marginTop: "-0.4rem" }}>
Pending applications from players requesting a staff rank.
</p>
<div className="admin-stats">
<StatusCard
label="Pending applications"
value={applications.length}
state={applications.length > 0 ? "warn" : "ok"}
icon="📨"
/>
</div>
<section className="admin-section">
<h2>Applications ({applications.length})</h2>
{applications.length === 0 ? ( {applications.length === 0 ? (
<p className="muted">No pending applications.</p> <p className="muted">No pending applications.</p>
) : ( ) : (
@@ -69,7 +84,7 @@ export default async function AdminApplications() {
<>User #{a.userId}</> <>User #{a.userId}</>
)} )}
</h3> </h3>
<span className="muted">Rank {a.rankId}</span> <span className="admin-badge">Rank {a.rankId}</span>
</div> </div>
<p className="muted" style={{ marginTop: 0 }}> <p className="muted" style={{ marginTop: 0 }}>
{formatDate(a.createdAt)} {formatDate(a.createdAt)}
@@ -86,6 +101,7 @@ export default async function AdminApplications() {
})} })}
</div> </div>
)} )}
</section>
</main> </main>
); );
} }
+33 -6
View File
@@ -1,4 +1,5 @@
import Link from "next/link"; import Link from "next/link";
import { StatusCard } from "@/components/admin/dashboard";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
@@ -14,15 +15,35 @@ export default async function AdminArticles() {
}) })
.catch(() => []); .catch(() => []);
const latest = articles[0]?.createdAt
? articles[0].createdAt.toISOString().slice(0, 10)
: "—";
return ( return (
<main> <main>
<div style={{ display: "flex", alignItems: "center", gap: "1rem" }}> <div style={{ display: "flex", alignItems: "center", gap: "1rem" }}>
<h1 style={{ flex: 1 }}>Articles</h1> <h1 style={{ flex: 1 }}>Articles</h1>
<Link href="/admin/articles/new">+ New article</Link> <Link href="/admin/articles/new" className="btn btn-primary">
+ New article
</Link>
</div> </div>
<table style={{ width: "100%", borderCollapse: "collapse" }}> <p className="muted" style={{ marginTop: "-0.4rem" }}>
News articles (<code>website_articles</code>) shown on the public site. 50 most recent.
</p>
<div className="admin-stats">
<StatusCard label="Articles shown" value={articles.length} icon="📰" />
<StatusCard label="Latest" value={latest} hint="most recent post" icon="🕒" />
</div>
<section className="admin-section">
<h2>Recent articles</h2>
{articles.length === 0 ? (
<p className="muted">No articles yet.</p>
) : (
<table>
<thead> <thead>
<tr style={{ textAlign: "left", color: "var(--muted)" }}> <tr>
<th>Title</th> <th>Title</th>
<th>Slug</th> <th>Slug</th>
<th>Created</th> <th>Created</th>
@@ -30,16 +51,22 @@ export default async function AdminArticles() {
</thead> </thead>
<tbody> <tbody>
{articles.map((a) => ( {articles.map((a) => (
<tr key={String(a.id)} style={{ borderTop: "1px solid var(--border)" }}> <tr key={String(a.id)}>
<td> <td>
<Link href={`/admin/articles/${a.id}`}>{a.title}</Link> <Link href={`/admin/articles/${a.id}`}>{a.title}</Link>
</td> </td>
<td className="muted">{a.slug}</td> <td className="muted">
<td className="muted">{a.createdAt ? a.createdAt.toISOString().slice(0, 10) : ""}</td> <code>{a.slug}</code>
</td>
<td className="muted" style={{ whiteSpace: "nowrap" }}>
{a.createdAt ? a.createdAt.toISOString().slice(0, 10) : "—"}
</td>
</tr> </tr>
))} ))}
</tbody> </tbody>
</table> </table>
)}
</section>
</main> </main>
); );
} }
+59 -9
View File
@@ -1,8 +1,13 @@
import { createBan, liftBan } from "@/actions/admin-bans"; import { createBan, liftBan } from "@/actions/admin-bans";
import { StatusCard } from "@/components/admin/dashboard";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
function fromUnix(ts: number): string {
return new Date(ts * 1000).toISOString().slice(0, 16).replace("T", " ");
}
export default async function AdminBans() { export default async function AdminBans() {
const now = Math.floor(Date.now() / 1000); const now = Math.floor(Date.now() / 1000);
let bans: Awaited<ReturnType<typeof prisma.ban.findMany>> = []; let bans: Awaited<ReturnType<typeof prisma.ban.findMany>> = [];
@@ -16,9 +21,32 @@ export default async function AdminBans() {
bans = []; bans = [];
} }
// banExpire of 0 means permanent in the emulator schema.
const permanent = bans.filter((b) => b.banExpire === 0).length;
const accountBans = bans.filter((b) => b.type === "account").length;
return ( return (
<main> <main>
<h1>Bans</h1> <h1>Bans</h1>
<p className="muted" style={{ marginTop: "-0.4rem" }}>
Active bans only (expiry in the future) · newest first.
</p>
<div className="admin-stats">
<StatusCard
label="Active bans"
value={bans.length}
state={bans.length > 0 ? "warn" : "ok"}
icon="🔨"
/>
<StatusCard
label="Permanent"
value={permanent}
state={permanent > 0 ? "danger" : "neutral"}
icon="⛔"
/>
<StatusCard label="Account bans" value={accountBans} icon="👤" />
</div>
<form action={createBan} className="card" style={{ marginBottom: "1.5rem" }}> <form action={createBan} className="card" style={{ marginBottom: "1.5rem" }}>
<h3 style={{ marginTop: 0 }}>New ban</h3> <h3 style={{ marginTop: 0 }}>New ban</h3>
@@ -32,13 +60,21 @@ export default async function AdminBans() {
</select> </select>
<input name="hours" type="number" min={0} placeholder="Hours (0 = perm)" /> <input name="hours" type="number" min={0} placeholder="Hours (0 = perm)" />
<input name="reason" placeholder="Reason" style={{ flex: 1, minWidth: 160 }} /> <input name="reason" placeholder="Reason" style={{ flex: 1, minWidth: 160 }} />
<button type="submit">Ban</button> <button type="submit" className="btn btn-danger">
Ban
</button>
</div> </div>
</form> </form>
<table style={{ width: "100%", borderCollapse: "collapse" }}> <section className="admin-section">
<h2>Active bans ({bans.length})</h2>
{bans.length === 0 ? (
<p className="muted">No active bans.</p>
) : (
<div style={{ overflowX: "auto" }}>
<table>
<thead> <thead>
<tr style={{ textAlign: "left", color: "var(--muted)" }}> <tr>
<th>User ID</th> <th>User ID</th>
<th>Type</th> <th>Type</th>
<th>Reason</th> <th>Reason</th>
@@ -48,22 +84,36 @@ export default async function AdminBans() {
</thead> </thead>
<tbody> <tbody>
{bans.map((b) => ( {bans.map((b) => (
<tr key={b.id} style={{ borderTop: "1px solid var(--border)" }}> <tr key={b.id}>
<td>{b.userId}</td> <td>{b.userId}</td>
<td>{b.type}</td> <td>
<td>{b.banReason}</td> <span className="admin-badge">{b.type}</span>
<td className="muted">{new Date(b.banExpire * 1000).toISOString().slice(0, 16).replace("T", " ")}</td> </td>
<td>{b.banReason || "—"}</td>
<td>
{b.banExpire === 0 ? (
<span className="admin-badge danger">permanent</span>
) : (
<span className="muted" style={{ whiteSpace: "nowrap" }}>
{fromUnix(b.banExpire)}
</span>
)}
</td>
<td> <td>
<form action={liftBan}> <form action={liftBan}>
<input type="hidden" name="id" value={b.id} /> <input type="hidden" name="id" value={b.id} />
<button type="submit">Lift</button> <button type="submit" className="btn btn-outline">
Lift
</button>
</form> </form>
</td> </td>
</tr> </tr>
))} ))}
</tbody> </tbody>
</table> </table>
{bans.length === 0 ? <p className="muted">No active bans.</p> : null} </div>
)}
</section>
</main> </main>
); );
} }
+32 -4
View File
@@ -1,4 +1,5 @@
import Link from "next/link"; import Link from "next/link";
import { StatusCard } from "@/components/admin/dashboard";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
@@ -39,10 +40,12 @@ export default async function AdminCalendar() {
// Tally reward counts per campaign in JS (avoid prisma.groupBy). // Tally reward counts per campaign in JS (avoid prisma.groupBy).
const rewardCounts = new Map<number, number>(); const rewardCounts = new Map<number, number>();
let totalRewards = 0;
try { try {
const rewards = await prisma.calendarRewards.findMany({ const rewards = await prisma.calendarRewards.findMany({
select: { campaignId: true }, select: { campaignId: true },
}); });
totalRewards = rewards.length;
for (const r of rewards) { for (const r of rewards) {
rewardCounts.set(r.campaignId, (rewardCounts.get(r.campaignId) ?? 0) + 1); rewardCounts.set(r.campaignId, (rewardCounts.get(r.campaignId) ?? 0) + 1);
} }
@@ -50,19 +53,32 @@ export default async function AdminCalendar() {
// leave map empty on failure // leave map empty on failure
} }
const enabledCount = campaigns.filter((c) => c.enabled === "1").length;
return ( return (
<main> <main>
<h1>Calendar campaigns</h1> <h1>Calendar campaigns</h1>
<p className="muted" style={{ marginTop: 0 }}> <p className="muted" style={{ marginTop: "-0.4rem" }}>
Read-only · {campaigns.length} campaign{campaigns.length === 1 ? "" : "s"}. Read-only · {campaigns.length} campaign{campaigns.length === 1 ? "" : "s"}.
</p> </p>
<div className="admin-stats">
<StatusCard label="Campaigns" value={campaigns.length} icon="📅" />
<StatusCard
label="Enabled"
value={enabledCount}
state={enabledCount > 0 ? "ok" : "neutral"}
icon="✅"
/>
<StatusCard label="Total rewards" value={totalRewards} icon="🎁" />
</div>
{campaigns.length === 0 ? ( {campaigns.length === 0 ? (
<p className="muted">No calendar campaigns found.</p> <p className="muted">No calendar campaigns found.</p>
) : ( ) : (
<div className="grid cols-3"> <div className="grid cols-3">
{campaigns.map((c) => { {campaigns.map((c) => {
const disabled = c.enabled !== "1"; const enabled = c.enabled === "1";
const count = rewardCounts.get(c.id) ?? 0; const count = rewardCounts.get(c.id) ?? 0;
return ( return (
<Link <Link
@@ -78,14 +94,26 @@ export default async function AdminCalendar() {
<div className="article-img" /> <div className="article-img" />
)} )}
<div className="body"> <div className="body">
<h3 style={{ margin: "0 0 0.35rem" }}>{c.name || `Campaign #${c.id}`}</h3> <div
style={{
display: "flex",
gap: "0.5rem",
alignItems: "center",
justifyContent: "space-between",
margin: "0 0 0.35rem",
}}
>
<h3 style={{ margin: 0 }}>{c.name || `Campaign #${c.id}`}</h3>
<span className={`admin-badge ${enabled ? "ok" : "danger"}`}>
{enabled ? "enabled" : "disabled"}
</span>
</div>
<p className="muted" style={{ margin: 0 }}> <p className="muted" style={{ margin: 0 }}>
#{c.id} · {c.totalDays} day{c.totalDays === 1 ? "" : "s"} · {count} reward #{c.id} · {c.totalDays} day{c.totalDays === 1 ? "" : "s"} · {count} reward
{count === 1 ? "" : "s"} {count === 1 ? "" : "s"}
</p> </p>
<p className="muted" style={{ margin: "0.35rem 0 0" }}> <p className="muted" style={{ margin: "0.35rem 0 0" }}>
Starts {fmtDate(c.startTimestamp)} Starts {fmtDate(c.startTimestamp)}
{disabled ? " · Disabled" : ""}
{c.lockExpired === "1" ? " · Locks expired" : ""} {c.lockExpired === "1" ? " · Locks expired" : ""}
</p> </p>
</div> </div>
+36 -10
View File
@@ -1,4 +1,5 @@
import Link from "next/link"; import Link from "next/link";
import { StatusCard } from "@/components/admin/dashboard";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
@@ -39,17 +40,40 @@ export default async function AdminCatalog() {
const captionById = new Map<number, string>(); const captionById = new Map<number, string>();
for (const p of pages) captionById.set(p.id, p.caption); for (const p of pages) captionById.set(p.id, p.caption);
const visibleCount = pages.filter(
(p) => p.visible === "1" && p.enabled === "1",
).length;
const hiddenCount = pages.length - visibleCount;
const rootCount = pages.filter((p) => p.parentId <= 0).length;
return ( return (
<main> <main>
<h1>Catalog</h1> <h1>Catalog</h1>
<p className="muted">Browse catalog pages and the items they contain. Read-only.</p> <p className="muted" style={{ marginTop: "-0.4rem" }}>
Browse catalog pages and the items they contain. Read-only.
</p>
<div className="admin-stats">
<StatusCard label="Pages" value={pages.length} icon="📚" />
<StatusCard label="Visible" value={visibleCount} state="ok" icon="👁️" />
<StatusCard
label="Hidden"
value={hiddenCount}
state={hiddenCount > 0 ? "warn" : "neutral"}
icon="🚫"
/>
<StatusCard label="Root pages" value={rootCount} icon="🗂️" />
</div>
<section className="admin-section">
<h2>Pages ({pages.length})</h2>
{pages.length === 0 ? ( {pages.length === 0 ? (
<p className="muted">No catalog pages found.</p> <p className="muted">No catalog pages found.</p>
) : ( ) : (
<table style={{ width: "100%", borderCollapse: "collapse" }}> <div style={{ overflowX: "auto" }}>
<table>
<thead> <thead>
<tr style={{ textAlign: "left", color: "var(--muted)" }}> <tr>
<th>ID</th> <th>ID</th>
<th>Caption</th> <th>Caption</th>
<th>Parent</th> <th>Parent</th>
@@ -65,8 +89,8 @@ export default async function AdminCatalog() {
p.parentId > 0 ? captionById.get(p.parentId) : null; p.parentId > 0 ? captionById.get(p.parentId) : null;
const hidden = p.visible !== "1" || p.enabled !== "1"; const hidden = p.visible !== "1" || p.enabled !== "1";
return ( return (
<tr key={p.id} style={{ borderTop: "1px solid var(--border)" }}> <tr key={p.id}>
<td>{p.id}</td> <td className="muted">{p.id}</td>
<td> <td>
<Link href={`/admin/catalog/${p.id}`}>{p.caption}</Link> <Link href={`/admin/catalog/${p.id}`}>{p.caption}</Link>
</td> </td>
@@ -80,17 +104,19 @@ export default async function AdminCatalog() {
<td className="muted">{p.pageLayout}</td> <td className="muted">{p.pageLayout}</td>
<td>{p.minRank}</td> <td>{p.minRank}</td>
<td>{p.orderNum}</td> <td>{p.orderNum}</td>
<td className="muted">{hidden ? "Hidden" : "Visible"}</td> <td>
<span className={`admin-badge ${hidden ? "danger" : "ok"}`}>
{hidden ? "Hidden" : "Visible"}
</span>
</td>
</tr> </tr>
); );
})} })}
</tbody> </tbody>
</table> </table>
</div>
)} )}
</section>
<p className="muted" style={{ marginTop: "1rem" }}>
{pages.length} page{pages.length === 1 ? "" : "s"}
</p>
</main> </main>
); );
} }
+16 -4
View File
@@ -1,4 +1,5 @@
import { updateEmulatorSetting, updateEmulatorText } from "@/actions/admin-emulator"; import { updateEmulatorSetting, updateEmulatorText } from "@/actions/admin-emulator";
import { StatusCard } from "@/components/admin/dashboard";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
@@ -40,8 +41,16 @@ export default async function AdminEmulator() {
on the next emulator read (some keys require an emulator reload). on the next emulator read (some keys require an emulator reload).
</p> </p>
<h2 style={{ marginTop: "1.5rem" }}>Settings</h2> <div className="admin-stats">
<p className="muted">{settings.length} keys.</p> <StatusCard label="Settings keys" value={settings.length} icon="⚙️" />
<StatusCard label="Text keys" value={textsCount} icon="🗒️" />
</div>
<section className="admin-section">
<h2>Settings</h2>
<p className="muted" style={{ marginTop: "-0.4rem" }}>
{settings.length} keys.
</p>
<form <form
action={updateEmulatorSetting} action={updateEmulatorSetting}
@@ -100,9 +109,11 @@ export default async function AdminEmulator() {
{settings.length === 0 ? ( {settings.length === 0 ? (
<p className="muted">No emulator settings found.</p> <p className="muted">No emulator settings found.</p>
) : null} ) : null}
</section>
<h2 style={{ marginTop: "2.5rem" }}>Texts</h2> <section className="admin-section">
<p className="muted"> <h2>Texts</h2>
<p className="muted" style={{ marginTop: "-0.4rem" }}>
{textsCount} keys{textsTruncated ? ` (showing first ${texts.length})` : ""}. {textsCount} keys{textsTruncated ? ` (showing first ${texts.length})` : ""}.
</p> </p>
@@ -169,6 +180,7 @@ export default async function AdminEmulator() {
add/overwrite form above to edit a key beyond this list. add/overwrite form above to edit a key beyond this list.
</p> </p>
) : null} ) : null}
</section>
</main> </main>
); );
} }
+26 -4
View File
@@ -1,4 +1,5 @@
import Link from "next/link"; import Link from "next/link";
import { StatusCard } from "@/components/admin/dashboard";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
@@ -16,17 +17,33 @@ export default async function AdminHelpQuestions() {
entries = []; entries = [];
} }
const smallBoxes = entries.filter((e) => e.smallBox).length;
const withButton = entries.filter((e) => e.buttonText).length;
return ( return (
<main> <main>
<div style={{ display: "flex", alignItems: "center", gap: "1rem" }}> <div style={{ display: "flex", alignItems: "center", gap: "1rem" }}>
<h1 style={{ flex: 1 }}>Help Center</h1> <h1 style={{ flex: 1 }}>Help Center</h1>
<Link href="/admin/help-questions/new">+ New entry</Link> <Link href="/admin/help-questions/new" className="btn btn-primary">
+ New entry
</Link>
</div> </div>
<p className="muted"> <p className="muted" style={{ marginTop: "-0.4rem" }}>
Help-center FAQ entries (<code>website_help_center_categories</code>). Shown to visitors on Help-center FAQ entries (<code>website_help_center_categories</code>). Shown to visitors on
the public help page, ordered by position. the public help page, ordered by position.
</p> </p>
<div className="admin-stats">
<StatusCard label="Entries" value={entries.length} icon="❓" />
<StatusCard label="With button" value={withButton} icon="🔘" />
<StatusCard label="Small boxes" value={smallBoxes} icon="🗂️" />
</div>
<section className="admin-section">
<h2>Entries</h2>
{entries.length === 0 ? (
<p className="muted">No help-center entries yet.</p>
) : (
<table> <table>
<thead> <thead>
<tr> <tr>
@@ -45,7 +62,11 @@ export default async function AdminHelpQuestions() {
<Link href={`/admin/help-questions/${e.id}`}>{e.name}</Link> <Link href={`/admin/help-questions/${e.id}`}>{e.name}</Link>
</td> </td>
<td className="muted">{e.buttonText || "—"}</td> <td className="muted">{e.buttonText || "—"}</td>
<td className="muted">{e.smallBox ? "Yes" : "No"}</td> <td>
<span className={`admin-badge ${e.smallBox ? "ok" : ""}`}>
{e.smallBox ? "Yes" : "No"}
</span>
</td>
<td> <td>
<Link href={`/admin/help-questions/${e.id}`}>Edit</Link> <Link href={`/admin/help-questions/${e.id}`}>Edit</Link>
</td> </td>
@@ -53,7 +74,8 @@ export default async function AdminHelpQuestions() {
))} ))}
</tbody> </tbody>
</table> </table>
{entries.length === 0 ? <p className="muted">No help-center entries yet.</p> : null} )}
</section>
</main> </main>
); );
} }
+37 -10
View File
@@ -1,4 +1,5 @@
import { deletePermission, upsertPermission } from "@/actions/admin-housekeeping"; import { deletePermission, upsertPermission } from "@/actions/admin-housekeeping";
import { StatusCard } from "@/components/admin/dashboard";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
@@ -15,24 +16,44 @@ export default async function AdminHousekeeping() {
permissions = []; permissions = [];
} }
const ranks = permissions.map((p) => p.minRank);
const maxRank = ranks.length ? Math.max(...ranks) : 0;
return ( return (
<main> <main>
<h1>Housekeeping Permissions</h1> <h1>Housekeeping Permissions</h1>
<p className="muted"> <p className="muted" style={{ marginTop: "-0.4rem" }}>
Minimum rank required to access each housekeeping permission key. Saving an Minimum rank required to access each housekeeping permission key. Saving an
existing key overwrites its min rank and description. existing key overwrites its min rank and description.
</p> </p>
<form action={upsertPermission} className="card" style={{ marginBottom: "1.5rem" }}> <div className="admin-stats">
<h3 style={{ marginTop: 0 }}>Add / overwrite permission</h3> <StatusCard label="Permissions" value={permissions.length} icon="🔑" />
<StatusCard label="Highest min rank" value={maxRank} icon="⬆️" />
</div>
<section className="admin-section">
<h2>Add / overwrite permission</h2>
<form action={upsertPermission} className="card">
<div style={{ display: "flex", gap: "0.5rem", flexWrap: "wrap" }}> <div style={{ display: "flex", gap: "0.5rem", flexWrap: "wrap" }}>
<input name="permission" placeholder="permission key" required style={{ minWidth: 200 }} /> <input name="permission" placeholder="permission key" required style={{ minWidth: 200 }} />
<input name="minRank" type="number" min={0} placeholder="Min rank" required style={{ width: 110 }} /> <input name="minRank" type="number" min={0} placeholder="Min rank" required style={{ width: 110 }} />
<input name="description" placeholder="description" style={{ flex: 1, minWidth: 160 }} /> <input name="description" placeholder="description" style={{ flex: 1, minWidth: 160 }} />
<button type="submit" className="btn btn-primary">Save</button> <button type="submit" className="btn btn-primary">
Save
</button>
</div> </div>
</form> </form>
</section>
<section className="admin-section">
<h2>
Permissions <span className="admin-badge">{permissions.length}</span>
</h2>
{permissions.length === 0 ? (
<p className="muted">No housekeeping permissions defined yet.</p>
) : (
<div className="card" style={{ padding: 0, overflowX: "auto" }}>
<table> <table>
<thead> <thead>
<tr> <tr>
@@ -45,7 +66,9 @@ export default async function AdminHousekeeping() {
<tbody> <tbody>
{permissions.map((p) => ( {permissions.map((p) => (
<tr key={String(p.id)}> <tr key={String(p.id)}>
<td><strong>{p.permission}</strong></td> <td>
<strong>{p.permission}</strong>
</td>
<td className="muted">{p.description ?? "—"}</td> <td className="muted">{p.description ?? "—"}</td>
<td> <td>
<form action={upsertPermission} style={{ display: "flex", gap: "0.5rem" }}> <form action={upsertPermission} style={{ display: "flex", gap: "0.5rem" }}>
@@ -58,22 +81,26 @@ export default async function AdminHousekeeping() {
defaultValue={p.minRank} defaultValue={p.minRank}
style={{ width: 90 }} style={{ width: 90 }}
/> />
<button type="submit" className="btn btn-outline">Save</button> <button type="submit" className="btn btn-outline">
Save
</button>
</form> </form>
</td> </td>
<td> <td>
<form action={deletePermission}> <form action={deletePermission}>
<input type="hidden" name="id" value={String(p.id)} /> <input type="hidden" name="id" value={String(p.id)} />
<button type="submit" className="btn btn-danger">✕</button> <button type="submit" className="btn btn-danger">
✕
</button>
</form> </form>
</td> </td>
</tr> </tr>
))} ))}
</tbody> </tbody>
</table> </table>
{permissions.length === 0 ? ( </div>
<p className="muted">No housekeeping permissions defined yet.</p> )}
) : null} </section>
</main> </main>
); );
} }
+36 -7
View File
@@ -4,6 +4,7 @@ import {
deleteBlacklist, deleteBlacklist,
deleteWhitelist, deleteWhitelist,
} from "@/actions/admin-ip"; } from "@/actions/admin-ip";
import { StatusCard } from "@/components/admin/dashboard";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
@@ -16,7 +17,7 @@ type IpRow = {
}; };
function formatDate(d: Date | null): string { function formatDate(d: Date | null): string {
return d ? d.toISOString().slice(0, 16).replace("T", " ") : ""; return d ? d.toISOString().slice(0, 16).replace("T", " ") : "—";
} }
export default async function AdminIp() { export default async function AdminIp() {
@@ -44,10 +45,28 @@ export default async function AdminIp() {
return ( return (
<main> <main>
<h1>IP Management</h1> <h1>IP Management</h1>
<p className="muted" style={{ marginTop: "-0.4rem" }}>
Allow or block IP addresses (and ASNs) at the website edge.
</p>
<div className="admin-stats">
<StatusCard
label="Whitelisted"
value={whitelist.length}
state={whitelist.length > 0 ? "ok" : "neutral"}
icon="✅"
/>
<StatusCard
label="Blacklisted"
value={blacklist.length}
state={blacklist.length > 0 ? "danger" : "neutral"}
icon="🚫"
/>
</div>
<div className="grid cols-2"> <div className="grid cols-2">
{/* ── Whitelist ─────────────────────────────── */} {/* ── Whitelist ──────────────────── */}
<section> <section className="admin-section">
<h2>Whitelist</h2> <h2>Whitelist</h2>
<form action={addWhitelist} className="card" style={{ marginBottom: "1rem" }}> <form action={addWhitelist} className="card" style={{ marginBottom: "1rem" }}>
@@ -65,6 +84,10 @@ export default async function AdminIp() {
</div> </div>
</form> </form>
{whitelist.length === 0 ? (
<p className="muted">No whitelisted IPs.</p>
) : (
<div style={{ overflowX: "auto" }}>
<table> <table>
<thead> <thead>
<tr> <tr>
@@ -92,11 +115,12 @@ export default async function AdminIp() {
))} ))}
</tbody> </tbody>
</table> </table>
{whitelist.length === 0 ? <p className="muted">No whitelisted IPs.</p> : null} </div>
)}
</section> </section>
{/* ── Blacklist ─────────────────────────────── */} {/* ── Blacklist ──────────────────── */}
<section> <section className="admin-section">
<h2>Blacklist</h2> <h2>Blacklist</h2>
<form action={addBlacklist} className="card" style={{ marginBottom: "1rem" }}> <form action={addBlacklist} className="card" style={{ marginBottom: "1rem" }}>
@@ -114,6 +138,10 @@ export default async function AdminIp() {
</div> </div>
</form> </form>
{blacklist.length === 0 ? (
<p className="muted">No blacklisted IPs.</p>
) : (
<div style={{ overflowX: "auto" }}>
<table> <table>
<thead> <thead>
<tr> <tr>
@@ -141,7 +169,8 @@ export default async function AdminIp() {
))} ))}
</tbody> </tbody>
</table> </table>
{blacklist.length === 0 ? <p className="muted">No blacklisted IPs.</p> : null} </div>
)}
</section> </section>
</div> </div>
</main> </main>
+26 -3
View File
@@ -1,7 +1,10 @@
import { StatusCard } from "@/components/admin/dashboard";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
const CRITICAL = new Set(["critical", "error", "danger"]);
// chatlogs_room and commandlogs are @@ignore'd in the schema (no PK), so Prisma // 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 // generates no delegate for them — they are read via $queryRaw against the real
// emulator columns. alert_logs has a proper delegate (prisma.alertLogs). // emulator columns. alert_logs has a proper delegate (prisma.alertLogs).
@@ -76,6 +79,12 @@ export default async function AdminLogs() {
Read-only · 50 most recent of each. Read-only · 50 most recent of each.
</p> </p>
<div className="admin-stats">
<StatusCard label="Room chat" value={chatlogs.length} icon="💬" />
<StatusCard label="Commands" value={commandlogs.length} icon="⌨️" />
<StatusCard label="Alerts" value={alertLogs.length} icon="🔔" />
</div>
<section className="card" style={{ marginBottom: "1.5rem" }}> <section className="card" style={{ marginBottom: "1.5rem" }}>
<h3 style={{ marginTop: 0 }}>Room chat ({chatlogs.length})</h3> <h3 style={{ marginTop: 0 }}>Room chat ({chatlogs.length})</h3>
{chatlogs.length === 0 ? ( {chatlogs.length === 0 ? (
@@ -131,7 +140,11 @@ export default async function AdminLogs() {
<td>{String(c.user_id)}</td> <td>{String(c.user_id)}</td>
<td>{c.command}</td> <td>{c.command}</td>
<td className="muted">{c.params}</td> <td className="muted">{c.params}</td>
<td>{c.succes === "yes" ? "✓" : "✗"}</td> <td>
<span className={`admin-badge ${c.succes === "yes" ? "ok" : "danger"}`}>
{c.succes === "yes" ? "OK" : "FAIL"}
</span>
</td>
</tr> </tr>
))} ))}
</tbody> </tbody>
@@ -161,9 +174,19 @@ export default async function AdminLogs() {
<tr key={String(a.id)}> <tr key={String(a.id)}>
<td className="muted">{fromDate(a.createdAt)}</td> <td className="muted">{fromDate(a.createdAt)}</td>
<td>{a.type}</td> <td>{a.type}</td>
<td>{a.severity}</td> <td>
<span
className={`admin-badge ${CRITICAL.has((a.severity || "").toLowerCase()) ? "danger" : ""}`}
>
{a.severity || "—"}
</span>
</td>
<td>{a.message}</td> <td>{a.message}</td>
<td>{a.isRead ? "✓" : "○"}</td> <td>
<span className={`admin-badge ${a.isRead ? "ok" : ""}`}>
{a.isRead ? "read" : "new"}
</span>
</td>
</tr> </tr>
))} ))}
</tbody> </tbody>
+34 -24
View File
@@ -1,4 +1,5 @@
import { saveMaintenance } from "@/actions/admin-maintenance"; import { saveMaintenance } from "@/actions/admin-maintenance";
import { StatusCard } from "@/components/admin/dashboard";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
@@ -34,34 +35,41 @@ export default async function AdminMaintenance() {
return ( return (
<main> <main>
<h1>Maintenance Mode</h1> <h1>Maintenance Mode</h1>
<p className="muted"> <p className="muted" style={{ marginTop: "-0.4rem" }}>
While maintenance is enabled, only staff (and users at or above the While maintenance is enabled, only staff (and users at or above the
minimum login rank) can log into the hotel. These values are stored in minimum login rank) can log into the hotel. These values are stored in
<code> website_settings</code> and read live by the login flow. <code> website_settings</code> and read live by the login flow.
</p> </p>
{/* ── Current state ─────────────────────────────────────── */} <div className="admin-stats">
<div <StatusCard
className="card" label="Maintenance"
style={{ value={enabled ? "ENABLED" : "Disabled"}
display: "flex", state={enabled ? "danger" : "ok"}
alignItems: "center", hint={enabled ? "Hotel is locked down" : "Hotel is open to everyone"}
gap: "0.75rem", icon={enabled ? "🚧" : "🟢"}
marginBottom: "1.5rem",
}}
>
<span
aria-hidden="true"
style={{
width: 12,
height: 12,
borderRadius: 999,
flexShrink: 0,
background: enabled
? "var(--color-danger)"
: "var(--color-secondary)",
}}
/> />
<StatusCard
label="Min login rank"
value={minRankValue}
hint="During maintenance"
icon="🛡️"
/>
<StatusCard
label="Splash message"
value={message ? "Set" : "Empty"}
state={message ? "ok" : "neutral"}
icon="💬"
/>
</div>
{/* ── Current state ─────────────────────────────────────── */}
<section className="admin-section">
<h2>Current state</h2>
<div className="card" style={{ display: "flex", alignItems: "center", gap: "0.75rem" }}>
<span className={`admin-badge ${enabled ? "danger" : "ok"}`}>
{enabled ? "ENABLED" : "disabled"}
</span>
<div> <div>
<strong> <strong>
Maintenance is currently {enabled ? "ENABLED" : "disabled"}. Maintenance is currently {enabled ? "ENABLED" : "disabled"}.
@@ -73,11 +81,12 @@ export default async function AdminMaintenance() {
</p> </p>
</div> </div>
</div> </div>
</section>
{/* ── Edit form ─────────────────────────────────────────── */} {/* ── Edit form ─────────────────────────────────────────── */}
<section className="admin-section">
<h2>Settings</h2>
<form action={saveMaintenance} className="card"> <form action={saveMaintenance} className="card">
<h3 style={{ marginTop: 0 }}>Settings</h3>
<label <label
htmlFor="mt-enabled" htmlFor="mt-enabled"
style={{ style={{
@@ -139,6 +148,7 @@ export default async function AdminMaintenance() {
Save changes Save changes
</button> </button>
</form> </form>
</section>
</main> </main>
); );
} }
+58 -26
View File
@@ -1,3 +1,4 @@
import { StatusCard } from "@/components/admin/dashboard";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
@@ -25,6 +26,13 @@ type PublicRow = {
visible: string; visible: string;
}; };
// Reusable yes/no pill so emulator boolean-ish "1"/"0" columns read consistently.
function YesNo({ on }: { on: boolean }) {
return (
<span className={`admin-badge ${on ? "ok" : "danger"}`}>{on ? "Yes" : "No"}</span>
);
}
export default async function AdminNavigation() { export default async function AdminNavigation() {
let flatcats: Flatcat[] = []; let flatcats: Flatcat[] = [];
try { try {
@@ -89,22 +97,44 @@ export default async function AdminNavigation() {
} }
} }
const publicFlatcats = flatcats.filter((c) => c.public === "1").length;
const visiblePublics = publics.filter((p) => p.visible === "1").length;
return ( return (
<main> <main>
<h1>Navigator</h1> <h1>Navigator</h1>
<p className="muted" style={{ marginTop: 0 }}> <p className="muted" style={{ marginTop: "-0.4rem" }}>
Read-only · the navigator config is owned by the emulator. Edit it in your Read-only · the navigator config is owned by the emulator. Edit it in your
emulator tooling, not here. emulator tooling, not here.
</p> </p>
<div className="admin-stats">
<StatusCard label="Flat categories" value={flatcats.length} icon="📂" />
<StatusCard
label="Public (flatcats)"
value={publicFlatcats}
hint="marked public"
icon="🌐"
/>
<StatusCard label="Public rooms" value={publics.length} icon="🚪" />
<StatusCard
label="Visible rooms"
value={visiblePublics}
state={visiblePublics > 0 ? "ok" : "neutral"}
icon="👁️"
/>
</div>
{/* ── navigator_flatcats ───────────────────────────────── */} {/* ── navigator_flatcats ───────────────────────────────── */}
<div className="card" style={{ padding: 0, overflowX: "auto", marginBottom: "1.5rem" }}> <section className="admin-section">
<div style={{ padding: "1.1rem 1.25rem 0" }}> <h2>Flat categories</h2>
<h3 style={{ margin: 0 }}>Flat categories</h3> <p className="muted" style={{ marginTop: "-0.4rem" }}>
<p className="muted" style={{ margin: "0.25rem 0 0" }}>
<code>navigator_flatcats</code> · {flatcats.length} rows <code>navigator_flatcats</code> · {flatcats.length} rows
</p> </p>
</div> {flatcats.length === 0 ? (
<p className="muted">No flat categories found.</p>
) : (
<div style={{ overflowX: "auto" }}>
<table> <table>
<thead> <thead>
<tr> <tr>
@@ -122,34 +152,37 @@ export default async function AdminNavigation() {
<tbody> <tbody>
{flatcats.map((c) => ( {flatcats.map((c) => (
<tr key={c.id}> <tr key={c.id}>
<td>{c.id}</td> <td className="muted">{c.id}</td>
<td>{c.caption || "—"}</td> <td>{c.caption || "—"}</td>
<td>{c.captionSave || "—"}</td> <td className="muted">{c.captionSave || "—"}</td>
<td>{c.minRank}</td> <td>{c.minRank}</td>
<td>{c.maxUserCount}</td> <td>{c.maxUserCount}</td>
<td>{c.canTrade === "1" ? "Yes" : "No"}</td> <td>
<td>{c.public === "1" ? "Yes" : "No"}</td> <YesNo on={c.canTrade === "1"} />
</td>
<td>
<YesNo on={c.public === "1"} />
</td>
<td>{c.listType}</td> <td>{c.listType}</td>
<td>{c.orderNum}</td> <td>{c.orderNum}</td>
</tr> </tr>
))} ))}
</tbody> </tbody>
</table> </table>
{flatcats.length === 0 ? (
<p className="muted" style={{ padding: "0 1.25rem 1.1rem" }}>
No flat categories found.
</p>
) : null}
</div> </div>
)}
</section>
{/* ── navigator_publics ────────────────────────────────── */} {/* ── navigator_publics ────────────────────────────────── */}
<div className="card" style={{ padding: 0, overflowX: "auto" }}> <section className="admin-section">
<div style={{ padding: "1.1rem 1.25rem 0" }}> <h2>Public rooms</h2>
<h3 style={{ margin: 0 }}>Public rooms</h3> <p className="muted" style={{ marginTop: "-0.4rem" }}>
<p className="muted" style={{ margin: "0.25rem 0 0" }}>
<code>navigator_publics</code> · {publics.length} rows <code>navigator_publics</code> · {publics.length} rows
</p> </p>
</div> {publics.length === 0 ? (
<p className="muted">No public-room assignments found.</p>
) : (
<div style={{ overflowX: "auto" }}>
<table> <table>
<thead> <thead>
<tr> <tr>
@@ -169,17 +202,16 @@ export default async function AdminNavigation() {
{roomNameById.get(p.room_id) || "(unnamed)"}{" "} {roomNameById.get(p.room_id) || "(unnamed)"}{" "}
<span className="muted">#{p.room_id}</span> <span className="muted">#{p.room_id}</span>
</td> </td>
<td>{p.visible === "1" ? "Yes" : "No"}</td> <td>
<YesNo on={p.visible === "1"} />
</td>
</tr> </tr>
))} ))}
</tbody> </tbody>
</table> </table>
{publics.length === 0 ? (
<p className="muted" style={{ padding: "0 1.25rem 1.1rem" }}>
No public-room assignments found.
</p>
) : null}
</div> </div>
)}
</section>
</main> </main>
); );
} }
+18 -5
View File
@@ -3,6 +3,7 @@ import {
deletePermission, deletePermission,
updatePermission, updatePermission,
} from "@/actions/admin-permissions"; } from "@/actions/admin-permissions";
import { StatusCard } from "@/components/admin/dashboard";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
@@ -29,14 +30,21 @@ export default async function AdminPermissions() {
permissions = []; permissions = [];
} }
const highestRank = permissions.reduce((max, p) => Math.max(max, p.minRank), 0);
return ( return (
<main> <main>
<h1>Permissions</h1> <h1>Permissions</h1>
<p className="muted"> <p className="muted" style={{ marginTop: "-0.4rem" }}>
CMS-owned permissions (<code>website_permissions</code>). Each entry maps CMS-owned permissions (<code>website_permissions</code>). Each entry maps
a permission name to the minimum rank allowed to use it. a permission name to the minimum rank allowed to use it.
</p> </p>
<div className="admin-stats">
<StatusCard label="Permissions" value={permissions.length} icon="🔑" />
<StatusCard label="Highest min rank" value={highestRank} icon="🎖️" />
</div>
<form action={createPermission} className="card" style={{ marginBottom: "1.5rem" }}> <form action={createPermission} className="card" style={{ marginBottom: "1.5rem" }}>
<h3 style={{ marginTop: 0 }}>Add / overwrite permission</h3> <h3 style={{ marginTop: 0 }}>Add / overwrite permission</h3>
<div style={{ display: "flex", gap: "0.5rem", flexWrap: "wrap" }}> <div style={{ display: "flex", gap: "0.5rem", flexWrap: "wrap" }}>
@@ -60,12 +68,15 @@ export default async function AdminPermissions() {
</div> </div>
</form> </form>
<section className="admin-section">
<h2>Permissions ({permissions.length})</h2>
{permissions.length === 0 ? ( {permissions.length === 0 ? (
<p className="muted">No permissions found.</p> <p className="muted">No permissions found.</p>
) : ( ) : (
<table style={{ width: "100%", borderCollapse: "collapse" }}> <div style={{ overflowX: "auto" }}>
<table>
<thead> <thead>
<tr style={{ textAlign: "left", color: "var(--muted)" }}> <tr>
<th>ID</th> <th>ID</th>
<th>Permission</th> <th>Permission</th>
<th>Min rank</th> <th>Min rank</th>
@@ -75,8 +86,8 @@ export default async function AdminPermissions() {
</thead> </thead>
<tbody> <tbody>
{permissions.map((p) => ( {permissions.map((p) => (
<tr key={String(p.id)} style={{ borderTop: "1px solid var(--border)" }}> <tr key={String(p.id)}>
<td>{String(p.id)}</td> <td className="muted">{String(p.id)}</td>
<td> <td>
<form <form
id={`perm-${String(p.id)}`} id={`perm-${String(p.id)}`}
@@ -124,7 +135,9 @@ export default async function AdminPermissions() {
))} ))}
</tbody> </tbody>
</table> </table>
</div>
)} )}
</section>
</main> </main>
); );
} }
+18 -2
View File
@@ -1,4 +1,5 @@
import { deletePhoto } from "@/actions/admin-photos"; import { deletePhoto } from "@/actions/admin-photos";
import { StatusCard } from "@/components/admin/dashboard";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
@@ -14,14 +15,28 @@ export default async function AdminPhotos() {
photos = []; photos = [];
} }
const uniqueRooms = new Set(photos.map((p) => p.roomId)).size;
const uniqueUsers = new Set(photos.map((p) => p.userId)).size;
return ( return (
<main> <main>
<h1>Photo Moderation</h1> <h1>Photo Moderation</h1>
<p className="muted"> <p className="muted" style={{ marginTop: "-0.4rem" }}>
Recent in-game camera photos. Deleting removes the record from{" "} Recent in-game camera photos. Deleting removes the record from{" "}
<code>camera_web</code>. <code>camera_web</code>.
</p> </p>
<div className="admin-stats">
<StatusCard label="Photos shown" value={photos.length} icon="📸" />
<StatusCard label="Rooms" value={uniqueRooms} hint="distinct rooms" icon="🚪" />
<StatusCard label="Users" value={uniqueUsers} hint="distinct photographers" icon="👤" />
</div>
<section className="admin-section">
<h2>Recent photos</h2>
{photos.length === 0 ? (
<p className="muted">No photos found.</p>
) : (
<div className="grid cols-3"> <div className="grid cols-3">
{photos.map((p) => ( {photos.map((p) => (
<div key={String(p.id)} className="card" style={{ padding: 0, overflow: "hidden" }}> <div key={String(p.id)} className="card" style={{ padding: 0, overflow: "hidden" }}>
@@ -48,7 +63,8 @@ export default async function AdminPhotos() {
</div> </div>
))} ))}
</div> </div>
{photos.length === 0 ? <p className="muted">No photos found.</p> : null} )}
</section>
</main> </main>
); );
} }
+38 -7
View File
@@ -5,6 +5,7 @@ import {
updateRadioBanner, updateRadioBanner,
} from '@/actions/admin-radio-extra'; } from '@/actions/admin-radio-extra';
import { prisma } from '@/lib/prisma'; import { prisma } from '@/lib/prisma';
import { StatusCard } from '@/components/admin/dashboard';
export const dynamic = 'force-dynamic'; export const dynamic = 'force-dynamic';
@@ -46,17 +47,21 @@ export default async function AdminRadioBannersPage() {
banners = []; banners = [];
} }
const activeCount = banners.filter((b) => b.isActive).length;
const hiddenCount = banners.length - activeCount;
return ( return (
<main> <main>
<nav className="muted" style={{ marginBottom: '0.5rem' }}> <nav className="muted" style={{ marginBottom: '0.5rem' }}>
<Link href="/admin/radio">Radio</Link> ·{' '} <Link href="/admin/radio">Radio</Link> ·{' '}
<Link href="/admin/radio/settings">Settings</Link> ·{' '} <Link href="/admin/radio/settings">Settings</Link> ·{' '}
<Link href="/admin/radio/banners">Banners</Link> ·{' '} <Link href="/admin/radio/banners">Banners</Link> ·{' '}
<Link href="/admin/radio/ranks">Ranks</Link> <Link href="/admin/radio/ranks">Ranks</Link> ·{' '}
<Link href="/admin/radio/history">History</Link>
</nav> </nav>
<h1>Radio Banners</h1> <h1>Radio Banners</h1>
<p className="muted"> <p className="muted" style={{ marginTop: '-0.4rem' }}>
Promotional banners shown on the radio page, ordered by sort order. Promotional banners shown on the radio page, ordered by sort order.
</p> </p>
@@ -66,10 +71,27 @@ export default async function AdminRadioBannersPage() {
Could not load banners (database unavailable). Could not load banners (database unavailable).
</p> </p>
</div> </div>
) : null} ) : (
<div className="admin-stats">
<StatusCard label="Banners" value={banners.length} icon="🖼️" />
<StatusCard
label="Active"
value={activeCount}
state={activeCount > 0 ? 'ok' : 'neutral'}
icon="✅"
/>
<StatusCard
label="Hidden"
value={hiddenCount}
state={hiddenCount > 0 ? 'warn' : 'neutral'}
icon="🚫"
/>
</div>
)}
<form action={createRadioBanner} className="card" style={{ marginBottom: '1.5rem' }}> <section className="admin-section">
<h3 style={{ marginTop: 0 }}>Add banner</h3> <h2>Add banner</h2>
<form action={createRadioBanner} className="card">
<div className="grid cols-2"> <div className="grid cols-2">
<div> <div>
<label htmlFor="new_imagePath" style={{ display: 'block', fontWeight: 700 }}> <label htmlFor="new_imagePath" style={{ display: 'block', fontWeight: 700 }}>
@@ -124,9 +146,12 @@ export default async function AdminRadioBannersPage() {
</button> </button>
</div> </div>
</form> </form>
</section>
<section className="admin-section">
<h2>Existing banners ({banners.length})</h2>
{banners.length === 0 ? ( {banners.length === 0 ? (
<p className="muted">No banners yet.</p> <p className="muted">No banners yet — add one above.</p>
) : ( ) : (
<div className="grid"> <div className="grid">
{banners.map((b) => ( {banners.map((b) => (
@@ -140,10 +165,15 @@ export default async function AdminRadioBannersPage() {
}} }}
> >
<strong>#{String(b.id)}</strong> <strong>#{String(b.id)}</strong>
<span className="muted"> <span
className="muted"
style={{ display: 'inline-flex', gap: '0.5rem', alignItems: 'center' }}
>
by user {String(b.userId)} · {formatDate(b.createdAt)} ·{' '} by user {String(b.userId)} · {formatDate(b.createdAt)} ·{' '}
<span className={`admin-badge ${b.isActive ? 'ok' : 'danger'}`}>
{b.isActive ? 'active' : 'hidden'} {b.isActive ? 'active' : 'hidden'}
</span> </span>
</span>
</div> </div>
{b.imagePath ? ( {b.imagePath ? (
@@ -248,6 +278,7 @@ export default async function AdminRadioBannersPage() {
))} ))}
</div> </div>
)} )}
</section>
</main> </main>
); );
} }
+37 -6
View File
@@ -1,5 +1,6 @@
import Link from 'next/link'; import Link from 'next/link';
import { prisma } from '@/lib/prisma'; import { prisma } from '@/lib/prisma';
import { StatusCard } from '@/components/admin/dashboard';
export const dynamic = 'force-dynamic'; export const dynamic = 'force-dynamic';
@@ -64,6 +65,13 @@ export default async function AdminRadioHistoryPage() {
history = []; history = [];
} }
const liveCount = history.filter((h) => !h.endedAt).length;
const peakListeners = history.reduce(
(max, h) => (h.listenersCount > max ? h.listenersCount : max),
0,
);
const uniqueDjs = new Set(history.map((h) => String(h.userId))).size;
return ( return (
<main> <main>
<nav className="muted" style={{ marginBottom: '0.5rem' }}> <nav className="muted" style={{ marginBottom: '0.5rem' }}>
@@ -75,7 +83,7 @@ export default async function AdminRadioHistoryPage() {
</nav> </nav>
<h1>Radio Play History</h1> <h1>Radio Play History</h1>
<p className="muted"> <p className="muted" style={{ marginTop: '-0.4rem' }}>
Past DJ shows and airtime (<code>radio_history</code>), most recent first. Past DJ shows and airtime (<code>radio_history</code>), most recent first.
Read-only log of who was on air, when, and how many listeners tuned in. Read-only log of who was on air, when, and how many listeners tuned in.
</p> </p>
@@ -86,11 +94,27 @@ export default async function AdminRadioHistoryPage() {
Could not load radio history (database unavailable). Could not load radio history (database unavailable).
</p> </p>
</div> </div>
) : (
<div className="admin-stats">
<StatusCard label="Shows logged" value={history.length} icon="🎙️" />
<StatusCard
label="On air now"
value={liveCount}
state={liveCount > 0 ? 'ok' : 'neutral'}
icon="🔴"
/>
<StatusCard label="Unique DJs" value={uniqueDjs} icon="🧑‍🎤" />
<StatusCard label="Peak listeners" value={peakListeners} icon="👥" />
</div>
)}
{!dbError && history.length === 0 ? (
<p className="muted">No radio history recorded yet.</p>
) : null} ) : null}
{history.length === 0 ? ( {history.length > 0 ? (
<p className="muted">No radio history recorded.</p> <section className="admin-section">
) : ( <h2>Show log ({history.length})</h2>
<table> <table>
<thead> <thead>
<tr> <tr>
@@ -111,7 +135,13 @@ export default async function AdminRadioHistoryPage() {
<td>{names.get(String(h.userId)) ?? `#${String(h.userId)}`}</td> <td>{names.get(String(h.userId)) ?? `#${String(h.userId)}`}</td>
<td>{h.showName ?? '—'}</td> <td>{h.showName ?? '—'}</td>
<td className="muted">{formatDate(h.startedAt)}</td> <td className="muted">{formatDate(h.startedAt)}</td>
<td className="muted">{formatDate(h.endedAt)}</td> <td>
{h.endedAt ? (
<span className="muted">{formatDate(h.endedAt)}</span>
) : (
<span className="admin-badge ok">on air</span>
)}
</td>
<td className="muted">{formatDuration(h.startedAt, h.endedAt)}</td> <td className="muted">{formatDuration(h.startedAt, h.endedAt)}</td>
<td>{h.listenersCount}</td> <td>{h.listenersCount}</td>
<td className="muted">{h.notes ?? '—'}</td> <td className="muted">{h.notes ?? '—'}</td>
@@ -119,7 +149,8 @@ export default async function AdminRadioHistoryPage() {
))} ))}
</tbody> </tbody>
</table> </table>
)} </section>
) : null}
</main> </main>
); );
} }
+63 -12
View File
@@ -1,5 +1,7 @@
import Link from 'next/link';
import { prisma } from '@/lib/prisma'; import { prisma } from '@/lib/prisma';
import { deleteShout } from '@/actions/admin-radio'; import { deleteShout } from '@/actions/admin-radio';
import { StatusCard } from '@/components/admin/dashboard';
export const dynamic = 'force-dynamic'; export const dynamic = 'force-dynamic';
@@ -39,6 +41,14 @@ function formatTime(d: Date | null): string {
return d ? d.toISOString().slice(11, 16) : '—'; return d ? d.toISOString().slice(11, 16) : '—';
} }
// Map an application status to an ok/danger/neutral pill tone.
function statusBadgeClass(status: string): string {
const s = status.toLowerCase();
if (s === 'approved' || s === 'accepted') return 'ok';
if (s === 'rejected' || s === 'denied') return 'danger';
return '';
}
export default async function AdminRadioPage() { export default async function AdminRadioPage() {
let shouts: Shout[] = []; let shouts: Shout[] = [];
let applications: Application[] = []; let applications: Application[] = [];
@@ -91,14 +101,47 @@ export default async function AdminRadioPage() {
schedules = []; schedules = [];
} }
const pendingApps = applications.filter(
(a) => a.status.toLowerCase() === 'pending',
).length;
const activeSchedules = schedules.filter((sc) => sc.isActive).length;
return ( return (
<main> <main>
<h1>Radio Moderation</h1> <nav className="muted" style={{ marginBottom: '0.5rem' }}>
<Link href="/admin/radio">Radio</Link> ·{' '}
<Link href="/admin/radio/settings">Settings</Link> ·{' '}
<Link href="/admin/radio/banners">Banners</Link> ·{' '}
<Link href="/admin/radio/ranks">Ranks</Link> ·{' '}
<Link href="/admin/radio/history">History</Link>
</nav>
<section style={{ marginBottom: '2rem' }}> <h1>Radio Moderation</h1>
<h2>Recent Shouts</h2> <p className="muted" style={{ marginTop: '-0.4rem' }}>
Latest shouts, DJ applications and on-air schedules (25 most recent of each).
</p>
<div className="admin-stats">
<StatusCard label="Recent shouts" value={shouts.length} icon="📣" />
<StatusCard
label="Pending applications"
value={pendingApps}
state={pendingApps > 0 ? 'warn' : 'ok'}
icon="📝"
/>
<StatusCard label="Schedules" value={schedules.length} icon="🗓️" />
<StatusCard
label="Active schedules"
value={activeSchedules}
state={activeSchedules > 0 ? 'ok' : 'neutral'}
icon="🎙️"
/>
</div>
<section className="admin-section">
<h2>Recent Shouts ({shouts.length})</h2>
{shouts.length === 0 ? ( {shouts.length === 0 ? (
<p className="muted">No shouts.</p> <p className="muted">No shouts to moderate.</p>
) : ( ) : (
<table> <table>
<thead> <thead>
@@ -132,10 +175,10 @@ export default async function AdminRadioPage() {
)} )}
</section> </section>
<section style={{ marginBottom: '2rem' }}> <section className="admin-section">
<h2>DJ Applications</h2> <h2>DJ Applications ({applications.length})</h2>
{applications.length === 0 ? ( {applications.length === 0 ? (
<p className="muted">No applications.</p> <p className="muted">No applications submitted.</p>
) : ( ) : (
<table> <table>
<thead> <thead>
@@ -159,7 +202,11 @@ export default async function AdminRadioPage() {
<td>{a.age}</td> <td>{a.age}</td>
<td>{a.musicStyle ?? '—'}</td> <td>{a.musicStyle ?? '—'}</td>
<td>{a.discordUsername ?? '—'}</td> <td>{a.discordUsername ?? '—'}</td>
<td>{a.status}</td> <td>
<span className={`admin-badge ${statusBadgeClass(a.status)}`}>
{a.status}
</span>
</td>
<td className="muted">{formatDate(a.createdAt)}</td> <td className="muted">{formatDate(a.createdAt)}</td>
</tr> </tr>
))} ))}
@@ -168,10 +215,10 @@ export default async function AdminRadioPage() {
)} )}
</section> </section>
<section> <section className="admin-section">
<h2>Schedules</h2> <h2>Schedules ({schedules.length})</h2>
{schedules.length === 0 ? ( {schedules.length === 0 ? (
<p className="muted">No schedules.</p> <p className="muted">No schedules configured.</p>
) : ( ) : (
<table> <table>
<thead> <thead>
@@ -194,7 +241,11 @@ export default async function AdminRadioPage() {
<td>{formatTime(sc.startTime)}</td> <td>{formatTime(sc.startTime)}</td>
<td>{formatTime(sc.endTime)}</td> <td>{formatTime(sc.endTime)}</td>
<td>{sc.showName ?? '—'}</td> <td>{sc.showName ?? '—'}</td>
<td>{sc.isActive ? '●' : '○'}</td> <td>
<span className={`admin-badge ${sc.isActive ? 'ok' : 'danger'}`}>
{sc.isActive ? 'active' : 'inactive'}
</span>
</td>
</tr> </tr>
))} ))}
</tbody> </tbody>
+48 -32
View File
@@ -5,6 +5,7 @@ import {
updateRadioRank, updateRadioRank,
} from '@/actions/admin-radio-extra'; } from '@/actions/admin-radio-extra';
import { prisma } from '@/lib/prisma'; import { prisma } from '@/lib/prisma';
import { StatusCard } from '@/components/admin/dashboard';
export const dynamic = 'force-dynamic'; export const dynamic = 'force-dynamic';
@@ -36,17 +37,22 @@ export default async function AdminRadioRanksPage() {
ranks = []; ranks = [];
} }
const activeCount = ranks.filter((r) => r.isActive).length;
return ( return (
<main> <main>
<nav className="muted" style={{ marginBottom: '0.5rem' }}> <nav className="muted" style={{ marginBottom: '0.5rem' }}>
<Link href="/admin/radio">Radio</Link> ·{' '} <Link href="/admin/radio">Radio</Link> ·{' '}
<Link href="/admin/radio/settings">Settings</Link> ·{' '} <Link href="/admin/radio/settings">Settings</Link> ·{' '}
<Link href="/admin/radio/banners">Banners</Link> ·{' '} <Link href="/admin/radio/banners">Banners</Link> ·{' '}
<Link href="/admin/radio/ranks">Ranks</Link> <Link href="/admin/radio/ranks">Ranks</Link> ·{' '}
<Link href="/admin/radio/history">History</Link>
</nav> </nav>
<h1>Radio Ranks</h1> <h1>Radio Ranks</h1>
<p className="muted">DJ / staff ranks assignable on radio applications.</p> <p className="muted" style={{ marginTop: '-0.4rem' }}>
DJ / staff ranks assignable on radio applications.
</p>
{dbError ? ( {dbError ? (
<div className="card" style={{ marginBottom: '1.5rem' }}> <div className="card" style={{ marginBottom: '1.5rem' }}>
@@ -54,10 +60,21 @@ export default async function AdminRadioRanksPage() {
Could not load ranks (database unavailable). Could not load ranks (database unavailable).
</p> </p>
</div> </div>
) : null} ) : (
<div className="admin-stats">
<StatusCard label="Ranks" value={ranks.length} icon="🎖️" />
<StatusCard
label="Active"
value={activeCount}
state={activeCount > 0 ? 'ok' : 'neutral'}
icon="✅"
/>
</div>
)}
<form action={createRadioRank} className="card" style={{ marginBottom: '1.5rem' }}> <section className="admin-section">
<h3 style={{ marginTop: 0 }}>Add rank</h3> <h2>Add rank</h2>
<form action={createRadioRank} className="card">
<div className="grid cols-3"> <div className="grid cols-3">
<div> <div>
<label htmlFor="new_name" style={{ display: 'block', fontWeight: 700 }}> <label htmlFor="new_name" style={{ display: 'block', fontWeight: 700 }}>
@@ -89,26 +106,31 @@ export default async function AdminRadioRanksPage() {
</button> </button>
</div> </div>
</form> </form>
</section>
<section className="admin-section">
<h2>Existing ranks ({ranks.length})</h2>
{ranks.length === 0 ? ( {ranks.length === 0 ? (
<p className="muted">No ranks yet.</p> <p className="muted">No ranks yet — add one above.</p>
) : ( ) : (
<table> <div className="grid">
<thead>
<tr>
<th>ID</th>
<th>Name</th>
<th>Badge</th>
<th>Description</th>
<th>Active</th>
<th />
</tr>
</thead>
<tbody>
{ranks.map((r) => ( {ranks.map((r) => (
<tr key={String(r.id)}> <div key={String(r.id)} className="card">
<td>{String(r.id)}</td> <div
<td colSpan={5}> style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'baseline',
gap: '0.75rem',
marginBottom: '0.6rem',
}}
>
<strong>#{String(r.id)}</strong>
<span className={`admin-badge ${r.isActive ? 'ok' : 'danger'}`}>
{r.isActive ? 'active' : 'inactive'}
</span>
</div>
<form <form
action={updateRadioRank} action={updateRadioRank}
style={{ style={{
@@ -153,24 +175,18 @@ export default async function AdminRadioRanksPage() {
Save Save
</button> </button>
</form> </form>
</td>
</tr> <form action={deleteRadioRank} style={{ marginTop: '0.5rem' }}>
))}
{ranks.map((r) => (
<tr key={`del_${String(r.id)}`}>
<td colSpan={6} style={{ borderTop: 'none', paddingTop: 0 }}>
<form action={deleteRadioRank} style={{ display: 'inline' }}>
<input type="hidden" name="id" value={String(r.id)} /> <input type="hidden" name="id" value={String(r.id)} />
<button type="submit" className="btn btn-danger"> <button type="submit" className="btn btn-danger">
Delete rank #{String(r.id)} Delete rank #{String(r.id)}
</button> </button>
</form> </form>
</td> </div>
</tr>
))} ))}
</tbody> </div>
</table>
)} )}
</section>
</main> </main>
); );
} }
+35 -3
View File
@@ -1,6 +1,7 @@
import Link from 'next/link'; import Link from 'next/link';
import { saveRadioSetting, saveRadioSettings } from '@/actions/admin-radio-extra'; import { saveRadioSetting, saveRadioSettings } from '@/actions/admin-radio-extra';
import { prisma } from '@/lib/prisma'; import { prisma } from '@/lib/prisma';
import { StatusCard } from '@/components/admin/dashboard';
export const dynamic = 'force-dynamic'; export const dynamic = 'force-dynamic';
@@ -208,17 +209,26 @@ export default async function AdminRadioSettingsPage() {
const allKeys = [...CURATED_KEYS, ...extra.map((e) => e.key)].join(','); const allKeys = [...CURATED_KEYS, ...extra.map((e) => e.key)].join(',');
const totalKeys = CURATED_KEYS.size + extra.length;
// Curated keys that already have a non-empty stored value.
const configuredKeys = [...CURATED_KEYS].filter((k) => {
const v = values.get(k);
return v != null && v !== '';
}).length;
const radioEnabled = (values.get('radio_enabled') ?? '') === '1';
return ( return (
<main> <main>
<nav className="muted" style={{ marginBottom: '0.5rem' }}> <nav className="muted" style={{ marginBottom: '0.5rem' }}>
<Link href="/admin/radio">Radio</Link> ·{' '} <Link href="/admin/radio">Radio</Link> ·{' '}
<Link href="/admin/radio/settings">Settings</Link> ·{' '} <Link href="/admin/radio/settings">Settings</Link> ·{' '}
<Link href="/admin/radio/banners">Banners</Link> ·{' '} <Link href="/admin/radio/banners">Banners</Link> ·{' '}
<Link href="/admin/radio/ranks">Ranks</Link> <Link href="/admin/radio/ranks">Ranks</Link> ·{' '}
<Link href="/admin/radio/history">History</Link>
</nav> </nav>
<h1>Radio Settings</h1> <h1>Radio Settings</h1>
<p className="muted"> <p className="muted" style={{ marginTop: '-0.4rem' }}>
Edits write to <code>website_settings</code> (radio_* keys) and bust the Edits write to <code>website_settings</code> (radio_* keys) and bust the
runtime settings cache. Booleans use <code>0</code> / <code>1</code>. runtime settings cache. Booleans use <code>0</code> / <code>1</code>.
</p> </p>
@@ -230,7 +240,29 @@ export default async function AdminRadioSettingsPage() {
created on save. created on save.
</p> </p>
</div> </div>
) : null} ) : (
<div className="admin-stats">
<StatusCard
label="Radio"
value={radioEnabled ? 'Enabled' : 'Disabled'}
state={radioEnabled ? 'ok' : 'danger'}
icon="📻"
/>
<StatusCard label="Total keys" value={totalKeys} icon="🔑" />
<StatusCard
label="Configured"
value={`${configuredKeys}/${CURATED_KEYS.size}`}
hint="curated keys with a value"
icon="⚙️"
/>
<StatusCard
label="Custom keys"
value={extra.length}
state={extra.length > 0 ? 'warn' : 'neutral'}
icon="➕"
/>
</div>
)}
<form action={saveRadioSettings}> <form action={saveRadioSettings}>
<input type="hidden" name="__keys" value={allKeys} /> <input type="hidden" name="__keys" value={allKeys} />
+14 -5
View File
@@ -4,6 +4,7 @@ import {
deleteCategory, deleteCategory,
deleteValue, deleteValue,
} from "@/actions/admin-rare-values"; } from "@/actions/admin-rare-values";
import { StatusCard } from "@/components/admin/dashboard";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
@@ -40,11 +41,16 @@ export default async function AdminRareValues() {
return ( return (
<main> <main>
<h1>Rare Values</h1> <h1>Rare Values</h1>
<p className="muted"> <p className="muted" style={{ marginTop: "-0.4rem" }}>
Catalog of rare furni and their trade values, grouped by category. Each category is Catalog of rare furni and their trade values, grouped by category. Each category is
rendered as a tab on the public rare values page. rendered as a tab on the public rare values page.
</p> </p>
<div className="admin-stats">
<StatusCard label="Categories" value={categories.length} icon="🗂️" />
<StatusCard label="Rare items" value={values.length} icon="💎" />
</div>
<form action={createCategory} className="card" style={{ marginBottom: "1.5rem" }}> <form action={createCategory} className="card" style={{ marginBottom: "1.5rem" }}>
<h3 style={{ marginTop: 0 }}>New category</h3> <h3 style={{ marginTop: 0 }}>New category</h3>
<div style={{ display: "flex", gap: "0.5rem", flexWrap: "wrap" }}> <div style={{ display: "flex", gap: "0.5rem", flexWrap: "wrap" }}>
@@ -74,10 +80,11 @@ export default async function AdminRareValues() {
flexWrap: "wrap", flexWrap: "wrap",
}} }}
> >
<div style={{ display: "flex", gap: "0.6rem", alignItems: "baseline" }}> <div style={{ display: "flex", gap: "0.6rem", alignItems: "baseline", flexWrap: "wrap" }}>
<strong>{cat.name}</strong> <strong>{cat.name}</strong>
<span className="muted">badge {cat.badge}</span> <span className="admin-badge">badge {cat.badge}</span>
<span className="muted">priority {cat.priority}</span> <span className="admin-badge">priority {cat.priority}</span>
<span className="muted">{rows.length} item{rows.length === 1 ? "" : "s"}</span>
</div> </div>
<form action={deleteCategory}> <form action={deleteCategory}>
<input type="hidden" name="id" value={catId} /> <input type="hidden" name="id" value={catId} />
@@ -104,7 +111,9 @@ export default async function AdminRareValues() {
<td className="muted">{v.itemId ?? "—"}</td> <td className="muted">{v.itemId ?? "—"}</td>
<td>{v.creditValue ?? "—"}</td> <td>{v.creditValue ?? "—"}</td>
<td>{v.currencyValue ?? "—"}</td> <td>{v.currencyValue ?? "—"}</td>
<td className="muted">{v.currencyType}</td> <td>
<span className="admin-badge">{v.currencyType}</span>
</td>
<td className="muted">{v.furnitureIcon}</td> <td className="muted">{v.furnitureIcon}</td>
<td> <td>
<form action={deleteValue}> <form action={deleteValue}>
+21 -2
View File
@@ -1,4 +1,5 @@
import Link from "next/link"; import Link from "next/link";
import { StatusCard } from "@/components/admin/dashboard";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
@@ -60,9 +61,23 @@ export default async function AdminRooms({
const qs = (p: number) => const qs = (p: number) =>
`/admin/rooms?q=${encodeURIComponent(q)}&page=${p}`; `/admin/rooms?q=${encodeURIComponent(q)}&page=${p}`;
// Page-local tallies (the full counts aren't fetched to keep the query cheap).
const openOnPage = rooms.filter((r) => r.state === "open").length;
const usersOnPage = rooms.reduce((sum, r) => sum + r.users, 0);
return ( return (
<main> <main>
<h1>Rooms</h1> <h1>Rooms</h1>
<p className="muted" style={{ marginTop: "-0.4rem" }}>
{q ? `Matching "${q}" · ` : ""}
{total} room{total === 1 ? "" : "s"} total.
</p>
<div className="admin-stats">
<StatusCard label={q ? "Matching rooms" : "Total rooms"} value={total} icon="🏠" />
<StatusCard label="Open (this page)" value={openOnPage} state="ok" icon="🔓" />
<StatusCard label="Users in-room (this page)" value={usersOnPage} icon="👥" />
</div>
<form style={{ display: "flex", gap: "0.5rem", marginBottom: "1rem" }}> <form style={{ display: "flex", gap: "0.5rem", marginBottom: "1rem" }}>
<input name="q" defaultValue={q} placeholder="Search room or owner" /> <input name="q" defaultValue={q} placeholder="Search room or owner" />
@@ -86,7 +101,7 @@ export default async function AdminRooms({
<tbody> <tbody>
{rooms.map((r) => ( {rooms.map((r) => (
<tr key={r.id}> <tr key={r.id}>
<td>{r.id}</td> <td className="muted">{r.id}</td>
<td> <td>
<Link href={`/admin/rooms/${r.id}`}>{r.name || "(unnamed)"}</Link> <Link href={`/admin/rooms/${r.id}`}>{r.name || "(unnamed)"}</Link>
</td> </td>
@@ -94,7 +109,11 @@ export default async function AdminRooms({
<td> <td>
{r.users} / {r.usersMax} {r.users} / {r.usersMax}
</td> </td>
<td>{r.state}</td> <td>
<span className={`admin-badge ${r.state === "open" ? "ok" : "danger"}`}>
{r.state}
</span>
</td>
<td>{r.category}</td> <td>{r.category}</td>
</tr> </tr>
))} ))}
+31 -7
View File
@@ -1,4 +1,5 @@
import { createSetting, deleteSetting, updateSetting } from "@/actions/admin-settings"; import { createSetting, deleteSetting, updateSetting } from "@/actions/admin-settings";
import { StatusCard } from "@/components/admin/dashboard";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
@@ -11,23 +12,41 @@ export default async function AdminSettings() {
settings = []; settings = [];
} }
const documented = settings.filter((s) => !!s.comment).length;
return ( return (
<main> <main>
<h1>CMS Settings</h1> <h1>CMS Settings</h1>
<p className="muted"> <p className="muted" style={{ marginTop: "-0.4rem" }}>
Key/value config read at runtime via <code>siteSettings.get()</code>. Saving busts the cache. Key/value config read at runtime via <code>siteSettings.get()</code>. Saving busts the cache.
</p> </p>
<form action={createSetting} className="card" style={{ marginBottom: "1.5rem" }}> <div className="admin-stats">
<h3 style={{ marginTop: 0 }}>Add / overwrite</h3> <StatusCard label="Settings" value={settings.length} icon="🗄️" />
<StatusCard label="With comment" value={documented} icon="📝" />
</div>
<section className="admin-section">
<h2>Add / overwrite</h2>
<form action={createSetting} className="card">
<div style={{ display: "flex", gap: "0.5rem", flexWrap: "wrap" }}> <div style={{ display: "flex", gap: "0.5rem", flexWrap: "wrap" }}>
<input name="key" placeholder="key" required /> <input name="key" placeholder="key" required />
<input name="value" placeholder="value" style={{ flex: 1, minWidth: 160 }} /> <input name="value" placeholder="value" style={{ flex: 1, minWidth: 160 }} />
<input name="comment" placeholder="comment" /> <input name="comment" placeholder="comment" />
<button type="submit">Save</button> <button type="submit" className="btn btn-primary">
Save
</button>
</div> </div>
</form> </form>
</section>
<section className="admin-section">
<h2>
Settings <span className="admin-badge">{settings.length}</span>
</h2>
{settings.length === 0 ? (
<p className="muted">No settings yet.</p>
) : (
<div className="grid"> <div className="grid">
{settings.map((s) => ( {settings.map((s) => (
<div key={s.key} className="card"> <div key={s.key} className="card">
@@ -39,11 +58,15 @@ export default async function AdminSettings() {
> >
<input type="hidden" name="key" value={s.key} /> <input type="hidden" name="key" value={s.key} />
<input name="value" defaultValue={s.value} style={{ flex: 1 }} /> <input name="value" defaultValue={s.value} style={{ flex: 1 }} />
<button type="submit">Save</button> <button type="submit" className="btn btn-outline">
Save
</button>
</form> </form>
<form action={deleteSetting}> <form action={deleteSetting}>
<input type="hidden" name="key" value={s.key} /> <input type="hidden" name="key" value={s.key} />
<button type="submit">✕</button> <button type="submit" className="btn btn-danger">
✕
</button>
</form> </form>
</div> </div>
{s.comment ? ( {s.comment ? (
@@ -54,7 +77,8 @@ export default async function AdminSettings() {
</div> </div>
))} ))}
</div> </div>
{settings.length === 0 ? <p className="muted">No settings yet.</p> : null} )}
</section>
</main> </main>
); );
} }
+78 -21
View File
@@ -1,8 +1,20 @@
import Link from "next/link"; import Link from "next/link";
import { StatusCard } from "@/components/admin/dashboard";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
const PAID_STATUS = new Set(["completed", "paid", "success", "approved"]);
function statusState(status: string | null | undefined): "ok" | "danger" | undefined {
if (!status) return undefined;
const s = status.toLowerCase();
if (PAID_STATUS.has(s)) return "ok";
if (s === "failed" || s === "denied" || s === "refunded" || s === "cancelled" || s === "canceled")
return "danger";
return undefined;
}
export default async function AdminShop() { export default async function AdminShop() {
let packages: Awaited<ReturnType<typeof prisma.websiteShopArticles.findMany>> = []; let packages: Awaited<ReturnType<typeof prisma.websiteShopArticles.findMany>> = [];
try { try {
@@ -24,20 +36,41 @@ export default async function AdminShop() {
orders = []; orders = [];
} }
const paidOrders = orders.filter((o) => statusState(o.status) === "ok").length;
return ( return (
<main> <main>
<div style={{ display: "flex", alignItems: "center", gap: "1rem" }}> <div style={{ display: "flex", alignItems: "center", gap: "1rem" }}>
<h1 style={{ flex: 1 }}>Shop</h1> <h1 style={{ flex: 1 }}>Shop</h1>
<Link href="/admin/shop/new">+ New package</Link> <Link href="/admin/shop/new" className="btn btn-primary">
+ New package
</Link>
</div> </div>
<p className="muted"> <p className="muted" style={{ marginTop: "-0.4rem" }}>
Website store packages (<code>website_shop_articles</code>). <code>costs</code> is the price Website store packages (<code>website_shop_articles</code>). <code>costs</code> is the price
shown in the store; credits/duckets/diamonds/badges are what the buyer receives. shown in the store; credits/duckets/diamonds/badges are what the buyer receives.
</p> </p>
<table style={{ width: "100%", borderCollapse: "collapse" }}> <div className="admin-stats">
<StatusCard label="Packages" value={packages.length} icon="🛍️" />
<StatusCard label="Recent orders" value={orders.length} icon="🧾" />
<StatusCard
label="Paid orders"
value={paidOrders}
state={paidOrders > 0 ? "ok" : "neutral"}
icon="💳"
/>
</div>
<section className="admin-section">
<h2>Packages ({packages.length})</h2>
{packages.length === 0 ? (
<p className="muted">No shop packages yet.</p>
) : (
<div style={{ overflowX: "auto" }}>
<table>
<thead> <thead>
<tr style={{ textAlign: "left", color: "var(--muted)" }}> <tr>
<th>#</th> <th>#</th>
<th>Name</th> <th>Name</th>
<th>Costs</th> <th>Costs</th>
@@ -50,30 +83,37 @@ export default async function AdminShop() {
</thead> </thead>
<tbody> <tbody>
{packages.map((p) => ( {packages.map((p) => (
<tr key={String(p.id)} style={{ borderTop: "1px solid var(--border)" }}> <tr key={String(p.id)}>
<td className="muted">{String(p.id)}</td> <td className="muted">{String(p.id)}</td>
<td> <td>
<Link href={`/admin/shop/${p.id}`}>{p.name}</Link> <Link href={`/admin/shop/${p.id}`}>{p.name}</Link>
</td> </td>
<td>{p.costs}</td> <td>{p.costs}</td>
<td className="muted">{p.credits ?? ""}</td> <td className="muted">{p.credits ?? "—"}</td>
<td className="muted">{p.duckets ?? ""}</td> <td className="muted">{p.duckets ?? "—"}</td>
<td className="muted">{p.diamonds ?? ""}</td> <td className="muted">{p.diamonds ?? "—"}</td>
<td className="muted">{p.giveRank ?? ""}</td> <td className="muted">{p.giveRank ?? "—"}</td>
<td className="muted">{p.position}</td> <td className="muted">{p.position}</td>
</tr> </tr>
))} ))}
</tbody> </tbody>
</table> </table>
{packages.length === 0 ? <p className="muted">No shop packages yet.</p> : null} </div>
)}
</section>
<h2 style={{ marginTop: "2rem" }}>Recent orders</h2> <section className="admin-section">
<p className="muted"> <h2>Recent orders ({orders.length})</h2>
<p className="muted" style={{ marginTop: "-0.4rem" }}>
Read-only PayPal transaction log (<code>website_paypal_transactions</code>). Read-only PayPal transaction log (<code>website_paypal_transactions</code>).
</p> </p>
<table style={{ width: "100%", borderCollapse: "collapse" }}> {orders.length === 0 ? (
<p className="muted">No orders recorded.</p>
) : (
<div style={{ overflowX: "auto" }}>
<table>
<thead> <thead>
<tr style={{ textAlign: "left", color: "var(--muted)" }}> <tr>
<th>Transaction</th> <th>Transaction</th>
<th>User</th> <th>User</th>
<th>Description</th> <th>Description</th>
@@ -83,23 +123,40 @@ export default async function AdminShop() {
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{orders.map((o) => ( {orders.map((o) => {
<tr key={String(o.id)} style={{ borderTop: "1px solid var(--border)" }}> const state = statusState(o.status);
return (
<tr key={String(o.id)}>
<td> <td>
<code>{o.transactionId}</code> <code>{o.transactionId}</code>
</td> </td>
<td className="muted">{o.userId}</td> <td className="muted">{o.userId}</td>
<td className="muted">{o.description ?? ""}</td> <td className="muted">{o.description ?? "—"}</td>
<td> <td>
{o.amount} {o.currency} {o.amount} {o.currency}
</td> </td>
<td className="muted">{o.status ?? ""}</td> <td>
<td className="muted">{o.createdAt ? o.createdAt.toISOString().slice(0, 10) : ""}</td> {o.status ? (
<span
className={`admin-badge ${state === "ok" ? "ok" : state === "danger" ? "danger" : ""}`}
>
{o.status}
</span>
) : (
<span className="muted">—</span>
)}
</td>
<td className="muted">
{o.createdAt ? o.createdAt.toISOString().slice(0, 10) : "—"}
</td>
</tr> </tr>
))} );
})}
</tbody> </tbody>
</table> </table>
{orders.length === 0 ? <p className="muted">No orders recorded.</p> : null} </div>
)}
</section>
</main> </main>
); );
} }
+33 -4
View File
@@ -1,3 +1,4 @@
import { StatusCard } from "@/components/admin/dashboard";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
@@ -39,13 +40,31 @@ export default async function AdminSubscriptions() {
active = []; active = [];
} }
// "Expiring soon" = within the next 7 days.
const soonCutoff = now + 7 * 24 * 60 * 60;
const expiringSoon = active.filter((s) => s.expiresAt <= soonCutoff).length;
const types = new Set(active.map((s) => s.subscriptionType ?? "—")).size;
return ( return (
<main> <main>
<h1>Subscriptions</h1> <h1>Subscriptions</h1>
<p className="muted" style={{ marginTop: "-0.25rem", marginBottom: "1rem" }}> <p className="muted" style={{ marginTop: "-0.4rem" }}>
Active Habbo Club / VIP subscriptions (expiry &gt; now). Read-only. Active Habbo Club / VIP subscriptions (expiry &gt; now). Read-only.
</p> </p>
<div className="admin-stats">
<StatusCard label="Active subs" value={active.length} icon="⭐" />
<StatusCard label="Types" value={types} icon="🏷️" />
<StatusCard
label="Expiring ≤ 7d"
value={expiringSoon}
state={expiringSoon > 0 ? "warn" : "ok"}
icon="⏳"
/>
</div>
<section className="admin-section">
<h2>Active subscriptions ({active.length})</h2>
<div className="card"> <div className="card">
<table> <table>
<thead> <thead>
@@ -53,22 +72,32 @@ export default async function AdminSubscriptions() {
<th>Sub ID</th> <th>Sub ID</th>
<th>User ID</th> <th>User ID</th>
<th>Type</th> <th>Type</th>
<th>Status</th>
<th>Expires</th> <th>Expires</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{active.map((s) => ( {active.map((s) => {
const soon = s.expiresAt <= soonCutoff;
return (
<tr key={s.id}> <tr key={s.id}>
<td>{s.id}</td> <td className="muted">{s.id}</td>
<td>{s.userId ?? "—"}</td> <td>{s.userId ?? "—"}</td>
<td>{s.subscriptionType ?? "—"}</td> <td>{s.subscriptionType ?? "—"}</td>
<td>
<span className={`admin-badge ${soon ? "" : "ok"}`}>
{soon ? "Expiring" : "Active"}
</span>
</td>
<td className="muted">{fmt(s.expiresAt)}</td> <td className="muted">{fmt(s.expiresAt)}</td>
</tr> </tr>
))} );
})}
</tbody> </tbody>
</table> </table>
{active.length === 0 ? <p className="muted">No active subscriptions.</p> : null} {active.length === 0 ? <p className="muted">No active subscriptions.</p> : null}
</div> </div>
</section>
</main> </main>
); );
} }
+36 -4
View File
@@ -1,4 +1,5 @@
import { createTag, deleteTag, updateTag } from '@/actions/admin-tags'; import { createTag, deleteTag, updateTag } from '@/actions/admin-tags';
import { StatusCard } from '@/components/admin/dashboard';
import { prisma } from '@/lib/prisma'; import { prisma } from '@/lib/prisma';
export const dynamic = 'force-dynamic'; export const dynamic = 'force-dynamic';
@@ -37,10 +38,13 @@ export default async function AdminTagsPage() {
tags = []; tags = [];
} }
const inUse = tags.filter((t) => (usage.get(String(t.id)) ?? 0) > 0).length;
const unused = tags.length - inUse;
return ( return (
<main> <main>
<h1>Tags</h1> <h1>Tags</h1>
<p className="muted"> <p className="muted" style={{ marginTop: '-0.4rem' }}>
Article tags / categories (<code>tags</code>). Each tag has a label and a Article tags / categories (<code>tags</code>). Each tag has a label and a
background colour, and can be attached to articles via <code>taggables</code>. background colour, and can be attached to articles via <code>taggables</code>.
</p> </p>
@@ -51,7 +55,24 @@ export default async function AdminTagsPage() {
Could not load tags (database unavailable). Could not load tags (database unavailable).
</p> </p>
</div> </div>
) : null} ) : (
<div className="admin-stats">
<StatusCard label="Tags" value={tags.length} icon="🏷️" />
<StatusCard
label="In use"
value={inUse}
hint="attached to content"
state={inUse > 0 ? 'ok' : 'neutral'}
icon="🔗"
/>
<StatusCard
label="Unused"
value={unused}
state={unused > 0 ? 'warn' : 'ok'}
icon="🫙"
/>
</div>
)}
<form action={createTag} className="card" style={{ marginBottom: '1.5rem' }}> <form action={createTag} className="card" style={{ marginBottom: '1.5rem' }}>
<h3 style={{ marginTop: 0 }}>Add tag</h3> <h3 style={{ marginTop: 0 }}>Add tag</h3>
@@ -93,6 +114,8 @@ export default async function AdminTagsPage() {
</div> </div>
</form> </form>
<section className="admin-section">
<h2>All tags</h2>
{tags.length === 0 ? ( {tags.length === 0 ? (
<p className="muted">No tags yet.</p> <p className="muted">No tags yet.</p>
) : ( ) : (
@@ -110,7 +133,7 @@ export default async function AdminTagsPage() {
<tbody> <tbody>
{tags.map((t) => ( {tags.map((t) => (
<tr key={String(t.id)}> <tr key={String(t.id)}>
<td>{String(t.id)}</td> <td className="muted">{String(t.id)}</td>
<td> <td>
<span <span
style={{ style={{
@@ -127,7 +150,15 @@ export default async function AdminTagsPage() {
<td className="muted"> <td className="muted">
<code>{t.backgroundColor}</code> <code>{t.backgroundColor}</code>
</td> </td>
<td className="muted">{usage.get(String(t.id)) ?? 0}</td> <td>
<span
className={`admin-badge ${
(usage.get(String(t.id)) ?? 0) > 0 ? 'ok' : ''
}`}
>
{usage.get(String(t.id)) ?? 0}
</span>
</td>
<td className="muted">{formatDate(t.createdAt)}</td> <td className="muted">{formatDate(t.createdAt)}</td>
<td> <td>
<form <form
@@ -164,6 +195,7 @@ export default async function AdminTagsPage() {
</tbody> </tbody>
</table> </table>
)} )}
</section>
</main> </main>
); );
} }
+31 -5
View File
@@ -1,4 +1,5 @@
import { createTeam, deleteTeam } from "@/actions/admin-teams"; import { createTeam, deleteTeam } from "@/actions/admin-teams";
import { StatusCard } from "@/components/admin/dashboard";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
@@ -30,11 +31,27 @@ export default async function AdminTeams() {
teams = []; teams = [];
} }
const hidden = teams.filter((t) => t.hiddenRank).length;
return ( return (
<main> <main>
<h1>Teams</h1> <h1>Teams</h1>
<p className="muted" style={{ marginTop: "-0.4rem" }}>
Staff rank definitions shown on the public team page.
</p>
<div className="card" style={{ marginBottom: "1.25rem" }}> <div className="admin-stats">
<StatusCard label="Team ranks" value={teams.length} icon="🛡️" />
<StatusCard
label="Visible"
value={teams.length - hidden}
state={teams.length - hidden > 0 ? "ok" : "neutral"}
icon="👁️"
/>
<StatusCard label="Hidden" value={hidden} icon="🙈" />
</div>
<div className="card" style={{ marginBottom: "1.5rem" }}>
<h3 style={{ marginTop: 0 }}>New team rank</h3> <h3 style={{ marginTop: 0 }}>New team rank</h3>
<form action={createTeam} style={{ display: "grid", gap: "0.6rem" }}> <form action={createTeam} style={{ display: "grid", gap: "0.6rem" }}>
<div className="grid cols-2"> <div className="grid cols-2">
@@ -67,9 +84,12 @@ export default async function AdminTeams() {
</form> </form>
</div> </div>
<section className="admin-section">
<h2>Ranks ({teams.length})</h2>
{teams.length === 0 ? ( {teams.length === 0 ? (
<p className="muted">No teams yet.</p> <p className="muted">No teams yet.</p>
) : ( ) : (
<div style={{ overflowX: "auto" }}>
<table> <table>
<thead> <thead>
<tr> <tr>
@@ -78,14 +98,14 @@ export default async function AdminTeams() {
<th>Badge</th> <th>Badge</th>
<th>Job description</th> <th>Job description</th>
<th>Color</th> <th>Color</th>
<th>Hidden</th> <th>Visibility</th>
<th></th> <th />
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{teams.map((t) => ( {teams.map((t) => (
<tr key={String(t.id)}> <tr key={String(t.id)}>
<td>{String(t.id)}</td> <td className="muted">{String(t.id)}</td>
<td>{t.rankName}</td> <td>{t.rankName}</td>
<td className="muted">{t.badge ?? "—"}</td> <td className="muted">{t.badge ?? "—"}</td>
<td className="muted">{t.jobDescription ?? "—"}</td> <td className="muted">{t.jobDescription ?? "—"}</td>
@@ -110,7 +130,11 @@ export default async function AdminTeams() {
{t.staffColor} {t.staffColor}
</span> </span>
</td> </td>
<td className="muted">{t.hiddenRank ? "Yes" : "No"}</td> <td>
<span className={`admin-badge ${t.hiddenRank ? "" : "ok"}`}>
{t.hiddenRank ? "hidden" : "visible"}
</span>
</td>
<td> <td>
<form action={deleteTeam}> <form action={deleteTeam}>
<input type="hidden" name="id" value={String(t.id)} /> <input type="hidden" name="id" value={String(t.id)} />
@@ -123,7 +147,9 @@ export default async function AdminTeams() {
))} ))}
</tbody> </tbody>
</table> </table>
</div>
)} )}
</section>
</main> </main>
); );
} }
+59 -8
View File
@@ -1,4 +1,5 @@
import Link from "next/link"; import Link from "next/link";
import { StatusCard } from "@/components/admin/dashboard";
import { requireStaff } from "@/lib/admin/guard"; import { requireStaff } from "@/lib/admin/guard";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
@@ -15,6 +16,23 @@ const PER_PAGE = 25;
type Txn = Awaited<ReturnType<typeof prisma.websitePaypalTransactions.findMany>>[number]; type Txn = Awaited<ReturnType<typeof prisma.websitePaypalTransactions.findMany>>[number];
const PAID_STATUS = new Set(["completed", "paid", "success", "approved"]);
const FAILED_STATUS = new Set([
"failed",
"denied",
"refunded",
"cancelled",
"canceled",
]);
function statusState(status: string | null | undefined): "ok" | "danger" | undefined {
if (!status) return undefined;
const s = status.toLowerCase();
if (PAID_STATUS.has(s)) return "ok";
if (FAILED_STATUS.has(s)) return "danger";
return undefined;
}
function fromDate(d: Date | null | undefined): string { function fromDate(d: Date | null | undefined): string {
return d ? d.toISOString().slice(0, 16).replace("T", " ") : "—"; return d ? d.toISOString().slice(0, 16).replace("T", " ") : "—";
} }
@@ -65,6 +83,9 @@ export default async function AdminTransactions({
const pages = Math.max(1, Math.ceil(total / PER_PAGE)); const pages = Math.max(1, Math.ceil(total / PER_PAGE));
const pagePaid = transactions.filter((t) => statusState(t.status) === "ok").length;
const pageFailed = transactions.filter((t) => statusState(t.status) === "danger").length;
return ( return (
<main> <main>
<h1>Transactions</h1> <h1>Transactions</h1>
@@ -72,14 +93,31 @@ export default async function AdminTransactions({
Read-only · PayPal / top-up transactions recorded by the CMS, newest first. Read-only · PayPal / top-up transactions recorded by the CMS, newest first.
</p> </p>
<section className="card"> <div className="admin-stats">
<StatusCard label="Total transactions" value={total} icon="🧾" />
<StatusCard
label="Paid (this page)"
value={pagePaid}
state={pagePaid > 0 ? "ok" : "neutral"}
icon="💳"
/>
<StatusCard
label="Failed (this page)"
value={pageFailed}
state={pageFailed > 0 ? "danger" : "neutral"}
icon="⚠️"
/>
</div>
<section className="admin-section">
<h2>Transactions ({total})</h2>
{transactions.length === 0 ? ( {transactions.length === 0 ? (
<p className="muted">No transactions.</p> <p className="muted">No transactions.</p>
) : ( ) : (
<div style={{ overflowX: "auto" }}> <div style={{ overflowX: "auto" }}>
<table style={{ width: "100%", borderCollapse: "collapse" }}> <table>
<thead> <thead>
<tr style={{ textAlign: "left", color: "var(--muted)" }}> <tr>
<th>ID</th> <th>ID</th>
<th>Payer</th> <th>Payer</th>
<th>Amount</th> <th>Amount</th>
@@ -90,9 +128,11 @@ export default async function AdminTransactions({
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{transactions.map((t) => ( {transactions.map((t) => {
<tr key={String(t.id)} style={{ borderTop: "1px solid var(--border)" }}> const state = statusState(t.status);
<td>{String(t.id)}</td> return (
<tr key={String(t.id)}>
<td className="muted">{String(t.id)}</td>
<td> <td>
<Link href={`/admin/users/${t.userId}`}> <Link href={`/admin/users/${t.userId}`}>
{names.get(t.userId) ?? `#${t.userId}`} {names.get(t.userId) ?? `#${t.userId}`}
@@ -100,11 +140,22 @@ export default async function AdminTransactions({
</td> </td>
<td>{formatAmount(t.amount, t.currency)}</td> <td>{formatAmount(t.amount, t.currency)}</td>
<td>{t.description ?? "—"}</td> <td>{t.description ?? "—"}</td>
<td>{t.status ?? "—"}</td> <td>
{t.status ? (
<span
className={`admin-badge ${state === "ok" ? "ok" : state === "danger" ? "danger" : ""}`}
>
{t.status}
</span>
) : (
<span className="muted">—</span>
)}
</td>
<td className="muted">{t.transactionId}</td> <td className="muted">{t.transactionId}</td>
<td className="muted">{fromDate(t.createdAt)}</td> <td className="muted">{fromDate(t.createdAt)}</td>
</tr> </tr>
))} );
})}
</tbody> </tbody>
</table> </table>
</div> </div>
+53 -8
View File
@@ -7,6 +7,7 @@ import {
setMotto, setMotto,
setRank, setRank,
} from "@/actions/admin-users"; } from "@/actions/admin-users";
import { InfoItem, StatusCard } from "@/components/admin/dashboard";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
@@ -26,17 +27,50 @@ export default async function AdminUserDetail({
.catch(() => null); .catch(() => null);
if (!user) notFound(); if (!user) notFound();
const isOnline = user.online === "1";
return ( return (
<main> <main>
<p className="muted"> <p className="muted">
<Link href="/admin/users">← Users</Link> <Link href="/admin/users">← Users</Link>
</p> </p>
<h1>{user.username}</h1> <h1>{user.username}</h1>
<p className="muted"> <p className="muted" style={{ marginTop: "-0.4rem" }}>
ID {user.id} · rank {user.rank} · {user.credits} credits ·{" "} {user.motto ? user.motto : "No motto set."}
{user.online === "1" ? "Online" : "Offline"}
</p> </p>
<div className="admin-stats">
<StatusCard label="User ID" value={user.id} icon="🆔" />
<StatusCard label="Rank" value={user.rank} icon="🎖️" />
<StatusCard label="Credits" value={user.credits} icon="💰" />
<StatusCard
label="Status"
value={isOnline ? "Online" : "Offline"}
state={isOnline ? "ok" : "neutral"}
icon={isOnline ? "🟢" : "⚪"}
/>
</div>
<section className="admin-section">
<h2>Overview</h2>
<div className="admin-info-grid">
<InfoItem label="Username" value={user.username} />
<InfoItem
label="Status"
value={
<span className={`admin-badge ${isOnline ? "ok" : ""}`}>
{isOnline ? "online" : "offline"}
</span>
}
/>
<InfoItem label="Rank" value={user.rank} />
<InfoItem label="Credits" value={user.credits} />
<InfoItem label="Look" value={<code>{user.look || "—"}</code>} />
</div>
</section>
<section className="admin-section">
<h2>Actions</h2>
<div className="grid cols-2"> <div className="grid cols-2">
<form action={giveCurrency} className="card"> <form action={giveCurrency} className="card">
<input type="hidden" name="userId" value={user.id} /> <input type="hidden" name="userId" value={user.id} />
@@ -48,37 +82,48 @@ export default async function AdminUserDetail({
<option value="points">Points</option> <option value="points">Points</option>
</select>{" "} </select>{" "}
<input name="amount" type="number" min={1} placeholder="Amount" />{" "} <input name="amount" type="number" min={1} placeholder="Amount" />{" "}
<button type="submit">Give</button> <button type="submit" className="btn btn-primary">
Give
</button>
</form> </form>
<form action={setMotto} className="card"> <form action={setMotto} className="card">
<input type="hidden" name="userId" value={user.id} /> <input type="hidden" name="userId" value={user.id} />
<h3 style={{ marginTop: 0 }}>Set motto</h3> <h3 style={{ marginTop: 0 }}>Set motto</h3>
<input name="motto" defaultValue={user.motto} maxLength={127} />{" "} <input name="motto" defaultValue={user.motto} maxLength={127} />{" "}
<button type="submit">Save</button> <button type="submit" className="btn btn-primary">
Save
</button>
</form> </form>
<form action={setRank} className="card"> <form action={setRank} className="card">
<input type="hidden" name="userId" value={user.id} /> <input type="hidden" name="userId" value={user.id} />
<h3 style={{ marginTop: 0 }}>Set rank</h3> <h3 style={{ marginTop: 0 }}>Set rank</h3>
<input name="rank" type="number" min={1} defaultValue={user.rank} />{" "} <input name="rank" type="number" min={1} defaultValue={user.rank} />{" "}
<button type="submit">Set</button> <button type="submit" className="btn btn-primary">
Set
</button>
</form> </form>
<form action={alertUser} className="card"> <form action={alertUser} className="card">
<input type="hidden" name="userId" value={user.id} /> <input type="hidden" name="userId" value={user.id} />
<h3 style={{ marginTop: 0 }}>Alert</h3> <h3 style={{ marginTop: 0 }}>Alert</h3>
<input name="message" placeholder="Message" />{" "} <input name="message" placeholder="Message" />{" "}
<button type="submit">Send</button> <button type="submit" className="btn btn-primary">
Send
</button>
</form> </form>
<form action={disconnectUser} className="card"> <form action={disconnectUser} className="card">
<input type="hidden" name="userId" value={user.id} /> <input type="hidden" name="userId" value={user.id} />
<input type="hidden" name="username" value={user.username} /> <input type="hidden" name="username" value={user.username} />
<h3 style={{ marginTop: 0 }}>Disconnect</h3> <h3 style={{ marginTop: 0 }}>Disconnect</h3>
<button type="submit">Disconnect from hotel</button> <button type="submit" className="btn btn-danger">
Disconnect from hotel
</button>
</form> </form>
</div> </div>
</section>
</main> </main>
); );
} }
+50 -9
View File
@@ -1,4 +1,5 @@
import Link from "next/link"; import Link from "next/link";
import { StatusCard } from "@/components/admin/dashboard";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
@@ -29,38 +30,78 @@ export default async function AdminUsers({
const total = await prisma.user.count({ where }).catch(() => 0); const total = await prisma.user.count({ where }).catch(() => 0);
const pages = Math.max(1, Math.ceil(total / PER_PAGE)); const pages = Math.max(1, Math.ceil(total / PER_PAGE));
const onlineOnPage = users.filter((u) => u.online === "1").length;
return ( return (
<main> <main>
<h1>Users</h1> <h1>Users</h1>
<form style={{ display: "flex", gap: "0.5rem", marginBottom: "1rem" }}> <p className="muted" style={{ marginTop: "-0.4rem" }}>
<input name="q" defaultValue={q} placeholder="Search username" /> {q ? `Search results for “${q}”.` : "Most recent accounts first."}
<button type="submit">Search</button> </p>
<div className="admin-stats">
<StatusCard label="Total users" value={total} icon="👥" />
<StatusCard label="On this page" value={users.length} icon="📄" />
<StatusCard
label="Online (page)"
value={onlineOnPage}
state={onlineOnPage > 0 ? "ok" : "neutral"}
icon="🟢"
/>
</div>
<form
className="card"
style={{ display: "flex", gap: "0.5rem", marginBottom: "1.5rem", flexWrap: "wrap" }}
>
<input
name="q"
defaultValue={q}
placeholder="Search username"
style={{ flex: 1, minWidth: 200 }}
/>
<button type="submit" className="btn btn-primary">
Search
</button>
</form> </form>
<table style={{ width: "100%", borderCollapse: "collapse" }}> <section className="admin-section">
<h2>Accounts ({total})</h2>
{users.length === 0 ? (
<p className="muted">No users match this search.</p>
) : (
<div style={{ overflowX: "auto" }}>
<table>
<thead> <thead>
<tr style={{ textAlign: "left", color: "var(--muted)" }}> <tr>
<th>ID</th> <th>ID</th>
<th>Username</th> <th>Username</th>
<th>Rank</th> <th>Rank</th>
<th>Credits</th> <th>Credits</th>
<th>Online</th> <th>Status</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{users.map((u) => ( {users.map((u) => (
<tr key={u.id} style={{ borderTop: "1px solid var(--border)" }}> <tr key={u.id}>
<td>{u.id}</td> <td className="muted">{u.id}</td>
<td> <td>
<Link href={`/admin/users/${u.id}`}>{u.username}</Link> <Link href={`/admin/users/${u.id}`}>{u.username}</Link>
</td> </td>
<td>{u.rank}</td> <td>{u.rank}</td>
<td>{u.credits}</td> <td>{u.credits}</td>
<td>{u.online === "1" ? "●" : "○"}</td> <td>
<span className={`admin-badge ${u.online === "1" ? "ok" : ""}`}>
{u.online === "1" ? "online" : "offline"}
</span>
</td>
</tr> </tr>
))} ))}
</tbody> </tbody>
</table> </table>
</div>
)}
</section>
<p className="muted"> <p className="muted">
Page {page} / {pages} · {total} users Page {page} / {pages} · {total} users
+49 -6
View File
@@ -1,4 +1,5 @@
import { createVoucher, deleteVoucher } from "@/actions/admin-vouchers"; import { createVoucher, deleteVoucher } from "@/actions/admin-vouchers";
import { StatusCard } from "@/components/admin/dashboard";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
@@ -40,14 +41,48 @@ export default async function AdminVouchers() {
vouchers = []; vouchers = [];
} }
const active = vouchers.filter((v) => {
const expired = v.expiresAt ? v.expiresAt.getTime() <= now : false;
const usedUp = v.maxUses > 0 && v.useCount >= v.maxUses;
return !expired && !usedUp;
}).length;
const expiredCount = vouchers.filter(
(v) => v.expiresAt && v.expiresAt.getTime() <= now,
).length;
const usedUpCount = vouchers.filter(
(v) => v.maxUses > 0 && v.useCount >= v.maxUses,
).length;
return ( return (
<main> <main>
<h1>Shop Vouchers</h1> <h1>Shop Vouchers</h1>
<p className="muted"> <p className="muted" style={{ marginTop: "-0.4rem" }}>
Codes redeemable on the website shop for a balance top-up. Each user can Codes redeemable on the website shop for a balance top-up. Each user can
use a given voucher once, up to its max uses. use a given voucher once, up to its max uses.
</p> </p>
<div className="admin-stats">
<StatusCard label="Vouchers" value={vouchers.length} icon="🎟️" />
<StatusCard
label="Active"
value={active}
state={active > 0 ? "ok" : "neutral"}
icon="✅"
/>
<StatusCard
label="Used up"
value={usedUpCount}
state={usedUpCount > 0 ? "warn" : "neutral"}
icon="📉"
/>
<StatusCard
label="Expired"
value={expiredCount}
state={expiredCount > 0 ? "danger" : "neutral"}
icon="⏰"
/>
</div>
<form action={createVoucher} className="card" style={{ marginBottom: "1.5rem" }}> <form action={createVoucher} className="card" style={{ marginBottom: "1.5rem" }}>
<h3 style={{ marginTop: 0 }}>New voucher</h3> <h3 style={{ marginTop: 0 }}>New voucher</h3>
<div className="grid cols-2" style={{ marginBottom: "0.75rem" }}> <div className="grid cols-2" style={{ marginBottom: "0.75rem" }}>
@@ -71,6 +106,8 @@ export default async function AdminVouchers() {
<button type="submit" className="btn btn-primary">Create voucher</button> <button type="submit" className="btn btn-primary">Create voucher</button>
</form> </form>
<section className="admin-section">
<h2>Vouchers ({vouchers.length})</h2>
<div className="card"> <div className="card">
<table> <table>
<thead> <thead>
@@ -78,6 +115,7 @@ export default async function AdminVouchers() {
<th>Code</th> <th>Code</th>
<th>Amount</th> <th>Amount</th>
<th>Uses</th> <th>Uses</th>
<th>Status</th>
<th>Expires</th> <th>Expires</th>
<th>Created</th> <th>Created</th>
<th /> <th />
@@ -91,16 +129,20 @@ export default async function AdminVouchers() {
<tr key={v.id.toString()}> <tr key={v.id.toString()}>
<td> <td>
<code>{v.code}</code> <code>{v.code}</code>
{expired ? (
<span className="muted"> · expired</span>
) : usedUp ? (
<span className="muted"> · used up</span>
) : null}
</td> </td>
<td>{v.amount}</td> <td>{v.amount}</td>
<td> <td>
{v.useCount} / {v.maxUses} {v.useCount} / {v.maxUses}
</td> </td>
<td>
{expired ? (
<span className="admin-badge danger">Expired</span>
) : usedUp ? (
<span className="admin-badge">Used up</span>
) : (
<span className="admin-badge ok">Active</span>
)}
</td>
<td className="muted">{fmtDate(v.expiresAt)}</td> <td className="muted">{fmtDate(v.expiresAt)}</td>
<td className="muted">{fmtDate(v.createdAt)}</td> <td className="muted">{fmtDate(v.createdAt)}</td>
<td> <td>
@@ -118,6 +160,7 @@ export default async function AdminVouchers() {
</table> </table>
{vouchers.length === 0 ? <p className="muted">No vouchers yet.</p> : null} {vouchers.length === 0 ? <p className="muted">No vouchers yet.</p> : null}
</div> </div>
</section>
</main> </main>
); );
} }
+43 -12
View File
@@ -1,4 +1,5 @@
import { addWord, deleteWord } from "@/actions/admin-wordfilter"; import { addWord, deleteWord } from "@/actions/admin-wordfilter";
import { StatusCard } from "@/components/admin/dashboard";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
@@ -20,19 +21,43 @@ export default async function AdminWordFilter() {
return ( return (
<main> <main>
<h1>Word Filter</h1> <h1>Word Filter</h1>
<p className="muted">Words added here are blocked in-game. Saving pushes the list to the emulator.</p> <p className="muted" style={{ marginTop: "-0.4rem" }}>
Words added here are blocked in-game. Saving pushes the list to the emulator.
</p>
<form action={addWord} className="card" style={{ marginBottom: "1.5rem" }}> <div className="admin-stats">
<h3 style={{ marginTop: 0 }}>Add word</h3> <StatusCard label="Filtered words" value={words.length} icon="🚫" />
</div>
<section className="admin-section">
<h2>Add word</h2>
<form action={addWord} className="card">
<div style={{ display: "flex", gap: "0.5rem", flexWrap: "wrap" }}> <div style={{ display: "flex", gap: "0.5rem", flexWrap: "wrap" }}>
<input name="word" placeholder="Word to filter" required maxLength={255} style={{ flex: 1, minWidth: 200 }} /> <input
<button type="submit" className="btn btn-primary">Add</button> name="word"
placeholder="Word to filter"
required
maxLength={255}
style={{ flex: 1, minWidth: 200 }}
/>
<button type="submit" className="btn btn-primary">
Add
</button>
</div> </div>
</form> </form>
</section>
<table style={{ width: "100%", borderCollapse: "collapse" }}> <section className="admin-section">
<h2>
Words <span className="admin-badge">{words.length}</span>
</h2>
{words.length === 0 ? (
<p className="muted">No filtered words.</p>
) : (
<div className="card" style={{ padding: 0, overflowX: "auto" }}>
<table>
<thead> <thead>
<tr style={{ textAlign: "left", color: "var(--muted)" }}> <tr>
<th>ID</th> <th>ID</th>
<th>Word</th> <th>Word</th>
<th /> <th />
@@ -40,20 +65,26 @@ export default async function AdminWordFilter() {
</thead> </thead>
<tbody> <tbody>
{words.map((w) => ( {words.map((w) => (
<tr key={String(w.id)} style={{ borderTop: "1px solid var(--border)" }}> <tr key={String(w.id)}>
<td>{String(w.id)}</td> <td className="muted">{String(w.id)}</td>
<td>{w.word}</td> <td>
<strong>{w.word}</strong>
</td>
<td> <td>
<form action={deleteWord}> <form action={deleteWord}>
<input type="hidden" name="id" value={String(w.id)} /> <input type="hidden" name="id" value={String(w.id)} />
<button type="submit" className="btn btn-danger">Delete</button> <button type="submit" className="btn btn-danger">
Delete
</button>
</form> </form>
</td> </td>
</tr> </tr>
))} ))}
</tbody> </tbody>
</table> </table>
{words.length === 0 ? <p className="muted">No filtered words.</p> : null} </div>
)}
</section>
</main> </main>
); );
} }