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

+36 -35
View File
@@ -1,6 +1,7 @@
import { giveBadge } from "@/actions/admin-badges"; import { giveBadge } from "@/actions/admin-badges";
import { uploadBadge } from "@/actions/admin-badge-upload"; import { uploadBadge } from "@/actions/admin-badge-upload";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
import { Award } from "lucide-react";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
@@ -25,34 +26,34 @@ export default async function AdminBadges({
return ( return (
<main> <main>
<section className="mt-6"> <div className="flex items-center gap-3 mb-6">
<h2>Badges</h2> <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 className="muted"> <Award size={20} className="text-[var(--color-primary)]" />
Website badge catalogue (<code>website_badges</code>). Granting fires the emulator </div>
<code> givebadge</code> RCON command and records the badge in <code>users_badges</code>. <div>
</p> <h1 className="m-0 text-xl font-extrabold text-[var(--color-text)]">Badges</h1>
<p className="m-0 text-xs text-[var(--color-text-muted)] mt-0.5">Website badge catalogue.</p>
</div>
</div>
{uploaded ? ( {uploaded && (
<p> <div className="mb-4 p-3 rounded-lg bg-green-600/10 text-green-600 dark:text-green-400 text-xs">
<span className="inline-block text-[0.72rem] font-bold px-2 py-0.5 rounded-full bg-green-600/20 text-[var(--color-text)]">Uploaded</span> Badge image{" "} Uploaded Badge image <code>{uploaded}.gif</code> saved.
<code>{uploaded}.gif</code> saved. </div>
</p> )}
) : null} {error && (
{error ? ( <div className="mb-4 p-3 rounded-lg bg-red-600/10 text-red-600 dark:text-red-400 text-xs">
<p> Error: {error}
<span className="inline-block text-[0.72rem] font-bold px-2 py-0.5 rounded-full bg-red-500/22 text-[var(--color-text)]">Error</span> {error} </div>
</p> )}
) : null}
</section>
<section className="mt-6"> <section className="mt-6">
<h2>Upload badge image</h2> <h2 className="admin-section-title">Upload badge image</h2>
<p className="muted" style={{ marginTop: "-0.4rem" }}> <p className="text-xs text-[var(--color-text-muted)] -mt-2 mb-3">
Saves a <code>.gif</code> badge image to the emulator badge directory Saves a <code>.gif</code> badge image to the emulator badge directory.
(<code>BADGE_UPLOAD_DIR</code>). The file is stored as <code>&lt;code&gt;.gif</code>.
</p> </p>
<form action={uploadBadge} className="card"> <form action={uploadBadge} className="admin-card">
<div style={{ display: "flex", gap: "0.5rem", flexWrap: "wrap", alignItems: "center" }}> <div className="flex gap-2 flex-wrap items-center">
<input type="file" name="file" accept=".gif,.png" required /> <input type="file" name="file" accept=".gif,.png" required />
<input <input
name="code" name="code"
@@ -60,7 +61,7 @@ export default async function AdminBadges({
required required
maxLength={64} maxLength={64}
pattern="[A-Za-z0-9_-]{1,64}" pattern="[A-Za-z0-9_-]{1,64}"
style={{ flex: 1, minWidth: 160 }} className="flex-1 min-w-[160px]"
/> />
<button type="submit" className="btn btn-primary"> <button type="submit" className="btn btn-primary">
Upload Upload
@@ -70,16 +71,16 @@ export default async function AdminBadges({
</section> </section>
<section className="mt-6"> <section className="mt-6">
<h2>Give badge to user</h2> <h2 className="admin-section-title">Give badge to user</h2>
<form action={giveBadge} className="card"> <form action={giveBadge} className="admin-card">
<div style={{ display: "flex", gap: "0.5rem", flexWrap: "wrap", alignItems: "center" }}> <div className="flex gap-2 flex-wrap items-center">
<input name="userId" type="number" min={1} placeholder="User ID" required /> <input name="userId" type="number" min={1} placeholder="User ID" required />
<input <input
name="code" name="code"
placeholder="Badge code (e.g. ADM)" placeholder="Badge code (e.g. ADM)"
required required
maxLength={32} maxLength={32}
style={{ flex: 1, minWidth: 160 }} className="flex-1 min-w-[160px]"
list="badge-codes" list="badge-codes"
/> />
<datalist id="badge-codes"> <datalist id="badge-codes">
@@ -97,11 +98,11 @@ export default async function AdminBadges({
</section> </section>
<section className="mt-6"> <section className="mt-6">
<h2>Catalogue ({badges.length})</h2> <h2 className="admin-section-title">Catalogue ({badges.length})</h2>
{badges.length === 0 ? ( {badges.length === 0 ? (
<p className="muted">No badges in the catalogue.</p> <div className="admin-empty">No badges in the catalogue.</div>
) : ( ) : (
<div style={{ overflowX: "auto" }}> <div className="overflow-x-auto">
<table> <table>
<thead> <thead>
<tr> <tr>
@@ -118,11 +119,11 @@ export default async function AdminBadges({
<code>{b.badgeKey}</code> <code>{b.badgeKey}</code>
</td> </td>
<td>{b.badgeName}</td> <td>{b.badgeName}</td>
<td className="muted">{b.badgeDescription}</td> <td className="text-sm text-gray-500 dark:text-gray-400">{b.badgeDescription}</td>
<td> <td>
<form <form
action={giveBadge} action={giveBadge}
style={{ display: "flex", gap: "0.4rem", alignItems: "center" }} className="flex gap-2 items-center"
> >
<input type="hidden" name="code" value={b.badgeKey} /> <input type="hidden" name="code" value={b.badgeKey} />
<input <input
@@ -131,7 +132,7 @@ export default async function AdminBadges({
min={1} min={1}
placeholder="User ID" placeholder="User ID"
required required
style={{ width: 90 }} className="w-[90px]"
/> />
<button type="submit" className="btn btn-outline"> <button type="submit" className="btn btn-outline">
Give Give
+160 -140
View File
@@ -1,4 +1,5 @@
import os from "node:os"; import os from "node:os";
import { Activity } from 'lucide-react';
import { import {
hotelAlert, hotelAlert,
updateCatalog, updateCatalog,
@@ -85,10 +86,15 @@ export default async function CommandoCentrum() {
return ( return (
<main> <main>
<h1>Commandocentrum</h1> <div className="flex items-center gap-3 mb-6">
<p className="muted" style={{ marginTop: "-0.25rem" }}> <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">
Live hotel status, diagnostics and emulator control over RCON. <Activity size={20} className="text-[var(--color-primary)]" />
</p> </div>
<div>
<h1 className="m-0 text-xl font-extrabold text-[var(--color-text)]">Commandocentrum</h1>
<p className="m-0 text-xs text-[var(--color-text-muted)] mt-0.5">Live hotel status, diagnostics and emulator control over RCON.</p>
</div>
</div>
{/* ── Live status ────────────────────────────────────────── */} {/* ── Live status ────────────────────────────────────────── */}
<div className="grid grid-cols-[repeat(auto-fit,minmax(180px,1fr))] gap-3.5 mb-6" style={{ marginTop: "1.1rem" }}> <div className="grid grid-cols-[repeat(auto-fit,minmax(180px,1fr))] gap-3.5 mb-6" style={{ marginTop: "1.1rem" }}>
@@ -123,8 +129,8 @@ export default async function CommandoCentrum() {
</div> </div>
{/* ── Online users widget ───────────────────────────────── */} {/* ── Online users widget ───────────────────────────────── */}
<section className="card" style={{ marginBottom: "1.5rem" }}> <section className="admin-card mb-6">
<h2 style={{ marginTop: 0 }}>Online Users</h2> <h2 className="admin-section-title">Online Users</h2>
<OnlineUsersWidget <OnlineUsersWidget
users={[]} users={[]}
isLoading={false} isLoading={false}
@@ -133,8 +139,8 @@ export default async function CommandoCentrum() {
{/* ── Server info + diagnostics ──────────────────────────── */} {/* ── Server info + diagnostics ──────────────────────────── */}
<div className="grid cols-2"> <div className="grid cols-2">
<section className="card"> <section className="admin-card p-5">
<h2 style={{ marginTop: 0 }}>Server info</h2> <h2 className="admin-section-title">Server info</h2>
<div className="grid grid-cols-[repeat(auto-fit,minmax(150px,1fr))] gap-2.5"> <div className="grid grid-cols-[repeat(auto-fit,minmax(150px,1fr))] gap-2.5">
<InfoItem label="Node" value={process.version} /> <InfoItem label="Node" value={process.version} />
<InfoItem label="Platform" value={`${process.platform} ${process.arch}`} /> <InfoItem label="Platform" value={`${process.platform} ${process.arch}`} />
@@ -147,8 +153,8 @@ export default async function CommandoCentrum() {
</div> </div>
</section> </section>
<section className="card"> <section className="admin-card p-5">
<h2 style={{ marginTop: 0 }}>System diagnostics</h2> <h2 className="admin-section-title">System diagnostics</h2>
<div className="flex flex-col"> <div className="flex flex-col">
<DiagnosticRow <DiagnosticRow
label="Database" label="Database"
@@ -184,13 +190,12 @@ export default async function CommandoCentrum() {
</section> </section>
</div> </div>
{/* ── RCON controls ──────────────────────────────────────── */}
<section className="mt-6"> <section className="mt-6">
<h2>Emulator controls</h2> <h2 className="admin-section-title">Emulator controls</h2>
<div className="grid cols-3"> <div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
<form action={updateCatalog} className="card"> <form action={updateCatalog} className="admin-card">
<h3 style={{ marginTop: 0 }}>Update catalog</h3> <h3 className="text-sm font-bold text-[var(--color-text)] mb-2">Update catalog</h3>
<p className="muted" style={{ margin: "0 0 0.85rem" }}> <p className="text-xs text-[var(--color-text-muted)] mb-4">
Reload catalog pages and items on the live server. Reload catalog pages and items on the live server.
</p> </p>
<button type="submit" className="btn btn-primary"> <button type="submit" className="btn btn-primary">
@@ -198,9 +203,9 @@ export default async function CommandoCentrum() {
</button> </button>
</form> </form>
<form action={updateWordFilter} className="card"> <form action={updateWordFilter} className="admin-card">
<h3 style={{ marginTop: 0 }}>Update word filter</h3> <h3 className="text-sm font-bold text-[var(--color-text)] mb-2">Update word filter</h3>
<p className="muted" style={{ margin: "0 0 0.85rem" }}> <p className="text-xs text-[var(--color-text-muted)] mb-4">
Reload the chat word filter from the database. Reload the chat word filter from the database.
</p> </p>
<button type="submit" className="btn btn-primary"> <button type="submit" className="btn btn-primary">
@@ -208,9 +213,9 @@ export default async function CommandoCentrum() {
</button> </button>
</form> </form>
<form action={updateNavigator} className="card"> <form action={updateNavigator} className="admin-card">
<h3 style={{ marginTop: 0 }}>Update navigator</h3> <h3 className="text-sm font-bold text-[var(--color-text)] mb-2">Update navigator</h3>
<p className="muted" style={{ margin: "0 0 0.85rem" }}> <p className="text-xs text-[var(--color-text-muted)] mb-4">
Reload navigator categories and room listings. Reload navigator categories and room listings.
</p> </p>
<button type="submit" className="btn btn-primary"> <button type="submit" className="btn btn-primary">
@@ -219,18 +224,18 @@ export default async function CommandoCentrum() {
</form> </form>
</div> </div>
<form action={hotelAlert} className="card" style={{ marginTop: "1rem" }}> <form action={hotelAlert} className="admin-card mt-6">
<h3 style={{ marginTop: 0 }}>Hotel alert</h3> <h3 className="text-sm font-bold text-[var(--color-text)] mb-2">Hotel alert</h3>
<p className="muted" style={{ margin: "0 0 0.75rem" }}> <p className="text-xs text-[var(--color-text-muted)] mb-3">
Broadcast a message to every connected user. Broadcast a message to every connected user.
</p> </p>
<div style={{ display: "flex", gap: "0.5rem", flexWrap: "wrap" }}> <div className="flex gap-2">
<input <input
name="message" name="message"
required required
maxLength={512} maxLength={512}
placeholder="Message to broadcast…" placeholder="Message to broadcast…"
style={{ flex: 1, minWidth: 220 }} className="flex-1 min-w-[220px]"
/> />
<button type="submit" className="btn btn-danger"> <button type="submit" className="btn btn-danger">
Send alert Send alert
@@ -241,149 +246,173 @@ export default async function CommandoCentrum() {
{/* ── User RCON actions ──────────────────────────────────── */} {/* ── User RCON actions ──────────────────────────────────── */}
<section className="mt-6"> <section className="mt-6">
<h2>User actions (RCON)</h2> <h2 className="admin-section-title">User actions (RCON)</h2>
<p className="muted" style={{ marginTop: "-0.4rem" }}> <p className="text-xs text-[var(--color-text-muted)] mb-4">
Enter a User ID to perform actions on a specific user. Username required for disconnect. Enter a User ID to perform actions on a specific user. Username required for disconnect.
</p> </p>
<div className="grid cols-3"> <div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
<form action={disconnectUser} className="card"> <form action={disconnectUser} className="admin-card">
<h3 style={{ marginTop: 0 }}>Disconnect / Kick</h3> <h3 className="text-sm font-bold text-[var(--color-text)] mb-2">Disconnect / Kick</h3>
<p className="muted" style={{ margin: "0 0 0.75rem" }}>Force disconnect a user.</p> <p className="text-xs text-[var(--color-text-muted)] mb-3">Force disconnect a user.</p>
<input name="userId" type="number" min={1} placeholder="User ID" required style={{ width: "100%", marginBottom: "0.5rem" }} /> <div className="space-y-2">
<input name="username" type="text" placeholder="Username (required)" required style={{ width: "100%", marginBottom: "0.75rem" }} /> <input name="userId" type="number" min={1} placeholder="User ID" required className="w-full" />
<button type="submit" className="btn btn-danger">Disconnect user</button> <input name="username" type="text" placeholder="Username (required)" required className="w-full" />
<button type="submit" className="btn btn-danger w-full">Disconnect user</button>
</div>
</form> </form>
<form action={alertUser} className="card"> <form action={alertUser} className="admin-card">
<h3 style={{ marginTop: 0 }}>Alert user</h3> <h3 className="text-sm font-bold text-[var(--color-text)] mb-2">Alert user</h3>
<p className="muted" style={{ margin: "0 0 0.75rem" }}>Send a popup alert to a user.</p> <p className="text-xs text-[var(--color-text-muted)] mb-3">Send a popup alert to a user.</p>
<input name="userId" type="number" min={1} placeholder="User ID" required style={{ width: "100%", marginBottom: "0.5rem" }} /> <div className="space-y-2">
<input name="message" type="text" placeholder="Alert message" required maxLength={512} style={{ width: "100%", marginBottom: "0.75rem" }} /> <input name="userId" type="number" min={1} placeholder="User ID" required className="w-full" />
<button type="submit" className="btn btn-primary">Send alert</button> <input name="message" type="text" placeholder="Alert message" required maxLength={512} className="w-full" />
<button type="submit" className="btn btn-primary w-full">Send alert</button>
</div>
</form> </form>
<form action={forwardUser} className="card"> <form action={forwardUser} className="admin-card">
<h3 style={{ marginTop: 0 }}>Forward to room</h3> <h3 className="text-sm font-bold text-[var(--color-text)] mb-2">Forward to room</h3>
<p className="muted" style={{ margin: "0 0 0.75rem" }}>Teleport user to a room ID.</p> <p className="text-xs text-[var(--color-text-muted)] mb-3">Teleport user to a room ID.</p>
<input name="userId" type="number" min={1} placeholder="User ID" required style={{ width: "100%", marginBottom: "0.5rem" }} /> <div className="space-y-2">
<input name="roomId" type="number" min={1} placeholder="Room ID" required style={{ width: "100%", marginBottom: "0.75rem" }} /> <input name="userId" type="number" min={1} placeholder="User ID" required className="w-full" />
<button type="submit" className="btn btn-primary">Forward user</button> <input name="roomId" type="number" min={1} placeholder="Room ID" required className="w-full" />
<button type="submit" className="btn btn-primary w-full">Forward user</button>
</div>
</form> </form>
</div> </div>
<div className="grid cols-3" style={{ marginTop: "1rem" }}> <div className="grid grid-cols-1 sm:grid-cols-3 gap-4 mt-4">
<form action={giveCredits} className="card"> <form action={giveCredits} className="admin-card">
<h3 style={{ marginTop: 0 }}>Give credits</h3> <h3 className="text-sm font-bold text-[var(--color-text)] mb-2">Give credits</h3>
<p className="muted" style={{ margin: "0 0 0.75rem" }}>Add credits to a user.</p> <p className="text-xs text-[var(--color-text-muted)] mb-3">Add credits to a user.</p>
<input name="userId" type="number" min={1} placeholder="User ID" required style={{ width: "100%", marginBottom: "0.5rem" }} /> <div className="space-y-2">
<input name="credits" type="number" min={1} placeholder="Amount" required style={{ width: "100%", marginBottom: "0.75rem" }} /> <input name="userId" type="number" min={1} placeholder="User ID" required className="w-full" />
<button type="submit" className="btn btn-primary">Give credits</button> <input name="credits" type="number" min={1} placeholder="Amount" required className="w-full" />
<button type="submit" className="btn btn-primary w-full">Give credits</button>
</div>
</form> </form>
<form action={giveDuckets} className="card"> <form action={giveDuckets} className="admin-card">
<h3 style={{ marginTop: 0 }}>Give duckets</h3> <h3 className="text-sm font-bold text-[var(--color-text)] mb-2">Give duckets</h3>
<p className="muted" style={{ margin: "0 0 0.75rem" }}>Add duckets to a user.</p> <p className="text-xs text-[var(--color-text-muted)] mb-3">Add duckets to a user.</p>
<input name="userId" type="number" min={1} placeholder="User ID" required style={{ width: "100%", marginBottom: "0.5rem" }} /> <div className="space-y-2">
<input name="amount" type="number" min={1} placeholder="Amount" required style={{ width: "100%", marginBottom: "0.75rem" }} /> <input name="userId" type="number" min={1} placeholder="User ID" required className="w-full" />
<button type="submit" className="btn btn-primary">Give duckets</button> <input name="amount" type="number" min={1} placeholder="Amount" required className="w-full" />
<button type="submit" className="btn btn-primary w-full">Give duckets</button>
</div>
</form> </form>
<form action={giveDiamonds} className="card"> <form action={giveDiamonds} className="admin-card">
<h3 style={{ marginTop: 0 }}>Give diamonds</h3> <h3 className="text-sm font-bold text-[var(--color-text)] mb-2">Give diamonds</h3>
<p className="muted" style={{ margin: "0 0 0.75rem" }}>Add diamonds to a user.</p> <p className="text-xs text-[var(--color-text-muted)] mb-3">Add diamonds to a user.</p>
<input name="userId" type="number" min={1} placeholder="User ID" required style={{ width: "100%", marginBottom: "0.5rem" }} /> <div className="space-y-2">
<input name="amount" type="number" min={1} placeholder="Amount" required style={{ width: "100%", marginBottom: "0.75rem" }} /> <input name="userId" type="number" min={1} placeholder="User ID" required className="w-full" />
<button type="submit" className="btn btn-primary">Give diamonds</button> <input name="amount" type="number" min={1} placeholder="Amount" required className="w-full" />
<button type="submit" className="btn btn-primary w-full">Give diamonds</button>
</div>
</form> </form>
</div> </div>
<div className="grid cols-3" style={{ marginTop: "1rem" }}> <div className="grid grid-cols-1 sm:grid-cols-3 gap-4 mt-4">
<form action={giveBadge} className="card"> <form action={giveBadge} className="admin-card">
<h3 style={{ marginTop: 0 }}>Give badge</h3> <h3 className="text-sm font-bold text-[var(--color-text)] mb-2">Give badge</h3>
<p className="muted" style={{ margin: "0 0 0.75rem" }}>Give a badge code (e.g. HOF1).</p> <p className="text-xs text-[var(--color-text-muted)] mb-3">Give a badge code (e.g. HOF1).</p>
<input name="userId" type="number" min={1} placeholder="User ID" required style={{ width: "100%", marginBottom: "0.5rem" }} /> <div className="space-y-2">
<input name="badge" type="text" placeholder="Badge code" required maxLength={32} style={{ width: "100%", marginBottom: "0.75rem" }} /> <input name="userId" type="number" min={1} placeholder="User ID" required className="w-full" />
<button type="submit" className="btn btn-primary">Give badge</button> <input name="badge" type="text" placeholder="Badge code" required maxLength={32} className="w-full" />
<button type="submit" className="btn btn-primary w-full">Give badge</button>
</div>
</form> </form>
<form action={setMotto} className="card"> <form action={setMotto} className="admin-card">
<h3 style={{ marginTop: 0 }}>Set motto</h3> <h3 className="text-sm font-bold text-[var(--color-text)] mb-2">Set motto</h3>
<p className="muted" style={{ margin: "0 0 0.75rem" }}>Change user's motto (max 127 chars).</p> <p className="text-xs text-[var(--color-text-muted)] mb-3">Change user's motto (max 127 chars).</p>
<input name="userId" type="number" min={1} placeholder="User ID" required style={{ width: "100%", marginBottom: "0.5rem" }} /> <div className="space-y-2">
<input name="motto" type="text" placeholder="New motto" required maxLength={127} style={{ width: "100%", marginBottom: "0.75rem" }} /> <input name="userId" type="number" min={1} placeholder="User ID" required className="w-full" />
<button type="submit" className="btn btn-primary">Set motto</button> <input name="motto" type="text" placeholder="New motto" required maxLength={127} className="w-full" />
<button type="submit" className="btn btn-primary w-full">Set motto</button>
</div>
</form> </form>
<form action={setRank} className="card"> <form action={setRank} className="admin-card">
<h3 style={{ marginTop: 0 }}>Set rank</h3> <h3 className="text-sm font-bold text-[var(--color-text)] mb-2">Set rank</h3>
<p className="muted" style={{ margin: "0 0 0.75rem" }}>Change user rank (0–10).</p> <p className="text-xs text-[var(--color-text-muted)] mb-3">Change user rank (0–10).</p>
<input name="userId" type="number" min={1} placeholder="User ID" required style={{ width: "100%", marginBottom: "0.5rem" }} /> <div className="space-y-2">
<input name="rank" type="number" min={0} max={10} placeholder="Rank (0–10)" required style={{ width: "100%", marginBottom: "0.75rem" }} /> <input name="userId" type="number" min={1} placeholder="User ID" required className="w-full" />
<button type="submit" className="btn btn-primary">Set rank</button> <input name="rank" type="number" min={0} max={10} placeholder="Rank (0–10)" required className="w-full" />
<button type="submit" className="btn btn-primary w-full">Set rank</button>
</div>
</form> </form>
</div> </div>
<div className="grid cols-3" style={{ marginTop: "1rem" }}> <div className="grid grid-cols-1 sm:grid-cols-3 gap-4 mt-4">
<form action={executeCommand} className="card"> <form action={executeCommand} className="admin-card">
<h3 style={{ marginTop: 0 }}>Execute command</h3> <h3 className="text-sm font-bold text-[var(--color-text)] mb-2">Execute command</h3>
<p className="muted" style={{ margin: "0 0 0.75rem" }}>Run a chat command as the user.</p> <p className="text-xs text-[var(--color-text-muted)] mb-3">Run a chat command as the user.</p>
<input name="userId" type="number" min={1} placeholder="User ID" required style={{ width: "100%", marginBottom: "0.5rem" }} /> <div className="space-y-2">
<input name="command" type="text" placeholder="Command (e.g. :pickall)" required maxLength={100} style={{ width: "100%", marginBottom: "0.75rem" }} /> <input name="userId" type="number" min={1} placeholder="User ID" required className="w-full" />
<button type="submit" className="btn btn-primary">Execute</button> <input name="command" type="text" placeholder="Command (e.g. :pickall)" required maxLength={100} className="w-full" />
<button type="submit" className="btn btn-primary w-full">Execute</button>
</div>
</form> </form>
<form action={sendGift} className="card"> <form action={sendGift} className="admin-card">
<h3 style={{ marginTop: 0 }}>Send gift</h3> <h3 className="text-sm font-bold text-[var(--color-text)] mb-2">Send gift</h3>
<p className="muted" style={{ margin: "0 0 0.75rem" }}>Send a furniture item as gift.</p> <p className="text-xs text-[var(--color-text-muted)] mb-3">Send a furniture item as gift.</p>
<input name="userId" type="number" min={1} placeholder="User ID" required style={{ width: "100%", marginBottom: "0.5rem" }} /> <div className="space-y-2">
<input name="itemId" type="number" min={1} placeholder="Furni ID" required style={{ width: "100%", marginBottom: "0.5rem" }} /> <input name="userId" type="number" min={1} placeholder="User ID" required className="w-full" />
<input name="message" type="text" placeholder="Gift message" maxLength={255} defaultValue="Here is a gift." style={{ width: "100%", marginBottom: "0.75rem" }} /> <input name="itemId" type="number" min={1} placeholder="Furni ID" required className="w-full" />
<button type="submit" className="btn btn-primary">Send gift</button> <input name="message" type="text" placeholder="Gift message" maxLength={255} defaultValue="Here is a gift." className="w-full" />
<button type="submit" className="btn btn-primary w-full">Send gift</button>
</div>
</form> </form>
</div> </div>
</section> </section>
{/* ── Staff activity ─────────────────────────────────────── */} {/* ── Staff activity ─────────────────────────────────────── */}
<section className="mt-6"> <section className="mt-6">
<h2>Recent staff activity</h2> <h2 className="admin-section-title">Recent staff activity</h2>
{activity.length === 0 ? ( {activity.length === 0 ? (
<p className="muted">No staff activity logged yet.</p> <div className="admin-empty">No staff activity logged yet.</div>
) : ( ) : (
<table> <div className="admin-card overflow-x-auto">
<thead> <table>
<tr> <thead>
<th>When</th> <tr>
<th>Staff</th> <th>When</th>
<th>Action</th> <th>Staff</th>
<th>Description</th> <th>Action</th>
</tr> <th>Description</th>
</thead>
<tbody>
{activity.map((a) => (
<tr key={String(a.id)}>
<td className="muted" style={{ whiteSpace: "nowrap" }}>
{fromDate(a.createdAt)}
</td>
<td>{nameById.get(Number(a.userId)) ?? `#${a.userId}`}</td>
<td>
<span className="inline-block text-[0.72rem] font-bold px-2 py-0.5 rounded-full bg-[var(--color-primary)]/18 text-[var(--color-text)]">{a.action}</span>
</td>
<td>{a.description}</td>
</tr> </tr>
))} </thead>
</tbody> <tbody>
</table> {activity.map((a) => (
<tr key={String(a.id)}>
<td className="text-sm text-gray-500 dark:text-gray-400 whitespace-nowrap">
{fromDate(a.createdAt)}
</td>
<td>{nameById.get(Number(a.userId)) ?? `#${a.userId}`}</td>
<td>
<span className="inline-block text-[0.6rem] font-bold px-2 py-0.5 rounded-full bg-[var(--color-primary)]/18 text-[var(--color-text)]">{a.action}</span>
</td>
<td className="text-sm text-gray-500 dark:text-gray-400">{a.description}</td>
</tr>
))}
</tbody>
</table>
</div>
)} )}
</section> </section>
{/* ── Emulator errors ────────────────────────────────────── */} {/* ── Emulator errors ────────────────────────────────────── */}
<section className="mt-6"> <section className="mt-6">
<h2>Recent emulator errors</h2> <h2 className="admin-section-title">Recent emulator errors</h2>
{errors.length === 0 ? ( {errors.length === 0 ? (
<p className="muted">No emulator errors logged.</p> <div className="admin-empty">No emulator errors logged.</div>
) : ( ) : (
<div className="card" style={{ overflowX: "auto" }}> <div className="admin-card overflow-x-auto">
<table> <table>
<thead> <thead>
<tr> <tr>
@@ -398,24 +427,15 @@ export default async function CommandoCentrum() {
const trace = decodeStacktrace(e.stacktrace); const trace = decodeStacktrace(e.stacktrace);
return ( return (
<tr key={e.id}> <tr key={e.id}>
<td className="muted" style={{ whiteSpace: "nowrap" }}> <td className="text-sm text-gray-500 dark:text-gray-400 whitespace-nowrap">
{formatTimestamp(e.timestamp)} {formatTimestamp(e.timestamp)}
</td> </td>
<td>{e.type}</td> <td>{e.type}</td>
<td className="muted" style={{ whiteSpace: "nowrap" }}> <td className="text-sm text-gray-500 dark:text-gray-400 whitespace-nowrap">
{e.version} {e.version}
</td> </td>
<td> <td>
<pre <pre className="text-xs p-2 bg-gray-50 dark:bg-black/20 rounded overflow-auto max-h-[160px]">
style={{
margin: 0,
maxHeight: 160,
overflow: "auto",
fontSize: "0.75rem",
whiteSpace: "pre-wrap",
wordBreak: "break-word",
}}
>
{trace || "(empty)"} {trace || "(empty)"}
</pre> </pre>
</td> </td>
+35 -46
View File
@@ -17,37 +17,36 @@ export default async function AdminEmailTemplates() {
return ( return (
<main> <main>
<h1>Email Templates</h1> <div className="flex items-center gap-3 mb-6">
<p className="muted"> <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">
Transactional email bodies. Use <code>{"{{ placeholders }}"}</code> in the body and list <span className="text-lg">✉️</span>
the available ones in the variables field (comma-separated). </div>
</p> <div>
<h1 className="m-0 text-xl font-extrabold text-[var(--color-text)]">Email Templates</h1>
<p className="m-0 text-xs text-[var(--color-text-muted)] mt-0.5">
Transactional email bodies. Use <code>{"{{ placeholders }}"}</code> in the body and list
the available ones in the variables field (comma-separated).
</p>
</div>
</div>
<form <form
action={createEmailTemplate} action={createEmailTemplate}
className="card" className="admin-card mb-6"
style={{ marginBottom: "1.5rem" }}
> >
<h3 style={{ marginTop: 0 }}>New template</h3> <h3 className="text-sm font-bold text-[var(--color-text)] mb-2">New template</h3>
<div <div className="flex gap-2 flex-wrap items-center mb-3">
style={{ <input name="name" placeholder="name (unique)" required className="flex-1 min-w-[180px]" />
display: "flex",
gap: "0.5rem",
flexWrap: "wrap",
marginBottom: "0.5rem",
}}
>
<input name="name" placeholder="name (unique)" required style={{ minWidth: 180 }} />
<input <input
name="subject" name="subject"
placeholder="subject" placeholder="subject"
required required
style={{ flex: 1, minWidth: 220 }} className="flex-1 min-w-[220px]"
/> />
<input name="variables" placeholder="variables (comma-separated)" style={{ minWidth: 200 }} /> <input name="variables" placeholder="variables (comma-separated)" className="min-w-[200px]" />
<label style={{ display: "inline-flex", alignItems: "center", gap: "0.35rem" }}> <label className="flex items-center gap-2">
<input type="checkbox" name="isActive" defaultChecked style={{ width: "auto" }} /> <input type="checkbox" name="isActive" defaultChecked className="w-auto" />
<span className="muted">Active</span> <span className="text-sm text-gray-500 dark:text-gray-400">Active</span>
</label> </label>
</div> </div>
<textarea <textarea
@@ -55,7 +54,7 @@ export default async function AdminEmailTemplates() {
placeholder="Email body (HTML or text)" placeholder="Email body (HTML or text)"
required required
rows={5} rows={5}
style={{ width: "100%", resize: "vertical", marginBottom: "0.5rem" }} className="w-full resize-y mb-3"
/> />
<button type="submit" className="btn btn-primary"> <button type="submit" className="btn btn-primary">
Create Create
@@ -66,42 +65,32 @@ export default async function AdminEmailTemplates() {
{templates.map((t) => { {templates.map((t) => {
const id = String(t.id); const id = String(t.id);
return ( return (
<div key={id} className="card"> <div key={id} className="admin-card mb-4">
<form action={updateEmailTemplate}> <form action={updateEmailTemplate}>
<input type="hidden" name="id" value={id} /> <input type="hidden" name="id" value={id} />
<div <div className="flex gap-2 flex-wrap items-center mb-2">
style={{ <strong className="min-w-[160px]">{t.name}</strong>
display: "flex",
gap: "0.5rem",
flexWrap: "wrap",
alignItems: "center",
marginBottom: "0.5rem",
}}
>
<strong style={{ minWidth: 160 }}>{t.name}</strong>
<input <input
name="subject" name="subject"
defaultValue={t.subject} defaultValue={t.subject}
placeholder="subject" placeholder="subject"
required required
style={{ flex: 1, minWidth: 220 }} className="flex-1 min-w-[220px]"
/> />
<input <input
name="variables" name="variables"
defaultValue={t.variables ?? ""} defaultValue={t.variables ?? ""}
placeholder="variables" placeholder="variables"
style={{ minWidth: 180 }} className="min-w-[180px]"
/> />
<label <label className="flex items-center gap-2">
style={{ display: "inline-flex", alignItems: "center", gap: "0.35rem" }}
>
<input <input
type="checkbox" type="checkbox"
name="isActive" name="isActive"
defaultChecked={t.isActive} defaultChecked={t.isActive}
style={{ width: "auto" }} className="w-auto"
/> />
<span className="muted">Active</span> <span className="text-sm text-gray-500 dark:text-gray-400">Active</span>
</label> </label>
</div> </div>
<textarea <textarea
@@ -109,20 +98,20 @@ export default async function AdminEmailTemplates() {
defaultValue={t.body} defaultValue={t.body}
required required
rows={6} rows={6}
style={{ width: "100%", resize: "vertical", marginBottom: "0.5rem" }} className="w-full resize-y mb-2"
/> />
<div style={{ display: "flex", gap: "0.5rem", alignItems: "center" }}> <div className="flex gap-2 items-center">
<button type="submit" className="btn btn-primary"> <button type="submit" className="btn btn-primary">
Save Save
</button> </button>
{t.updatedAt ? ( {t.updatedAt ? (
<span className="muted"> <span className="text-xs text-gray-500 dark:text-gray-400">
Updated {new Date(t.updatedAt).toISOString().slice(0, 16).replace("T", " ")} Updated {new Date(t.updatedAt).toISOString().slice(0, 16).replace("T", " ")}
</span> </span>
) : null} ) : null}
</div> </div>
</form> </form>
<form action={deleteEmailTemplate} style={{ marginTop: "0.5rem" }}> <form action={deleteEmailTemplate} className="mt-4">
<input type="hidden" name="id" value={id} /> <input type="hidden" name="id" value={id} />
<button type="submit" className="btn btn-danger"> <button type="submit" className="btn btn-danger">
Delete Delete
@@ -132,7 +121,7 @@ export default async function AdminEmailTemplates() {
); );
})} })}
</div> </div>
{templates.length === 0 ? <p className="muted">No email templates yet.</p> : null} {templates.length === 0 ? <div className="admin-empty">No email templates yet.</div> : null}
</main> </main>
); );
} }
+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>
); );
+43 -33
View File
@@ -90,35 +90,41 @@ export default async function AdminTheme({
return ( return (
<main> <main>
<h1>Theme</h1> <div className="flex items-center gap-3 mb-6">
<p className="muted" style={{ marginTop: "-0.4rem" }}> <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">
Restyle the whole site. Values are written to <code>website_settings</code> and applied <span className="text-lg">🎨</span>
live (no rebuild). </div>
</p> <div>
<h1 className="m-0 text-xl font-extrabold text-[var(--color-text)]">Theme</h1>
<p className="m-0 text-xs text-[var(--color-text-muted)] mt-0.5">
Restyle the whole site. Values are written to <code>website_settings</code> and applied
live (no rebuild).
</p>
</div>
</div>
{sp.saved ? ( {sp.saved ? (
<p className="inline-block text-[0.72rem] font-bold px-2 py-0.5 rounded-full bg-green-600/20 text-[var(--color-text)]"> <div className="mb-4 p-3 rounded-lg bg-green-600/10 text-green-600 dark:text-green-400 text-xs">
Theme saved Theme saved
</p> </div>
) : null} ) : null}
{sp.preset ? ( {sp.preset ? (
<p className="inline-block text-[0.72rem] font-bold px-2 py-0.5 rounded-full bg-green-600/20 text-[var(--color-text)]"> <div className="mb-4 p-3 rounded-lg bg-green-600/10 text-green-600 dark:text-green-400 text-xs">
Applied preset “{sp.preset}” Applied preset “{sp.preset}”
</p> </div>
) : null} ) : null}
<section className="mt-6"> <section className="mt-6">
<h2>Presets</h2> <h2 className="admin-section-title">Presets</h2>
<div style={{ display: "flex", flexWrap: "wrap", gap: "0.5rem" }}> <div className="flex flex-wrap gap-2">
{Object.entries(PRESETS).map(([name, palette]) => ( {Object.entries(PRESETS).map(([name, palette]) => (
<form key={name} action={applyPreset}> <form key={name} action={applyPreset}>
<input type="hidden" name="preset" value={name} /> <input type="hidden" name="preset" value={name} />
<button <button
type="submit" type="submit"
className="btn btn-outline" className="btn btn-outline inline-flex items-center gap-2"
style={{ display: "inline-flex", alignItems: "center", gap: "0.5rem" }}
> >
<span style={{ display: "inline-flex" }}> <span className="flex items-center gap-1">
{["color_primary", "color_accent", "color_surface", "color_text"].map((k) => ( {["color_primary", "color_accent", "color_surface", "color_text"].map((k) => (
<span <span
key={k} key={k}
@@ -142,21 +148,25 @@ export default async function AdminTheme({
<form action={saveTheme}> <form action={saveTheme}>
<section className="mt-6"> <section className="mt-6">
<h2>Colours</h2> <h2 className="admin-section-title">Colours</h2>
<div className="card">{colorGrid(COLOR_FIELDS, current)}</div> <div className="admin-card">
{colorGrid(COLOR_FIELDS, current)}
</div>
</section> </section>
<section className="mt-6"> <section className="mt-6">
<h2>Buttons & links</h2> <h2 className="admin-section-title">Buttons & links</h2>
<div className="card">{colorGrid(BUTTON_FIELDS, current)}</div> <div className="admin-card">
{colorGrid(BUTTON_FIELDS, current)}
</div>
</section> </section>
<section className="mt-6"> <section className="mt-6">
<h2>Typography & layout</h2> <h2 className="admin-section-title">Typography & layout</h2>
<div className="card"> <div className="admin-card">
<div style={{ display: "flex", flexWrap: "wrap", gap: "1.25rem", alignItems: "center" }}> <div className="flex flex-wrap gap-5 items-center">
<label style={{ display: "flex", alignItems: "center", gap: "0.5rem" }}> <label className="flex items-center gap-2">
<span style={{ fontSize: "0.88rem" }}>Font</span> <span className="text-sm">Font</span>
<select name="font_family" defaultValue={fontKey}> <select name="font_family" defaultValue={fontKey}>
{Object.entries(FONTS).map(([key, f]) => ( {Object.entries(FONTS).map(([key, f]) => (
<option key={key} value={key}> <option key={key} value={key}>
@@ -166,27 +176,27 @@ export default async function AdminTheme({
</select> </select>
</label> </label>
{HEADINGS.map((h) => ( {HEADINGS.map((h) => (
<label key={h.key} style={{ display: "flex", alignItems: "center", gap: "0.4rem" }}> <label key={h.key} className="flex items-center gap-2">
<span style={{ fontSize: "0.88rem" }}>{h.label} (px)</span> <span className="text-sm">{h.label} (px)</span>
<input <input
name={h.key} name={h.key}
type="number" type="number"
min={10} min={10}
max={80} max={80}
defaultValue={/^\d{1,3}$/.test(headings[h.key]) ? headings[h.key] : h.def} defaultValue={/^\d{1,3}$/.test(headings[h.key]) ? headings[h.key] : h.def}
style={{ width: 80 }} className="w-20"
/> />
</label> </label>
))} ))}
<label style={{ display: "flex", alignItems: "center", gap: "0.4rem" }}> <label className="flex items-center gap-2">
<span style={{ fontSize: "0.88rem" }}>Border radius (px)</span> <span className="text-sm">Border radius (px)</span>
<input <input
name="border_radius" name="border_radius"
type="number" type="number"
min={0} min={0}
max={40} max={40}
defaultValue={/^\d{1,3}$/.test(radius) ? radius : "12"} defaultValue={/^\d{1,3}$/.test(radius) ? radius : "12"}
style={{ width: 80 }} className="w-20"
/> />
</label> </label>
</div> </div>
@@ -194,8 +204,8 @@ export default async function AdminTheme({
</section> </section>
<section className="mt-6"> <section className="mt-6">
<h2>Custom CSS</h2> <h2 className="admin-section-title">Custom CSS</h2>
<p className="muted" style={{ marginTop: "-0.4rem" }}> <p className="text-xs text-[var(--color-text-muted)] mb-2">
Injected raw into every page (after the theme variables). Use it for tweaks the controls Injected raw into every page (after the theme variables). Use it for tweaks the controls
above don’t cover. above don’t cover.
</p> </p>
@@ -205,9 +215,9 @@ export default async function AdminTheme({
rows={8} rows={8}
spellCheck={false} spellCheck={false}
placeholder={"/* e.g. */\n.nav-item { letter-spacing: 0.5px; }"} placeholder={"/* e.g. */\n.nav-item { letter-spacing: 0.5px; }"}
style={{ width: "100%", fontFamily: "monospace", fontSize: "0.85rem" }} className="w-full font-mono text-sm"
/> />
<div style={{ marginTop: "1rem", textAlign: "right" }}> <div className="mt-4 text-right">
<button type="submit" className="btn btn-primary"> <button type="submit" className="btn btn-primary">
Save theme Save theme
</button> </button>
+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>
+62 -71
View File
@@ -35,15 +35,19 @@ export function MediaPicker({
}, [open, load]); }, [open, 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(); const urls: string[] = [];
fd.set("file", file); for (const file of Array.from(files)) {
const url = await uploadMediaAndReturn(fd); const fd = new FormData();
if (url) { fd.set("file", file);
const url = await uploadMediaAndReturn(fd);
if (url) urls.push(url);
}
if (urls.length > 0) {
await load(); await load();
onSelect(url); onSelect(urls[urls.length - 1]); // select the last one for backward compatibility
setOpen(false); setOpen(false);
} }
setUploading(false); setUploading(false);
@@ -63,75 +67,62 @@ export function MediaPicker({
)} )}
</div> </div>
{open && ( {open && (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50"
onClick={() => setOpen(false)}
>
<div <div
style={{ onClick={(e) => e.stopPropagation()}
position: "fixed", inset: 0, zIndex: 9999, className="bg-[var(--color-surface)] text-[var(--color-text)] rounded-2xl w-[min(90vw,680px)] max-h-[80vh] flex flex-col shadow-xl"
display: "flex", alignItems: "center", justifyContent: "center",
backgroundColor: "rgba(0,0,0,0.5)",
}}
onClick={() => setOpen(false)}
> >
<div <div className="flex items-center justify-between px-4 py-3 border-b border-[var(--color-text-muted)]/14">
onClick={(e) => e.stopPropagation()} <strong className="text-base">Media Library</strong>
style={{ <div className="flex gap-2 items-center">
background: "var(--color-surface)", color: "var(--color-text)", <label className="btn btn-primary text-sm cursor-pointer">
borderRadius: 14, width: "min(90vw, 680px)", maxHeight: "80vh", {uploading ? "Uploading…" : "Upload"}
display: "flex", flexDirection: "column", boxShadow: "0 16px 48px rgba(0,0,0,0.25)", <input ref={fileRef} type="file" accept="image/png,image/jpeg,image/gif,image/webp" onChange={handleUpload} className="hidden" multiple />
}} </label>
> <button type="button" onClick={() => setOpen(false)} className="btn text-sm">
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", padding: "0.85rem 1rem", borderBottom: "1px solid color-mix(in srgb, var(--color-text-muted) 14%, transparent)" }}> Close
<strong style={{ fontSize: "1rem" }}>Media Library</strong> </button>
<div style={{ display: "flex", gap: "0.4rem", alignItems: "center" }}>
<label className="btn btn-primary" style={{ fontSize: "0.82rem", padding: "0.3rem 0.7rem", cursor: "pointer" }}>
{uploading ? "Uploading…" : "Upload"}
<input ref={fileRef} type="file" accept="image/png,image/jpeg,image/gif,image/webp" onChange={handleUpload} style={{ display: "none" }} />
</label>
<button type="button" onClick={() => setOpen(false)} className="btn" style={{ fontSize: "0.82rem", padding: "0.3rem 0.7rem" }}>
Close
</button>
</div>
</div>
<div style={{ flex: 1, overflow: "auto", padding: "0.75rem" }}>
{loading ? (
<p className="muted" style={{ textAlign: "center", padding: "2rem" }}>Loading…</p>
) : files.length === 0 ? (
<p className="muted" style={{ textAlign: "center", padding: "2rem" }}>
No images yet. Upload one above.
</p>
) : (
<div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fill, minmax(140px, 1fr))", gap: "0.65rem" }}>
{files.map((f) => (
<button
type="button"
key={f.name}
onClick={() => { onSelect(f.url); setOpen(false); }}
style={{
border: `2px solid ${f.url === current ? "var(--color-primary)" : "transparent"}`,
borderRadius: 10, overflow: "hidden", cursor: "pointer",
background: "var(--color-background)", padding: 0,
display: "flex", flexDirection: "column",
transition: "border-color 0.15s",
}}
onMouseEnter={(e) => { if (f.url !== current) e.currentTarget.style.borderColor = "color-mix(in srgb, var(--color-primary) 40%, transparent)"; }}
onMouseLeave={(e) => { if (f.url !== current) e.currentTarget.style.borderColor = "transparent"; }}
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={f.url}
alt={f.name}
style={{ width: "100%", height: 100, objectFit: "cover", display: "block" }}
/>
<span style={{ fontSize: "0.72rem", padding: "0.3rem 0.4rem", textAlign: "center", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
{f.name}
</span>
</button>
))}
</div>
)}
</div> </div>
</div> </div>
<div className="flex-1 overflow-auto p-3">
{loading ? (
<p className="text-xs text-[var(--color-text-muted)] text-center py-8">Loading…</p>
) : files.length === 0 ? (
<p className="text-xs text-[var(--color-text-muted)] text-center py-8">
No images yet. Upload one above.
</p>
) : (
<div className="grid grid-cols-[repeat(auto-fill,minmax(140px,1fr))] gap-2.5">
{files.map((f) => (
<button
type="button"
key={f.name}
onClick={() => { onSelect(f.url); setOpen(false); }}
className="border-2 border-transparent rounded-xl overflow-hidden cursor-pointer bg-[var(--color-background)] p-0 flex flex-col transition-all duration-150"
style={{ borderColor: f.url === current ? "var(--color-primary)" : "transparent" }}
onMouseEnter={(e) => { if (f.url !== current) e.currentTarget.style.borderColor = "color-mix(in srgb, var(--color-primary) 40%, transparent)"; }}
onMouseLeave={(e) => { if (f.url !== current) e.currentTarget.style.borderColor = "transparent"; }}
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={f.url}
alt={f.name}
className="w-full h-[100px] object-cover block"
/>
<span className="text-xs py-1 text-center overflow-hidden text-ellipsis whitespace-nowrap">
{f.name}
</span>
</button>
))}
</div>
)}
</div>
</div> </div>
</div>
)} )}
</> </>
); );