refactor: convert admin pages to Tailwind and improve media uploads; fix TypeScript error in commandocentrum

This commit is contained in:
openhands committed 2026-07-02 17:26:16 +02:00
1 parent 72ef74fc87
commit 518c072491
22 files changed
+488 -430

No files matched your search

Binary file not shown.

Before

Width:  |  Height:  |  Size: 95 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 65 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 139 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 138 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 98 KiB

File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+7 -7
View File
@@ -35,7 +35,7 @@ export default async function AdminEmulator() {
return ( return (
<main> <main>
<h1>Emulator Config</h1> <h1>Emulator Config</h1>
<p className="muted"> <p className="text-xs text-gray-500 dark:text-gray-400 mb-3">
Live key/value config read by the Arcturus emulator from Live key/value config read by the Arcturus emulator from
<code> emulator_settings</code> and <code> emulator_texts</code>. Changes take effect <code> emulator_settings</code> and <code> emulator_texts</code>. Changes take effect
on the next emulator read (some keys require an emulator reload). on the next emulator read (some keys require an emulator reload).
@@ -48,7 +48,7 @@ export default async function AdminEmulator() {
<section className="mt-6"> <section className="mt-6">
<h2 className="admin-section-title">Settings</h2> <h2 className="admin-section-title">Settings</h2>
<p className="muted" style={{ marginTop: "-0.4rem" }}> <p className="text-xs text-gray-500 dark:text-gray-400 mb-3">
{settings.length} keys. {settings.length} keys.
</p> </p>
@@ -74,7 +74,7 @@ export default async function AdminEmulator() {
<table> <table>
<thead> <thead>
<tr> <tr>
<th style={{ width: 320 }}>Key</th> <th className="w-[320px]">Key</th>
<th>Value</th> <th>Value</th>
</tr> </tr>
</thead> </thead>
@@ -87,14 +87,14 @@ export default async function AdminEmulator() {
<td> <td>
<form <form
action={updateEmulatorSetting} action={updateEmulatorSetting}
style={{ display: "flex", gap: "0.5rem" }} className="flex gap-2"
> >
<input type="hidden" name="key" value={s.key} /> <input type="hidden" name="key" value={s.key} />
<input <input
name="value" name="value"
defaultValue={s.value} defaultValue={s.value}
maxLength={512} maxLength={512}
style={{ flex: 1 }} className="flex-1"
/> />
<button type="submit" className="btn btn-outline"> <button type="submit" className="btn btn-outline">
Save Save
@@ -106,13 +106,13 @@ export default async function AdminEmulator() {
</tbody> </tbody>
</table> </table>
{settings.length === 0 ? ( {settings.length === 0 ? (
<p className="muted">No emulator settings found.</p> <p className="text-xs text-gray-500 dark:text-gray-400">No emulator settings found.</p>
) : null} ) : null}
</section> </section>
<section className="mt-6"> <section className="mt-6">
<h2 className="admin-section-title">Texts</h2> <h2 className="admin-section-title">Texts</h2>
<p className="muted" style={{ marginTop: "-0.4rem" }}> <p className="text-xs text-gray-500 dark:text-gray-400 mb-3">
{textsCount} keys{textsTruncated ? ` (showing first ${texts.length})` : ""}. {textsCount} keys{textsTruncated ? ` (showing first ${texts.length})` : ""}.
</p> </p>
+4 -10
View File
@@ -38,14 +38,14 @@ export function EmbedSnippet({
return ( return (
<section className="mt-6"> <section className="mt-6">
<div style={{ display: 'flex', alignItems: 'center', gap: '1rem' }}> <div className="flex items-center justify-between gap-4 mb-1">
<h2 style={{ flex: 1, margin: 0 }}>{title}</h2> <h2 className="m-0 text-base font-bold text-[var(--color-text)]">{title}</h2>
<button type="button" className="btn btn-primary" onClick={copy}> <button type="button" className="btn btn-primary" onClick={copy}>
{copied ? 'Copied!' : 'Copy'} {copied ? 'Copied!' : 'Copy'}
</button> </button>
</div> </div>
{description ? ( {description ? (
<p className="muted" style={{ marginTop: '0.4rem' }}> <p className="text-xs text-gray-500 dark:text-gray-400 mb-3">
{description} {description}
</p> </p>
) : null} ) : null}
@@ -56,13 +56,7 @@ export function EmbedSnippet({
rows={Math.min(Math.max(lines + 1, 3), 14)} rows={Math.min(Math.max(lines + 1, 3), 14)}
onFocus={(e) => e.currentTarget.select()} onFocus={(e) => e.currentTarget.select()}
spellCheck={false} spellCheck={false}
style={{ className="w-full font-mono text-sm resize-vertical"
width: '100%',
fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Consolas, monospace',
fontSize: '0.85rem',
whiteSpace: 'pre',
resize: 'vertical',
}}
/> />
</section> </section>
); );
+8 -8
View File
@@ -108,7 +108,7 @@ export default async function AdminRadioPage() {
return ( return (
<main> <main>
<nav className="muted" style={{ marginBottom: '0.5rem' }}> <nav className="text-xs text-gray-500 dark:text-gray-400 mb-2">
<Link href="/admin/radio">Radio</Link> ·{' '} <Link href="/admin/radio">Radio</Link> ·{' '}
<Link href="/admin/radio/settings">Settings</Link> ·{' '} <Link href="/admin/radio/settings">Settings</Link> ·{' '}
<Link href="/admin/radio/banners">Banners</Link> ·{' '} <Link href="/admin/radio/banners">Banners</Link> ·{' '}
@@ -117,7 +117,7 @@ export default async function AdminRadioPage() {
</nav> </nav>
<h1>Radio Moderation</h1> <h1>Radio Moderation</h1>
<p className="muted" style={{ marginTop: '-0.4rem' }}> <p className="text-xs text-gray-500 dark:text-gray-400">
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>
@@ -141,7 +141,7 @@ export default async function AdminRadioPage() {
<section className="mt-6"> <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="text-xs text-gray-500 dark:text-gray-400">No shouts to moderate.</p>
) : ( ) : (
<table> <table>
<thead> <thead>
@@ -156,10 +156,10 @@ export default async function AdminRadioPage() {
<tbody> <tbody>
{shouts.map((s) => ( {shouts.map((s) => (
<tr key={String(s.id)}> <tr key={String(s.id)}>
<td>{String(s.id)}</td> <td className="text-sm text-gray-500 dark:text-gray-400 whitespace-nowrap">{String(s.id)}</td>
<td>{String(s.userId)}</td> <td>{String(s.userId)}</td>
<td>{s.message}</td> <td>{s.message}</td>
<td className="muted">{formatDate(s.createdAt)}</td> <td className="text-sm text-gray-500 dark:text-gray-400">{formatDate(s.createdAt)}</td>
<td> <td>
<form action={deleteShout}> <form action={deleteShout}>
<input type="hidden" name="id" value={String(s.id)} /> <input type="hidden" name="id" value={String(s.id)} />
@@ -178,7 +178,7 @@ export default async function AdminRadioPage() {
<section className="mt-6"> <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="text-xs text-gray-500 dark:text-gray-400">No applications submitted.</p>
) : ( ) : (
<table> <table>
<thead> <thead>
@@ -207,7 +207,7 @@ export default async function AdminRadioPage() {
{a.status} {a.status}
</span> </span>
</td> </td>
<td className="muted">{formatDate(a.createdAt)}</td> <td className="text-sm text-gray-500 dark:text-gray-400">{formatDate(a.createdAt)}</td>
</tr> </tr>
))} ))}
</tbody> </tbody>
@@ -218,7 +218,7 @@ export default async function AdminRadioPage() {
<section className="mt-6"> <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="text-xs text-gray-500 dark:text-gray-400">No schedules configured.</p>
) : ( ) : (
<table> <table>
<thead> <thead>
+1 -1
View File
@@ -54,7 +54,7 @@ export default async function AdminSettings() {
) : ( ) : (
<div className="grid"> <div className="grid">
{settings.map((s) => ( {settings.map((s) => (
<div key={s.key} className="card"> <div key={s.key} className="admin-card">
<div style={{ display: "flex", gap: "0.75rem", alignItems: "baseline" }}> <div style={{ display: "flex", gap: "0.75rem", alignItems: "baseline" }}>
<strong style={{ minWidth: 220 }}>{s.key}</strong> <strong style={{ minWidth: 220 }}>{s.key}</strong>
<form <form
+39 -26
View File
@@ -2,6 +2,7 @@ import Link from "next/link";
import { notFound } from "next/navigation"; import { notFound } from "next/navigation";
import { deleteShopArticle, updateShopArticle } from "@/actions/admin-shop"; import { deleteShopArticle, updateShopArticle } from "@/actions/admin-shop";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
import { Package } from "lucide-react";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
@@ -21,32 +22,44 @@ export default async function EditShopArticle({
return ( return (
<main> <main>
<p className="muted"> <div className="flex items-center gap-3 mb-6">
<Link href="/admin/shop">← Shop</Link> <div className="w-10 h-10 rounded-xl bg-gradient-to-br from-[var(--color-primary)]/20 to-[var(--color-primary)]/5 grid place-items-center">
</p> <Package size={20} className="text-[var(--color-primary)]" />
<h1>Edit shop package</h1> </div>
<form action={updateShopArticle} className="card" style={{ display: "grid", gap: "0.6rem" }}> <div>
<input type="hidden" name="id" value={String(pkg.id)} /> <h1 className="m-0 text-xl font-extrabold text-[var(--color-text)]">Edit shop package</h1>
<input name="name" defaultValue={pkg.name} placeholder="Name" required maxLength={255} /> <Link href="/admin/shop" className="text-xs text-[var(--color-text-muted)] hover:text-[var(--color-primary)]">
<input name="info" defaultValue={pkg.info} placeholder="Info / description" maxLength={255} /> ← Back to shop
<input name="icon" defaultValue={pkg.iconUrl} placeholder="Icon URL" maxLength={255} /> </Link>
<input name="color" defaultValue={pkg.color} placeholder="Color (e.g. #ffcc00)" maxLength={255} /> </div>
<input name="costs" type="number" min={0} defaultValue={pkg.costs} placeholder="Costs (store price)" required /> </div>
<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)" /> <form action={updateShopArticle} className="admin-card">
<input name="diamonds" type="number" min={0} defaultValue={pkg.diamonds ?? undefined} placeholder="Diamonds given (optional)" /> <div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<input name="giveRank" type="number" min={0} defaultValue={pkg.giveRank ?? undefined} placeholder="Give rank (optional)" /> <input type="hidden" name="id" value={String(pkg.id)} />
<input name="badges" defaultValue={pkg.badges ?? ""} placeholder="Badge codes (optional)" maxLength={255} /> <input name="name" defaultValue={pkg.name} placeholder="Name" required maxLength={255} className="w-full" />
<input name="position" type="number" min={0} defaultValue={pkg.position} placeholder="Position" /> <input name="info" defaultValue={pkg.info} placeholder="Info / description" maxLength={255} className="w-full" />
<button type="submit" className="btn btn-primary"> <input name="icon" defaultValue={pkg.iconUrl} placeholder="Icon URL" maxLength={255} className="w-full" />
Save <input name="color" defaultValue={pkg.color} placeholder="Color (e.g. #ffcc00)" maxLength={255} className="w-full" />
</button> <input name="costs" type="number" min={0} defaultValue={pkg.costs} placeholder="Costs (store price)" required className="w-full" />
</form> <input name="credits" type="number" min={0} defaultValue={pkg.credits ?? undefined} placeholder="Credits given (optional)" className="w-full" />
<form action={deleteShopArticle} style={{ marginTop: "1rem" }}> <input name="duckets" type="number" min={0} defaultValue={pkg.duckets ?? undefined} placeholder="Duckets given (optional)" className="w-full" />
<input type="hidden" name="id" value={String(pkg.id)} /> <input name="diamonds" type="number" min={0} defaultValue={pkg.diamonds ?? undefined} placeholder="Diamonds given (optional)" className="w-full" />
<button type="submit" className="btn btn-danger"> <input name="giveRank" type="number" min={0} defaultValue={pkg.giveRank ?? undefined} placeholder="Give rank (optional)" className="w-full" />
Delete package <input name="badges" defaultValue={pkg.badges ?? ""} placeholder="Badge codes (optional)" maxLength={255} className="w-full" />
</button> <input name="position" type="number" min={0} defaultValue={pkg.position} placeholder="Position" className="w-full" />
</div>
<div className="mt-6 flex gap-2">
<button type="submit" className="btn btn-primary">
Save
</button>
<form action={deleteShopArticle}>
<input type="hidden" name="id" value={String(pkg.id)} />
<button type="submit" className="btn btn-danger">
Delete package
</button>
</form>
</div>
</form> </form>
</main> </main>
); );
+32 -19
View File
@@ -1,28 +1,41 @@
import Link from "next/link"; import Link from "next/link";
import { createShopArticle } from "@/actions/admin-shop"; import { createShopArticle } from "@/actions/admin-shop";
import { Package } from "lucide-react";
export default function NewShopArticle() { export default function NewShopArticle() {
return ( return (
<main> <main>
<p className="muted"> <div className="flex items-center gap-3 mb-6">
<Link href="/admin/shop">← Shop</Link> <div className="w-10 h-10 rounded-xl bg-gradient-to-br from-[var(--color-primary)]/20 to-[var(--color-primary)]/5 grid place-items-center">
</p> <Package size={20} className="text-[var(--color-primary)]" />
<h1>New shop package</h1> </div>
<form action={createShopArticle} className="card" style={{ display: "grid", gap: "0.6rem" }}> <div>
<input name="name" placeholder="Name" required maxLength={255} /> <h1 className="m-0 text-xl font-extrabold text-[var(--color-text)]">New shop package</h1>
<input name="info" placeholder="Info / description" maxLength={255} /> <Link href="/admin/shop" className="text-xs text-[var(--color-text-muted)] hover:text-[var(--color-primary)]">
<input name="icon" placeholder="Icon" maxLength={255} /> ← Back to shop
<input name="color" placeholder="Color (e.g. #ffcc00)" maxLength={255} /> </Link>
<input name="costs" type="number" min={0} placeholder="Costs (store price)" required /> </div>
<input name="credits" type="number" min={0} placeholder="Credits given (optional)" /> </div>
<input name="duckets" type="number" min={0} placeholder="Duckets given (optional)" />
<input name="diamonds" type="number" min={0} placeholder="Diamonds given (optional)" /> <form action={createShopArticle} className="admin-card">
<input name="giveRank" type="number" min={0} placeholder="Give rank (optional)" /> <div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<input name="badges" placeholder="Badge codes (optional)" maxLength={255} /> <input name="name" placeholder="Name" required maxLength={255} className="w-full" />
<input name="position" type="number" min={0} placeholder="Position" defaultValue={0} /> <input name="info" placeholder="Info / description" maxLength={255} className="w-full" />
<button type="submit" className="btn btn-primary"> <input name="icon" placeholder="Icon" maxLength={255} className="w-full" />
Create <input name="color" placeholder="Color (e.g. #ffcc00)" maxLength={255} className="w-full" />
</button> <input name="costs" type="number" min={0} placeholder="Costs (store price)" required className="w-full" />
<input name="credits" type="number" min={0} placeholder="Credits given (optional)" className="w-full" />
<input name="duckets" type="number" min={0} placeholder="Duckets given (optional)" className="w-full" />
<input name="diamonds" type="number" min={0} placeholder="Diamonds given (optional)" className="w-full" />
<input name="giveRank" type="number" min={0} placeholder="Give rank (optional)" className="w-full" />
<input name="badges" placeholder="Badge codes (optional)" maxLength={255} className="w-full" />
<input name="position" type="number" min={0} placeholder="Position" defaultValue={0} className="w-full" />
</div>
<div className="mt-6 text-right">
<button type="submit" className="btn btn-primary">
Create
</button>
</div>
</form> </form>
</main> </main>
); );
File diff suppressed because it is too large. Load diff
+14 -14
View File
@@ -49,20 +49,20 @@ export default async function AdminUsers({
/> />
</div> </div>
<form <form
className="card" className="admin-card mb-6"
style={{ display: "flex", gap: "0.5rem", marginBottom: "1.5rem", flexWrap: "wrap" }} style={{ display: "flex", gap: "0.5rem", marginBottom: "1.5rem", flexWrap: "wrap" }}
> >
<input <input
name="q" name="q"
defaultValue={q} defaultValue={q}
placeholder="Search username" placeholder="Search username"
style={{ flex: 1, minWidth: 200 }} style={{ flex: 1, minWidth: 200 }}
/> />
<button type="submit" className="btn btn-primary"> <button type="submit" className="btn btn-primary">
Search Search
</button> </button>
</form> </form>
<section className="mt-6"> <section className="mt-6">
<h2>Accounts ({total})</h2> <h2>Accounts ({total})</h2>
+47 -20
View File
@@ -9,6 +9,7 @@ export function AdminMediaGrid() {
const [files, setFiles] = useState<MediaFile[]>([]); const [files, setFiles] = useState<MediaFile[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [uploading, setUploading] = useState(false); const [uploading, setUploading] = useState(false);
const [progress, setProgress] = useState({ current: 0, total: 0 });
const fileRef = useRef<HTMLInputElement>(null); const fileRef = useRef<HTMLInputElement>(null);
const load = useCallback(async () => { const load = useCallback(async () => {
@@ -24,16 +25,27 @@ export function AdminMediaGrid() {
useEffect(() => { load(); }, [load]); useEffect(() => { load(); }, [load]);
async function handleUpload(e: React.ChangeEvent<HTMLInputElement>) { async function handleUpload(e: React.ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0]; const files = e.target.files;
if (!file) return; if (!files || files.length === 0) return;
setUploading(true); setUploading(true);
const fd = new FormData(); setProgress({ current: 0, total: files.length });
fd.set("file", file);
try { const fileArray = Array.from(files);
await uploadMedia(fd);
await load(); await Promise.all(fileArray.map(async (file) => {
} catch { /* ignore */ } const fd = new FormData();
fd.set("file", file);
try {
await uploadMedia(fd);
} catch (err) {
console.error(err);
}
setProgress(prev => ({ ...prev, current: prev.current + 1 }));
}));
await load();
setUploading(false); setUploading(false);
setProgress({ current: 0, total: 0 });
if (fileRef.current) fileRef.current.value = ""; if (fileRef.current) fileRef.current.value = "";
} }
@@ -42,30 +54,45 @@ export function AdminMediaGrid() {
} }
return ( return (
<div className="card" style={{ marginTop: "1rem" }}> <div className="admin-card mb-4">
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", marginBottom: "0.75rem" }}> <div className="flex items-center justify-between mb-3">
<strong>{files.length} image{files.length !== 1 ? "s" : ""}</strong> <strong>{files.length} image{files.length !== 1 ? "s" : ""}</strong>
<label className="btn btn-primary" style={{ cursor: "pointer", fontSize: "0.88rem" }}> <label className="btn btn-primary cursor-pointer text-sm">
{uploading ? "Uploading…" : "Upload image"} {uploading ? "Uploading…" : "Upload image"}
<input ref={fileRef} type="file" accept="image/png,image/jpeg,image/gif,image/webp" onChange={handleUpload} style={{ display: "none" }} /> <input ref={fileRef} type="file" accept="image/png,image/jpeg,image/gif,image/webp" onChange={handleUpload} multiple className="hidden" />
</label> </label>
</div> </div>
{uploading && (
<div className="mb-4">
<div className="flex justify-between text-xs mb-1">
<span>Uploading images...</span>
<span>{progress.current} / {progress.total}</span>
</div>
<div className="h-2 w-full bg-[var(--color-primary)]/10 rounded-full overflow-hidden">
<div
className="h-full bg-gradient-to-r from-[var(--color-primary)] to-[var(--color-primary-hover)] transition-all duration-300"
style={{ width: `${(progress.current / progress.total) * 100}%` }}
/>
</div>
</div>
)}
{loading ? ( {loading ? (
<p className="muted">Loading…</p> <p className="text-xs text-gray-500 dark:text-gray-400">Loading…</p>
) : files.length === 0 ? ( ) : files.length === 0 ? (
<p className="muted">No images yet. Upload one above.</p> <p className="text-xs text-gray-500 dark:text-gray-400">No images yet. Upload one above.</p>
) : ( ) : (
<div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fill, minmax(160px, 1fr))", gap: "0.75rem" }}> <div className="grid grid-cols-[repeat(auto-fill,minmax(160px,1fr))] gap-3">
{files.map((f) => ( {files.map((f) => (
<div key={f.name} style={{ border: "1px solid color-mix(in srgb, var(--color-text-muted) 14%, transparent)", borderRadius: 10, overflow: "hidden", background: "var(--color-background)" }}> <div key={f.name} className="border border-[var(--color-text-muted)]/14 rounded-[10px] overflow-hidden bg-[var(--color-background)]">
{/* eslint-disable-next-line @next/next/no-img-element */} {/* eslint-disable-next-line @next/next/no-img-element */}
<img src={f.url} alt={f.name} style={{ width: "100%", height: 120, objectFit: "cover", display: "block" }} /> <img src={f.url} alt={f.name} className="w-full h-[120px] object-cover block" />
<div style={{ padding: "0.4rem" }}> <div className="p-2">
<p style={{ fontSize: "0.72rem", margin: "0 0 0.3rem", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap", color: "var(--color-text-muted)" }}> <p className="text-xs text-[var(--color-text-muted)] m-0 mb-1 overflow-hidden text-ellipsis whitespace-nowrap">
{f.name} {f.name}
</p> </p>
<button type="button" onClick={() => copyUrl(f.url)} className="btn btn-outline" style={{ fontSize: "0.72rem", padding: "0.2rem 0.5rem", width: "100%" }}> <button type="button" onClick={() => copyUrl(f.url)} className="btn btn-outline text-xs p-1 w-full">
Copy URL Copy URL
</button> </button>
</div> </div>
File diff suppressed because it is too large. Load diff