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
+2357
-1396
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,47 +40,60 @@ export default async function AdminAchievements() {
|
||||
groups.set(a.category, list);
|
||||
}
|
||||
|
||||
// Distinct achievement names (each name can have multiple level rows).
|
||||
const distinctNames = new Set(achievements.map((a) => a.name)).size;
|
||||
|
||||
return (
|
||||
<main>
|
||||
<h1>Achievements</h1>
|
||||
<p className="muted" style={{ marginTop: 0 }}>
|
||||
<p className="muted" style={{ marginTop: "-0.4rem" }}>
|
||||
Read-only · {achievements.length} rows across {groups.size} categories.
|
||||
</p>
|
||||
|
||||
<div className="admin-stats">
|
||||
<StatusCard label="Achievement rows" value={achievements.length} icon="🏆" />
|
||||
<StatusCard label="Distinct achievements" value={distinctNames} icon="🎖️" />
|
||||
<StatusCard label="Categories" value={groups.size} icon="🗂️" />
|
||||
</div>
|
||||
|
||||
{groups.size === 0 ? (
|
||||
<p className="muted">No achievements found.</p>
|
||||
) : (
|
||||
[...groups.entries()].map(([category, rows]) => (
|
||||
<div key={category} className="card" style={{ marginBottom: "1.5rem" }}>
|
||||
<h3 style={{ marginTop: 0 }}>{category}</h3>
|
||||
<table style={{ width: "100%", borderCollapse: "collapse" }}>
|
||||
<thead>
|
||||
<tr style={{ textAlign: "left", color: "var(--muted)" }}>
|
||||
<th>Name</th>
|
||||
<th>Level</th>
|
||||
<th>Progress needed</th>
|
||||
<th>Reward type</th>
|
||||
<th>Reward amount</th>
|
||||
<th>Points</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((a) => (
|
||||
<tr
|
||||
key={`${a.name}-${a.level}`}
|
||||
style={{ borderTop: "1px solid var(--border)" }}
|
||||
>
|
||||
<td>{a.name}</td>
|
||||
<td>{a.level}</td>
|
||||
<td>{a.progressNeeded}</td>
|
||||
<td>{a.rewardType}</td>
|
||||
<td>{a.rewardAmount}</td>
|
||||
<td>{a.points ?? 0}</td>
|
||||
<section key={category} className="admin-section">
|
||||
<h2>
|
||||
{category}{" "}
|
||||
<span className="admin-badge">{rows.length}</span>
|
||||
</h2>
|
||||
<div className="card" style={{ padding: 0, overflowX: "auto" }}>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Level</th>
|
||||
<th>Progress needed</th>
|
||||
<th>Reward type</th>
|
||||
<th>Reward amount</th>
|
||||
<th>Points</th>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((a) => (
|
||||
<tr key={`${a.name}-${a.level}`}>
|
||||
<td>
|
||||
<strong>{a.name}</strong>
|
||||
</td>
|
||||
<td>{a.level}</td>
|
||||
<td>{a.progressNeeded}</td>
|
||||
<td>{a.rewardType}</td>
|
||||
<td>{a.rewardAmount}</td>
|
||||
<td>{a.points ?? 0}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
))
|
||||
)}
|
||||
</main>
|
||||
|
||||
+50
-32
@@ -1,4 +1,5 @@
|
||||
import Link from "next/link";
|
||||
import { StatusCard } from "@/components/admin/dashboard";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
@@ -14,47 +15,64 @@ export default async function AdminAds() {
|
||||
ads = [];
|
||||
}
|
||||
|
||||
const latest = ads[0]?.createdAt
|
||||
? ads[0].createdAt.toISOString().slice(0, 10)
|
||||
: "—";
|
||||
|
||||
return (
|
||||
<main>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: "1rem" }}>
|
||||
<h1 style={{ flex: 1 }}>Advertisements</h1>
|
||||
<Link href="/admin/ads/new">+ New ad</Link>
|
||||
<Link href="/admin/ads/new" className="btn btn-primary">
|
||||
+ New ad
|
||||
</Link>
|
||||
</div>
|
||||
<p className="muted">
|
||||
<p className="muted" style={{ marginTop: "-0.4rem" }}>
|
||||
Website advertisement banners (<code>website_ads</code>). Each ad is an image URL shown
|
||||
across the site.
|
||||
</p>
|
||||
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Preview</th>
|
||||
<th>Image URL</th>
|
||||
<th>Created</th>
|
||||
<th />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{ads.map((ad) => (
|
||||
<tr key={String(ad.id)}>
|
||||
<td>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img src={ad.image} alt="" style={{ maxHeight: 40, maxWidth: 120 }} />
|
||||
</td>
|
||||
<td className="muted">
|
||||
<code>{ad.image}</code>
|
||||
</td>
|
||||
<td className="muted">
|
||||
{ad.createdAt ? ad.createdAt.toISOString().slice(0, 10) : ""}
|
||||
</td>
|
||||
<td>
|
||||
<Link href={`/admin/ads/${ad.id}`}>Edit</Link>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
{ads.length === 0 ? <p className="muted">No advertisements yet.</p> : null}
|
||||
<div className="admin-stats">
|
||||
<StatusCard label="Ads" value={ads.length} icon="🖼️" />
|
||||
<StatusCard label="Latest" value={latest} hint="most recent ad" icon="🕒" />
|
||||
</div>
|
||||
|
||||
<section className="admin-section">
|
||||
<h2>All ads</h2>
|
||||
{ads.length === 0 ? (
|
||||
<p className="muted">No advertisements yet.</p>
|
||||
) : (
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Preview</th>
|
||||
<th>Image URL</th>
|
||||
<th>Created</th>
|
||||
<th />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{ads.map((ad) => (
|
||||
<tr key={String(ad.id)}>
|
||||
<td>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img src={ad.image} alt="" style={{ maxHeight: 40, maxWidth: 120 }} />
|
||||
</td>
|
||||
<td className="muted">
|
||||
<code>{ad.image}</code>
|
||||
</td>
|
||||
<td className="muted" style={{ whiteSpace: "nowrap" }}>
|
||||
{ad.createdAt ? ad.createdAt.toISOString().slice(0, 10) : "—"}
|
||||
</td>
|
||||
<td>
|
||||
<Link href={`/admin/ads/${ad.id}`}>Edit</Link>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -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,47 +46,62 @@ export default async function AdminApplications() {
|
||||
return (
|
||||
<main>
|
||||
<h1>Staff Applications</h1>
|
||||
<p className="muted" style={{ marginTop: "-0.4rem" }}>
|
||||
Pending applications from players requesting a staff rank.
|
||||
</p>
|
||||
|
||||
{applications.length === 0 ? (
|
||||
<p className="muted">No pending applications.</p>
|
||||
) : (
|
||||
<div className="grid cols-2">
|
||||
{applications.map((a) => {
|
||||
const username = userMap.get(a.userId);
|
||||
return (
|
||||
<article key={String(a.id)} className="card">
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "baseline",
|
||||
gap: "0.5rem",
|
||||
}}
|
||||
>
|
||||
<h3 style={{ margin: "0 0 0.25rem" }}>
|
||||
{username ? (
|
||||
<Link href={`/admin/users/${a.userId}`}>{username}</Link>
|
||||
) : (
|
||||
<>User #{a.userId}</>
|
||||
)}
|
||||
</h3>
|
||||
<span className="muted">Rank {a.rankId}</span>
|
||||
</div>
|
||||
<p className="muted" style={{ marginTop: 0 }}>
|
||||
{formatDate(a.createdAt)}
|
||||
</p>
|
||||
<p style={{ whiteSpace: "pre-wrap", margin: "0 0 0.75rem" }}>{a.content}</p>
|
||||
<form action={dismissApplication}>
|
||||
<input type="hidden" name="id" value={String(a.id)} />
|
||||
<button type="submit" className="btn btn-danger">
|
||||
Dismiss
|
||||
</button>
|
||||
</form>
|
||||
</article>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
<div className="admin-stats">
|
||||
<StatusCard
|
||||
label="Pending applications"
|
||||
value={applications.length}
|
||||
state={applications.length > 0 ? "warn" : "ok"}
|
||||
icon="📨"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<section className="admin-section">
|
||||
<h2>Applications ({applications.length})</h2>
|
||||
{applications.length === 0 ? (
|
||||
<p className="muted">No pending applications.</p>
|
||||
) : (
|
||||
<div className="grid cols-2">
|
||||
{applications.map((a) => {
|
||||
const username = userMap.get(a.userId);
|
||||
return (
|
||||
<article key={String(a.id)} className="card">
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "baseline",
|
||||
gap: "0.5rem",
|
||||
}}
|
||||
>
|
||||
<h3 style={{ margin: "0 0 0.25rem" }}>
|
||||
{username ? (
|
||||
<Link href={`/admin/users/${a.userId}`}>{username}</Link>
|
||||
) : (
|
||||
<>User #{a.userId}</>
|
||||
)}
|
||||
</h3>
|
||||
<span className="admin-badge">Rank {a.rankId}</span>
|
||||
</div>
|
||||
<p className="muted" style={{ marginTop: 0 }}>
|
||||
{formatDate(a.createdAt)}
|
||||
</p>
|
||||
<p style={{ whiteSpace: "pre-wrap", margin: "0 0 0.75rem" }}>{a.content}</p>
|
||||
<form action={dismissApplication}>
|
||||
<input type="hidden" name="id" value={String(a.id)} />
|
||||
<button type="submit" className="btn btn-danger">
|
||||
Dismiss
|
||||
</button>
|
||||
</form>
|
||||
</article>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import Link from "next/link";
|
||||
import { StatusCard } from "@/components/admin/dashboard";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
@@ -14,32 +15,58 @@ export default async function AdminArticles() {
|
||||
})
|
||||
.catch(() => []);
|
||||
|
||||
const latest = articles[0]?.createdAt
|
||||
? articles[0].createdAt.toISOString().slice(0, 10)
|
||||
: "—";
|
||||
|
||||
return (
|
||||
<main>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: "1rem" }}>
|
||||
<h1 style={{ flex: 1 }}>Articles</h1>
|
||||
<Link href="/admin/articles/new">+ New article</Link>
|
||||
<Link href="/admin/articles/new" className="btn btn-primary">
|
||||
+ New article
|
||||
</Link>
|
||||
</div>
|
||||
<table style={{ width: "100%", borderCollapse: "collapse" }}>
|
||||
<thead>
|
||||
<tr style={{ textAlign: "left", color: "var(--muted)" }}>
|
||||
<th>Title</th>
|
||||
<th>Slug</th>
|
||||
<th>Created</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{articles.map((a) => (
|
||||
<tr key={String(a.id)} style={{ borderTop: "1px solid var(--border)" }}>
|
||||
<td>
|
||||
<Link href={`/admin/articles/${a.id}`}>{a.title}</Link>
|
||||
</td>
|
||||
<td className="muted">{a.slug}</td>
|
||||
<td className="muted">{a.createdAt ? a.createdAt.toISOString().slice(0, 10) : ""}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
<p className="muted" style={{ marginTop: "-0.4rem" }}>
|
||||
News articles (<code>website_articles</code>) shown on the public site. 50 most recent.
|
||||
</p>
|
||||
|
||||
<div className="admin-stats">
|
||||
<StatusCard label="Articles shown" value={articles.length} icon="📰" />
|
||||
<StatusCard label="Latest" value={latest} hint="most recent post" icon="🕒" />
|
||||
</div>
|
||||
|
||||
<section className="admin-section">
|
||||
<h2>Recent articles</h2>
|
||||
{articles.length === 0 ? (
|
||||
<p className="muted">No articles yet.</p>
|
||||
) : (
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Title</th>
|
||||
<th>Slug</th>
|
||||
<th>Created</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{articles.map((a) => (
|
||||
<tr key={String(a.id)}>
|
||||
<td>
|
||||
<Link href={`/admin/articles/${a.id}`}>{a.title}</Link>
|
||||
</td>
|
||||
<td className="muted">
|
||||
<code>{a.slug}</code>
|
||||
</td>
|
||||
<td className="muted" style={{ whiteSpace: "nowrap" }}>
|
||||
{a.createdAt ? a.createdAt.toISOString().slice(0, 10) : "—"}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
+79
-29
@@ -1,8 +1,13 @@
|
||||
import { createBan, liftBan } from "@/actions/admin-bans";
|
||||
import { StatusCard } from "@/components/admin/dashboard";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
function fromUnix(ts: number): string {
|
||||
return new Date(ts * 1000).toISOString().slice(0, 16).replace("T", " ");
|
||||
}
|
||||
|
||||
export default async function AdminBans() {
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
let bans: Awaited<ReturnType<typeof prisma.ban.findMany>> = [];
|
||||
@@ -16,9 +21,32 @@ export default async function AdminBans() {
|
||||
bans = [];
|
||||
}
|
||||
|
||||
// banExpire of 0 means permanent in the emulator schema.
|
||||
const permanent = bans.filter((b) => b.banExpire === 0).length;
|
||||
const accountBans = bans.filter((b) => b.type === "account").length;
|
||||
|
||||
return (
|
||||
<main>
|
||||
<h1>Bans</h1>
|
||||
<p className="muted" style={{ marginTop: "-0.4rem" }}>
|
||||
Active bans only (expiry in the future) · newest first.
|
||||
</p>
|
||||
|
||||
<div className="admin-stats">
|
||||
<StatusCard
|
||||
label="Active bans"
|
||||
value={bans.length}
|
||||
state={bans.length > 0 ? "warn" : "ok"}
|
||||
icon="🔨"
|
||||
/>
|
||||
<StatusCard
|
||||
label="Permanent"
|
||||
value={permanent}
|
||||
state={permanent > 0 ? "danger" : "neutral"}
|
||||
icon="⛔"
|
||||
/>
|
||||
<StatusCard label="Account bans" value={accountBans} icon="👤" />
|
||||
</div>
|
||||
|
||||
<form action={createBan} className="card" style={{ marginBottom: "1.5rem" }}>
|
||||
<h3 style={{ marginTop: 0 }}>New ban</h3>
|
||||
@@ -32,38 +60,60 @@ export default async function AdminBans() {
|
||||
</select>
|
||||
<input name="hours" type="number" min={0} placeholder="Hours (0 = perm)" />
|
||||
<input name="reason" placeholder="Reason" style={{ flex: 1, minWidth: 160 }} />
|
||||
<button type="submit">Ban</button>
|
||||
<button type="submit" className="btn btn-danger">
|
||||
Ban
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<table style={{ width: "100%", borderCollapse: "collapse" }}>
|
||||
<thead>
|
||||
<tr style={{ textAlign: "left", color: "var(--muted)" }}>
|
||||
<th>User ID</th>
|
||||
<th>Type</th>
|
||||
<th>Reason</th>
|
||||
<th>Expires</th>
|
||||
<th />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{bans.map((b) => (
|
||||
<tr key={b.id} style={{ borderTop: "1px solid var(--border)" }}>
|
||||
<td>{b.userId}</td>
|
||||
<td>{b.type}</td>
|
||||
<td>{b.banReason}</td>
|
||||
<td className="muted">{new Date(b.banExpire * 1000).toISOString().slice(0, 16).replace("T", " ")}</td>
|
||||
<td>
|
||||
<form action={liftBan}>
|
||||
<input type="hidden" name="id" value={b.id} />
|
||||
<button type="submit">Lift</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
{bans.length === 0 ? <p className="muted">No active bans.</p> : null}
|
||||
<section className="admin-section">
|
||||
<h2>Active bans ({bans.length})</h2>
|
||||
{bans.length === 0 ? (
|
||||
<p className="muted">No active bans.</p>
|
||||
) : (
|
||||
<div style={{ overflowX: "auto" }}>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>User ID</th>
|
||||
<th>Type</th>
|
||||
<th>Reason</th>
|
||||
<th>Expires</th>
|
||||
<th />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{bans.map((b) => (
|
||||
<tr key={b.id}>
|
||||
<td>{b.userId}</td>
|
||||
<td>
|
||||
<span className="admin-badge">{b.type}</span>
|
||||
</td>
|
||||
<td>{b.banReason || "—"}</td>
|
||||
<td>
|
||||
{b.banExpire === 0 ? (
|
||||
<span className="admin-badge danger">permanent</span>
|
||||
) : (
|
||||
<span className="muted" style={{ whiteSpace: "nowrap" }}>
|
||||
{fromUnix(b.banExpire)}
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td>
|
||||
<form action={liftBan}>
|
||||
<input type="hidden" name="id" value={b.id} />
|
||||
<button type="submit" className="btn btn-outline">
|
||||
Lift
|
||||
</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -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,58 +40,83 @@ export default async function AdminCatalog() {
|
||||
const captionById = new Map<number, string>();
|
||||
for (const p of pages) captionById.set(p.id, p.caption);
|
||||
|
||||
const visibleCount = pages.filter(
|
||||
(p) => p.visible === "1" && p.enabled === "1",
|
||||
).length;
|
||||
const hiddenCount = pages.length - visibleCount;
|
||||
const rootCount = pages.filter((p) => p.parentId <= 0).length;
|
||||
|
||||
return (
|
||||
<main>
|
||||
<h1>Catalog</h1>
|
||||
<p className="muted">Browse catalog pages and the items they contain. Read-only.</p>
|
||||
|
||||
{pages.length === 0 ? (
|
||||
<p className="muted">No catalog pages found.</p>
|
||||
) : (
|
||||
<table style={{ width: "100%", borderCollapse: "collapse" }}>
|
||||
<thead>
|
||||
<tr style={{ textAlign: "left", color: "var(--muted)" }}>
|
||||
<th>ID</th>
|
||||
<th>Caption</th>
|
||||
<th>Parent</th>
|
||||
<th>Layout</th>
|
||||
<th>Min rank</th>
|
||||
<th>Order</th>
|
||||
<th>Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{pages.map((p) => {
|
||||
const parentName =
|
||||
p.parentId > 0 ? captionById.get(p.parentId) : null;
|
||||
const hidden = p.visible !== "1" || p.enabled !== "1";
|
||||
return (
|
||||
<tr key={p.id} style={{ borderTop: "1px solid var(--border)" }}>
|
||||
<td>{p.id}</td>
|
||||
<td>
|
||||
<Link href={`/admin/catalog/${p.id}`}>{p.caption}</Link>
|
||||
</td>
|
||||
<td className="muted">
|
||||
{p.parentId <= 0
|
||||
? "—"
|
||||
: parentName
|
||||
? `${parentName} (#${p.parentId})`
|
||||
: `#${p.parentId}`}
|
||||
</td>
|
||||
<td className="muted">{p.pageLayout}</td>
|
||||
<td>{p.minRank}</td>
|
||||
<td>{p.orderNum}</td>
|
||||
<td className="muted">{hidden ? "Hidden" : "Visible"}</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
|
||||
<p className="muted" style={{ marginTop: "1rem" }}>
|
||||
{pages.length} page{pages.length === 1 ? "" : "s"}
|
||||
<p className="muted" style={{ marginTop: "-0.4rem" }}>
|
||||
Browse catalog pages and the items they contain. Read-only.
|
||||
</p>
|
||||
|
||||
<div className="admin-stats">
|
||||
<StatusCard label="Pages" value={pages.length} icon="📚" />
|
||||
<StatusCard label="Visible" value={visibleCount} state="ok" icon="👁️" />
|
||||
<StatusCard
|
||||
label="Hidden"
|
||||
value={hiddenCount}
|
||||
state={hiddenCount > 0 ? "warn" : "neutral"}
|
||||
icon="🚫"
|
||||
/>
|
||||
<StatusCard label="Root pages" value={rootCount} icon="🗂️" />
|
||||
</div>
|
||||
|
||||
<section className="admin-section">
|
||||
<h2>Pages ({pages.length})</h2>
|
||||
{pages.length === 0 ? (
|
||||
<p className="muted">No catalog pages found.</p>
|
||||
) : (
|
||||
<div style={{ overflowX: "auto" }}>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>Caption</th>
|
||||
<th>Parent</th>
|
||||
<th>Layout</th>
|
||||
<th>Min rank</th>
|
||||
<th>Order</th>
|
||||
<th>Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{pages.map((p) => {
|
||||
const parentName =
|
||||
p.parentId > 0 ? captionById.get(p.parentId) : null;
|
||||
const hidden = p.visible !== "1" || p.enabled !== "1";
|
||||
return (
|
||||
<tr key={p.id}>
|
||||
<td className="muted">{p.id}</td>
|
||||
<td>
|
||||
<Link href={`/admin/catalog/${p.id}`}>{p.caption}</Link>
|
||||
</td>
|
||||
<td className="muted">
|
||||
{p.parentId <= 0
|
||||
? "—"
|
||||
: parentName
|
||||
? `${parentName} (#${p.parentId})`
|
||||
: `#${p.parentId}`}
|
||||
</td>
|
||||
<td className="muted">{p.pageLayout}</td>
|
||||
<td>{p.minRank}</td>
|
||||
<td>{p.orderNum}</td>
|
||||
<td>
|
||||
<span className={`admin-badge ${hidden ? "danger" : "ok"}`}>
|
||||
{hidden ? "Hidden" : "Visible"}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { deletePermission, upsertPermission } from "@/actions/admin-housekeeping";
|
||||
import { StatusCard } from "@/components/admin/dashboard";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
@@ -15,65 +16,91 @@ export default async function AdminHousekeeping() {
|
||||
permissions = [];
|
||||
}
|
||||
|
||||
const ranks = permissions.map((p) => p.minRank);
|
||||
const maxRank = ranks.length ? Math.max(...ranks) : 0;
|
||||
|
||||
return (
|
||||
<main>
|
||||
<h1>Housekeeping Permissions</h1>
|
||||
<p className="muted">
|
||||
<p className="muted" style={{ marginTop: "-0.4rem" }}>
|
||||
Minimum rank required to access each housekeeping permission key. Saving an
|
||||
existing key overwrites its min rank and description.
|
||||
</p>
|
||||
|
||||
<form action={upsertPermission} className="card" style={{ marginBottom: "1.5rem" }}>
|
||||
<h3 style={{ marginTop: 0 }}>Add / overwrite permission</h3>
|
||||
<div style={{ display: "flex", gap: "0.5rem", flexWrap: "wrap" }}>
|
||||
<input name="permission" placeholder="permission key" required style={{ minWidth: 200 }} />
|
||||
<input name="minRank" type="number" min={0} placeholder="Min rank" required style={{ width: 110 }} />
|
||||
<input name="description" placeholder="description" style={{ flex: 1, minWidth: 160 }} />
|
||||
<button type="submit" className="btn btn-primary">Save</button>
|
||||
</div>
|
||||
</form>
|
||||
<div className="admin-stats">
|
||||
<StatusCard label="Permissions" value={permissions.length} icon="🔑" />
|
||||
<StatusCard label="Highest min rank" value={maxRank} icon="⬆️" />
|
||||
</div>
|
||||
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Permission</th>
|
||||
<th>Description</th>
|
||||
<th style={{ width: 200 }}>Min rank</th>
|
||||
<th style={{ width: 80 }} />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{permissions.map((p) => (
|
||||
<tr key={String(p.id)}>
|
||||
<td><strong>{p.permission}</strong></td>
|
||||
<td className="muted">{p.description ?? "—"}</td>
|
||||
<td>
|
||||
<form action={upsertPermission} style={{ display: "flex", gap: "0.5rem" }}>
|
||||
<input type="hidden" name="permission" value={p.permission} />
|
||||
<input type="hidden" name="description" value={p.description ?? ""} />
|
||||
<input
|
||||
name="minRank"
|
||||
type="number"
|
||||
min={0}
|
||||
defaultValue={p.minRank}
|
||||
style={{ width: 90 }}
|
||||
/>
|
||||
<button type="submit" className="btn btn-outline">Save</button>
|
||||
</form>
|
||||
</td>
|
||||
<td>
|
||||
<form action={deletePermission}>
|
||||
<input type="hidden" name="id" value={String(p.id)} />
|
||||
<button type="submit" className="btn btn-danger">✕</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
{permissions.length === 0 ? (
|
||||
<p className="muted">No housekeeping permissions defined yet.</p>
|
||||
) : null}
|
||||
<section className="admin-section">
|
||||
<h2>Add / overwrite permission</h2>
|
||||
<form action={upsertPermission} className="card">
|
||||
<div style={{ display: "flex", gap: "0.5rem", flexWrap: "wrap" }}>
|
||||
<input name="permission" placeholder="permission key" required style={{ minWidth: 200 }} />
|
||||
<input name="minRank" type="number" min={0} placeholder="Min rank" required style={{ width: 110 }} />
|
||||
<input name="description" placeholder="description" style={{ flex: 1, minWidth: 160 }} />
|
||||
<button type="submit" className="btn btn-primary">
|
||||
Save
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section className="admin-section">
|
||||
<h2>
|
||||
Permissions <span className="admin-badge">{permissions.length}</span>
|
||||
</h2>
|
||||
{permissions.length === 0 ? (
|
||||
<p className="muted">No housekeeping permissions defined yet.</p>
|
||||
) : (
|
||||
<div className="card" style={{ padding: 0, overflowX: "auto" }}>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Permission</th>
|
||||
<th>Description</th>
|
||||
<th style={{ width: 200 }}>Min rank</th>
|
||||
<th style={{ width: 80 }} />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{permissions.map((p) => (
|
||||
<tr key={String(p.id)}>
|
||||
<td>
|
||||
<strong>{p.permission}</strong>
|
||||
</td>
|
||||
<td className="muted">{p.description ?? "—"}</td>
|
||||
<td>
|
||||
<form action={upsertPermission} style={{ display: "flex", gap: "0.5rem" }}>
|
||||
<input type="hidden" name="permission" value={p.permission} />
|
||||
<input type="hidden" name="description" value={p.description ?? ""} />
|
||||
<input
|
||||
name="minRank"
|
||||
type="number"
|
||||
min={0}
|
||||
defaultValue={p.minRank}
|
||||
style={{ width: 90 }}
|
||||
/>
|
||||
<button type="submit" className="btn btn-outline">
|
||||
Save
|
||||
</button>
|
||||
</form>
|
||||
</td>
|
||||
<td>
|
||||
<form action={deletePermission}>
|
||||
<input type="hidden" name="id" value={String(p.id)} />
|
||||
<button type="submit" className="btn btn-danger">
|
||||
✕
|
||||
</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
+90
-61
@@ -4,6 +4,7 @@ import {
|
||||
deleteBlacklist,
|
||||
deleteWhitelist,
|
||||
} from "@/actions/admin-ip";
|
||||
import { StatusCard } from "@/components/admin/dashboard";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
@@ -16,7 +17,7 @@ type IpRow = {
|
||||
};
|
||||
|
||||
function formatDate(d: Date | null): string {
|
||||
return d ? d.toISOString().slice(0, 16).replace("T", " ") : "";
|
||||
return d ? d.toISOString().slice(0, 16).replace("T", " ") : "—";
|
||||
}
|
||||
|
||||
export default async function AdminIp() {
|
||||
@@ -44,10 +45,28 @@ export default async function AdminIp() {
|
||||
return (
|
||||
<main>
|
||||
<h1>IP Management</h1>
|
||||
<p className="muted" style={{ marginTop: "-0.4rem" }}>
|
||||
Allow or block IP addresses (and ASNs) at the website edge.
|
||||
</p>
|
||||
|
||||
<div className="admin-stats">
|
||||
<StatusCard
|
||||
label="Whitelisted"
|
||||
value={whitelist.length}
|
||||
state={whitelist.length > 0 ? "ok" : "neutral"}
|
||||
icon="✅"
|
||||
/>
|
||||
<StatusCard
|
||||
label="Blacklisted"
|
||||
value={blacklist.length}
|
||||
state={blacklist.length > 0 ? "danger" : "neutral"}
|
||||
icon="🚫"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid cols-2">
|
||||
{/* ── Whitelist ─────────────────────────────── */}
|
||||
<section>
|
||||
{/* ── Whitelist ──────────────────── */}
|
||||
<section className="admin-section">
|
||||
<h2>Whitelist</h2>
|
||||
|
||||
<form action={addWhitelist} className="card" style={{ marginBottom: "1rem" }}>
|
||||
@@ -65,38 +84,43 @@ export default async function AdminIp() {
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>IP</th>
|
||||
<th>ASN</th>
|
||||
<th>Added</th>
|
||||
<th />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{whitelist.map((w) => (
|
||||
<tr key={String(w.id)}>
|
||||
<td>{w.ipAddress}</td>
|
||||
<td className="muted">{w.asn ?? "—"}</td>
|
||||
<td className="muted">{formatDate(w.createdAt)}</td>
|
||||
<td>
|
||||
<form action={deleteWhitelist}>
|
||||
<input type="hidden" name="id" value={String(w.id)} />
|
||||
<button type="submit" className="btn btn-danger">
|
||||
Remove
|
||||
</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
{whitelist.length === 0 ? <p className="muted">No whitelisted IPs.</p> : null}
|
||||
{whitelist.length === 0 ? (
|
||||
<p className="muted">No whitelisted IPs.</p>
|
||||
) : (
|
||||
<div style={{ overflowX: "auto" }}>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>IP</th>
|
||||
<th>ASN</th>
|
||||
<th>Added</th>
|
||||
<th />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{whitelist.map((w) => (
|
||||
<tr key={String(w.id)}>
|
||||
<td>{w.ipAddress}</td>
|
||||
<td className="muted">{w.asn ?? "—"}</td>
|
||||
<td className="muted">{formatDate(w.createdAt)}</td>
|
||||
<td>
|
||||
<form action={deleteWhitelist}>
|
||||
<input type="hidden" name="id" value={String(w.id)} />
|
||||
<button type="submit" className="btn btn-danger">
|
||||
Remove
|
||||
</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* ── Blacklist ─────────────────────────────── */}
|
||||
<section>
|
||||
{/* ── Blacklist ──────────────────── */}
|
||||
<section className="admin-section">
|
||||
<h2>Blacklist</h2>
|
||||
|
||||
<form action={addBlacklist} className="card" style={{ marginBottom: "1rem" }}>
|
||||
@@ -114,34 +138,39 @@ export default async function AdminIp() {
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>IP</th>
|
||||
<th>ASN</th>
|
||||
<th>Added</th>
|
||||
<th />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{blacklist.map((b) => (
|
||||
<tr key={String(b.id)}>
|
||||
<td>{b.ipAddress}</td>
|
||||
<td className="muted">{b.asn ?? "—"}</td>
|
||||
<td className="muted">{formatDate(b.createdAt)}</td>
|
||||
<td>
|
||||
<form action={deleteBlacklist}>
|
||||
<input type="hidden" name="id" value={String(b.id)} />
|
||||
<button type="submit" className="btn btn-danger">
|
||||
Remove
|
||||
</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
{blacklist.length === 0 ? <p className="muted">No blacklisted IPs.</p> : null}
|
||||
{blacklist.length === 0 ? (
|
||||
<p className="muted">No blacklisted IPs.</p>
|
||||
) : (
|
||||
<div style={{ overflowX: "auto" }}>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>IP</th>
|
||||
<th>ASN</th>
|
||||
<th>Added</th>
|
||||
<th />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{blacklist.map((b) => (
|
||||
<tr key={String(b.id)}>
|
||||
<td>{b.ipAddress}</td>
|
||||
<td className="muted">{b.asn ?? "—"}</td>
|
||||
<td className="muted">{formatDate(b.createdAt)}</td>
|
||||
<td>
|
||||
<form action={deleteBlacklist}>
|
||||
<input type="hidden" name="id" value={String(b.id)} />
|
||||
<button type="submit" className="btn btn-danger">
|
||||
Remove
|
||||
</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
@@ -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,111 +35,120 @@ export default async function AdminMaintenance() {
|
||||
return (
|
||||
<main>
|
||||
<h1>Maintenance Mode</h1>
|
||||
<p className="muted">
|
||||
<p className="muted" style={{ marginTop: "-0.4rem" }}>
|
||||
While maintenance is enabled, only staff (and users at or above the
|
||||
minimum login rank) can log into the hotel. These values are stored in
|
||||
<code> website_settings</code> and read live by the login flow.
|
||||
</p>
|
||||
|
||||
{/* ── Current state ─────────────────────────────────────── */}
|
||||
<div
|
||||
className="card"
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "0.75rem",
|
||||
marginBottom: "1.5rem",
|
||||
}}
|
||||
>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
style={{
|
||||
width: 12,
|
||||
height: 12,
|
||||
borderRadius: 999,
|
||||
flexShrink: 0,
|
||||
background: enabled
|
||||
? "var(--color-danger)"
|
||||
: "var(--color-secondary)",
|
||||
}}
|
||||
<div className="admin-stats">
|
||||
<StatusCard
|
||||
label="Maintenance"
|
||||
value={enabled ? "ENABLED" : "Disabled"}
|
||||
state={enabled ? "danger" : "ok"}
|
||||
hint={enabled ? "Hotel is locked down" : "Hotel is open to everyone"}
|
||||
icon={enabled ? "🚧" : "🟢"}
|
||||
/>
|
||||
<StatusCard
|
||||
label="Min login rank"
|
||||
value={minRankValue}
|
||||
hint="During maintenance"
|
||||
icon="🛡️"
|
||||
/>
|
||||
<StatusCard
|
||||
label="Splash message"
|
||||
value={message ? "Set" : "Empty"}
|
||||
state={message ? "ok" : "neutral"}
|
||||
icon="💬"
|
||||
/>
|
||||
<div>
|
||||
<strong>
|
||||
Maintenance is currently {enabled ? "ENABLED" : "disabled"}.
|
||||
</strong>
|
||||
<p className="muted" style={{ margin: "0.2rem 0 0" }}>
|
||||
{enabled
|
||||
? `Only users with rank ${minRankValue} or higher can log in right now.`
|
||||
: "The hotel is open to everyone."}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Current state ─────────────────────────────────────── */}
|
||||
<section className="admin-section">
|
||||
<h2>Current state</h2>
|
||||
<div className="card" style={{ display: "flex", alignItems: "center", gap: "0.75rem" }}>
|
||||
<span className={`admin-badge ${enabled ? "danger" : "ok"}`}>
|
||||
{enabled ? "ENABLED" : "disabled"}
|
||||
</span>
|
||||
<div>
|
||||
<strong>
|
||||
Maintenance is currently {enabled ? "ENABLED" : "disabled"}.
|
||||
</strong>
|
||||
<p className="muted" style={{ margin: "0.2rem 0 0" }}>
|
||||
{enabled
|
||||
? `Only users with rank ${minRankValue} or higher can log in right now.`
|
||||
: "The hotel is open to everyone."}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ── Edit form ─────────────────────────────────────────── */}
|
||||
<form action={saveMaintenance} className="card">
|
||||
<h3 style={{ marginTop: 0 }}>Settings</h3>
|
||||
|
||||
<label
|
||||
htmlFor="mt-enabled"
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "0.5rem",
|
||||
marginBottom: "1rem",
|
||||
}}
|
||||
>
|
||||
<input
|
||||
id="mt-enabled"
|
||||
type="checkbox"
|
||||
name="enabled"
|
||||
value="1"
|
||||
defaultChecked={enabled}
|
||||
style={{ width: "auto" }}
|
||||
/>
|
||||
<span>Enable maintenance mode</span>
|
||||
</label>
|
||||
|
||||
<div style={{ marginBottom: "1rem", maxWidth: 220 }}>
|
||||
<section className="admin-section">
|
||||
<h2>Settings</h2>
|
||||
<form action={saveMaintenance} className="card">
|
||||
<label
|
||||
htmlFor="mt-min-rank"
|
||||
className="muted"
|
||||
style={{ display: "block", marginBottom: "0.3rem" }}
|
||||
htmlFor="mt-enabled"
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "0.5rem",
|
||||
marginBottom: "1rem",
|
||||
}}
|
||||
>
|
||||
Minimum login rank during maintenance
|
||||
<input
|
||||
id="mt-enabled"
|
||||
type="checkbox"
|
||||
name="enabled"
|
||||
value="1"
|
||||
defaultChecked={enabled}
|
||||
style={{ width: "auto" }}
|
||||
/>
|
||||
<span>Enable maintenance mode</span>
|
||||
</label>
|
||||
<input
|
||||
id="mt-min-rank"
|
||||
type="number"
|
||||
name="min_rank"
|
||||
min={0}
|
||||
step={1}
|
||||
defaultValue={minRankValue}
|
||||
style={{ width: "100%" }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div style={{ marginBottom: "1rem" }}>
|
||||
<label
|
||||
htmlFor="mt-message"
|
||||
className="muted"
|
||||
style={{ display: "block", marginBottom: "0.3rem" }}
|
||||
>
|
||||
Maintenance message (shown on the splash; basic HTML allowed)
|
||||
</label>
|
||||
<textarea
|
||||
id="mt-message"
|
||||
name="message"
|
||||
rows={8}
|
||||
defaultValue={message}
|
||||
placeholder="We're working hard on the hotel — back soon!"
|
||||
style={{ width: "100%", resize: "vertical" }}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ marginBottom: "1rem", maxWidth: 220 }}>
|
||||
<label
|
||||
htmlFor="mt-min-rank"
|
||||
className="muted"
|
||||
style={{ display: "block", marginBottom: "0.3rem" }}
|
||||
>
|
||||
Minimum login rank during maintenance
|
||||
</label>
|
||||
<input
|
||||
id="mt-min-rank"
|
||||
type="number"
|
||||
name="min_rank"
|
||||
min={0}
|
||||
step={1}
|
||||
defaultValue={minRankValue}
|
||||
style={{ width: "100%" }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button type="submit" className="btn btn-primary">
|
||||
Save changes
|
||||
</button>
|
||||
</form>
|
||||
<div style={{ marginBottom: "1rem" }}>
|
||||
<label
|
||||
htmlFor="mt-message"
|
||||
className="muted"
|
||||
style={{ display: "block", marginBottom: "0.3rem" }}
|
||||
>
|
||||
Maintenance message (shown on the splash; basic HTML allowed)
|
||||
</label>
|
||||
<textarea
|
||||
id="mt-message"
|
||||
name="message"
|
||||
rows={8}
|
||||
defaultValue={message}
|
||||
placeholder="We're working hard on the hotel — back soon!"
|
||||
style={{ width: "100%", resize: "vertical" }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button type="submit" className="btn btn-primary">
|
||||
Save changes
|
||||
</button>
|
||||
</form>
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import { StatusCard } from "@/components/admin/dashboard";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
@@ -25,6 +26,13 @@ type PublicRow = {
|
||||
visible: string;
|
||||
};
|
||||
|
||||
// Reusable yes/no pill so emulator boolean-ish "1"/"0" columns read consistently.
|
||||
function YesNo({ on }: { on: boolean }) {
|
||||
return (
|
||||
<span className={`admin-badge ${on ? "ok" : "danger"}`}>{on ? "Yes" : "No"}</span>
|
||||
);
|
||||
}
|
||||
|
||||
export default async function AdminNavigation() {
|
||||
let flatcats: Flatcat[] = [];
|
||||
try {
|
||||
@@ -89,97 +97,121 @@ export default async function AdminNavigation() {
|
||||
}
|
||||
}
|
||||
|
||||
const publicFlatcats = flatcats.filter((c) => c.public === "1").length;
|
||||
const visiblePublics = publics.filter((p) => p.visible === "1").length;
|
||||
|
||||
return (
|
||||
<main>
|
||||
<h1>Navigator</h1>
|
||||
<p className="muted" style={{ marginTop: 0 }}>
|
||||
<p className="muted" style={{ marginTop: "-0.4rem" }}>
|
||||
Read-only · the navigator config is owned by the emulator. Edit it in your
|
||||
emulator tooling, not here.
|
||||
</p>
|
||||
|
||||
{/* ── navigator_flatcats ───────────────────────────────── */}
|
||||
<div className="card" style={{ padding: 0, overflowX: "auto", marginBottom: "1.5rem" }}>
|
||||
<div style={{ padding: "1.1rem 1.25rem 0" }}>
|
||||
<h3 style={{ margin: 0 }}>Flat categories</h3>
|
||||
<p className="muted" style={{ margin: "0.25rem 0 0" }}>
|
||||
<code>navigator_flatcats</code> · {flatcats.length} rows
|
||||
</p>
|
||||
</div>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>Caption</th>
|
||||
<th>Caption (save)</th>
|
||||
<th>Min rank</th>
|
||||
<th>Max users</th>
|
||||
<th>Can trade</th>
|
||||
<th>Public</th>
|
||||
<th>List type</th>
|
||||
<th>Order</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{flatcats.map((c) => (
|
||||
<tr key={c.id}>
|
||||
<td>{c.id}</td>
|
||||
<td>{c.caption || "—"}</td>
|
||||
<td>{c.captionSave || "—"}</td>
|
||||
<td>{c.minRank}</td>
|
||||
<td>{c.maxUserCount}</td>
|
||||
<td>{c.canTrade === "1" ? "Yes" : "No"}</td>
|
||||
<td>{c.public === "1" ? "Yes" : "No"}</td>
|
||||
<td>{c.listType}</td>
|
||||
<td>{c.orderNum}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
{flatcats.length === 0 ? (
|
||||
<p className="muted" style={{ padding: "0 1.25rem 1.1rem" }}>
|
||||
No flat categories found.
|
||||
</p>
|
||||
) : null}
|
||||
<div className="admin-stats">
|
||||
<StatusCard label="Flat categories" value={flatcats.length} icon="📂" />
|
||||
<StatusCard
|
||||
label="Public (flatcats)"
|
||||
value={publicFlatcats}
|
||||
hint="marked public"
|
||||
icon="🌐"
|
||||
/>
|
||||
<StatusCard label="Public rooms" value={publics.length} icon="🚪" />
|
||||
<StatusCard
|
||||
label="Visible rooms"
|
||||
value={visiblePublics}
|
||||
state={visiblePublics > 0 ? "ok" : "neutral"}
|
||||
icon="👁️"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* ── navigator_flatcats ───────────────────────────────── */}
|
||||
<section className="admin-section">
|
||||
<h2>Flat categories</h2>
|
||||
<p className="muted" style={{ marginTop: "-0.4rem" }}>
|
||||
<code>navigator_flatcats</code> · {flatcats.length} rows
|
||||
</p>
|
||||
{flatcats.length === 0 ? (
|
||||
<p className="muted">No flat categories found.</p>
|
||||
) : (
|
||||
<div style={{ overflowX: "auto" }}>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>Caption</th>
|
||||
<th>Caption (save)</th>
|
||||
<th>Min rank</th>
|
||||
<th>Max users</th>
|
||||
<th>Can trade</th>
|
||||
<th>Public</th>
|
||||
<th>List type</th>
|
||||
<th>Order</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{flatcats.map((c) => (
|
||||
<tr key={c.id}>
|
||||
<td className="muted">{c.id}</td>
|
||||
<td>{c.caption || "—"}</td>
|
||||
<td className="muted">{c.captionSave || "—"}</td>
|
||||
<td>{c.minRank}</td>
|
||||
<td>{c.maxUserCount}</td>
|
||||
<td>
|
||||
<YesNo on={c.canTrade === "1"} />
|
||||
</td>
|
||||
<td>
|
||||
<YesNo on={c.public === "1"} />
|
||||
</td>
|
||||
<td>{c.listType}</td>
|
||||
<td>{c.orderNum}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* ── navigator_publics ────────────────────────────────── */}
|
||||
<div className="card" style={{ padding: 0, overflowX: "auto" }}>
|
||||
<div style={{ padding: "1.1rem 1.25rem 0" }}>
|
||||
<h3 style={{ margin: 0 }}>Public rooms</h3>
|
||||
<p className="muted" style={{ margin: "0.25rem 0 0" }}>
|
||||
<code>navigator_publics</code> · {publics.length} rows
|
||||
</p>
|
||||
</div>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Public cat</th>
|
||||
<th>Room</th>
|
||||
<th>Visible</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{publics.map((p, i) => (
|
||||
<tr key={`${p.public_cat_id}-${p.room_id}-${i}`}>
|
||||
<td>
|
||||
{catNameById.get(p.public_cat_id) ?? "—"}{" "}
|
||||
<span className="muted">#{p.public_cat_id}</span>
|
||||
</td>
|
||||
<td>
|
||||
{roomNameById.get(p.room_id) || "(unnamed)"}{" "}
|
||||
<span className="muted">#{p.room_id}</span>
|
||||
</td>
|
||||
<td>{p.visible === "1" ? "Yes" : "No"}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
<section className="admin-section">
|
||||
<h2>Public rooms</h2>
|
||||
<p className="muted" style={{ marginTop: "-0.4rem" }}>
|
||||
<code>navigator_publics</code> · {publics.length} rows
|
||||
</p>
|
||||
{publics.length === 0 ? (
|
||||
<p className="muted" style={{ padding: "0 1.25rem 1.1rem" }}>
|
||||
No public-room assignments found.
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
<p className="muted">No public-room assignments found.</p>
|
||||
) : (
|
||||
<div style={{ overflowX: "auto" }}>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Public cat</th>
|
||||
<th>Room</th>
|
||||
<th>Visible</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{publics.map((p, i) => (
|
||||
<tr key={`${p.public_cat_id}-${p.room_id}-${i}`}>
|
||||
<td>
|
||||
{catNameById.get(p.public_cat_id) ?? "—"}{" "}
|
||||
<span className="muted">#{p.public_cat_id}</span>
|
||||
</td>
|
||||
<td>
|
||||
{roomNameById.get(p.room_id) || "(unnamed)"}{" "}
|
||||
<span className="muted">#{p.room_id}</span>
|
||||
</td>
|
||||
<td>
|
||||
<YesNo on={p.visible === "1"} />
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
deletePermission,
|
||||
updatePermission,
|
||||
} from "@/actions/admin-permissions";
|
||||
import { StatusCard } from "@/components/admin/dashboard";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
@@ -29,14 +30,21 @@ export default async function AdminPermissions() {
|
||||
permissions = [];
|
||||
}
|
||||
|
||||
const highestRank = permissions.reduce((max, p) => Math.max(max, p.minRank), 0);
|
||||
|
||||
return (
|
||||
<main>
|
||||
<h1>Permissions</h1>
|
||||
<p className="muted">
|
||||
<p className="muted" style={{ marginTop: "-0.4rem" }}>
|
||||
CMS-owned permissions (<code>website_permissions</code>). Each entry maps
|
||||
a permission name to the minimum rank allowed to use it.
|
||||
</p>
|
||||
|
||||
<div className="admin-stats">
|
||||
<StatusCard label="Permissions" value={permissions.length} icon="🔑" />
|
||||
<StatusCard label="Highest min rank" value={highestRank} icon="🎖️" />
|
||||
</div>
|
||||
|
||||
<form action={createPermission} className="card" style={{ marginBottom: "1.5rem" }}>
|
||||
<h3 style={{ marginTop: 0 }}>Add / overwrite permission</h3>
|
||||
<div style={{ display: "flex", gap: "0.5rem", flexWrap: "wrap" }}>
|
||||
@@ -60,71 +68,76 @@ export default async function AdminPermissions() {
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{permissions.length === 0 ? (
|
||||
<p className="muted">No permissions found.</p>
|
||||
) : (
|
||||
<table style={{ width: "100%", borderCollapse: "collapse" }}>
|
||||
<thead>
|
||||
<tr style={{ textAlign: "left", color: "var(--muted)" }}>
|
||||
<th>ID</th>
|
||||
<th>Permission</th>
|
||||
<th>Min rank</th>
|
||||
<th>Description</th>
|
||||
<th />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{permissions.map((p) => (
|
||||
<tr key={String(p.id)} style={{ borderTop: "1px solid var(--border)" }}>
|
||||
<td>{String(p.id)}</td>
|
||||
<td>
|
||||
<form
|
||||
id={`perm-${String(p.id)}`}
|
||||
action={updatePermission}
|
||||
style={{ display: "contents" }}
|
||||
>
|
||||
<input type="hidden" name="id" value={String(p.id)} />
|
||||
<input name="permission" defaultValue={p.permission} required maxLength={255} />
|
||||
</form>
|
||||
</td>
|
||||
<td>
|
||||
<input
|
||||
form={`perm-${String(p.id)}`}
|
||||
name="minRank"
|
||||
type="number"
|
||||
min={0}
|
||||
defaultValue={p.minRank}
|
||||
style={{ width: 90 }}
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<input
|
||||
form={`perm-${String(p.id)}`}
|
||||
name="description"
|
||||
defaultValue={p.description ?? ""}
|
||||
style={{ width: "100%" }}
|
||||
/>
|
||||
</td>
|
||||
<td style={{ display: "flex", gap: "0.5rem" }}>
|
||||
<button
|
||||
type="submit"
|
||||
form={`perm-${String(p.id)}`}
|
||||
className="btn btn-primary"
|
||||
>
|
||||
Save
|
||||
</button>
|
||||
<form action={deletePermission}>
|
||||
<input type="hidden" name="id" value={String(p.id)} />
|
||||
<button type="submit" className="btn btn-danger">
|
||||
Delete
|
||||
</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
<section className="admin-section">
|
||||
<h2>Permissions ({permissions.length})</h2>
|
||||
{permissions.length === 0 ? (
|
||||
<p className="muted">No permissions found.</p>
|
||||
) : (
|
||||
<div style={{ overflowX: "auto" }}>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>Permission</th>
|
||||
<th>Min rank</th>
|
||||
<th>Description</th>
|
||||
<th />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{permissions.map((p) => (
|
||||
<tr key={String(p.id)}>
|
||||
<td className="muted">{String(p.id)}</td>
|
||||
<td>
|
||||
<form
|
||||
id={`perm-${String(p.id)}`}
|
||||
action={updatePermission}
|
||||
style={{ display: "contents" }}
|
||||
>
|
||||
<input type="hidden" name="id" value={String(p.id)} />
|
||||
<input name="permission" defaultValue={p.permission} required maxLength={255} />
|
||||
</form>
|
||||
</td>
|
||||
<td>
|
||||
<input
|
||||
form={`perm-${String(p.id)}`}
|
||||
name="minRank"
|
||||
type="number"
|
||||
min={0}
|
||||
defaultValue={p.minRank}
|
||||
style={{ width: 90 }}
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<input
|
||||
form={`perm-${String(p.id)}`}
|
||||
name="description"
|
||||
defaultValue={p.description ?? ""}
|
||||
style={{ width: "100%" }}
|
||||
/>
|
||||
</td>
|
||||
<td style={{ display: "flex", gap: "0.5rem" }}>
|
||||
<button
|
||||
type="submit"
|
||||
form={`perm-${String(p.id)}`}
|
||||
className="btn btn-primary"
|
||||
>
|
||||
Save
|
||||
</button>
|
||||
<form action={deletePermission}>
|
||||
<input type="hidden" name="id" value={String(p.id)} />
|
||||
<button type="submit" className="btn btn-danger">
|
||||
Delete
|
||||
</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
updateRadioBanner,
|
||||
} from '@/actions/admin-radio-extra';
|
||||
import { prisma } from '@/lib/prisma';
|
||||
import { StatusCard } from '@/components/admin/dashboard';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
@@ -46,17 +47,21 @@ export default async function AdminRadioBannersPage() {
|
||||
banners = [];
|
||||
}
|
||||
|
||||
const activeCount = banners.filter((b) => b.isActive).length;
|
||||
const hiddenCount = banners.length - activeCount;
|
||||
|
||||
return (
|
||||
<main>
|
||||
<nav className="muted" style={{ marginBottom: '0.5rem' }}>
|
||||
<Link href="/admin/radio">Radio</Link> ·{' '}
|
||||
<Link href="/admin/radio/settings">Settings</Link> ·{' '}
|
||||
<Link href="/admin/radio/banners">Banners</Link> ·{' '}
|
||||
<Link href="/admin/radio/ranks">Ranks</Link>
|
||||
<Link href="/admin/radio/ranks">Ranks</Link> ·{' '}
|
||||
<Link href="/admin/radio/history">History</Link>
|
||||
</nav>
|
||||
|
||||
<h1>Radio Banners</h1>
|
||||
<p className="muted">
|
||||
<p className="muted" style={{ marginTop: '-0.4rem' }}>
|
||||
Promotional banners shown on the radio page, ordered by sort order.
|
||||
</p>
|
||||
|
||||
@@ -66,188 +71,214 @@ export default async function AdminRadioBannersPage() {
|
||||
Could not load banners (database unavailable).
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<form action={createRadioBanner} className="card" style={{ marginBottom: '1.5rem' }}>
|
||||
<h3 style={{ marginTop: 0 }}>Add banner</h3>
|
||||
<div className="grid cols-2">
|
||||
<div>
|
||||
<label htmlFor="new_imagePath" style={{ display: 'block', fontWeight: 700 }}>
|
||||
Image path / URL
|
||||
</label>
|
||||
<input
|
||||
id="new_imagePath"
|
||||
name="imagePath"
|
||||
placeholder="/storage/radio/banner.png"
|
||||
required
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="new_title" style={{ display: 'block', fontWeight: 700 }}>
|
||||
Title
|
||||
</label>
|
||||
<input id="new_title" name="title" style={{ width: '100%' }} />
|
||||
</div>
|
||||
<div style={{ gridColumn: '1 / -1' }}>
|
||||
<label htmlFor="new_description" style={{ display: 'block', fontWeight: 700 }}>
|
||||
Description
|
||||
</label>
|
||||
<textarea
|
||||
id="new_description"
|
||||
name="description"
|
||||
rows={2}
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="new_sortOrder" style={{ display: 'block', fontWeight: 700 }}>
|
||||
Sort order
|
||||
</label>
|
||||
<input
|
||||
id="new_sortOrder"
|
||||
name="sortOrder"
|
||||
type="number"
|
||||
defaultValue={0}
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'flex-end' }}>
|
||||
<label style={{ display: 'inline-flex', gap: '0.4rem', alignItems: 'center' }}>
|
||||
<input type="checkbox" name="isActive" value="1" defaultChecked /> Active
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ marginTop: '0.75rem' }}>
|
||||
<button type="submit" className="btn btn-primary">
|
||||
Create banner
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{banners.length === 0 ? (
|
||||
<p className="muted">No banners yet.</p>
|
||||
) : (
|
||||
<div className="grid">
|
||||
{banners.map((b) => (
|
||||
<div key={String(b.id)} className="card">
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'baseline',
|
||||
gap: '0.75rem',
|
||||
}}
|
||||
>
|
||||
<strong>#{String(b.id)}</strong>
|
||||
<span className="muted">
|
||||
by user {String(b.userId)} · {formatDate(b.createdAt)} ·{' '}
|
||||
{b.isActive ? 'active' : 'hidden'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{b.imagePath ? (
|
||||
<img
|
||||
src={b.imagePath}
|
||||
alt={b.title ?? 'Radio banner'}
|
||||
className="article-img"
|
||||
style={{ margin: '0.5rem 0' }}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<form action={updateRadioBanner}>
|
||||
<input type="hidden" name="id" value={String(b.id)} />
|
||||
<div className="grid cols-2">
|
||||
<div>
|
||||
<label
|
||||
htmlFor={`img_${b.id}`}
|
||||
style={{ display: 'block', fontWeight: 700 }}
|
||||
>
|
||||
Image path / URL
|
||||
</label>
|
||||
<input
|
||||
id={`img_${b.id}`}
|
||||
name="imagePath"
|
||||
defaultValue={b.imagePath}
|
||||
required
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label
|
||||
htmlFor={`title_${b.id}`}
|
||||
style={{ display: 'block', fontWeight: 700 }}
|
||||
>
|
||||
Title
|
||||
</label>
|
||||
<input
|
||||
id={`title_${b.id}`}
|
||||
name="title"
|
||||
defaultValue={b.title ?? ''}
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ gridColumn: '1 / -1' }}>
|
||||
<label
|
||||
htmlFor={`desc_${b.id}`}
|
||||
style={{ display: 'block', fontWeight: 700 }}
|
||||
>
|
||||
Description
|
||||
</label>
|
||||
<textarea
|
||||
id={`desc_${b.id}`}
|
||||
name="description"
|
||||
rows={2}
|
||||
defaultValue={b.description ?? ''}
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label
|
||||
htmlFor={`sort_${b.id}`}
|
||||
style={{ display: 'block', fontWeight: 700 }}
|
||||
>
|
||||
Sort order
|
||||
</label>
|
||||
<input
|
||||
id={`sort_${b.id}`}
|
||||
name="sortOrder"
|
||||
type="number"
|
||||
defaultValue={b.sortOrder}
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'flex-end' }}>
|
||||
<label
|
||||
style={{ display: 'inline-flex', gap: '0.4rem', alignItems: 'center' }}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
name="isActive"
|
||||
value="1"
|
||||
defaultChecked={b.isActive}
|
||||
/>{' '}
|
||||
Active
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ marginTop: '0.6rem' }}>
|
||||
<button type="submit" className="btn btn-primary">
|
||||
Save
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<form action={deleteRadioBanner} style={{ marginTop: '0.5rem' }}>
|
||||
<input type="hidden" name="id" value={String(b.id)} />
|
||||
<button type="submit" className="btn btn-danger">
|
||||
Delete
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
))}
|
||||
<div className="admin-stats">
|
||||
<StatusCard label="Banners" value={banners.length} icon="🖼️" />
|
||||
<StatusCard
|
||||
label="Active"
|
||||
value={activeCount}
|
||||
state={activeCount > 0 ? 'ok' : 'neutral'}
|
||||
icon="✅"
|
||||
/>
|
||||
<StatusCard
|
||||
label="Hidden"
|
||||
value={hiddenCount}
|
||||
state={hiddenCount > 0 ? 'warn' : 'neutral'}
|
||||
icon="🚫"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<section className="admin-section">
|
||||
<h2>Add banner</h2>
|
||||
<form action={createRadioBanner} className="card">
|
||||
<div className="grid cols-2">
|
||||
<div>
|
||||
<label htmlFor="new_imagePath" style={{ display: 'block', fontWeight: 700 }}>
|
||||
Image path / URL
|
||||
</label>
|
||||
<input
|
||||
id="new_imagePath"
|
||||
name="imagePath"
|
||||
placeholder="/storage/radio/banner.png"
|
||||
required
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="new_title" style={{ display: 'block', fontWeight: 700 }}>
|
||||
Title
|
||||
</label>
|
||||
<input id="new_title" name="title" style={{ width: '100%' }} />
|
||||
</div>
|
||||
<div style={{ gridColumn: '1 / -1' }}>
|
||||
<label htmlFor="new_description" style={{ display: 'block', fontWeight: 700 }}>
|
||||
Description
|
||||
</label>
|
||||
<textarea
|
||||
id="new_description"
|
||||
name="description"
|
||||
rows={2}
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="new_sortOrder" style={{ display: 'block', fontWeight: 700 }}>
|
||||
Sort order
|
||||
</label>
|
||||
<input
|
||||
id="new_sortOrder"
|
||||
name="sortOrder"
|
||||
type="number"
|
||||
defaultValue={0}
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'flex-end' }}>
|
||||
<label style={{ display: 'inline-flex', gap: '0.4rem', alignItems: 'center' }}>
|
||||
<input type="checkbox" name="isActive" value="1" defaultChecked /> Active
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ marginTop: '0.75rem' }}>
|
||||
<button type="submit" className="btn btn-primary">
|
||||
Create banner
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section className="admin-section">
|
||||
<h2>Existing banners ({banners.length})</h2>
|
||||
{banners.length === 0 ? (
|
||||
<p className="muted">No banners yet — add one above.</p>
|
||||
) : (
|
||||
<div className="grid">
|
||||
{banners.map((b) => (
|
||||
<div key={String(b.id)} className="card">
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'baseline',
|
||||
gap: '0.75rem',
|
||||
}}
|
||||
>
|
||||
<strong>#{String(b.id)}</strong>
|
||||
<span
|
||||
className="muted"
|
||||
style={{ display: 'inline-flex', gap: '0.5rem', alignItems: 'center' }}
|
||||
>
|
||||
by user {String(b.userId)} · {formatDate(b.createdAt)} ·{' '}
|
||||
<span className={`admin-badge ${b.isActive ? 'ok' : 'danger'}`}>
|
||||
{b.isActive ? 'active' : 'hidden'}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{b.imagePath ? (
|
||||
<img
|
||||
src={b.imagePath}
|
||||
alt={b.title ?? 'Radio banner'}
|
||||
className="article-img"
|
||||
style={{ margin: '0.5rem 0' }}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<form action={updateRadioBanner}>
|
||||
<input type="hidden" name="id" value={String(b.id)} />
|
||||
<div className="grid cols-2">
|
||||
<div>
|
||||
<label
|
||||
htmlFor={`img_${b.id}`}
|
||||
style={{ display: 'block', fontWeight: 700 }}
|
||||
>
|
||||
Image path / URL
|
||||
</label>
|
||||
<input
|
||||
id={`img_${b.id}`}
|
||||
name="imagePath"
|
||||
defaultValue={b.imagePath}
|
||||
required
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label
|
||||
htmlFor={`title_${b.id}`}
|
||||
style={{ display: 'block', fontWeight: 700 }}
|
||||
>
|
||||
Title
|
||||
</label>
|
||||
<input
|
||||
id={`title_${b.id}`}
|
||||
name="title"
|
||||
defaultValue={b.title ?? ''}
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ gridColumn: '1 / -1' }}>
|
||||
<label
|
||||
htmlFor={`desc_${b.id}`}
|
||||
style={{ display: 'block', fontWeight: 700 }}
|
||||
>
|
||||
Description
|
||||
</label>
|
||||
<textarea
|
||||
id={`desc_${b.id}`}
|
||||
name="description"
|
||||
rows={2}
|
||||
defaultValue={b.description ?? ''}
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label
|
||||
htmlFor={`sort_${b.id}`}
|
||||
style={{ display: 'block', fontWeight: 700 }}
|
||||
>
|
||||
Sort order
|
||||
</label>
|
||||
<input
|
||||
id={`sort_${b.id}`}
|
||||
name="sortOrder"
|
||||
type="number"
|
||||
defaultValue={b.sortOrder}
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'flex-end' }}>
|
||||
<label
|
||||
style={{ display: 'inline-flex', gap: '0.4rem', alignItems: 'center' }}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
name="isActive"
|
||||
value="1"
|
||||
defaultChecked={b.isActive}
|
||||
/>{' '}
|
||||
Active
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ marginTop: '0.6rem' }}>
|
||||
<button type="submit" className="btn btn-primary">
|
||||
Save
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<form action={deleteRadioBanner} style={{ marginTop: '0.5rem' }}>
|
||||
<input type="hidden" name="id" value={String(b.id)} />
|
||||
<button type="submit" className="btn btn-danger">
|
||||
Delete
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import Link from 'next/link';
|
||||
import { prisma } from '@/lib/prisma';
|
||||
import { StatusCard } from '@/components/admin/dashboard';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
@@ -64,6 +65,13 @@ export default async function AdminRadioHistoryPage() {
|
||||
history = [];
|
||||
}
|
||||
|
||||
const liveCount = history.filter((h) => !h.endedAt).length;
|
||||
const peakListeners = history.reduce(
|
||||
(max, h) => (h.listenersCount > max ? h.listenersCount : max),
|
||||
0,
|
||||
);
|
||||
const uniqueDjs = new Set(history.map((h) => String(h.userId))).size;
|
||||
|
||||
return (
|
||||
<main>
|
||||
<nav className="muted" style={{ marginBottom: '0.5rem' }}>
|
||||
@@ -75,7 +83,7 @@ export default async function AdminRadioHistoryPage() {
|
||||
</nav>
|
||||
|
||||
<h1>Radio Play History</h1>
|
||||
<p className="muted">
|
||||
<p className="muted" style={{ marginTop: '-0.4rem' }}>
|
||||
Past DJ shows and airtime (<code>radio_history</code>), most recent first.
|
||||
Read-only log of who was on air, when, and how many listeners tuned in.
|
||||
</p>
|
||||
@@ -86,40 +94,63 @@ export default async function AdminRadioHistoryPage() {
|
||||
Could not load radio history (database unavailable).
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="admin-stats">
|
||||
<StatusCard label="Shows logged" value={history.length} icon="🎙️" />
|
||||
<StatusCard
|
||||
label="On air now"
|
||||
value={liveCount}
|
||||
state={liveCount > 0 ? 'ok' : 'neutral'}
|
||||
icon="🔴"
|
||||
/>
|
||||
<StatusCard label="Unique DJs" value={uniqueDjs} icon="🧑🎤" />
|
||||
<StatusCard label="Peak listeners" value={peakListeners} icon="👥" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!dbError && history.length === 0 ? (
|
||||
<p className="muted">No radio history recorded yet.</p>
|
||||
) : null}
|
||||
|
||||
{history.length === 0 ? (
|
||||
<p className="muted">No radio history recorded.</p>
|
||||
) : (
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>DJ</th>
|
||||
<th>Show</th>
|
||||
<th>Started</th>
|
||||
<th>Ended</th>
|
||||
<th>Airtime</th>
|
||||
<th>Listeners</th>
|
||||
<th>Notes</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{history.map((h) => (
|
||||
<tr key={String(h.id)}>
|
||||
<td>{String(h.id)}</td>
|
||||
<td>{names.get(String(h.userId)) ?? `#${String(h.userId)}`}</td>
|
||||
<td>{h.showName ?? '—'}</td>
|
||||
<td className="muted">{formatDate(h.startedAt)}</td>
|
||||
<td className="muted">{formatDate(h.endedAt)}</td>
|
||||
<td className="muted">{formatDuration(h.startedAt, h.endedAt)}</td>
|
||||
<td>{h.listenersCount}</td>
|
||||
<td className="muted">{h.notes ?? '—'}</td>
|
||||
{history.length > 0 ? (
|
||||
<section className="admin-section">
|
||||
<h2>Show log ({history.length})</h2>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>DJ</th>
|
||||
<th>Show</th>
|
||||
<th>Started</th>
|
||||
<th>Ended</th>
|
||||
<th>Airtime</th>
|
||||
<th>Listeners</th>
|
||||
<th>Notes</th>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</thead>
|
||||
<tbody>
|
||||
{history.map((h) => (
|
||||
<tr key={String(h.id)}>
|
||||
<td>{String(h.id)}</td>
|
||||
<td>{names.get(String(h.userId)) ?? `#${String(h.userId)}`}</td>
|
||||
<td>{h.showName ?? '—'}</td>
|
||||
<td className="muted">{formatDate(h.startedAt)}</td>
|
||||
<td>
|
||||
{h.endedAt ? (
|
||||
<span className="muted">{formatDate(h.endedAt)}</span>
|
||||
) : (
|
||||
<span className="admin-badge ok">on air</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="muted">{formatDuration(h.startedAt, h.endedAt)}</td>
|
||||
<td>{h.listenersCount}</td>
|
||||
<td className="muted">{h.notes ?? '—'}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
) : null}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
import Link from 'next/link';
|
||||
import { prisma } from '@/lib/prisma';
|
||||
import { deleteShout } from '@/actions/admin-radio';
|
||||
import { StatusCard } from '@/components/admin/dashboard';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
@@ -39,6 +41,14 @@ function formatTime(d: Date | null): string {
|
||||
return d ? d.toISOString().slice(11, 16) : '—';
|
||||
}
|
||||
|
||||
// Map an application status to an ok/danger/neutral pill tone.
|
||||
function statusBadgeClass(status: string): string {
|
||||
const s = status.toLowerCase();
|
||||
if (s === 'approved' || s === 'accepted') return 'ok';
|
||||
if (s === 'rejected' || s === 'denied') return 'danger';
|
||||
return '';
|
||||
}
|
||||
|
||||
export default async function AdminRadioPage() {
|
||||
let shouts: Shout[] = [];
|
||||
let applications: Application[] = [];
|
||||
@@ -91,14 +101,47 @@ export default async function AdminRadioPage() {
|
||||
schedules = [];
|
||||
}
|
||||
|
||||
const pendingApps = applications.filter(
|
||||
(a) => a.status.toLowerCase() === 'pending',
|
||||
).length;
|
||||
const activeSchedules = schedules.filter((sc) => sc.isActive).length;
|
||||
|
||||
return (
|
||||
<main>
|
||||
<h1>Radio Moderation</h1>
|
||||
<nav className="muted" style={{ marginBottom: '0.5rem' }}>
|
||||
<Link href="/admin/radio">Radio</Link> ·{' '}
|
||||
<Link href="/admin/radio/settings">Settings</Link> ·{' '}
|
||||
<Link href="/admin/radio/banners">Banners</Link> ·{' '}
|
||||
<Link href="/admin/radio/ranks">Ranks</Link> ·{' '}
|
||||
<Link href="/admin/radio/history">History</Link>
|
||||
</nav>
|
||||
|
||||
<section style={{ marginBottom: '2rem' }}>
|
||||
<h2>Recent Shouts</h2>
|
||||
<h1>Radio Moderation</h1>
|
||||
<p className="muted" style={{ marginTop: '-0.4rem' }}>
|
||||
Latest shouts, DJ applications and on-air schedules (25 most recent of each).
|
||||
</p>
|
||||
|
||||
<div className="admin-stats">
|
||||
<StatusCard label="Recent shouts" value={shouts.length} icon="📣" />
|
||||
<StatusCard
|
||||
label="Pending applications"
|
||||
value={pendingApps}
|
||||
state={pendingApps > 0 ? 'warn' : 'ok'}
|
||||
icon="📝"
|
||||
/>
|
||||
<StatusCard label="Schedules" value={schedules.length} icon="🗓️" />
|
||||
<StatusCard
|
||||
label="Active schedules"
|
||||
value={activeSchedules}
|
||||
state={activeSchedules > 0 ? 'ok' : 'neutral'}
|
||||
icon="🎙️"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<section className="admin-section">
|
||||
<h2>Recent Shouts ({shouts.length})</h2>
|
||||
{shouts.length === 0 ? (
|
||||
<p className="muted">No shouts.</p>
|
||||
<p className="muted">No shouts to moderate.</p>
|
||||
) : (
|
||||
<table>
|
||||
<thead>
|
||||
@@ -132,10 +175,10 @@ export default async function AdminRadioPage() {
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section style={{ marginBottom: '2rem' }}>
|
||||
<h2>DJ Applications</h2>
|
||||
<section className="admin-section">
|
||||
<h2>DJ Applications ({applications.length})</h2>
|
||||
{applications.length === 0 ? (
|
||||
<p className="muted">No applications.</p>
|
||||
<p className="muted">No applications submitted.</p>
|
||||
) : (
|
||||
<table>
|
||||
<thead>
|
||||
@@ -159,7 +202,11 @@ export default async function AdminRadioPage() {
|
||||
<td>{a.age}</td>
|
||||
<td>{a.musicStyle ?? '—'}</td>
|
||||
<td>{a.discordUsername ?? '—'}</td>
|
||||
<td>{a.status}</td>
|
||||
<td>
|
||||
<span className={`admin-badge ${statusBadgeClass(a.status)}`}>
|
||||
{a.status}
|
||||
</span>
|
||||
</td>
|
||||
<td className="muted">{formatDate(a.createdAt)}</td>
|
||||
</tr>
|
||||
))}
|
||||
@@ -168,10 +215,10 @@ export default async function AdminRadioPage() {
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>Schedules</h2>
|
||||
<section className="admin-section">
|
||||
<h2>Schedules ({schedules.length})</h2>
|
||||
{schedules.length === 0 ? (
|
||||
<p className="muted">No schedules.</p>
|
||||
<p className="muted">No schedules configured.</p>
|
||||
) : (
|
||||
<table>
|
||||
<thead>
|
||||
@@ -194,7 +241,11 @@ export default async function AdminRadioPage() {
|
||||
<td>{formatTime(sc.startTime)}</td>
|
||||
<td>{formatTime(sc.endTime)}</td>
|
||||
<td>{sc.showName ?? '—'}</td>
|
||||
<td>{sc.isActive ? '●' : '○'}</td>
|
||||
<td>
|
||||
<span className={`admin-badge ${sc.isActive ? 'ok' : 'danger'}`}>
|
||||
{sc.isActive ? 'active' : 'inactive'}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
|
||||
+133
-117
@@ -5,6 +5,7 @@ import {
|
||||
updateRadioRank,
|
||||
} from '@/actions/admin-radio-extra';
|
||||
import { prisma } from '@/lib/prisma';
|
||||
import { StatusCard } from '@/components/admin/dashboard';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
@@ -36,17 +37,22 @@ export default async function AdminRadioRanksPage() {
|
||||
ranks = [];
|
||||
}
|
||||
|
||||
const activeCount = ranks.filter((r) => r.isActive).length;
|
||||
|
||||
return (
|
||||
<main>
|
||||
<nav className="muted" style={{ marginBottom: '0.5rem' }}>
|
||||
<Link href="/admin/radio">Radio</Link> ·{' '}
|
||||
<Link href="/admin/radio/settings">Settings</Link> ·{' '}
|
||||
<Link href="/admin/radio/banners">Banners</Link> ·{' '}
|
||||
<Link href="/admin/radio/ranks">Ranks</Link>
|
||||
<Link href="/admin/radio/ranks">Ranks</Link> ·{' '}
|
||||
<Link href="/admin/radio/history">History</Link>
|
||||
</nav>
|
||||
|
||||
<h1>Radio Ranks</h1>
|
||||
<p className="muted">DJ / staff ranks assignable on radio applications.</p>
|
||||
<p className="muted" style={{ marginTop: '-0.4rem' }}>
|
||||
DJ / staff ranks assignable on radio applications.
|
||||
</p>
|
||||
|
||||
{dbError ? (
|
||||
<div className="card" style={{ marginBottom: '1.5rem' }}>
|
||||
@@ -54,123 +60,133 @@ export default async function AdminRadioRanksPage() {
|
||||
Could not load ranks (database unavailable).
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<form action={createRadioRank} className="card" style={{ marginBottom: '1.5rem' }}>
|
||||
<h3 style={{ marginTop: 0 }}>Add rank</h3>
|
||||
<div className="grid cols-3">
|
||||
<div>
|
||||
<label htmlFor="new_name" style={{ display: 'block', fontWeight: 700 }}>
|
||||
Name
|
||||
</label>
|
||||
<input id="new_name" name="name" required style={{ width: '100%' }} />
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="new_badge" style={{ display: 'block', fontWeight: 700 }}>
|
||||
Badge code
|
||||
</label>
|
||||
<input id="new_badge" name="badgeCode" style={{ width: '100%' }} />
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'flex-end' }}>
|
||||
<label style={{ display: 'inline-flex', gap: '0.4rem', alignItems: 'center' }}>
|
||||
<input type="checkbox" name="isActive" value="1" defaultChecked /> Active
|
||||
</label>
|
||||
</div>
|
||||
<div style={{ gridColumn: '1 / -1' }}>
|
||||
<label htmlFor="new_desc" style={{ display: 'block', fontWeight: 700 }}>
|
||||
Description
|
||||
</label>
|
||||
<input id="new_desc" name="description" style={{ width: '100%' }} />
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ marginTop: '0.75rem' }}>
|
||||
<button type="submit" className="btn btn-primary">
|
||||
Create rank
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{ranks.length === 0 ? (
|
||||
<p className="muted">No ranks yet.</p>
|
||||
) : (
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>Name</th>
|
||||
<th>Badge</th>
|
||||
<th>Description</th>
|
||||
<th>Active</th>
|
||||
<th />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{ranks.map((r) => (
|
||||
<tr key={String(r.id)}>
|
||||
<td>{String(r.id)}</td>
|
||||
<td colSpan={5}>
|
||||
<form
|
||||
action={updateRadioRank}
|
||||
style={{
|
||||
display: 'flex',
|
||||
gap: '0.5rem',
|
||||
alignItems: 'center',
|
||||
flexWrap: 'wrap',
|
||||
}}
|
||||
>
|
||||
<input type="hidden" name="id" value={String(r.id)} />
|
||||
<input
|
||||
name="name"
|
||||
defaultValue={r.name}
|
||||
required
|
||||
placeholder="Name"
|
||||
style={{ minWidth: 140 }}
|
||||
/>
|
||||
<input
|
||||
name="badgeCode"
|
||||
defaultValue={r.badgeCode ?? ''}
|
||||
placeholder="Badge"
|
||||
style={{ minWidth: 100 }}
|
||||
/>
|
||||
<input
|
||||
name="description"
|
||||
defaultValue={r.description ?? ''}
|
||||
placeholder="Description"
|
||||
style={{ flex: 1, minWidth: 160 }}
|
||||
/>
|
||||
<label
|
||||
style={{ display: 'inline-flex', gap: '0.3rem', alignItems: 'center' }}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
name="isActive"
|
||||
value="1"
|
||||
defaultChecked={r.isActive}
|
||||
/>{' '}
|
||||
Active
|
||||
</label>
|
||||
<button type="submit" className="btn btn-primary">
|
||||
Save
|
||||
</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{ranks.map((r) => (
|
||||
<tr key={`del_${String(r.id)}`}>
|
||||
<td colSpan={6} style={{ borderTop: 'none', paddingTop: 0 }}>
|
||||
<form action={deleteRadioRank} style={{ display: 'inline' }}>
|
||||
<input type="hidden" name="id" value={String(r.id)} />
|
||||
<button type="submit" className="btn btn-danger">
|
||||
Delete rank #{String(r.id)}
|
||||
</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
<div className="admin-stats">
|
||||
<StatusCard label="Ranks" value={ranks.length} icon="🎖️" />
|
||||
<StatusCard
|
||||
label="Active"
|
||||
value={activeCount}
|
||||
state={activeCount > 0 ? 'ok' : 'neutral'}
|
||||
icon="✅"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<section className="admin-section">
|
||||
<h2>Add rank</h2>
|
||||
<form action={createRadioRank} className="card">
|
||||
<div className="grid cols-3">
|
||||
<div>
|
||||
<label htmlFor="new_name" style={{ display: 'block', fontWeight: 700 }}>
|
||||
Name
|
||||
</label>
|
||||
<input id="new_name" name="name" required style={{ width: '100%' }} />
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="new_badge" style={{ display: 'block', fontWeight: 700 }}>
|
||||
Badge code
|
||||
</label>
|
||||
<input id="new_badge" name="badgeCode" style={{ width: '100%' }} />
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'flex-end' }}>
|
||||
<label style={{ display: 'inline-flex', gap: '0.4rem', alignItems: 'center' }}>
|
||||
<input type="checkbox" name="isActive" value="1" defaultChecked /> Active
|
||||
</label>
|
||||
</div>
|
||||
<div style={{ gridColumn: '1 / -1' }}>
|
||||
<label htmlFor="new_desc" style={{ display: 'block', fontWeight: 700 }}>
|
||||
Description
|
||||
</label>
|
||||
<input id="new_desc" name="description" style={{ width: '100%' }} />
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ marginTop: '0.75rem' }}>
|
||||
<button type="submit" className="btn btn-primary">
|
||||
Create rank
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section className="admin-section">
|
||||
<h2>Existing ranks ({ranks.length})</h2>
|
||||
{ranks.length === 0 ? (
|
||||
<p className="muted">No ranks yet — add one above.</p>
|
||||
) : (
|
||||
<div className="grid">
|
||||
{ranks.map((r) => (
|
||||
<div key={String(r.id)} className="card">
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'baseline',
|
||||
gap: '0.75rem',
|
||||
marginBottom: '0.6rem',
|
||||
}}
|
||||
>
|
||||
<strong>#{String(r.id)}</strong>
|
||||
<span className={`admin-badge ${r.isActive ? 'ok' : 'danger'}`}>
|
||||
{r.isActive ? 'active' : 'inactive'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<form
|
||||
action={updateRadioRank}
|
||||
style={{
|
||||
display: 'flex',
|
||||
gap: '0.5rem',
|
||||
alignItems: 'center',
|
||||
flexWrap: 'wrap',
|
||||
}}
|
||||
>
|
||||
<input type="hidden" name="id" value={String(r.id)} />
|
||||
<input
|
||||
name="name"
|
||||
defaultValue={r.name}
|
||||
required
|
||||
placeholder="Name"
|
||||
style={{ minWidth: 140 }}
|
||||
/>
|
||||
<input
|
||||
name="badgeCode"
|
||||
defaultValue={r.badgeCode ?? ''}
|
||||
placeholder="Badge"
|
||||
style={{ minWidth: 100 }}
|
||||
/>
|
||||
<input
|
||||
name="description"
|
||||
defaultValue={r.description ?? ''}
|
||||
placeholder="Description"
|
||||
style={{ flex: 1, minWidth: 160 }}
|
||||
/>
|
||||
<label
|
||||
style={{ display: 'inline-flex', gap: '0.3rem', alignItems: 'center' }}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
name="isActive"
|
||||
value="1"
|
||||
defaultChecked={r.isActive}
|
||||
/>{' '}
|
||||
Active
|
||||
</label>
|
||||
<button type="submit" className="btn btn-primary">
|
||||
Save
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<form action={deleteRadioRank} style={{ marginTop: '0.5rem' }}>
|
||||
<input type="hidden" name="id" value={String(r.id)} />
|
||||
<button type="submit" className="btn btn-danger">
|
||||
Delete rank #{String(r.id)}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -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,50 +12,73 @@ export default async function AdminSettings() {
|
||||
settings = [];
|
||||
}
|
||||
|
||||
const documented = settings.filter((s) => !!s.comment).length;
|
||||
|
||||
return (
|
||||
<main>
|
||||
<h1>CMS Settings</h1>
|
||||
<p className="muted">
|
||||
<p className="muted" style={{ marginTop: "-0.4rem" }}>
|
||||
Key/value config read at runtime via <code>siteSettings.get()</code>. Saving busts the cache.
|
||||
</p>
|
||||
|
||||
<form action={createSetting} className="card" style={{ marginBottom: "1.5rem" }}>
|
||||
<h3 style={{ marginTop: 0 }}>Add / overwrite</h3>
|
||||
<div style={{ display: "flex", gap: "0.5rem", flexWrap: "wrap" }}>
|
||||
<input name="key" placeholder="key" required />
|
||||
<input name="value" placeholder="value" style={{ flex: 1, minWidth: 160 }} />
|
||||
<input name="comment" placeholder="comment" />
|
||||
<button type="submit">Save</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div className="grid">
|
||||
{settings.map((s) => (
|
||||
<div key={s.key} className="card">
|
||||
<div style={{ display: "flex", gap: "0.75rem", alignItems: "baseline" }}>
|
||||
<strong style={{ minWidth: 220 }}>{s.key}</strong>
|
||||
<form
|
||||
action={updateSetting}
|
||||
style={{ display: "flex", gap: "0.5rem", flex: 1 }}
|
||||
>
|
||||
<input type="hidden" name="key" value={s.key} />
|
||||
<input name="value" defaultValue={s.value} style={{ flex: 1 }} />
|
||||
<button type="submit">Save</button>
|
||||
</form>
|
||||
<form action={deleteSetting}>
|
||||
<input type="hidden" name="key" value={s.key} />
|
||||
<button type="submit">✕</button>
|
||||
</form>
|
||||
</div>
|
||||
{s.comment ? (
|
||||
<p className="muted" style={{ margin: "0.4rem 0 0" }}>
|
||||
{s.comment}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
))}
|
||||
<div className="admin-stats">
|
||||
<StatusCard label="Settings" value={settings.length} icon="🗄️" />
|
||||
<StatusCard label="With comment" value={documented} icon="📝" />
|
||||
</div>
|
||||
{settings.length === 0 ? <p className="muted">No settings yet.</p> : null}
|
||||
|
||||
<section className="admin-section">
|
||||
<h2>Add / overwrite</h2>
|
||||
<form action={createSetting} className="card">
|
||||
<div style={{ display: "flex", gap: "0.5rem", flexWrap: "wrap" }}>
|
||||
<input name="key" placeholder="key" required />
|
||||
<input name="value" placeholder="value" style={{ flex: 1, minWidth: 160 }} />
|
||||
<input name="comment" placeholder="comment" />
|
||||
<button type="submit" className="btn btn-primary">
|
||||
Save
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section className="admin-section">
|
||||
<h2>
|
||||
Settings <span className="admin-badge">{settings.length}</span>
|
||||
</h2>
|
||||
{settings.length === 0 ? (
|
||||
<p className="muted">No settings yet.</p>
|
||||
) : (
|
||||
<div className="grid">
|
||||
{settings.map((s) => (
|
||||
<div key={s.key} className="card">
|
||||
<div style={{ display: "flex", gap: "0.75rem", alignItems: "baseline" }}>
|
||||
<strong style={{ minWidth: 220 }}>{s.key}</strong>
|
||||
<form
|
||||
action={updateSetting}
|
||||
style={{ display: "flex", gap: "0.5rem", flex: 1 }}
|
||||
>
|
||||
<input type="hidden" name="key" value={s.key} />
|
||||
<input name="value" defaultValue={s.value} style={{ flex: 1 }} />
|
||||
<button type="submit" className="btn btn-outline">
|
||||
Save
|
||||
</button>
|
||||
</form>
|
||||
<form action={deleteSetting}>
|
||||
<input type="hidden" name="key" value={s.key} />
|
||||
<button type="submit" className="btn btn-danger">
|
||||
✕
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
{s.comment ? (
|
||||
<p className="muted" style={{ margin: "0.4rem 0 0" }}>
|
||||
{s.comment}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
+123
-66
@@ -1,8 +1,20 @@
|
||||
import Link from "next/link";
|
||||
import { StatusCard } from "@/components/admin/dashboard";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
const PAID_STATUS = new Set(["completed", "paid", "success", "approved"]);
|
||||
|
||||
function statusState(status: string | null | undefined): "ok" | "danger" | undefined {
|
||||
if (!status) return undefined;
|
||||
const s = status.toLowerCase();
|
||||
if (PAID_STATUS.has(s)) return "ok";
|
||||
if (s === "failed" || s === "denied" || s === "refunded" || s === "cancelled" || s === "canceled")
|
||||
return "danger";
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export default async function AdminShop() {
|
||||
let packages: Awaited<ReturnType<typeof prisma.websiteShopArticles.findMany>> = [];
|
||||
try {
|
||||
@@ -24,82 +36,127 @@ export default async function AdminShop() {
|
||||
orders = [];
|
||||
}
|
||||
|
||||
const paidOrders = orders.filter((o) => statusState(o.status) === "ok").length;
|
||||
|
||||
return (
|
||||
<main>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: "1rem" }}>
|
||||
<h1 style={{ flex: 1 }}>Shop</h1>
|
||||
<Link href="/admin/shop/new">+ New package</Link>
|
||||
<Link href="/admin/shop/new" className="btn btn-primary">
|
||||
+ New package
|
||||
</Link>
|
||||
</div>
|
||||
<p className="muted">
|
||||
<p className="muted" style={{ marginTop: "-0.4rem" }}>
|
||||
Website store packages (<code>website_shop_articles</code>). <code>costs</code> is the price
|
||||
shown in the store; credits/duckets/diamonds/badges are what the buyer receives.
|
||||
</p>
|
||||
|
||||
<table style={{ width: "100%", borderCollapse: "collapse" }}>
|
||||
<thead>
|
||||
<tr style={{ textAlign: "left", color: "var(--muted)" }}>
|
||||
<th>#</th>
|
||||
<th>Name</th>
|
||||
<th>Costs</th>
|
||||
<th>Credits</th>
|
||||
<th>Duckets</th>
|
||||
<th>Diamonds</th>
|
||||
<th>Rank</th>
|
||||
<th>Position</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{packages.map((p) => (
|
||||
<tr key={String(p.id)} style={{ borderTop: "1px solid var(--border)" }}>
|
||||
<td className="muted">{String(p.id)}</td>
|
||||
<td>
|
||||
<Link href={`/admin/shop/${p.id}`}>{p.name}</Link>
|
||||
</td>
|
||||
<td>{p.costs}</td>
|
||||
<td className="muted">{p.credits ?? ""}</td>
|
||||
<td className="muted">{p.duckets ?? ""}</td>
|
||||
<td className="muted">{p.diamonds ?? ""}</td>
|
||||
<td className="muted">{p.giveRank ?? ""}</td>
|
||||
<td className="muted">{p.position}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
{packages.length === 0 ? <p className="muted">No shop packages yet.</p> : null}
|
||||
<div className="admin-stats">
|
||||
<StatusCard label="Packages" value={packages.length} icon="🛍️" />
|
||||
<StatusCard label="Recent orders" value={orders.length} icon="🧾" />
|
||||
<StatusCard
|
||||
label="Paid orders"
|
||||
value={paidOrders}
|
||||
state={paidOrders > 0 ? "ok" : "neutral"}
|
||||
icon="💳"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<h2 style={{ marginTop: "2rem" }}>Recent orders</h2>
|
||||
<p className="muted">
|
||||
Read-only PayPal transaction log (<code>website_paypal_transactions</code>).
|
||||
</p>
|
||||
<table style={{ width: "100%", borderCollapse: "collapse" }}>
|
||||
<thead>
|
||||
<tr style={{ textAlign: "left", color: "var(--muted)" }}>
|
||||
<th>Transaction</th>
|
||||
<th>User</th>
|
||||
<th>Description</th>
|
||||
<th>Amount</th>
|
||||
<th>Status</th>
|
||||
<th>Date</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{orders.map((o) => (
|
||||
<tr key={String(o.id)} style={{ borderTop: "1px solid var(--border)" }}>
|
||||
<td>
|
||||
<code>{o.transactionId}</code>
|
||||
</td>
|
||||
<td className="muted">{o.userId}</td>
|
||||
<td className="muted">{o.description ?? ""}</td>
|
||||
<td>
|
||||
{o.amount} {o.currency}
|
||||
</td>
|
||||
<td className="muted">{o.status ?? ""}</td>
|
||||
<td className="muted">{o.createdAt ? o.createdAt.toISOString().slice(0, 10) : ""}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
{orders.length === 0 ? <p className="muted">No orders recorded.</p> : null}
|
||||
<section className="admin-section">
|
||||
<h2>Packages ({packages.length})</h2>
|
||||
{packages.length === 0 ? (
|
||||
<p className="muted">No shop packages yet.</p>
|
||||
) : (
|
||||
<div style={{ overflowX: "auto" }}>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>#</th>
|
||||
<th>Name</th>
|
||||
<th>Costs</th>
|
||||
<th>Credits</th>
|
||||
<th>Duckets</th>
|
||||
<th>Diamonds</th>
|
||||
<th>Rank</th>
|
||||
<th>Position</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{packages.map((p) => (
|
||||
<tr key={String(p.id)}>
|
||||
<td className="muted">{String(p.id)}</td>
|
||||
<td>
|
||||
<Link href={`/admin/shop/${p.id}`}>{p.name}</Link>
|
||||
</td>
|
||||
<td>{p.costs}</td>
|
||||
<td className="muted">{p.credits ?? "—"}</td>
|
||||
<td className="muted">{p.duckets ?? "—"}</td>
|
||||
<td className="muted">{p.diamonds ?? "—"}</td>
|
||||
<td className="muted">{p.giveRank ?? "—"}</td>
|
||||
<td className="muted">{p.position}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className="admin-section">
|
||||
<h2>Recent orders ({orders.length})</h2>
|
||||
<p className="muted" style={{ marginTop: "-0.4rem" }}>
|
||||
Read-only PayPal transaction log (<code>website_paypal_transactions</code>).
|
||||
</p>
|
||||
{orders.length === 0 ? (
|
||||
<p className="muted">No orders recorded.</p>
|
||||
) : (
|
||||
<div style={{ overflowX: "auto" }}>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Transaction</th>
|
||||
<th>User</th>
|
||||
<th>Description</th>
|
||||
<th>Amount</th>
|
||||
<th>Status</th>
|
||||
<th>Date</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{orders.map((o) => {
|
||||
const state = statusState(o.status);
|
||||
return (
|
||||
<tr key={String(o.id)}>
|
||||
<td>
|
||||
<code>{o.transactionId}</code>
|
||||
</td>
|
||||
<td className="muted">{o.userId}</td>
|
||||
<td className="muted">{o.description ?? "—"}</td>
|
||||
<td>
|
||||
{o.amount} {o.currency}
|
||||
</td>
|
||||
<td>
|
||||
{o.status ? (
|
||||
<span
|
||||
className={`admin-badge ${state === "ok" ? "ok" : state === "danger" ? "danger" : ""}`}
|
||||
>
|
||||
{o.status}
|
||||
</span>
|
||||
) : (
|
||||
<span className="muted">—</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="muted">
|
||||
{o.createdAt ? o.createdAt.toISOString().slice(0, 10) : "—"}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -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 > now). Read-only.
|
||||
</p>
|
||||
|
||||
<div className="card">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Sub ID</th>
|
||||
<th>User ID</th>
|
||||
<th>Type</th>
|
||||
<th>Expires</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{active.map((s) => (
|
||||
<tr key={s.id}>
|
||||
<td>{s.id}</td>
|
||||
<td>{s.userId ?? "—"}</td>
|
||||
<td>{s.subscriptionType ?? "—"}</td>
|
||||
<td className="muted">{fmt(s.expiresAt)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
{active.length === 0 ? <p className="muted">No active subscriptions.</p> : null}
|
||||
<div className="admin-stats">
|
||||
<StatusCard label="Active subs" value={active.length} icon="⭐" />
|
||||
<StatusCard label="Types" value={types} icon="🏷️" />
|
||||
<StatusCard
|
||||
label="Expiring ≤ 7d"
|
||||
value={expiringSoon}
|
||||
state={expiringSoon > 0 ? "warn" : "ok"}
|
||||
icon="⏳"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<section className="admin-section">
|
||||
<h2>Active subscriptions ({active.length})</h2>
|
||||
<div className="card">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Sub ID</th>
|
||||
<th>User ID</th>
|
||||
<th>Type</th>
|
||||
<th>Status</th>
|
||||
<th>Expires</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{active.map((s) => {
|
||||
const soon = s.expiresAt <= soonCutoff;
|
||||
return (
|
||||
<tr key={s.id}>
|
||||
<td className="muted">{s.id}</td>
|
||||
<td>{s.userId ?? "—"}</td>
|
||||
<td>{s.subscriptionType ?? "—"}</td>
|
||||
<td>
|
||||
<span className={`admin-badge ${soon ? "" : "ok"}`}>
|
||||
{soon ? "Expiring" : "Active"}
|
||||
</span>
|
||||
</td>
|
||||
<td className="muted">{fmt(s.expiresAt)}</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
{active.length === 0 ? <p className="muted">No active subscriptions.</p> : null}
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
+104
-72
@@ -1,4 +1,5 @@
|
||||
import { createTag, deleteTag, updateTag } from '@/actions/admin-tags';
|
||||
import { StatusCard } from '@/components/admin/dashboard';
|
||||
import { prisma } from '@/lib/prisma';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
@@ -37,10 +38,13 @@ export default async function AdminTagsPage() {
|
||||
tags = [];
|
||||
}
|
||||
|
||||
const inUse = tags.filter((t) => (usage.get(String(t.id)) ?? 0) > 0).length;
|
||||
const unused = tags.length - inUse;
|
||||
|
||||
return (
|
||||
<main>
|
||||
<h1>Tags</h1>
|
||||
<p className="muted">
|
||||
<p className="muted" style={{ marginTop: '-0.4rem' }}>
|
||||
Article tags / categories (<code>tags</code>). Each tag has a label and a
|
||||
background colour, and can be attached to articles via <code>taggables</code>.
|
||||
</p>
|
||||
@@ -51,7 +55,24 @@ export default async function AdminTagsPage() {
|
||||
Could not load tags (database unavailable).
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
) : (
|
||||
<div className="admin-stats">
|
||||
<StatusCard label="Tags" value={tags.length} icon="🏷️" />
|
||||
<StatusCard
|
||||
label="In use"
|
||||
value={inUse}
|
||||
hint="attached to content"
|
||||
state={inUse > 0 ? 'ok' : 'neutral'}
|
||||
icon="🔗"
|
||||
/>
|
||||
<StatusCard
|
||||
label="Unused"
|
||||
value={unused}
|
||||
state={unused > 0 ? 'warn' : 'ok'}
|
||||
icon="🫙"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form action={createTag} className="card" style={{ marginBottom: '1.5rem' }}>
|
||||
<h3 style={{ marginTop: 0 }}>Add tag</h3>
|
||||
@@ -93,77 +114,88 @@ export default async function AdminTagsPage() {
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{tags.length === 0 ? (
|
||||
<p className="muted">No tags yet.</p>
|
||||
) : (
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>Tag</th>
|
||||
<th>Colour</th>
|
||||
<th>Used by</th>
|
||||
<th>Created</th>
|
||||
<th />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{tags.map((t) => (
|
||||
<tr key={String(t.id)}>
|
||||
<td>{String(t.id)}</td>
|
||||
<td>
|
||||
<span
|
||||
style={{
|
||||
display: 'inline-block',
|
||||
padding: '0.1rem 0.5rem',
|
||||
borderRadius: '0.4rem',
|
||||
background: t.backgroundColor,
|
||||
color: '#fff',
|
||||
}}
|
||||
>
|
||||
{t.name}
|
||||
</span>
|
||||
</td>
|
||||
<td className="muted">
|
||||
<code>{t.backgroundColor}</code>
|
||||
</td>
|
||||
<td className="muted">{usage.get(String(t.id)) ?? 0}</td>
|
||||
<td className="muted">{formatDate(t.createdAt)}</td>
|
||||
<td>
|
||||
<form
|
||||
action={updateTag}
|
||||
style={{ display: 'flex', gap: '0.4rem', alignItems: 'center' }}
|
||||
>
|
||||
<input type="hidden" name="id" value={String(t.id)} />
|
||||
<input
|
||||
name="name"
|
||||
defaultValue={t.name}
|
||||
required
|
||||
maxLength={255}
|
||||
style={{ width: 140 }}
|
||||
/>
|
||||
<input
|
||||
name="backgroundColor"
|
||||
defaultValue={t.backgroundColor}
|
||||
maxLength={10}
|
||||
style={{ width: 90 }}
|
||||
/>
|
||||
<button type="submit" className="btn btn-primary">
|
||||
Save
|
||||
</button>
|
||||
</form>
|
||||
<form action={deleteTag} style={{ marginTop: '0.4rem' }}>
|
||||
<input type="hidden" name="id" value={String(t.id)} />
|
||||
<button type="submit" className="btn btn-danger">
|
||||
Delete
|
||||
</button>
|
||||
</form>
|
||||
</td>
|
||||
<section className="admin-section">
|
||||
<h2>All tags</h2>
|
||||
{tags.length === 0 ? (
|
||||
<p className="muted">No tags yet.</p>
|
||||
) : (
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>Tag</th>
|
||||
<th>Colour</th>
|
||||
<th>Used by</th>
|
||||
<th>Created</th>
|
||||
<th />
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</thead>
|
||||
<tbody>
|
||||
{tags.map((t) => (
|
||||
<tr key={String(t.id)}>
|
||||
<td className="muted">{String(t.id)}</td>
|
||||
<td>
|
||||
<span
|
||||
style={{
|
||||
display: 'inline-block',
|
||||
padding: '0.1rem 0.5rem',
|
||||
borderRadius: '0.4rem',
|
||||
background: t.backgroundColor,
|
||||
color: '#fff',
|
||||
}}
|
||||
>
|
||||
{t.name}
|
||||
</span>
|
||||
</td>
|
||||
<td className="muted">
|
||||
<code>{t.backgroundColor}</code>
|
||||
</td>
|
||||
<td>
|
||||
<span
|
||||
className={`admin-badge ${
|
||||
(usage.get(String(t.id)) ?? 0) > 0 ? 'ok' : ''
|
||||
}`}
|
||||
>
|
||||
{usage.get(String(t.id)) ?? 0}
|
||||
</span>
|
||||
</td>
|
||||
<td className="muted">{formatDate(t.createdAt)}</td>
|
||||
<td>
|
||||
<form
|
||||
action={updateTag}
|
||||
style={{ display: 'flex', gap: '0.4rem', alignItems: 'center' }}
|
||||
>
|
||||
<input type="hidden" name="id" value={String(t.id)} />
|
||||
<input
|
||||
name="name"
|
||||
defaultValue={t.name}
|
||||
required
|
||||
maxLength={255}
|
||||
style={{ width: 140 }}
|
||||
/>
|
||||
<input
|
||||
name="backgroundColor"
|
||||
defaultValue={t.backgroundColor}
|
||||
maxLength={10}
|
||||
style={{ width: 90 }}
|
||||
/>
|
||||
<button type="submit" className="btn btn-primary">
|
||||
Save
|
||||
</button>
|
||||
</form>
|
||||
<form action={deleteTag} style={{ marginTop: '0.4rem' }}>
|
||||
<input type="hidden" name="id" value={String(t.id)} />
|
||||
<button type="submit" className="btn btn-danger">
|
||||
Delete
|
||||
</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { createTeam, deleteTeam } from "@/actions/admin-teams";
|
||||
import { StatusCard } from "@/components/admin/dashboard";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
@@ -30,11 +31,27 @@ export default async function AdminTeams() {
|
||||
teams = [];
|
||||
}
|
||||
|
||||
const hidden = teams.filter((t) => t.hiddenRank).length;
|
||||
|
||||
return (
|
||||
<main>
|
||||
<h1>Teams</h1>
|
||||
<p className="muted" style={{ marginTop: "-0.4rem" }}>
|
||||
Staff rank definitions shown on the public team page.
|
||||
</p>
|
||||
|
||||
<div className="card" style={{ marginBottom: "1.25rem" }}>
|
||||
<div className="admin-stats">
|
||||
<StatusCard label="Team ranks" value={teams.length} icon="🛡️" />
|
||||
<StatusCard
|
||||
label="Visible"
|
||||
value={teams.length - hidden}
|
||||
state={teams.length - hidden > 0 ? "ok" : "neutral"}
|
||||
icon="👁️"
|
||||
/>
|
||||
<StatusCard label="Hidden" value={hidden} icon="🙈" />
|
||||
</div>
|
||||
|
||||
<div className="card" style={{ marginBottom: "1.5rem" }}>
|
||||
<h3 style={{ marginTop: 0 }}>New team rank</h3>
|
||||
<form action={createTeam} style={{ display: "grid", gap: "0.6rem" }}>
|
||||
<div className="grid cols-2">
|
||||
@@ -67,63 +84,72 @@ export default async function AdminTeams() {
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{teams.length === 0 ? (
|
||||
<p className="muted">No teams yet.</p>
|
||||
) : (
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>Rank name</th>
|
||||
<th>Badge</th>
|
||||
<th>Job description</th>
|
||||
<th>Color</th>
|
||||
<th>Hidden</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{teams.map((t) => (
|
||||
<tr key={String(t.id)}>
|
||||
<td>{String(t.id)}</td>
|
||||
<td>{t.rankName}</td>
|
||||
<td className="muted">{t.badge ?? "—"}</td>
|
||||
<td className="muted">{t.jobDescription ?? "—"}</td>
|
||||
<td>
|
||||
<span
|
||||
style={{
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
gap: "0.4rem",
|
||||
}}
|
||||
>
|
||||
<span
|
||||
aria-hidden
|
||||
style={{
|
||||
width: 14,
|
||||
height: 14,
|
||||
borderRadius: 4,
|
||||
border: "1px solid var(--border-color)",
|
||||
background: t.staffColor,
|
||||
}}
|
||||
/>
|
||||
{t.staffColor}
|
||||
</span>
|
||||
</td>
|
||||
<td className="muted">{t.hiddenRank ? "Yes" : "No"}</td>
|
||||
<td>
|
||||
<form action={deleteTeam}>
|
||||
<input type="hidden" name="id" value={String(t.id)} />
|
||||
<button type="submit" className="btn btn-danger">
|
||||
Delete
|
||||
</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
<section className="admin-section">
|
||||
<h2>Ranks ({teams.length})</h2>
|
||||
{teams.length === 0 ? (
|
||||
<p className="muted">No teams yet.</p>
|
||||
) : (
|
||||
<div style={{ overflowX: "auto" }}>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>Rank name</th>
|
||||
<th>Badge</th>
|
||||
<th>Job description</th>
|
||||
<th>Color</th>
|
||||
<th>Visibility</th>
|
||||
<th />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{teams.map((t) => (
|
||||
<tr key={String(t.id)}>
|
||||
<td className="muted">{String(t.id)}</td>
|
||||
<td>{t.rankName}</td>
|
||||
<td className="muted">{t.badge ?? "—"}</td>
|
||||
<td className="muted">{t.jobDescription ?? "—"}</td>
|
||||
<td>
|
||||
<span
|
||||
style={{
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
gap: "0.4rem",
|
||||
}}
|
||||
>
|
||||
<span
|
||||
aria-hidden
|
||||
style={{
|
||||
width: 14,
|
||||
height: 14,
|
||||
borderRadius: 4,
|
||||
border: "1px solid var(--border-color)",
|
||||
background: t.staffColor,
|
||||
}}
|
||||
/>
|
||||
{t.staffColor}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<span className={`admin-badge ${t.hiddenRank ? "" : "ok"}`}>
|
||||
{t.hiddenRank ? "hidden" : "visible"}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<form action={deleteTeam}>
|
||||
<input type="hidden" name="id" value={String(t.id)} />
|
||||
<button type="submit" className="btn btn-danger">
|
||||
Delete
|
||||
</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import Link from "next/link";
|
||||
import { StatusCard } from "@/components/admin/dashboard";
|
||||
import { requireStaff } from "@/lib/admin/guard";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
@@ -15,6 +16,23 @@ const PER_PAGE = 25;
|
||||
|
||||
type Txn = Awaited<ReturnType<typeof prisma.websitePaypalTransactions.findMany>>[number];
|
||||
|
||||
const PAID_STATUS = new Set(["completed", "paid", "success", "approved"]);
|
||||
const FAILED_STATUS = new Set([
|
||||
"failed",
|
||||
"denied",
|
||||
"refunded",
|
||||
"cancelled",
|
||||
"canceled",
|
||||
]);
|
||||
|
||||
function statusState(status: string | null | undefined): "ok" | "danger" | undefined {
|
||||
if (!status) return undefined;
|
||||
const s = status.toLowerCase();
|
||||
if (PAID_STATUS.has(s)) return "ok";
|
||||
if (FAILED_STATUS.has(s)) return "danger";
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function fromDate(d: Date | null | undefined): string {
|
||||
return d ? d.toISOString().slice(0, 16).replace("T", " ") : "—";
|
||||
}
|
||||
@@ -65,6 +83,9 @@ export default async function AdminTransactions({
|
||||
|
||||
const pages = Math.max(1, Math.ceil(total / PER_PAGE));
|
||||
|
||||
const pagePaid = transactions.filter((t) => statusState(t.status) === "ok").length;
|
||||
const pageFailed = transactions.filter((t) => statusState(t.status) === "danger").length;
|
||||
|
||||
return (
|
||||
<main>
|
||||
<h1>Transactions</h1>
|
||||
@@ -72,14 +93,31 @@ export default async function AdminTransactions({
|
||||
Read-only · PayPal / top-up transactions recorded by the CMS, newest first.
|
||||
</p>
|
||||
|
||||
<section className="card">
|
||||
<div className="admin-stats">
|
||||
<StatusCard label="Total transactions" value={total} icon="🧾" />
|
||||
<StatusCard
|
||||
label="Paid (this page)"
|
||||
value={pagePaid}
|
||||
state={pagePaid > 0 ? "ok" : "neutral"}
|
||||
icon="💳"
|
||||
/>
|
||||
<StatusCard
|
||||
label="Failed (this page)"
|
||||
value={pageFailed}
|
||||
state={pageFailed > 0 ? "danger" : "neutral"}
|
||||
icon="⚠️"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<section className="admin-section">
|
||||
<h2>Transactions ({total})</h2>
|
||||
{transactions.length === 0 ? (
|
||||
<p className="muted">No transactions.</p>
|
||||
) : (
|
||||
<div style={{ overflowX: "auto" }}>
|
||||
<table style={{ width: "100%", borderCollapse: "collapse" }}>
|
||||
<table>
|
||||
<thead>
|
||||
<tr style={{ textAlign: "left", color: "var(--muted)" }}>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>Payer</th>
|
||||
<th>Amount</th>
|
||||
@@ -90,21 +128,34 @@ export default async function AdminTransactions({
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{transactions.map((t) => (
|
||||
<tr key={String(t.id)} style={{ borderTop: "1px solid var(--border)" }}>
|
||||
<td>{String(t.id)}</td>
|
||||
<td>
|
||||
<Link href={`/admin/users/${t.userId}`}>
|
||||
{names.get(t.userId) ?? `#${t.userId}`}
|
||||
</Link>
|
||||
</td>
|
||||
<td>{formatAmount(t.amount, t.currency)}</td>
|
||||
<td>{t.description ?? "—"}</td>
|
||||
<td>{t.status ?? "—"}</td>
|
||||
<td className="muted">{t.transactionId}</td>
|
||||
<td className="muted">{fromDate(t.createdAt)}</td>
|
||||
</tr>
|
||||
))}
|
||||
{transactions.map((t) => {
|
||||
const state = statusState(t.status);
|
||||
return (
|
||||
<tr key={String(t.id)}>
|
||||
<td className="muted">{String(t.id)}</td>
|
||||
<td>
|
||||
<Link href={`/admin/users/${t.userId}`}>
|
||||
{names.get(t.userId) ?? `#${t.userId}`}
|
||||
</Link>
|
||||
</td>
|
||||
<td>{formatAmount(t.amount, t.currency)}</td>
|
||||
<td>{t.description ?? "—"}</td>
|
||||
<td>
|
||||
{t.status ? (
|
||||
<span
|
||||
className={`admin-badge ${state === "ok" ? "ok" : state === "danger" ? "danger" : ""}`}
|
||||
>
|
||||
{t.status}
|
||||
</span>
|
||||
) : (
|
||||
<span className="muted">—</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="muted">{t.transactionId}</td>
|
||||
<td className="muted">{fromDate(t.createdAt)}</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
setMotto,
|
||||
setRank,
|
||||
} from "@/actions/admin-users";
|
||||
import { InfoItem, StatusCard } from "@/components/admin/dashboard";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
@@ -26,59 +27,103 @@ export default async function AdminUserDetail({
|
||||
.catch(() => null);
|
||||
if (!user) notFound();
|
||||
|
||||
const isOnline = user.online === "1";
|
||||
|
||||
return (
|
||||
<main>
|
||||
<p className="muted">
|
||||
<Link href="/admin/users">← Users</Link>
|
||||
</p>
|
||||
<h1>{user.username}</h1>
|
||||
<p className="muted">
|
||||
ID {user.id} · rank {user.rank} · {user.credits} credits ·{" "}
|
||||
{user.online === "1" ? "Online" : "Offline"}
|
||||
<p className="muted" style={{ marginTop: "-0.4rem" }}>
|
||||
{user.motto ? user.motto : "No motto set."}
|
||||
</p>
|
||||
|
||||
<div className="grid cols-2">
|
||||
<form action={giveCurrency} className="card">
|
||||
<input type="hidden" name="userId" value={user.id} />
|
||||
<h3 style={{ marginTop: 0 }}>Give currency</h3>
|
||||
<select name="type">
|
||||
<option value="credits">Credits</option>
|
||||
<option value="duckets">Duckets</option>
|
||||
<option value="diamonds">Diamonds</option>
|
||||
<option value="points">Points</option>
|
||||
</select>{" "}
|
||||
<input name="amount" type="number" min={1} placeholder="Amount" />{" "}
|
||||
<button type="submit">Give</button>
|
||||
</form>
|
||||
|
||||
<form action={setMotto} className="card">
|
||||
<input type="hidden" name="userId" value={user.id} />
|
||||
<h3 style={{ marginTop: 0 }}>Set motto</h3>
|
||||
<input name="motto" defaultValue={user.motto} maxLength={127} />{" "}
|
||||
<button type="submit">Save</button>
|
||||
</form>
|
||||
|
||||
<form action={setRank} className="card">
|
||||
<input type="hidden" name="userId" value={user.id} />
|
||||
<h3 style={{ marginTop: 0 }}>Set rank</h3>
|
||||
<input name="rank" type="number" min={1} defaultValue={user.rank} />{" "}
|
||||
<button type="submit">Set</button>
|
||||
</form>
|
||||
|
||||
<form action={alertUser} className="card">
|
||||
<input type="hidden" name="userId" value={user.id} />
|
||||
<h3 style={{ marginTop: 0 }}>Alert</h3>
|
||||
<input name="message" placeholder="Message" />{" "}
|
||||
<button type="submit">Send</button>
|
||||
</form>
|
||||
|
||||
<form action={disconnectUser} className="card">
|
||||
<input type="hidden" name="userId" value={user.id} />
|
||||
<input type="hidden" name="username" value={user.username} />
|
||||
<h3 style={{ marginTop: 0 }}>Disconnect</h3>
|
||||
<button type="submit">Disconnect from hotel</button>
|
||||
</form>
|
||||
<div className="admin-stats">
|
||||
<StatusCard label="User ID" value={user.id} icon="🆔" />
|
||||
<StatusCard label="Rank" value={user.rank} icon="🎖️" />
|
||||
<StatusCard label="Credits" value={user.credits} icon="💰" />
|
||||
<StatusCard
|
||||
label="Status"
|
||||
value={isOnline ? "Online" : "Offline"}
|
||||
state={isOnline ? "ok" : "neutral"}
|
||||
icon={isOnline ? "🟢" : "⚪"}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<section className="admin-section">
|
||||
<h2>Overview</h2>
|
||||
<div className="admin-info-grid">
|
||||
<InfoItem label="Username" value={user.username} />
|
||||
<InfoItem
|
||||
label="Status"
|
||||
value={
|
||||
<span className={`admin-badge ${isOnline ? "ok" : ""}`}>
|
||||
{isOnline ? "online" : "offline"}
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
<InfoItem label="Rank" value={user.rank} />
|
||||
<InfoItem label="Credits" value={user.credits} />
|
||||
<InfoItem label="Look" value={<code>{user.look || "—"}</code>} />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="admin-section">
|
||||
<h2>Actions</h2>
|
||||
<div className="grid cols-2">
|
||||
<form action={giveCurrency} className="card">
|
||||
<input type="hidden" name="userId" value={user.id} />
|
||||
<h3 style={{ marginTop: 0 }}>Give currency</h3>
|
||||
<select name="type">
|
||||
<option value="credits">Credits</option>
|
||||
<option value="duckets">Duckets</option>
|
||||
<option value="diamonds">Diamonds</option>
|
||||
<option value="points">Points</option>
|
||||
</select>{" "}
|
||||
<input name="amount" type="number" min={1} placeholder="Amount" />{" "}
|
||||
<button type="submit" className="btn btn-primary">
|
||||
Give
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<form action={setMotto} className="card">
|
||||
<input type="hidden" name="userId" value={user.id} />
|
||||
<h3 style={{ marginTop: 0 }}>Set motto</h3>
|
||||
<input name="motto" defaultValue={user.motto} maxLength={127} />{" "}
|
||||
<button type="submit" className="btn btn-primary">
|
||||
Save
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<form action={setRank} className="card">
|
||||
<input type="hidden" name="userId" value={user.id} />
|
||||
<h3 style={{ marginTop: 0 }}>Set rank</h3>
|
||||
<input name="rank" type="number" min={1} defaultValue={user.rank} />{" "}
|
||||
<button type="submit" className="btn btn-primary">
|
||||
Set
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<form action={alertUser} className="card">
|
||||
<input type="hidden" name="userId" value={user.id} />
|
||||
<h3 style={{ marginTop: 0 }}>Alert</h3>
|
||||
<input name="message" placeholder="Message" />{" "}
|
||||
<button type="submit" className="btn btn-primary">
|
||||
Send
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<form action={disconnectUser} className="card">
|
||||
<input type="hidden" name="userId" value={user.id} />
|
||||
<input type="hidden" name="username" value={user.username} />
|
||||
<h3 style={{ marginTop: 0 }}>Disconnect</h3>
|
||||
<button type="submit" className="btn btn-danger">
|
||||
Disconnect from hotel
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import Link from "next/link";
|
||||
import { StatusCard } from "@/components/admin/dashboard";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
@@ -29,38 +30,78 @@ export default async function AdminUsers({
|
||||
const total = await prisma.user.count({ where }).catch(() => 0);
|
||||
const pages = Math.max(1, Math.ceil(total / PER_PAGE));
|
||||
|
||||
const onlineOnPage = users.filter((u) => u.online === "1").length;
|
||||
|
||||
return (
|
||||
<main>
|
||||
<h1>Users</h1>
|
||||
<form style={{ display: "flex", gap: "0.5rem", marginBottom: "1rem" }}>
|
||||
<input name="q" defaultValue={q} placeholder="Search username" />
|
||||
<button type="submit">Search</button>
|
||||
<p className="muted" style={{ marginTop: "-0.4rem" }}>
|
||||
{q ? `Search results for “${q}”.` : "Most recent accounts first."}
|
||||
</p>
|
||||
|
||||
<div className="admin-stats">
|
||||
<StatusCard label="Total users" value={total} icon="👥" />
|
||||
<StatusCard label="On this page" value={users.length} icon="📄" />
|
||||
<StatusCard
|
||||
label="Online (page)"
|
||||
value={onlineOnPage}
|
||||
state={onlineOnPage > 0 ? "ok" : "neutral"}
|
||||
icon="🟢"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<form
|
||||
className="card"
|
||||
style={{ display: "flex", gap: "0.5rem", marginBottom: "1.5rem", flexWrap: "wrap" }}
|
||||
>
|
||||
<input
|
||||
name="q"
|
||||
defaultValue={q}
|
||||
placeholder="Search username"
|
||||
style={{ flex: 1, minWidth: 200 }}
|
||||
/>
|
||||
<button type="submit" className="btn btn-primary">
|
||||
Search
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<table style={{ width: "100%", borderCollapse: "collapse" }}>
|
||||
<thead>
|
||||
<tr style={{ textAlign: "left", color: "var(--muted)" }}>
|
||||
<th>ID</th>
|
||||
<th>Username</th>
|
||||
<th>Rank</th>
|
||||
<th>Credits</th>
|
||||
<th>Online</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{users.map((u) => (
|
||||
<tr key={u.id} style={{ borderTop: "1px solid var(--border)" }}>
|
||||
<td>{u.id}</td>
|
||||
<td>
|
||||
<Link href={`/admin/users/${u.id}`}>{u.username}</Link>
|
||||
</td>
|
||||
<td>{u.rank}</td>
|
||||
<td>{u.credits}</td>
|
||||
<td>{u.online === "1" ? "●" : "○"}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
<section className="admin-section">
|
||||
<h2>Accounts ({total})</h2>
|
||||
{users.length === 0 ? (
|
||||
<p className="muted">No users match this search.</p>
|
||||
) : (
|
||||
<div style={{ overflowX: "auto" }}>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>Username</th>
|
||||
<th>Rank</th>
|
||||
<th>Credits</th>
|
||||
<th>Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{users.map((u) => (
|
||||
<tr key={u.id}>
|
||||
<td className="muted">{u.id}</td>
|
||||
<td>
|
||||
<Link href={`/admin/users/${u.id}`}>{u.username}</Link>
|
||||
</td>
|
||||
<td>{u.rank}</td>
|
||||
<td>{u.credits}</td>
|
||||
<td>
|
||||
<span className={`admin-badge ${u.online === "1" ? "ok" : ""}`}>
|
||||
{u.online === "1" ? "online" : "offline"}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<p className="muted">
|
||||
Page {page} / {pages} · {total} users
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { createVoucher, deleteVoucher } from "@/actions/admin-vouchers";
|
||||
import { StatusCard } from "@/components/admin/dashboard";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
@@ -40,14 +41,48 @@ export default async function AdminVouchers() {
|
||||
vouchers = [];
|
||||
}
|
||||
|
||||
const active = vouchers.filter((v) => {
|
||||
const expired = v.expiresAt ? v.expiresAt.getTime() <= now : false;
|
||||
const usedUp = v.maxUses > 0 && v.useCount >= v.maxUses;
|
||||
return !expired && !usedUp;
|
||||
}).length;
|
||||
const expiredCount = vouchers.filter(
|
||||
(v) => v.expiresAt && v.expiresAt.getTime() <= now,
|
||||
).length;
|
||||
const usedUpCount = vouchers.filter(
|
||||
(v) => v.maxUses > 0 && v.useCount >= v.maxUses,
|
||||
).length;
|
||||
|
||||
return (
|
||||
<main>
|
||||
<h1>Shop Vouchers</h1>
|
||||
<p className="muted">
|
||||
<p className="muted" style={{ marginTop: "-0.4rem" }}>
|
||||
Codes redeemable on the website shop for a balance top-up. Each user can
|
||||
use a given voucher once, up to its max uses.
|
||||
</p>
|
||||
|
||||
<div className="admin-stats">
|
||||
<StatusCard label="Vouchers" value={vouchers.length} icon="🎟️" />
|
||||
<StatusCard
|
||||
label="Active"
|
||||
value={active}
|
||||
state={active > 0 ? "ok" : "neutral"}
|
||||
icon="✅"
|
||||
/>
|
||||
<StatusCard
|
||||
label="Used up"
|
||||
value={usedUpCount}
|
||||
state={usedUpCount > 0 ? "warn" : "neutral"}
|
||||
icon="📉"
|
||||
/>
|
||||
<StatusCard
|
||||
label="Expired"
|
||||
value={expiredCount}
|
||||
state={expiredCount > 0 ? "danger" : "neutral"}
|
||||
icon="⏰"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<form action={createVoucher} className="card" style={{ marginBottom: "1.5rem" }}>
|
||||
<h3 style={{ marginTop: 0 }}>New voucher</h3>
|
||||
<div className="grid cols-2" style={{ marginBottom: "0.75rem" }}>
|
||||
@@ -71,53 +106,61 @@ export default async function AdminVouchers() {
|
||||
<button type="submit" className="btn btn-primary">Create voucher</button>
|
||||
</form>
|
||||
|
||||
<div className="card">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Code</th>
|
||||
<th>Amount</th>
|
||||
<th>Uses</th>
|
||||
<th>Expires</th>
|
||||
<th>Created</th>
|
||||
<th />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{vouchers.map((v) => {
|
||||
const expired = v.expiresAt ? v.expiresAt.getTime() <= now : false;
|
||||
const usedUp = v.maxUses > 0 && v.useCount >= v.maxUses;
|
||||
return (
|
||||
<tr key={v.id.toString()}>
|
||||
<td>
|
||||
<code>{v.code}</code>
|
||||
{expired ? (
|
||||
<span className="muted"> · expired</span>
|
||||
) : usedUp ? (
|
||||
<span className="muted"> · used up</span>
|
||||
) : null}
|
||||
</td>
|
||||
<td>{v.amount}</td>
|
||||
<td>
|
||||
{v.useCount} / {v.maxUses}
|
||||
</td>
|
||||
<td className="muted">{fmtDate(v.expiresAt)}</td>
|
||||
<td className="muted">{fmtDate(v.createdAt)}</td>
|
||||
<td>
|
||||
<form action={deleteVoucher}>
|
||||
<input type="hidden" name="id" value={v.id.toString()} />
|
||||
<button type="submit" className="btn btn-danger">
|
||||
Delete
|
||||
</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
{vouchers.length === 0 ? <p className="muted">No vouchers yet.</p> : null}
|
||||
</div>
|
||||
<section className="admin-section">
|
||||
<h2>Vouchers ({vouchers.length})</h2>
|
||||
<div className="card">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Code</th>
|
||||
<th>Amount</th>
|
||||
<th>Uses</th>
|
||||
<th>Status</th>
|
||||
<th>Expires</th>
|
||||
<th>Created</th>
|
||||
<th />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{vouchers.map((v) => {
|
||||
const expired = v.expiresAt ? v.expiresAt.getTime() <= now : false;
|
||||
const usedUp = v.maxUses > 0 && v.useCount >= v.maxUses;
|
||||
return (
|
||||
<tr key={v.id.toString()}>
|
||||
<td>
|
||||
<code>{v.code}</code>
|
||||
</td>
|
||||
<td>{v.amount}</td>
|
||||
<td>
|
||||
{v.useCount} / {v.maxUses}
|
||||
</td>
|
||||
<td>
|
||||
{expired ? (
|
||||
<span className="admin-badge danger">Expired</span>
|
||||
) : usedUp ? (
|
||||
<span className="admin-badge">Used up</span>
|
||||
) : (
|
||||
<span className="admin-badge ok">Active</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="muted">{fmtDate(v.expiresAt)}</td>
|
||||
<td className="muted">{fmtDate(v.createdAt)}</td>
|
||||
<td>
|
||||
<form action={deleteVoucher}>
|
||||
<input type="hidden" name="id" value={v.id.toString()} />
|
||||
<button type="submit" className="btn btn-danger">
|
||||
Delete
|
||||
</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
{vouchers.length === 0 ? <p className="muted">No vouchers yet.</p> : null}
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { addWord, deleteWord } from "@/actions/admin-wordfilter";
|
||||
import { StatusCard } from "@/components/admin/dashboard";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
@@ -20,40 +21,70 @@ export default async function AdminWordFilter() {
|
||||
return (
|
||||
<main>
|
||||
<h1>Word Filter</h1>
|
||||
<p className="muted">Words added here are blocked in-game. Saving pushes the list to the emulator.</p>
|
||||
<p className="muted" style={{ marginTop: "-0.4rem" }}>
|
||||
Words added here are blocked in-game. Saving pushes the list to the emulator.
|
||||
</p>
|
||||
|
||||
<form action={addWord} className="card" style={{ marginBottom: "1.5rem" }}>
|
||||
<h3 style={{ marginTop: 0 }}>Add word</h3>
|
||||
<div style={{ display: "flex", gap: "0.5rem", flexWrap: "wrap" }}>
|
||||
<input name="word" placeholder="Word to filter" required maxLength={255} style={{ flex: 1, minWidth: 200 }} />
|
||||
<button type="submit" className="btn btn-primary">Add</button>
|
||||
</div>
|
||||
</form>
|
||||
<div className="admin-stats">
|
||||
<StatusCard label="Filtered words" value={words.length} icon="🚫" />
|
||||
</div>
|
||||
|
||||
<table style={{ width: "100%", borderCollapse: "collapse" }}>
|
||||
<thead>
|
||||
<tr style={{ textAlign: "left", color: "var(--muted)" }}>
|
||||
<th>ID</th>
|
||||
<th>Word</th>
|
||||
<th />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{words.map((w) => (
|
||||
<tr key={String(w.id)} style={{ borderTop: "1px solid var(--border)" }}>
|
||||
<td>{String(w.id)}</td>
|
||||
<td>{w.word}</td>
|
||||
<td>
|
||||
<form action={deleteWord}>
|
||||
<input type="hidden" name="id" value={String(w.id)} />
|
||||
<button type="submit" className="btn btn-danger">Delete</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
{words.length === 0 ? <p className="muted">No filtered words.</p> : null}
|
||||
<section className="admin-section">
|
||||
<h2>Add word</h2>
|
||||
<form action={addWord} className="card">
|
||||
<div style={{ display: "flex", gap: "0.5rem", flexWrap: "wrap" }}>
|
||||
<input
|
||||
name="word"
|
||||
placeholder="Word to filter"
|
||||
required
|
||||
maxLength={255}
|
||||
style={{ flex: 1, minWidth: 200 }}
|
||||
/>
|
||||
<button type="submit" className="btn btn-primary">
|
||||
Add
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section className="admin-section">
|
||||
<h2>
|
||||
Words <span className="admin-badge">{words.length}</span>
|
||||
</h2>
|
||||
{words.length === 0 ? (
|
||||
<p className="muted">No filtered words.</p>
|
||||
) : (
|
||||
<div className="card" style={{ padding: 0, overflowX: "auto" }}>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>Word</th>
|
||||
<th />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{words.map((w) => (
|
||||
<tr key={String(w.id)}>
|
||||
<td className="muted">{String(w.id)}</td>
|
||||
<td>
|
||||
<strong>{w.word}</strong>
|
||||
</td>
|
||||
<td>
|
||||
<form action={deleteWord}>
|
||||
<input type="hidden" name="id" value={String(w.id)} />
|
||||
<button type="submit" className="btn btn-danger">
|
||||
Delete
|
||||
</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user