Add security middleware, audit log, alerts, PayPal, cron, radio + apps
Security (launch blockers): - src/middleware.ts (edge): forwards x-pathname + real client IP. - access-guard.ts (Node, from root layout): routes non-staff to /maintenance when maintenance mode is on, banned users to /banned. New /banned + /maintenance pages (the consumers the admin toggle was missing). Admin layout enforces force_staff_2fa before /admin. - staff-activity.ts audit log wired into ban/lift/give-currency/set-rank actions. Infra (parallel agents): alert service (alert_logs + Discord embed + email), PayPal top-up (create/capture API routes + /shop/topup), cron worker (scripts/jobs-worker.ts via croner: emulator-ping->alert, maintenance-check, bans-cleanup), social connections page, admin radio settings/banners/ranks. Public radio subsystem: /radio (+schedule, shouts+post, contests, giveaways, apply, leaderboard) and /apply/staff + /apply/team submission forms. Radio nav link added. .env.example documents the new optional vars. (radio song-requests dropped: its table is a stub in AtomCMS — columns added by un-modeled alter-migrations.) Verified: tsc exit 0, vitest 48/48, next build exit 0 (82 page routes).
This commit is contained in:
1 parent
e19debb795
commit
22d53d0e9c
40 files changed
+3781
-6
No files matched your search
@@ -0,0 +1,253 @@
|
||||
import Link from 'next/link';
|
||||
import {
|
||||
createRadioBanner,
|
||||
deleteRadioBanner,
|
||||
updateRadioBanner,
|
||||
} from '@/actions/admin-radio-extra';
|
||||
import { prisma } from '@/lib/prisma';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
type Banner = {
|
||||
id: bigint;
|
||||
userId: bigint;
|
||||
imagePath: string;
|
||||
title: string | null;
|
||||
description: string | null;
|
||||
sortOrder: number;
|
||||
isActive: boolean;
|
||||
createdAt: Date | null;
|
||||
};
|
||||
|
||||
function formatDate(d: Date | null): string {
|
||||
return d ? d.toISOString().slice(0, 16).replace('T', ' ') : '—';
|
||||
}
|
||||
|
||||
export default async function AdminRadioBannersPage() {
|
||||
let banners: Banner[] = [];
|
||||
let dbError = false;
|
||||
|
||||
try {
|
||||
banners = await prisma.radioBanners.findMany({
|
||||
select: {
|
||||
id: true,
|
||||
userId: true,
|
||||
imagePath: true,
|
||||
title: true,
|
||||
description: true,
|
||||
sortOrder: true,
|
||||
isActive: true,
|
||||
createdAt: true,
|
||||
},
|
||||
orderBy: [{ sortOrder: 'asc' }, { id: 'asc' }],
|
||||
});
|
||||
} catch {
|
||||
dbError = true;
|
||||
banners = [];
|
||||
}
|
||||
|
||||
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>
|
||||
</nav>
|
||||
|
||||
<h1>Radio Banners</h1>
|
||||
<p className="muted">
|
||||
Promotional banners shown on the radio page, ordered by sort order.
|
||||
</p>
|
||||
|
||||
{dbError ? (
|
||||
<div className="card" style={{ marginBottom: '1.5rem' }}>
|
||||
<p className="muted" style={{ margin: 0 }}>
|
||||
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>
|
||||
)}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user