Adapt + improve all admin pages to the new design system

Swept the whole housekeeping panel (34 pages) so it's a consistent,
improved adaptation of the old Filament admin rather than bare tables:
- Live status tiles (StatusCard) summarising each page's data — counts
  and stateful metrics (users online, active/permanent bans, pending
  applications, settings, radio shouts, etc.).
- Status pills (admin-badge ok/danger) replacing ad-hoc text: online/
  offline, account/ip ban types, permanent vs timed, command OK/FAIL,
  alert severity & read/new, enabled/disabled, open/closed.
- Sections wrapped in .admin-section with h2 headers; redundant inline
  table styling removed (the global .admin layer handles it).
- alerts/logs/emulator hand-built as the reference pattern; the rest
  via parallel agents that read the schema and preserved every server
  action, query and field name.

Behaviour unchanged (same actions, queries, field names). Verified in a
real authed admin session (prod, amx_test): pages render with live data
(Users tiles show 7 real accounts + online/offline pills), no errors.
tsc 0, vitest 49/49, next build 0.
This commit is contained in:
Simo committed 2026-06-28 19:07:48 +02:00
1 parent d419731566
commit 0de0ac6a3c
34 files changed
+2357 -1396

No files matched your search

+212 -181
View File
@@ -5,6 +5,7 @@ import {
updateRadioBanner,
} from '@/actions/admin-radio-extra';
import { prisma } from '@/lib/prisma';
import { StatusCard } from '@/components/admin/dashboard';
export const dynamic = 'force-dynamic';
@@ -46,17 +47,21 @@ export default async function AdminRadioBannersPage() {
banners = [];
}
const activeCount = banners.filter((b) => b.isActive).length;
const hiddenCount = banners.length - activeCount;
return (
<main>
<nav className="muted" style={{ marginBottom: '0.5rem' }}>
<Link href="/admin/radio">Radio</Link> ·{' '}
<Link href="/admin/radio/settings">Settings</Link> ·{' '}
<Link href="/admin/radio/banners">Banners</Link> ·{' '}
<Link href="/admin/radio/ranks">Ranks</Link>
<Link href="/admin/radio/ranks">Ranks</Link> ·{' '}
<Link href="/admin/radio/history">History</Link>
</nav>
<h1>Radio Banners</h1>
<p className="muted">
<p className="muted" style={{ marginTop: '-0.4rem' }}>
Promotional banners shown on the radio page, ordered by sort order.
</p>
@@ -66,188 +71,214 @@ export default async function AdminRadioBannersPage() {
Could not load banners (database unavailable).
</p>
</div>
) : null}
<form action={createRadioBanner} className="card" style={{ marginBottom: '1.5rem' }}>
<h3 style={{ marginTop: 0 }}>Add banner</h3>
<div className="grid cols-2">
<div>
<label htmlFor="new_imagePath" style={{ display: 'block', fontWeight: 700 }}>
Image path / URL
</label>
<input
id="new_imagePath"
name="imagePath"
placeholder="/storage/radio/banner.png"
required
style={{ width: '100%' }}
/>
</div>
<div>
<label htmlFor="new_title" style={{ display: 'block', fontWeight: 700 }}>
Title
</label>
<input id="new_title" name="title" style={{ width: '100%' }} />
</div>
<div style={{ gridColumn: '1 / -1' }}>
<label htmlFor="new_description" style={{ display: 'block', fontWeight: 700 }}>
Description
</label>
<textarea
id="new_description"
name="description"
rows={2}
style={{ width: '100%' }}
/>
</div>
<div>
<label htmlFor="new_sortOrder" style={{ display: 'block', fontWeight: 700 }}>
Sort order
</label>
<input
id="new_sortOrder"
name="sortOrder"
type="number"
defaultValue={0}
style={{ width: '100%' }}
/>
</div>
<div style={{ display: 'flex', alignItems: 'flex-end' }}>
<label style={{ display: 'inline-flex', gap: '0.4rem', alignItems: 'center' }}>
<input type="checkbox" name="isActive" value="1" defaultChecked /> Active
</label>
</div>
</div>
<div style={{ marginTop: '0.75rem' }}>
<button type="submit" className="btn btn-primary">
Create banner
</button>
</div>
</form>
{banners.length === 0 ? (
<p className="muted">No banners yet.</p>
) : (
<div className="grid">
{banners.map((b) => (
<div key={String(b.id)} className="card">
<div
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'baseline',
gap: '0.75rem',
}}
>
<strong>#{String(b.id)}</strong>
<span className="muted">
by user {String(b.userId)} · {formatDate(b.createdAt)} ·{' '}
{b.isActive ? 'active' : 'hidden'}
</span>
</div>
{b.imagePath ? (
<img
src={b.imagePath}
alt={b.title ?? 'Radio banner'}
className="article-img"
style={{ margin: '0.5rem 0' }}
/>
) : null}
<form action={updateRadioBanner}>
<input type="hidden" name="id" value={String(b.id)} />
<div className="grid cols-2">
<div>
<label
htmlFor={`img_${b.id}`}
style={{ display: 'block', fontWeight: 700 }}
>
Image path / URL
</label>
<input
id={`img_${b.id}`}
name="imagePath"
defaultValue={b.imagePath}
required
style={{ width: '100%' }}
/>
</div>
<div>
<label
htmlFor={`title_${b.id}`}
style={{ display: 'block', fontWeight: 700 }}
>
Title
</label>
<input
id={`title_${b.id}`}
name="title"
defaultValue={b.title ?? ''}
style={{ width: '100%' }}
/>
</div>
<div style={{ gridColumn: '1 / -1' }}>
<label
htmlFor={`desc_${b.id}`}
style={{ display: 'block', fontWeight: 700 }}
>
Description
</label>
<textarea
id={`desc_${b.id}`}
name="description"
rows={2}
defaultValue={b.description ?? ''}
style={{ width: '100%' }}
/>
</div>
<div>
<label
htmlFor={`sort_${b.id}`}
style={{ display: 'block', fontWeight: 700 }}
>
Sort order
</label>
<input
id={`sort_${b.id}`}
name="sortOrder"
type="number"
defaultValue={b.sortOrder}
style={{ width: '100%' }}
/>
</div>
<div style={{ display: 'flex', alignItems: 'flex-end' }}>
<label
style={{ display: 'inline-flex', gap: '0.4rem', alignItems: 'center' }}
>
<input
type="checkbox"
name="isActive"
value="1"
defaultChecked={b.isActive}
/>{' '}
Active
</label>
</div>
</div>
<div style={{ marginTop: '0.6rem' }}>
<button type="submit" className="btn btn-primary">
Save
</button>
</div>
</form>
<form action={deleteRadioBanner} style={{ marginTop: '0.5rem' }}>
<input type="hidden" name="id" value={String(b.id)} />
<button type="submit" className="btn btn-danger">
Delete
</button>
</form>
</div>
))}
<div className="admin-stats">
<StatusCard label="Banners" value={banners.length} icon="🖼️" />
<StatusCard
label="Active"
value={activeCount}
state={activeCount > 0 ? 'ok' : 'neutral'}
icon="✅"
/>
<StatusCard
label="Hidden"
value={hiddenCount}
state={hiddenCount > 0 ? 'warn' : 'neutral'}
icon="🚫"
/>
</div>
)}
<section className="admin-section">
<h2>Add banner</h2>
<form action={createRadioBanner} className="card">
<div className="grid cols-2">
<div>
<label htmlFor="new_imagePath" style={{ display: 'block', fontWeight: 700 }}>
Image path / URL
</label>
<input
id="new_imagePath"
name="imagePath"
placeholder="/storage/radio/banner.png"
required
style={{ width: '100%' }}
/>
</div>
<div>
<label htmlFor="new_title" style={{ display: 'block', fontWeight: 700 }}>
Title
</label>
<input id="new_title" name="title" style={{ width: '100%' }} />
</div>
<div style={{ gridColumn: '1 / -1' }}>
<label htmlFor="new_description" style={{ display: 'block', fontWeight: 700 }}>
Description
</label>
<textarea
id="new_description"
name="description"
rows={2}
style={{ width: '100%' }}
/>
</div>
<div>
<label htmlFor="new_sortOrder" style={{ display: 'block', fontWeight: 700 }}>
Sort order
</label>
<input
id="new_sortOrder"
name="sortOrder"
type="number"
defaultValue={0}
style={{ width: '100%' }}
/>
</div>
<div style={{ display: 'flex', alignItems: 'flex-end' }}>
<label style={{ display: 'inline-flex', gap: '0.4rem', alignItems: 'center' }}>
<input type="checkbox" name="isActive" value="1" defaultChecked /> Active
</label>
</div>
</div>
<div style={{ marginTop: '0.75rem' }}>
<button type="submit" className="btn btn-primary">
Create banner
</button>
</div>
</form>
</section>
<section className="admin-section">
<h2>Existing banners ({banners.length})</h2>
{banners.length === 0 ? (
<p className="muted">No banners yet — add one above.</p>
) : (
<div className="grid">
{banners.map((b) => (
<div key={String(b.id)} className="card">
<div
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'baseline',
gap: '0.75rem',
}}
>
<strong>#{String(b.id)}</strong>
<span
className="muted"
style={{ display: 'inline-flex', gap: '0.5rem', alignItems: 'center' }}
>
by user {String(b.userId)} · {formatDate(b.createdAt)} ·{' '}
<span className={`admin-badge ${b.isActive ? 'ok' : 'danger'}`}>
{b.isActive ? 'active' : 'hidden'}
</span>
</span>
</div>
{b.imagePath ? (
<img
src={b.imagePath}
alt={b.title ?? 'Radio banner'}
className="article-img"
style={{ margin: '0.5rem 0' }}
/>
) : null}
<form action={updateRadioBanner}>
<input type="hidden" name="id" value={String(b.id)} />
<div className="grid cols-2">
<div>
<label
htmlFor={`img_${b.id}`}
style={{ display: 'block', fontWeight: 700 }}
>
Image path / URL
</label>
<input
id={`img_${b.id}`}
name="imagePath"
defaultValue={b.imagePath}
required
style={{ width: '100%' }}
/>
</div>
<div>
<label
htmlFor={`title_${b.id}`}
style={{ display: 'block', fontWeight: 700 }}
>
Title
</label>
<input
id={`title_${b.id}`}
name="title"
defaultValue={b.title ?? ''}
style={{ width: '100%' }}
/>
</div>
<div style={{ gridColumn: '1 / -1' }}>
<label
htmlFor={`desc_${b.id}`}
style={{ display: 'block', fontWeight: 700 }}
>
Description
</label>
<textarea
id={`desc_${b.id}`}
name="description"
rows={2}
defaultValue={b.description ?? ''}
style={{ width: '100%' }}
/>
</div>
<div>
<label
htmlFor={`sort_${b.id}`}
style={{ display: 'block', fontWeight: 700 }}
>
Sort order
</label>
<input
id={`sort_${b.id}`}
name="sortOrder"
type="number"
defaultValue={b.sortOrder}
style={{ width: '100%' }}
/>
</div>
<div style={{ display: 'flex', alignItems: 'flex-end' }}>
<label
style={{ display: 'inline-flex', gap: '0.4rem', alignItems: 'center' }}
>
<input
type="checkbox"
name="isActive"
value="1"
defaultChecked={b.isActive}
/>{' '}
Active
</label>
</div>
</div>
<div style={{ marginTop: '0.6rem' }}>
<button type="submit" className="btn btn-primary">
Save
</button>
</div>
</form>
<form action={deleteRadioBanner} style={{ marginTop: '0.5rem' }}>
<input type="hidden" name="id" value={String(b.id)} />
<button type="submit" className="btn btn-danger">
Delete
</button>
</form>
</div>
))}
</div>
)}
</section>
</main>
);
}
+63 -32
View File
@@ -1,5 +1,6 @@
import Link from 'next/link';
import { prisma } from '@/lib/prisma';
import { StatusCard } from '@/components/admin/dashboard';
export const dynamic = 'force-dynamic';
@@ -64,6 +65,13 @@ export default async function AdminRadioHistoryPage() {
history = [];
}
const liveCount = history.filter((h) => !h.endedAt).length;
const peakListeners = history.reduce(
(max, h) => (h.listenersCount > max ? h.listenersCount : max),
0,
);
const uniqueDjs = new Set(history.map((h) => String(h.userId))).size;
return (
<main>
<nav className="muted" style={{ marginBottom: '0.5rem' }}>
@@ -75,7 +83,7 @@ export default async function AdminRadioHistoryPage() {
</nav>
<h1>Radio Play History</h1>
<p className="muted">
<p className="muted" style={{ marginTop: '-0.4rem' }}>
Past DJ shows and airtime (<code>radio_history</code>), most recent first.
Read-only log of who was on air, when, and how many listeners tuned in.
</p>
@@ -86,40 +94,63 @@ export default async function AdminRadioHistoryPage() {
Could not load radio history (database unavailable).
</p>
</div>
) : (
<div className="admin-stats">
<StatusCard label="Shows logged" value={history.length} icon="🎙️" />
<StatusCard
label="On air now"
value={liveCount}
state={liveCount > 0 ? 'ok' : 'neutral'}
icon="🔴"
/>
<StatusCard label="Unique DJs" value={uniqueDjs} icon="🧑‍🎤" />
<StatusCard label="Peak listeners" value={peakListeners} icon="👥" />
</div>
)}
{!dbError && history.length === 0 ? (
<p className="muted">No radio history recorded yet.</p>
) : null}
{history.length === 0 ? (
<p className="muted">No radio history recorded.</p>
) : (
<table>
<thead>
<tr>
<th>ID</th>
<th>DJ</th>
<th>Show</th>
<th>Started</th>
<th>Ended</th>
<th>Airtime</th>
<th>Listeners</th>
<th>Notes</th>
</tr>
</thead>
<tbody>
{history.map((h) => (
<tr key={String(h.id)}>
<td>{String(h.id)}</td>
<td>{names.get(String(h.userId)) ?? `#${String(h.userId)}`}</td>
<td>{h.showName ?? '—'}</td>
<td className="muted">{formatDate(h.startedAt)}</td>
<td className="muted">{formatDate(h.endedAt)}</td>
<td className="muted">{formatDuration(h.startedAt, h.endedAt)}</td>
<td>{h.listenersCount}</td>
<td className="muted">{h.notes ?? '—'}</td>
{history.length > 0 ? (
<section className="admin-section">
<h2>Show log ({history.length})</h2>
<table>
<thead>
<tr>
<th>ID</th>
<th>DJ</th>
<th>Show</th>
<th>Started</th>
<th>Ended</th>
<th>Airtime</th>
<th>Listeners</th>
<th>Notes</th>
</tr>
))}
</tbody>
</table>
)}
</thead>
<tbody>
{history.map((h) => (
<tr key={String(h.id)}>
<td>{String(h.id)}</td>
<td>{names.get(String(h.userId)) ?? `#${String(h.userId)}`}</td>
<td>{h.showName ?? '—'}</td>
<td className="muted">{formatDate(h.startedAt)}</td>
<td>
{h.endedAt ? (
<span className="muted">{formatDate(h.endedAt)}</span>
) : (
<span className="admin-badge ok">on air</span>
)}
</td>
<td className="muted">{formatDuration(h.startedAt, h.endedAt)}</td>
<td>{h.listenersCount}</td>
<td className="muted">{h.notes ?? '—'}</td>
</tr>
))}
</tbody>
</table>
</section>
) : null}
</main>
);
}
+63 -12
View File
@@ -1,5 +1,7 @@
import Link from 'next/link';
import { prisma } from '@/lib/prisma';
import { deleteShout } from '@/actions/admin-radio';
import { StatusCard } from '@/components/admin/dashboard';
export const dynamic = 'force-dynamic';
@@ -39,6 +41,14 @@ function formatTime(d: Date | null): string {
return d ? d.toISOString().slice(11, 16) : '—';
}
// Map an application status to an ok/danger/neutral pill tone.
function statusBadgeClass(status: string): string {
const s = status.toLowerCase();
if (s === 'approved' || s === 'accepted') return 'ok';
if (s === 'rejected' || s === 'denied') return 'danger';
return '';
}
export default async function AdminRadioPage() {
let shouts: Shout[] = [];
let applications: Application[] = [];
@@ -91,14 +101,47 @@ export default async function AdminRadioPage() {
schedules = [];
}
const pendingApps = applications.filter(
(a) => a.status.toLowerCase() === 'pending',
).length;
const activeSchedules = schedules.filter((sc) => sc.isActive).length;
return (
<main>
<h1>Radio Moderation</h1>
<nav className="muted" style={{ marginBottom: '0.5rem' }}>
<Link href="/admin/radio">Radio</Link> ·{' '}
<Link href="/admin/radio/settings">Settings</Link> ·{' '}
<Link href="/admin/radio/banners">Banners</Link> ·{' '}
<Link href="/admin/radio/ranks">Ranks</Link> ·{' '}
<Link href="/admin/radio/history">History</Link>
</nav>
<section style={{ marginBottom: '2rem' }}>
<h2>Recent Shouts</h2>
<h1>Radio Moderation</h1>
<p className="muted" style={{ marginTop: '-0.4rem' }}>
Latest shouts, DJ applications and on-air schedules (25 most recent of each).
</p>
<div className="admin-stats">
<StatusCard label="Recent shouts" value={shouts.length} icon="📣" />
<StatusCard
label="Pending applications"
value={pendingApps}
state={pendingApps > 0 ? 'warn' : 'ok'}
icon="📝"
/>
<StatusCard label="Schedules" value={schedules.length} icon="🗓️" />
<StatusCard
label="Active schedules"
value={activeSchedules}
state={activeSchedules > 0 ? 'ok' : 'neutral'}
icon="🎙️"
/>
</div>
<section className="admin-section">
<h2>Recent Shouts ({shouts.length})</h2>
{shouts.length === 0 ? (
<p className="muted">No shouts.</p>
<p className="muted">No shouts to moderate.</p>
) : (
<table>
<thead>
@@ -132,10 +175,10 @@ export default async function AdminRadioPage() {
)}
</section>
<section style={{ marginBottom: '2rem' }}>
<h2>DJ Applications</h2>
<section className="admin-section">
<h2>DJ Applications ({applications.length})</h2>
{applications.length === 0 ? (
<p className="muted">No applications.</p>
<p className="muted">No applications submitted.</p>
) : (
<table>
<thead>
@@ -159,7 +202,11 @@ export default async function AdminRadioPage() {
<td>{a.age}</td>
<td>{a.musicStyle ?? '—'}</td>
<td>{a.discordUsername ?? '—'}</td>
<td>{a.status}</td>
<td>
<span className={`admin-badge ${statusBadgeClass(a.status)}`}>
{a.status}
</span>
</td>
<td className="muted">{formatDate(a.createdAt)}</td>
</tr>
))}
@@ -168,10 +215,10 @@ export default async function AdminRadioPage() {
)}
</section>
<section>
<h2>Schedules</h2>
<section className="admin-section">
<h2>Schedules ({schedules.length})</h2>
{schedules.length === 0 ? (
<p className="muted">No schedules.</p>
<p className="muted">No schedules configured.</p>
) : (
<table>
<thead>
@@ -194,7 +241,11 @@ export default async function AdminRadioPage() {
<td>{formatTime(sc.startTime)}</td>
<td>{formatTime(sc.endTime)}</td>
<td>{sc.showName ?? '—'}</td>
<td>{sc.isActive ? '●' : '○'}</td>
<td>
<span className={`admin-badge ${sc.isActive ? 'ok' : 'danger'}`}>
{sc.isActive ? 'active' : 'inactive'}
</span>
</td>
</tr>
))}
</tbody>
+133 -117
View File
@@ -5,6 +5,7 @@ import {
updateRadioRank,
} from '@/actions/admin-radio-extra';
import { prisma } from '@/lib/prisma';
import { StatusCard } from '@/components/admin/dashboard';
export const dynamic = 'force-dynamic';
@@ -36,17 +37,22 @@ export default async function AdminRadioRanksPage() {
ranks = [];
}
const activeCount = ranks.filter((r) => r.isActive).length;
return (
<main>
<nav className="muted" style={{ marginBottom: '0.5rem' }}>
<Link href="/admin/radio">Radio</Link> ·{' '}
<Link href="/admin/radio/settings">Settings</Link> ·{' '}
<Link href="/admin/radio/banners">Banners</Link> ·{' '}
<Link href="/admin/radio/ranks">Ranks</Link>
<Link href="/admin/radio/ranks">Ranks</Link> ·{' '}
<Link href="/admin/radio/history">History</Link>
</nav>
<h1>Radio Ranks</h1>
<p className="muted">DJ / staff ranks assignable on radio applications.</p>
<p className="muted" style={{ marginTop: '-0.4rem' }}>
DJ / staff ranks assignable on radio applications.
</p>
{dbError ? (
<div className="card" style={{ marginBottom: '1.5rem' }}>
@@ -54,123 +60,133 @@ export default async function AdminRadioRanksPage() {
Could not load ranks (database unavailable).
</p>
</div>
) : null}
<form action={createRadioRank} className="card" style={{ marginBottom: '1.5rem' }}>
<h3 style={{ marginTop: 0 }}>Add rank</h3>
<div className="grid cols-3">
<div>
<label htmlFor="new_name" style={{ display: 'block', fontWeight: 700 }}>
Name
</label>
<input id="new_name" name="name" required style={{ width: '100%' }} />
</div>
<div>
<label htmlFor="new_badge" style={{ display: 'block', fontWeight: 700 }}>
Badge code
</label>
<input id="new_badge" name="badgeCode" style={{ width: '100%' }} />
</div>
<div style={{ display: 'flex', alignItems: 'flex-end' }}>
<label style={{ display: 'inline-flex', gap: '0.4rem', alignItems: 'center' }}>
<input type="checkbox" name="isActive" value="1" defaultChecked /> Active
</label>
</div>
<div style={{ gridColumn: '1 / -1' }}>
<label htmlFor="new_desc" style={{ display: 'block', fontWeight: 700 }}>
Description
</label>
<input id="new_desc" name="description" style={{ width: '100%' }} />
</div>
</div>
<div style={{ marginTop: '0.75rem' }}>
<button type="submit" className="btn btn-primary">
Create rank
</button>
</div>
</form>
{ranks.length === 0 ? (
<p className="muted">No ranks yet.</p>
) : (
<table>
<thead>
<tr>
<th>ID</th>
<th>Name</th>
<th>Badge</th>
<th>Description</th>
<th>Active</th>
<th />
</tr>
</thead>
<tbody>
{ranks.map((r) => (
<tr key={String(r.id)}>
<td>{String(r.id)}</td>
<td colSpan={5}>
<form
action={updateRadioRank}
style={{
display: 'flex',
gap: '0.5rem',
alignItems: 'center',
flexWrap: 'wrap',
}}
>
<input type="hidden" name="id" value={String(r.id)} />
<input
name="name"
defaultValue={r.name}
required
placeholder="Name"
style={{ minWidth: 140 }}
/>
<input
name="badgeCode"
defaultValue={r.badgeCode ?? ''}
placeholder="Badge"
style={{ minWidth: 100 }}
/>
<input
name="description"
defaultValue={r.description ?? ''}
placeholder="Description"
style={{ flex: 1, minWidth: 160 }}
/>
<label
style={{ display: 'inline-flex', gap: '0.3rem', alignItems: 'center' }}
>
<input
type="checkbox"
name="isActive"
value="1"
defaultChecked={r.isActive}
/>{' '}
Active
</label>
<button type="submit" className="btn btn-primary">
Save
</button>
</form>
</td>
</tr>
))}
{ranks.map((r) => (
<tr key={`del_${String(r.id)}`}>
<td colSpan={6} style={{ borderTop: 'none', paddingTop: 0 }}>
<form action={deleteRadioRank} style={{ display: 'inline' }}>
<input type="hidden" name="id" value={String(r.id)} />
<button type="submit" className="btn btn-danger">
Delete rank #{String(r.id)}
</button>
</form>
</td>
</tr>
))}
</tbody>
</table>
<div className="admin-stats">
<StatusCard label="Ranks" value={ranks.length} icon="🎖️" />
<StatusCard
label="Active"
value={activeCount}
state={activeCount > 0 ? 'ok' : 'neutral'}
icon="✅"
/>
</div>
)}
<section className="admin-section">
<h2>Add rank</h2>
<form action={createRadioRank} className="card">
<div className="grid cols-3">
<div>
<label htmlFor="new_name" style={{ display: 'block', fontWeight: 700 }}>
Name
</label>
<input id="new_name" name="name" required style={{ width: '100%' }} />
</div>
<div>
<label htmlFor="new_badge" style={{ display: 'block', fontWeight: 700 }}>
Badge code
</label>
<input id="new_badge" name="badgeCode" style={{ width: '100%' }} />
</div>
<div style={{ display: 'flex', alignItems: 'flex-end' }}>
<label style={{ display: 'inline-flex', gap: '0.4rem', alignItems: 'center' }}>
<input type="checkbox" name="isActive" value="1" defaultChecked /> Active
</label>
</div>
<div style={{ gridColumn: '1 / -1' }}>
<label htmlFor="new_desc" style={{ display: 'block', fontWeight: 700 }}>
Description
</label>
<input id="new_desc" name="description" style={{ width: '100%' }} />
</div>
</div>
<div style={{ marginTop: '0.75rem' }}>
<button type="submit" className="btn btn-primary">
Create rank
</button>
</div>
</form>
</section>
<section className="admin-section">
<h2>Existing ranks ({ranks.length})</h2>
{ranks.length === 0 ? (
<p className="muted">No ranks yet — add one above.</p>
) : (
<div className="grid">
{ranks.map((r) => (
<div key={String(r.id)} className="card">
<div
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'baseline',
gap: '0.75rem',
marginBottom: '0.6rem',
}}
>
<strong>#{String(r.id)}</strong>
<span className={`admin-badge ${r.isActive ? 'ok' : 'danger'}`}>
{r.isActive ? 'active' : 'inactive'}
</span>
</div>
<form
action={updateRadioRank}
style={{
display: 'flex',
gap: '0.5rem',
alignItems: 'center',
flexWrap: 'wrap',
}}
>
<input type="hidden" name="id" value={String(r.id)} />
<input
name="name"
defaultValue={r.name}
required
placeholder="Name"
style={{ minWidth: 140 }}
/>
<input
name="badgeCode"
defaultValue={r.badgeCode ?? ''}
placeholder="Badge"
style={{ minWidth: 100 }}
/>
<input
name="description"
defaultValue={r.description ?? ''}
placeholder="Description"
style={{ flex: 1, minWidth: 160 }}
/>
<label
style={{ display: 'inline-flex', gap: '0.3rem', alignItems: 'center' }}
>
<input
type="checkbox"
name="isActive"
value="1"
defaultChecked={r.isActive}
/>{' '}
Active
</label>
<button type="submit" className="btn btn-primary">
Save
</button>
</form>
<form action={deleteRadioRank} style={{ marginTop: '0.5rem' }}>
<input type="hidden" name="id" value={String(r.id)} />
<button type="submit" className="btn btn-danger">
Delete rank #{String(r.id)}
</button>
</form>
</div>
))}
</div>
)}
</section>
</main>
);
}
+35 -3
View File
@@ -1,6 +1,7 @@
import Link from 'next/link';
import { saveRadioSetting, saveRadioSettings } from '@/actions/admin-radio-extra';
import { prisma } from '@/lib/prisma';
import { StatusCard } from '@/components/admin/dashboard';
export const dynamic = 'force-dynamic';
@@ -208,17 +209,26 @@ export default async function AdminRadioSettingsPage() {
const allKeys = [...CURATED_KEYS, ...extra.map((e) => e.key)].join(',');
const totalKeys = CURATED_KEYS.size + extra.length;
// Curated keys that already have a non-empty stored value.
const configuredKeys = [...CURATED_KEYS].filter((k) => {
const v = values.get(k);
return v != null && v !== '';
}).length;
const radioEnabled = (values.get('radio_enabled') ?? '') === '1';
return (
<main>
<nav className="muted" style={{ marginBottom: '0.5rem' }}>
<Link href="/admin/radio">Radio</Link> ·{' '}
<Link href="/admin/radio/settings">Settings</Link> ·{' '}
<Link href="/admin/radio/banners">Banners</Link> ·{' '}
<Link href="/admin/radio/ranks">Ranks</Link>
<Link href="/admin/radio/ranks">Ranks</Link> ·{' '}
<Link href="/admin/radio/history">History</Link>
</nav>
<h1>Radio Settings</h1>
<p className="muted">
<p className="muted" style={{ marginTop: '-0.4rem' }}>
Edits write to <code>website_settings</code> (radio_* keys) and bust the
runtime settings cache. Booleans use <code>0</code> / <code>1</code>.
</p>
@@ -230,7 +240,29 @@ export default async function AdminRadioSettingsPage() {
created on save.
</p>
</div>
) : null}
) : (
<div className="admin-stats">
<StatusCard
label="Radio"
value={radioEnabled ? 'Enabled' : 'Disabled'}
state={radioEnabled ? 'ok' : 'danger'}
icon="📻"
/>
<StatusCard label="Total keys" value={totalKeys} icon="🔑" />
<StatusCard
label="Configured"
value={`${configuredKeys}/${CURATED_KEYS.size}`}
hint="curated keys with a value"
icon="⚙️"
/>
<StatusCard
label="Custom keys"
value={extra.length}
state={extra.length > 0 ? 'warn' : 'neutral'}
icon="➕"
/>
</div>
)}
<form action={saveRadioSettings}>
<input type="hidden" name="__keys" value={allKeys} />