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>
File diff suppressed because it is too large. Load diff
+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" }}>
File diff suppressed because it is too large. Load diff
+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>
);
}
File diff suppressed because it is too large. Load diff
+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>
File diff suppressed because it is too large. Load diff
+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>
);
}
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+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>
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+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>
);
}
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+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>
))}
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+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>
);
}
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff