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 (
<main>
<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
<code> emulator_settings</code> and <code> emulator_texts</code>. Changes take effect
on the next emulator read (some keys require an emulator reload).
@@ -48,7 +48,7 @@ export default async function AdminEmulator() {
<section className="mt-6">
<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.
</p>
@@ -74,7 +74,7 @@ export default async function AdminEmulator() {
<table>
<thead>
<tr>
<th style={{ width: 320 }}>Key</th>
<th className="w-[320px]">Key</th>
<th>Value</th>
</tr>
</thead>
@@ -87,14 +87,14 @@ export default async function AdminEmulator() {
<td>
<form
action={updateEmulatorSetting}
style={{ display: "flex", gap: "0.5rem" }}
className="flex gap-2"
>
<input type="hidden" name="key" value={s.key} />
<input
name="value"
defaultValue={s.value}
maxLength={512}
style={{ flex: 1 }}
className="flex-1"
/>
<button type="submit" className="btn btn-outline">
Save
@@ -106,13 +106,13 @@ export default async function AdminEmulator() {
</tbody>
</table>
{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}
</section>
<section className="mt-6">
<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})` : ""}.
</p>
+4 -10
View File
@@ -38,14 +38,14 @@ export function EmbedSnippet({
return (
<section className="mt-6">
<div style={{ display: 'flex', alignItems: 'center', gap: '1rem' }}>
<h2 style={{ flex: 1, margin: 0 }}>{title}</h2>
<div className="flex items-center justify-between gap-4 mb-1">
<h2 className="m-0 text-base font-bold text-[var(--color-text)]">{title}</h2>
<button type="button" className="btn btn-primary" onClick={copy}>
{copied ? 'Copied!' : 'Copy'}
</button>
</div>
{description ? (
<p className="muted" style={{ marginTop: '0.4rem' }}>
<p className="text-xs text-gray-500 dark:text-gray-400 mb-3">
{description}
</p>
) : null}
@@ -56,13 +56,7 @@ export function EmbedSnippet({
rows={Math.min(Math.max(lines + 1, 3), 14)}
onFocus={(e) => e.currentTarget.select()}
spellCheck={false}
style={{
width: '100%',
fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Consolas, monospace',
fontSize: '0.85rem',
whiteSpace: 'pre',
resize: 'vertical',
}}
className="w-full font-mono text-sm resize-vertical"
/>
</section>
);
+8 -8
View File
@@ -108,7 +108,7 @@ export default async function AdminRadioPage() {
return (
<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/settings">Settings</Link> ·{' '}
<Link href="/admin/radio/banners">Banners</Link> ·{' '}
@@ -117,7 +117,7 @@ export default async function AdminRadioPage() {
</nav>
<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).
</p>
@@ -141,7 +141,7 @@ export default async function AdminRadioPage() {
<section className="mt-6">
<h2>Recent Shouts ({shouts.length})</h2>
{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>
<thead>
@@ -156,10 +156,10 @@ export default async function AdminRadioPage() {
<tbody>
{shouts.map((s) => (
<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>{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>
<form action={deleteShout}>
<input type="hidden" name="id" value={String(s.id)} />
@@ -178,7 +178,7 @@ export default async function AdminRadioPage() {
<section className="mt-6">
<h2>DJ Applications ({applications.length})</h2>
{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>
<thead>
@@ -207,7 +207,7 @@ export default async function AdminRadioPage() {
{a.status}
</span>
</td>
<td className="muted">{formatDate(a.createdAt)}</td>
<td className="text-sm text-gray-500 dark:text-gray-400">{formatDate(a.createdAt)}</td>
</tr>
))}
</tbody>
@@ -218,7 +218,7 @@ export default async function AdminRadioPage() {
<section className="mt-6">
<h2>Schedules ({schedules.length})</h2>
{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>
<thead>
+1 -1
View File
@@ -54,7 +54,7 @@ export default async function AdminSettings() {
) : (
<div className="grid">
{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" }}>
<strong style={{ minWidth: 220 }}>{s.key}</strong>
<form
+39 -26
View File
@@ -2,6 +2,7 @@ import Link from "next/link";
import { notFound } from "next/navigation";
import { deleteShopArticle, updateShopArticle } from "@/actions/admin-shop";
import { prisma } from "@/lib/prisma";
import { Package } from "lucide-react";
export const dynamic = "force-dynamic";
@@ -21,32 +22,44 @@ export default async function EditShopArticle({
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.iconUrl} placeholder="Icon URL" 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>
<div className="flex items-center gap-3 mb-6">
<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">
<Package size={20} className="text-[var(--color-primary)]" />
</div>
<div>
<h1 className="m-0 text-xl font-extrabold text-[var(--color-text)]">Edit shop package</h1>
<Link href="/admin/shop" className="text-xs text-[var(--color-text-muted)] hover:text-[var(--color-primary)]">
← Back to shop
</Link>
</div>
</div>
<form action={updateShopArticle} className="admin-card">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<input type="hidden" name="id" value={String(pkg.id)} />
<input name="name" defaultValue={pkg.name} placeholder="Name" required maxLength={255} className="w-full" />
<input name="info" defaultValue={pkg.info} placeholder="Info / description" maxLength={255} className="w-full" />
<input name="icon" defaultValue={pkg.iconUrl} placeholder="Icon URL" maxLength={255} className="w-full" />
<input name="color" defaultValue={pkg.color} placeholder="Color (e.g. #ffcc00)" maxLength={255} className="w-full" />
<input name="costs" type="number" min={0} defaultValue={pkg.costs} placeholder="Costs (store price)" required className="w-full" />
<input name="credits" type="number" min={0} defaultValue={pkg.credits ?? undefined} placeholder="Credits given (optional)" className="w-full" />
<input name="duckets" type="number" min={0} defaultValue={pkg.duckets ?? undefined} placeholder="Duckets given (optional)" className="w-full" />
<input name="diamonds" type="number" min={0} defaultValue={pkg.diamonds ?? undefined} placeholder="Diamonds given (optional)" className="w-full" />
<input name="giveRank" type="number" min={0} defaultValue={pkg.giveRank ?? undefined} placeholder="Give rank (optional)" className="w-full" />
<input name="badges" defaultValue={pkg.badges ?? ""} placeholder="Badge codes (optional)" maxLength={255} className="w-full" />
<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>
</main>
);
+32 -19
View File
@@ -1,28 +1,41 @@
import Link from "next/link";
import { createShopArticle } from "@/actions/admin-shop";
import { Package } from "lucide-react";
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>
<div className="flex items-center gap-3 mb-6">
<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">
<Package size={20} className="text-[var(--color-primary)]" />
</div>
<div>
<h1 className="m-0 text-xl font-extrabold text-[var(--color-text)]">New shop package</h1>
<Link href="/admin/shop" className="text-xs text-[var(--color-text-muted)] hover:text-[var(--color-primary)]">
← Back to shop
</Link>
</div>
</div>
<form action={createShopArticle} className="admin-card">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<input name="name" placeholder="Name" required maxLength={255} className="w-full" />
<input name="info" placeholder="Info / description" maxLength={255} className="w-full" />
<input name="icon" placeholder="Icon" maxLength={255} className="w-full" />
<input name="color" placeholder="Color (e.g. #ffcc00)" maxLength={255} className="w-full" />
<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>
</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>
<form
className="card"
style={{ display: "flex", gap: "0.5rem", marginBottom: "1.5rem", flexWrap: "wrap" }}
>
<input
name="q"
defaultValue={q}
placeholder="Search username"
style={{ flex: 1, minWidth: 200 }}
/>
<button type="submit" className="btn btn-primary">
Search
</button>
</form>
<form
className="admin-card mb-6"
style={{ display: "flex", gap: "0.5rem", marginBottom: "1.5rem", flexWrap: "wrap" }}
>
<input
name="q"
defaultValue={q}
placeholder="Search username"
style={{ flex: 1, minWidth: 200 }}
/>
<button type="submit" className="btn btn-primary">
Search
</button>
</form>
<section className="mt-6">
<h2>Accounts ({total})</h2>
+47 -20
View File
@@ -9,6 +9,7 @@ export function AdminMediaGrid() {
const [files, setFiles] = useState<MediaFile[]>([]);
const [loading, setLoading] = useState(true);
const [uploading, setUploading] = useState(false);
const [progress, setProgress] = useState({ current: 0, total: 0 });
const fileRef = useRef<HTMLInputElement>(null);
const load = useCallback(async () => {
@@ -24,16 +25,27 @@ export function AdminMediaGrid() {
useEffect(() => { load(); }, [load]);
async function handleUpload(e: React.ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0];
if (!file) return;
const files = e.target.files;
if (!files || files.length === 0) return;
setUploading(true);
const fd = new FormData();
fd.set("file", file);
try {
await uploadMedia(fd);
await load();
} catch { /* ignore */ }
setProgress({ current: 0, total: files.length });
const fileArray = Array.from(files);
await Promise.all(fileArray.map(async (file) => {
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);
setProgress({ current: 0, total: 0 });
if (fileRef.current) fileRef.current.value = "";
}
@@ -42,30 +54,45 @@ export function AdminMediaGrid() {
}
return (
<div className="card" style={{ marginTop: "1rem" }}>
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", marginBottom: "0.75rem" }}>
<div className="admin-card mb-4">
<div className="flex items-center justify-between mb-3">
<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"}
<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>
</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 ? (
<p className="muted">Loading…</p>
<p className="text-xs text-gray-500 dark:text-gray-400">Loading…</p>
) : 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) => (
<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 */}
<img src={f.url} alt={f.name} style={{ width: "100%", height: 120, objectFit: "cover", display: "block" }} />
<div style={{ padding: "0.4rem" }}>
<p style={{ fontSize: "0.72rem", margin: "0 0 0.3rem", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap", color: "var(--color-text-muted)" }}>
<img src={f.url} alt={f.name} className="w-full h-[120px] object-cover block" />
<div className="p-2">
<p className="text-xs text-[var(--color-text-muted)] m-0 mb-1 overflow-hidden text-ellipsis whitespace-nowrap">
{f.name}
</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
</button>
</div>
File diff suppressed because it is too large. Load diff