Add dark mode, i18n, messenger/moderation/verify, admin CRUD parity
Web-tier features completing the AtomCMS→Next.js conversion (slice 2): UI/UX: - Dark mode: html.dark CSS-var overrides + ThemeSwitcher (localStorage, no-flash boot script) wired into the nav. - i18n (next-intl, cookie-based / no URL routing): en + it catalogs, request.ts, provider in root layout, LanguageSwitcher; shell (nav, header, footer) fully translated. URLs + access-guard unchanged. - globals.css: --muted/--border aliases used across admin pages. User features: - /messages: offline messages + friend-request accept (server action re-reads session, two directional rows, idempotent). - Email verification: signed-token /verify route + sendVerification wired into register (best-effort, never blocks signup). - Article reactions: toggle UI on news/[slug] + server action. - Content moderation service (website_wordfilter + optional OpenAI moderations, fail-open) wired into article comments + guestbook. Admin CRUD parity (Filament replacement): - /admin/shop (+ new/[id]) packages CRUD + read-only orders. - /admin/transactions read-only PayPal log. - /admin/permissions, /admin/tags, /admin/ads (+ new/[id]), /admin/help-questions (+ new/[id]), /admin/radio/history, /admin/users/[id]/edit. All gated by requireStaff + logStaffActivity. Verified: tsc 0, vitest 48/48, next build 0 (all routes incl. new admin CRUD + /messages + /verify).
This commit is contained in:
1 parent
22d53d0e9c
commit
7daeccb832
45 files changed
+3312
-84
No files matched your search
@@ -0,0 +1,43 @@
|
||||
import Link from "next/link";
|
||||
import { notFound } from "next/navigation";
|
||||
import { deleteAd, updateAd } from "@/actions/admin-ads";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function EditAd({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ id: string }>;
|
||||
}) {
|
||||
const { id } = await params;
|
||||
let ad: Awaited<ReturnType<typeof prisma.websiteAds.findUnique>> = null;
|
||||
try {
|
||||
ad = await prisma.websiteAds.findUnique({ where: { id: BigInt(id) } });
|
||||
} catch {
|
||||
notFound();
|
||||
}
|
||||
if (!ad) notFound();
|
||||
|
||||
return (
|
||||
<main>
|
||||
<p className="muted">
|
||||
<Link href="/admin/ads">← Advertisements</Link>
|
||||
</p>
|
||||
<h1>Edit advertisement</h1>
|
||||
<form action={updateAd} className="card" style={{ display: "grid", gap: "0.6rem" }}>
|
||||
<input type="hidden" name="id" value={String(ad.id)} />
|
||||
<input name="image" defaultValue={ad.image} placeholder="Image URL" required maxLength={255} />
|
||||
<button type="submit" className="btn btn-primary">
|
||||
Save
|
||||
</button>
|
||||
</form>
|
||||
<form action={deleteAd} style={{ marginTop: "1rem" }}>
|
||||
<input type="hidden" name="id" value={String(ad.id)} />
|
||||
<button type="submit" className="btn btn-danger">
|
||||
Delete advertisement
|
||||
</button>
|
||||
</form>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import Link from "next/link";
|
||||
import { createAd } from "@/actions/admin-ads";
|
||||
|
||||
export default function NewAd() {
|
||||
return (
|
||||
<main>
|
||||
<p className="muted">
|
||||
<Link href="/admin/ads">← Advertisements</Link>
|
||||
</p>
|
||||
<h1>New advertisement</h1>
|
||||
<form action={createAd} className="card" style={{ display: "grid", gap: "0.6rem" }}>
|
||||
<input name="image" placeholder="Image URL" required maxLength={255} />
|
||||
<button type="submit" className="btn btn-primary">
|
||||
Create
|
||||
</button>
|
||||
</form>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import Link from "next/link";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function AdminAds() {
|
||||
let ads: Awaited<ReturnType<typeof prisma.websiteAds.findMany>> = [];
|
||||
try {
|
||||
ads = await prisma.websiteAds.findMany({
|
||||
orderBy: { id: "desc" },
|
||||
take: 500,
|
||||
});
|
||||
} catch {
|
||||
ads = [];
|
||||
}
|
||||
|
||||
return (
|
||||
<main>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: "1rem" }}>
|
||||
<h1 style={{ flex: 1 }}>Advertisements</h1>
|
||||
<Link href="/admin/ads/new">+ New ad</Link>
|
||||
</div>
|
||||
<p className="muted">
|
||||
Website advertisement banners (<code>website_ads</code>). Each ad is an image URL shown
|
||||
across the site.
|
||||
</p>
|
||||
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Preview</th>
|
||||
<th>Image URL</th>
|
||||
<th>Created</th>
|
||||
<th />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{ads.map((ad) => (
|
||||
<tr key={String(ad.id)}>
|
||||
<td>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img src={ad.image} alt="" style={{ maxHeight: 40, maxWidth: 120 }} />
|
||||
</td>
|
||||
<td className="muted">
|
||||
<code>{ad.image}</code>
|
||||
</td>
|
||||
<td className="muted">
|
||||
{ad.createdAt ? ad.createdAt.toISOString().slice(0, 10) : ""}
|
||||
</td>
|
||||
<td>
|
||||
<Link href={`/admin/ads/${ad.id}`}>Edit</Link>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
{ads.length === 0 ? <p className="muted">No advertisements yet.</p> : null}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import Link from "next/link";
|
||||
import { notFound } from "next/navigation";
|
||||
import { deleteHelpQuestion, updateHelpQuestion } from "@/actions/admin-help";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function EditHelpQuestion({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ id: string }>;
|
||||
}) {
|
||||
const { id } = await params;
|
||||
let entry: Awaited<
|
||||
ReturnType<typeof prisma.websiteHelpCenterCategories.findUnique>
|
||||
> = null;
|
||||
try {
|
||||
entry = await prisma.websiteHelpCenterCategories.findUnique({
|
||||
where: { id: BigInt(id) },
|
||||
});
|
||||
} catch {
|
||||
notFound();
|
||||
}
|
||||
if (!entry) notFound();
|
||||
|
||||
return (
|
||||
<main>
|
||||
<p className="muted">
|
||||
<Link href="/admin/help-questions">← Help Center</Link>
|
||||
</p>
|
||||
<h1>Edit help-center entry</h1>
|
||||
<form action={updateHelpQuestion} className="card" style={{ display: "grid", gap: "0.6rem" }}>
|
||||
<input type="hidden" name="id" value={String(entry.id)} />
|
||||
<input name="name" defaultValue={entry.name} placeholder="Name / title" required maxLength={255} />
|
||||
<textarea name="content" defaultValue={entry.content} placeholder="Content" rows={6} required />
|
||||
<input
|
||||
name="position"
|
||||
type="number"
|
||||
min={1}
|
||||
defaultValue={entry.position}
|
||||
placeholder="Position"
|
||||
/>
|
||||
<input
|
||||
name="imageUrl"
|
||||
defaultValue={entry.imageUrl ?? ""}
|
||||
placeholder="Image URL (optional)"
|
||||
maxLength={255}
|
||||
/>
|
||||
<input
|
||||
name="buttonText"
|
||||
defaultValue={entry.buttonText ?? ""}
|
||||
placeholder="Button text (optional)"
|
||||
maxLength={255}
|
||||
/>
|
||||
<input
|
||||
name="buttonUrl"
|
||||
defaultValue={entry.buttonUrl ?? ""}
|
||||
placeholder="Button URL (optional)"
|
||||
maxLength={255}
|
||||
/>
|
||||
<label style={{ display: "flex", gap: "0.4rem", alignItems: "center" }}>
|
||||
Button color
|
||||
<input name="buttonColor" type="color" defaultValue={entry.buttonColor} />
|
||||
</label>
|
||||
<label style={{ display: "flex", gap: "0.4rem", alignItems: "center" }}>
|
||||
Button border color
|
||||
<input name="buttonBorderColor" type="color" defaultValue={entry.buttonBorderColor} />
|
||||
</label>
|
||||
<label style={{ display: "flex", gap: "0.4rem", alignItems: "center" }}>
|
||||
<input name="smallBox" type="checkbox" value="1" defaultChecked={entry.smallBox} />
|
||||
Small box
|
||||
</label>
|
||||
<button type="submit" className="btn btn-primary">
|
||||
Save
|
||||
</button>
|
||||
</form>
|
||||
<form action={deleteHelpQuestion} style={{ marginTop: "1rem" }}>
|
||||
<input type="hidden" name="id" value={String(entry.id)} />
|
||||
<button type="submit" className="btn btn-danger">
|
||||
Delete entry
|
||||
</button>
|
||||
</form>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import Link from "next/link";
|
||||
import { createHelpQuestion } from "@/actions/admin-help";
|
||||
|
||||
export default function NewHelpQuestion() {
|
||||
return (
|
||||
<main>
|
||||
<p className="muted">
|
||||
<Link href="/admin/help-questions">← Help Center</Link>
|
||||
</p>
|
||||
<h1>New help-center entry</h1>
|
||||
<form action={createHelpQuestion} className="card" style={{ display: "grid", gap: "0.6rem" }}>
|
||||
<input name="name" placeholder="Name / title" required maxLength={255} />
|
||||
<textarea name="content" placeholder="Content" rows={6} required />
|
||||
<input name="position" type="number" min={1} defaultValue={1} placeholder="Position" />
|
||||
<input name="imageUrl" placeholder="Image URL (optional)" maxLength={255} />
|
||||
<input name="buttonText" placeholder="Button text (optional)" maxLength={255} />
|
||||
<input name="buttonUrl" placeholder="Button URL (optional)" maxLength={255} />
|
||||
<label style={{ display: "flex", gap: "0.4rem", alignItems: "center" }}>
|
||||
Button color
|
||||
<input name="buttonColor" type="color" defaultValue="#eeb425" />
|
||||
</label>
|
||||
<label style={{ display: "flex", gap: "0.4rem", alignItems: "center" }}>
|
||||
Button border color
|
||||
<input name="buttonBorderColor" type="color" defaultValue="#facc15" />
|
||||
</label>
|
||||
<label style={{ display: "flex", gap: "0.4rem", alignItems: "center" }}>
|
||||
<input name="smallBox" type="checkbox" value="1" />
|
||||
Small box
|
||||
</label>
|
||||
<button type="submit" className="btn btn-primary">
|
||||
Create
|
||||
</button>
|
||||
</form>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import Link from "next/link";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function AdminHelpQuestions() {
|
||||
let entries: Awaited<
|
||||
ReturnType<typeof prisma.websiteHelpCenterCategories.findMany>
|
||||
> = [];
|
||||
try {
|
||||
entries = await prisma.websiteHelpCenterCategories.findMany({
|
||||
orderBy: [{ position: "asc" }, { id: "asc" }],
|
||||
take: 500,
|
||||
});
|
||||
} catch {
|
||||
entries = [];
|
||||
}
|
||||
|
||||
return (
|
||||
<main>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: "1rem" }}>
|
||||
<h1 style={{ flex: 1 }}>Help Center</h1>
|
||||
<Link href="/admin/help-questions/new">+ New entry</Link>
|
||||
</div>
|
||||
<p className="muted">
|
||||
Help-center FAQ entries (<code>website_help_center_categories</code>). Shown to visitors on
|
||||
the public help page, ordered by position.
|
||||
</p>
|
||||
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Pos.</th>
|
||||
<th>Name</th>
|
||||
<th>Button</th>
|
||||
<th>Small box</th>
|
||||
<th />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{entries.map((e) => (
|
||||
<tr key={String(e.id)}>
|
||||
<td className="muted">{e.position}</td>
|
||||
<td>
|
||||
<Link href={`/admin/help-questions/${e.id}`}>{e.name}</Link>
|
||||
</td>
|
||||
<td className="muted">{e.buttonText || "—"}</td>
|
||||
<td className="muted">{e.smallBox ? "Yes" : "No"}</td>
|
||||
<td>
|
||||
<Link href={`/admin/help-questions/${e.id}`}>Edit</Link>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
{entries.length === 0 ? <p className="muted">No help-center entries yet.</p> : null}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -29,10 +29,14 @@ export default async function AdminLayout({ children }: { children: ReactNode })
|
||||
<Link href="/admin/users">Users</Link>
|
||||
<Link href="/admin/rooms">Rooms</Link>
|
||||
<Link href="/admin/articles">Articles</Link>
|
||||
<Link href="/admin/tags">Tags</Link>
|
||||
<Link href="/admin/catalog">Catalog</Link>
|
||||
<Link href="/admin/shop">Shop</Link>
|
||||
<Link href="/admin/transactions">Transactions</Link>
|
||||
<Link href="/admin/vouchers">Vouchers</Link>
|
||||
<Link href="/admin/badges">Badges</Link>
|
||||
<Link href="/admin/radio">Radio</Link>
|
||||
<Link href="/admin/radio/history">Radio History</Link>
|
||||
<Link href="/admin/achievements">Achievements</Link>
|
||||
<Link href="/admin/subscriptions">Subscriptions</Link>
|
||||
<Link href="/admin/calendar">Calendar</Link>
|
||||
@@ -40,6 +44,8 @@ export default async function AdminLayout({ children }: { children: ReactNode })
|
||||
<Link href="/admin/photos">Photos</Link>
|
||||
<Link href="/admin/bans">Bans</Link>
|
||||
<Link href="/admin/applications">Applications</Link>
|
||||
<Link href="/admin/help-questions">Help Center</Link>
|
||||
<Link href="/admin/ads">Advertisements</Link>
|
||||
<Link href="/admin/wordfilter">Word Filter</Link>
|
||||
<Link href="/admin/ip">IP Management</Link>
|
||||
<Link href="/admin/logs">Logs</Link>
|
||||
|
||||
@@ -1,25 +1,29 @@
|
||||
import {
|
||||
createPermission,
|
||||
deletePermission,
|
||||
updatePermission,
|
||||
} from "@/actions/admin-permissions";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
// NOTE: The emulator's standalone `permissions` table is not present in the
|
||||
// Prisma schema (there is no `prisma.permissions` accessor). The real,
|
||||
// queryable rank-permission table in this schema is `website_housekeeping_permissions`
|
||||
// (model WebsiteHousekeepingPermissions), which gates each permission by the
|
||||
// minimum rank that holds it — exactly the rank-permission data this page lists.
|
||||
// Manages website_permissions (model WebsitePermissions) — the CMS-owned
|
||||
// rank-permission mapping AtomCMS exposes in admin. Each row is a key with an
|
||||
// optional value and comment; those are the only editable columns on the table.
|
||||
type PermissionRow = {
|
||||
id: bigint;
|
||||
permission: string;
|
||||
minRank: number;
|
||||
description: string | null;
|
||||
key: string;
|
||||
value: string | null;
|
||||
comment: string | null;
|
||||
};
|
||||
|
||||
export default async function AdminPermissions() {
|
||||
let permissions: PermissionRow[] = [];
|
||||
try {
|
||||
permissions = await prisma.websiteHousekeepingPermissions.findMany({
|
||||
select: { id: true, permission: true, minRank: true, description: true },
|
||||
orderBy: [{ minRank: "asc" }, { permission: "asc" }],
|
||||
permissions = await prisma.websitePermissions.findMany({
|
||||
select: { id: true, key: true, value: true, comment: true },
|
||||
orderBy: { key: "asc" },
|
||||
take: 500,
|
||||
});
|
||||
} catch {
|
||||
permissions = [];
|
||||
@@ -29,29 +33,87 @@ export default async function AdminPermissions() {
|
||||
<main>
|
||||
<h1>Permissions</h1>
|
||||
<p className="muted">
|
||||
Read-only view of rank permissions. Each entry is the minimum rank that
|
||||
grants the permission.
|
||||
CMS-owned rank permissions (<code>website_permissions</code>). Each entry
|
||||
maps a permission key to its value/name and an optional comment.
|
||||
</p>
|
||||
|
||||
<form action={createPermission} className="card" style={{ marginBottom: "1.5rem" }}>
|
||||
<h3 style={{ marginTop: 0 }}>Add / overwrite permission</h3>
|
||||
<div style={{ display: "flex", gap: "0.5rem", flexWrap: "wrap" }}>
|
||||
<input name="key" placeholder="key" required maxLength={255} />
|
||||
<input
|
||||
name="value"
|
||||
placeholder="value / name"
|
||||
maxLength={255}
|
||||
style={{ flex: 1, minWidth: 160 }}
|
||||
/>
|
||||
<input name="comment" placeholder="comment" maxLength={255} />
|
||||
<button type="submit" className="btn btn-primary">
|
||||
Save
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{permissions.length === 0 ? (
|
||||
<p className="muted">No permissions found.</p>
|
||||
) : (
|
||||
<table>
|
||||
<table style={{ width: "100%", borderCollapse: "collapse" }}>
|
||||
<thead>
|
||||
<tr>
|
||||
<tr style={{ textAlign: "left", color: "var(--muted)" }}>
|
||||
<th>ID</th>
|
||||
<th>Permission</th>
|
||||
<th>Min rank</th>
|
||||
<th>Description</th>
|
||||
<th>Key</th>
|
||||
<th>Value</th>
|
||||
<th>Comment</th>
|
||||
<th />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{permissions.map((p) => (
|
||||
<tr key={String(p.id)}>
|
||||
<tr key={String(p.id)} style={{ borderTop: "1px solid var(--border)" }}>
|
||||
<td>{String(p.id)}</td>
|
||||
<td>{p.permission}</td>
|
||||
<td>{p.minRank}</td>
|
||||
<td className="muted">{p.description ?? "—"}</td>
|
||||
<td>
|
||||
<form
|
||||
id={`perm-${String(p.id)}`}
|
||||
action={updatePermission}
|
||||
style={{ display: "contents" }}
|
||||
>
|
||||
<input type="hidden" name="id" value={String(p.id)} />
|
||||
<input name="key" defaultValue={p.key} required maxLength={255} />
|
||||
</form>
|
||||
</td>
|
||||
<td>
|
||||
<input
|
||||
form={`perm-${String(p.id)}`}
|
||||
name="value"
|
||||
defaultValue={p.value ?? ""}
|
||||
maxLength={255}
|
||||
style={{ width: "100%" }}
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<input
|
||||
form={`perm-${String(p.id)}`}
|
||||
name="comment"
|
||||
defaultValue={p.comment ?? ""}
|
||||
maxLength={255}
|
||||
style={{ width: "100%" }}
|
||||
/>
|
||||
</td>
|
||||
<td style={{ display: "flex", gap: "0.5rem" }}>
|
||||
<button
|
||||
type="submit"
|
||||
form={`perm-${String(p.id)}`}
|
||||
className="btn btn-primary"
|
||||
>
|
||||
Save
|
||||
</button>
|
||||
<form action={deletePermission}>
|
||||
<input type="hidden" name="id" value={String(p.id)} />
|
||||
<button type="submit" className="btn btn-danger">
|
||||
Delete
|
||||
</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
import Link from 'next/link';
|
||||
import { prisma } from '@/lib/prisma';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
type History = {
|
||||
id: bigint;
|
||||
userId: bigint;
|
||||
showName: string | null;
|
||||
startedAt: Date;
|
||||
endedAt: Date | null;
|
||||
listenersCount: number;
|
||||
notes: string | null;
|
||||
};
|
||||
|
||||
function formatDate(d: Date | null): string {
|
||||
return d ? d.toISOString().slice(0, 16).replace('T', ' ') : '—';
|
||||
}
|
||||
|
||||
/** Human-readable airtime between two timestamps (e.g. "1h 23m"). */
|
||||
function formatDuration(start: Date, end: Date | null): string {
|
||||
if (!end) return '—';
|
||||
const ms = end.getTime() - start.getTime();
|
||||
if (ms <= 0) return '—';
|
||||
const mins = Math.round(ms / 60000);
|
||||
const h = Math.floor(mins / 60);
|
||||
const m = mins % 60;
|
||||
return h > 0 ? `${h}h ${m}m` : `${m}m`;
|
||||
}
|
||||
|
||||
export default async function AdminRadioHistoryPage() {
|
||||
let history: History[] = [];
|
||||
let names = new Map<string, string>();
|
||||
let dbError = false;
|
||||
|
||||
try {
|
||||
history = await prisma.radioHistory.findMany({
|
||||
select: {
|
||||
id: true,
|
||||
userId: true,
|
||||
showName: true,
|
||||
startedAt: true,
|
||||
endedAt: true,
|
||||
listenersCount: true,
|
||||
notes: true,
|
||||
},
|
||||
orderBy: { startedAt: 'desc' },
|
||||
take: 100,
|
||||
});
|
||||
|
||||
// Resolve DJ usernames (users.id is Int, radio_history.user_id is BigInt).
|
||||
const ids = Array.from(new Set(history.map((h) => Number(h.userId)))).filter(
|
||||
(n) => Number.isFinite(n) && n > 0,
|
||||
);
|
||||
if (ids.length > 0) {
|
||||
const users = await prisma.user.findMany({
|
||||
where: { id: { in: ids } },
|
||||
select: { id: true, username: true },
|
||||
});
|
||||
names = new Map(users.map((u) => [String(u.id), u.username]));
|
||||
}
|
||||
} catch {
|
||||
dbError = true;
|
||||
history = [];
|
||||
}
|
||||
|
||||
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/history">History</Link>
|
||||
</nav>
|
||||
|
||||
<h1>Radio Play History</h1>
|
||||
<p className="muted">
|
||||
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>
|
||||
|
||||
{dbError ? (
|
||||
<div className="card" style={{ marginBottom: '1.5rem' }}>
|
||||
<p className="muted" style={{ margin: 0 }}>
|
||||
Could not load radio history (database unavailable).
|
||||
</p>
|
||||
</div>
|
||||
) : 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>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import Link from "next/link";
|
||||
import { notFound } from "next/navigation";
|
||||
import { deleteShopArticle, updateShopArticle } from "@/actions/admin-shop";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function EditShopArticle({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ id: string }>;
|
||||
}) {
|
||||
const { id } = await params;
|
||||
let pkg: Awaited<ReturnType<typeof prisma.websiteShopArticles.findUnique>> = null;
|
||||
try {
|
||||
pkg = await prisma.websiteShopArticles.findUnique({ where: { id: BigInt(id) } });
|
||||
} catch {
|
||||
notFound();
|
||||
}
|
||||
if (!pkg) notFound();
|
||||
|
||||
return (
|
||||
<main>
|
||||
<p className="muted">
|
||||
<Link href="/admin/shop">← Shop</Link>
|
||||
</p>
|
||||
<h1>Edit shop package</h1>
|
||||
<form action={updateShopArticle} className="card" style={{ display: "grid", gap: "0.6rem" }}>
|
||||
<input type="hidden" name="id" value={String(pkg.id)} />
|
||||
<input name="name" defaultValue={pkg.name} placeholder="Name" required maxLength={255} />
|
||||
<input name="info" defaultValue={pkg.info} placeholder="Info / description" maxLength={255} />
|
||||
<input name="icon" defaultValue={pkg.icon} placeholder="Icon" maxLength={255} />
|
||||
<input name="color" defaultValue={pkg.color} placeholder="Color (e.g. #ffcc00)" maxLength={255} />
|
||||
<input name="costs" type="number" min={0} defaultValue={pkg.costs} placeholder="Costs (store price)" required />
|
||||
<input name="credits" type="number" min={0} defaultValue={pkg.credits ?? undefined} placeholder="Credits given (optional)" />
|
||||
<input name="duckets" type="number" min={0} defaultValue={pkg.duckets ?? undefined} placeholder="Duckets given (optional)" />
|
||||
<input name="diamonds" type="number" min={0} defaultValue={pkg.diamonds ?? undefined} placeholder="Diamonds given (optional)" />
|
||||
<input name="giveRank" type="number" min={0} defaultValue={pkg.giveRank ?? undefined} placeholder="Give rank (optional)" />
|
||||
<input name="badges" defaultValue={pkg.badges ?? ""} placeholder="Badge codes (optional)" maxLength={255} />
|
||||
<input name="position" type="number" min={0} defaultValue={pkg.position} placeholder="Position" />
|
||||
<button type="submit" className="btn btn-primary">
|
||||
Save
|
||||
</button>
|
||||
</form>
|
||||
<form action={deleteShopArticle} style={{ marginTop: "1rem" }}>
|
||||
<input type="hidden" name="id" value={String(pkg.id)} />
|
||||
<button type="submit" className="btn btn-danger">
|
||||
Delete package
|
||||
</button>
|
||||
</form>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import Link from "next/link";
|
||||
import { createShopArticle } from "@/actions/admin-shop";
|
||||
|
||||
export default function NewShopArticle() {
|
||||
return (
|
||||
<main>
|
||||
<p className="muted">
|
||||
<Link href="/admin/shop">← Shop</Link>
|
||||
</p>
|
||||
<h1>New shop package</h1>
|
||||
<form action={createShopArticle} className="card" style={{ display: "grid", gap: "0.6rem" }}>
|
||||
<input name="name" placeholder="Name" required maxLength={255} />
|
||||
<input name="info" placeholder="Info / description" maxLength={255} />
|
||||
<input name="icon" placeholder="Icon" maxLength={255} />
|
||||
<input name="color" placeholder="Color (e.g. #ffcc00)" maxLength={255} />
|
||||
<input name="costs" type="number" min={0} placeholder="Costs (store price)" required />
|
||||
<input name="credits" type="number" min={0} placeholder="Credits given (optional)" />
|
||||
<input name="duckets" type="number" min={0} placeholder="Duckets given (optional)" />
|
||||
<input name="diamonds" type="number" min={0} placeholder="Diamonds given (optional)" />
|
||||
<input name="giveRank" type="number" min={0} placeholder="Give rank (optional)" />
|
||||
<input name="badges" placeholder="Badge codes (optional)" maxLength={255} />
|
||||
<input name="position" type="number" min={0} placeholder="Position" defaultValue={0} />
|
||||
<button type="submit" className="btn btn-primary">
|
||||
Create
|
||||
</button>
|
||||
</form>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
import Link from "next/link";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function AdminShop() {
|
||||
let packages: Awaited<ReturnType<typeof prisma.websiteShopArticles.findMany>> = [];
|
||||
try {
|
||||
packages = await prisma.websiteShopArticles.findMany({
|
||||
orderBy: [{ position: "asc" }, { name: "asc" }],
|
||||
take: 500,
|
||||
});
|
||||
} catch {
|
||||
packages = [];
|
||||
}
|
||||
|
||||
let orders: Awaited<ReturnType<typeof prisma.websitePaypalTransactions.findMany>> = [];
|
||||
try {
|
||||
orders = await prisma.websitePaypalTransactions.findMany({
|
||||
orderBy: { createdAt: "desc" },
|
||||
take: 50,
|
||||
});
|
||||
} catch {
|
||||
orders = [];
|
||||
}
|
||||
|
||||
return (
|
||||
<main>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: "1rem" }}>
|
||||
<h1 style={{ flex: 1 }}>Shop</h1>
|
||||
<Link href="/admin/shop/new">+ New package</Link>
|
||||
</div>
|
||||
<p className="muted">
|
||||
Website store packages (<code>website_shop_articles</code>). <code>costs</code> is the price
|
||||
shown in the store; credits/duckets/diamonds/badges are what the buyer receives.
|
||||
</p>
|
||||
|
||||
<table style={{ width: "100%", borderCollapse: "collapse" }}>
|
||||
<thead>
|
||||
<tr style={{ textAlign: "left", color: "var(--muted)" }}>
|
||||
<th>#</th>
|
||||
<th>Name</th>
|
||||
<th>Costs</th>
|
||||
<th>Credits</th>
|
||||
<th>Duckets</th>
|
||||
<th>Diamonds</th>
|
||||
<th>Rank</th>
|
||||
<th>Position</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{packages.map((p) => (
|
||||
<tr key={String(p.id)} style={{ borderTop: "1px solid var(--border)" }}>
|
||||
<td className="muted">{String(p.id)}</td>
|
||||
<td>
|
||||
<Link href={`/admin/shop/${p.id}`}>{p.name}</Link>
|
||||
</td>
|
||||
<td>{p.costs}</td>
|
||||
<td className="muted">{p.credits ?? ""}</td>
|
||||
<td className="muted">{p.duckets ?? ""}</td>
|
||||
<td className="muted">{p.diamonds ?? ""}</td>
|
||||
<td className="muted">{p.giveRank ?? ""}</td>
|
||||
<td className="muted">{p.position}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
{packages.length === 0 ? <p className="muted">No shop packages yet.</p> : null}
|
||||
|
||||
<h2 style={{ marginTop: "2rem" }}>Recent orders</h2>
|
||||
<p className="muted">
|
||||
Read-only PayPal transaction log (<code>website_paypal_transactions</code>).
|
||||
</p>
|
||||
<table style={{ width: "100%", borderCollapse: "collapse" }}>
|
||||
<thead>
|
||||
<tr style={{ textAlign: "left", color: "var(--muted)" }}>
|
||||
<th>Transaction</th>
|
||||
<th>User</th>
|
||||
<th>Description</th>
|
||||
<th>Amount</th>
|
||||
<th>Status</th>
|
||||
<th>Date</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{orders.map((o) => (
|
||||
<tr key={String(o.id)} style={{ borderTop: "1px solid var(--border)" }}>
|
||||
<td>
|
||||
<code>{o.transactionId}</code>
|
||||
</td>
|
||||
<td className="muted">{o.userId}</td>
|
||||
<td className="muted">{o.description ?? ""}</td>
|
||||
<td>
|
||||
{o.amount} {o.currency}
|
||||
</td>
|
||||
<td className="muted">{o.status ?? ""}</td>
|
||||
<td className="muted">{o.createdAt ? o.createdAt.toISOString().slice(0, 10) : ""}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
{orders.length === 0 ? <p className="muted">No orders recorded.</p> : null}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
import { createTag, deleteTag, updateTag } from '@/actions/admin-tags';
|
||||
import { prisma } from '@/lib/prisma';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
type Tag = {
|
||||
id: bigint;
|
||||
name: string;
|
||||
backgroundColor: string;
|
||||
createdAt: Date | null;
|
||||
};
|
||||
|
||||
function formatDate(d: Date | null): string {
|
||||
return d ? d.toISOString().slice(0, 10) : '—';
|
||||
}
|
||||
|
||||
export default async function AdminTagsPage() {
|
||||
let tags: Tag[] = [];
|
||||
let usage = new Map<string, number>();
|
||||
let dbError = false;
|
||||
|
||||
try {
|
||||
tags = await prisma.tags.findMany({
|
||||
select: { id: true, name: true, backgroundColor: true, createdAt: true },
|
||||
orderBy: { name: 'asc' },
|
||||
take: 500,
|
||||
});
|
||||
|
||||
// How many things each tag is attached to (taggables join table).
|
||||
const counts = await prisma.taggables.groupBy({
|
||||
by: ['tagId'],
|
||||
_count: { tagId: true },
|
||||
});
|
||||
usage = new Map(counts.map((c) => [String(c.tagId), c._count.tagId]));
|
||||
} catch {
|
||||
dbError = true;
|
||||
tags = [];
|
||||
}
|
||||
|
||||
return (
|
||||
<main>
|
||||
<h1>Tags</h1>
|
||||
<p className="muted">
|
||||
Article tags / categories (<code>tags</code>). Each tag has a label and a
|
||||
background colour, and can be attached to articles via <code>taggables</code>.
|
||||
</p>
|
||||
|
||||
{dbError ? (
|
||||
<div className="card" style={{ marginBottom: '1.5rem' }}>
|
||||
<p className="muted" style={{ margin: 0 }}>
|
||||
Could not load tags (database unavailable).
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<form action={createTag} className="card" style={{ marginBottom: '1.5rem' }}>
|
||||
<h3 style={{ marginTop: 0 }}>Add tag</h3>
|
||||
<div className="grid cols-2">
|
||||
<div>
|
||||
<label htmlFor="new_name" style={{ display: 'block', fontWeight: 700 }}>
|
||||
Name
|
||||
</label>
|
||||
<input
|
||||
id="new_name"
|
||||
name="name"
|
||||
placeholder="e.g. Events"
|
||||
required
|
||||
maxLength={255}
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label
|
||||
htmlFor="new_backgroundColor"
|
||||
style={{ display: 'block', fontWeight: 700 }}
|
||||
>
|
||||
Background colour
|
||||
</label>
|
||||
<input
|
||||
id="new_backgroundColor"
|
||||
name="backgroundColor"
|
||||
placeholder="#888888"
|
||||
defaultValue="#888888"
|
||||
maxLength={10}
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ marginTop: '0.75rem' }}>
|
||||
<button type="submit" className="btn btn-primary">
|
||||
Create tag
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{tags.length === 0 ? (
|
||||
<p className="muted">No tags yet.</p>
|
||||
) : (
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>Tag</th>
|
||||
<th>Colour</th>
|
||||
<th>Used by</th>
|
||||
<th>Created</th>
|
||||
<th />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{tags.map((t) => (
|
||||
<tr key={String(t.id)}>
|
||||
<td>{String(t.id)}</td>
|
||||
<td>
|
||||
<span
|
||||
style={{
|
||||
display: 'inline-block',
|
||||
padding: '0.1rem 0.5rem',
|
||||
borderRadius: '0.4rem',
|
||||
background: t.backgroundColor,
|
||||
color: '#fff',
|
||||
}}
|
||||
>
|
||||
{t.name}
|
||||
</span>
|
||||
</td>
|
||||
<td className="muted">
|
||||
<code>{t.backgroundColor}</code>
|
||||
</td>
|
||||
<td className="muted">{usage.get(String(t.id)) ?? 0}</td>
|
||||
<td className="muted">{formatDate(t.createdAt)}</td>
|
||||
<td>
|
||||
<form
|
||||
action={updateTag}
|
||||
style={{ display: 'flex', gap: '0.4rem', alignItems: 'center' }}
|
||||
>
|
||||
<input type="hidden" name="id" value={String(t.id)} />
|
||||
<input
|
||||
name="name"
|
||||
defaultValue={t.name}
|
||||
required
|
||||
maxLength={255}
|
||||
style={{ width: 140 }}
|
||||
/>
|
||||
<input
|
||||
name="backgroundColor"
|
||||
defaultValue={t.backgroundColor}
|
||||
maxLength={10}
|
||||
style={{ width: 90 }}
|
||||
/>
|
||||
<button type="submit" className="btn btn-primary">
|
||||
Save
|
||||
</button>
|
||||
</form>
|
||||
<form action={deleteTag} style={{ marginTop: '0.4rem' }}>
|
||||
<input type="hidden" name="id" value={String(t.id)} />
|
||||
<button type="submit" className="btn btn-danger">
|
||||
Delete
|
||||
</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
import Link from "next/link";
|
||||
import { requireStaff } from "@/lib/admin/guard";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
const PER_PAGE = 25;
|
||||
|
||||
// Read-only listing of PayPal / top-up transactions recorded by the CMS
|
||||
// (website_paypal_transactions). No mutations — this page never writes.
|
||||
//
|
||||
// website_* ids are BigInt; users.id (the payer) is the emulator-owned Int.
|
||||
// The model has no Prisma relation to User, so payer usernames are resolved in
|
||||
// one batched lookup keyed by userId.
|
||||
|
||||
type Txn = Awaited<ReturnType<typeof prisma.websitePaypalTransactions.findMany>>[number];
|
||||
|
||||
function fromDate(d: Date | null | undefined): string {
|
||||
return d ? d.toISOString().slice(0, 16).replace("T", " ") : "—";
|
||||
}
|
||||
|
||||
function formatAmount(amount: number, currency: string): string {
|
||||
const n = Number(amount);
|
||||
const value = Number.isFinite(n) ? n.toFixed(2) : "0.00";
|
||||
return `${value} ${currency}`;
|
||||
}
|
||||
|
||||
export default async function AdminTransactions({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<{ page?: string }>;
|
||||
}) {
|
||||
await requireStaff();
|
||||
|
||||
const sp = await searchParams;
|
||||
const page = Math.max(1, Number(sp.page ?? "1") || 1);
|
||||
|
||||
let transactions: Txn[] = [];
|
||||
let total = 0;
|
||||
let names = new Map<number, string>();
|
||||
|
||||
try {
|
||||
[transactions, total] = await Promise.all([
|
||||
prisma.websitePaypalTransactions.findMany({
|
||||
orderBy: { id: "desc" },
|
||||
skip: (page - 1) * PER_PAGE,
|
||||
take: PER_PAGE,
|
||||
}),
|
||||
prisma.websitePaypalTransactions.count(),
|
||||
]);
|
||||
|
||||
const userIds = [...new Set(transactions.map((t) => t.userId))];
|
||||
if (userIds.length > 0) {
|
||||
const users = await prisma.user.findMany({
|
||||
where: { id: { in: userIds } },
|
||||
select: { id: true, username: true },
|
||||
});
|
||||
names = new Map(users.map((u) => [u.id, u.username]));
|
||||
}
|
||||
} catch {
|
||||
transactions = [];
|
||||
total = 0;
|
||||
names = new Map();
|
||||
}
|
||||
|
||||
const pages = Math.max(1, Math.ceil(total / PER_PAGE));
|
||||
|
||||
return (
|
||||
<main>
|
||||
<h1>Transactions</h1>
|
||||
<p className="muted" style={{ marginTop: "-0.4rem" }}>
|
||||
Read-only · PayPal / top-up transactions recorded by the CMS, newest first.
|
||||
</p>
|
||||
|
||||
<section className="card">
|
||||
{transactions.length === 0 ? (
|
||||
<p className="muted">No transactions.</p>
|
||||
) : (
|
||||
<div style={{ overflowX: "auto" }}>
|
||||
<table style={{ width: "100%", borderCollapse: "collapse" }}>
|
||||
<thead>
|
||||
<tr style={{ textAlign: "left", color: "var(--muted)" }}>
|
||||
<th>ID</th>
|
||||
<th>Payer</th>
|
||||
<th>Amount</th>
|
||||
<th>Package</th>
|
||||
<th>Status</th>
|
||||
<th>Transaction</th>
|
||||
<th>Date</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{transactions.map((t) => (
|
||||
<tr key={String(t.id)} style={{ borderTop: "1px solid var(--border)" }}>
|
||||
<td>{String(t.id)}</td>
|
||||
<td>
|
||||
<Link href={`/admin/users/${t.userId}`}>
|
||||
{names.get(t.userId) ?? `#${t.userId}`}
|
||||
</Link>
|
||||
</td>
|
||||
<td>{formatAmount(t.amount, t.currency)}</td>
|
||||
<td>{t.description ?? "—"}</td>
|
||||
<td>{t.status ?? "—"}</td>
|
||||
<td className="muted">{t.transactionId}</td>
|
||||
<td className="muted">{fromDate(t.createdAt)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<p className="muted">
|
||||
Page {page} / {pages} · {total} transactions
|
||||
{page > 1 ? (
|
||||
<>
|
||||
{" · "}
|
||||
<Link href={`/admin/transactions?page=${page - 1}`}>← Prev</Link>
|
||||
</>
|
||||
) : null}
|
||||
{page < pages ? (
|
||||
<>
|
||||
{" · "}
|
||||
<Link href={`/admin/transactions?page=${page + 1}`}>Next →</Link>
|
||||
</>
|
||||
) : null}
|
||||
</p>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
import Link from "next/link";
|
||||
import { notFound } from "next/navigation";
|
||||
import { updateUser } from "@/actions/admin-user-edit";
|
||||
import { requireStaff } from "@/lib/admin/guard";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
// users_currency.type values for the non-credits currencies (see send-currency.ts).
|
||||
const DUCKETS_TYPE = 0;
|
||||
const DIAMONDS_TYPE = 5;
|
||||
|
||||
export default async function AdminUserEdit({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ id: string }>;
|
||||
}) {
|
||||
// Gate the page with the SAME helper the action re-checks.
|
||||
await requireStaff();
|
||||
|
||||
const { id } = await params;
|
||||
const userId = Number(id);
|
||||
if (!Number.isInteger(userId) || userId <= 0) notFound();
|
||||
|
||||
let user: {
|
||||
id: number;
|
||||
username: string;
|
||||
mail: string | null;
|
||||
motto: string;
|
||||
look: string;
|
||||
rank: number;
|
||||
credits: number;
|
||||
pixels: number;
|
||||
points: number;
|
||||
} | null = null;
|
||||
let duckets = 0;
|
||||
let diamonds = 0;
|
||||
|
||||
try {
|
||||
user = await prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: {
|
||||
id: true,
|
||||
username: true,
|
||||
mail: true,
|
||||
motto: true,
|
||||
look: true,
|
||||
rank: true,
|
||||
credits: true,
|
||||
pixels: true,
|
||||
points: true,
|
||||
},
|
||||
});
|
||||
if (user) {
|
||||
const currencies = await prisma.usersCurrency.findMany({
|
||||
where: { userId, type: { in: [DUCKETS_TYPE, DIAMONDS_TYPE] } },
|
||||
select: { type: true, amount: true },
|
||||
});
|
||||
for (const c of currencies) {
|
||||
if (c.type === DUCKETS_TYPE) duckets = c.amount;
|
||||
else if (c.type === DIAMONDS_TYPE) diamonds = c.amount;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
return (
|
||||
<main>
|
||||
<p className="muted">
|
||||
<Link href="/admin/users">← Users</Link>
|
||||
</p>
|
||||
<h1>Edit user</h1>
|
||||
<p className="muted">Could not load this user right now. Try again shortly.</p>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
if (!user) notFound();
|
||||
|
||||
return (
|
||||
<main>
|
||||
<p className="muted">
|
||||
<Link href={`/admin/users/${user.id}`}>← {user.username}</Link>
|
||||
</p>
|
||||
<h1>Edit {user.username}</h1>
|
||||
<p className="muted">ID {user.id}</p>
|
||||
|
||||
<form action={updateUser} className="card">
|
||||
<input type="hidden" name="id" value={user.id} />
|
||||
|
||||
<div className="grid cols-2">
|
||||
<label>
|
||||
Email
|
||||
<input name="mail" type="email" defaultValue={user.mail ?? ""} maxLength={500} />
|
||||
</label>
|
||||
|
||||
<label>
|
||||
Rank
|
||||
<input name="rank" type="number" min={1} defaultValue={user.rank} />
|
||||
</label>
|
||||
|
||||
<label>
|
||||
Motto
|
||||
<input name="motto" defaultValue={user.motto} maxLength={127} />
|
||||
</label>
|
||||
|
||||
<label>
|
||||
Look
|
||||
<input name="look" defaultValue={user.look} maxLength={256} />
|
||||
</label>
|
||||
|
||||
<label>
|
||||
Credits
|
||||
<input name="credits" type="number" min={0} defaultValue={user.credits} />
|
||||
</label>
|
||||
|
||||
<label>
|
||||
Pixels
|
||||
<input name="pixels" type="number" min={0} defaultValue={user.pixels} />
|
||||
</label>
|
||||
|
||||
<label>
|
||||
Points
|
||||
<input name="points" type="number" min={0} defaultValue={user.points} />
|
||||
</label>
|
||||
|
||||
<label>
|
||||
Duckets
|
||||
<input name="duckets" type="number" min={0} defaultValue={duckets} />
|
||||
</label>
|
||||
|
||||
<label>
|
||||
Diamonds
|
||||
<input name="diamonds" type="number" min={0} defaultValue={diamonds} />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<p className="muted">Currency fields set the exact balance, not a delta. Passwords are not editable here.</p>
|
||||
|
||||
<div style={{ display: "flex", gap: "0.5rem", marginTop: "0.5rem" }}>
|
||||
<button type="submit" className="btn btn-primary">Save changes</button>
|
||||
<Link href={`/admin/users/${user.id}`} className="btn">Cancel</Link>
|
||||
</div>
|
||||
</form>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user