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";
export const dynamic = "force-dynamic";
@@ -39,47 +40,60 @@ export default async function AdminAchievements() {
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 (
<main>
<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.
</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 ? (
<p className="muted">No achievements found.</p>
) : (
[...groups.entries()].map(([category, rows]) => (
<div key={category} className="card" style={{ marginBottom: "1.5rem" }}>
<h3 style={{ marginTop: 0 }}>{category}</h3>
<table style={{ width: "100%", borderCollapse: "collapse" }}>
<thead>
<tr style={{ textAlign: "left", color: "var(--muted)" }}>
<th>Name</th>
<th>Level</th>
<th>Progress needed</th>
<th>Reward type</th>
<th>Reward amount</th>
<th>Points</th>
</tr>
</thead>
<tbody>
{rows.map((a) => (
<tr
key={`${a.name}-${a.level}`}
style={{ borderTop: "1px solid var(--border)" }}
>
<td>{a.name}</td>
<td>{a.level}</td>
<td>{a.progressNeeded}</td>
<td>{a.rewardType}</td>
<td>{a.rewardAmount}</td>
<td>{a.points ?? 0}</td>
<section key={category} className="admin-section">
<h2>
{category}{" "}
<span className="admin-badge">{rows.length}</span>
</h2>
<div className="card" style={{ padding: 0, overflowX: "auto" }}>
<table>
<thead>
<tr>
<th>Name</th>
<th>Level</th>
<th>Progress needed</th>
<th>Reward type</th>
<th>Reward amount</th>
<th>Points</th>
</tr>
))}
</tbody>
</table>
</div>
</thead>
<tbody>
{rows.map((a) => (
<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>
+50 -32
View File
@@ -1,4 +1,5 @@
import Link from "next/link";
import { StatusCard } from "@/components/admin/dashboard";
import { prisma } from "@/lib/prisma";
export const dynamic = "force-dynamic";
@@ -14,47 +15,64 @@ export default async function AdminAds() {
ads = [];
}
const latest = ads[0]?.createdAt
? ads[0].createdAt.toISOString().slice(0, 10)
: "—";
return (
<main>
<div style={{ display: "flex", alignItems: "center", gap: "1rem" }}>
<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>
<p className="muted">
<p className="muted" style={{ marginTop: "-0.4rem" }}>
Website advertisement banners (<code>website_ads</code>). Each ad is an image URL shown
across the site.
</p>
<table>
<thead>
<tr>
<th>Preview</th>
<th>Image URL</th>
<th>Created</th>
<th />
</tr>
</thead>
<tbody>
{ads.map((ad) => (
<tr key={String(ad.id)}>
<td>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src={ad.image} alt="" style={{ maxHeight: 40, maxWidth: 120 }} />
</td>
<td className="muted">
<code>{ad.image}</code>
</td>
<td className="muted">
{ad.createdAt ? ad.createdAt.toISOString().slice(0, 10) : ""}
</td>
<td>
<Link href={`/admin/ads/${ad.id}`}>Edit</Link>
</td>
</tr>
))}
</tbody>
</table>
{ads.length === 0 ? <p className="muted">No advertisements yet.</p> : null}
<div className="admin-stats">
<StatusCard label="Ads" value={ads.length} icon="🖼️" />
<StatusCard label="Latest" value={latest} hint="most recent ad" icon="🕒" />
</div>
<section className="admin-section">
<h2>All ads</h2>
{ads.length === 0 ? (
<p className="muted">No advertisements yet.</p>
) : (
<table>
<thead>
<tr>
<th>Preview</th>
<th>Image URL</th>
<th>Created</th>
<th />
</tr>
</thead>
<tbody>
{ads.map((ad) => (
<tr key={String(ad.id)}>
<td>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src={ad.image} alt="" style={{ maxHeight: 40, maxWidth: 120 }} />
</td>
<td className="muted">
<code>{ad.image}</code>
</td>
<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>
);
}
+30
View File
@@ -1,8 +1,12 @@
import { sendHotelAlert } from "@/actions/admin-alerts";
import { StatusCard } from "@/components/admin/dashboard";
import { prisma } from "@/lib/prisma";
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
// 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.
@@ -64,6 +68,10 @@ export default async function AdminAlerts() {
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 (
<main>
<h1>Alerts</h1>
@@ -71,6 +79,28 @@ export default async function AdminAlerts() {
Read-only · 100 most recent alert_logs (newest first).
</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" }}>
<h3 style={{ marginTop: 0 }}>Send hotel alert</h3>
<p className="muted" style={{ margin: "0 0 0.75rem" }}>
+57 -41
View File
@@ -1,5 +1,6 @@
import Link from "next/link";
import { dismissApplication } from "@/actions/admin-applications";
import { StatusCard } from "@/components/admin/dashboard";
import { prisma } from "@/lib/prisma";
export const dynamic = "force-dynamic";
@@ -13,7 +14,7 @@ type ApplicationRow = {
};
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() {
@@ -45,47 +46,62 @@ export default async function AdminApplications() {
return (
<main>
<h1>Staff Applications</h1>
<p className="muted" style={{ marginTop: "-0.4rem" }}>
Pending applications from players requesting a staff rank.
</p>
{applications.length === 0 ? (
<p className="muted">No pending applications.</p>
) : (
<div className="grid cols-2">
{applications.map((a) => {
const username = userMap.get(a.userId);
return (
<article key={String(a.id)} className="card">
<div
style={{
display: "flex",
justifyContent: "space-between",
alignItems: "baseline",
gap: "0.5rem",
}}
>
<h3 style={{ margin: "0 0 0.25rem" }}>
{username ? (
<Link href={`/admin/users/${a.userId}`}>{username}</Link>
) : (
<>User #{a.userId}</>
)}
</h3>
<span className="muted">Rank {a.rankId}</span>
</div>
<p className="muted" style={{ marginTop: 0 }}>
{formatDate(a.createdAt)}
</p>
<p style={{ whiteSpace: "pre-wrap", margin: "0 0 0.75rem" }}>{a.content}</p>
<form action={dismissApplication}>
<input type="hidden" name="id" value={String(a.id)} />
<button type="submit" className="btn btn-danger">
Dismiss
</button>
</form>
</article>
);
})}
</div>
)}
<div className="admin-stats">
<StatusCard
label="Pending applications"
value={applications.length}
state={applications.length > 0 ? "warn" : "ok"}
icon="📨"
/>
</div>
<section className="admin-section">
<h2>Applications ({applications.length})</h2>
{applications.length === 0 ? (
<p className="muted">No pending applications.</p>
) : (
<div className="grid cols-2">
{applications.map((a) => {
const username = userMap.get(a.userId);
return (
<article key={String(a.id)} className="card">
<div
style={{
display: "flex",
justifyContent: "space-between",
alignItems: "baseline",
gap: "0.5rem",
}}
>
<h3 style={{ margin: "0 0 0.25rem" }}>
{username ? (
<Link href={`/admin/users/${a.userId}`}>{username}</Link>
) : (
<>User #{a.userId}</>
)}
</h3>
<span className="admin-badge">Rank {a.rankId}</span>
</div>
<p className="muted" style={{ marginTop: 0 }}>
{formatDate(a.createdAt)}
</p>
<p style={{ whiteSpace: "pre-wrap", margin: "0 0 0.75rem" }}>{a.content}</p>
<form action={dismissApplication}>
<input type="hidden" name="id" value={String(a.id)} />
<button type="submit" className="btn btn-danger">
Dismiss
</button>
</form>
</article>
);
})}
</div>
)}
</section>
</main>
);
}
+48 -21
View File
@@ -1,4 +1,5 @@
import Link from "next/link";
import { StatusCard } from "@/components/admin/dashboard";
import { prisma } from "@/lib/prisma";
export const dynamic = "force-dynamic";
@@ -14,32 +15,58 @@ export default async function AdminArticles() {
})
.catch(() => []);
const latest = articles[0]?.createdAt
? articles[0].createdAt.toISOString().slice(0, 10)
: "—";
return (
<main>
<div style={{ display: "flex", alignItems: "center", gap: "1rem" }}>
<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>
<table style={{ width: "100%", borderCollapse: "collapse" }}>
<thead>
<tr style={{ textAlign: "left", color: "var(--muted)" }}>
<th>Title</th>
<th>Slug</th>
<th>Created</th>
</tr>
</thead>
<tbody>
{articles.map((a) => (
<tr key={String(a.id)} style={{ borderTop: "1px solid var(--border)" }}>
<td>
<Link href={`/admin/articles/${a.id}`}>{a.title}</Link>
</td>
<td className="muted">{a.slug}</td>
<td className="muted">{a.createdAt ? a.createdAt.toISOString().slice(0, 10) : ""}</td>
</tr>
))}
</tbody>
</table>
<p className="muted" style={{ marginTop: "-0.4rem" }}>
News articles (<code>website_articles</code>) shown on the public site. 50 most recent.
</p>
<div className="admin-stats">
<StatusCard label="Articles shown" value={articles.length} icon="📰" />
<StatusCard label="Latest" value={latest} hint="most recent post" icon="🕒" />
</div>
<section className="admin-section">
<h2>Recent articles</h2>
{articles.length === 0 ? (
<p className="muted">No articles yet.</p>
) : (
<table>
<thead>
<tr>
<th>Title</th>
<th>Slug</th>
<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>
);
}
+79 -29
View File
@@ -1,8 +1,13 @@
import { createBan, liftBan } from "@/actions/admin-bans";
import { StatusCard } from "@/components/admin/dashboard";
import { prisma } from "@/lib/prisma";
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() {
const now = Math.floor(Date.now() / 1000);
let bans: Awaited<ReturnType<typeof prisma.ban.findMany>> = [];
@@ -16,9 +21,32 @@ export default async function AdminBans() {
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 (
<main>
<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" }}>
<h3 style={{ marginTop: 0 }}>New ban</h3>
@@ -32,38 +60,60 @@ export default async function AdminBans() {
</select>
<input name="hours" type="number" min={0} placeholder="Hours (0 = perm)" />
<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>
</form>
<table style={{ width: "100%", borderCollapse: "collapse" }}>
<thead>
<tr style={{ textAlign: "left", color: "var(--muted)" }}>
<th>User ID</th>
<th>Type</th>
<th>Reason</th>
<th>Expires</th>
<th />
</tr>
</thead>
<tbody>
{bans.map((b) => (
<tr key={b.id} style={{ borderTop: "1px solid var(--border)" }}>
<td>{b.userId}</td>
<td>{b.type}</td>
<td>{b.banReason}</td>
<td className="muted">{new Date(b.banExpire * 1000).toISOString().slice(0, 16).replace("T", " ")}</td>
<td>
<form action={liftBan}>
<input type="hidden" name="id" value={b.id} />
<button type="submit">Lift</button>
</form>
</td>
</tr>
))}
</tbody>
</table>
{bans.length === 0 ? <p className="muted">No active bans.</p> : null}
<section className="admin-section">
<h2>Active bans ({bans.length})</h2>
{bans.length === 0 ? (
<p className="muted">No active bans.</p>
) : (
<div style={{ overflowX: "auto" }}>
<table>
<thead>
<tr>
<th>User ID</th>
<th>Type</th>
<th>Reason</th>
<th>Expires</th>
<th />
</tr>
</thead>
<tbody>
{bans.map((b) => (
<tr key={b.id}>
<td>{b.userId}</td>
<td>
<span className="admin-badge">{b.type}</span>
</td>
<td>{b.banReason || "—"}</td>
<td>
{b.banExpire === 0 ? (
<span className="admin-badge danger">permanent</span>
) : (
<span className="muted" style={{ whiteSpace: "nowrap" }}>
{fromUnix(b.banExpire)}
</span>
)}
</td>
<td>
<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>
);
}
+32 -4
View File
@@ -1,4 +1,5 @@
import Link from "next/link";
import { StatusCard } from "@/components/admin/dashboard";
import { prisma } from "@/lib/prisma";
export const dynamic = "force-dynamic";
@@ -39,10 +40,12 @@ export default async function AdminCalendar() {
// Tally reward counts per campaign in JS (avoid prisma.groupBy).
const rewardCounts = new Map<number, number>();
let totalRewards = 0;
try {
const rewards = await prisma.calendarRewards.findMany({
select: { campaignId: true },
});
totalRewards = rewards.length;
for (const r of rewards) {
rewardCounts.set(r.campaignId, (rewardCounts.get(r.campaignId) ?? 0) + 1);
}
@@ -50,19 +53,32 @@ export default async function AdminCalendar() {
// leave map empty on failure
}
const enabledCount = campaigns.filter((c) => c.enabled === "1").length;
return (
<main>
<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"}.
</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 ? (
<p className="muted">No calendar campaigns found.</p>
) : (
<div className="grid cols-3">
{campaigns.map((c) => {
const disabled = c.enabled !== "1";
const enabled = c.enabled === "1";
const count = rewardCounts.get(c.id) ?? 0;
return (
<Link
@@ -78,14 +94,26 @@ export default async function AdminCalendar() {
<div className="article-img" />
)}
<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 }}>
#{c.id} · {c.totalDays} day{c.totalDays === 1 ? "" : "s"} · {count} reward
{count === 1 ? "" : "s"}
</p>
<p className="muted" style={{ margin: "0.35rem 0 0" }}>
Starts {fmtDate(c.startTimestamp)}
{disabled ? " · Disabled" : ""}
{c.lockExpired === "1" ? " · Locks expired" : ""}
</p>
</div>
+74 -48
View File
@@ -1,4 +1,5 @@
import Link from "next/link";
import { StatusCard } from "@/components/admin/dashboard";
import { prisma } from "@/lib/prisma";
export const dynamic = "force-dynamic";
@@ -39,58 +40,83 @@ export default async function AdminCatalog() {
const captionById = new Map<number, string>();
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 (
<main>
<h1>Catalog</h1>
<p className="muted">Browse catalog pages and the items they contain. Read-only.</p>
{pages.length === 0 ? (
<p className="muted">No catalog pages found.</p>
) : (
<table style={{ width: "100%", borderCollapse: "collapse" }}>
<thead>
<tr style={{ textAlign: "left", color: "var(--muted)" }}>
<th>ID</th>
<th>Caption</th>
<th>Parent</th>
<th>Layout</th>
<th>Min rank</th>
<th>Order</th>
<th>Status</th>
</tr>
</thead>
<tbody>
{pages.map((p) => {
const parentName =
p.parentId > 0 ? captionById.get(p.parentId) : null;
const hidden = p.visible !== "1" || p.enabled !== "1";
return (
<tr key={p.id} style={{ borderTop: "1px solid var(--border)" }}>
<td>{p.id}</td>
<td>
<Link href={`/admin/catalog/${p.id}`}>{p.caption}</Link>
</td>
<td className="muted">
{p.parentId <= 0
? "—"
: parentName
? `${parentName} (#${p.parentId})`
: `#${p.parentId}`}
</td>
<td className="muted">{p.pageLayout}</td>
<td>{p.minRank}</td>
<td>{p.orderNum}</td>
<td className="muted">{hidden ? "Hidden" : "Visible"}</td>
</tr>
);
})}
</tbody>
</table>
)}
<p className="muted" style={{ marginTop: "1rem" }}>
{pages.length} page{pages.length === 1 ? "" : "s"}
<p className="muted" style={{ marginTop: "-0.4rem" }}>
Browse catalog pages and the items they contain. Read-only.
</p>
<div className="admin-stats">
<StatusCard label="Pages" value={pages.length} icon="📚" />
<StatusCard label="Visible" value={visibleCount} state="ok" icon="👁️" />
<StatusCard
label="Hidden"
value={hiddenCount}
state={hiddenCount > 0 ? "warn" : "neutral"}
icon="🚫"
/>
<StatusCard label="Root pages" value={rootCount} icon="🗂️" />
</div>
<section className="admin-section">
<h2>Pages ({pages.length})</h2>
{pages.length === 0 ? (
<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>
);
}
+18 -6
View File
@@ -1,4 +1,5 @@
import { updateEmulatorSetting, updateEmulatorText } from "@/actions/admin-emulator";
import { StatusCard } from "@/components/admin/dashboard";
import { prisma } from "@/lib/prisma";
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).
</p>
<h2 style={{ marginTop: "1.5rem" }}>Settings</h2>
<p className="muted">{settings.length} keys.</p>
<div className="admin-stats">
<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
action={updateEmulatorSetting}
@@ -100,11 +109,13 @@ export default async function AdminEmulator() {
{settings.length === 0 ? (
<p className="muted">No emulator settings found.</p>
) : null}
</section>
<h2 style={{ marginTop: "2.5rem" }}>Texts</h2>
<p className="muted">
{textsCount} keys{textsTruncated ? ` (showing first ${texts.length})` : ""}.
</p>
<section className="admin-section">
<h2>Texts</h2>
<p className="muted" style={{ marginTop: "-0.4rem" }}>
{textsCount} keys{textsTruncated ? ` (showing first ${texts.length})` : ""}.
</p>
<form
action={updateEmulatorText}
@@ -169,6 +180,7 @@ export default async function AdminEmulator() {
add/overwrite form above to edit a key beyond this list.
</p>
) : null}
</section>
</main>
);
}
+51 -29
View File
@@ -1,4 +1,5 @@
import Link from "next/link";
import { StatusCard } from "@/components/admin/dashboard";
import { prisma } from "@/lib/prisma";
export const dynamic = "force-dynamic";
@@ -16,44 +17,65 @@ export default async function AdminHelpQuestions() {
entries = [];
}
const smallBoxes = entries.filter((e) => e.smallBox).length;
const withButton = entries.filter((e) => e.buttonText).length;
return (
<main>
<div style={{ display: "flex", alignItems: "center", gap: "1rem" }}>
<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>
<p className="muted">
<p className="muted" style={{ marginTop: "-0.4rem" }}>
Help-center FAQ entries (<code>website_help_center_categories</code>). Shown to visitors on
the public help page, ordered by position.
</p>
<table>
<thead>
<tr>
<th>Pos.</th>
<th>Name</th>
<th>Button</th>
<th>Small box</th>
<th />
</tr>
</thead>
<tbody>
{entries.map((e) => (
<tr key={String(e.id)}>
<td className="muted">{e.position}</td>
<td>
<Link href={`/admin/help-questions/${e.id}`}>{e.name}</Link>
</td>
<td className="muted">{e.buttonText || "—"}</td>
<td className="muted">{e.smallBox ? "Yes" : "No"}</td>
<td>
<Link href={`/admin/help-questions/${e.id}`}>Edit</Link>
</td>
</tr>
))}
</tbody>
</table>
{entries.length === 0 ? <p className="muted">No help-center entries yet.</p> : null}
<div className="admin-stats">
<StatusCard label="Entries" value={entries.length} icon="❓" />
<StatusCard label="With button" value={withButton} icon="🔘" />
<StatusCard label="Small boxes" value={smallBoxes} icon="🗂️" />
</div>
<section className="admin-section">
<h2>Entries</h2>
{entries.length === 0 ? (
<p className="muted">No help-center entries yet.</p>
) : (
<table>
<thead>
<tr>
<th>Pos.</th>
<th>Name</th>
<th>Button</th>
<th>Small box</th>
<th />
</tr>
</thead>
<tbody>
{entries.map((e) => (
<tr key={String(e.id)}>
<td className="muted">{e.position}</td>
<td>
<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>
);
}
+78 -51
View File
@@ -1,4 +1,5 @@
import { deletePermission, upsertPermission } from "@/actions/admin-housekeeping";
import { StatusCard } from "@/components/admin/dashboard";
import { prisma } from "@/lib/prisma";
export const dynamic = "force-dynamic";
@@ -15,65 +16,91 @@ export default async function AdminHousekeeping() {
permissions = [];
}
const ranks = permissions.map((p) => p.minRank);
const maxRank = ranks.length ? Math.max(...ranks) : 0;
return (
<main>
<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
existing key overwrites its min rank and description.
</p>
<form action={upsertPermission} className="card" style={{ marginBottom: "1.5rem" }}>
<h3 style={{ marginTop: 0 }}>Add / overwrite permission</h3>
<div style={{ display: "flex", gap: "0.5rem", flexWrap: "wrap" }}>
<input name="permission" placeholder="permission key" required style={{ minWidth: 200 }} />
<input name="minRank" type="number" min={0} placeholder="Min rank" required style={{ width: 110 }} />
<input name="description" placeholder="description" style={{ flex: 1, minWidth: 160 }} />
<button type="submit" className="btn btn-primary">Save</button>
</div>
</form>
<div className="admin-stats">
<StatusCard label="Permissions" value={permissions.length} icon="🔑" />
<StatusCard label="Highest min rank" value={maxRank} icon="⬆️" />
</div>
<table>
<thead>
<tr>
<th>Permission</th>
<th>Description</th>
<th style={{ width: 200 }}>Min rank</th>
<th style={{ width: 80 }} />
</tr>
</thead>
<tbody>
{permissions.map((p) => (
<tr key={String(p.id)}>
<td><strong>{p.permission}</strong></td>
<td className="muted">{p.description ?? "—"}</td>
<td>
<form action={upsertPermission} style={{ display: "flex", gap: "0.5rem" }}>
<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>
{permissions.length === 0 ? (
<p className="muted">No housekeeping permissions defined yet.</p>
) : null}
<section className="admin-section">
<h2>Add / overwrite permission</h2>
<form action={upsertPermission} className="card">
<div style={{ display: "flex", gap: "0.5rem", flexWrap: "wrap" }}>
<input name="permission" placeholder="permission key" required style={{ minWidth: 200 }} />
<input name="minRank" type="number" min={0} placeholder="Min rank" required style={{ width: 110 }} />
<input name="description" placeholder="description" style={{ flex: 1, minWidth: 160 }} />
<button type="submit" className="btn btn-primary">
Save
</button>
</div>
</form>
</section>
<section className="admin-section">
<h2>
Permissions <span className="admin-badge">{permissions.length}</span>
</h2>
{permissions.length === 0 ? (
<p className="muted">No housekeeping permissions defined yet.</p>
) : (
<div className="card" style={{ padding: 0, overflowX: "auto" }}>
<table>
<thead>
<tr>
<th>Permission</th>
<th>Description</th>
<th style={{ width: 200 }}>Min rank</th>
<th style={{ width: 80 }} />
</tr>
</thead>
<tbody>
{permissions.map((p) => (
<tr key={String(p.id)}>
<td>
<strong>{p.permission}</strong>
</td>
<td className="muted">{p.description ?? "—"}</td>
<td>
<form action={upsertPermission} style={{ display: "flex", gap: "0.5rem" }}>
<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>
);
}
+90 -61
View File
@@ -4,6 +4,7 @@ import {
deleteBlacklist,
deleteWhitelist,
} from "@/actions/admin-ip";
import { StatusCard } from "@/components/admin/dashboard";
import { prisma } from "@/lib/prisma";
export const dynamic = "force-dynamic";
@@ -16,7 +17,7 @@ type IpRow = {
};
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() {
@@ -44,10 +45,28 @@ export default async function AdminIp() {
return (
<main>
<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">
{/* ── Whitelist ─────────────────────────────── */}
<section>
{/* ── Whitelist ──────────────────── */}
<section className="admin-section">
<h2>Whitelist</h2>
<form action={addWhitelist} className="card" style={{ marginBottom: "1rem" }}>
@@ -65,38 +84,43 @@ export default async function AdminIp() {
</div>
</form>
<table>
<thead>
<tr>
<th>IP</th>
<th>ASN</th>
<th>Added</th>
<th />
</tr>
</thead>
<tbody>
{whitelist.map((w) => (
<tr key={String(w.id)}>
<td>{w.ipAddress}</td>
<td className="muted">{w.asn ?? "—"}</td>
<td className="muted">{formatDate(w.createdAt)}</td>
<td>
<form action={deleteWhitelist}>
<input type="hidden" name="id" value={String(w.id)} />
<button type="submit" className="btn btn-danger">
Remove
</button>
</form>
</td>
</tr>
))}
</tbody>
</table>
{whitelist.length === 0 ? <p className="muted">No whitelisted IPs.</p> : null}
{whitelist.length === 0 ? (
<p className="muted">No whitelisted IPs.</p>
) : (
<div style={{ overflowX: "auto" }}>
<table>
<thead>
<tr>
<th>IP</th>
<th>ASN</th>
<th>Added</th>
<th />
</tr>
</thead>
<tbody>
{whitelist.map((w) => (
<tr key={String(w.id)}>
<td>{w.ipAddress}</td>
<td className="muted">{w.asn ?? "—"}</td>
<td className="muted">{formatDate(w.createdAt)}</td>
<td>
<form action={deleteWhitelist}>
<input type="hidden" name="id" value={String(w.id)} />
<button type="submit" className="btn btn-danger">
Remove
</button>
</form>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</section>
{/* ── Blacklist ─────────────────────────────── */}
<section>
{/* ── Blacklist ──────────────────── */}
<section className="admin-section">
<h2>Blacklist</h2>
<form action={addBlacklist} className="card" style={{ marginBottom: "1rem" }}>
@@ -114,34 +138,39 @@ export default async function AdminIp() {
</div>
</form>
<table>
<thead>
<tr>
<th>IP</th>
<th>ASN</th>
<th>Added</th>
<th />
</tr>
</thead>
<tbody>
{blacklist.map((b) => (
<tr key={String(b.id)}>
<td>{b.ipAddress}</td>
<td className="muted">{b.asn ?? "—"}</td>
<td className="muted">{formatDate(b.createdAt)}</td>
<td>
<form action={deleteBlacklist}>
<input type="hidden" name="id" value={String(b.id)} />
<button type="submit" className="btn btn-danger">
Remove
</button>
</form>
</td>
</tr>
))}
</tbody>
</table>
{blacklist.length === 0 ? <p className="muted">No blacklisted IPs.</p> : null}
{blacklist.length === 0 ? (
<p className="muted">No blacklisted IPs.</p>
) : (
<div style={{ overflowX: "auto" }}>
<table>
<thead>
<tr>
<th>IP</th>
<th>ASN</th>
<th>Added</th>
<th />
</tr>
</thead>
<tbody>
{blacklist.map((b) => (
<tr key={String(b.id)}>
<td>{b.ipAddress}</td>
<td className="muted">{b.asn ?? "—"}</td>
<td className="muted">{formatDate(b.createdAt)}</td>
<td>
<form action={deleteBlacklist}>
<input type="hidden" name="id" value={String(b.id)} />
<button type="submit" className="btn btn-danger">
Remove
</button>
</form>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</section>
</div>
</main>
+26 -3
View File
@@ -1,7 +1,10 @@
import { StatusCard } from "@/components/admin/dashboard";
import { prisma } from "@/lib/prisma";
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
// generates no delegate for them — they are read via $queryRaw against the real
// 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.
</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" }}>
<h3 style={{ marginTop: 0 }}>Room chat ({chatlogs.length})</h3>
{chatlogs.length === 0 ? (
@@ -131,7 +140,11 @@ export default async function AdminLogs() {
<td>{String(c.user_id)}</td>
<td>{c.command}</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>
))}
</tbody>
@@ -161,9 +174,19 @@ export default async function AdminLogs() {
<tr key={String(a.id)}>
<td className="muted">{fromDate(a.createdAt)}</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.isRead ? "✓" : "○"}</td>
<td>
<span className={`admin-badge ${a.isRead ? "ok" : ""}`}>
{a.isRead ? "read" : "new"}
</span>
</td>
</tr>
))}
</tbody>
+101 -91
View File
@@ -1,4 +1,5 @@
import { saveMaintenance } from "@/actions/admin-maintenance";
import { StatusCard } from "@/components/admin/dashboard";
import { prisma } from "@/lib/prisma";
export const dynamic = "force-dynamic";
@@ -34,111 +35,120 @@ export default async function AdminMaintenance() {
return (
<main>
<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
minimum login rank) can log into the hotel. These values are stored in
<code> website_settings</code> and read live by the login flow.
</p>
{/* ── Current state ─────────────────────────────────────── */}
<div
className="card"
style={{
display: "flex",
alignItems: "center",
gap: "0.75rem",
marginBottom: "1.5rem",
}}
>
<span
aria-hidden="true"
style={{
width: 12,
height: 12,
borderRadius: 999,
flexShrink: 0,
background: enabled
? "var(--color-danger)"
: "var(--color-secondary)",
}}
<div className="admin-stats">
<StatusCard
label="Maintenance"
value={enabled ? "ENABLED" : "Disabled"}
state={enabled ? "danger" : "ok"}
hint={enabled ? "Hotel is locked down" : "Hotel is open to everyone"}
icon={enabled ? "🚧" : "🟢"}
/>
<StatusCard
label="Min login rank"
value={minRankValue}
hint="During maintenance"
icon="🛡️"
/>
<StatusCard
label="Splash message"
value={message ? "Set" : "Empty"}
state={message ? "ok" : "neutral"}
icon="💬"
/>
<div>
<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>
{/* ── 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 ─────────────────────────────────────────── */}
<form action={saveMaintenance} className="card">
<h3 style={{ marginTop: 0 }}>Settings</h3>
<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 }}>
<section className="admin-section">
<h2>Settings</h2>
<form action={saveMaintenance} className="card">
<label
htmlFor="mt-min-rank"
className="muted"
style={{ display: "block", marginBottom: "0.3rem" }}
htmlFor="mt-enabled"
style={{
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>
<input
id="mt-min-rank"
type="number"
name="min_rank"
min={0}
step={1}
defaultValue={minRankValue}
style={{ width: "100%" }}
/>
</div>
<div style={{ marginBottom: "1rem" }}>
<label
htmlFor="mt-message"
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>
<div style={{ marginBottom: "1rem", maxWidth: 220 }}>
<label
htmlFor="mt-min-rank"
className="muted"
style={{ display: "block", marginBottom: "0.3rem" }}
>
Minimum login rank during maintenance
</label>
<input
id="mt-min-rank"
type="number"
name="min_rank"
min={0}
step={1}
defaultValue={minRankValue}
style={{ width: "100%" }}
/>
</div>
<button type="submit" className="btn btn-primary">
Save changes
</button>
</form>
<div style={{ marginBottom: "1rem" }}>
<label
htmlFor="mt-message"
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>
);
}
+112 -80
View File
@@ -1,3 +1,4 @@
import { StatusCard } from "@/components/admin/dashboard";
import { prisma } from "@/lib/prisma";
export const dynamic = "force-dynamic";
@@ -25,6 +26,13 @@ type PublicRow = {
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() {
let flatcats: Flatcat[] = [];
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 (
<main>
<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
emulator tooling, not here.
</p>
{/* ── navigator_flatcats ───────────────────────────────── */}
<div className="card" style={{ padding: 0, overflowX: "auto", marginBottom: "1.5rem" }}>
<div style={{ padding: "1.1rem 1.25rem 0" }}>
<h3 style={{ margin: 0 }}>Flat categories</h3>
<p className="muted" style={{ margin: "0.25rem 0 0" }}>
<code>navigator_flatcats</code> · {flatcats.length} rows
</p>
</div>
<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>{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 className="admin-stats">
<StatusCard label="Flat categories" value={flatcats.length} icon="📂" />
<StatusCard
label="Public (flatcats)"
value={publicFlatcats}
hint="marked public"
icon="🌐"
/>
<StatusCard label="Public rooms" value={publics.length} icon="🚪" />
<StatusCard
label="Visible rooms"
value={visiblePublics}
state={visiblePublics > 0 ? "ok" : "neutral"}
icon="👁️"
/>
</div>
{/* ── navigator_flatcats ───────────────────────────────── */}
<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 ────────────────────────────────── */}
<div className="card" style={{ padding: 0, overflowX: "auto" }}>
<div style={{ padding: "1.1rem 1.25rem 0" }}>
<h3 style={{ margin: 0 }}>Public rooms</h3>
<p className="muted" style={{ margin: "0.25rem 0 0" }}>
<code>navigator_publics</code> · {publics.length} rows
</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>
<section className="admin-section">
<h2>Public rooms</h2>
<p className="muted" style={{ marginTop: "-0.4rem" }}>
<code>navigator_publics</code> · {publics.length} rows
</p>
{publics.length === 0 ? (
<p className="muted" style={{ padding: "0 1.25rem 1.1rem" }}>
No public-room assignments found.
</p>
) : null}
</div>
<p className="muted">No public-room assignments found.</p>
) : (
<div style={{ overflowX: "auto" }}>
<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>
<YesNo on={p.visible === "1"} />
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</section>
</main>
);
}
+79 -66
View File
@@ -3,6 +3,7 @@ import {
deletePermission,
updatePermission,
} from "@/actions/admin-permissions";
import { StatusCard } from "@/components/admin/dashboard";
import { prisma } from "@/lib/prisma";
export const dynamic = "force-dynamic";
@@ -29,14 +30,21 @@ export default async function AdminPermissions() {
permissions = [];
}
const highestRank = permissions.reduce((max, p) => Math.max(max, p.minRank), 0);
return (
<main>
<h1>Permissions</h1>
<p className="muted">
<p className="muted" style={{ marginTop: "-0.4rem" }}>
CMS-owned permissions (<code>website_permissions</code>). Each entry maps
a permission name to the minimum rank allowed to use it.
</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" }}>
<h3 style={{ marginTop: 0 }}>Add / overwrite permission</h3>
<div style={{ display: "flex", gap: "0.5rem", flexWrap: "wrap" }}>
@@ -60,71 +68,76 @@ export default async function AdminPermissions() {
</div>
</form>
{permissions.length === 0 ? (
<p className="muted">No permissions found.</p>
) : (
<table style={{ width: "100%", borderCollapse: "collapse" }}>
<thead>
<tr style={{ textAlign: "left", color: "var(--muted)" }}>
<th>ID</th>
<th>Permission</th>
<th>Min rank</th>
<th>Description</th>
<th />
</tr>
</thead>
<tbody>
{permissions.map((p) => (
<tr key={String(p.id)} style={{ borderTop: "1px solid var(--border)" }}>
<td>{String(p.id)}</td>
<td>
<form
id={`perm-${String(p.id)}`}
action={updatePermission}
style={{ display: "contents" }}
>
<input type="hidden" name="id" value={String(p.id)} />
<input name="permission" defaultValue={p.permission} required maxLength={255} />
</form>
</td>
<td>
<input
form={`perm-${String(p.id)}`}
name="minRank"
type="number"
min={0}
defaultValue={p.minRank}
style={{ width: 90 }}
/>
</td>
<td>
<input
form={`perm-${String(p.id)}`}
name="description"
defaultValue={p.description ?? ""}
style={{ width: "100%" }}
/>
</td>
<td style={{ display: "flex", gap: "0.5rem" }}>
<button
type="submit"
form={`perm-${String(p.id)}`}
className="btn btn-primary"
>
Save
</button>
<form action={deletePermission}>
<input type="hidden" name="id" value={String(p.id)} />
<button type="submit" className="btn btn-danger">
Delete
</button>
</form>
</td>
</tr>
))}
</tbody>
</table>
)}
<section className="admin-section">
<h2>Permissions ({permissions.length})</h2>
{permissions.length === 0 ? (
<p className="muted">No permissions found.</p>
) : (
<div style={{ overflowX: "auto" }}>
<table>
<thead>
<tr>
<th>ID</th>
<th>Permission</th>
<th>Min rank</th>
<th>Description</th>
<th />
</tr>
</thead>
<tbody>
{permissions.map((p) => (
<tr key={String(p.id)}>
<td className="muted">{String(p.id)}</td>
<td>
<form
id={`perm-${String(p.id)}`}
action={updatePermission}
style={{ display: "contents" }}
>
<input type="hidden" name="id" value={String(p.id)} />
<input name="permission" defaultValue={p.permission} required maxLength={255} />
</form>
</td>
<td>
<input
form={`perm-${String(p.id)}`}
name="minRank"
type="number"
min={0}
defaultValue={p.minRank}
style={{ width: 90 }}
/>
</td>
<td>
<input
form={`perm-${String(p.id)}`}
name="description"
defaultValue={p.description ?? ""}
style={{ width: "100%" }}
/>
</td>
<td style={{ display: "flex", gap: "0.5rem" }}>
<button
type="submit"
form={`perm-${String(p.id)}`}
className="btn btn-primary"
>
Save
</button>
<form action={deletePermission}>
<input type="hidden" name="id" value={String(p.id)} />
<button type="submit" className="btn btn-danger">
Delete
</button>
</form>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</section>
</main>
);
}
+43 -27
View File
@@ -1,4 +1,5 @@
import { deletePhoto } from "@/actions/admin-photos";
import { StatusCard } from "@/components/admin/dashboard";
import { prisma } from "@/lib/prisma";
export const dynamic = "force-dynamic";
@@ -14,41 +15,56 @@ export default async function AdminPhotos() {
photos = [];
}
const uniqueRooms = new Set(photos.map((p) => p.roomId)).size;
const uniqueUsers = new Set(photos.map((p) => p.userId)).size;
return (
<main>
<h1>Photo Moderation</h1>
<p className="muted">
<p className="muted" style={{ marginTop: "-0.4rem" }}>
Recent in-game camera photos. Deleting removes the record from{" "}
<code>camera_web</code>.
</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 className="admin-stats">
<StatusCard label="Photos shown" value={photos.length} icon="📸" />
<StatusCard label="Rooms" value={uniqueRooms} hint="distinct rooms" icon="🚪" />
<StatusCard label="Users" value={uniqueUsers} hint="distinct photographers" icon="👤" />
</div>
{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>
);
}
+212 -181
View File
@@ -5,6 +5,7 @@ import {
updateRadioBanner,
} from '@/actions/admin-radio-extra';
import { prisma } from '@/lib/prisma';
import { StatusCard } from '@/components/admin/dashboard';
export const dynamic = 'force-dynamic';
@@ -46,17 +47,21 @@ export default async function AdminRadioBannersPage() {
banners = [];
}
const activeCount = banners.filter((b) => b.isActive).length;
const hiddenCount = banners.length - activeCount;
return (
<main>
<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/ranks">Ranks</Link> ·{' '}
<Link href="/admin/radio/history">History</Link>
</nav>
<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.
</p>
@@ -66,188 +71,214 @@ export default async function AdminRadioBannersPage() {
Could not load banners (database unavailable).
</p>
</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">
{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">
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 className="admin-stats">
<StatusCard label="Banners" value={banners.length} icon="🖼️" />
<StatusCard
label="Active"
value={activeCount}
state={activeCount > 0 ? 'ok' : 'neutral'}
icon="✅"
/>
<StatusCard
label="Hidden"
value={hiddenCount}
state={hiddenCount > 0 ? 'warn' : 'neutral'}
icon="🚫"
/>
</div>
)}
<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>
);
}
+63 -32
View File
@@ -1,5 +1,6 @@
import Link from 'next/link';
import { prisma } from '@/lib/prisma';
import { StatusCard } from '@/components/admin/dashboard';
export const dynamic = 'force-dynamic';
@@ -64,6 +65,13 @@ export default async function AdminRadioHistoryPage() {
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 (
<main>
<nav className="muted" style={{ marginBottom: '0.5rem' }}>
@@ -75,7 +83,7 @@ export default async function AdminRadioHistoryPage() {
</nav>
<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.
Read-only log of who was on air, when, and how many listeners tuned in.
</p>
@@ -86,40 +94,63 @@ export default async function AdminRadioHistoryPage() {
Could not load radio history (database unavailable).
</p>
</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}
{history.length === 0 ? (
<p className="muted">No radio history recorded.</p>
) : (
<table>
<thead>
<tr>
<th>ID</th>
<th>DJ</th>
<th>Show</th>
<th>Started</th>
<th>Ended</th>
<th>Airtime</th>
<th>Listeners</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>
{history.length > 0 ? (
<section className="admin-section">
<h2>Show log ({history.length})</h2>
<table>
<thead>
<tr>
<th>ID</th>
<th>DJ</th>
<th>Show</th>
<th>Started</th>
<th>Ended</th>
<th>Airtime</th>
<th>Listeners</th>
<th>Notes</th>
</tr>
))}
</tbody>
</table>
)}
</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>
{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>
);
}
+63 -12
View File
@@ -1,5 +1,7 @@
import Link from 'next/link';
import { prisma } from '@/lib/prisma';
import { deleteShout } from '@/actions/admin-radio';
import { StatusCard } from '@/components/admin/dashboard';
export const dynamic = 'force-dynamic';
@@ -39,6 +41,14 @@ function formatTime(d: Date | null): string {
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() {
let shouts: Shout[] = [];
let applications: Application[] = [];
@@ -91,14 +101,47 @@ export default async function AdminRadioPage() {
schedules = [];
}
const pendingApps = applications.filter(
(a) => a.status.toLowerCase() === 'pending',
).length;
const activeSchedules = schedules.filter((sc) => sc.isActive).length;
return (
<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' }}>
<h2>Recent Shouts</h2>
<h1>Radio Moderation</h1>
<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 ? (
<p className="muted">No shouts.</p>
<p className="muted">No shouts to moderate.</p>
) : (
<table>
<thead>
@@ -132,10 +175,10 @@ export default async function AdminRadioPage() {
)}
</section>
<section style={{ marginBottom: '2rem' }}>
<h2>DJ Applications</h2>
<section className="admin-section">
<h2>DJ Applications ({applications.length})</h2>
{applications.length === 0 ? (
<p className="muted">No applications.</p>
<p className="muted">No applications submitted.</p>
) : (
<table>
<thead>
@@ -159,7 +202,11 @@ export default async function AdminRadioPage() {
<td>{a.age}</td>
<td>{a.musicStyle ?? '—'}</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>
</tr>
))}
@@ -168,10 +215,10 @@ export default async function AdminRadioPage() {
)}
</section>
<section>
<h2>Schedules</h2>
<section className="admin-section">
<h2>Schedules ({schedules.length})</h2>
{schedules.length === 0 ? (
<p className="muted">No schedules.</p>
<p className="muted">No schedules configured.</p>
) : (
<table>
<thead>
@@ -194,7 +241,11 @@ export default async function AdminRadioPage() {
<td>{formatTime(sc.startTime)}</td>
<td>{formatTime(sc.endTime)}</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>
))}
</tbody>
+133 -117
View File
@@ -5,6 +5,7 @@ import {
updateRadioRank,
} from '@/actions/admin-radio-extra';
import { prisma } from '@/lib/prisma';
import { StatusCard } from '@/components/admin/dashboard';
export const dynamic = 'force-dynamic';
@@ -36,17 +37,22 @@ export default async function AdminRadioRanksPage() {
ranks = [];
}
const activeCount = ranks.filter((r) => r.isActive).length;
return (
<main>
<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/ranks">Ranks</Link> ·{' '}
<Link href="/admin/radio/history">History</Link>
</nav>
<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 ? (
<div className="card" style={{ marginBottom: '1.5rem' }}>
@@ -54,123 +60,133 @@ export default async function AdminRadioRanksPage() {
Could not load ranks (database unavailable).
</p>
</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>
<thead>
<tr>
<th>ID</th>
<th>Name</th>
<th>Badge</th>
<th>Description</th>
<th>Active</th>
<th />
</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>
<div className="admin-stats">
<StatusCard label="Ranks" value={ranks.length} icon="🎖️" />
<StatusCard
label="Active"
value={activeCount}
state={activeCount > 0 ? 'ok' : 'neutral'}
icon="✅"
/>
</div>
)}
<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>
);
}
+35 -3
View File
@@ -1,6 +1,7 @@
import Link from 'next/link';
import { saveRadioSetting, saveRadioSettings } from '@/actions/admin-radio-extra';
import { prisma } from '@/lib/prisma';
import { StatusCard } from '@/components/admin/dashboard';
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 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 (
<main>
<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/ranks">Ranks</Link> ·{' '}
<Link href="/admin/radio/history">History</Link>
</nav>
<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
runtime settings cache. Booleans use <code>0</code> / <code>1</code>.
</p>
@@ -230,7 +240,29 @@ export default async function AdminRadioSettingsPage() {
created on save.
</p>
</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}>
<input type="hidden" name="__keys" value={allKeys} />
+14 -5
View File
@@ -4,6 +4,7 @@ import {
deleteCategory,
deleteValue,
} from "@/actions/admin-rare-values";
import { StatusCard } from "@/components/admin/dashboard";
import { prisma } from "@/lib/prisma";
export const dynamic = "force-dynamic";
@@ -40,11 +41,16 @@ export default async function AdminRareValues() {
return (
<main>
<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
rendered as a tab on the public rare values page.
</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" }}>
<h3 style={{ marginTop: 0 }}>New category</h3>
<div style={{ display: "flex", gap: "0.5rem", flexWrap: "wrap" }}>
@@ -74,10 +80,11 @@ export default async function AdminRareValues() {
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>
<span className="muted">badge {cat.badge}</span>
<span className="muted">priority {cat.priority}</span>
<span className="admin-badge">badge {cat.badge}</span>
<span className="admin-badge">priority {cat.priority}</span>
<span className="muted">{rows.length} item{rows.length === 1 ? "" : "s"}</span>
</div>
<form action={deleteCategory}>
<input type="hidden" name="id" value={catId} />
@@ -104,7 +111,9 @@ export default async function AdminRareValues() {
<td className="muted">{v.itemId ?? "—"}</td>
<td>{v.creditValue ?? "—"}</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>
<form action={deleteValue}>
+21 -2
View File
@@ -1,4 +1,5 @@
import Link from "next/link";
import { StatusCard } from "@/components/admin/dashboard";
import { prisma } from "@/lib/prisma";
export const dynamic = "force-dynamic";
@@ -60,9 +61,23 @@ export default async function AdminRooms({
const qs = (p: number) =>
`/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 (
<main>
<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" }}>
<input name="q" defaultValue={q} placeholder="Search room or owner" />
@@ -86,7 +101,7 @@ export default async function AdminRooms({
<tbody>
{rooms.map((r) => (
<tr key={r.id}>
<td>{r.id}</td>
<td className="muted">{r.id}</td>
<td>
<Link href={`/admin/rooms/${r.id}`}>{r.name || "(unnamed)"}</Link>
</td>
@@ -94,7 +109,11 @@ export default async function AdminRooms({
<td>
{r.users} / {r.usersMax}
</td>
<td>{r.state}</td>
<td>
<span className={`admin-badge ${r.state === "open" ? "ok" : "danger"}`}>
{r.state}
</span>
</td>
<td>{r.category}</td>
</tr>
))}
+61 -37
View File
@@ -1,4 +1,5 @@
import { createSetting, deleteSetting, updateSetting } from "@/actions/admin-settings";
import { StatusCard } from "@/components/admin/dashboard";
import { prisma } from "@/lib/prisma";
export const dynamic = "force-dynamic";
@@ -11,50 +12,73 @@ export default async function AdminSettings() {
settings = [];
}
const documented = settings.filter((s) => !!s.comment).length;
return (
<main>
<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.
</p>
<form action={createSetting} className="card" style={{ marginBottom: "1.5rem" }}>
<h3 style={{ marginTop: 0 }}>Add / overwrite</h3>
<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">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 className="admin-stats">
<StatusCard label="Settings" value={settings.length} icon="🗄️" />
<StatusCard label="With comment" value={documented} icon="📝" />
</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>
);
}
+123 -66
View File
@@ -1,8 +1,20 @@
import Link from "next/link";
import { StatusCard } from "@/components/admin/dashboard";
import { prisma } from "@/lib/prisma";
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() {
let packages: Awaited<ReturnType<typeof prisma.websiteShopArticles.findMany>> = [];
try {
@@ -24,82 +36,127 @@ export default async function AdminShop() {
orders = [];
}
const paidOrders = orders.filter((o) => statusState(o.status) === "ok").length;
return (
<main>
<div style={{ display: "flex", alignItems: "center", gap: "1rem" }}>
<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>
<p className="muted">
<p className="muted" style={{ marginTop: "-0.4rem" }}>
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.
</p>
<table style={{ width: "100%", borderCollapse: "collapse" }}>
<thead>
<tr style={{ textAlign: "left", color: "var(--muted)" }}>
<th>#</th>
<th>Name</th>
<th>Costs</th>
<th>Credits</th>
<th>Duckets</th>
<th>Diamonds</th>
<th>Rank</th>
<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}
<div className="admin-stats">
<StatusCard label="Packages" value={packages.length} icon="🛍️" />
<StatusCard label="Recent orders" value={orders.length} icon="🧾" />
<StatusCard
label="Paid orders"
value={paidOrders}
state={paidOrders > 0 ? "ok" : "neutral"}
icon="💳"
/>
</div>
<h2 style={{ marginTop: "2rem" }}>Recent orders</h2>
<p className="muted">
Read-only PayPal transaction log (<code>website_paypal_transactions</code>).
</p>
<table style={{ width: "100%", borderCollapse: "collapse" }}>
<thead>
<tr style={{ textAlign: "left", color: "var(--muted)" }}>
<th>Transaction</th>
<th>User</th>
<th>Description</th>
<th>Amount</th>
<th>Status</th>
<th>Date</th>
</tr>
</thead>
<tbody>
{orders.map((o) => (
<tr key={String(o.id)} style={{ borderTop: "1px solid var(--border)" }}>
<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 className="muted">{o.status ?? ""}</td>
<td className="muted">{o.createdAt ? o.createdAt.toISOString().slice(0, 10) : ""}</td>
</tr>
))}
</tbody>
</table>
{orders.length === 0 ? <p className="muted">No orders recorded.</p> : null}
<section className="admin-section">
<h2>Packages ({packages.length})</h2>
{packages.length === 0 ? (
<p className="muted">No shop packages yet.</p>
) : (
<div style={{ overflowX: "auto" }}>
<table>
<thead>
<tr>
<th>#</th>
<th>Name</th>
<th>Costs</th>
<th>Credits</th>
<th>Duckets</th>
<th>Diamonds</th>
<th>Rank</th>
<th>Position</th>
</tr>
</thead>
<tbody>
{packages.map((p) => (
<tr key={String(p.id)}>
<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>
</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>
);
}
+52 -23
View File
@@ -1,3 +1,4 @@
import { StatusCard } from "@/components/admin/dashboard";
import { prisma } from "@/lib/prisma";
export const dynamic = "force-dynamic";
@@ -39,36 +40,64 @@ export default async function AdminSubscriptions() {
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 (
<main>
<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.
</p>
<div className="card">
<table>
<thead>
<tr>
<th>Sub ID</th>
<th>User ID</th>
<th>Type</th>
<th>Expires</th>
</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 className="admin-stats">
<StatusCard label="Active subs" value={active.length} icon="⭐" />
<StatusCard label="Types" value={types} icon="🏷️" />
<StatusCard
label="Expiring ≤ 7d"
value={expiringSoon}
state={expiringSoon > 0 ? "warn" : "ok"}
icon="⏳"
/>
</div>
<section className="admin-section">
<h2>Active subscriptions ({active.length})</h2>
<div className="card">
<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>
);
}
+104 -72
View File
@@ -1,4 +1,5 @@
import { createTag, deleteTag, updateTag } from '@/actions/admin-tags';
import { StatusCard } from '@/components/admin/dashboard';
import { prisma } from '@/lib/prisma';
export const dynamic = 'force-dynamic';
@@ -37,10 +38,13 @@ export default async function AdminTagsPage() {
tags = [];
}
const inUse = tags.filter((t) => (usage.get(String(t.id)) ?? 0) > 0).length;
const unused = tags.length - inUse;
return (
<main>
<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
background colour, and can be attached to articles via <code>taggables</code>.
</p>
@@ -51,7 +55,24 @@ export default async function AdminTagsPage() {
Could not load tags (database unavailable).
</p>
</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' }}>
<h3 style={{ marginTop: 0 }}>Add tag</h3>
@@ -93,77 +114,88 @@ export default async function AdminTagsPage() {
</div>
</form>
{tags.length === 0 ? (
<p className="muted">No tags yet.</p>
) : (
<table>
<thead>
<tr>
<th>ID</th>
<th>Tag</th>
<th>Colour</th>
<th>Used by</th>
<th>Created</th>
<th />
</tr>
</thead>
<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>
<section className="admin-section">
<h2>All tags</h2>
{tags.length === 0 ? (
<p className="muted">No tags yet.</p>
) : (
<table>
<thead>
<tr>
<th>ID</th>
<th>Tag</th>
<th>Colour</th>
<th>Used by</th>
<th>Created</th>
<th />
</tr>
))}
</tbody>
</table>
)}
</thead>
<tbody>
{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>
);
}
+84 -58
View File
@@ -1,4 +1,5 @@
import { createTeam, deleteTeam } from "@/actions/admin-teams";
import { StatusCard } from "@/components/admin/dashboard";
import { prisma } from "@/lib/prisma";
export const dynamic = "force-dynamic";
@@ -30,11 +31,27 @@ export default async function AdminTeams() {
teams = [];
}
const hidden = teams.filter((t) => t.hiddenRank).length;
return (
<main>
<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>
<form action={createTeam} style={{ display: "grid", gap: "0.6rem" }}>
<div className="grid cols-2">
@@ -67,63 +84,72 @@ export default async function AdminTeams() {
</form>
</div>
{teams.length === 0 ? (
<p className="muted">No teams yet.</p>
) : (
<table>
<thead>
<tr>
<th>ID</th>
<th>Rank name</th>
<th>Badge</th>
<th>Job description</th>
<th>Color</th>
<th>Hidden</th>
<th></th>
</tr>
</thead>
<tbody>
{teams.map((t) => (
<tr key={String(t.id)}>
<td>{String(t.id)}</td>
<td>{t.rankName}</td>
<td className="muted">{t.badge ?? "—"}</td>
<td className="muted">{t.jobDescription ?? "—"}</td>
<td>
<span
style={{
display: "inline-flex",
alignItems: "center",
gap: "0.4rem",
}}
>
<span
aria-hidden
style={{
width: 14,
height: 14,
borderRadius: 4,
border: "1px solid var(--border-color)",
background: t.staffColor,
}}
/>
{t.staffColor}
</span>
</td>
<td className="muted">{t.hiddenRank ? "Yes" : "No"}</td>
<td>
<form action={deleteTeam}>
<input type="hidden" name="id" value={String(t.id)} />
<button type="submit" className="btn btn-danger">
Delete
</button>
</form>
</td>
</tr>
))}
</tbody>
</table>
)}
<section className="admin-section">
<h2>Ranks ({teams.length})</h2>
{teams.length === 0 ? (
<p className="muted">No teams yet.</p>
) : (
<div style={{ overflowX: "auto" }}>
<table>
<thead>
<tr>
<th>ID</th>
<th>Rank name</th>
<th>Badge</th>
<th>Job description</th>
<th>Color</th>
<th>Visibility</th>
<th />
</tr>
</thead>
<tbody>
{teams.map((t) => (
<tr key={String(t.id)}>
<td className="muted">{String(t.id)}</td>
<td>{t.rankName}</td>
<td className="muted">{t.badge ?? "—"}</td>
<td className="muted">{t.jobDescription ?? "—"}</td>
<td>
<span
style={{
display: "inline-flex",
alignItems: "center",
gap: "0.4rem",
}}
>
<span
aria-hidden
style={{
width: 14,
height: 14,
borderRadius: 4,
border: "1px solid var(--border-color)",
background: t.staffColor,
}}
/>
{t.staffColor}
</span>
</td>
<td>
<span className={`admin-badge ${t.hiddenRank ? "" : "ok"}`}>
{t.hiddenRank ? "hidden" : "visible"}
</span>
</td>
<td>
<form action={deleteTeam}>
<input type="hidden" name="id" value={String(t.id)} />
<button type="submit" className="btn btn-danger">
Delete
</button>
</form>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</section>
</main>
);
}
+69 -18
View File
@@ -1,4 +1,5 @@
import Link from "next/link";
import { StatusCard } from "@/components/admin/dashboard";
import { requireStaff } from "@/lib/admin/guard";
import { prisma } from "@/lib/prisma";
@@ -15,6 +16,23 @@ const PER_PAGE = 25;
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 {
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 pagePaid = transactions.filter((t) => statusState(t.status) === "ok").length;
const pageFailed = transactions.filter((t) => statusState(t.status) === "danger").length;
return (
<main>
<h1>Transactions</h1>
@@ -72,14 +93,31 @@ export default async function AdminTransactions({
Read-only · PayPal / top-up transactions recorded by the CMS, newest first.
</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 ? (
<p className="muted">No transactions.</p>
) : (
<div style={{ overflowX: "auto" }}>
<table style={{ width: "100%", borderCollapse: "collapse" }}>
<table>
<thead>
<tr style={{ textAlign: "left", color: "var(--muted)" }}>
<tr>
<th>ID</th>
<th>Payer</th>
<th>Amount</th>
@@ -90,21 +128,34 @@ export default async function AdminTransactions({
</tr>
</thead>
<tbody>
{transactions.map((t) => (
<tr key={String(t.id)} style={{ borderTop: "1px solid var(--border)" }}>
<td>{String(t.id)}</td>
<td>
<Link href={`/admin/users/${t.userId}`}>
{names.get(t.userId) ?? `#${t.userId}`}
</Link>
</td>
<td>{formatAmount(t.amount, t.currency)}</td>
<td>{t.description ?? "—"}</td>
<td>{t.status ?? "—"}</td>
<td className="muted">{t.transactionId}</td>
<td className="muted">{fromDate(t.createdAt)}</td>
</tr>
))}
{transactions.map((t) => {
const state = statusState(t.status);
return (
<tr key={String(t.id)}>
<td className="muted">{String(t.id)}</td>
<td>
<Link href={`/admin/users/${t.userId}`}>
{names.get(t.userId) ?? `#${t.userId}`}
</Link>
</td>
<td>{formatAmount(t.amount, t.currency)}</td>
<td>{t.description ?? "—"}</td>
<td>
{t.status ? (
<span
className={`admin-badge ${state === "ok" ? "ok" : state === "danger" ? "danger" : ""}`}
>
{t.status}
</span>
) : (
<span className="muted">—</span>
)}
</td>
<td className="muted">{t.transactionId}</td>
<td className="muted">{fromDate(t.createdAt)}</td>
</tr>
);
})}
</tbody>
</table>
</div>
+89 -44
View File
@@ -7,6 +7,7 @@ import {
setMotto,
setRank,
} from "@/actions/admin-users";
import { InfoItem, StatusCard } from "@/components/admin/dashboard";
import { prisma } from "@/lib/prisma";
export const dynamic = "force-dynamic";
@@ -26,59 +27,103 @@ export default async function AdminUserDetail({
.catch(() => null);
if (!user) notFound();
const isOnline = user.online === "1";
return (
<main>
<p className="muted">
<Link href="/admin/users">← Users</Link>
</p>
<h1>{user.username}</h1>
<p className="muted">
ID {user.id} · rank {user.rank} · {user.credits} credits ·{" "}
{user.online === "1" ? "Online" : "Offline"}
<p className="muted" style={{ marginTop: "-0.4rem" }}>
{user.motto ? user.motto : "No motto set."}
</p>
<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">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 className="admin-stats">
<StatusCard label="User ID" value={user.id} icon="🆔" />
<StatusCard label="Rank" value={user.rank} icon="🎖️" />
<StatusCard label="Credits" value={user.credits} icon="💰" />
<StatusCard
label="Status"
value={isOnline ? "Online" : "Offline"}
state={isOnline ? "ok" : "neutral"}
icon={isOnline ? "🟢" : "⚪"}
/>
</div>
<section className="admin-section">
<h2>Overview</h2>
<div className="admin-info-grid">
<InfoItem label="Username" value={user.username} />
<InfoItem
label="Status"
value={
<span className={`admin-badge ${isOnline ? "ok" : ""}`}>
{isOnline ? "online" : "offline"}
</span>
}
/>
<InfoItem label="Rank" value={user.rank} />
<InfoItem label="Credits" value={user.credits} />
<InfoItem label="Look" value={<code>{user.look || "—"}</code>} />
</div>
</section>
<section className="admin-section">
<h2>Actions</h2>
<div className="grid cols-2">
<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>
);
}
+68 -27
View File
@@ -1,4 +1,5 @@
import Link from "next/link";
import { StatusCard } from "@/components/admin/dashboard";
import { prisma } from "@/lib/prisma";
export const dynamic = "force-dynamic";
@@ -29,38 +30,78 @@ export default async function AdminUsers({
const total = await prisma.user.count({ where }).catch(() => 0);
const pages = Math.max(1, Math.ceil(total / PER_PAGE));
const onlineOnPage = users.filter((u) => u.online === "1").length;
return (
<main>
<h1>Users</h1>
<form style={{ display: "flex", gap: "0.5rem", marginBottom: "1rem" }}>
<input name="q" defaultValue={q} placeholder="Search username" />
<button type="submit">Search</button>
<p className="muted" style={{ marginTop: "-0.4rem" }}>
{q ? `Search results for “${q}”.` : "Most recent accounts first."}
</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>
<table style={{ width: "100%", borderCollapse: "collapse" }}>
<thead>
<tr style={{ textAlign: "left", color: "var(--muted)" }}>
<th>ID</th>
<th>Username</th>
<th>Rank</th>
<th>Credits</th>
<th>Online</th>
</tr>
</thead>
<tbody>
{users.map((u) => (
<tr key={u.id} style={{ borderTop: "1px solid var(--border)" }}>
<td>{u.id}</td>
<td>
<Link href={`/admin/users/${u.id}`}>{u.username}</Link>
</td>
<td>{u.rank}</td>
<td>{u.credits}</td>
<td>{u.online === "1" ? "●" : "○"}</td>
</tr>
))}
</tbody>
</table>
<section className="admin-section">
<h2>Accounts ({total})</h2>
{users.length === 0 ? (
<p className="muted">No users match this search.</p>
) : (
<div style={{ overflowX: "auto" }}>
<table>
<thead>
<tr>
<th>ID</th>
<th>Username</th>
<th>Rank</th>
<th>Credits</th>
<th>Status</th>
</tr>
</thead>
<tbody>
{users.map((u) => (
<tr key={u.id}>
<td className="muted">{u.id}</td>
<td>
<Link href={`/admin/users/${u.id}`}>{u.username}</Link>
</td>
<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">
Page {page} / {pages} · {total} users
+91 -48
View File
@@ -1,4 +1,5 @@
import { createVoucher, deleteVoucher } from "@/actions/admin-vouchers";
import { StatusCard } from "@/components/admin/dashboard";
import { prisma } from "@/lib/prisma";
export const dynamic = "force-dynamic";
@@ -40,14 +41,48 @@ export default async function AdminVouchers() {
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 (
<main>
<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
use a given voucher once, up to its max uses.
</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" }}>
<h3 style={{ marginTop: 0 }}>New voucher</h3>
<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>
</form>
<div className="card">
<table>
<thead>
<tr>
<th>Code</th>
<th>Amount</th>
<th>Uses</th>
<th>Expires</th>
<th>Created</th>
<th />
</tr>
</thead>
<tbody>
{vouchers.map((v) => {
const expired = v.expiresAt ? v.expiresAt.getTime() <= now : false;
const usedUp = v.maxUses > 0 && v.useCount >= v.maxUses;
return (
<tr key={v.id.toString()}>
<td>
<code>{v.code}</code>
{expired ? (
<span className="muted"> · expired</span>
) : usedUp ? (
<span className="muted"> · used up</span>
) : null}
</td>
<td>{v.amount}</td>
<td>
{v.useCount} / {v.maxUses}
</td>
<td className="muted">{fmtDate(v.expiresAt)}</td>
<td className="muted">{fmtDate(v.createdAt)}</td>
<td>
<form action={deleteVoucher}>
<input type="hidden" name="id" value={v.id.toString()} />
<button type="submit" className="btn btn-danger">
Delete
</button>
</form>
</td>
</tr>
);
})}
</tbody>
</table>
{vouchers.length === 0 ? <p className="muted">No vouchers yet.</p> : null}
</div>
<section className="admin-section">
<h2>Vouchers ({vouchers.length})</h2>
<div className="card">
<table>
<thead>
<tr>
<th>Code</th>
<th>Amount</th>
<th>Uses</th>
<th>Status</th>
<th>Expires</th>
<th>Created</th>
<th />
</tr>
</thead>
<tbody>
{vouchers.map((v) => {
const expired = v.expiresAt ? v.expiresAt.getTime() <= now : false;
const usedUp = v.maxUses > 0 && v.useCount >= v.maxUses;
return (
<tr key={v.id.toString()}>
<td>
<code>{v.code}</code>
</td>
<td>{v.amount}</td>
<td>
{v.useCount} / {v.maxUses}
</td>
<td>
{expired ? (
<span className="admin-badge danger">Expired</span>
) : usedUp ? (
<span className="admin-badge">Used up</span>
) : (
<span className="admin-badge ok">Active</span>
)}
</td>
<td className="muted">{fmtDate(v.expiresAt)}</td>
<td className="muted">{fmtDate(v.createdAt)}</td>
<td>
<form action={deleteVoucher}>
<input type="hidden" name="id" value={v.id.toString()} />
<button type="submit" className="btn btn-danger">
Delete
</button>
</form>
</td>
</tr>
);
})}
</tbody>
</table>
{vouchers.length === 0 ? <p className="muted">No vouchers yet.</p> : null}
</div>
</section>
</main>
);
}
+63 -32
View File
@@ -1,4 +1,5 @@
import { addWord, deleteWord } from "@/actions/admin-wordfilter";
import { StatusCard } from "@/components/admin/dashboard";
import { prisma } from "@/lib/prisma";
export const dynamic = "force-dynamic";
@@ -20,40 +21,70 @@ export default async function AdminWordFilter() {
return (
<main>
<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" }}>
<h3 style={{ marginTop: 0 }}>Add word</h3>
<div style={{ display: "flex", gap: "0.5rem", flexWrap: "wrap" }}>
<input name="word" placeholder="Word to filter" required maxLength={255} style={{ flex: 1, minWidth: 200 }} />
<button type="submit" className="btn btn-primary">Add</button>
</div>
</form>
<div className="admin-stats">
<StatusCard label="Filtered words" value={words.length} icon="🚫" />
</div>
<table style={{ width: "100%", borderCollapse: "collapse" }}>
<thead>
<tr style={{ textAlign: "left", color: "var(--muted)" }}>
<th>ID</th>
<th>Word</th>
<th />
</tr>
</thead>
<tbody>
{words.map((w) => (
<tr key={String(w.id)} style={{ borderTop: "1px solid var(--border)" }}>
<td>{String(w.id)}</td>
<td>{w.word}</td>
<td>
<form action={deleteWord}>
<input type="hidden" name="id" value={String(w.id)} />
<button type="submit" className="btn btn-danger">Delete</button>
</form>
</td>
</tr>
))}
</tbody>
</table>
{words.length === 0 ? <p className="muted">No filtered words.</p> : null}
<section className="admin-section">
<h2>Add word</h2>
<form action={addWord} className="card">
<div style={{ display: "flex", gap: "0.5rem", flexWrap: "wrap" }}>
<input
name="word"
placeholder="Word to filter"
required
maxLength={255}
style={{ flex: 1, minWidth: 200 }}
/>
<button type="submit" className="btn btn-primary">
Add
</button>
</div>
</form>
</section>
<section className="admin-section">
<h2>
Words <span className="admin-badge">{words.length}</span>
</h2>
{words.length === 0 ? (
<p className="muted">No filtered words.</p>
) : (
<div className="card" style={{ padding: 0, overflowX: "auto" }}>
<table>
<thead>
<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>
);
}