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:
1 parent
d419731566
commit
0de0ac6a3c
34 files changed
+1243
-282
No files matched your search
@@ -1,3 +1,4 @@
|
||||
import { StatusCard } from "@/components/admin/dashboard";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
@@ -39,22 +40,35 @@ 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" }}>
|
||||
<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 style={{ textAlign: "left", color: "var(--muted)" }}>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Level</th>
|
||||
<th>Progress needed</th>
|
||||
@@ -65,11 +79,10 @@ export default async function AdminAchievements() {
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((a) => (
|
||||
<tr
|
||||
key={`${a.name}-${a.level}`}
|
||||
style={{ borderTop: "1px solid var(--border)" }}
|
||||
>
|
||||
<td>{a.name}</td>
|
||||
<tr key={`${a.name}-${a.level}`}>
|
||||
<td>
|
||||
<strong>{a.name}</strong>
|
||||
</td>
|
||||
<td>{a.level}</td>
|
||||
<td>{a.progressNeeded}</td>
|
||||
<td>{a.rewardType}</td>
|
||||
@@ -80,6 +93,7 @@ export default async function AdminAchievements() {
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
))
|
||||
)}
|
||||
</main>
|
||||
|
||||
@@ -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,17 +15,33 @@ 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>
|
||||
|
||||
<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>
|
||||
@@ -44,8 +61,8 @@ export default async function AdminAds() {
|
||||
<td className="muted">
|
||||
<code>{ad.image}</code>
|
||||
</td>
|
||||
<td className="muted">
|
||||
{ad.createdAt ? ad.createdAt.toISOString().slice(0, 10) : ""}
|
||||
<td className="muted" style={{ whiteSpace: "nowrap" }}>
|
||||
{ad.createdAt ? ad.createdAt.toISOString().slice(0, 10) : "—"}
|
||||
</td>
|
||||
<td>
|
||||
<Link href={`/admin/ads/${ad.id}`}>Edit</Link>
|
||||
@@ -54,7 +71,8 @@ export default async function AdminAds() {
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
{ads.length === 0 ? <p className="muted">No advertisements yet.</p> : null}
|
||||
)}
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -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" }}>
|
||||
|
||||
@@ -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,7 +46,21 @@ 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>
|
||||
|
||||
<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>
|
||||
) : (
|
||||
@@ -69,7 +84,7 @@ export default async function AdminApplications() {
|
||||
<>User #{a.userId}</>
|
||||
)}
|
||||
</h3>
|
||||
<span className="muted">Rank {a.rankId}</span>
|
||||
<span className="admin-badge">Rank {a.rankId}</span>
|
||||
</div>
|
||||
<p className="muted" style={{ marginTop: 0 }}>
|
||||
{formatDate(a.createdAt)}
|
||||
@@ -86,6 +101,7 @@ export default async function AdminApplications() {
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -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,15 +15,35 @@ 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" }}>
|
||||
<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 style={{ textAlign: "left", color: "var(--muted)" }}>
|
||||
<tr>
|
||||
<th>Title</th>
|
||||
<th>Slug</th>
|
||||
<th>Created</th>
|
||||
@@ -30,16 +51,22 @@ export default async function AdminArticles() {
|
||||
</thead>
|
||||
<tbody>
|
||||
{articles.map((a) => (
|
||||
<tr key={String(a.id)} style={{ borderTop: "1px solid var(--border)" }}>
|
||||
<tr key={String(a.id)}>
|
||||
<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>
|
||||
<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
@@ -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>
|
||||
|
||||
@@ -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,17 +40,40 @@ 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>
|
||||
<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>
|
||||
) : (
|
||||
<table style={{ width: "100%", borderCollapse: "collapse" }}>
|
||||
<div style={{ overflowX: "auto" }}>
|
||||
<table>
|
||||
<thead>
|
||||
<tr style={{ textAlign: "left", color: "var(--muted)" }}>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>Caption</th>
|
||||
<th>Parent</th>
|
||||
@@ -65,8 +89,8 @@ export default async function AdminCatalog() {
|
||||
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>
|
||||
<tr key={p.id}>
|
||||
<td className="muted">{p.id}</td>
|
||||
<td>
|
||||
<Link href={`/admin/catalog/${p.id}`}>{p.caption}</Link>
|
||||
</td>
|
||||
@@ -80,17 +104,19 @@ export default async function AdminCatalog() {
|
||||
<td className="muted">{p.pageLayout}</td>
|
||||
<td>{p.minRank}</td>
|
||||
<td>{p.orderNum}</td>
|
||||
<td className="muted">{hidden ? "Hidden" : "Visible"}</td>
|
||||
<td>
|
||||
<span className={`admin-badge ${hidden ? "danger" : "ok"}`}>
|
||||
{hidden ? "Hidden" : "Visible"}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<p className="muted" style={{ marginTop: "1rem" }}>
|
||||
{pages.length} page{pages.length === 1 ? "" : "s"}
|
||||
</p>
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -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,9 +109,11 @@ 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">
|
||||
<section className="admin-section">
|
||||
<h2>Texts</h2>
|
||||
<p className="muted" style={{ marginTop: "-0.4rem" }}>
|
||||
{textsCount} keys{textsTruncated ? ` (showing first ${texts.length})` : ""}.
|
||||
</p>
|
||||
|
||||
@@ -169,6 +180,7 @@ export default async function AdminEmulator() {
|
||||
add/overwrite form above to edit a key beyond this list.
|
||||
</p>
|
||||
) : null}
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -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,17 +17,33 @@ 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>
|
||||
|
||||
<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>
|
||||
@@ -45,7 +62,11 @@ export default async function AdminHelpQuestions() {
|
||||
<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>
|
||||
<span className={`admin-badge ${e.smallBox ? "ok" : ""}`}>
|
||||
{e.smallBox ? "Yes" : "No"}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<Link href={`/admin/help-questions/${e.id}`}>Edit</Link>
|
||||
</td>
|
||||
@@ -53,7 +74,8 @@ export default async function AdminHelpQuestions() {
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
{entries.length === 0 ? <p className="muted">No help-center entries yet.</p> : null}
|
||||
)}
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -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,24 +16,44 @@ 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 className="admin-stats">
|
||||
<StatusCard label="Permissions" value={permissions.length} icon="🔑" />
|
||||
<StatusCard label="Highest min rank" value={maxRank} icon="⬆️" />
|
||||
</div>
|
||||
|
||||
<section className="admin-section">
|
||||
<h2>Add / overwrite permission</h2>
|
||||
<form action={upsertPermission} className="card">
|
||||
<div style={{ display: "flex", gap: "0.5rem", flexWrap: "wrap" }}>
|
||||
<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>
|
||||
<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>
|
||||
@@ -45,7 +66,9 @@ export default async function AdminHousekeeping() {
|
||||
<tbody>
|
||||
{permissions.map((p) => (
|
||||
<tr key={String(p.id)}>
|
||||
<td><strong>{p.permission}</strong></td>
|
||||
<td>
|
||||
<strong>{p.permission}</strong>
|
||||
</td>
|
||||
<td className="muted">{p.description ?? "—"}</td>
|
||||
<td>
|
||||
<form action={upsertPermission} style={{ display: "flex", gap: "0.5rem" }}>
|
||||
@@ -58,22 +81,26 @@ export default async function AdminHousekeeping() {
|
||||
defaultValue={p.minRank}
|
||||
style={{ width: 90 }}
|
||||
/>
|
||||
<button type="submit" className="btn btn-outline">Save</button>
|
||||
<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>
|
||||
<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}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -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,6 +84,10 @@ export default async function AdminIp() {
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{whitelist.length === 0 ? (
|
||||
<p className="muted">No whitelisted IPs.</p>
|
||||
) : (
|
||||
<div style={{ overflowX: "auto" }}>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
@@ -92,11 +115,12 @@ export default async function AdminIp() {
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
{whitelist.length === 0 ? <p className="muted">No whitelisted IPs.</p> : null}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* ── Blacklist ─────────────────────────────── */}
|
||||
<section>
|
||||
{/* ── Blacklist ──────────────────── */}
|
||||
<section className="admin-section">
|
||||
<h2>Blacklist</h2>
|
||||
|
||||
<form action={addBlacklist} className="card" style={{ marginBottom: "1rem" }}>
|
||||
@@ -114,6 +138,10 @@ export default async function AdminIp() {
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{blacklist.length === 0 ? (
|
||||
<p className="muted">No blacklisted IPs.</p>
|
||||
) : (
|
||||
<div style={{ overflowX: "auto" }}>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
@@ -141,7 +169,8 @@ export default async function AdminIp() {
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
{blacklist.length === 0 ? <p className="muted">No blacklisted IPs.</p> : null}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,34 +35,41 @@ 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>
|
||||
|
||||
{/* ── 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"}.
|
||||
@@ -73,11 +81,12 @@ export default async function AdminMaintenance() {
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ── Edit form ─────────────────────────────────────────── */}
|
||||
<section className="admin-section">
|
||||
<h2>Settings</h2>
|
||||
<form action={saveMaintenance} className="card">
|
||||
<h3 style={{ marginTop: 0 }}>Settings</h3>
|
||||
|
||||
<label
|
||||
htmlFor="mt-enabled"
|
||||
style={{
|
||||
@@ -139,6 +148,7 @@ export default async function AdminMaintenance() {
|
||||
Save changes
|
||||
</button>
|
||||
</form>
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -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,12 +68,15 @@ export default async function AdminPermissions() {
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<section className="admin-section">
|
||||
<h2>Permissions ({permissions.length})</h2>
|
||||
{permissions.length === 0 ? (
|
||||
<p className="muted">No permissions found.</p>
|
||||
) : (
|
||||
<table style={{ width: "100%", borderCollapse: "collapse" }}>
|
||||
<div style={{ overflowX: "auto" }}>
|
||||
<table>
|
||||
<thead>
|
||||
<tr style={{ textAlign: "left", color: "var(--muted)" }}>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>Permission</th>
|
||||
<th>Min rank</th>
|
||||
@@ -75,8 +86,8 @@ export default async function AdminPermissions() {
|
||||
</thead>
|
||||
<tbody>
|
||||
{permissions.map((p) => (
|
||||
<tr key={String(p.id)} style={{ borderTop: "1px solid var(--border)" }}>
|
||||
<td>{String(p.id)}</td>
|
||||
<tr key={String(p.id)}>
|
||||
<td className="muted">{String(p.id)}</td>
|
||||
<td>
|
||||
<form
|
||||
id={`perm-${String(p.id)}`}
|
||||
@@ -124,7 +135,9 @@ export default async function AdminPermissions() {
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -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,14 +15,28 @@ 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="admin-stats">
|
||||
<StatusCard label="Photos shown" value={photos.length} icon="📸" />
|
||||
<StatusCard label="Rooms" value={uniqueRooms} hint="distinct rooms" icon="🚪" />
|
||||
<StatusCard label="Users" value={uniqueUsers} hint="distinct photographers" icon="👤" />
|
||||
</div>
|
||||
|
||||
<section className="admin-section">
|
||||
<h2>Recent photos</h2>
|
||||
{photos.length === 0 ? (
|
||||
<p className="muted">No photos found.</p>
|
||||
) : (
|
||||
<div className="grid cols-3">
|
||||
{photos.map((p) => (
|
||||
<div key={String(p.id)} className="card" style={{ padding: 0, overflow: "hidden" }}>
|
||||
@@ -48,7 +63,8 @@ export default async function AdminPhotos() {
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{photos.length === 0 ? <p className="muted">No photos found.</p> : null}
|
||||
)}
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -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,10 +71,27 @@ export default async function AdminRadioBannersPage() {
|
||||
Could not load banners (database unavailable).
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
) : (
|
||||
<div className="admin-stats">
|
||||
<StatusCard label="Banners" value={banners.length} icon="🖼️" />
|
||||
<StatusCard
|
||||
label="Active"
|
||||
value={activeCount}
|
||||
state={activeCount > 0 ? 'ok' : 'neutral'}
|
||||
icon="✅"
|
||||
/>
|
||||
<StatusCard
|
||||
label="Hidden"
|
||||
value={hiddenCount}
|
||||
state={hiddenCount > 0 ? 'warn' : 'neutral'}
|
||||
icon="🚫"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form action={createRadioBanner} className="card" style={{ marginBottom: '1.5rem' }}>
|
||||
<h3 style={{ marginTop: 0 }}>Add banner</h3>
|
||||
<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 }}>
|
||||
@@ -124,9 +146,12 @@ export default async function AdminRadioBannersPage() {
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section className="admin-section">
|
||||
<h2>Existing banners ({banners.length})</h2>
|
||||
{banners.length === 0 ? (
|
||||
<p className="muted">No banners yet.</p>
|
||||
<p className="muted">No banners yet — add one above.</p>
|
||||
) : (
|
||||
<div className="grid">
|
||||
{banners.map((b) => (
|
||||
@@ -140,10 +165,15 @@ export default async function AdminRadioBannersPage() {
|
||||
}}
|
||||
>
|
||||
<strong>#{String(b.id)}</strong>
|
||||
<span className="muted">
|
||||
<span
|
||||
className="muted"
|
||||
style={{ display: 'inline-flex', gap: '0.5rem', alignItems: 'center' }}
|
||||
>
|
||||
by user {String(b.userId)} · {formatDate(b.createdAt)} ·{' '}
|
||||
<span className={`admin-badge ${b.isActive ? 'ok' : 'danger'}`}>
|
||||
{b.isActive ? 'active' : 'hidden'}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{b.imagePath ? (
|
||||
@@ -248,6 +278,7 @@ export default async function AdminRadioBannersPage() {
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -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,11 +94,27 @@ 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>
|
||||
) : (
|
||||
{history.length > 0 ? (
|
||||
<section className="admin-section">
|
||||
<h2>Show log ({history.length})</h2>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
@@ -111,7 +135,13 @@ export default async function AdminRadioHistoryPage() {
|
||||
<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>
|
||||
{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>
|
||||
@@ -119,7 +149,8 @@ export default async function AdminRadioHistoryPage() {
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</section>
|
||||
) : null}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -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} />
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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>
|
||||
))}
|
||||
|
||||
@@ -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,23 +12,41 @@ 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 className="admin-stats">
|
||||
<StatusCard label="Settings" value={settings.length} icon="🗄️" />
|
||||
<StatusCard label="With comment" value={documented} icon="📝" />
|
||||
</div>
|
||||
|
||||
<section className="admin-section">
|
||||
<h2>Add / overwrite</h2>
|
||||
<form action={createSetting} className="card">
|
||||
<div style={{ display: "flex", gap: "0.5rem", flexWrap: "wrap" }}>
|
||||
<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>
|
||||
<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">
|
||||
@@ -39,11 +58,15 @@ export default async function AdminSettings() {
|
||||
>
|
||||
<input type="hidden" name="key" value={s.key} />
|
||||
<input name="value" defaultValue={s.value} style={{ flex: 1 }} />
|
||||
<button type="submit">Save</button>
|
||||
<button type="submit" className="btn btn-outline">
|
||||
Save
|
||||
</button>
|
||||
</form>
|
||||
<form action={deleteSetting}>
|
||||
<input type="hidden" name="key" value={s.key} />
|
||||
<button type="submit">✕</button>
|
||||
<button type="submit" className="btn btn-danger">
|
||||
✕
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
{s.comment ? (
|
||||
@@ -54,7 +77,8 @@ export default async function AdminSettings() {
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{settings.length === 0 ? <p className="muted">No settings yet.</p> : null}
|
||||
)}
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
+78
-21
File diff suppressed because it is too large.
Load diff
@@ -1,3 +1,4 @@
|
||||
import { StatusCard } from "@/components/admin/dashboard";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
@@ -39,13 +40,31 @@ 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 > now). Read-only.
|
||||
</p>
|
||||
|
||||
<div className="admin-stats">
|
||||
<StatusCard label="Active subs" value={active.length} icon="⭐" />
|
||||
<StatusCard label="Types" value={types} icon="🏷️" />
|
||||
<StatusCard
|
||||
label="Expiring ≤ 7d"
|
||||
value={expiringSoon}
|
||||
state={expiringSoon > 0 ? "warn" : "ok"}
|
||||
icon="⏳"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<section className="admin-section">
|
||||
<h2>Active subscriptions ({active.length})</h2>
|
||||
<div className="card">
|
||||
<table>
|
||||
<thead>
|
||||
@@ -53,22 +72,32 @@ export default async function AdminSubscriptions() {
|
||||
<th>Sub ID</th>
|
||||
<th>User ID</th>
|
||||
<th>Type</th>
|
||||
<th>Status</th>
|
||||
<th>Expires</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{active.map((s) => (
|
||||
{active.map((s) => {
|
||||
const soon = s.expiresAt <= soonCutoff;
|
||||
return (
|
||||
<tr key={s.id}>
|
||||
<td>{s.id}</td>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
@@ -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,6 +114,8 @@ export default async function AdminTagsPage() {
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<section className="admin-section">
|
||||
<h2>All tags</h2>
|
||||
{tags.length === 0 ? (
|
||||
<p className="muted">No tags yet.</p>
|
||||
) : (
|
||||
@@ -110,7 +133,7 @@ export default async function AdminTagsPage() {
|
||||
<tbody>
|
||||
{tags.map((t) => (
|
||||
<tr key={String(t.id)}>
|
||||
<td>{String(t.id)}</td>
|
||||
<td className="muted">{String(t.id)}</td>
|
||||
<td>
|
||||
<span
|
||||
style={{
|
||||
@@ -127,7 +150,15 @@ export default async function AdminTagsPage() {
|
||||
<td className="muted">
|
||||
<code>{t.backgroundColor}</code>
|
||||
</td>
|
||||
<td className="muted">{usage.get(String(t.id)) ?? 0}</td>
|
||||
<td>
|
||||
<span
|
||||
className={`admin-badge ${
|
||||
(usage.get(String(t.id)) ?? 0) > 0 ? 'ok' : ''
|
||||
}`}
|
||||
>
|
||||
{usage.get(String(t.id)) ?? 0}
|
||||
</span>
|
||||
</td>
|
||||
<td className="muted">{formatDate(t.createdAt)}</td>
|
||||
<td>
|
||||
<form
|
||||
@@ -164,6 +195,7 @@ export default async function AdminTagsPage() {
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -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,9 +84,12 @@ export default async function AdminTeams() {
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
@@ -78,14 +98,14 @@ export default async function AdminTeams() {
|
||||
<th>Badge</th>
|
||||
<th>Job description</th>
|
||||
<th>Color</th>
|
||||
<th>Hidden</th>
|
||||
<th></th>
|
||||
<th>Visibility</th>
|
||||
<th />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{teams.map((t) => (
|
||||
<tr key={String(t.id)}>
|
||||
<td>{String(t.id)}</td>
|
||||
<td className="muted">{String(t.id)}</td>
|
||||
<td>{t.rankName}</td>
|
||||
<td className="muted">{t.badge ?? "—"}</td>
|
||||
<td className="muted">{t.jobDescription ?? "—"}</td>
|
||||
@@ -110,7 +130,11 @@ export default async function AdminTeams() {
|
||||
{t.staffColor}
|
||||
</span>
|
||||
</td>
|
||||
<td className="muted">{t.hiddenRank ? "Yes" : "No"}</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)} />
|
||||
@@ -123,7 +147,9 @@ export default async function AdminTeams() {
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -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" }}>
|
||||
<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 style={{ textAlign: "left", color: "var(--muted)" }}>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>Username</th>
|
||||
<th>Rank</th>
|
||||
<th>Credits</th>
|
||||
<th>Online</th>
|
||||
<th>Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{users.map((u) => (
|
||||
<tr key={u.id} style={{ borderTop: "1px solid var(--border)" }}>
|
||||
<td>{u.id}</td>
|
||||
<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>{u.online === "1" ? "●" : "○"}</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
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -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,19 +21,43 @@ 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 className="admin-stats">
|
||||
<StatusCard label="Filtered words" value={words.length} icon="🚫" />
|
||||
</div>
|
||||
|
||||
<section className="admin-section">
|
||||
<h2>Add word</h2>
|
||||
<form action={addWord} className="card">
|
||||
<div style={{ display: "flex", gap: "0.5rem", flexWrap: "wrap" }}>
|
||||
<input name="word" placeholder="Word to filter" required maxLength={255} style={{ flex: 1, minWidth: 200 }} />
|
||||
<button type="submit" className="btn btn-primary">Add</button>
|
||||
<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>
|
||||
|
||||
<table style={{ width: "100%", borderCollapse: "collapse" }}>
|
||||
<section className="admin-section">
|
||||
<h2>
|
||||
Words <span className="admin-badge">{words.length}</span>
|
||||
</h2>
|
||||
{words.length === 0 ? (
|
||||
<p className="muted">No filtered words.</p>
|
||||
) : (
|
||||
<div className="card" style={{ padding: 0, overflowX: "auto" }}>
|
||||
<table>
|
||||
<thead>
|
||||
<tr style={{ textAlign: "left", color: "var(--muted)" }}>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>Word</th>
|
||||
<th />
|
||||
@@ -40,20 +65,26 @@ export default async function AdminWordFilter() {
|
||||
</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>
|
||||
<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>
|
||||
<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}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user