refactor: convert admin panel to Tailwind CSS
- Convert admin layout, sidebar, and navigation to Tailwind classes - Convert dashboard components (StatusCard, DiagnosticRow, etc.) to Tailwind - Convert all ~48 admin page files from admin CSS classes to Tailwind - Remove unused admin CSS from globals.css (973 → 712 lines) - Convert developers page badges to Tailwind - Remove <style jsx> block from OnlineUsersWidget
This commit is contained in:
1 parent
64f50b2dde
commit
bd299be55d
52 files changed
+277
-644
No files matched your search
@@ -50,7 +50,7 @@ export default async function AdminAchievements() {
|
|||||||
Read-only · {achievements.length} rows across {groups.size} categories.
|
Read-only · {achievements.length} rows across {groups.size} categories.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div className="admin-stats">
|
<div className="grid grid-cols-[repeat(auto-fit,minmax(180px,1fr))] gap-3.5 mb-6">
|
||||||
<StatusCard label="Achievement rows" value={achievements.length} icon="🏆" />
|
<StatusCard label="Achievement rows" value={achievements.length} icon="🏆" />
|
||||||
<StatusCard label="Distinct achievements" value={distinctNames} icon="🎖️" />
|
<StatusCard label="Distinct achievements" value={distinctNames} icon="🎖️" />
|
||||||
<StatusCard label="Categories" value={groups.size} icon="🗂️" />
|
<StatusCard label="Categories" value={groups.size} icon="🗂️" />
|
||||||
@@ -60,10 +60,10 @@ export default async function AdminAchievements() {
|
|||||||
<p className="muted">No achievements found.</p>
|
<p className="muted">No achievements found.</p>
|
||||||
) : (
|
) : (
|
||||||
[...groups.entries()].map(([category, rows]) => (
|
[...groups.entries()].map(([category, rows]) => (
|
||||||
<section key={category} className="admin-section">
|
<section key={category} className="mt-6">
|
||||||
<h2>
|
<h2>
|
||||||
{category}{" "}
|
{category}{" "}
|
||||||
<span className="admin-badge">{rows.length}</span>
|
<span className="inline-block text-[0.72rem] font-bold px-2 py-0.5 rounded-full bg-[var(--color-primary)]/18 text-[var(--color-text)]">{rows.length}</span>
|
||||||
</h2>
|
</h2>
|
||||||
<div className="card" style={{ padding: 0, overflowX: "auto" }}>
|
<div className="card" style={{ padding: 0, overflowX: "auto" }}>
|
||||||
<table>
|
<table>
|
||||||
|
|||||||
@@ -32,12 +32,12 @@ export default async function AdminAds() {
|
|||||||
across the site.
|
across the site.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div className="admin-stats">
|
<div className="grid grid-cols-[repeat(auto-fit,minmax(180px,1fr))] gap-3.5 mb-6">
|
||||||
<StatusCard label="Ads" value={ads.length} icon="🖼️" />
|
<StatusCard label="Ads" value={ads.length} icon="🖼️" />
|
||||||
<StatusCard label="Latest" value={latest} hint="most recent ad" icon="🕒" />
|
<StatusCard label="Latest" value={latest} hint="most recent ad" icon="🕒" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<section className="admin-section">
|
<section className="mt-6">
|
||||||
<h2>All ads</h2>
|
<h2>All ads</h2>
|
||||||
{ads.length === 0 ? (
|
{ads.length === 0 ? (
|
||||||
<p className="muted">No advertisements yet.</p>
|
<p className="muted">No advertisements yet.</p>
|
||||||
|
|||||||
@@ -79,7 +79,7 @@ export default async function AdminAlerts() {
|
|||||||
Read-only · 100 most recent alert_logs (newest first).
|
Read-only · 100 most recent alert_logs (newest first).
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div className="admin-stats">
|
<div className="grid grid-cols-[repeat(auto-fit,minmax(180px,1fr))] gap-3.5 mb-6">
|
||||||
<StatusCard label="Alerts shown" value={alerts.length} icon="🔔" />
|
<StatusCard label="Alerts shown" value={alerts.length} icon="🔔" />
|
||||||
<StatusCard
|
<StatusCard
|
||||||
label="Critical"
|
label="Critical"
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ export default async function AdminApplications() {
|
|||||||
Pending applications from players requesting a staff rank.
|
Pending applications from players requesting a staff rank.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div className="admin-stats">
|
<div className="grid grid-cols-[repeat(auto-fit,minmax(180px,1fr))] gap-3.5 mb-6">
|
||||||
<StatusCard
|
<StatusCard
|
||||||
label="Pending applications"
|
label="Pending applications"
|
||||||
value={applications.length}
|
value={applications.length}
|
||||||
@@ -59,7 +59,7 @@ export default async function AdminApplications() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<section className="admin-section">
|
<section className="mt-6">
|
||||||
<h2>Applications ({applications.length})</h2>
|
<h2>Applications ({applications.length})</h2>
|
||||||
{applications.length === 0 ? (
|
{applications.length === 0 ? (
|
||||||
<p className="muted">No pending applications.</p>
|
<p className="muted">No pending applications.</p>
|
||||||
@@ -84,7 +84,7 @@ export default async function AdminApplications() {
|
|||||||
<>User #{a.userId}</>
|
<>User #{a.userId}</>
|
||||||
)}
|
)}
|
||||||
</h3>
|
</h3>
|
||||||
<span className="admin-badge">Rank {a.rankId}</span>
|
<span className="inline-block text-[0.72rem] font-bold px-2 py-0.5 rounded-full bg-[var(--color-primary)]/18 text-[var(--color-text)]">Rank {a.rankId}</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="muted" style={{ marginTop: 0 }}>
|
<p className="muted" style={{ marginTop: 0 }}>
|
||||||
{formatDate(a.createdAt)}
|
{formatDate(a.createdAt)}
|
||||||
|
|||||||
@@ -41,12 +41,12 @@ export default async function AdminArticles({
|
|||||||
</p>
|
</p>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<div className="admin-stats">
|
<div className="grid grid-cols-[repeat(auto-fit,minmax(180px,1fr))] gap-3.5 mb-6">
|
||||||
<StatusCard label="Articles shown" value={articles.length} icon="📰" />
|
<StatusCard label="Articles shown" value={articles.length} icon="📰" />
|
||||||
<StatusCard label="Latest" value={latest} hint="most recent post" icon="🕒" />
|
<StatusCard label="Latest" value={latest} hint="most recent post" icon="🕒" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<section className="admin-section">
|
<section className="mt-6">
|
||||||
<h2>Recent articles</h2>
|
<h2>Recent articles</h2>
|
||||||
{articles.length === 0 ? (
|
{articles.length === 0 ? (
|
||||||
<p className="muted">No articles yet.</p>
|
<p className="muted">No articles yet.</p>
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ export default async function AdminBadges({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<main>
|
<main>
|
||||||
<section className="admin-section">
|
<section className="mt-6">
|
||||||
<h2>Badges</h2>
|
<h2>Badges</h2>
|
||||||
<p className="muted">
|
<p className="muted">
|
||||||
Website badge catalogue (<code>website_badges</code>). Granting fires the emulator
|
Website badge catalogue (<code>website_badges</code>). Granting fires the emulator
|
||||||
@@ -34,18 +34,18 @@ export default async function AdminBadges({
|
|||||||
|
|
||||||
{uploaded ? (
|
{uploaded ? (
|
||||||
<p>
|
<p>
|
||||||
<span className="admin-badge ok">Uploaded</span> Badge image{" "}
|
<span className="inline-block text-[0.72rem] font-bold px-2 py-0.5 rounded-full bg-green-600/20 text-[var(--color-text)]">Uploaded</span> Badge image{" "}
|
||||||
<code>{uploaded}.gif</code> saved.
|
<code>{uploaded}.gif</code> saved.
|
||||||
</p>
|
</p>
|
||||||
) : null}
|
) : null}
|
||||||
{error ? (
|
{error ? (
|
||||||
<p>
|
<p>
|
||||||
<span className="admin-badge danger">Error</span> {error}
|
<span className="inline-block text-[0.72rem] font-bold px-2 py-0.5 rounded-full bg-red-500/22 text-[var(--color-text)]">Error</span> {error}
|
||||||
</p>
|
</p>
|
||||||
) : null}
|
) : null}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="admin-section">
|
<section className="mt-6">
|
||||||
<h2>Upload badge image</h2>
|
<h2>Upload badge image</h2>
|
||||||
<p className="muted" style={{ marginTop: "-0.4rem" }}>
|
<p className="muted" style={{ marginTop: "-0.4rem" }}>
|
||||||
Saves a <code>.gif</code> badge image to the emulator badge directory
|
Saves a <code>.gif</code> badge image to the emulator badge directory
|
||||||
@@ -69,7 +69,7 @@ export default async function AdminBadges({
|
|||||||
</form>
|
</form>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="admin-section">
|
<section className="mt-6">
|
||||||
<h2>Give badge to user</h2>
|
<h2>Give badge to user</h2>
|
||||||
<form action={giveBadge} className="card">
|
<form action={giveBadge} className="card">
|
||||||
<div style={{ display: "flex", gap: "0.5rem", flexWrap: "wrap", alignItems: "center" }}>
|
<div style={{ display: "flex", gap: "0.5rem", flexWrap: "wrap", alignItems: "center" }}>
|
||||||
@@ -96,7 +96,7 @@ export default async function AdminBadges({
|
|||||||
</form>
|
</form>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="admin-section">
|
<section className="mt-6">
|
||||||
<h2>Catalogue ({badges.length})</h2>
|
<h2>Catalogue ({badges.length})</h2>
|
||||||
{badges.length === 0 ? (
|
{badges.length === 0 ? (
|
||||||
<p className="muted">No badges in the catalogue.</p>
|
<p className="muted">No badges in the catalogue.</p>
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ export default async function AdminBans() {
|
|||||||
Active bans only (expiry in the future) · newest first.
|
Active bans only (expiry in the future) · newest first.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div className="admin-stats">
|
<div className="grid grid-cols-[repeat(auto-fit,minmax(180px,1fr))] gap-3.5 mb-6">
|
||||||
<StatusCard
|
<StatusCard
|
||||||
label="Active bans"
|
label="Active bans"
|
||||||
value={bans.length}
|
value={bans.length}
|
||||||
@@ -66,7 +66,7 @@ export default async function AdminBans() {
|
|||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<section className="admin-section">
|
<section className="mt-6">
|
||||||
<h2>Active bans ({bans.length})</h2>
|
<h2>Active bans ({bans.length})</h2>
|
||||||
{bans.length === 0 ? (
|
{bans.length === 0 ? (
|
||||||
<p className="muted">No active bans.</p>
|
<p className="muted">No active bans.</p>
|
||||||
@@ -87,12 +87,12 @@ export default async function AdminBans() {
|
|||||||
<tr key={b.id}>
|
<tr key={b.id}>
|
||||||
<td>{b.userId}</td>
|
<td>{b.userId}</td>
|
||||||
<td>
|
<td>
|
||||||
<span className="admin-badge">{b.type}</span>
|
<span className="inline-block text-[0.72rem] font-bold px-2 py-0.5 rounded-full bg-[var(--color-primary)]/18 text-[var(--color-text)]">{b.type}</span>
|
||||||
</td>
|
</td>
|
||||||
<td>{b.banReason || "—"}</td>
|
<td>{b.banReason || "—"}</td>
|
||||||
<td>
|
<td>
|
||||||
{b.banExpire === 0 ? (
|
{b.banExpire === 0 ? (
|
||||||
<span className="admin-badge danger">permanent</span>
|
<span className="inline-block text-[0.72rem] font-bold px-2 py-0.5 rounded-full bg-red-500/22 text-[var(--color-text)]">permanent</span>
|
||||||
) : (
|
) : (
|
||||||
<span className="muted" style={{ whiteSpace: "nowrap" }}>
|
<span className="muted" style={{ whiteSpace: "nowrap" }}>
|
||||||
{fromUnix(b.banExpire)}
|
{fromUnix(b.banExpire)}
|
||||||
|
|||||||
@@ -62,7 +62,7 @@ export default async function AdminCalendar() {
|
|||||||
Read-only · {campaigns.length} campaign{campaigns.length === 1 ? "" : "s"}.
|
Read-only · {campaigns.length} campaign{campaigns.length === 1 ? "" : "s"}.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div className="admin-stats">
|
<div className="grid grid-cols-[repeat(auto-fit,minmax(180px,1fr))] gap-3.5 mb-6">
|
||||||
<StatusCard label="Campaigns" value={campaigns.length} icon="📅" />
|
<StatusCard label="Campaigns" value={campaigns.length} icon="📅" />
|
||||||
<StatusCard
|
<StatusCard
|
||||||
label="Enabled"
|
label="Enabled"
|
||||||
@@ -104,7 +104,7 @@ export default async function AdminCalendar() {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<h3 style={{ margin: 0 }}>{c.name || `Campaign #${c.id}`}</h3>
|
<h3 style={{ margin: 0 }}>{c.name || `Campaign #${c.id}`}</h3>
|
||||||
<span className={`admin-badge ${enabled ? "ok" : "danger"}`}>
|
<span className={`inline-block text-[0.72rem] font-bold px-2 py-0.5 rounded-full ${enabled ? "bg-green-600/20 text-[var(--color-text)]" : "bg-red-500/22 text-[var(--color-text)]"}`}>
|
||||||
{enabled ? "enabled" : "disabled"}
|
{enabled ? "enabled" : "disabled"}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -53,7 +53,7 @@ export default async function AdminCatalog() {
|
|||||||
Browse catalog pages and the items they contain. Read-only.
|
Browse catalog pages and the items they contain. Read-only.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div className="admin-stats">
|
<div className="grid grid-cols-[repeat(auto-fit,minmax(180px,1fr))] gap-3.5 mb-6">
|
||||||
<StatusCard label="Pages" value={pages.length} icon="📚" />
|
<StatusCard label="Pages" value={pages.length} icon="📚" />
|
||||||
<StatusCard label="Visible" value={visibleCount} state="ok" icon="👁️" />
|
<StatusCard label="Visible" value={visibleCount} state="ok" icon="👁️" />
|
||||||
<StatusCard
|
<StatusCard
|
||||||
@@ -65,7 +65,7 @@ export default async function AdminCatalog() {
|
|||||||
<StatusCard label="Root pages" value={rootCount} icon="🗂️" />
|
<StatusCard label="Root pages" value={rootCount} icon="🗂️" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<section className="admin-section">
|
<section className="mt-6">
|
||||||
<h2>Pages ({pages.length})</h2>
|
<h2>Pages ({pages.length})</h2>
|
||||||
{pages.length === 0 ? (
|
{pages.length === 0 ? (
|
||||||
<p className="muted">No catalog pages found.</p>
|
<p className="muted">No catalog pages found.</p>
|
||||||
@@ -105,7 +105,7 @@ export default async function AdminCatalog() {
|
|||||||
<td>{p.minRank}</td>
|
<td>{p.minRank}</td>
|
||||||
<td>{p.orderNum}</td>
|
<td>{p.orderNum}</td>
|
||||||
<td>
|
<td>
|
||||||
<span className={`admin-badge ${hidden ? "danger" : "ok"}`}>
|
<span className={`inline-block text-[0.72rem] font-bold px-2 py-0.5 rounded-full ${hidden ? "bg-red-500/22 text-[var(--color-text)]" : "bg-green-600/20 text-[var(--color-text)]"}`}>
|
||||||
{hidden ? "Hidden" : "Visible"}
|
{hidden ? "Hidden" : "Visible"}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
|
|||||||
@@ -91,7 +91,7 @@ export default async function CommandoCentrum() {
|
|||||||
</p>
|
</p>
|
||||||
|
|
||||||
{/* ── Live status ────────────────────────────────────────── */}
|
{/* ── Live status ────────────────────────────────────────── */}
|
||||||
<div className="admin-stats" style={{ marginTop: "1.1rem" }}>
|
<div className="grid grid-cols-[repeat(auto-fit,minmax(180px,1fr))] gap-3.5 mb-6" style={{ marginTop: "1.1rem" }}>
|
||||||
<StatusCard
|
<StatusCard
|
||||||
label="Players online"
|
label="Players online"
|
||||||
value={onlineCount}
|
value={onlineCount}
|
||||||
@@ -135,7 +135,7 @@ export default async function CommandoCentrum() {
|
|||||||
<div className="grid cols-2">
|
<div className="grid cols-2">
|
||||||
<section className="card">
|
<section className="card">
|
||||||
<h2 style={{ marginTop: 0 }}>Server info</h2>
|
<h2 style={{ marginTop: 0 }}>Server info</h2>
|
||||||
<div className="admin-info-grid">
|
<div className="grid grid-cols-[repeat(auto-fit,minmax(150px,1fr))] gap-2.5">
|
||||||
<InfoItem label="Node" value={process.version} />
|
<InfoItem label="Node" value={process.version} />
|
||||||
<InfoItem label="Platform" value={`${process.platform} ${process.arch}`} />
|
<InfoItem label="Platform" value={`${process.platform} ${process.arch}`} />
|
||||||
<InfoItem label="App uptime" value={uptime(process.uptime())} />
|
<InfoItem label="App uptime" value={uptime(process.uptime())} />
|
||||||
@@ -149,7 +149,7 @@ export default async function CommandoCentrum() {
|
|||||||
|
|
||||||
<section className="card">
|
<section className="card">
|
||||||
<h2 style={{ marginTop: 0 }}>System diagnostics</h2>
|
<h2 style={{ marginTop: 0 }}>System diagnostics</h2>
|
||||||
<div className="admin-diag">
|
<div className="flex flex-col">
|
||||||
<DiagnosticRow
|
<DiagnosticRow
|
||||||
label="Database"
|
label="Database"
|
||||||
detail={dbOk ? "Connection healthy" : "Unreachable"}
|
detail={dbOk ? "Connection healthy" : "Unreachable"}
|
||||||
@@ -185,7 +185,7 @@ export default async function CommandoCentrum() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* ── RCON controls ──────────────────────────────────────── */}
|
{/* ── RCON controls ──────────────────────────────────────── */}
|
||||||
<section className="admin-section">
|
<section className="mt-6">
|
||||||
<h2>Emulator controls</h2>
|
<h2>Emulator controls</h2>
|
||||||
<div className="grid cols-3">
|
<div className="grid cols-3">
|
||||||
<form action={updateCatalog} className="card">
|
<form action={updateCatalog} className="card">
|
||||||
@@ -240,7 +240,7 @@ export default async function CommandoCentrum() {
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* ── User RCON actions ──────────────────────────────────── */}
|
{/* ── User RCON actions ──────────────────────────────────── */}
|
||||||
<section className="admin-section">
|
<section className="mt-6">
|
||||||
<h2>User actions (RCON)</h2>
|
<h2>User actions (RCON)</h2>
|
||||||
<p className="muted" style={{ marginTop: "-0.4rem" }}>
|
<p className="muted" style={{ marginTop: "-0.4rem" }}>
|
||||||
Enter a User ID to perform actions on a specific user. Username required for disconnect.
|
Enter a User ID to perform actions on a specific user. Username required for disconnect.
|
||||||
@@ -345,7 +345,7 @@ export default async function CommandoCentrum() {
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* ── Staff activity ─────────────────────────────────────── */}
|
{/* ── Staff activity ─────────────────────────────────────── */}
|
||||||
<section className="admin-section">
|
<section className="mt-6">
|
||||||
<h2>Recent staff activity</h2>
|
<h2>Recent staff activity</h2>
|
||||||
{activity.length === 0 ? (
|
{activity.length === 0 ? (
|
||||||
<p className="muted">No staff activity logged yet.</p>
|
<p className="muted">No staff activity logged yet.</p>
|
||||||
@@ -367,7 +367,7 @@ export default async function CommandoCentrum() {
|
|||||||
</td>
|
</td>
|
||||||
<td>{nameById.get(Number(a.userId)) ?? `#${a.userId}`}</td>
|
<td>{nameById.get(Number(a.userId)) ?? `#${a.userId}`}</td>
|
||||||
<td>
|
<td>
|
||||||
<span className="admin-badge">{a.action}</span>
|
<span className="inline-block text-[0.72rem] font-bold px-2 py-0.5 rounded-full bg-[var(--color-primary)]/18 text-[var(--color-text)]">{a.action}</span>
|
||||||
</td>
|
</td>
|
||||||
<td>{a.description}</td>
|
<td>{a.description}</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -378,7 +378,7 @@ export default async function CommandoCentrum() {
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* ── Emulator errors ────────────────────────────────────── */}
|
{/* ── Emulator errors ────────────────────────────────────── */}
|
||||||
<section className="admin-section">
|
<section className="mt-6">
|
||||||
<h2>Recent emulator errors</h2>
|
<h2>Recent emulator errors</h2>
|
||||||
{errors.length === 0 ? (
|
{errors.length === 0 ? (
|
||||||
<p className="muted">No emulator errors logged.</p>
|
<p className="muted">No emulator errors logged.</p>
|
||||||
|
|||||||
@@ -41,12 +41,12 @@ export default async function AdminEmulator() {
|
|||||||
on the next emulator read (some keys require an emulator reload).
|
on the next emulator read (some keys require an emulator reload).
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div className="admin-stats">
|
<div className="grid grid-cols-[repeat(auto-fit,minmax(180px,1fr))] gap-3.5 mb-6">
|
||||||
<StatusCard label="Settings keys" value={settings.length} icon="⚙️" />
|
<StatusCard label="Settings keys" value={settings.length} icon="⚙️" />
|
||||||
<StatusCard label="Text keys" value={textsCount} icon="🗒️" />
|
<StatusCard label="Text keys" value={textsCount} icon="🗒️" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<section className="admin-section">
|
<section className="mt-6">
|
||||||
<h2>Settings</h2>
|
<h2>Settings</h2>
|
||||||
<p className="muted" style={{ marginTop: "-0.4rem" }}>
|
<p className="muted" style={{ marginTop: "-0.4rem" }}>
|
||||||
{settings.length} keys.
|
{settings.length} keys.
|
||||||
@@ -111,7 +111,7 @@ export default async function AdminEmulator() {
|
|||||||
) : null}
|
) : null}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="admin-section">
|
<section className="mt-6">
|
||||||
<h2>Texts</h2>
|
<h2>Texts</h2>
|
||||||
<p className="muted" style={{ marginTop: "-0.4rem" }}>
|
<p className="muted" style={{ marginTop: "-0.4rem" }}>
|
||||||
{textsCount} keys{textsTruncated ? ` (showing first ${texts.length})` : ""}.
|
{textsCount} keys{textsTruncated ? ` (showing first ${texts.length})` : ""}.
|
||||||
|
|||||||
@@ -33,13 +33,13 @@ export default async function AdminHelpQuestions() {
|
|||||||
the public help page, ordered by position.
|
the public help page, ordered by position.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div className="admin-stats">
|
<div className="grid grid-cols-[repeat(auto-fit,minmax(180px,1fr))] gap-3.5 mb-6">
|
||||||
<StatusCard label="Entries" value={entries.length} icon="❓" />
|
<StatusCard label="Entries" value={entries.length} icon="❓" />
|
||||||
<StatusCard label="With button" value={withButton} icon="🔘" />
|
<StatusCard label="With button" value={withButton} icon="🔘" />
|
||||||
<StatusCard label="Small boxes" value={smallBoxes} icon="🗂️" />
|
<StatusCard label="Small boxes" value={smallBoxes} icon="🗂️" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<section className="admin-section">
|
<section className="mt-6">
|
||||||
<h2>Entries</h2>
|
<h2>Entries</h2>
|
||||||
{entries.length === 0 ? (
|
{entries.length === 0 ? (
|
||||||
<p className="muted">No help-center entries yet.</p>
|
<p className="muted">No help-center entries yet.</p>
|
||||||
@@ -63,7 +63,7 @@ export default async function AdminHelpQuestions() {
|
|||||||
</td>
|
</td>
|
||||||
<td className="muted">{e.buttonText || "—"}</td>
|
<td className="muted">{e.buttonText || "—"}</td>
|
||||||
<td>
|
<td>
|
||||||
<span className={`admin-badge ${e.smallBox ? "ok" : ""}`}>
|
<span className={`inline-block text-[0.72rem] font-bold px-2 py-0.5 rounded-full ${e.smallBox ? "bg-green-600/20 text-[var(--color-text)]" : "bg-[var(--color-primary)]/18 text-[var(--color-text)]"}`}>
|
||||||
{e.smallBox ? "Yes" : "No"}
|
{e.smallBox ? "Yes" : "No"}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
|
|||||||
@@ -49,13 +49,13 @@ export default async function AdminHousekeeping({
|
|||||||
existing key overwrites its min rank and description.
|
existing key overwrites its min rank and description.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div className="admin-stats">
|
<div className="grid grid-cols-[repeat(auto-fit,minmax(180px,1fr))] gap-3.5 mb-6">
|
||||||
<StatusCard label="Permissions" value={permissions.length} icon="🔑" />
|
<StatusCard label="Permissions" value={permissions.length} icon="🔑" />
|
||||||
<StatusCard label="Highest min rank" value={maxRank} icon="⬆️" />
|
<StatusCard label="Highest min rank" value={maxRank} icon="⬆️" />
|
||||||
<StatusCard label="Total min rank" value={totalMin} icon="∑" />
|
<StatusCard label="Total min rank" value={totalMin} icon="∑" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="card admin-section-header" style={{ marginBottom: "1rem" }}>
|
<div className="card mt-6" style={{ marginBottom: "1rem" }}>
|
||||||
<div style={{ display: "flex", gap: "0.5rem", alignItems: "center", flexWrap: "wrap" }}>
|
<div style={{ display: "flex", gap: "0.5rem", alignItems: "center", flexWrap: "wrap" }}>
|
||||||
<form style={{ display: "flex", gap: "0.5rem", flex: 1 }}>
|
<form style={{ display: "flex", gap: "0.5rem", flex: 1 }}>
|
||||||
<input
|
<input
|
||||||
@@ -97,7 +97,7 @@ export default async function AdminHousekeeping({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{mode === "manage" ? (
|
{mode === "manage" ? (
|
||||||
<section className="admin-section">
|
<section className="mt-6">
|
||||||
<h2>Add / overwrite permission</h2>
|
<h2>Add / overwrite permission</h2>
|
||||||
<form action={upsertPermission} className="card">
|
<form action={upsertPermission} className="card">
|
||||||
<div style={{ display: "flex", gap: "0.5rem", flexWrap: "wrap" }}>
|
<div style={{ display: "flex", gap: "0.5rem", flexWrap: "wrap" }}>
|
||||||
@@ -109,7 +109,7 @@ export default async function AdminHousekeeping({
|
|||||||
</form>
|
</form>
|
||||||
</section>
|
</section>
|
||||||
) : (
|
) : (
|
||||||
<section className="admin-section">
|
<section className="mt-6">
|
||||||
<h2>Import Permissions</h2>
|
<h2>Import Permissions</h2>
|
||||||
<p className="muted" style={{ marginTop: "-0.4rem" }}>
|
<p className="muted" style={{ marginTop: "-0.4rem" }}>
|
||||||
Paste comma-separated permission IDs to import them as a selection.
|
Paste comma-separated permission IDs to import them as a selection.
|
||||||
@@ -123,9 +123,9 @@ export default async function AdminHousekeeping({
|
|||||||
</section>
|
</section>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<section className="admin-section">
|
<section className="mt-6">
|
||||||
<h2>
|
<h2>
|
||||||
Permissions <span className="admin-badge">{filtered.length}</span>
|
Permissions <span className="inline-block text-[0.72rem] font-bold px-2 py-0.5 rounded-full bg-[var(--color-primary)]/18 text-[var(--color-text)]">{filtered.length}</span>
|
||||||
</h2>
|
</h2>
|
||||||
{filtered.length === 0 ? (
|
{filtered.length === 0 ? (
|
||||||
<p className="muted">No housekeeping permissions match this search.</p>
|
<p className="muted">No housekeeping permissions match this search.</p>
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ export default async function AdminIp() {
|
|||||||
Allow or block IP addresses (and ASNs) at the website edge.
|
Allow or block IP addresses (and ASNs) at the website edge.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div className="admin-stats">
|
<div className="grid grid-cols-[repeat(auto-fit,minmax(180px,1fr))] gap-3.5 mb-6">
|
||||||
<StatusCard
|
<StatusCard
|
||||||
label="Whitelisted"
|
label="Whitelisted"
|
||||||
value={whitelist.length}
|
value={whitelist.length}
|
||||||
@@ -66,7 +66,7 @@ export default async function AdminIp() {
|
|||||||
|
|
||||||
<div className="grid cols-2">
|
<div className="grid cols-2">
|
||||||
{/* ── Whitelist ──────────────────── */}
|
{/* ── Whitelist ──────────────────── */}
|
||||||
<section className="admin-section">
|
<section className="mt-6">
|
||||||
<h2>Whitelist</h2>
|
<h2>Whitelist</h2>
|
||||||
|
|
||||||
<form action={addWhitelist} className="card" style={{ marginBottom: "1rem" }}>
|
<form action={addWhitelist} className="card" style={{ marginBottom: "1rem" }}>
|
||||||
@@ -120,7 +120,7 @@ export default async function AdminIp() {
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* ── Blacklist ──────────────────── */}
|
{/* ── Blacklist ──────────────────── */}
|
||||||
<section className="admin-section">
|
<section className="mt-6">
|
||||||
<h2>Blacklist</h2>
|
<h2>Blacklist</h2>
|
||||||
|
|
||||||
<form action={addBlacklist} className="card" style={{ marginBottom: "1rem" }}>
|
<form action={addBlacklist} className="card" style={{ marginBottom: "1rem" }}>
|
||||||
|
|||||||
+21
-14
@@ -92,22 +92,29 @@ export default async function AdminLayout({ children }: { children: ReactNode })
|
|||||||
const initial = staff.username.charAt(0).toUpperCase();
|
const initial = staff.username.charAt(0).toUpperCase();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="admin">
|
<div className="grid grid-cols-1 lg:grid-cols-[232px_minmax(0,1fr)] gap-5 items-start">
|
||||||
<aside className="admin-sidebar">
|
<aside className="sticky top-4 self-start bg-[#2d2d44] text-[#e7e9f3] rounded-xl p-3.5 shadow-card max-h-[calc(100vh-2rem)] overflow-y-auto">
|
||||||
<div className="admin-sidebar-head">
|
<div className="flex items-center gap-2.5 px-1.5 pb-3.5 border-b border-white/10 mb-2">
|
||||||
<span className="admin-avatar" aria-hidden>
|
<span
|
||||||
|
className="flex-none w-[38px] h-[38px] rounded-lg grid place-items-center font-extrabold text-[#1a1a2e] bg-[var(--color-primary)]"
|
||||||
|
aria-hidden
|
||||||
|
>
|
||||||
{initial}
|
{initial}
|
||||||
</span>
|
</span>
|
||||||
<div style={{ minWidth: 0 }}>
|
<div className="min-w-0">
|
||||||
<div className="admin-sidebar-name">{staff.username}</div>
|
<div className="font-bold text-sm leading-tight text-white truncate">{staff.username}</div>
|
||||||
<span className="admin-rank-badge">rank {staff.rank}</span>
|
<span className="inline-block mt-0.5 text-[0.66rem] font-bold uppercase tracking-wider text-[#1a1a2e] bg-[var(--color-primary)] rounded-full px-2 py-0.5">
|
||||||
|
rank {staff.rank}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<nav className="admin-nav">
|
<nav className="flex flex-col gap-0.5">
|
||||||
{NAV_GROUPS.map((group) => (
|
{NAV_GROUPS.map((group) => (
|
||||||
<div key={group.label} className="admin-nav-group">
|
<div key={group.label} className="mt-2">
|
||||||
<span className="admin-nav-group-label">{group.label}</span>
|
<span className="block text-[0.64rem] font-bold uppercase tracking-widest text-[rgba(231,233,243,0.42)] px-2.5 pb-1">
|
||||||
|
{group.label}
|
||||||
|
</span>
|
||||||
{group.items.map((item) => (
|
{group.items.map((item) => (
|
||||||
<AdminNavLink key={item.href} href={item.href} label={item.label} />
|
<AdminNavLink key={item.href} href={item.href} label={item.label} />
|
||||||
))}
|
))}
|
||||||
@@ -116,10 +123,10 @@ export default async function AdminLayout({ children }: { children: ReactNode })
|
|||||||
</nav>
|
</nav>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
<div className="admin-content">
|
<div className="min-w-0">
|
||||||
<header className="admin-topbar">
|
<header className="flex items-center justify-between gap-4 bg-[var(--color-surface)] border border-[var(--border)] rounded-xl px-4 py-3 mb-4 shadow-card">
|
||||||
<h2>Housekeeping</h2>
|
<h2 className="m-0 text-base font-extrabold">Housekeeping</h2>
|
||||||
<span className="muted" style={{ fontSize: "0.8rem" }}>
|
<span className="muted text-[0.8rem]">
|
||||||
Signed in as <strong>{staff.username}</strong>
|
Signed in as <strong>{staff.username}</strong>
|
||||||
</span>
|
</span>
|
||||||
</header>
|
</header>
|
||||||
|
|||||||
@@ -86,7 +86,7 @@ export default async function AdminStaffActivities({
|
|||||||
Auditor trail of all staff actions in the hotel.
|
Auditor trail of all staff actions in the hotel.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div className="admin-stats">
|
<div className="grid grid-cols-[repeat(auto-fit,minmax(180px,1fr))] gap-3.5 mb-6">
|
||||||
<StatusCard label="Total activities" value={total} icon="📋" />
|
<StatusCard label="Total activities" value={total} icon="📋" />
|
||||||
<StatusCard label="On this page" value={activities.length} icon="📄" />
|
<StatusCard label="On this page" value={activities.length} icon="📄" />
|
||||||
<StatusCard
|
<StatusCard
|
||||||
@@ -130,7 +130,7 @@ export default async function AdminStaffActivities({
|
|||||||
)}
|
)}
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<section className="admin-section">
|
<section className="mt-6">
|
||||||
<h2>Activities ({total})</h2>
|
<h2>Activities ({total})</h2>
|
||||||
{activities.length === 0 ? (
|
{activities.length === 0 ? (
|
||||||
<p className="muted">No staff activities match this search.</p>
|
<p className="muted">No staff activities match this search.</p>
|
||||||
@@ -153,7 +153,7 @@ export default async function AdminStaffActivities({
|
|||||||
<td className="muted" style={{ whiteSpace: "nowrap" }}>{fromDate(a.createdAt)}</td>
|
<td className="muted" style={{ whiteSpace: "nowrap" }}>{fromDate(a.createdAt)}</td>
|
||||||
<td className="muted">#{Number(a.userId)} ({nameById.get(Number(a.userId)) ?? "unknown"})</td>
|
<td className="muted">#{Number(a.userId)} ({nameById.get(Number(a.userId)) ?? "unknown"})</td>
|
||||||
<td>
|
<td>
|
||||||
<span className="admin-badge">{a.action}</span>
|
<span className="inline-block text-[0.72rem] font-bold px-2 py-0.5 rounded-full bg-[var(--color-primary)]/18 text-[var(--color-text)]">{a.action}</span>
|
||||||
</td>
|
</td>
|
||||||
<td>{a.description}</td>
|
<td>{a.description}</td>
|
||||||
<td>
|
<td>
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ export default async function AdminMaintenance() {
|
|||||||
<code> website_settings</code> and read live by the login flow.
|
<code> website_settings</code> and read live by the login flow.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div className="admin-stats">
|
<div className="grid grid-cols-[repeat(auto-fit,minmax(180px,1fr))] gap-3.5 mb-6">
|
||||||
<StatusCard
|
<StatusCard
|
||||||
label="Maintenance"
|
label="Maintenance"
|
||||||
value={enabled ? "ENABLED" : "Disabled"}
|
value={enabled ? "ENABLED" : "Disabled"}
|
||||||
@@ -64,10 +64,10 @@ export default async function AdminMaintenance() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* ── Current state ─────────────────────────────────────── */}
|
{/* ── Current state ─────────────────────────────────────── */}
|
||||||
<section className="admin-section">
|
<section className="mt-6">
|
||||||
<h2>Current state</h2>
|
<h2>Current state</h2>
|
||||||
<div className="card" style={{ display: "flex", alignItems: "center", gap: "0.75rem" }}>
|
<div className="card" style={{ display: "flex", alignItems: "center", gap: "0.75rem" }}>
|
||||||
<span className={`admin-badge ${enabled ? "danger" : "ok"}`}>
|
<span className={`inline-block text-[0.72rem] font-bold px-2 py-0.5 rounded-full ${enabled ? "bg-red-500/22 text-[var(--color-text)]" : "bg-green-600/20 text-[var(--color-text)]"}`}>
|
||||||
{enabled ? "ENABLED" : "disabled"}
|
{enabled ? "ENABLED" : "disabled"}
|
||||||
</span>
|
</span>
|
||||||
<div>
|
<div>
|
||||||
@@ -84,7 +84,7 @@ export default async function AdminMaintenance() {
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* ── Edit form ─────────────────────────────────────────── */}
|
{/* ── Edit form ─────────────────────────────────────────── */}
|
||||||
<section className="admin-section">
|
<section className="mt-6">
|
||||||
<h2>Settings</h2>
|
<h2>Settings</h2>
|
||||||
<form action={saveMaintenance} className="card">
|
<form action={saveMaintenance} className="card">
|
||||||
<label
|
<label
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ type PublicRow = {
|
|||||||
// Reusable yes/no pill so emulator boolean-ish "1"/"0" columns read consistently.
|
// Reusable yes/no pill so emulator boolean-ish "1"/"0" columns read consistently.
|
||||||
function YesNo({ on }: { on: boolean }) {
|
function YesNo({ on }: { on: boolean }) {
|
||||||
return (
|
return (
|
||||||
<span className={`admin-badge ${on ? "ok" : "danger"}`}>{on ? "Yes" : "No"}</span>
|
<span className={`inline-block text-[0.72rem] font-bold px-2 py-0.5 rounded-full ${on ? "bg-green-600/20 text-[var(--color-text)]" : "bg-red-500/22 text-[var(--color-text)]"}`}>{on ? "Yes" : "No"}</span>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -108,7 +108,7 @@ export default async function AdminNavigation() {
|
|||||||
emulator tooling, not here.
|
emulator tooling, not here.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div className="admin-stats">
|
<div className="grid grid-cols-[repeat(auto-fit,minmax(180px,1fr))] gap-3.5 mb-6">
|
||||||
<StatusCard label="Flat categories" value={flatcats.length} icon="📂" />
|
<StatusCard label="Flat categories" value={flatcats.length} icon="📂" />
|
||||||
<StatusCard
|
<StatusCard
|
||||||
label="Public (flatcats)"
|
label="Public (flatcats)"
|
||||||
@@ -126,7 +126,7 @@ export default async function AdminNavigation() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* ── navigator_flatcats ───────────────────────────────── */}
|
{/* ── navigator_flatcats ───────────────────────────────── */}
|
||||||
<section className="admin-section">
|
<section className="mt-6">
|
||||||
<h2>Flat categories</h2>
|
<h2>Flat categories</h2>
|
||||||
<p className="muted" style={{ marginTop: "-0.4rem" }}>
|
<p className="muted" style={{ marginTop: "-0.4rem" }}>
|
||||||
<code>navigator_flatcats</code> · {flatcats.length} rows
|
<code>navigator_flatcats</code> · {flatcats.length} rows
|
||||||
@@ -174,7 +174,7 @@ export default async function AdminNavigation() {
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* ── navigator_publics ────────────────────────────────── */}
|
{/* ── navigator_publics ────────────────────────────────── */}
|
||||||
<section className="admin-section">
|
<section className="mt-6">
|
||||||
<h2>Public rooms</h2>
|
<h2>Public rooms</h2>
|
||||||
<p className="muted" style={{ marginTop: "-0.4rem" }}>
|
<p className="muted" style={{ marginTop: "-0.4rem" }}>
|
||||||
<code>navigator_publics</code> · {publics.length} rows
|
<code>navigator_publics</code> · {publics.length} rows
|
||||||
|
|||||||
+22
-24
@@ -52,7 +52,7 @@ export default async function AdminDashboard() {
|
|||||||
<main>
|
<main>
|
||||||
<h1>Dashboard</h1>
|
<h1>Dashboard</h1>
|
||||||
|
|
||||||
<div className="admin-stats">
|
<div className="grid grid-cols-[repeat(auto-fit,minmax(180px,1fr))] gap-3.5 mb-6">
|
||||||
<StatusCard
|
<StatusCard
|
||||||
label="Players online"
|
label="Players online"
|
||||||
value={online}
|
value={online}
|
||||||
@@ -75,44 +75,40 @@ export default async function AdminDashboard() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<section className="admin-section">
|
<section className="mt-6">
|
||||||
<h2 style={{ margin: 0 }}>Users by rank</h2>
|
<h2 className="m-0 text-lg">Users by rank</h2>
|
||||||
{rankCounts.length === 0 ? (
|
{rankCounts.length === 0 ? (
|
||||||
<p className="muted" style={{ marginTop: "0.7rem" }}>
|
<p className="muted mt-3">
|
||||||
{dbOk ? "No accounts to break down yet." : "DB unavailable."}
|
{dbOk ? "No accounts to break down yet." : "DB unavailable."}
|
||||||
</p>
|
</p>
|
||||||
) : (
|
) : (
|
||||||
<div style={{ display: "grid", gap: "0.5rem", marginTop: "0.8rem" }}>
|
<div className="grid gap-2 mt-3">
|
||||||
{rankCounts.map((r) => {
|
{rankCounts.map((r) => {
|
||||||
const pct = maxRankCount > 0 ? Math.round((r.count / maxRankCount) * 100) : 0;
|
const pct = maxRankCount > 0 ? Math.round((r.count / maxRankCount) * 100) : 0;
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={r.rank}
|
key={r.rank}
|
||||||
style={{ display: "grid", gridTemplateColumns: "5.5rem 1fr 3rem", alignItems: "center", gap: "0.6rem" }}
|
className="grid items-center gap-2.5"
|
||||||
|
style={{ gridTemplateColumns: "5.5rem 1fr 3rem" }}
|
||||||
>
|
>
|
||||||
<span className="muted" style={{ fontSize: "0.82rem", whiteSpace: "nowrap" }}>
|
<span className="text-[var(--color-text-muted)] text-[0.82rem] whitespace-nowrap">
|
||||||
Rank {r.rank}
|
Rank {r.rank}
|
||||||
</span>
|
</span>
|
||||||
<div
|
<div
|
||||||
style={{
|
className="h-3.5 rounded-[var(--radius-sm)] overflow-hidden"
|
||||||
background: "color-mix(in srgb, var(--color-primary) 12%, transparent)",
|
style={{ background: "color-mix(in srgb, var(--color-primary) 12%, transparent)" }}
|
||||||
borderRadius: "var(--radius-sm)",
|
|
||||||
height: "0.85rem",
|
|
||||||
overflow: "hidden",
|
|
||||||
}}
|
|
||||||
role="img"
|
role="img"
|
||||||
aria-label={`Rank ${r.rank}: ${r.count} accounts`}
|
aria-label={`Rank ${r.rank}: ${r.count} accounts`}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
|
className="h-full rounded-[var(--radius-sm)]"
|
||||||
style={{
|
style={{
|
||||||
width: `${Math.max(pct, r.count > 0 ? 4 : 0)}%`,
|
width: `${Math.max(pct, r.count > 0 ? 4 : 0)}%`,
|
||||||
height: "100%",
|
|
||||||
background: "var(--color-primary, #eeb425)",
|
background: "var(--color-primary, #eeb425)",
|
||||||
borderRadius: "var(--radius-sm)",
|
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<strong style={{ fontSize: "0.85rem", textAlign: "right" }}>{r.count}</strong>
|
<strong className="text-[0.85rem] text-right">{r.count}</strong>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
@@ -120,17 +116,17 @@ export default async function AdminDashboard() {
|
|||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="admin-section">
|
<section className="mt-6">
|
||||||
<div style={{ display: "flex", alignItems: "baseline", gap: "0.75rem" }}>
|
<div className="flex items-baseline gap-3">
|
||||||
<h2 style={{ margin: 0 }}>Recent staff activity</h2>
|
<h2 className="m-0 text-lg">Recent staff activity</h2>
|
||||||
<Link href="/admin/commandocentrum" className="muted" style={{ fontSize: "0.82rem" }}>
|
<Link href="/admin/commandocentrum" className="muted text-[0.82rem]">
|
||||||
View all →
|
View all →
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
{activity.length === 0 ? (
|
{activity.length === 0 ? (
|
||||||
<p className="muted">No staff activity logged yet.</p>
|
<p className="muted mt-3">No staff activity logged yet.</p>
|
||||||
) : (
|
) : (
|
||||||
<table style={{ marginTop: "0.7rem" }}>
|
<table className="mt-3">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>When</th>
|
<th>When</th>
|
||||||
@@ -142,12 +138,14 @@ export default async function AdminDashboard() {
|
|||||||
<tbody>
|
<tbody>
|
||||||
{activity.map((a) => (
|
{activity.map((a) => (
|
||||||
<tr key={String(a.id)}>
|
<tr key={String(a.id)}>
|
||||||
<td className="muted" style={{ whiteSpace: "nowrap" }}>
|
<td className="muted whitespace-nowrap">
|
||||||
{fromDate(a.createdAt)}
|
{fromDate(a.createdAt)}
|
||||||
</td>
|
</td>
|
||||||
<td>{nameById.get(Number(a.userId)) ?? `#${a.userId}`}</td>
|
<td>{nameById.get(Number(a.userId)) ?? `#${a.userId}`}</td>
|
||||||
<td>
|
<td>
|
||||||
<span className="admin-badge">{a.action}</span>
|
<span className="inline-block text-[0.72rem] font-bold px-2 py-0.5 rounded-full bg-[var(--color-primary)]/18 text-[var(--color-text)]">
|
||||||
|
{a.action}
|
||||||
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td>{a.description}</td>
|
<td>{a.description}</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ export default async function AdminPermissions() {
|
|||||||
a permission name to the minimum rank allowed to use it.
|
a permission name to the minimum rank allowed to use it.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div className="admin-stats">
|
<div className="grid grid-cols-[repeat(auto-fit,minmax(180px,1fr))] gap-3.5 mb-6">
|
||||||
<StatusCard label="Permissions" value={permissions.length} icon="🔑" />
|
<StatusCard label="Permissions" value={permissions.length} icon="🔑" />
|
||||||
<StatusCard label="Highest min rank" value={highestRank} icon="🎖️" />
|
<StatusCard label="Highest min rank" value={highestRank} icon="🎖️" />
|
||||||
</div>
|
</div>
|
||||||
@@ -68,7 +68,7 @@ export default async function AdminPermissions() {
|
|||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<section className="admin-section">
|
<section className="mt-6">
|
||||||
<h2>Permissions ({permissions.length})</h2>
|
<h2>Permissions ({permissions.length})</h2>
|
||||||
{permissions.length === 0 ? (
|
{permissions.length === 0 ? (
|
||||||
<p className="muted">No permissions found.</p>
|
<p className="muted">No permissions found.</p>
|
||||||
|
|||||||
@@ -26,13 +26,13 @@ export default async function AdminPhotos() {
|
|||||||
<code>camera_web</code>.
|
<code>camera_web</code>.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div className="admin-stats">
|
<div className="grid grid-cols-[repeat(auto-fit,minmax(180px,1fr))] gap-3.5 mb-6">
|
||||||
<StatusCard label="Photos shown" value={photos.length} icon="📸" />
|
<StatusCard label="Photos shown" value={photos.length} icon="📸" />
|
||||||
<StatusCard label="Rooms" value={uniqueRooms} hint="distinct rooms" icon="🚪" />
|
<StatusCard label="Rooms" value={uniqueRooms} hint="distinct rooms" icon="🚪" />
|
||||||
<StatusCard label="Users" value={uniqueUsers} hint="distinct photographers" icon="👤" />
|
<StatusCard label="Users" value={uniqueUsers} hint="distinct photographers" icon="👤" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<section className="admin-section">
|
<section className="mt-6">
|
||||||
<h2>Recent photos</h2>
|
<h2>Recent photos</h2>
|
||||||
{photos.length === 0 ? (
|
{photos.length === 0 ? (
|
||||||
<p className="muted">No photos found.</p>
|
<p className="muted">No photos found.</p>
|
||||||
|
|||||||
@@ -74,7 +74,7 @@ export default async function AdminRadioApiKeysPage() {
|
|||||||
<Link href="/admin/radio/monitoring">Monitoring</Link>
|
<Link href="/admin/radio/monitoring">Monitoring</Link>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<section className="admin-section">
|
<section className="mt-6">
|
||||||
<h1>Radio API Keys</h1>
|
<h1>Radio API Keys</h1>
|
||||||
<p className="muted" style={{ marginTop: "-0.4rem" }}>
|
<p className="muted" style={{ marginTop: "-0.4rem" }}>
|
||||||
API keys for external radio integrations (<code>radio_api_keys</code>). Keys are generated
|
API keys for external radio integrations (<code>radio_api_keys</code>). Keys are generated
|
||||||
@@ -88,7 +88,7 @@ export default async function AdminRadioApiKeysPage() {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="admin-stats">
|
<div className="grid grid-cols-[repeat(auto-fit,minmax(180px,1fr))] gap-3.5 mb-6">
|
||||||
<StatusCard label="Total keys" value={total} icon="🔑" />
|
<StatusCard label="Total keys" value={total} icon="🔑" />
|
||||||
<StatusCard
|
<StatusCard
|
||||||
label="Active"
|
label="Active"
|
||||||
@@ -106,7 +106,7 @@ export default async function AdminRadioApiKeysPage() {
|
|||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="admin-section">
|
<section className="mt-6">
|
||||||
<h2>Generate key</h2>
|
<h2>Generate key</h2>
|
||||||
<p className="muted" style={{ marginTop: "-0.4rem" }}>
|
<p className="muted" style={{ marginTop: "-0.4rem" }}>
|
||||||
The secret key is generated automatically with cryptographic randomness — you do not enter
|
The secret key is generated automatically with cryptographic randomness — you do not enter
|
||||||
@@ -161,7 +161,7 @@ export default async function AdminRadioApiKeysPage() {
|
|||||||
</form>
|
</form>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="admin-section">
|
<section className="mt-6">
|
||||||
<h2>Existing keys ({total})</h2>
|
<h2>Existing keys ({total})</h2>
|
||||||
{total === 0 ? (
|
{total === 0 ? (
|
||||||
<p className="muted">No API keys yet — generate one above.</p>
|
<p className="muted">No API keys yet — generate one above.</p>
|
||||||
@@ -194,7 +194,7 @@ export default async function AdminRadioApiKeysPage() {
|
|||||||
<td className="muted">{formatDate(k.lastUsedAt)}</td>
|
<td className="muted">{formatDate(k.lastUsedAt)}</td>
|
||||||
<td className="muted">{formatDate(k.expiresAt)}</td>
|
<td className="muted">{formatDate(k.expiresAt)}</td>
|
||||||
<td>
|
<td>
|
||||||
<span className={`admin-badge ${k.isActive ? "ok" : "danger"}`}>
|
<span className={`inline-block text-[0.72rem] font-bold px-2 py-0.5 rounded-full ${k.isActive ? "bg-green-600/20 text-[var(--color-text)]" : "bg-red-500/22 text-[var(--color-text)]"}`}>
|
||||||
{k.isActive ? "active" : "inactive"}
|
{k.isActive ? "active" : "inactive"}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
|
|||||||
@@ -76,7 +76,7 @@ export default async function AdminRadioAutoDjPage() {
|
|||||||
<Link href="/admin/radio/autodj">AutoDJ</Link>
|
<Link href="/admin/radio/autodj">AutoDJ</Link>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<section className="admin-section">
|
<section className="mt-6">
|
||||||
<h2>Radio AutoDJ</h2>
|
<h2>Radio AutoDJ</h2>
|
||||||
<p className="muted" style={{ marginTop: '-0.4rem' }}>
|
<p className="muted" style={{ marginTop: '-0.4rem' }}>
|
||||||
Fallback playlist (<code>radio_auto_dj_playlist</code>) the radio rotates
|
Fallback playlist (<code>radio_auto_dj_playlist</code>) the radio rotates
|
||||||
@@ -90,7 +90,7 @@ export default async function AdminRadioAutoDjPage() {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="admin-stats">
|
<div className="grid grid-cols-[repeat(auto-fit,minmax(180px,1fr))] gap-3.5 mb-6">
|
||||||
<StatusCard label="Tracks" value={tracks.length} icon="🎵" />
|
<StatusCard label="Tracks" value={tracks.length} icon="🎵" />
|
||||||
<StatusCard
|
<StatusCard
|
||||||
label="Active"
|
label="Active"
|
||||||
@@ -108,7 +108,7 @@ export default async function AdminRadioAutoDjPage() {
|
|||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="admin-section">
|
<section className="mt-6">
|
||||||
<h2>Add track</h2>
|
<h2>Add track</h2>
|
||||||
<form action={createTrack} className="card">
|
<form action={createTrack} className="card">
|
||||||
<div className="grid cols-2">
|
<div className="grid cols-2">
|
||||||
@@ -186,7 +186,7 @@ export default async function AdminRadioAutoDjPage() {
|
|||||||
</form>
|
</form>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="admin-section">
|
<section className="mt-6">
|
||||||
<h2>Playlist ({tracks.length})</h2>
|
<h2>Playlist ({tracks.length})</h2>
|
||||||
{tracks.length === 0 ? (
|
{tracks.length === 0 ? (
|
||||||
<p className="muted">No AutoDJ tracks yet — add one above.</p>
|
<p className="muted">No AutoDJ tracks yet — add one above.</p>
|
||||||
@@ -232,7 +232,7 @@ export default async function AdminRadioAutoDjPage() {
|
|||||||
<td className="muted">{t.playCount}</td>
|
<td className="muted">{t.playCount}</td>
|
||||||
<td className="muted">{formatDate(t.lastPlayedAt)}</td>
|
<td className="muted">{formatDate(t.lastPlayedAt)}</td>
|
||||||
<td>
|
<td>
|
||||||
<span className={`admin-badge ${t.isActive ? 'ok' : 'danger'}`}>
|
<span className={`inline-block text-[0.72rem] font-bold px-2 py-0.5 rounded-full ${t.isActive ? 'bg-green-600/20 text-[var(--color-text)]' : 'bg-red-500/22 text-[var(--color-text)]'}`}>
|
||||||
{t.isActive ? 'active' : 'inactive'}
|
{t.isActive ? 'active' : 'inactive'}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
|
|||||||
@@ -72,7 +72,7 @@ export default async function AdminRadioBannersPage() {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="admin-stats">
|
<div className="grid grid-cols-[repeat(auto-fit,minmax(180px,1fr))] gap-3.5 mb-6">
|
||||||
<StatusCard label="Banners" value={banners.length} icon="🖼️" />
|
<StatusCard label="Banners" value={banners.length} icon="🖼️" />
|
||||||
<StatusCard
|
<StatusCard
|
||||||
label="Active"
|
label="Active"
|
||||||
@@ -89,7 +89,7 @@ export default async function AdminRadioBannersPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<section className="admin-section">
|
<section className="mt-6">
|
||||||
<h2>Add banner</h2>
|
<h2>Add banner</h2>
|
||||||
<form action={createRadioBanner} className="card">
|
<form action={createRadioBanner} className="card">
|
||||||
<div className="grid cols-2">
|
<div className="grid cols-2">
|
||||||
@@ -148,7 +148,7 @@ export default async function AdminRadioBannersPage() {
|
|||||||
</form>
|
</form>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="admin-section">
|
<section className="mt-6">
|
||||||
<h2>Existing banners ({banners.length})</h2>
|
<h2>Existing banners ({banners.length})</h2>
|
||||||
{banners.length === 0 ? (
|
{banners.length === 0 ? (
|
||||||
<p className="muted">No banners yet — add one above.</p>
|
<p className="muted">No banners yet — add one above.</p>
|
||||||
@@ -170,7 +170,7 @@ export default async function AdminRadioBannersPage() {
|
|||||||
style={{ display: 'inline-flex', gap: '0.5rem', alignItems: 'center' }}
|
style={{ display: 'inline-flex', gap: '0.5rem', alignItems: 'center' }}
|
||||||
>
|
>
|
||||||
by user {String(b.userId)} · {formatDate(b.createdAt)} ·{' '}
|
by user {String(b.userId)} · {formatDate(b.createdAt)} ·{' '}
|
||||||
<span className={`admin-badge ${b.isActive ? 'ok' : 'danger'}`}>
|
<span className={`inline-block text-[0.72rem] font-bold px-2 py-0.5 rounded-full ${b.isActive ? 'bg-green-600/20 text-[var(--color-text)]' : 'bg-red-500/22 text-[var(--color-text)]'}`}>
|
||||||
{b.isActive ? 'active' : 'hidden'}
|
{b.isActive ? 'active' : 'hidden'}
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ export function EmbedSnippet({
|
|||||||
const lines = snippet.split('\n').length;
|
const lines = snippet.split('\n').length;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="admin-section">
|
<section className="mt-6">
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: '1rem' }}>
|
<div style={{ display: 'flex', alignItems: 'center', gap: '1rem' }}>
|
||||||
<h2 style={{ flex: 1, margin: 0 }}>{title}</h2>
|
<h2 style={{ flex: 1, margin: 0 }}>{title}</h2>
|
||||||
<button type="button" className="btn btn-primary" onClick={copy}>
|
<button type="button" className="btn btn-primary" onClick={copy}>
|
||||||
|
|||||||
@@ -53,7 +53,7 @@ export default async function AdminRadioEmbedPage() {
|
|||||||
<Link href="/admin/radio/history">History</Link>
|
<Link href="/admin/radio/history">History</Link>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<section className="admin-section">
|
<section className="mt-6">
|
||||||
<h1>Radio Embed</h1>
|
<h1>Radio Embed</h1>
|
||||||
<p className="muted" style={{ marginTop: '-0.4rem' }}>
|
<p className="muted" style={{ marginTop: '-0.4rem' }}>
|
||||||
Copy-ready embed code for the live stream. Read-only — nothing here
|
Copy-ready embed code for the live stream. Read-only — nothing here
|
||||||
@@ -62,7 +62,7 @@ export default async function AdminRadioEmbedPage() {
|
|||||||
<code>radio_stream_url</code>).
|
<code>radio_stream_url</code>).
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div className="admin-stats">
|
<div className="grid grid-cols-[repeat(auto-fit,minmax(180px,1fr))] gap-3.5 mb-6">
|
||||||
<StatusCard
|
<StatusCard
|
||||||
label="Stream URL"
|
label="Stream URL"
|
||||||
value={hasStream ? 'Configured' : 'Missing'}
|
value={hasStream ? 'Configured' : 'Missing'}
|
||||||
@@ -80,7 +80,7 @@ export default async function AdminRadioEmbedPage() {
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
{!hasStream ? (
|
{!hasStream ? (
|
||||||
<section className="admin-section">
|
<section className="mt-6">
|
||||||
<h2>No stream URL configured</h2>
|
<h2>No stream URL configured</h2>
|
||||||
<p className="muted">
|
<p className="muted">
|
||||||
There is no <code>radio_stream_url</code> set, so there is nothing to
|
There is no <code>radio_stream_url</code> set, so there is nothing to
|
||||||
@@ -98,7 +98,7 @@ export default async function AdminRadioEmbedPage() {
|
|||||||
</section>
|
</section>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<section className="admin-section">
|
<section className="mt-6">
|
||||||
<h2>Live preview</h2>
|
<h2>Live preview</h2>
|
||||||
<p className="muted" style={{ marginTop: '-0.4rem' }}>
|
<p className="muted" style={{ marginTop: '-0.4rem' }}>
|
||||||
This is the bare <code><audio></code> player as visitors will
|
This is the bare <code><audio></code> player as visitors will
|
||||||
|
|||||||
@@ -95,7 +95,7 @@ export default async function AdminRadioHistoryPage() {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="admin-stats">
|
<div className="grid grid-cols-[repeat(auto-fit,minmax(180px,1fr))] gap-3.5 mb-6">
|
||||||
<StatusCard label="Shows logged" value={history.length} icon="🎙️" />
|
<StatusCard label="Shows logged" value={history.length} icon="🎙️" />
|
||||||
<StatusCard
|
<StatusCard
|
||||||
label="On air now"
|
label="On air now"
|
||||||
@@ -113,7 +113,7 @@ export default async function AdminRadioHistoryPage() {
|
|||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{history.length > 0 ? (
|
{history.length > 0 ? (
|
||||||
<section className="admin-section">
|
<section className="mt-6">
|
||||||
<h2>Show log ({history.length})</h2>
|
<h2>Show log ({history.length})</h2>
|
||||||
<table>
|
<table>
|
||||||
<thead>
|
<thead>
|
||||||
@@ -139,7 +139,7 @@ export default async function AdminRadioHistoryPage() {
|
|||||||
{h.endedAt ? (
|
{h.endedAt ? (
|
||||||
<span className="muted">{formatDate(h.endedAt)}</span>
|
<span className="muted">{formatDate(h.endedAt)}</span>
|
||||||
) : (
|
) : (
|
||||||
<span className="admin-badge ok">on air</span>
|
<span className="inline-block text-[0.72rem] font-bold px-2 py-0.5 rounded-full bg-green-600/20 text-[var(--color-text)]">on air</span>
|
||||||
)}
|
)}
|
||||||
</td>
|
</td>
|
||||||
<td className="muted">{formatDuration(h.startedAt, h.endedAt)}</td>
|
<td className="muted">{formatDuration(h.startedAt, h.endedAt)}</td>
|
||||||
|
|||||||
@@ -51,11 +51,11 @@ export default async function AdminRadioModerationPage() {
|
|||||||
Review and remove radio shouts (50 most recent).
|
Review and remove radio shouts (50 most recent).
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div className="admin-stats">
|
<div className="grid grid-cols-[repeat(auto-fit,minmax(180px,1fr))] gap-3.5 mb-6">
|
||||||
<StatusCard label="Recent shouts" value={shouts.length} icon="📣" />
|
<StatusCard label="Recent shouts" value={shouts.length} icon="📣" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<section className="admin-section">
|
<section className="mt-6">
|
||||||
<h2>Recent Shouts ({shouts.length})</h2>
|
<h2>Recent Shouts ({shouts.length})</h2>
|
||||||
{shouts.length === 0 ? (
|
{shouts.length === 0 ? (
|
||||||
<p className="muted">No shouts to moderate.</p>
|
<p className="muted">No shouts to moderate.</p>
|
||||||
|
|||||||
@@ -224,7 +224,7 @@ export default async function AdminRadioMonitoringPage() {
|
|||||||
<Link href="/admin/radio/history">History</Link>
|
<Link href="/admin/radio/history">History</Link>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<section className="admin-section">
|
<section className="mt-6">
|
||||||
<h2>Radio Monitoring</h2>
|
<h2>Radio Monitoring</h2>
|
||||||
<p className="muted" style={{ marginTop: '-0.4rem' }}>
|
<p className="muted" style={{ marginTop: '-0.4rem' }}>
|
||||||
Read-only live probe of the configured radio endpoints
|
Read-only live probe of the configured radio endpoints
|
||||||
@@ -234,7 +234,7 @@ export default async function AdminRadioMonitoringPage() {
|
|||||||
<Link href="/admin/radio/settings">Settings</Link>.
|
<Link href="/admin/radio/settings">Settings</Link>.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div className="admin-stats">
|
<div className="grid grid-cols-[repeat(auto-fit,minmax(180px,1fr))] gap-3.5 mb-6">
|
||||||
<StatusCard
|
<StatusCard
|
||||||
label="Stream"
|
label="Stream"
|
||||||
value={
|
value={
|
||||||
@@ -261,14 +261,14 @@ export default async function AdminRadioMonitoringPage() {
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="admin-section">
|
<section className="mt-6">
|
||||||
<h2>Endpoint diagnostics</h2>
|
<h2>Endpoint diagnostics</h2>
|
||||||
<p className="muted" style={{ marginTop: '-0.4rem' }}>
|
<p className="muted" style={{ marginTop: '-0.4rem' }}>
|
||||||
<span className="admin-badge ok">OK</span> reachable ·{' '}
|
<span className="inline-block text-[0.72rem] font-bold px-2 py-0.5 rounded-full bg-green-600/20 text-[var(--color-text)]">OK</span> reachable ·{' '}
|
||||||
<span className="admin-badge">WARN</span> not configured ·{' '}
|
<span className="inline-block text-[0.72rem] font-bold px-2 py-0.5 rounded-full bg-[var(--color-primary)]/18 text-[var(--color-text)]">WARN</span> not configured ·{' '}
|
||||||
<span className="admin-badge danger">ERROR</span> fetch failed / timed out.
|
<span className="inline-block text-[0.72rem] font-bold px-2 py-0.5 rounded-full bg-red-500/22 text-[var(--color-text)]">ERROR</span> fetch failed / timed out.
|
||||||
</p>
|
</p>
|
||||||
<div className="admin-diag">
|
<div className="flex flex-col">
|
||||||
{endpoints.map((ep) => {
|
{endpoints.map((ep) => {
|
||||||
let state: 'ok' | 'warn' | 'danger';
|
let state: 'ok' | 'warn' | 'danger';
|
||||||
let detail: string;
|
let detail: string;
|
||||||
|
|||||||
@@ -121,7 +121,7 @@ export default async function AdminRadioPage() {
|
|||||||
Latest shouts, DJ applications and on-air schedules (25 most recent of each).
|
Latest shouts, DJ applications and on-air schedules (25 most recent of each).
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div className="admin-stats">
|
<div className="grid grid-cols-[repeat(auto-fit,minmax(180px,1fr))] gap-3.5 mb-6">
|
||||||
<StatusCard label="Recent shouts" value={shouts.length} icon="📣" />
|
<StatusCard label="Recent shouts" value={shouts.length} icon="📣" />
|
||||||
<StatusCard
|
<StatusCard
|
||||||
label="Pending applications"
|
label="Pending applications"
|
||||||
@@ -138,7 +138,7 @@ export default async function AdminRadioPage() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<section className="admin-section">
|
<section className="mt-6">
|
||||||
<h2>Recent Shouts ({shouts.length})</h2>
|
<h2>Recent Shouts ({shouts.length})</h2>
|
||||||
{shouts.length === 0 ? (
|
{shouts.length === 0 ? (
|
||||||
<p className="muted">No shouts to moderate.</p>
|
<p className="muted">No shouts to moderate.</p>
|
||||||
@@ -175,7 +175,7 @@ export default async function AdminRadioPage() {
|
|||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="admin-section">
|
<section className="mt-6">
|
||||||
<h2>DJ Applications ({applications.length})</h2>
|
<h2>DJ Applications ({applications.length})</h2>
|
||||||
{applications.length === 0 ? (
|
{applications.length === 0 ? (
|
||||||
<p className="muted">No applications submitted.</p>
|
<p className="muted">No applications submitted.</p>
|
||||||
@@ -203,7 +203,7 @@ export default async function AdminRadioPage() {
|
|||||||
<td>{a.musicStyle ?? '—'}</td>
|
<td>{a.musicStyle ?? '—'}</td>
|
||||||
<td>{a.discordUsername ?? '—'}</td>
|
<td>{a.discordUsername ?? '—'}</td>
|
||||||
<td>
|
<td>
|
||||||
<span className={`admin-badge ${statusBadgeClass(a.status)}`}>
|
<span className={`inline-block text-[0.72rem] font-bold px-2 py-0.5 rounded-full ${statusBadgeClass(a.status) === 'ok' ? 'bg-green-600/20 text-[var(--color-text)]' : statusBadgeClass(a.status) === 'danger' ? 'bg-red-500/22 text-[var(--color-text)]' : 'bg-[var(--color-primary)]/18 text-[var(--color-text)]'}`}>
|
||||||
{a.status}
|
{a.status}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
@@ -215,7 +215,7 @@ export default async function AdminRadioPage() {
|
|||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="admin-section">
|
<section className="mt-6">
|
||||||
<h2>Schedules ({schedules.length})</h2>
|
<h2>Schedules ({schedules.length})</h2>
|
||||||
{schedules.length === 0 ? (
|
{schedules.length === 0 ? (
|
||||||
<p className="muted">No schedules configured.</p>
|
<p className="muted">No schedules configured.</p>
|
||||||
@@ -242,7 +242,7 @@ export default async function AdminRadioPage() {
|
|||||||
<td>{formatTime(sc.endTime)}</td>
|
<td>{formatTime(sc.endTime)}</td>
|
||||||
<td>{sc.showName ?? '—'}</td>
|
<td>{sc.showName ?? '—'}</td>
|
||||||
<td>
|
<td>
|
||||||
<span className={`admin-badge ${sc.isActive ? 'ok' : 'danger'}`}>
|
<span className={`inline-block text-[0.72rem] font-bold px-2 py-0.5 rounded-full ${sc.isActive ? 'bg-green-600/20 text-[var(--color-text)]' : 'bg-red-500/22 text-[var(--color-text)]'}`}>
|
||||||
{sc.isActive ? 'active' : 'inactive'}
|
{sc.isActive ? 'active' : 'inactive'}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
|
|||||||
@@ -66,7 +66,7 @@ export default async function AdminRadioPointsPage({
|
|||||||
<Link href="/admin/radio/history">History</Link>
|
<Link href="/admin/radio/history">History</Link>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<section className="admin-section">
|
<section className="mt-6">
|
||||||
<h2>Radio Listener Points</h2>
|
<h2>Radio Listener Points</h2>
|
||||||
<p className="muted" style={{ marginTop: "-0.4rem" }}>
|
<p className="muted" style={{ marginTop: "-0.4rem" }}>
|
||||||
Reward listeners with currency for time spent on the radio. Edits write
|
Reward listeners with currency for time spent on the radio. Edits write
|
||||||
@@ -77,11 +77,11 @@ export default async function AdminRadioPointsPage({
|
|||||||
|
|
||||||
{saved === "1" ? (
|
{saved === "1" ? (
|
||||||
<p>
|
<p>
|
||||||
<span className="admin-badge ok">Saved</span>
|
<span className="inline-block text-[0.72rem] font-bold px-2 py-0.5 rounded-full bg-green-600/20 text-[var(--color-text)]">Saved</span>
|
||||||
</p>
|
</p>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<div className="admin-stats">
|
<div className="grid grid-cols-[repeat(auto-fit,minmax(180px,1fr))] gap-3.5 mb-6">
|
||||||
<StatusCard
|
<StatusCard
|
||||||
label="Points"
|
label="Points"
|
||||||
value={enabled ? "Enabled" : "Disabled"}
|
value={enabled ? "Enabled" : "Disabled"}
|
||||||
@@ -103,7 +103,7 @@ export default async function AdminRadioPointsPage({
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="admin-section">
|
<section className="mt-6">
|
||||||
<h2>Settings</h2>
|
<h2>Settings</h2>
|
||||||
<form action={savePoints}>
|
<form action={savePoints}>
|
||||||
<div className="grid cols-2">
|
<div className="grid cols-2">
|
||||||
|
|||||||
@@ -61,7 +61,7 @@ export default async function AdminRadioRanksPage() {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="admin-stats">
|
<div className="grid grid-cols-[repeat(auto-fit,minmax(180px,1fr))] gap-3.5 mb-6">
|
||||||
<StatusCard label="Ranks" value={ranks.length} icon="🎖️" />
|
<StatusCard label="Ranks" value={ranks.length} icon="🎖️" />
|
||||||
<StatusCard
|
<StatusCard
|
||||||
label="Active"
|
label="Active"
|
||||||
@@ -72,7 +72,7 @@ export default async function AdminRadioRanksPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<section className="admin-section">
|
<section className="mt-6">
|
||||||
<h2>Add rank</h2>
|
<h2>Add rank</h2>
|
||||||
<form action={createRadioRank} className="card">
|
<form action={createRadioRank} className="card">
|
||||||
<div className="grid cols-3">
|
<div className="grid cols-3">
|
||||||
@@ -108,7 +108,7 @@ export default async function AdminRadioRanksPage() {
|
|||||||
</form>
|
</form>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="admin-section">
|
<section className="mt-6">
|
||||||
<h2>Existing ranks ({ranks.length})</h2>
|
<h2>Existing ranks ({ranks.length})</h2>
|
||||||
{ranks.length === 0 ? (
|
{ranks.length === 0 ? (
|
||||||
<p className="muted">No ranks yet — add one above.</p>
|
<p className="muted">No ranks yet — add one above.</p>
|
||||||
@@ -126,7 +126,7 @@ export default async function AdminRadioRanksPage() {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<strong>#{String(r.id)}</strong>
|
<strong>#{String(r.id)}</strong>
|
||||||
<span className={`admin-badge ${r.isActive ? 'ok' : 'danger'}`}>
|
<span className={`inline-block text-[0.72rem] font-bold px-2 py-0.5 rounded-full ${r.isActive ? 'bg-green-600/20 text-[var(--color-text)]' : 'bg-red-500/22 text-[var(--color-text)]'}`}>
|
||||||
{r.isActive ? 'active' : 'inactive'}
|
{r.isActive ? 'active' : 'inactive'}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -241,7 +241,7 @@ export default async function AdminRadioSettingsPage() {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="admin-stats">
|
<div className="grid grid-cols-[repeat(auto-fit,minmax(180px,1fr))] gap-3.5 mb-6">
|
||||||
<StatusCard
|
<StatusCard
|
||||||
label="Radio"
|
label="Radio"
|
||||||
value={radioEnabled ? 'Enabled' : 'Disabled'}
|
value={radioEnabled ? 'Enabled' : 'Disabled'}
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ export default async function AdminRareValues() {
|
|||||||
rendered as a tab on the public rare values page.
|
rendered as a tab on the public rare values page.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div className="admin-stats">
|
<div className="grid grid-cols-[repeat(auto-fit,minmax(180px,1fr))] gap-3.5 mb-6">
|
||||||
<StatusCard label="Categories" value={categories.length} icon="🗂️" />
|
<StatusCard label="Categories" value={categories.length} icon="🗂️" />
|
||||||
<StatusCard label="Rare items" value={values.length} icon="💎" />
|
<StatusCard label="Rare items" value={values.length} icon="💎" />
|
||||||
</div>
|
</div>
|
||||||
@@ -82,8 +82,8 @@ export default async function AdminRareValues() {
|
|||||||
>
|
>
|
||||||
<div style={{ display: "flex", gap: "0.6rem", alignItems: "baseline", flexWrap: "wrap" }}>
|
<div style={{ display: "flex", gap: "0.6rem", alignItems: "baseline", flexWrap: "wrap" }}>
|
||||||
<strong>{cat.name}</strong>
|
<strong>{cat.name}</strong>
|
||||||
<span className="admin-badge">badge {cat.badge}</span>
|
<span className="inline-block text-[0.72rem] font-bold px-2 py-0.5 rounded-full bg-[var(--color-primary)]/18 text-[var(--color-text)]">badge {cat.badge}</span>
|
||||||
<span className="admin-badge">priority {cat.priority}</span>
|
<span className="inline-block text-[0.72rem] font-bold px-2 py-0.5 rounded-full bg-[var(--color-primary)]/18 text-[var(--color-text)]">priority {cat.priority}</span>
|
||||||
<span className="muted">{rows.length} item{rows.length === 1 ? "" : "s"}</span>
|
<span className="muted">{rows.length} item{rows.length === 1 ? "" : "s"}</span>
|
||||||
</div>
|
</div>
|
||||||
<form action={deleteCategory}>
|
<form action={deleteCategory}>
|
||||||
@@ -112,7 +112,7 @@ export default async function AdminRareValues() {
|
|||||||
<td>{v.creditValue ?? "—"}</td>
|
<td>{v.creditValue ?? "—"}</td>
|
||||||
<td>{v.currencyValue ?? "—"}</td>
|
<td>{v.currencyValue ?? "—"}</td>
|
||||||
<td>
|
<td>
|
||||||
<span className="admin-badge">{v.currencyType}</span>
|
<span className="inline-block text-[0.72rem] font-bold px-2 py-0.5 rounded-full bg-[var(--color-primary)]/18 text-[var(--color-text)]">{v.currencyType}</span>
|
||||||
</td>
|
</td>
|
||||||
<td className="muted">{v.furnitureIcon}</td>
|
<td className="muted">{v.furnitureIcon}</td>
|
||||||
<td>
|
<td>
|
||||||
|
|||||||
@@ -73,7 +73,7 @@ export default async function AdminRooms({
|
|||||||
{total} room{total === 1 ? "" : "s"} total.
|
{total} room{total === 1 ? "" : "s"} total.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div className="admin-stats">
|
<div className="grid grid-cols-[repeat(auto-fit,minmax(180px,1fr))] gap-3.5 mb-6">
|
||||||
<StatusCard label={q ? "Matching rooms" : "Total rooms"} value={total} icon="🏠" />
|
<StatusCard label={q ? "Matching rooms" : "Total rooms"} value={total} icon="🏠" />
|
||||||
<StatusCard label="Open (this page)" value={openOnPage} state="ok" icon="🔓" />
|
<StatusCard label="Open (this page)" value={openOnPage} state="ok" icon="🔓" />
|
||||||
<StatusCard label="Users in-room (this page)" value={usersOnPage} icon="👥" />
|
<StatusCard label="Users in-room (this page)" value={usersOnPage} icon="👥" />
|
||||||
@@ -110,7 +110,7 @@ export default async function AdminRooms({
|
|||||||
{r.users} / {r.usersMax}
|
{r.users} / {r.usersMax}
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<span className={`admin-badge ${r.state === "open" ? "ok" : "danger"}`}>
|
<span className={`inline-block text-[0.72rem] font-bold px-2 py-0.5 rounded-full ${r.state === "open" ? "bg-green-600/20 text-[var(--color-text)]" : "bg-red-500/22 text-[var(--color-text)]"}`}>
|
||||||
{r.state}
|
{r.state}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
|
|||||||
@@ -21,12 +21,12 @@ export default async function AdminSettings() {
|
|||||||
Key/value config read at runtime via <code>siteSettings.get()</code>. Saving busts the cache.
|
Key/value config read at runtime via <code>siteSettings.get()</code>. Saving busts the cache.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div className="admin-stats">
|
<div className="grid grid-cols-[repeat(auto-fit,minmax(180px,1fr))] gap-3.5 mb-6">
|
||||||
<StatusCard label="Settings" value={settings.length} icon="🗄️" />
|
<StatusCard label="Settings" value={settings.length} icon="🗄️" />
|
||||||
<StatusCard label="With comment" value={documented} icon="📝" />
|
<StatusCard label="With comment" value={documented} icon="📝" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<section className="admin-section">
|
<section className="mt-6">
|
||||||
<h2>Add / overwrite</h2>
|
<h2>Add / overwrite</h2>
|
||||||
<form action={createSetting} className="card">
|
<form action={createSetting} className="card">
|
||||||
<div style={{ display: "flex", gap: "0.5rem", flexWrap: "wrap" }}>
|
<div style={{ display: "flex", gap: "0.5rem", flexWrap: "wrap" }}>
|
||||||
@@ -40,9 +40,9 @@ export default async function AdminSettings() {
|
|||||||
</form>
|
</form>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="admin-section">
|
<section className="mt-6">
|
||||||
<h2>
|
<h2>
|
||||||
Settings <span className="admin-badge">{settings.length}</span>
|
Settings <span className="inline-block text-[0.72rem] font-bold px-2 py-0.5 rounded-full bg-[var(--color-primary)]/18 text-[var(--color-text)]">{settings.length}</span>
|
||||||
</h2>
|
</h2>
|
||||||
{settings.length === 0 ? (
|
{settings.length === 0 ? (
|
||||||
<p className="muted">No settings yet.</p>
|
<p className="muted">No settings yet.</p>
|
||||||
|
|||||||
@@ -51,7 +51,7 @@ export default async function AdminShop() {
|
|||||||
shown in the store; credits/duckets/diamonds/badges are what the buyer receives.
|
shown in the store; credits/duckets/diamonds/badges are what the buyer receives.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div className="admin-stats">
|
<div className="grid grid-cols-[repeat(auto-fit,minmax(180px,1fr))] gap-3.5 mb-6">
|
||||||
<StatusCard label="Packages" value={packages.length} icon="🛍️" />
|
<StatusCard label="Packages" value={packages.length} icon="🛍️" />
|
||||||
<StatusCard label="Recent orders" value={orders.length} icon="🧾" />
|
<StatusCard label="Recent orders" value={orders.length} icon="🧾" />
|
||||||
<StatusCard
|
<StatusCard
|
||||||
@@ -62,7 +62,7 @@ export default async function AdminShop() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<section className="admin-section">
|
<section className="mt-6">
|
||||||
<h2>Packages ({packages.length})</h2>
|
<h2>Packages ({packages.length})</h2>
|
||||||
{packages.length === 0 ? (
|
{packages.length === 0 ? (
|
||||||
<p className="muted">No shop packages yet.</p>
|
<p className="muted">No shop packages yet.</p>
|
||||||
@@ -102,7 +102,7 @@ export default async function AdminShop() {
|
|||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="admin-section">
|
<section className="mt-6">
|
||||||
<h2>Recent orders ({orders.length})</h2>
|
<h2>Recent orders ({orders.length})</h2>
|
||||||
<p className="muted" style={{ marginTop: "-0.4rem" }}>
|
<p className="muted" style={{ marginTop: "-0.4rem" }}>
|
||||||
Read-only PayPal transaction log (<code>website_paypal_transactions</code>).
|
Read-only PayPal transaction log (<code>website_paypal_transactions</code>).
|
||||||
@@ -138,7 +138,7 @@ export default async function AdminShop() {
|
|||||||
<td>
|
<td>
|
||||||
{o.status ? (
|
{o.status ? (
|
||||||
<span
|
<span
|
||||||
className={`admin-badge ${state === "ok" ? "ok" : state === "danger" ? "danger" : ""}`}
|
className={`inline-block text-[0.72rem] font-bold px-2 py-0.5 rounded-full ${state === "ok" ? "bg-green-600/20 text-[var(--color-text)]" : state === "danger" ? "bg-red-500/22 text-[var(--color-text)]" : "bg-[var(--color-primary)]/18 text-[var(--color-text)]"}`}
|
||||||
>
|
>
|
||||||
{o.status}
|
{o.status}
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ export default async function AdminSubscriptions() {
|
|||||||
Active Habbo Club / VIP subscriptions (expiry > now). Read-only.
|
Active Habbo Club / VIP subscriptions (expiry > now). Read-only.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div className="admin-stats">
|
<div className="grid grid-cols-[repeat(auto-fit,minmax(180px,1fr))] gap-3.5 mb-6">
|
||||||
<StatusCard label="Active subs" value={active.length} icon="⭐" />
|
<StatusCard label="Active subs" value={active.length} icon="⭐" />
|
||||||
<StatusCard label="Types" value={types} icon="🏷️" />
|
<StatusCard label="Types" value={types} icon="🏷️" />
|
||||||
<StatusCard
|
<StatusCard
|
||||||
@@ -63,7 +63,7 @@ export default async function AdminSubscriptions() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<section className="admin-section">
|
<section className="mt-6">
|
||||||
<h2>Active subscriptions ({active.length})</h2>
|
<h2>Active subscriptions ({active.length})</h2>
|
||||||
<div className="card">
|
<div className="card">
|
||||||
<table>
|
<table>
|
||||||
@@ -85,7 +85,7 @@ export default async function AdminSubscriptions() {
|
|||||||
<td>{s.userId ?? "—"}</td>
|
<td>{s.userId ?? "—"}</td>
|
||||||
<td>{s.subscriptionType ?? "—"}</td>
|
<td>{s.subscriptionType ?? "—"}</td>
|
||||||
<td>
|
<td>
|
||||||
<span className={`admin-badge ${soon ? "" : "ok"}`}>
|
<span className={`inline-block text-[0.72rem] font-bold px-2 py-0.5 rounded-full ${soon ? "bg-[var(--color-primary)]/18 text-[var(--color-text)]" : "bg-green-600/20 text-[var(--color-text)]"}`}>
|
||||||
{soon ? "Expiring" : "Active"}
|
{soon ? "Expiring" : "Active"}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
|
|||||||
@@ -56,7 +56,7 @@ export default async function AdminTagsPage() {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="admin-stats">
|
<div className="grid grid-cols-[repeat(auto-fit,minmax(180px,1fr))] gap-3.5 mb-6">
|
||||||
<StatusCard label="Tags" value={tags.length} icon="🏷️" />
|
<StatusCard label="Tags" value={tags.length} icon="🏷️" />
|
||||||
<StatusCard
|
<StatusCard
|
||||||
label="In use"
|
label="In use"
|
||||||
@@ -114,7 +114,7 @@ export default async function AdminTagsPage() {
|
|||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<section className="admin-section">
|
<section className="mt-6">
|
||||||
<h2>All tags</h2>
|
<h2>All tags</h2>
|
||||||
{tags.length === 0 ? (
|
{tags.length === 0 ? (
|
||||||
<p className="muted">No tags yet.</p>
|
<p className="muted">No tags yet.</p>
|
||||||
@@ -152,8 +152,8 @@ export default async function AdminTagsPage() {
|
|||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<span
|
<span
|
||||||
className={`admin-badge ${
|
className={`inline-block text-[0.72rem] font-bold px-2 py-0.5 rounded-full ${
|
||||||
(usage.get(String(t.id)) ?? 0) > 0 ? 'ok' : ''
|
(usage.get(String(t.id)) ?? 0) > 0 ? 'bg-green-600/20 text-[var(--color-text)]' : 'bg-[var(--color-primary)]/18 text-[var(--color-text)]'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{usage.get(String(t.id)) ?? 0}
|
{usage.get(String(t.id)) ?? 0}
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ export default async function AdminTeams() {
|
|||||||
Staff rank definitions shown on the public team page.
|
Staff rank definitions shown on the public team page.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div className="admin-stats">
|
<div className="grid grid-cols-[repeat(auto-fit,minmax(180px,1fr))] gap-3.5 mb-6">
|
||||||
<StatusCard label="Team ranks" value={teams.length} icon="🛡️" />
|
<StatusCard label="Team ranks" value={teams.length} icon="🛡️" />
|
||||||
<StatusCard
|
<StatusCard
|
||||||
label="Visible"
|
label="Visible"
|
||||||
@@ -84,7 +84,7 @@ export default async function AdminTeams() {
|
|||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<section className="admin-section">
|
<section className="mt-6">
|
||||||
<h2>Ranks ({teams.length})</h2>
|
<h2>Ranks ({teams.length})</h2>
|
||||||
{teams.length === 0 ? (
|
{teams.length === 0 ? (
|
||||||
<p className="muted">No teams yet.</p>
|
<p className="muted">No teams yet.</p>
|
||||||
@@ -131,7 +131,7 @@ export default async function AdminTeams() {
|
|||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<span className={`admin-badge ${t.hiddenRank ? "" : "ok"}`}>
|
<span className={`inline-block text-[0.72rem] font-bold px-2 py-0.5 rounded-full ${t.hiddenRank ? "bg-[var(--color-primary)]/18 text-[var(--color-text)]" : "bg-green-600/20 text-[var(--color-text)]"}`}>
|
||||||
{t.hiddenRank ? "hidden" : "visible"}
|
{t.hiddenRank ? "hidden" : "visible"}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
|
|||||||
@@ -97,17 +97,17 @@ export default async function AdminTheme({
|
|||||||
</p>
|
</p>
|
||||||
|
|
||||||
{sp.saved ? (
|
{sp.saved ? (
|
||||||
<p className="admin-badge ok" style={{ display: "inline-block" }}>
|
<p className="inline-block text-[0.72rem] font-bold px-2 py-0.5 rounded-full bg-green-600/20 text-[var(--color-text)]">
|
||||||
Theme saved
|
Theme saved
|
||||||
</p>
|
</p>
|
||||||
) : null}
|
) : null}
|
||||||
{sp.preset ? (
|
{sp.preset ? (
|
||||||
<p className="admin-badge ok" style={{ display: "inline-block" }}>
|
<p className="inline-block text-[0.72rem] font-bold px-2 py-0.5 rounded-full bg-green-600/20 text-[var(--color-text)]">
|
||||||
Applied preset “{sp.preset}”
|
Applied preset “{sp.preset}”
|
||||||
</p>
|
</p>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<section className="admin-section">
|
<section className="mt-6">
|
||||||
<h2>Presets</h2>
|
<h2>Presets</h2>
|
||||||
<div style={{ display: "flex", flexWrap: "wrap", gap: "0.5rem" }}>
|
<div style={{ display: "flex", flexWrap: "wrap", gap: "0.5rem" }}>
|
||||||
{Object.entries(PRESETS).map(([name, palette]) => (
|
{Object.entries(PRESETS).map(([name, palette]) => (
|
||||||
@@ -141,17 +141,17 @@ export default async function AdminTheme({
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
<form action={saveTheme}>
|
<form action={saveTheme}>
|
||||||
<section className="admin-section">
|
<section className="mt-6">
|
||||||
<h2>Colours</h2>
|
<h2>Colours</h2>
|
||||||
<div className="card">{colorGrid(COLOR_FIELDS, current)}</div>
|
<div className="card">{colorGrid(COLOR_FIELDS, current)}</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="admin-section">
|
<section className="mt-6">
|
||||||
<h2>Buttons & links</h2>
|
<h2>Buttons & links</h2>
|
||||||
<div className="card">{colorGrid(BUTTON_FIELDS, current)}</div>
|
<div className="card">{colorGrid(BUTTON_FIELDS, current)}</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="admin-section">
|
<section className="mt-6">
|
||||||
<h2>Typography & layout</h2>
|
<h2>Typography & layout</h2>
|
||||||
<div className="card">
|
<div className="card">
|
||||||
<div style={{ display: "flex", flexWrap: "wrap", gap: "1.25rem", alignItems: "center" }}>
|
<div style={{ display: "flex", flexWrap: "wrap", gap: "1.25rem", alignItems: "center" }}>
|
||||||
@@ -193,7 +193,7 @@ export default async function AdminTheme({
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="admin-section">
|
<section className="mt-6">
|
||||||
<h2>Custom CSS</h2>
|
<h2>Custom CSS</h2>
|
||||||
<p className="muted" style={{ marginTop: "-0.4rem" }}>
|
<p className="muted" style={{ marginTop: "-0.4rem" }}>
|
||||||
Injected raw into every page (after the theme variables). Use it for tweaks the controls
|
Injected raw into every page (after the theme variables). Use it for tweaks the controls
|
||||||
|
|||||||
@@ -93,7 +93,7 @@ export default async function AdminTransactions({
|
|||||||
Read-only · PayPal / top-up transactions recorded by the CMS, newest first.
|
Read-only · PayPal / top-up transactions recorded by the CMS, newest first.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div className="admin-stats">
|
<div className="grid grid-cols-[repeat(auto-fit,minmax(180px,1fr))] gap-3.5 mb-6">
|
||||||
<StatusCard label="Total transactions" value={total} icon="🧾" />
|
<StatusCard label="Total transactions" value={total} icon="🧾" />
|
||||||
<StatusCard
|
<StatusCard
|
||||||
label="Paid (this page)"
|
label="Paid (this page)"
|
||||||
@@ -109,7 +109,7 @@ export default async function AdminTransactions({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<section className="admin-section">
|
<section className="mt-6">
|
||||||
<h2>Transactions ({total})</h2>
|
<h2>Transactions ({total})</h2>
|
||||||
{transactions.length === 0 ? (
|
{transactions.length === 0 ? (
|
||||||
<p className="muted">No transactions.</p>
|
<p className="muted">No transactions.</p>
|
||||||
@@ -143,7 +143,7 @@ export default async function AdminTransactions({
|
|||||||
<td>
|
<td>
|
||||||
{t.status ? (
|
{t.status ? (
|
||||||
<span
|
<span
|
||||||
className={`admin-badge ${state === "ok" ? "ok" : state === "danger" ? "danger" : ""}`}
|
className={`inline-block text-[0.72rem] font-bold px-2 py-0.5 rounded-full ${state === "ok" ? "bg-green-600/20 text-[var(--color-text)]" : state === "danger" ? "bg-red-500/22 text-[var(--color-text)]" : "bg-[var(--color-primary)]/18 text-[var(--color-text)]"}`}
|
||||||
>
|
>
|
||||||
{t.status}
|
{t.status}
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ export default async function AdminUserDetail({
|
|||||||
{user.motto ? user.motto : "No motto set."}
|
{user.motto ? user.motto : "No motto set."}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div className="admin-stats">
|
<div className="grid grid-cols-[repeat(auto-fit,minmax(180px,1fr))] gap-3.5 mb-6">
|
||||||
<StatusCard label="User ID" value={user.id} icon="🆔" />
|
<StatusCard label="User ID" value={user.id} icon="🆔" />
|
||||||
<StatusCard label="Rank" value={user.rank} icon="🎖️" />
|
<StatusCard label="Rank" value={user.rank} icon="🎖️" />
|
||||||
<StatusCard label="Credits" value={user.credits} icon="💰" />
|
<StatusCard label="Credits" value={user.credits} icon="💰" />
|
||||||
@@ -51,14 +51,14 @@ export default async function AdminUserDetail({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<section className="admin-section">
|
<section className="mt-6">
|
||||||
<h2>Overview</h2>
|
<h2>Overview</h2>
|
||||||
<div className="admin-info-grid">
|
<div className="grid grid-cols-[repeat(auto-fit,minmax(150px,1fr))] gap-2.5">
|
||||||
<InfoItem label="Username" value={user.username} />
|
<InfoItem label="Username" value={user.username} />
|
||||||
<InfoItem
|
<InfoItem
|
||||||
label="Status"
|
label="Status"
|
||||||
value={
|
value={
|
||||||
<span className={`admin-badge ${isOnline ? "ok" : ""}`}>
|
<span className={`inline-block text-[0.72rem] font-bold px-2 py-0.5 rounded-full ${isOnline ? "bg-green-600/20 text-[var(--color-text)]" : "bg-[var(--color-primary)]/18 text-[var(--color-text)]"}`}>
|
||||||
{isOnline ? "online" : "offline"}
|
{isOnline ? "online" : "offline"}
|
||||||
</span>
|
</span>
|
||||||
}
|
}
|
||||||
@@ -69,7 +69,7 @@ export default async function AdminUserDetail({
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="admin-section">
|
<section className="mt-6">
|
||||||
<h2>Actions</h2>
|
<h2>Actions</h2>
|
||||||
<div className="grid cols-2">
|
<div className="grid cols-2">
|
||||||
<form action={giveCurrency} className="card">
|
<form action={giveCurrency} className="card">
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ export default async function AdminUsers({
|
|||||||
{q ? `Search results for “${q}”.` : "Most recent accounts first."}
|
{q ? `Search results for “${q}”.` : "Most recent accounts first."}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div className="admin-stats">
|
<div className="grid grid-cols-[repeat(auto-fit,minmax(180px,1fr))] gap-3.5 mb-6">
|
||||||
<StatusCard label="Total users" value={total} icon="👥" />
|
<StatusCard label="Total users" value={total} icon="👥" />
|
||||||
<StatusCard label="On this page" value={users.length} icon="📄" />
|
<StatusCard label="On this page" value={users.length} icon="📄" />
|
||||||
<StatusCard
|
<StatusCard
|
||||||
@@ -63,7 +63,7 @@ export default async function AdminUsers({
|
|||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<section className="admin-section">
|
<section className="mt-6">
|
||||||
<h2>Accounts ({total})</h2>
|
<h2>Accounts ({total})</h2>
|
||||||
{users.length === 0 ? (
|
{users.length === 0 ? (
|
||||||
<p className="muted">No users match this search.</p>
|
<p className="muted">No users match this search.</p>
|
||||||
@@ -90,7 +90,7 @@ export default async function AdminUsers({
|
|||||||
<td>{u.rank}</td>
|
<td>{u.rank}</td>
|
||||||
<td>{u.credits}</td>
|
<td>{u.credits}</td>
|
||||||
<td>
|
<td>
|
||||||
<span className={`admin-badge ${u.online === "1" ? "ok" : ""}`}>
|
<span className={`inline-block text-[0.72rem] font-bold px-2 py-0.5 rounded-full ${u.online === "1" ? "bg-green-600/20 text-[var(--color-text)]" : "bg-[var(--color-primary)]/18 text-[var(--color-text)]"}`}>
|
||||||
{u.online === "1" ? "online" : "offline"}
|
{u.online === "1" ? "online" : "offline"}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
|
|||||||
@@ -61,7 +61,7 @@ export default async function AdminVouchers() {
|
|||||||
use a given voucher once, up to its max uses.
|
use a given voucher once, up to its max uses.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div className="admin-stats">
|
<div className="grid grid-cols-[repeat(auto-fit,minmax(180px,1fr))] gap-3.5 mb-6">
|
||||||
<StatusCard label="Vouchers" value={vouchers.length} icon="🎟️" />
|
<StatusCard label="Vouchers" value={vouchers.length} icon="🎟️" />
|
||||||
<StatusCard
|
<StatusCard
|
||||||
label="Active"
|
label="Active"
|
||||||
@@ -106,7 +106,7 @@ export default async function AdminVouchers() {
|
|||||||
<button type="submit" className="btn btn-primary">Create voucher</button>
|
<button type="submit" className="btn btn-primary">Create voucher</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<section className="admin-section">
|
<section className="mt-6">
|
||||||
<h2>Vouchers ({vouchers.length})</h2>
|
<h2>Vouchers ({vouchers.length})</h2>
|
||||||
<div className="card">
|
<div className="card">
|
||||||
<table>
|
<table>
|
||||||
@@ -136,11 +136,11 @@ export default async function AdminVouchers() {
|
|||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
{expired ? (
|
{expired ? (
|
||||||
<span className="admin-badge danger">Expired</span>
|
<span className="inline-block text-[0.72rem] font-bold px-2 py-0.5 rounded-full bg-red-500/22 text-[var(--color-text)]">Expired</span>
|
||||||
) : usedUp ? (
|
) : usedUp ? (
|
||||||
<span className="admin-badge">Used up</span>
|
<span className="inline-block text-[0.72rem] font-bold px-2 py-0.5 rounded-full bg-[var(--color-primary)]/18 text-[var(--color-text)]">Used up</span>
|
||||||
) : (
|
) : (
|
||||||
<span className="admin-badge ok">Active</span>
|
<span className="inline-block text-[0.72rem] font-bold px-2 py-0.5 rounded-full bg-green-600/20 text-[var(--color-text)]">Active</span>
|
||||||
)}
|
)}
|
||||||
</td>
|
</td>
|
||||||
<td className="muted">{fmtDate(v.expiresAt)}</td>
|
<td className="muted">{fmtDate(v.expiresAt)}</td>
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ export default async function AdminVpn({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<main>
|
<main>
|
||||||
<section className="admin-section">
|
<section className="mt-6">
|
||||||
<h2>VPN / Proxy Detection</h2>
|
<h2>VPN / Proxy Detection</h2>
|
||||||
<p className="muted" style={{ marginTop: "-0.4rem" }}>
|
<p className="muted" style={{ marginTop: "-0.4rem" }}>
|
||||||
Block registrations from detected VPN/proxy IPs at sign-up. Settings
|
Block registrations from detected VPN/proxy IPs at sign-up. Settings
|
||||||
@@ -41,12 +41,12 @@ export default async function AdminVpn({
|
|||||||
</p>
|
</p>
|
||||||
|
|
||||||
{sp.saved ? (
|
{sp.saved ? (
|
||||||
<p className="admin-badge ok" style={{ display: "inline-block" }}>
|
<p className="inline-block text-[0.72rem] font-bold px-2 py-0.5 rounded-full bg-green-600/20 text-[var(--color-text)]" style={{ display: "inline-block" }}>
|
||||||
VPN settings saved
|
VPN settings saved
|
||||||
</p>
|
</p>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<div className="admin-stats">
|
<div className="grid grid-cols-[repeat(auto-fit,minmax(180px,1fr))] gap-3.5 mb-6">
|
||||||
<StatusCard
|
<StatusCard
|
||||||
label="Protection"
|
label="Protection"
|
||||||
value={enabled ? "On" : "Off"}
|
value={enabled ? "On" : "Off"}
|
||||||
@@ -57,7 +57,7 @@ export default async function AdminVpn({
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="admin-section">
|
<section className="mt-6">
|
||||||
<h2>Settings</h2>
|
<h2>Settings</h2>
|
||||||
<form action={saveVpn} className="card">
|
<form action={saveVpn} className="card">
|
||||||
<div style={{ marginBottom: "1rem" }}>
|
<div style={{ marginBottom: "1rem" }}>
|
||||||
@@ -147,7 +147,7 @@ export default async function AdminVpn({
|
|||||||
</form>
|
</form>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="admin-section">
|
<section className="mt-6">
|
||||||
<h2>Raw IP allow / deny list</h2>
|
<h2>Raw IP allow / deny list</h2>
|
||||||
<p className="muted" style={{ marginTop: "-0.4rem" }}>
|
<p className="muted" style={{ marginTop: "-0.4rem" }}>
|
||||||
VPN detection screens unknown IPs automatically. To explicitly allow
|
VPN detection screens unknown IPs automatically. To explicitly allow
|
||||||
|
|||||||
@@ -25,11 +25,11 @@ export default async function AdminWordFilter() {
|
|||||||
Words added here are blocked in-game. Saving pushes the list to the emulator.
|
Words added here are blocked in-game. Saving pushes the list to the emulator.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div className="admin-stats">
|
<div className="grid grid-cols-[repeat(auto-fit,minmax(180px,1fr))] gap-3.5 mb-6">
|
||||||
<StatusCard label="Filtered words" value={words.length} icon="🚫" />
|
<StatusCard label="Filtered words" value={words.length} icon="🚫" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<section className="admin-section">
|
<section className="mt-6">
|
||||||
<h2>Add word</h2>
|
<h2>Add word</h2>
|
||||||
<form action={addWord} className="card">
|
<form action={addWord} className="card">
|
||||||
<div style={{ display: "flex", gap: "0.5rem", flexWrap: "wrap" }}>
|
<div style={{ display: "flex", gap: "0.5rem", flexWrap: "wrap" }}>
|
||||||
@@ -47,9 +47,9 @@ export default async function AdminWordFilter() {
|
|||||||
</form>
|
</form>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="admin-section">
|
<section className="mt-6">
|
||||||
<h2>
|
<h2>
|
||||||
Words <span className="admin-badge">{words.length}</span>
|
Words <span className="inline-block text-[0.72rem] font-bold px-2 py-0.5 rounded-full bg-[var(--color-primary)]/18 text-[var(--color-text)]">{words.length}</span>
|
||||||
</h2>
|
</h2>
|
||||||
{words.length === 0 ? (
|
{words.length === 0 ? (
|
||||||
<p className="muted">No filtered words.</p>
|
<p className="muted">No filtered words.</p>
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ export default async function AdminWriteableBoxes() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<main>
|
<main>
|
||||||
<section className="admin-section">
|
<section className="mt-6">
|
||||||
<h2>Writeable boxes</h2>
|
<h2>Writeable boxes</h2>
|
||||||
<p className="muted" style={{ marginTop: "-0.4rem" }}>
|
<p className="muted" style={{ marginTop: "-0.4rem" }}>
|
||||||
Content panels stored in <code>website_writeable_boxes</code> and ordered by{" "}
|
Content panels stored in <code>website_writeable_boxes</code> and ordered by{" "}
|
||||||
@@ -26,7 +26,7 @@ export default async function AdminWriteableBoxes() {
|
|||||||
hidden ones are kept here as drafts.
|
hidden ones are kept here as drafts.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div className="admin-stats">
|
<div className="grid grid-cols-[repeat(auto-fit,minmax(180px,1fr))] gap-3.5 mb-6">
|
||||||
<StatusCard label="Total boxes" value={boxes.length} icon="🗂️" />
|
<StatusCard label="Total boxes" value={boxes.length} icon="🗂️" />
|
||||||
<StatusCard
|
<StatusCard
|
||||||
label="Active"
|
label="Active"
|
||||||
@@ -38,7 +38,7 @@ export default async function AdminWriteableBoxes() {
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="admin-section">
|
<section className="mt-6">
|
||||||
<h2>New box</h2>
|
<h2>New box</h2>
|
||||||
<form action={createBox} className="grid cols-2">
|
<form action={createBox} className="grid cols-2">
|
||||||
<div>
|
<div>
|
||||||
@@ -96,7 +96,7 @@ export default async function AdminWriteableBoxes() {
|
|||||||
</form>
|
</form>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="admin-section">
|
<section className="mt-6">
|
||||||
<h2>Boxes ({boxes.length})</h2>
|
<h2>Boxes ({boxes.length})</h2>
|
||||||
{boxes.length === 0 ? (
|
{boxes.length === 0 ? (
|
||||||
<p className="muted">No writeable boxes yet.</p>
|
<p className="muted">No writeable boxes yet.</p>
|
||||||
@@ -115,7 +115,7 @@ export default async function AdminWriteableBoxes() {
|
|||||||
<span className="muted">#{String(box.id)}</span>
|
<span className="muted">#{String(box.id)}</span>
|
||||||
{box.icon ? <span aria-hidden>{box.icon}</span> : null}
|
{box.icon ? <span aria-hidden>{box.icon}</span> : null}
|
||||||
<strong style={{ flex: 1 }}>{box.title}</strong>
|
<strong style={{ flex: 1 }}>{box.title}</strong>
|
||||||
<span className={`admin-badge ${box.isActive ? "ok" : "danger"}`}>
|
<span className={`inline-block text-[0.72rem] font-bold px-2 py-0.5 rounded-full ${box.isActive ? "bg-green-600/20 text-[var(--color-text)]" : "bg-red-500/22 text-[var(--color-text)]"}`}>
|
||||||
{box.isActive ? "Active" : "Hidden"}
|
{box.isActive ? "Active" : "Hidden"}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -299,28 +299,28 @@ const GROUPS: Group[] = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
const METHOD_CLASS: Record<Method, string> = {
|
const METHOD_CLASS: Record<Method, string> = {
|
||||||
GET: "ok",
|
GET: "bg-green-600/20 text-[var(--color-text)]",
|
||||||
POST: "",
|
POST: "bg-[var(--color-primary)]/18 text-[var(--color-text)]",
|
||||||
DELETE: "danger",
|
DELETE: "bg-red-500/22 text-[var(--color-text)]",
|
||||||
};
|
};
|
||||||
|
|
||||||
function AuthTag({ endpoint }: { endpoint: Endpoint }) {
|
function AuthTag({ endpoint }: { endpoint: Endpoint }) {
|
||||||
if (endpoint.bearer) {
|
if (endpoint.bearer) {
|
||||||
return (
|
return (
|
||||||
<span className="admin-badge" title="Requires Authorization: Bearer <token>">
|
<span className="inline-block text-[0.72rem] font-bold px-2 py-0.5 rounded-full bg-[var(--color-primary)]/18 text-[var(--color-text)]" title="Requires Authorization: Bearer <token>">
|
||||||
🔒 Bearer
|
🔒 Bearer
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (endpoint.session) {
|
if (endpoint.session) {
|
||||||
return (
|
return (
|
||||||
<span className="admin-badge" title="Requires a signed-in web session">
|
<span className="inline-block text-[0.72rem] font-bold px-2 py-0.5 rounded-full bg-[var(--color-primary)]/18 text-[var(--color-text)]" title="Requires a signed-in web session">
|
||||||
🔒 Session
|
🔒 Session
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<span className="admin-badge ok" title="No authentication required">
|
<span className="inline-block text-[0.72rem] font-bold px-2 py-0.5 rounded-full bg-green-600/20 text-[var(--color-text)]" title="No authentication required">
|
||||||
Public
|
Public
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
@@ -355,7 +355,7 @@ function EndpointRow({ endpoint }: { endpoint: Endpoint }) {
|
|||||||
overflowX: "auto",
|
overflowX: "auto",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<span className={`admin-badge ${METHOD_CLASS[endpoint.method]}`}>{endpoint.method}</span>
|
<span className={`inline-block text-[0.72rem] font-bold px-2 py-0.5 rounded-full ${METHOD_CLASS[endpoint.method]}`}>{endpoint.method}</span>
|
||||||
<span>{endpoint.path}</span>
|
<span>{endpoint.path}</span>
|
||||||
</code>
|
</code>
|
||||||
<span className="muted" style={{ flex: "1 1 14rem", minWidth: "12rem" }}>
|
<span className="muted" style={{ flex: "1 1 14rem", minWidth: "12rem" }}>
|
||||||
@@ -387,7 +387,7 @@ export default function DevelopersPage() {
|
|||||||
<div style={{ display: "grid", gap: "0.75rem" }}>
|
<div style={{ display: "grid", gap: "0.75rem" }}>
|
||||||
<p className="muted" style={{ margin: 0 }}>
|
<p className="muted" style={{ margin: 0 }}>
|
||||||
Most reads are <strong>open</strong> and need no credentials. Endpoints marked{" "}
|
Most reads are <strong>open</strong> and need no credentials. Endpoints marked{" "}
|
||||||
<span className="admin-badge">🔒 Bearer</span> require a personal access token sent in
|
<span className="inline-block text-[0.72rem] font-bold px-2 py-0.5 rounded-full bg-[var(--color-primary)]/18 text-[var(--color-text)]">🔒 Bearer</span> require a personal access token sent in
|
||||||
the request header:
|
the request header:
|
||||||
</p>
|
</p>
|
||||||
<code
|
<code
|
||||||
@@ -411,7 +411,7 @@ export default function DevelopersPage() {
|
|||||||
<code>POST</code> to <code>/api/tokens</code> — the plaintext token is returned{" "}
|
<code>POST</code> to <code>/api/tokens</code> — the plaintext token is returned{" "}
|
||||||
<strong>once</strong> and never shown again, so store it safely. You can list and revoke
|
<strong>once</strong> and never shown again, so store it safely. You can list and revoke
|
||||||
your tokens at <code>/api/me/tokens</code>. These token-management endpoints are marked{" "}
|
your tokens at <code>/api/me/tokens</code>. These token-management endpoints are marked{" "}
|
||||||
<span className="admin-badge">🔒 Session</span> because they use your signed-in web
|
<span className="inline-block text-[0.72rem] font-bold px-2 py-0.5 rounded-full bg-[var(--color-primary)]/18 text-[var(--color-text)]">🔒 Session</span> because they use your signed-in web
|
||||||
session rather than a Bearer token.
|
session rather than a Bearer token.
|
||||||
</p>
|
</p>
|
||||||
<p className="muted" style={{ margin: 0 }}>
|
<p className="muted" style={{ margin: 0 }}>
|
||||||
|
|||||||
+1
-262
@@ -436,136 +436,7 @@ tbody tr:hover {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* ── Admin panel (Housekeeping) ─────────────────────────────────
|
/* ── Admin panel (Housekeeping) ─────────────────────────────────
|
||||||
Faithful to AtomCMS's Filament panel: dark #2d2d44 sidebar with
|
Page content wrapper — table card styling and h1 formatting. */
|
||||||
grouped nav + amber active state, a topbar, and a light content
|
|
||||||
area with carded tables. Scoped under .admin so it never leaks
|
|
||||||
into the public site. */
|
|
||||||
.admin {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: 232px minmax(0, 1fr);
|
|
||||||
gap: 1.25rem;
|
|
||||||
align-items: start;
|
|
||||||
}
|
|
||||||
@media (max-width: 860px) {
|
|
||||||
.admin {
|
|
||||||
grid-template-columns: 1fr;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-sidebar {
|
|
||||||
position: sticky;
|
|
||||||
top: 1rem;
|
|
||||||
align-self: start;
|
|
||||||
background: #2d2d44;
|
|
||||||
color: #e7e9f3;
|
|
||||||
border-radius: 14px;
|
|
||||||
padding: 0.85rem 0.7rem;
|
|
||||||
box-shadow: var(--shadow-card);
|
|
||||||
max-height: calc(100vh - 2rem);
|
|
||||||
overflow-y: auto;
|
|
||||||
}
|
|
||||||
.admin-sidebar-head {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.6rem;
|
|
||||||
padding: 0.3rem 0.45rem 0.85rem;
|
|
||||||
border-bottom: 1px solid rgba(255, 255, 255, 0.09);
|
|
||||||
margin-bottom: 0.5rem;
|
|
||||||
}
|
|
||||||
.admin-avatar {
|
|
||||||
flex: none;
|
|
||||||
width: 38px;
|
|
||||||
height: 38px;
|
|
||||||
border-radius: 10px;
|
|
||||||
display: grid;
|
|
||||||
place-items: center;
|
|
||||||
font-weight: 800;
|
|
||||||
color: #1a1a2e;
|
|
||||||
background: var(--color-primary, #eeb425);
|
|
||||||
}
|
|
||||||
.admin-sidebar-name {
|
|
||||||
font-weight: 700;
|
|
||||||
font-size: 0.92rem;
|
|
||||||
line-height: 1.1;
|
|
||||||
color: #fff;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
.admin-rank-badge {
|
|
||||||
display: inline-block;
|
|
||||||
margin-top: 0.2rem;
|
|
||||||
font-size: 0.66rem;
|
|
||||||
font-weight: 700;
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.05em;
|
|
||||||
color: #1a1a2e;
|
|
||||||
background: var(--color-primary, #eeb425);
|
|
||||||
border-radius: 999px;
|
|
||||||
padding: 0.05rem 0.5rem;
|
|
||||||
}
|
|
||||||
.admin-nav {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 0.1rem;
|
|
||||||
}
|
|
||||||
.admin-nav-group {
|
|
||||||
margin-top: 0.55rem;
|
|
||||||
}
|
|
||||||
.admin-nav-group-label {
|
|
||||||
display: block;
|
|
||||||
font-size: 0.64rem;
|
|
||||||
font-weight: 700;
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.09em;
|
|
||||||
color: rgba(231, 233, 243, 0.42);
|
|
||||||
padding: 0.3rem 0.55rem 0.25rem;
|
|
||||||
}
|
|
||||||
.admin-nav-link {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.55rem;
|
|
||||||
padding: 0.43rem 0.6rem;
|
|
||||||
border-radius: 9px;
|
|
||||||
color: #c8cbe0;
|
|
||||||
font-size: 0.85rem;
|
|
||||||
font-weight: 600;
|
|
||||||
text-decoration: none;
|
|
||||||
transition: background 0.15s ease, color 0.15s ease;
|
|
||||||
}
|
|
||||||
.admin-nav-link:hover {
|
|
||||||
background: rgba(255, 255, 255, 0.07);
|
|
||||||
color: #fff;
|
|
||||||
text-decoration: none;
|
|
||||||
}
|
|
||||||
.admin-nav-link.active {
|
|
||||||
background: var(--color-primary, #eeb425);
|
|
||||||
color: #1a1a2e;
|
|
||||||
box-shadow: 0 2px 8px rgba(238, 180, 37, 0.35);
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-content {
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
.admin-topbar {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: 1rem;
|
|
||||||
background: var(--color-surface);
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
border-radius: 12px;
|
|
||||||
padding: 0.7rem 1.15rem;
|
|
||||||
margin-bottom: 1.1rem;
|
|
||||||
box-shadow: var(--shadow-card);
|
|
||||||
}
|
|
||||||
.admin-topbar h2 {
|
|
||||||
margin: 0;
|
|
||||||
font-size: 1.05rem;
|
|
||||||
font-weight: 800;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Page content inside the admin panel */
|
|
||||||
.admin-page main > h1:first-child,
|
.admin-page main > h1:first-child,
|
||||||
.admin-page > h1:first-child {
|
.admin-page > h1:first-child {
|
||||||
margin-top: 0;
|
margin-top: 0;
|
||||||
@@ -603,138 +474,6 @@ tbody tr:hover {
|
|||||||
.admin-page tbody td {
|
.admin-page tbody td {
|
||||||
border-top: 1px solid color-mix(in srgb, var(--color-text-muted) 12%, transparent);
|
border-top: 1px solid color-mix(in srgb, var(--color-text-muted) 12%, transparent);
|
||||||
}
|
}
|
||||||
/* status pill used by some admin lists */
|
|
||||||
.admin-badge {
|
|
||||||
display: inline-block;
|
|
||||||
font-size: 0.72rem;
|
|
||||||
font-weight: 700;
|
|
||||||
padding: 0.1rem 0.55rem;
|
|
||||||
border-radius: 999px;
|
|
||||||
background: color-mix(in srgb, var(--color-primary) 18%, transparent);
|
|
||||||
color: var(--color-text);
|
|
||||||
}
|
|
||||||
.admin-badge.ok {
|
|
||||||
background: color-mix(in srgb, #16a34a 20%, transparent);
|
|
||||||
}
|
|
||||||
.admin-badge.danger {
|
|
||||||
background: color-mix(in srgb, #ef4444 22%, transparent);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Dashboard: live-status tiles, server info, diagnostics */
|
|
||||||
.admin-stats {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
|
||||||
gap: 0.9rem;
|
|
||||||
margin-bottom: 1.4rem;
|
|
||||||
}
|
|
||||||
.admin-stat-card {
|
|
||||||
background: var(--color-surface);
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
border-left: 4px solid var(--color-text-muted);
|
|
||||||
border-radius: 12px;
|
|
||||||
padding: 0.85rem 1rem;
|
|
||||||
box-shadow: var(--shadow-card);
|
|
||||||
}
|
|
||||||
.admin-stat-card.ok {
|
|
||||||
border-left-color: #16a34a;
|
|
||||||
}
|
|
||||||
.admin-stat-card.warn {
|
|
||||||
border-left-color: var(--color-primary, #eeb425);
|
|
||||||
}
|
|
||||||
.admin-stat-card.danger {
|
|
||||||
border-left-color: #ef4444;
|
|
||||||
}
|
|
||||||
.admin-stat-top {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: 0.5rem;
|
|
||||||
}
|
|
||||||
.admin-stat-label {
|
|
||||||
font-size: 0.72rem;
|
|
||||||
font-weight: 700;
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.05em;
|
|
||||||
color: var(--color-text-muted);
|
|
||||||
}
|
|
||||||
.admin-stat-icon {
|
|
||||||
font-size: 1.1rem;
|
|
||||||
line-height: 1;
|
|
||||||
}
|
|
||||||
.admin-stat-value {
|
|
||||||
font-size: 1.7rem;
|
|
||||||
font-weight: 800;
|
|
||||||
line-height: 1.1;
|
|
||||||
margin-top: 0.3rem;
|
|
||||||
}
|
|
||||||
.admin-stat-hint {
|
|
||||||
font-size: 0.78rem;
|
|
||||||
color: var(--color-text-muted);
|
|
||||||
margin-top: 0.15rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-info-grid {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
|
|
||||||
gap: 0.6rem 1.2rem;
|
|
||||||
}
|
|
||||||
.admin-info-item {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 0.1rem;
|
|
||||||
font-size: 0.88rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-diag {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
}
|
|
||||||
.admin-diag-row {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.6rem;
|
|
||||||
padding: 0.5rem 0.1rem;
|
|
||||||
border-top: 1px solid color-mix(in srgb, var(--color-text-muted) 12%, transparent);
|
|
||||||
}
|
|
||||||
.admin-diag-row:first-child {
|
|
||||||
border-top: none;
|
|
||||||
}
|
|
||||||
.admin-diag-label {
|
|
||||||
font-weight: 700;
|
|
||||||
font-size: 0.88rem;
|
|
||||||
}
|
|
||||||
.admin-diag-detail {
|
|
||||||
flex: 1;
|
|
||||||
font-size: 0.82rem;
|
|
||||||
}
|
|
||||||
.admin-dot {
|
|
||||||
flex: none;
|
|
||||||
width: 9px;
|
|
||||||
height: 9px;
|
|
||||||
border-radius: 999px;
|
|
||||||
background: var(--color-text-muted);
|
|
||||||
}
|
|
||||||
.admin-dot.ok {
|
|
||||||
background: #16a34a;
|
|
||||||
box-shadow: 0 0 0 3px color-mix(in srgb, #16a34a 22%, transparent);
|
|
||||||
}
|
|
||||||
.admin-dot.warn {
|
|
||||||
background: var(--color-primary, #eeb425);
|
|
||||||
box-shadow: 0 0 0 3px color-mix(in srgb, var(--color-primary) 22%, transparent);
|
|
||||||
}
|
|
||||||
.admin-dot.danger {
|
|
||||||
background: #ef4444;
|
|
||||||
box-shadow: 0 0 0 3px color-mix(in srgb, #ef4444 22%, transparent);
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-section {
|
|
||||||
margin-top: 1.6rem;
|
|
||||||
}
|
|
||||||
.admin-section > h2 {
|
|
||||||
margin: 0 0 0.7rem;
|
|
||||||
font-size: 1.15rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Public design system (faithful to the atom theme) ──────────
|
/* ── Public design system (faithful to the atom theme) ──────────
|
||||||
content-card = surface card with a primary-tinted header (icon +
|
content-card = surface card with a primary-tinted header (icon +
|
||||||
title + subtitle) and a padded body — the signature atom block. */
|
title + subtitle) and a padded body — the signature atom block. */
|
||||||
|
|||||||
@@ -14,7 +14,12 @@ export function AdminNavLink({ href, label }: { href: string; label: string }) {
|
|||||||
href === "/admin" ? pathname === "/admin" : pathname === href || pathname.startsWith(`${href}/`);
|
href === "/admin" ? pathname === "/admin" : pathname === href || pathname.startsWith(`${href}/`);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Link href={href} className={`admin-nav-link${active ? " active" : ""}`}>
|
<Link
|
||||||
|
href={href}
|
||||||
|
className={`flex items-center gap-2 px-2.5 py-1.5 rounded-lg text-[#c8cbe0] text-sm font-semibold no-underline transition-[background,color] duration-150 hover:bg-white/7 hover:text-white hover:no-underline ${
|
||||||
|
active ? "bg-[var(--color-primary)] text-[#1a1a2e] shadow-[0_2px_8px_rgba(238,180,37,0.35)]" : ""
|
||||||
|
}`}
|
||||||
|
>
|
||||||
{label}
|
{label}
|
||||||
</Link>
|
</Link>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -27,18 +27,24 @@ export function StatusCard({
|
|||||||
state?: State;
|
state?: State;
|
||||||
icon?: string;
|
icon?: string;
|
||||||
}) {
|
}) {
|
||||||
|
const borderColor =
|
||||||
|
state === "ok" ? "border-l-green-600" :
|
||||||
|
state === "warn" ? "border-l-[var(--color-primary)]" :
|
||||||
|
state === "danger" ? "border-l-red-500" :
|
||||||
|
"border-l-[var(--color-text-muted)]";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`admin-stat-card ${state}`}>
|
<div className={`bg-[var(--color-surface)] border border-[var(--border)] border-l-4 ${borderColor} rounded-xl p-3.5 shadow-card`}>
|
||||||
<div className="admin-stat-top">
|
<div className="flex items-center justify-between gap-2">
|
||||||
<span className="admin-stat-label">{label}</span>
|
<span className="text-[0.72rem] font-bold uppercase tracking-wider text-[var(--color-text-muted)]">{label}</span>
|
||||||
{icon ? (
|
{icon ? (
|
||||||
<span className="admin-stat-icon" aria-hidden>
|
<span className="text-lg leading-none" aria-hidden>
|
||||||
{icon}
|
{icon}
|
||||||
</span>
|
</span>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
<div className="admin-stat-value">{value}</div>
|
<div className="text-[1.7rem] font-extrabold leading-tight mt-1.5">{value}</div>
|
||||||
{hint ? <div className="admin-stat-hint">{hint}</div> : null}
|
{hint ? <div className="text-[0.78rem] text-[var(--color-text-muted)] mt-1">{hint}</div> : null}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -54,12 +60,19 @@ export function DiagnosticRow({
|
|||||||
state: Exclude<State, "neutral">;
|
state: Exclude<State, "neutral">;
|
||||||
}) {
|
}) {
|
||||||
const text = state === "ok" ? "OK" : state === "warn" ? "WARN" : "ERROR";
|
const text = state === "ok" ? "OK" : state === "warn" ? "WARN" : "ERROR";
|
||||||
|
const dotColor = state === "ok" ? "bg-green-600 shadow-[0_0_0_3px_rgba(22,163,74,0.22)]" :
|
||||||
|
state === "warn" ? "bg-[var(--color-primary)] shadow-[0_0_0_3px_rgba(238,180,37,0.22)]" :
|
||||||
|
"bg-red-500 shadow-[0_0_0_3px_rgba(239,68,68,0.22)]";
|
||||||
|
const badgeColor = state === "ok" ? "bg-green-600/20" :
|
||||||
|
state === "danger" ? "bg-red-500/22" :
|
||||||
|
"bg-[var(--color-primary)]/18";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="admin-diag-row">
|
<div className="flex items-center gap-2.5 py-2 px-0.5 border-t border-[var(--border)] first:border-t-0">
|
||||||
<span className={`admin-dot ${state}`} aria-hidden />
|
<span className={`flex-none w-[9px] h-[9px] rounded-full ${dotColor}`} aria-hidden />
|
||||||
<span className="admin-diag-label">{label}</span>
|
<span className="font-bold text-sm">{label}</span>
|
||||||
<span className="admin-diag-detail muted">{detail}</span>
|
<span className="flex-1 text-[0.82rem] text-[var(--color-text-muted)]">{detail}</span>
|
||||||
<span className={`admin-badge ${state === "ok" ? "ok" : state === "danger" ? "danger" : ""}`}>
|
<span className={`inline-block text-[0.72rem] font-bold px-2 py-0.5 rounded-full ${badgeColor} text-[var(--color-text)]`}>
|
||||||
{text}
|
{text}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -69,8 +82,8 @@ export function DiagnosticRow({
|
|||||||
/** Small labelled key/value used in the server-info card. */
|
/** Small labelled key/value used in the server-info card. */
|
||||||
export function InfoItem({ label, value }: { label: string; value: ReactNode }) {
|
export function InfoItem({ label, value }: { label: string; value: ReactNode }) {
|
||||||
return (
|
return (
|
||||||
<div className="admin-info-item">
|
<div className="flex flex-col gap-0.5 text-sm">
|
||||||
<span className="muted">{label}</span>
|
<span className="text-[var(--color-text-muted)]">{label}</span>
|
||||||
<strong>{value}</strong>
|
<strong>{value}</strong>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -95,49 +108,50 @@ export function OnlineUsersWidget({
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="online-users-widget">
|
<div className="bg-[var(--color-surface)] rounded-xl border border-[var(--border)] p-6">
|
||||||
<div className="widget-header">
|
<div className="flex justify-between items-center mb-4">
|
||||||
<h3>Online Now</h3>
|
<h3 className="m-0 text-lg font-semibold text-[var(--color-text)]">Online Now</h3>
|
||||||
<span className="online-count">
|
<span className="text-sm text-[var(--color-text-muted)] bg-[var(--color-primary)]/10 px-3 py-1 rounded-full">
|
||||||
{isLoading ? "..." : users.length} users
|
{isLoading ? "..." : users.length} users
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="online-users-list">
|
<div className="max-h-80 overflow-y-auto flex flex-col gap-3">
|
||||||
{users.length === 0 ? (
|
{users.length === 0 ? (
|
||||||
<div className="empty-state">
|
<div className="py-8 text-center bg-[var(--color-text-muted)]/5 rounded-lg">
|
||||||
<span className="muted">No users online</span>
|
<span className="text-[var(--color-text-muted)] text-sm">No users online</span>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
users.map((user) => (
|
users.map((user) => (
|
||||||
<div key={user.id} className="online-user-row">
|
<div
|
||||||
<div className="user-avatar-container">
|
key={user.id}
|
||||||
|
className="flex items-center gap-3 p-3 bg-[var(--color-text-muted)]/5 rounded-lg transition-all duration-150 hover:bg-[var(--color-text-muted)]/10 hover:translate-x-0.5"
|
||||||
|
>
|
||||||
|
<div className="relative flex-none w-[50px] h-[62px]">
|
||||||
<img
|
<img
|
||||||
src={`https://www.habbo.com/habbo-imaging/avatarimage?width=50&height=62&direction=2&headonly=1&crop=face&action=wav&gender=m&look=${encodeURIComponent(user.look)}`}
|
src={`https://www.habbo.com/habbo-imaging/avatarimage?width=50&height=62&direction=2&headonly=1&crop=face&action=wav&gender=m&look=${encodeURIComponent(user.look)}`}
|
||||||
alt={user.username}
|
alt={user.username}
|
||||||
className="user-avatar"
|
className="w-full h-full rounded object-cover border border-[var(--border)]"
|
||||||
loading="lazy"
|
loading="lazy"
|
||||||
/>
|
/>
|
||||||
{user.roomId && user.roomName && (
|
{user.roomId && user.roomName && (
|
||||||
<div className="room-indicator">
|
<div className="absolute -bottom-0.5 -right-0.5">
|
||||||
<span className="room-dot" />
|
<span className="block w-[10px] h-[10px] bg-green-500 rounded-full border-2 border-[var(--color-surface)]" />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="user-info">
|
<div className="flex-1 min-w-0">
|
||||||
<div className="user-name">
|
<div className="font-medium text-[var(--color-text)] mb-1 truncate">
|
||||||
<a href={`/u/${user.username}`} className="user-link">
|
<a href={`/u/${user.username}`} className="text-inherit no-underline hover:text-[var(--color-primary)] hover:underline">
|
||||||
{user.username}
|
{user.username}
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
<div className="user-status">
|
<div className="text-[0.75rem] text-[var(--color-text-muted)]">
|
||||||
{user.roomId && user.roomName ? (
|
{user.roomId && user.roomName ? (
|
||||||
<span className="in-room">
|
<span className="text-green-500 font-medium">🏠 {user.roomName}</span>
|
||||||
🏠 {user.roomName}
|
|
||||||
</span>
|
|
||||||
) : (
|
) : (
|
||||||
<span className="afk">⏱️ {formatLastSeen(user.lastActivity)}</span>
|
<span className="text-red-500">⏱️ {formatLastSeen(user.lastActivity)}</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -145,136 +159,6 @@ export function OnlineUsersWidget({
|
|||||||
))
|
))
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<style jsx>{`
|
|
||||||
.online-users-widget {
|
|
||||||
background: var(--color-surface);
|
|
||||||
border-radius: 12px;
|
|
||||||
border: 1px solid color-mix(in srgb, var(--color-text-muted) 15%, transparent);
|
|
||||||
padding: 1.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.widget-header {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
margin-bottom: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.widget-header h3 {
|
|
||||||
margin: 0;
|
|
||||||
font-size: 1.125rem;
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--color-text);
|
|
||||||
}
|
|
||||||
|
|
||||||
.online-count {
|
|
||||||
font-size: 0.875rem;
|
|
||||||
color: var(--color-text-muted);
|
|
||||||
background: color-mix(in srgb, var(--color-primary) 10%, transparent);
|
|
||||||
padding: 0.25rem 0.75rem;
|
|
||||||
border-radius: 999px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.online-users-list {
|
|
||||||
max-height: 320px;
|
|
||||||
overflow-y: auto;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 0.75rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.empty-state {
|
|
||||||
padding: 2rem;
|
|
||||||
text-align: center;
|
|
||||||
background: color-mix(in srgb, var(--color-text-muted) 5%, transparent);
|
|
||||||
border-radius: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.online-user-row {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.75rem;
|
|
||||||
padding: 0.75rem;
|
|
||||||
background: color-mix(in srgb, var(--color-text-muted) 5%, transparent);
|
|
||||||
border-radius: 8px;
|
|
||||||
transition: all 0.15s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.online-user-row:hover {
|
|
||||||
background: color-mix(in srgb, var(--color-text-muted) 10%, transparent);
|
|
||||||
transform: translateX(2px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.user-avatar-container {
|
|
||||||
position: relative;
|
|
||||||
width: 50px;
|
|
||||||
height: 62px;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.user-avatar {
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
border-radius: 4px;
|
|
||||||
object-fit: cover;
|
|
||||||
border: 1px solid color-mix(in srgb, var(--color-text-muted) 20%, transparent);
|
|
||||||
}
|
|
||||||
|
|
||||||
.room-indicator {
|
|
||||||
position: absolute;
|
|
||||||
bottom: -2px;
|
|
||||||
right: -2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.room-dot {
|
|
||||||
width: 10px;
|
|
||||||
height: 10px;
|
|
||||||
background: #22c55e;
|
|
||||||
border-radius: 999px;
|
|
||||||
border: 2px solid var(--color-surface);
|
|
||||||
display: block;
|
|
||||||
}
|
|
||||||
|
|
||||||
.user-info {
|
|
||||||
flex: 1;
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.user-name {
|
|
||||||
font-weight: 500;
|
|
||||||
color: var(--color-text);
|
|
||||||
margin-bottom: 0.25rem;
|
|
||||||
white-space: nowrap;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
}
|
|
||||||
|
|
||||||
.user-name .user-link {
|
|
||||||
color: inherit;
|
|
||||||
text-decoration: none;
|
|
||||||
transition: color 0.15s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.user-name .user-link:hover {
|
|
||||||
color: var(--color-primary);
|
|
||||||
text-decoration: underline;
|
|
||||||
}
|
|
||||||
|
|
||||||
.user-status {
|
|
||||||
font-size: 0.75rem;
|
|
||||||
color: var(--color-text-muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
.in-room {
|
|
||||||
color: #22c55e;
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
|
||||||
|
|
||||||
.afk {
|
|
||||||
color: #ef4444;
|
|
||||||
}
|
|
||||||
`}</style>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
Reference in new issue
Block a user