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
+2357 -1396

No files matched your search

+44 -30
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,47 +40,60 @@ 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}{" "}
<thead> <span className="admin-badge">{rows.length}</span>
<tr style={{ textAlign: "left", color: "var(--muted)" }}> </h2>
<th>Name</th> <div className="card" style={{ padding: 0, overflowX: "auto" }}>
<th>Level</th> <table>
<th>Progress needed</th> <thead>
<th>Reward type</th> <tr>
<th>Reward amount</th> <th>Name</th>
<th>Points</th> <th>Level</th>
</tr> <th>Progress needed</th>
</thead> <th>Reward type</th>
<tbody> <th>Reward amount</th>
{rows.map((a) => ( <th>Points</th>
<tr
key={`${a.name}-${a.level}`}
style={{ borderTop: "1px solid var(--border)" }}
>
<td>{a.name}</td>
<td>{a.level}</td>
<td>{a.progressNeeded}</td>
<td>{a.rewardType}</td>
<td>{a.rewardAmount}</td>
<td>{a.points ?? 0}</td>
</tr> </tr>
))} </thead>
</tbody> <tbody>
</table> {rows.map((a) => (
</div> <tr key={`${a.name}-${a.level}`}>
<td>
<strong>{a.name}</strong>
</td>
<td>{a.level}</td>
<td>{a.progressNeeded}</td>
<td>{a.rewardType}</td>
<td>{a.rewardAmount}</td>
<td>{a.points ?? 0}</td>
</tr>
))}
</tbody>
</table>
</div>
</section>
)) ))
)} )}
</main> </main>
+50 -32
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,47 +15,64 @@ 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>
<table> <div className="admin-stats">
<thead> <StatusCard label="Ads" value={ads.length} icon="🖼️" />
<tr> <StatusCard label="Latest" value={latest} hint="most recent ad" icon="🕒" />
<th>Preview</th> </div>
<th>Image URL</th>
<th>Created</th> <section className="admin-section">
<th /> <h2>All ads</h2>
</tr> {ads.length === 0 ? (
</thead> <p className="muted">No advertisements yet.</p>
<tbody> ) : (
{ads.map((ad) => ( <table>
<tr key={String(ad.id)}> <thead>
<td> <tr>
{/* eslint-disable-next-line @next/next/no-img-element */} <th>Preview</th>
<img src={ad.image} alt="" style={{ maxHeight: 40, maxWidth: 120 }} /> <th>Image URL</th>
</td> <th>Created</th>
<td className="muted"> <th />
<code>{ad.image}</code> </tr>
</td> </thead>
<td className="muted"> <tbody>
{ad.createdAt ? ad.createdAt.toISOString().slice(0, 10) : ""} {ads.map((ad) => (
</td> <tr key={String(ad.id)}>
<td> <td>
<Link href={`/admin/ads/${ad.id}`}>Edit</Link> {/* eslint-disable-next-line @next/next/no-img-element */}
</td> <img src={ad.image} alt="" style={{ maxHeight: 40, maxWidth: 120 }} />
</tr> </td>
))} <td className="muted">
</tbody> <code>{ad.image}</code>
</table> </td>
{ads.length === 0 ? <p className="muted">No advertisements yet.</p> : null} <td className="muted" style={{ whiteSpace: "nowrap" }}>
{ad.createdAt ? ad.createdAt.toISOString().slice(0, 10) : "—"}
</td>
<td>
<Link href={`/admin/ads/${ad.id}`}>Edit</Link>
</td>
</tr>
))}
</tbody>
</table>
)}
</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" }}>
+57 -41
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,47 +46,62 @@ 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>
{applications.length === 0 ? ( <div className="admin-stats">
<p className="muted">No pending applications.</p> <StatusCard
) : ( label="Pending applications"
<div className="grid cols-2"> value={applications.length}
{applications.map((a) => { state={applications.length > 0 ? "warn" : "ok"}
const username = userMap.get(a.userId); icon="📨"
return ( />
<article key={String(a.id)} className="card"> </div>
<div
style={{ <section className="admin-section">
display: "flex", <h2>Applications ({applications.length})</h2>
justifyContent: "space-between", {applications.length === 0 ? (
alignItems: "baseline", <p className="muted">No pending applications.</p>
gap: "0.5rem", ) : (
}} <div className="grid cols-2">
> {applications.map((a) => {
<h3 style={{ margin: "0 0 0.25rem" }}> const username = userMap.get(a.userId);
{username ? ( return (
<Link href={`/admin/users/${a.userId}`}>{username}</Link> <article key={String(a.id)} className="card">
) : ( <div
<>User #{a.userId}</> style={{
)} display: "flex",
</h3> justifyContent: "space-between",
<span className="muted">Rank {a.rankId}</span> alignItems: "baseline",
</div> gap: "0.5rem",
<p className="muted" style={{ marginTop: 0 }}> }}
{formatDate(a.createdAt)} >
</p> <h3 style={{ margin: "0 0 0.25rem" }}>
<p style={{ whiteSpace: "pre-wrap", margin: "0 0 0.75rem" }}>{a.content}</p> {username ? (
<form action={dismissApplication}> <Link href={`/admin/users/${a.userId}`}>{username}</Link>
<input type="hidden" name="id" value={String(a.id)} /> ) : (
<button type="submit" className="btn btn-danger"> <>User #{a.userId}</>
Dismiss )}
</button> </h3>
</form> <span className="admin-badge">Rank {a.rankId}</span>
</article> </div>
); <p className="muted" style={{ marginTop: 0 }}>
})} {formatDate(a.createdAt)}
</div> </p>
)} <p style={{ whiteSpace: "pre-wrap", margin: "0 0 0.75rem" }}>{a.content}</p>
<form action={dismissApplication}>
<input type="hidden" name="id" value={String(a.id)} />
<button type="submit" className="btn btn-danger">
Dismiss
</button>
</form>
</article>
);
})}
</div>
)}
</section>
</main> </main>
); );
} }
+48 -21
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,32 +15,58 @@ 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" }}>
<thead> News articles (<code>website_articles</code>) shown on the public site. 50 most recent.
<tr style={{ textAlign: "left", color: "var(--muted)" }}> </p>
<th>Title</th>
<th>Slug</th> <div className="admin-stats">
<th>Created</th> <StatusCard label="Articles shown" value={articles.length} icon="📰" />
</tr> <StatusCard label="Latest" value={latest} hint="most recent post" icon="🕒" />
</thead> </div>
<tbody>
{articles.map((a) => ( <section className="admin-section">
<tr key={String(a.id)} style={{ borderTop: "1px solid var(--border)" }}> <h2>Recent articles</h2>
<td> {articles.length === 0 ? (
<Link href={`/admin/articles/${a.id}`}>{a.title}</Link> <p className="muted">No articles yet.</p>
</td> ) : (
<td className="muted">{a.slug}</td> <table>
<td className="muted">{a.createdAt ? a.createdAt.toISOString().slice(0, 10) : ""}</td> <thead>
</tr> <tr>
))} <th>Title</th>
</tbody> <th>Slug</th>
</table> <th>Created</th>
</tr>
</thead>
<tbody>
{articles.map((a) => (
<tr key={String(a.id)}>
<td>
<Link href={`/admin/articles/${a.id}`}>{a.title}</Link>
</td>
<td className="muted">
<code>{a.slug}</code>
</td>
<td className="muted" style={{ whiteSpace: "nowrap" }}>
{a.createdAt ? a.createdAt.toISOString().slice(0, 10) : "—"}
</td>
</tr>
))}
</tbody>
</table>
)}
</section>
</main> </main>
); );
} }
+79 -29
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,38 +60,60 @@ 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">
<thead> <h2>Active bans ({bans.length})</h2>
<tr style={{ textAlign: "left", color: "var(--muted)" }}> {bans.length === 0 ? (
<th>User ID</th> <p className="muted">No active bans.</p>
<th>Type</th> ) : (
<th>Reason</th> <div style={{ overflowX: "auto" }}>
<th>Expires</th> <table>
<th /> <thead>
</tr> <tr>
</thead> <th>User ID</th>
<tbody> <th>Type</th>
{bans.map((b) => ( <th>Reason</th>
<tr key={b.id} style={{ borderTop: "1px solid var(--border)" }}> <th>Expires</th>
<td>{b.userId}</td> <th />
<td>{b.type}</td> </tr>
<td>{b.banReason}</td> </thead>
<td className="muted">{new Date(b.banExpire * 1000).toISOString().slice(0, 16).replace("T", " ")}</td> <tbody>
<td> {bans.map((b) => (
<form action={liftBan}> <tr key={b.id}>
<input type="hidden" name="id" value={b.id} /> <td>{b.userId}</td>
<button type="submit">Lift</button> <td>
</form> <span className="admin-badge">{b.type}</span>
</td> </td>
</tr> <td>{b.banReason || "—"}</td>
))} <td>
</tbody> {b.banExpire === 0 ? (
</table> <span className="admin-badge danger">permanent</span>
{bans.length === 0 ? <p className="muted">No active bans.</p> : null} ) : (
<span className="muted" style={{ whiteSpace: "nowrap" }}>
{fromUnix(b.banExpire)}
</span>
)}
</td>
<td>
<form action={liftBan}>
<input type="hidden" name="id" value={b.id} />
<button type="submit" className="btn btn-outline">
Lift
</button>
</form>
</td>
</tr>
))}
</tbody>
</table>
</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>
+74 -48
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,58 +40,83 @@ 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.
{pages.length === 0 ? (
<p className="muted">No catalog pages found.</p>
) : (
<table style={{ width: "100%", borderCollapse: "collapse" }}>
<thead>
<tr style={{ textAlign: "left", color: "var(--muted)" }}>
<th>ID</th>
<th>Caption</th>
<th>Parent</th>
<th>Layout</th>
<th>Min rank</th>
<th>Order</th>
<th>Status</th>
</tr>
</thead>
<tbody>
{pages.map((p) => {
const parentName =
p.parentId > 0 ? captionById.get(p.parentId) : null;
const hidden = p.visible !== "1" || p.enabled !== "1";
return (
<tr key={p.id} style={{ borderTop: "1px solid var(--border)" }}>
<td>{p.id}</td>
<td>
<Link href={`/admin/catalog/${p.id}`}>{p.caption}</Link>
</td>
<td className="muted">
{p.parentId <= 0
? "—"
: parentName
? `${parentName} (#${p.parentId})`
: `#${p.parentId}`}
</td>
<td className="muted">{p.pageLayout}</td>
<td>{p.minRank}</td>
<td>{p.orderNum}</td>
<td className="muted">{hidden ? "Hidden" : "Visible"}</td>
</tr>
);
})}
</tbody>
</table>
)}
<p className="muted" style={{ marginTop: "1rem" }}>
{pages.length} page{pages.length === 1 ? "" : "s"}
</p> </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 ? (
<p className="muted">No catalog pages found.</p>
) : (
<div style={{ overflowX: "auto" }}>
<table>
<thead>
<tr>
<th>ID</th>
<th>Caption</th>
<th>Parent</th>
<th>Layout</th>
<th>Min rank</th>
<th>Order</th>
<th>Status</th>
</tr>
</thead>
<tbody>
{pages.map((p) => {
const parentName =
p.parentId > 0 ? captionById.get(p.parentId) : null;
const hidden = p.visible !== "1" || p.enabled !== "1";
return (
<tr key={p.id}>
<td className="muted">{p.id}</td>
<td>
<Link href={`/admin/catalog/${p.id}`}>{p.caption}</Link>
</td>
<td className="muted">
{p.parentId <= 0
? "—"
: parentName
? `${parentName} (#${p.parentId})`
: `#${p.parentId}`}
</td>
<td className="muted">{p.pageLayout}</td>
<td>{p.minRank}</td>
<td>{p.orderNum}</td>
<td>
<span className={`admin-badge ${hidden ? "danger" : "ok"}`}>
{hidden ? "Hidden" : "Visible"}
</span>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
</section>
</main> </main>
); );
} }
+18 -6
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,11 +109,13 @@ 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>
{textsCount} keys{textsTruncated ? ` (showing first ${texts.length})` : ""}. <p className="muted" style={{ marginTop: "-0.4rem" }}>
</p> {textsCount} keys{textsTruncated ? ` (showing first ${texts.length})` : ""}.
</p>
<form <form
action={updateEmulatorText} action={updateEmulatorText}
@@ -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>
); );
} }
+51 -29
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,44 +17,65 @@ 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>
<table> <div className="admin-stats">
<thead> <StatusCard label="Entries" value={entries.length} icon="❓" />
<tr> <StatusCard label="With button" value={withButton} icon="🔘" />
<th>Pos.</th> <StatusCard label="Small boxes" value={smallBoxes} icon="🗂️" />
<th>Name</th> </div>
<th>Button</th>
<th>Small box</th> <section className="admin-section">
<th /> <h2>Entries</h2>
</tr> {entries.length === 0 ? (
</thead> <p className="muted">No help-center entries yet.</p>
<tbody> ) : (
{entries.map((e) => ( <table>
<tr key={String(e.id)}> <thead>
<td className="muted">{e.position}</td> <tr>
<td> <th>Pos.</th>
<Link href={`/admin/help-questions/${e.id}`}>{e.name}</Link> <th>Name</th>
</td> <th>Button</th>
<td className="muted">{e.buttonText || "—"}</td> <th>Small box</th>
<td className="muted">{e.smallBox ? "Yes" : "No"}</td> <th />
<td> </tr>
<Link href={`/admin/help-questions/${e.id}`}>Edit</Link> </thead>
</td> <tbody>
</tr> {entries.map((e) => (
))} <tr key={String(e.id)}>
</tbody> <td className="muted">{e.position}</td>
</table> <td>
{entries.length === 0 ? <p className="muted">No help-center entries yet.</p> : null} <Link href={`/admin/help-questions/${e.id}`}>{e.name}</Link>
</td>
<td className="muted">{e.buttonText || "—"}</td>
<td>
<span className={`admin-badge ${e.smallBox ? "ok" : ""}`}>
{e.smallBox ? "Yes" : "No"}
</span>
</td>
<td>
<Link href={`/admin/help-questions/${e.id}`}>Edit</Link>
</td>
</tr>
))}
</tbody>
</table>
)}
</section>
</main> </main>
); );
} }
+78 -51
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,65 +16,91 @@ 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="🔑" />
<div style={{ display: "flex", gap: "0.5rem", flexWrap: "wrap" }}> <StatusCard label="Highest min rank" value={maxRank} icon="⬆️" />
<input name="permission" placeholder="permission key" required style={{ minWidth: 200 }} /> </div>
<input name="minRank" type="number" min={0} placeholder="Min rank" required style={{ width: 110 }} />
<input name="description" placeholder="description" style={{ flex: 1, minWidth: 160 }} />
<button type="submit" className="btn btn-primary">Save</button>
</div>
</form>
<table> <section className="admin-section">
<thead> <h2>Add / overwrite permission</h2>
<tr> <form action={upsertPermission} className="card">
<th>Permission</th> <div style={{ display: "flex", gap: "0.5rem", flexWrap: "wrap" }}>
<th>Description</th> <input name="permission" placeholder="permission key" required style={{ minWidth: 200 }} />
<th style={{ width: 200 }}>Min rank</th> <input name="minRank" type="number" min={0} placeholder="Min rank" required style={{ width: 110 }} />
<th style={{ width: 80 }} /> <input name="description" placeholder="description" style={{ flex: 1, minWidth: 160 }} />
</tr> <button type="submit" className="btn btn-primary">
</thead> Save
<tbody> </button>
{permissions.map((p) => ( </div>
<tr key={String(p.id)}> </form>
<td><strong>{p.permission}</strong></td> </section>
<td className="muted">{p.description ?? "—"}</td>
<td> <section className="admin-section">
<form action={upsertPermission} style={{ display: "flex", gap: "0.5rem" }}> <h2>
<input type="hidden" name="permission" value={p.permission} /> Permissions <span className="admin-badge">{permissions.length}</span>
<input type="hidden" name="description" value={p.description ?? ""} /> </h2>
<input {permissions.length === 0 ? (
name="minRank" <p className="muted">No housekeeping permissions defined yet.</p>
type="number" ) : (
min={0} <div className="card" style={{ padding: 0, overflowX: "auto" }}>
defaultValue={p.minRank} <table>
style={{ width: 90 }} <thead>
/> <tr>
<button type="submit" className="btn btn-outline">Save</button> <th>Permission</th>
</form> <th>Description</th>
</td> <th style={{ width: 200 }}>Min rank</th>
<td> <th style={{ width: 80 }} />
<form action={deletePermission}> </tr>
<input type="hidden" name="id" value={String(p.id)} /> </thead>
<button type="submit" className="btn btn-danger">✕</button> <tbody>
</form> {permissions.map((p) => (
</td> <tr key={String(p.id)}>
</tr> <td>
))} <strong>{p.permission}</strong>
</tbody> </td>
</table> <td className="muted">{p.description ?? "—"}</td>
{permissions.length === 0 ? ( <td>
<p className="muted">No housekeeping permissions defined yet.</p> <form action={upsertPermission} style={{ display: "flex", gap: "0.5rem" }}>
) : null} <input type="hidden" name="permission" value={p.permission} />
<input type="hidden" name="description" value={p.description ?? ""} />
<input
name="minRank"
type="number"
min={0}
defaultValue={p.minRank}
style={{ width: 90 }}
/>
<button type="submit" className="btn btn-outline">
Save
</button>
</form>
</td>
<td>
<form action={deletePermission}>
<input type="hidden" name="id" value={String(p.id)} />
<button type="submit" className="btn btn-danger">
✕
</button>
</form>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</section>
</main> </main>
); );
} }
+90 -61
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,38 +84,43 @@ export default async function AdminIp() {
</div> </div>
</form> </form>
<table> {whitelist.length === 0 ? (
<thead> <p className="muted">No whitelisted IPs.</p>
<tr> ) : (
<th>IP</th> <div style={{ overflowX: "auto" }}>
<th>ASN</th> <table>
<th>Added</th> <thead>
<th /> <tr>
</tr> <th>IP</th>
</thead> <th>ASN</th>
<tbody> <th>Added</th>
{whitelist.map((w) => ( <th />
<tr key={String(w.id)}> </tr>
<td>{w.ipAddress}</td> </thead>
<td className="muted">{w.asn ?? "—"}</td> <tbody>
<td className="muted">{formatDate(w.createdAt)}</td> {whitelist.map((w) => (
<td> <tr key={String(w.id)}>
<form action={deleteWhitelist}> <td>{w.ipAddress}</td>
<input type="hidden" name="id" value={String(w.id)} /> <td className="muted">{w.asn ?? "—"}</td>
<button type="submit" className="btn btn-danger"> <td className="muted">{formatDate(w.createdAt)}</td>
Remove <td>
</button> <form action={deleteWhitelist}>
</form> <input type="hidden" name="id" value={String(w.id)} />
</td> <button type="submit" className="btn btn-danger">
</tr> Remove
))} </button>
</tbody> </form>
</table> </td>
{whitelist.length === 0 ? <p className="muted">No whitelisted IPs.</p> : null} </tr>
))}
</tbody>
</table>
</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,34 +138,39 @@ export default async function AdminIp() {
</div> </div>
</form> </form>
<table> {blacklist.length === 0 ? (
<thead> <p className="muted">No blacklisted IPs.</p>
<tr> ) : (
<th>IP</th> <div style={{ overflowX: "auto" }}>
<th>ASN</th> <table>
<th>Added</th> <thead>
<th /> <tr>
</tr> <th>IP</th>
</thead> <th>ASN</th>
<tbody> <th>Added</th>
{blacklist.map((b) => ( <th />
<tr key={String(b.id)}> </tr>
<td>{b.ipAddress}</td> </thead>
<td className="muted">{b.asn ?? "—"}</td> <tbody>
<td className="muted">{formatDate(b.createdAt)}</td> {blacklist.map((b) => (
<td> <tr key={String(b.id)}>
<form action={deleteBlacklist}> <td>{b.ipAddress}</td>
<input type="hidden" name="id" value={String(b.id)} /> <td className="muted">{b.asn ?? "—"}</td>
<button type="submit" className="btn btn-danger"> <td className="muted">{formatDate(b.createdAt)}</td>
Remove <td>
</button> <form action={deleteBlacklist}>
</form> <input type="hidden" name="id" value={String(b.id)} />
</td> <button type="submit" className="btn btn-danger">
</tr> Remove
))} </button>
</tbody> </form>
</table> </td>
{blacklist.length === 0 ? <p className="muted">No blacklisted IPs.</p> : null} </tr>
))}
</tbody>
</table>
</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>
+101 -91
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,111 +35,120 @@ 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", />
}} <StatusCard
> label="Min login rank"
<span value={minRankValue}
aria-hidden="true" hint="During maintenance"
style={{ icon="🛡️"
width: 12, />
height: 12, <StatusCard
borderRadius: 999, label="Splash message"
flexShrink: 0, value={message ? "Set" : "Empty"}
background: enabled state={message ? "ok" : "neutral"}
? "var(--color-danger)" icon="💬"
: "var(--color-secondary)",
}}
/> />
<div>
<strong>
Maintenance is currently {enabled ? "ENABLED" : "disabled"}.
</strong>
<p className="muted" style={{ margin: "0.2rem 0 0" }}>
{enabled
? `Only users with rank ${minRankValue} or higher can log in right now.`
: "The hotel is open to everyone."}
</p>
</div>
</div> </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>
<strong>
Maintenance is currently {enabled ? "ENABLED" : "disabled"}.
</strong>
<p className="muted" style={{ margin: "0.2rem 0 0" }}>
{enabled
? `Only users with rank ${minRankValue} or higher can log in right now.`
: "The hotel is open to everyone."}
</p>
</div>
</div>
</section>
{/* ── Edit form ─────────────────────────────────────────── */} {/* ── Edit form ─────────────────────────────────────────── */}
<form action={saveMaintenance} className="card"> <section className="admin-section">
<h3 style={{ marginTop: 0 }}>Settings</h3> <h2>Settings</h2>
<form action={saveMaintenance} className="card">
<label
htmlFor="mt-enabled"
style={{
display: "flex",
alignItems: "center",
gap: "0.5rem",
marginBottom: "1rem",
}}
>
<input
id="mt-enabled"
type="checkbox"
name="enabled"
value="1"
defaultChecked={enabled}
style={{ width: "auto" }}
/>
<span>Enable maintenance mode</span>
</label>
<div style={{ marginBottom: "1rem", maxWidth: 220 }}>
<label <label
htmlFor="mt-min-rank" htmlFor="mt-enabled"
className="muted" style={{
style={{ display: "block", marginBottom: "0.3rem" }} display: "flex",
alignItems: "center",
gap: "0.5rem",
marginBottom: "1rem",
}}
> >
Minimum login rank during maintenance <input
id="mt-enabled"
type="checkbox"
name="enabled"
value="1"
defaultChecked={enabled}
style={{ width: "auto" }}
/>
<span>Enable maintenance mode</span>
</label> </label>
<input
id="mt-min-rank"
type="number"
name="min_rank"
min={0}
step={1}
defaultValue={minRankValue}
style={{ width: "100%" }}
/>
</div>
<div style={{ marginBottom: "1rem" }}> <div style={{ marginBottom: "1rem", maxWidth: 220 }}>
<label <label
htmlFor="mt-message" htmlFor="mt-min-rank"
className="muted" className="muted"
style={{ display: "block", marginBottom: "0.3rem" }} style={{ display: "block", marginBottom: "0.3rem" }}
> >
Maintenance message (shown on the splash; basic HTML allowed) Minimum login rank during maintenance
</label> </label>
<textarea <input
id="mt-message" id="mt-min-rank"
name="message" type="number"
rows={8} name="min_rank"
defaultValue={message} min={0}
placeholder="We're working hard on the hotel — back soon!" step={1}
style={{ width: "100%", resize: "vertical" }} defaultValue={minRankValue}
/> style={{ width: "100%" }}
</div> />
</div>
<button type="submit" className="btn btn-primary"> <div style={{ marginBottom: "1rem" }}>
Save changes <label
</button> htmlFor="mt-message"
</form> className="muted"
style={{ display: "block", marginBottom: "0.3rem" }}
>
Maintenance message (shown on the splash; basic HTML allowed)
</label>
<textarea
id="mt-message"
name="message"
rows={8}
defaultValue={message}
placeholder="We're working hard on the hotel — back soon!"
style={{ width: "100%", resize: "vertical" }}
/>
</div>
<button type="submit" className="btn btn-primary">
Save changes
</button>
</form>
</section>
</main> </main>
); );
} }
+112 -80
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,97 +97,121 @@ 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>
{/* ── navigator_flatcats ───────────────────────────────── */} <div className="admin-stats">
<div className="card" style={{ padding: 0, overflowX: "auto", marginBottom: "1.5rem" }}> <StatusCard label="Flat categories" value={flatcats.length} icon="📂" />
<div style={{ padding: "1.1rem 1.25rem 0" }}> <StatusCard
<h3 style={{ margin: 0 }}>Flat categories</h3> label="Public (flatcats)"
<p className="muted" style={{ margin: "0.25rem 0 0" }}> value={publicFlatcats}
<code>navigator_flatcats</code> · {flatcats.length} rows hint="marked public"
</p> icon="🌐"
</div> />
<table> <StatusCard label="Public rooms" value={publics.length} icon="🚪" />
<thead> <StatusCard
<tr> label="Visible rooms"
<th>ID</th> value={visiblePublics}
<th>Caption</th> state={visiblePublics > 0 ? "ok" : "neutral"}
<th>Caption (save)</th> icon="👁️"
<th>Min rank</th> />
<th>Max users</th>
<th>Can trade</th>
<th>Public</th>
<th>List type</th>
<th>Order</th>
</tr>
</thead>
<tbody>
{flatcats.map((c) => (
<tr key={c.id}>
<td>{c.id}</td>
<td>{c.caption || "—"}</td>
<td>{c.captionSave || "—"}</td>
<td>{c.minRank}</td>
<td>{c.maxUserCount}</td>
<td>{c.canTrade === "1" ? "Yes" : "No"}</td>
<td>{c.public === "1" ? "Yes" : "No"}</td>
<td>{c.listType}</td>
<td>{c.orderNum}</td>
</tr>
))}
</tbody>
</table>
{flatcats.length === 0 ? (
<p className="muted" style={{ padding: "0 1.25rem 1.1rem" }}>
No flat categories found.
</p>
) : null}
</div> </div>
{/* ── navigator_flatcats ───────────────────────────────── */}
<section className="admin-section">
<h2>Flat categories</h2>
<p className="muted" style={{ marginTop: "-0.4rem" }}>
<code>navigator_flatcats</code> · {flatcats.length} rows
</p>
{flatcats.length === 0 ? (
<p className="muted">No flat categories found.</p>
) : (
<div style={{ overflowX: "auto" }}>
<table>
<thead>
<tr>
<th>ID</th>
<th>Caption</th>
<th>Caption (save)</th>
<th>Min rank</th>
<th>Max users</th>
<th>Can trade</th>
<th>Public</th>
<th>List type</th>
<th>Order</th>
</tr>
</thead>
<tbody>
{flatcats.map((c) => (
<tr key={c.id}>
<td className="muted">{c.id}</td>
<td>{c.caption || "—"}</td>
<td className="muted">{c.captionSave || "—"}</td>
<td>{c.minRank}</td>
<td>{c.maxUserCount}</td>
<td>
<YesNo on={c.canTrade === "1"} />
</td>
<td>
<YesNo on={c.public === "1"} />
</td>
<td>{c.listType}</td>
<td>{c.orderNum}</td>
</tr>
))}
</tbody>
</table>
</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>
<table>
<thead>
<tr>
<th>Public cat</th>
<th>Room</th>
<th>Visible</th>
</tr>
</thead>
<tbody>
{publics.map((p, i) => (
<tr key={`${p.public_cat_id}-${p.room_id}-${i}`}>
<td>
{catNameById.get(p.public_cat_id) ?? "—"}{" "}
<span className="muted">#{p.public_cat_id}</span>
</td>
<td>
{roomNameById.get(p.room_id) || "(unnamed)"}{" "}
<span className="muted">#{p.room_id}</span>
</td>
<td>{p.visible === "1" ? "Yes" : "No"}</td>
</tr>
))}
</tbody>
</table>
{publics.length === 0 ? ( {publics.length === 0 ? (
<p className="muted" style={{ padding: "0 1.25rem 1.1rem" }}> <p className="muted">No public-room assignments found.</p>
No public-room assignments found. ) : (
</p> <div style={{ overflowX: "auto" }}>
) : null} <table>
</div> <thead>
<tr>
<th>Public cat</th>
<th>Room</th>
<th>Visible</th>
</tr>
</thead>
<tbody>
{publics.map((p, i) => (
<tr key={`${p.public_cat_id}-${p.room_id}-${i}`}>
<td>
{catNameById.get(p.public_cat_id) ?? "—"}{" "}
<span className="muted">#{p.public_cat_id}</span>
</td>
<td>
{roomNameById.get(p.room_id) || "(unnamed)"}{" "}
<span className="muted">#{p.room_id}</span>
</td>
<td>
<YesNo on={p.visible === "1"} />
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</section>
</main> </main>
); );
} }
+79 -66
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,71 +68,76 @@ export default async function AdminPermissions() {
</div> </div>
</form> </form>
{permissions.length === 0 ? ( <section className="admin-section">
<p className="muted">No permissions found.</p> <h2>Permissions ({permissions.length})</h2>
) : ( {permissions.length === 0 ? (
<table style={{ width: "100%", borderCollapse: "collapse" }}> <p className="muted">No permissions found.</p>
<thead> ) : (
<tr style={{ textAlign: "left", color: "var(--muted)" }}> <div style={{ overflowX: "auto" }}>
<th>ID</th> <table>
<th>Permission</th> <thead>
<th>Min rank</th> <tr>
<th>Description</th> <th>ID</th>
<th /> <th>Permission</th>
</tr> <th>Min rank</th>
</thead> <th>Description</th>
<tbody> <th />
{permissions.map((p) => ( </tr>
<tr key={String(p.id)} style={{ borderTop: "1px solid var(--border)" }}> </thead>
<td>{String(p.id)}</td> <tbody>
<td> {permissions.map((p) => (
<form <tr key={String(p.id)}>
id={`perm-${String(p.id)}`} <td className="muted">{String(p.id)}</td>
action={updatePermission} <td>
style={{ display: "contents" }} <form
> id={`perm-${String(p.id)}`}
<input type="hidden" name="id" value={String(p.id)} /> action={updatePermission}
<input name="permission" defaultValue={p.permission} required maxLength={255} /> style={{ display: "contents" }}
</form> >
</td> <input type="hidden" name="id" value={String(p.id)} />
<td> <input name="permission" defaultValue={p.permission} required maxLength={255} />
<input </form>
form={`perm-${String(p.id)}`} </td>
name="minRank" <td>
type="number" <input
min={0} form={`perm-${String(p.id)}`}
defaultValue={p.minRank} name="minRank"
style={{ width: 90 }} type="number"
/> min={0}
</td> defaultValue={p.minRank}
<td> style={{ width: 90 }}
<input />
form={`perm-${String(p.id)}`} </td>
name="description" <td>
defaultValue={p.description ?? ""} <input
style={{ width: "100%" }} form={`perm-${String(p.id)}`}
/> name="description"
</td> defaultValue={p.description ?? ""}
<td style={{ display: "flex", gap: "0.5rem" }}> style={{ width: "100%" }}
<button />
type="submit" </td>
form={`perm-${String(p.id)}`} <td style={{ display: "flex", gap: "0.5rem" }}>
className="btn btn-primary" <button
> type="submit"
Save form={`perm-${String(p.id)}`}
</button> className="btn btn-primary"
<form action={deletePermission}> >
<input type="hidden" name="id" value={String(p.id)} /> Save
<button type="submit" className="btn btn-danger"> </button>
Delete <form action={deletePermission}>
</button> <input type="hidden" name="id" value={String(p.id)} />
</form> <button type="submit" className="btn btn-danger">
</td> Delete
</tr> </button>
))} </form>
</tbody> </td>
</table> </tr>
)} ))}
</tbody>
</table>
</div>
)}
</section>
</main> </main>
); );
} }
+43 -27
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,41 +15,56 @@ 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="grid cols-3"> <div className="admin-stats">
{photos.map((p) => ( <StatusCard label="Photos shown" value={photos.length} icon="📸" />
<div key={String(p.id)} className="card" style={{ padding: 0, overflow: "hidden" }}> <StatusCard label="Rooms" value={uniqueRooms} hint="distinct rooms" icon="🚪" />
{/* eslint-disable-next-line @next/next/no-img-element */} <StatusCard label="Users" value={uniqueUsers} hint="distinct photographers" icon="👤" />
<img
src={p.url}
alt={`Photo ${String(p.id)}`}
style={{ width: "100%", height: 200, objectFit: "cover", display: "block", background: "#f1f5f9" }}
/>
<div style={{ padding: "0.85rem 1.05rem 1.05rem" }}>
<div style={{ display: "flex", justifyContent: "space-between", gap: "0.5rem", alignItems: "baseline" }}>
<strong>#{String(p.id)}</strong>
<span className="muted">Room {p.roomId}</span>
</div>
<p className="muted" style={{ margin: "0.3rem 0 0" }}>
User {p.userId} ·{" "}
{new Date(p.timestamp * 1000).toISOString().slice(0, 16).replace("T", " ")}
</p>
<form action={deletePhoto} style={{ marginTop: "0.75rem" }}>
<input type="hidden" name="id" value={String(p.id)} />
<button type="submit" className="btn btn-danger">Delete</button>
</form>
</div>
</div>
))}
</div> </div>
{photos.length === 0 ? <p className="muted">No photos found.</p> : null}
<section className="admin-section">
<h2>Recent photos</h2>
{photos.length === 0 ? (
<p className="muted">No photos found.</p>
) : (
<div className="grid cols-3">
{photos.map((p) => (
<div key={String(p.id)} className="card" style={{ padding: 0, overflow: "hidden" }}>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={p.url}
alt={`Photo ${String(p.id)}`}
style={{ width: "100%", height: 200, objectFit: "cover", display: "block", background: "#f1f5f9" }}
/>
<div style={{ padding: "0.85rem 1.05rem 1.05rem" }}>
<div style={{ display: "flex", justifyContent: "space-between", gap: "0.5rem", alignItems: "baseline" }}>
<strong>#{String(p.id)}</strong>
<span className="muted">Room {p.roomId}</span>
</div>
<p className="muted" style={{ margin: "0.3rem 0 0" }}>
User {p.userId} ·{" "}
{new Date(p.timestamp * 1000).toISOString().slice(0, 16).replace("T", " ")}
</p>
<form action={deletePhoto} style={{ marginTop: "0.75rem" }}>
<input type="hidden" name="id" value={String(p.id)} />
<button type="submit" className="btn btn-danger">Delete</button>
</form>
</div>
</div>
))}
</div>
)}
</section>
</main> </main>
); );
} }
+212 -181
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,188 +71,214 @@ export default async function AdminRadioBannersPage() {
Could not load banners (database unavailable). Could not load banners (database unavailable).
</p> </p>
</div> </div>
) : null}
<form action={createRadioBanner} className="card" style={{ marginBottom: '1.5rem' }}>
<h3 style={{ marginTop: 0 }}>Add banner</h3>
<div className="grid cols-2">
<div>
<label htmlFor="new_imagePath" style={{ display: 'block', fontWeight: 700 }}>
Image path / URL
</label>
<input
id="new_imagePath"
name="imagePath"
placeholder="/storage/radio/banner.png"
required
style={{ width: '100%' }}
/>
</div>
<div>
<label htmlFor="new_title" style={{ display: 'block', fontWeight: 700 }}>
Title
</label>
<input id="new_title" name="title" style={{ width: '100%' }} />
</div>
<div style={{ gridColumn: '1 / -1' }}>
<label htmlFor="new_description" style={{ display: 'block', fontWeight: 700 }}>
Description
</label>
<textarea
id="new_description"
name="description"
rows={2}
style={{ width: '100%' }}
/>
</div>
<div>
<label htmlFor="new_sortOrder" style={{ display: 'block', fontWeight: 700 }}>
Sort order
</label>
<input
id="new_sortOrder"
name="sortOrder"
type="number"
defaultValue={0}
style={{ width: '100%' }}
/>
</div>
<div style={{ display: 'flex', alignItems: 'flex-end' }}>
<label style={{ display: 'inline-flex', gap: '0.4rem', alignItems: 'center' }}>
<input type="checkbox" name="isActive" value="1" defaultChecked /> Active
</label>
</div>
</div>
<div style={{ marginTop: '0.75rem' }}>
<button type="submit" className="btn btn-primary">
Create banner
</button>
</div>
</form>
{banners.length === 0 ? (
<p className="muted">No banners yet.</p>
) : ( ) : (
<div className="grid"> <div className="admin-stats">
{banners.map((b) => ( <StatusCard label="Banners" value={banners.length} icon="🖼️" />
<div key={String(b.id)} className="card"> <StatusCard
<div label="Active"
style={{ value={activeCount}
display: 'flex', state={activeCount > 0 ? 'ok' : 'neutral'}
justifyContent: 'space-between', icon="✅"
alignItems: 'baseline', />
gap: '0.75rem', <StatusCard
}} label="Hidden"
> value={hiddenCount}
<strong>#{String(b.id)}</strong> state={hiddenCount > 0 ? 'warn' : 'neutral'}
<span className="muted"> icon="🚫"
by user {String(b.userId)} · {formatDate(b.createdAt)} ·{' '} />
{b.isActive ? 'active' : 'hidden'}
</span>
</div>
{b.imagePath ? (
<img
src={b.imagePath}
alt={b.title ?? 'Radio banner'}
className="article-img"
style={{ margin: '0.5rem 0' }}
/>
) : null}
<form action={updateRadioBanner}>
<input type="hidden" name="id" value={String(b.id)} />
<div className="grid cols-2">
<div>
<label
htmlFor={`img_${b.id}`}
style={{ display: 'block', fontWeight: 700 }}
>
Image path / URL
</label>
<input
id={`img_${b.id}`}
name="imagePath"
defaultValue={b.imagePath}
required
style={{ width: '100%' }}
/>
</div>
<div>
<label
htmlFor={`title_${b.id}`}
style={{ display: 'block', fontWeight: 700 }}
>
Title
</label>
<input
id={`title_${b.id}`}
name="title"
defaultValue={b.title ?? ''}
style={{ width: '100%' }}
/>
</div>
<div style={{ gridColumn: '1 / -1' }}>
<label
htmlFor={`desc_${b.id}`}
style={{ display: 'block', fontWeight: 700 }}
>
Description
</label>
<textarea
id={`desc_${b.id}`}
name="description"
rows={2}
defaultValue={b.description ?? ''}
style={{ width: '100%' }}
/>
</div>
<div>
<label
htmlFor={`sort_${b.id}`}
style={{ display: 'block', fontWeight: 700 }}
>
Sort order
</label>
<input
id={`sort_${b.id}`}
name="sortOrder"
type="number"
defaultValue={b.sortOrder}
style={{ width: '100%' }}
/>
</div>
<div style={{ display: 'flex', alignItems: 'flex-end' }}>
<label
style={{ display: 'inline-flex', gap: '0.4rem', alignItems: 'center' }}
>
<input
type="checkbox"
name="isActive"
value="1"
defaultChecked={b.isActive}
/>{' '}
Active
</label>
</div>
</div>
<div style={{ marginTop: '0.6rem' }}>
<button type="submit" className="btn btn-primary">
Save
</button>
</div>
</form>
<form action={deleteRadioBanner} style={{ marginTop: '0.5rem' }}>
<input type="hidden" name="id" value={String(b.id)} />
<button type="submit" className="btn btn-danger">
Delete
</button>
</form>
</div>
))}
</div> </div>
)} )}
<section className="admin-section">
<h2>Add banner</h2>
<form action={createRadioBanner} className="card">
<div className="grid cols-2">
<div>
<label htmlFor="new_imagePath" style={{ display: 'block', fontWeight: 700 }}>
Image path / URL
</label>
<input
id="new_imagePath"
name="imagePath"
placeholder="/storage/radio/banner.png"
required
style={{ width: '100%' }}
/>
</div>
<div>
<label htmlFor="new_title" style={{ display: 'block', fontWeight: 700 }}>
Title
</label>
<input id="new_title" name="title" style={{ width: '100%' }} />
</div>
<div style={{ gridColumn: '1 / -1' }}>
<label htmlFor="new_description" style={{ display: 'block', fontWeight: 700 }}>
Description
</label>
<textarea
id="new_description"
name="description"
rows={2}
style={{ width: '100%' }}
/>
</div>
<div>
<label htmlFor="new_sortOrder" style={{ display: 'block', fontWeight: 700 }}>
Sort order
</label>
<input
id="new_sortOrder"
name="sortOrder"
type="number"
defaultValue={0}
style={{ width: '100%' }}
/>
</div>
<div style={{ display: 'flex', alignItems: 'flex-end' }}>
<label style={{ display: 'inline-flex', gap: '0.4rem', alignItems: 'center' }}>
<input type="checkbox" name="isActive" value="1" defaultChecked /> Active
</label>
</div>
</div>
<div style={{ marginTop: '0.75rem' }}>
<button type="submit" className="btn btn-primary">
Create banner
</button>
</div>
</form>
</section>
<section className="admin-section">
<h2>Existing banners ({banners.length})</h2>
{banners.length === 0 ? (
<p className="muted">No banners yet — add one above.</p>
) : (
<div className="grid">
{banners.map((b) => (
<div key={String(b.id)} className="card">
<div
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'baseline',
gap: '0.75rem',
}}
>
<strong>#{String(b.id)}</strong>
<span
className="muted"
style={{ display: 'inline-flex', gap: '0.5rem', alignItems: 'center' }}
>
by user {String(b.userId)} · {formatDate(b.createdAt)} ·{' '}
<span className={`admin-badge ${b.isActive ? 'ok' : 'danger'}`}>
{b.isActive ? 'active' : 'hidden'}
</span>
</span>
</div>
{b.imagePath ? (
<img
src={b.imagePath}
alt={b.title ?? 'Radio banner'}
className="article-img"
style={{ margin: '0.5rem 0' }}
/>
) : null}
<form action={updateRadioBanner}>
<input type="hidden" name="id" value={String(b.id)} />
<div className="grid cols-2">
<div>
<label
htmlFor={`img_${b.id}`}
style={{ display: 'block', fontWeight: 700 }}
>
Image path / URL
</label>
<input
id={`img_${b.id}`}
name="imagePath"
defaultValue={b.imagePath}
required
style={{ width: '100%' }}
/>
</div>
<div>
<label
htmlFor={`title_${b.id}`}
style={{ display: 'block', fontWeight: 700 }}
>
Title
</label>
<input
id={`title_${b.id}`}
name="title"
defaultValue={b.title ?? ''}
style={{ width: '100%' }}
/>
</div>
<div style={{ gridColumn: '1 / -1' }}>
<label
htmlFor={`desc_${b.id}`}
style={{ display: 'block', fontWeight: 700 }}
>
Description
</label>
<textarea
id={`desc_${b.id}`}
name="description"
rows={2}
defaultValue={b.description ?? ''}
style={{ width: '100%' }}
/>
</div>
<div>
<label
htmlFor={`sort_${b.id}`}
style={{ display: 'block', fontWeight: 700 }}
>
Sort order
</label>
<input
id={`sort_${b.id}`}
name="sortOrder"
type="number"
defaultValue={b.sortOrder}
style={{ width: '100%' }}
/>
</div>
<div style={{ display: 'flex', alignItems: 'flex-end' }}>
<label
style={{ display: 'inline-flex', gap: '0.4rem', alignItems: 'center' }}
>
<input
type="checkbox"
name="isActive"
value="1"
defaultChecked={b.isActive}
/>{' '}
Active
</label>
</div>
</div>
<div style={{ marginTop: '0.6rem' }}>
<button type="submit" className="btn btn-primary">
Save
</button>
</div>
</form>
<form action={deleteRadioBanner} style={{ marginTop: '0.5rem' }}>
<input type="hidden" name="id" value={String(b.id)} />
<button type="submit" className="btn btn-danger">
Delete
</button>
</form>
</div>
))}
</div>
)}
</section>
</main> </main>
); );
} }
+63 -32
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,40 +94,63 @@ 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>
<th>ID</th> <th>ID</th>
<th>DJ</th> <th>DJ</th>
<th>Show</th> <th>Show</th>
<th>Started</th> <th>Started</th>
<th>Ended</th> <th>Ended</th>
<th>Airtime</th> <th>Airtime</th>
<th>Listeners</th> <th>Listeners</th>
<th>Notes</th> <th>Notes</th>
</tr>
</thead>
<tbody>
{history.map((h) => (
<tr key={String(h.id)}>
<td>{String(h.id)}</td>
<td>{names.get(String(h.userId)) ?? `#${String(h.userId)}`}</td>
<td>{h.showName ?? '—'}</td>
<td className="muted">{formatDate(h.startedAt)}</td>
<td className="muted">{formatDate(h.endedAt)}</td>
<td className="muted">{formatDuration(h.startedAt, h.endedAt)}</td>
<td>{h.listenersCount}</td>
<td className="muted">{h.notes ?? '—'}</td>
</tr> </tr>
))} </thead>
</tbody> <tbody>
</table> {history.map((h) => (
)} <tr key={String(h.id)}>
<td>{String(h.id)}</td>
<td>{names.get(String(h.userId)) ?? `#${String(h.userId)}`}</td>
<td>{h.showName ?? '—'}</td>
<td className="muted">{formatDate(h.startedAt)}</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>{h.listenersCount}</td>
<td className="muted">{h.notes ?? '—'}</td>
</tr>
))}
</tbody>
</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>
+133 -117
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,123 +60,133 @@ export default async function AdminRadioRanksPage() {
Could not load ranks (database unavailable). Could not load ranks (database unavailable).
</p> </p>
</div> </div>
) : null}
<form action={createRadioRank} className="card" style={{ marginBottom: '1.5rem' }}>
<h3 style={{ marginTop: 0 }}>Add rank</h3>
<div className="grid cols-3">
<div>
<label htmlFor="new_name" style={{ display: 'block', fontWeight: 700 }}>
Name
</label>
<input id="new_name" name="name" required style={{ width: '100%' }} />
</div>
<div>
<label htmlFor="new_badge" style={{ display: 'block', fontWeight: 700 }}>
Badge code
</label>
<input id="new_badge" name="badgeCode" style={{ width: '100%' }} />
</div>
<div style={{ display: 'flex', alignItems: 'flex-end' }}>
<label style={{ display: 'inline-flex', gap: '0.4rem', alignItems: 'center' }}>
<input type="checkbox" name="isActive" value="1" defaultChecked /> Active
</label>
</div>
<div style={{ gridColumn: '1 / -1' }}>
<label htmlFor="new_desc" style={{ display: 'block', fontWeight: 700 }}>
Description
</label>
<input id="new_desc" name="description" style={{ width: '100%' }} />
</div>
</div>
<div style={{ marginTop: '0.75rem' }}>
<button type="submit" className="btn btn-primary">
Create rank
</button>
</div>
</form>
{ranks.length === 0 ? (
<p className="muted">No ranks yet.</p>
) : ( ) : (
<table> <div className="admin-stats">
<thead> <StatusCard label="Ranks" value={ranks.length} icon="🎖️" />
<tr> <StatusCard
<th>ID</th> label="Active"
<th>Name</th> value={activeCount}
<th>Badge</th> state={activeCount > 0 ? 'ok' : 'neutral'}
<th>Description</th> icon="✅"
<th>Active</th> />
<th /> </div>
</tr>
</thead>
<tbody>
{ranks.map((r) => (
<tr key={String(r.id)}>
<td>{String(r.id)}</td>
<td colSpan={5}>
<form
action={updateRadioRank}
style={{
display: 'flex',
gap: '0.5rem',
alignItems: 'center',
flexWrap: 'wrap',
}}
>
<input type="hidden" name="id" value={String(r.id)} />
<input
name="name"
defaultValue={r.name}
required
placeholder="Name"
style={{ minWidth: 140 }}
/>
<input
name="badgeCode"
defaultValue={r.badgeCode ?? ''}
placeholder="Badge"
style={{ minWidth: 100 }}
/>
<input
name="description"
defaultValue={r.description ?? ''}
placeholder="Description"
style={{ flex: 1, minWidth: 160 }}
/>
<label
style={{ display: 'inline-flex', gap: '0.3rem', alignItems: 'center' }}
>
<input
type="checkbox"
name="isActive"
value="1"
defaultChecked={r.isActive}
/>{' '}
Active
</label>
<button type="submit" className="btn btn-primary">
Save
</button>
</form>
</td>
</tr>
))}
{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)} />
<button type="submit" className="btn btn-danger">
Delete rank #{String(r.id)}
</button>
</form>
</td>
</tr>
))}
</tbody>
</table>
)} )}
<section className="admin-section">
<h2>Add rank</h2>
<form action={createRadioRank} className="card">
<div className="grid cols-3">
<div>
<label htmlFor="new_name" style={{ display: 'block', fontWeight: 700 }}>
Name
</label>
<input id="new_name" name="name" required style={{ width: '100%' }} />
</div>
<div>
<label htmlFor="new_badge" style={{ display: 'block', fontWeight: 700 }}>
Badge code
</label>
<input id="new_badge" name="badgeCode" style={{ width: '100%' }} />
</div>
<div style={{ display: 'flex', alignItems: 'flex-end' }}>
<label style={{ display: 'inline-flex', gap: '0.4rem', alignItems: 'center' }}>
<input type="checkbox" name="isActive" value="1" defaultChecked /> Active
</label>
</div>
<div style={{ gridColumn: '1 / -1' }}>
<label htmlFor="new_desc" style={{ display: 'block', fontWeight: 700 }}>
Description
</label>
<input id="new_desc" name="description" style={{ width: '100%' }} />
</div>
</div>
<div style={{ marginTop: '0.75rem' }}>
<button type="submit" className="btn btn-primary">
Create rank
</button>
</div>
</form>
</section>
<section className="admin-section">
<h2>Existing ranks ({ranks.length})</h2>
{ranks.length === 0 ? (
<p className="muted">No ranks yet — add one above.</p>
) : (
<div className="grid">
{ranks.map((r) => (
<div key={String(r.id)} className="card">
<div
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
action={updateRadioRank}
style={{
display: 'flex',
gap: '0.5rem',
alignItems: 'center',
flexWrap: 'wrap',
}}
>
<input type="hidden" name="id" value={String(r.id)} />
<input
name="name"
defaultValue={r.name}
required
placeholder="Name"
style={{ minWidth: 140 }}
/>
<input
name="badgeCode"
defaultValue={r.badgeCode ?? ''}
placeholder="Badge"
style={{ minWidth: 100 }}
/>
<input
name="description"
defaultValue={r.description ?? ''}
placeholder="Description"
style={{ flex: 1, minWidth: 160 }}
/>
<label
style={{ display: 'inline-flex', gap: '0.3rem', alignItems: 'center' }}
>
<input
type="checkbox"
name="isActive"
value="1"
defaultChecked={r.isActive}
/>{' '}
Active
</label>
<button type="submit" className="btn btn-primary">
Save
</button>
</form>
<form action={deleteRadioRank} style={{ marginTop: '0.5rem' }}>
<input type="hidden" name="id" value={String(r.id)} />
<button type="submit" className="btn btn-danger">
Delete rank #{String(r.id)}
</button>
</form>
</div>
))}
</div>
)}
</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>
))} ))}
+61 -37
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,50 +12,73 @@ 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="🗄️" />
<div style={{ display: "flex", gap: "0.5rem", flexWrap: "wrap" }}> <StatusCard label="With comment" value={documented} icon="📝" />
<input name="key" placeholder="key" required />
<input name="value" placeholder="value" style={{ flex: 1, minWidth: 160 }} />
<input name="comment" placeholder="comment" />
<button type="submit">Save</button>
</div>
</form>
<div className="grid">
{settings.map((s) => (
<div key={s.key} className="card">
<div style={{ display: "flex", gap: "0.75rem", alignItems: "baseline" }}>
<strong style={{ minWidth: 220 }}>{s.key}</strong>
<form
action={updateSetting}
style={{ display: "flex", gap: "0.5rem", flex: 1 }}
>
<input type="hidden" name="key" value={s.key} />
<input name="value" defaultValue={s.value} style={{ flex: 1 }} />
<button type="submit">Save</button>
</form>
<form action={deleteSetting}>
<input type="hidden" name="key" value={s.key} />
<button type="submit">✕</button>
</form>
</div>
{s.comment ? (
<p className="muted" style={{ margin: "0.4rem 0 0" }}>
{s.comment}
</p>
) : null}
</div>
))}
</div> </div>
{settings.length === 0 ? <p className="muted">No settings yet.</p> : null}
<section className="admin-section">
<h2>Add / overwrite</h2>
<form action={createSetting} className="card">
<div style={{ display: "flex", gap: "0.5rem", flexWrap: "wrap" }}>
<input name="key" placeholder="key" required />
<input name="value" placeholder="value" style={{ flex: 1, minWidth: 160 }} />
<input name="comment" placeholder="comment" />
<button type="submit" className="btn btn-primary">
Save
</button>
</div>
</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">
{settings.map((s) => (
<div key={s.key} className="card">
<div style={{ display: "flex", gap: "0.75rem", alignItems: "baseline" }}>
<strong style={{ minWidth: 220 }}>{s.key}</strong>
<form
action={updateSetting}
style={{ display: "flex", gap: "0.5rem", flex: 1 }}
>
<input type="hidden" name="key" value={s.key} />
<input name="value" defaultValue={s.value} style={{ flex: 1 }} />
<button type="submit" className="btn btn-outline">
Save
</button>
</form>
<form action={deleteSetting}>
<input type="hidden" name="key" value={s.key} />
<button type="submit" className="btn btn-danger">
✕
</button>
</form>
</div>
{s.comment ? (
<p className="muted" style={{ margin: "0.4rem 0 0" }}>
{s.comment}
</p>
) : null}
</div>
))}
</div>
)}
</section>
</main> </main>
); );
} }
+123 -66
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,82 +36,127 @@ 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">
<thead> <StatusCard label="Packages" value={packages.length} icon="🛍️" />
<tr style={{ textAlign: "left", color: "var(--muted)" }}> <StatusCard label="Recent orders" value={orders.length} icon="🧾" />
<th>#</th> <StatusCard
<th>Name</th> label="Paid orders"
<th>Costs</th> value={paidOrders}
<th>Credits</th> state={paidOrders > 0 ? "ok" : "neutral"}
<th>Duckets</th> icon="💳"
<th>Diamonds</th> />
<th>Rank</th> </div>
<th>Position</th>
</tr>
</thead>
<tbody>
{packages.map((p) => (
<tr key={String(p.id)} style={{ borderTop: "1px solid var(--border)" }}>
<td className="muted">{String(p.id)}</td>
<td>
<Link href={`/admin/shop/${p.id}`}>{p.name}</Link>
</td>
<td>{p.costs}</td>
<td className="muted">{p.credits ?? ""}</td>
<td className="muted">{p.duckets ?? ""}</td>
<td className="muted">{p.diamonds ?? ""}</td>
<td className="muted">{p.giveRank ?? ""}</td>
<td className="muted">{p.position}</td>
</tr>
))}
</tbody>
</table>
{packages.length === 0 ? <p className="muted">No shop packages yet.</p> : null}
<h2 style={{ marginTop: "2rem" }}>Recent orders</h2> <section className="admin-section">
<p className="muted"> <h2>Packages ({packages.length})</h2>
Read-only PayPal transaction log (<code>website_paypal_transactions</code>). {packages.length === 0 ? (
</p> <p className="muted">No shop packages yet.</p>
<table style={{ width: "100%", borderCollapse: "collapse" }}> ) : (
<thead> <div style={{ overflowX: "auto" }}>
<tr style={{ textAlign: "left", color: "var(--muted)" }}> <table>
<th>Transaction</th> <thead>
<th>User</th> <tr>
<th>Description</th> <th>#</th>
<th>Amount</th> <th>Name</th>
<th>Status</th> <th>Costs</th>
<th>Date</th> <th>Credits</th>
</tr> <th>Duckets</th>
</thead> <th>Diamonds</th>
<tbody> <th>Rank</th>
{orders.map((o) => ( <th>Position</th>
<tr key={String(o.id)} style={{ borderTop: "1px solid var(--border)" }}> </tr>
<td> </thead>
<code>{o.transactionId}</code> <tbody>
</td> {packages.map((p) => (
<td className="muted">{o.userId}</td> <tr key={String(p.id)}>
<td className="muted">{o.description ?? ""}</td> <td className="muted">{String(p.id)}</td>
<td> <td>
{o.amount} {o.currency} <Link href={`/admin/shop/${p.id}`}>{p.name}</Link>
</td> </td>
<td className="muted">{o.status ?? ""}</td> <td>{p.costs}</td>
<td className="muted">{o.createdAt ? o.createdAt.toISOString().slice(0, 10) : ""}</td> <td className="muted">{p.credits ?? "—"}</td>
</tr> <td className="muted">{p.duckets ?? "—"}</td>
))} <td className="muted">{p.diamonds ?? "—"}</td>
</tbody> <td className="muted">{p.giveRank ?? "—"}</td>
</table> <td className="muted">{p.position}</td>
{orders.length === 0 ? <p className="muted">No orders recorded.</p> : null} </tr>
))}
</tbody>
</table>
</div>
)}
</section>
<section className="admin-section">
<h2>Recent orders ({orders.length})</h2>
<p className="muted" style={{ marginTop: "-0.4rem" }}>
Read-only PayPal transaction log (<code>website_paypal_transactions</code>).
</p>
{orders.length === 0 ? (
<p className="muted">No orders recorded.</p>
) : (
<div style={{ overflowX: "auto" }}>
<table>
<thead>
<tr>
<th>Transaction</th>
<th>User</th>
<th>Description</th>
<th>Amount</th>
<th>Status</th>
<th>Date</th>
</tr>
</thead>
<tbody>
{orders.map((o) => {
const state = statusState(o.status);
return (
<tr key={String(o.id)}>
<td>
<code>{o.transactionId}</code>
</td>
<td className="muted">{o.userId}</td>
<td className="muted">{o.description ?? "—"}</td>
<td>
{o.amount} {o.currency}
</td>
<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>
);
})}
</tbody>
</table>
</div>
)}
</section>
</main> </main>
); );
} }
+52 -23
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,36 +40,64 @@ 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="card"> <div className="admin-stats">
<table> <StatusCard label="Active subs" value={active.length} icon="⭐" />
<thead> <StatusCard label="Types" value={types} icon="🏷️" />
<tr> <StatusCard
<th>Sub ID</th> label="Expiring ≤ 7d"
<th>User ID</th> value={expiringSoon}
<th>Type</th> state={expiringSoon > 0 ? "warn" : "ok"}
<th>Expires</th> icon="⏳"
</tr> />
</thead>
<tbody>
{active.map((s) => (
<tr key={s.id}>
<td>{s.id}</td>
<td>{s.userId ?? "—"}</td>
<td>{s.subscriptionType ?? "—"}</td>
<td className="muted">{fmt(s.expiresAt)}</td>
</tr>
))}
</tbody>
</table>
{active.length === 0 ? <p className="muted">No active subscriptions.</p> : null}
</div> </div>
<section className="admin-section">
<h2>Active subscriptions ({active.length})</h2>
<div className="card">
<table>
<thead>
<tr>
<th>Sub ID</th>
<th>User ID</th>
<th>Type</th>
<th>Status</th>
<th>Expires</th>
</tr>
</thead>
<tbody>
{active.map((s) => {
const soon = s.expiresAt <= soonCutoff;
return (
<tr key={s.id}>
<td className="muted">{s.id}</td>
<td>{s.userId ?? "—"}</td>
<td>{s.subscriptionType ?? "—"}</td>
<td>
<span className={`admin-badge ${soon ? "" : "ok"}`}>
{soon ? "Expiring" : "Active"}
</span>
</td>
<td className="muted">{fmt(s.expiresAt)}</td>
</tr>
);
})}
</tbody>
</table>
{active.length === 0 ? <p className="muted">No active subscriptions.</p> : null}
</div>
</section>
</main> </main>
); );
} }
+104 -72
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,77 +114,88 @@ export default async function AdminTagsPage() {
</div> </div>
</form> </form>
{tags.length === 0 ? ( <section className="admin-section">
<p className="muted">No tags yet.</p> <h2>All tags</h2>
) : ( {tags.length === 0 ? (
<table> <p className="muted">No tags yet.</p>
<thead> ) : (
<tr> <table>
<th>ID</th> <thead>
<th>Tag</th> <tr>
<th>Colour</th> <th>ID</th>
<th>Used by</th> <th>Tag</th>
<th>Created</th> <th>Colour</th>
<th /> <th>Used by</th>
</tr> <th>Created</th>
</thead> <th />
<tbody>
{tags.map((t) => (
<tr key={String(t.id)}>
<td>{String(t.id)}</td>
<td>
<span
style={{
display: 'inline-block',
padding: '0.1rem 0.5rem',
borderRadius: '0.4rem',
background: t.backgroundColor,
color: '#fff',
}}
>
{t.name}
</span>
</td>
<td className="muted">
<code>{t.backgroundColor}</code>
</td>
<td className="muted">{usage.get(String(t.id)) ?? 0}</td>
<td className="muted">{formatDate(t.createdAt)}</td>
<td>
<form
action={updateTag}
style={{ display: 'flex', gap: '0.4rem', alignItems: 'center' }}
>
<input type="hidden" name="id" value={String(t.id)} />
<input
name="name"
defaultValue={t.name}
required
maxLength={255}
style={{ width: 140 }}
/>
<input
name="backgroundColor"
defaultValue={t.backgroundColor}
maxLength={10}
style={{ width: 90 }}
/>
<button type="submit" className="btn btn-primary">
Save
</button>
</form>
<form action={deleteTag} style={{ marginTop: '0.4rem' }}>
<input type="hidden" name="id" value={String(t.id)} />
<button type="submit" className="btn btn-danger">
Delete
</button>
</form>
</td>
</tr> </tr>
))} </thead>
</tbody> <tbody>
</table> {tags.map((t) => (
)} <tr key={String(t.id)}>
<td className="muted">{String(t.id)}</td>
<td>
<span
style={{
display: 'inline-block',
padding: '0.1rem 0.5rem',
borderRadius: '0.4rem',
background: t.backgroundColor,
color: '#fff',
}}
>
{t.name}
</span>
</td>
<td className="muted">
<code>{t.backgroundColor}</code>
</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>
<form
action={updateTag}
style={{ display: 'flex', gap: '0.4rem', alignItems: 'center' }}
>
<input type="hidden" name="id" value={String(t.id)} />
<input
name="name"
defaultValue={t.name}
required
maxLength={255}
style={{ width: 140 }}
/>
<input
name="backgroundColor"
defaultValue={t.backgroundColor}
maxLength={10}
style={{ width: 90 }}
/>
<button type="submit" className="btn btn-primary">
Save
</button>
</form>
<form action={deleteTag} style={{ marginTop: '0.4rem' }}>
<input type="hidden" name="id" value={String(t.id)} />
<button type="submit" className="btn btn-danger">
Delete
</button>
</form>
</td>
</tr>
))}
</tbody>
</table>
)}
</section>
</main> </main>
); );
} }
+84 -58
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,63 +84,72 @@ export default async function AdminTeams() {
</form> </form>
</div> </div>
{teams.length === 0 ? ( <section className="admin-section">
<p className="muted">No teams yet.</p> <h2>Ranks ({teams.length})</h2>
) : ( {teams.length === 0 ? (
<table> <p className="muted">No teams yet.</p>
<thead> ) : (
<tr> <div style={{ overflowX: "auto" }}>
<th>ID</th> <table>
<th>Rank name</th> <thead>
<th>Badge</th> <tr>
<th>Job description</th> <th>ID</th>
<th>Color</th> <th>Rank name</th>
<th>Hidden</th> <th>Badge</th>
<th></th> <th>Job description</th>
</tr> <th>Color</th>
</thead> <th>Visibility</th>
<tbody> <th />
{teams.map((t) => ( </tr>
<tr key={String(t.id)}> </thead>
<td>{String(t.id)}</td> <tbody>
<td>{t.rankName}</td> {teams.map((t) => (
<td className="muted">{t.badge ?? "—"}</td> <tr key={String(t.id)}>
<td className="muted">{t.jobDescription ?? "—"}</td> <td className="muted">{String(t.id)}</td>
<td> <td>{t.rankName}</td>
<span <td className="muted">{t.badge ?? "—"}</td>
style={{ <td className="muted">{t.jobDescription ?? "—"}</td>
display: "inline-flex", <td>
alignItems: "center", <span
gap: "0.4rem", style={{
}} display: "inline-flex",
> alignItems: "center",
<span gap: "0.4rem",
aria-hidden }}
style={{ >
width: 14, <span
height: 14, aria-hidden
borderRadius: 4, style={{
border: "1px solid var(--border-color)", width: 14,
background: t.staffColor, height: 14,
}} borderRadius: 4,
/> border: "1px solid var(--border-color)",
{t.staffColor} background: t.staffColor,
</span> }}
</td> />
<td className="muted">{t.hiddenRank ? "Yes" : "No"}</td> {t.staffColor}
<td> </span>
<form action={deleteTeam}> </td>
<input type="hidden" name="id" value={String(t.id)} /> <td>
<button type="submit" className="btn btn-danger"> <span className={`admin-badge ${t.hiddenRank ? "" : "ok"}`}>
Delete {t.hiddenRank ? "hidden" : "visible"}
</button> </span>
</form> </td>
</td> <td>
</tr> <form action={deleteTeam}>
))} <input type="hidden" name="id" value={String(t.id)} />
</tbody> <button type="submit" className="btn btn-danger">
</table> Delete
)} </button>
</form>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</section>
</main> </main>
); );
} }
+69 -18
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,21 +128,34 @@ 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 (
<td> <tr key={String(t.id)}>
<Link href={`/admin/users/${t.userId}`}> <td className="muted">{String(t.id)}</td>
{names.get(t.userId) ?? `#${t.userId}`} <td>
</Link> <Link href={`/admin/users/${t.userId}`}>
</td> {names.get(t.userId) ?? `#${t.userId}`}
<td>{formatAmount(t.amount, t.currency)}</td> </Link>
<td>{t.description ?? "—"}</td> </td>
<td>{t.status ?? "—"}</td> <td>{formatAmount(t.amount, t.currency)}</td>
<td className="muted">{t.transactionId}</td> <td>{t.description ?? "—"}</td>
<td className="muted">{fromDate(t.createdAt)}</td> <td>
</tr> {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">{fromDate(t.createdAt)}</td>
</tr>
);
})}
</tbody> </tbody>
</table> </table>
</div> </div>
+89 -44
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,59 +27,103 @@ 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="grid cols-2"> <div className="admin-stats">
<form action={giveCurrency} className="card"> <StatusCard label="User ID" value={user.id} icon="🆔" />
<input type="hidden" name="userId" value={user.id} /> <StatusCard label="Rank" value={user.rank} icon="🎖️" />
<h3 style={{ marginTop: 0 }}>Give currency</h3> <StatusCard label="Credits" value={user.credits} icon="💰" />
<select name="type"> <StatusCard
<option value="credits">Credits</option> label="Status"
<option value="duckets">Duckets</option> value={isOnline ? "Online" : "Offline"}
<option value="diamonds">Diamonds</option> state={isOnline ? "ok" : "neutral"}
<option value="points">Points</option> icon={isOnline ? "🟢" : "⚪"}
</select>{" "} />
<input name="amount" type="number" min={1} placeholder="Amount" />{" "}
<button type="submit">Give</button>
</form>
<form action={setMotto} className="card">
<input type="hidden" name="userId" value={user.id} />
<h3 style={{ marginTop: 0 }}>Set motto</h3>
<input name="motto" defaultValue={user.motto} maxLength={127} />{" "}
<button type="submit">Save</button>
</form>
<form action={setRank} className="card">
<input type="hidden" name="userId" value={user.id} />
<h3 style={{ marginTop: 0 }}>Set rank</h3>
<input name="rank" type="number" min={1} defaultValue={user.rank} />{" "}
<button type="submit">Set</button>
</form>
<form action={alertUser} className="card">
<input type="hidden" name="userId" value={user.id} />
<h3 style={{ marginTop: 0 }}>Alert</h3>
<input name="message" placeholder="Message" />{" "}
<button type="submit">Send</button>
</form>
<form action={disconnectUser} className="card">
<input type="hidden" name="userId" value={user.id} />
<input type="hidden" name="username" value={user.username} />
<h3 style={{ marginTop: 0 }}>Disconnect</h3>
<button type="submit">Disconnect from hotel</button>
</form>
</div> </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">
<form action={giveCurrency} className="card">
<input type="hidden" name="userId" value={user.id} />
<h3 style={{ marginTop: 0 }}>Give currency</h3>
<select name="type">
<option value="credits">Credits</option>
<option value="duckets">Duckets</option>
<option value="diamonds">Diamonds</option>
<option value="points">Points</option>
</select>{" "}
<input name="amount" type="number" min={1} placeholder="Amount" />{" "}
<button type="submit" className="btn btn-primary">
Give
</button>
</form>
<form action={setMotto} className="card">
<input type="hidden" name="userId" value={user.id} />
<h3 style={{ marginTop: 0 }}>Set motto</h3>
<input name="motto" defaultValue={user.motto} maxLength={127} />{" "}
<button type="submit" className="btn btn-primary">
Save
</button>
</form>
<form action={setRank} className="card">
<input type="hidden" name="userId" value={user.id} />
<h3 style={{ marginTop: 0 }}>Set rank</h3>
<input name="rank" type="number" min={1} defaultValue={user.rank} />{" "}
<button type="submit" className="btn btn-primary">
Set
</button>
</form>
<form action={alertUser} className="card">
<input type="hidden" name="userId" value={user.id} />
<h3 style={{ marginTop: 0 }}>Alert</h3>
<input name="message" placeholder="Message" />{" "}
<button type="submit" className="btn btn-primary">
Send
</button>
</form>
<form action={disconnectUser} className="card">
<input type="hidden" name="userId" value={user.id} />
<input type="hidden" name="username" value={user.username} />
<h3 style={{ marginTop: 0 }}>Disconnect</h3>
<button type="submit" className="btn btn-danger">
Disconnect from hotel
</button>
</form>
</div>
</section>
</main> </main>
); );
} }
+68 -27
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">
<thead> <h2>Accounts ({total})</h2>
<tr style={{ textAlign: "left", color: "var(--muted)" }}> {users.length === 0 ? (
<th>ID</th> <p className="muted">No users match this search.</p>
<th>Username</th> ) : (
<th>Rank</th> <div style={{ overflowX: "auto" }}>
<th>Credits</th> <table>
<th>Online</th> <thead>
</tr> <tr>
</thead> <th>ID</th>
<tbody> <th>Username</th>
{users.map((u) => ( <th>Rank</th>
<tr key={u.id} style={{ borderTop: "1px solid var(--border)" }}> <th>Credits</th>
<td>{u.id}</td> <th>Status</th>
<td> </tr>
<Link href={`/admin/users/${u.id}`}>{u.username}</Link> </thead>
</td> <tbody>
<td>{u.rank}</td> {users.map((u) => (
<td>{u.credits}</td> <tr key={u.id}>
<td>{u.online === "1" ? "●" : "○"}</td> <td className="muted">{u.id}</td>
</tr> <td>
))} <Link href={`/admin/users/${u.id}`}>{u.username}</Link>
</tbody> </td>
</table> <td>{u.rank}</td>
<td>{u.credits}</td>
<td>
<span className={`admin-badge ${u.online === "1" ? "ok" : ""}`}>
{u.online === "1" ? "online" : "offline"}
</span>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</section>
<p className="muted"> <p className="muted">
Page {page} / {pages} · {total} users Page {page} / {pages} · {total} users
+91 -48
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,53 +106,61 @@ 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>
<div className="card"> <section className="admin-section">
<table> <h2>Vouchers ({vouchers.length})</h2>
<thead> <div className="card">
<tr> <table>
<th>Code</th> <thead>
<th>Amount</th> <tr>
<th>Uses</th> <th>Code</th>
<th>Expires</th> <th>Amount</th>
<th>Created</th> <th>Uses</th>
<th /> <th>Status</th>
</tr> <th>Expires</th>
</thead> <th>Created</th>
<tbody> <th />
{vouchers.map((v) => { </tr>
const expired = v.expiresAt ? v.expiresAt.getTime() <= now : false; </thead>
const usedUp = v.maxUses > 0 && v.useCount >= v.maxUses; <tbody>
return ( {vouchers.map((v) => {
<tr key={v.id.toString()}> const expired = v.expiresAt ? v.expiresAt.getTime() <= now : false;
<td> const usedUp = v.maxUses > 0 && v.useCount >= v.maxUses;
<code>{v.code}</code> return (
{expired ? ( <tr key={v.id.toString()}>
<span className="muted"> · expired</span> <td>
) : usedUp ? ( <code>{v.code}</code>
<span className="muted"> · used up</span> </td>
) : null} <td>{v.amount}</td>
</td> <td>
<td>{v.amount}</td> {v.useCount} / {v.maxUses}
<td> </td>
{v.useCount} / {v.maxUses} <td>
</td> {expired ? (
<td className="muted">{fmtDate(v.expiresAt)}</td> <span className="admin-badge danger">Expired</span>
<td className="muted">{fmtDate(v.createdAt)}</td> ) : usedUp ? (
<td> <span className="admin-badge">Used up</span>
<form action={deleteVoucher}> ) : (
<input type="hidden" name="id" value={v.id.toString()} /> <span className="admin-badge ok">Active</span>
<button type="submit" className="btn btn-danger"> )}
Delete </td>
</button> <td className="muted">{fmtDate(v.expiresAt)}</td>
</form> <td className="muted">{fmtDate(v.createdAt)}</td>
</td> <td>
</tr> <form action={deleteVoucher}>
); <input type="hidden" name="id" value={v.id.toString()} />
})} <button type="submit" className="btn btn-danger">
</tbody> Delete
</table> </button>
{vouchers.length === 0 ? <p className="muted">No vouchers yet.</p> : null} </form>
</div> </td>
</tr>
);
})}
</tbody>
</table>
{vouchers.length === 0 ? <p className="muted">No vouchers yet.</p> : null}
</div>
</section>
</main> </main>
); );
} }
+63 -32
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,40 +21,70 @@ 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 style={{ display: "flex", gap: "0.5rem", flexWrap: "wrap" }}> </div>
<input name="word" placeholder="Word to filter" required maxLength={255} style={{ flex: 1, minWidth: 200 }} />
<button type="submit" className="btn btn-primary">Add</button>
</div>
</form>
<table style={{ width: "100%", borderCollapse: "collapse" }}> <section className="admin-section">
<thead> <h2>Add word</h2>
<tr style={{ textAlign: "left", color: "var(--muted)" }}> <form action={addWord} className="card">
<th>ID</th> <div style={{ display: "flex", gap: "0.5rem", flexWrap: "wrap" }}>
<th>Word</th> <input
<th /> name="word"
</tr> placeholder="Word to filter"
</thead> required
<tbody> maxLength={255}
{words.map((w) => ( style={{ flex: 1, minWidth: 200 }}
<tr key={String(w.id)} style={{ borderTop: "1px solid var(--border)" }}> />
<td>{String(w.id)}</td> <button type="submit" className="btn btn-primary">
<td>{w.word}</td> Add
<td> </button>
<form action={deleteWord}> </div>
<input type="hidden" name="id" value={String(w.id)} /> </form>
<button type="submit" className="btn btn-danger">Delete</button> </section>
</form>
</td> <section className="admin-section">
</tr> <h2>
))} Words <span className="admin-badge">{words.length}</span>
</tbody> </h2>
</table> {words.length === 0 ? (
{words.length === 0 ? <p className="muted">No filtered words.</p> : null} <p className="muted">No filtered words.</p>
) : (
<div className="card" style={{ padding: 0, overflowX: "auto" }}>
<table>
<thead>
<tr>
<th>ID</th>
<th>Word</th>
<th />
</tr>
</thead>
<tbody>
{words.map((w) => (
<tr key={String(w.id)}>
<td className="muted">{String(w.id)}</td>
<td>
<strong>{w.word}</strong>
</td>
<td>
<form action={deleteWord}>
<input type="hidden" name="id" value={String(w.id)} />
<button type="submit" className="btn btn-danger">
Delete
</button>
</form>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</section>
</main> </main>
); );
} }