Translate admin panel to all 6 languages

This commit is contained in:
openhands committed 2026-07-04 21:21:15 +02:00
1 parent f381aa987f
commit befa4ec282
63 files changed
+9353 -1443

No files matched your search

+15 -14
View File
@@ -1,4 +1,5 @@
import { Award } from "lucide-react";
import { getTranslations } from "next-intl/server";
import { StatusCard } from "@/components/admin/dashboard";
import { prisma } from "@/lib/prisma";
@@ -15,6 +16,8 @@ type Achievement = {
};
export default async function AdminAchievements() {
const t = await getTranslations("pages.admin.achievements");
let achievements: Achievement[] = [];
try {
achievements = await prisma.achievements.findMany({
@@ -33,7 +36,6 @@ export default async function AdminAchievements() {
achievements = [];
}
// Group by category (already sorted by category, then name, then level).
const groups = new Map<string, Achievement[]>();
for (const a of achievements) {
const list = groups.get(a.category) ?? [];
@@ -41,7 +43,6 @@ export default async function AdminAchievements() {
groups.set(a.category, list);
}
// Distinct achievement names (each name can have multiple level rows).
const distinctNames = new Set(achievements.map((a) => a.name)).size;
return (
@@ -51,19 +52,19 @@ export default async function AdminAchievements() {
<Award size={20} className="text-[var(--color-primary)]" />
</div>
<div>
<h1 className="m-0 text-xl font-extrabold text-[var(--color-text)]">Achievements</h1>
<p className="m-0 text-xs text-[var(--color-text-muted)] mt-0.5">Read-only · {achievements.length} rows across {groups.size} categories.</p>
<h1 className="m-0 text-xl font-extrabold text-[var(--color-text)]">{t("title")}</h1>
<p className="m-0 text-xs text-[var(--color-text-muted)] mt-0.5">{t("subtitle", { rows: achievements.length, categories: groups.size })}</p>
</div>
</div>
<div className="grid grid-cols-[repeat(auto-fit,minmax(180px,1fr))] gap-3.5 mb-6">
<StatusCard label="Achievement rows" value={achievements.length} icon="🏆" />
<StatusCard label="Distinct achievements" value={distinctNames} icon="🎖️" />
<StatusCard label="Categories" value={groups.size} icon="🗂️" />
<StatusCard label={t("achievementRows")} value={achievements.length} icon="🏆" />
<StatusCard label={t("distinctAchievements")} value={distinctNames} icon="🎖️" />
<StatusCard label={t("categories")} value={groups.size} icon="🗂️" />
</div>
{groups.size === 0 ? (
<div className="admin-empty">No achievements found.</div>
<div className="admin-empty">{t("noAchievements")}</div>
) : (
[...groups.entries()].map(([category, rows]) => (
<section key={category} className="mt-6">
@@ -75,12 +76,12 @@ export default async function AdminAchievements() {
<table>
<thead>
<tr>
<th>Name</th>
<th>Level</th>
<th>Progress needed</th>
<th>Reward type</th>
<th>Reward amount</th>
<th>Points</th>
<th>{t("colName")}</th>
<th>{t("colLevel")}</th>
<th>{t("colProgress")}</th>
<th>{t("colRewardType")}</th>
<th>{t("colRewardAmount")}</th>
<th>{t("colPoints")}</th>
</tr>
</thead>
<tbody>
+13 -11
View File
@@ -1,4 +1,5 @@
import Link from "next/link";
import { getTranslations } from "next-intl/server";
import { Megaphone } from "lucide-react";
import { StatusCard } from "@/components/admin/dashboard";
import { prisma } from "@/lib/prisma";
@@ -6,6 +7,7 @@ import { prisma } from "@/lib/prisma";
export const dynamic = "force-dynamic";
export default async function AdminAds() {
const t = await getTranslations("pages.admin.ads");
let ads: Awaited<ReturnType<typeof prisma.websiteAds.findMany>> = [];
try {
ads = await prisma.websiteAds.findMany({
@@ -27,30 +29,30 @@ export default async function AdminAds() {
<Megaphone size={20} className="text-[var(--color-primary)]" />
</div>
<div className="flex-1">
<h1 className="m-0 text-xl font-extrabold text-[var(--color-text)]">Advertisements</h1>
<p className="m-0 text-xs text-[var(--color-text-muted)] mt-0.5">Website advertisement banners (<code>website_ads</code>). Each ad is an image URL shown across the site.</p>
<h1 className="m-0 text-xl font-extrabold text-[var(--color-text)]">{t("title")}</h1>
<p className="m-0 text-xs text-[var(--color-text-muted)] mt-0.5">{t("subtitle")}</p>
</div>
<Link href="/admin/ads/new" className="btn btn-primary">
+ New ad
+ {t("newAd")}
</Link>
</div>
<div className="grid grid-cols-[repeat(auto-fit,minmax(180px,1fr))] gap-3.5 mb-6">
<StatusCard label="Ads" value={ads.length} icon="🖼️" />
<StatusCard label="Latest" value={latest} hint="most recent ad" icon="🕒" />
<StatusCard label={t("adsCount")} value={ads.length} icon="🖼️" />
<StatusCard label={t("latest")} value={latest} hint={t("mostRecentAd")} icon="🕒" />
</div>
<section className="mt-6">
<h2 className="admin-section-title">All ads</h2>
<h2 className="admin-section-title">{t("allAds")}</h2>
{ads.length === 0 ? (
<div className="admin-empty">No advertisements yet.</div>
<div className="admin-empty">{t("noAds")}</div>
) : (
<table>
<thead>
<tr>
<th>Preview</th>
<th>Image URL</th>
<th>Created</th>
<th>{t("colPreview")}</th>
<th>{t("colImage")}</th>
<th>{t("colCreated")}</th>
<th />
</tr>
</thead>
@@ -68,7 +70,7 @@ export default async function AdminAds() {
{ad.createdAt ? ad.createdAt.toISOString().slice(0, 10) : "—"}
</td>
<td>
<Link href={`/admin/ads/${ad.id}`}>Edit</Link>
<Link href={`/admin/ads/${ad.id}`}>{t("edit")}</Link>
</td>
</tr>
))}
+21 -22
View File
@@ -1,3 +1,4 @@
import { getTranslations } from "next-intl/server";
import { Bell } from "lucide-react";
import { sendHotelAlert } from "@/actions/admin-alerts";
import { StatusCard } from "@/components/admin/dashboard";
@@ -8,16 +9,10 @@ export const dynamic = "force-dynamic";
const CRITICAL = new Set(["critical", "error", "danger"]);
const WARNING = new Set(["warning", "warn"]);
// Read-only feed of alert_logs (prisma.alertLogs delegate): the emulator and
// CMS write rows here when hotel/user alerts fire. Auto-gated by the admin
// layout (requireStaff); only the mutation re-asserts staff in the action.
function fromDate(d: Date | null | undefined): string {
return d ? d.toISOString().slice(0, 16).replace("T", " ") : "—";
}
// Map a free-text severity to a theme-token colour. Unknown severities fall
// back to muted grey rather than inventing a new palette.
function severityColor(severity: string): string {
switch (severity.toLowerCase()) {
case "critical":
@@ -59,6 +54,8 @@ function Badge({ label, color }: { label: string; color: string }) {
}
export default async function AdminAlerts() {
const t = await getTranslations("pages.admin.alerts");
let alerts: Awaited<ReturnType<typeof prisma.alertLogs.findMany>> = [];
try {
alerts = await prisma.alertLogs.findMany({
@@ -80,27 +77,27 @@ export default async function AdminAlerts() {
<Bell size={20} className="text-[var(--color-primary)]" />
</div>
<div>
<h1 className="m-0 text-xl font-extrabold text-[var(--color-text)]">Alerts</h1>
<p className="m-0 text-xs text-[var(--color-text-muted)] mt-0.5">Read-only · 100 most recent alert_logs (newest first).</p>
<h1 className="m-0 text-xl font-extrabold text-[var(--color-text)]">{t("title")}</h1>
<p className="m-0 text-xs text-[var(--color-text-muted)] mt-0.5">{t("subtitle")}</p>
</div>
</div>
<div className="grid grid-cols-[repeat(auto-fit,minmax(180px,1fr))] gap-3.5 mb-6">
<StatusCard label="Alerts shown" value={alerts.length} icon="🔔" />
<StatusCard label={t("alertsShown")} value={alerts.length} icon="🔔" />
<StatusCard
label="Critical"
label={t("critical")}
value={critical}
state={critical > 0 ? "danger" : "ok"}
icon="🚨"
/>
<StatusCard
label="Warnings"
label={t("warnings")}
value={warnings}
state={warnings > 0 ? "warn" : "neutral"}
icon="⚠️"
/>
<StatusCard
label="Unread"
label={t("unread")}
value={unread}
state={unread > 0 ? "warn" : "ok"}
icon="📨"
@@ -108,9 +105,11 @@ export default async function AdminAlerts() {
</div>
<form action={sendHotelAlert} className="admin-card mb-6">
<h3 className="mt-0">Send hotel alert</h3>
<h3 className="mt-0">{t("form.send")}</h3>
<p className="text-xs text-gray-500 dark:text-gray-400">
Broadcasts a message to every online user via RCON (<code>hotelalert</code>).
{t.rich("form.description", {
code: (chunks) => <code>{chunks}</code>,
})}
</p>
<div className="flex gap-2 flex-wrap items-start">
<textarea
@@ -118,28 +117,28 @@ export default async function AdminAlerts() {
required
maxLength={1000}
rows={2}
placeholder="Message to broadcast to the hotel…"
placeholder={t("form.messagePlaceholder")}
className="flex-1 min-w-[240px]"
/>
<button type="submit" className="btn btn-primary">
Send alert
{t("form.send")}
</button>
</div>
</form>
<section className="admin-card">
<h3 className="mt-0">Recent alerts ({alerts.length})</h3>
<h3 className="mt-0">{t("recentAlerts", { count: alerts.length })}</h3>
{alerts.length === 0 ? (
<div className="admin-empty">No alert logs.</div>
<div className="admin-empty">{t("noAlerts")}</div>
) : (
<div className="overflow-x-auto">
<table>
<thead>
<tr>
<th>When</th>
<th>Severity</th>
<th>Type</th>
<th>Message</th>
<th>{t("colWhen")}</th>
<th>{t("colSeverity")}</th>
<th>{t("colType")}</th>
<th>{t("colMessage")}</th>
</tr>
</thead>
<tbody>
+10 -8
View File
@@ -3,6 +3,7 @@ import { ClipboardList } from "lucide-react";
import { dismissApplication } from "@/actions/admin-applications";
import { StatusCard } from "@/components/admin/dashboard";
import { prisma } from "@/lib/prisma";
import { getTranslations } from "next-intl/server";
export const dynamic = "force-dynamic";
@@ -19,6 +20,7 @@ function formatDate(d: Date | null): string {
}
export default async function AdminApplications() {
const t = await getTranslations("pages.admin.applications");
let applications: ApplicationRow[] = [];
try {
applications = await prisma.websiteStaffApplications.findMany({
@@ -51,14 +53,14 @@ export default async function AdminApplications() {
<ClipboardList size={20} className="text-[var(--color-primary)]" />
</div>
<div>
<h1 className="m-0 text-xl font-extrabold text-[var(--color-text)]">Staff Applications</h1>
<p className="m-0 text-xs text-[var(--color-text-muted)] mt-0.5">Pending applications from players requesting a staff rank.</p>
<h1 className="m-0 text-xl font-extrabold text-[var(--color-text)]">{t("title")}</h1>
<p className="m-0 text-xs text-[var(--color-text-muted)] mt-0.5">{t("subtitle")}</p>
</div>
</div>
<div className="grid grid-cols-[repeat(auto-fit,minmax(180px,1fr))] gap-3.5 mb-6">
<StatusCard
label="Pending applications"
label={t("pending")}
value={applications.length}
state={applications.length > 0 ? "warn" : "ok"}
icon="📨"
@@ -66,9 +68,9 @@ export default async function AdminApplications() {
</div>
<section className="mt-6">
<h2 className="admin-section-title">Applications ({applications.length})</h2>
<h2 className="admin-section-title">{t("applicationsTitle", { count: applications.length })}</h2>
{applications.length === 0 ? (
<div className="admin-empty">No pending applications.</div>
<div className="admin-empty">{t("noApplications")}</div>
) : (
<div className="grid cols-2">
{applications.map((a) => {
@@ -80,10 +82,10 @@ export default async function AdminApplications() {
{username ? (
<Link href={`/admin/users/${a.userId}`}>{username}</Link>
) : (
<>User #{a.userId}</>
<>{t("userNumber", { id: a.userId })}</>
)}
</h3>
<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)]">Rank {a.rankId}</span>
<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)]">{t("rankLabel", { rank: a.rankId })}</span>
</div>
<p className="text-xs text-gray-500 dark:text-gray-400 m-0 mb-2">
{formatDate(a.createdAt)}
@@ -92,7 +94,7 @@ export default async function AdminApplications() {
<form action={dismissApplication}>
<input type="hidden" name="id" value={String(a.id)} />
<button type="submit" className="btn btn-danger">
Dismiss
{t("dismiss")}
</button>
</form>
</article>
+5 -3
View File
@@ -1,4 +1,5 @@
import Link from "next/link";
import { getTranslations } from "next-intl/server";
import { Newspaper } from "lucide-react";
import { notFound } from "next/navigation";
import { deleteArticle, updateArticle } from "@/actions/admin-articles";
@@ -16,6 +17,7 @@ export default async function EditArticle({
}) {
const { id } = await params;
const { error } = await searchParams;
const t = await getTranslations("pages.admin.articles");
const article = await prisma.websiteArticles.findUnique({
where: { id: BigInt(id) },
select: { id: true, slug: true, title: true, shortStory: true, fullStory: true, image: true },
@@ -29,8 +31,8 @@ export default async function EditArticle({
<Newspaper size={20} className="text-[var(--color-primary)]" />
</div>
<div>
<h1 className="m-0 text-xl font-extrabold text-[var(--color-text)]">Edit article</h1>
<p className="m-0 text-xs text-[var(--color-text-muted)] mt-0.5"><Link href="/admin/articles">← Articles</Link></p>
<h1 className="m-0 text-xl font-extrabold text-[var(--color-text)]">{t("form.title")}</h1>
<p className="m-0 text-xs text-[var(--color-text-muted)] mt-0.5"><Link href="/admin/articles">← {t("title")}</Link></p>
</div>
</div>
{error ? (
@@ -53,7 +55,7 @@ export default async function EditArticle({
/>
<form action={deleteArticle} style={{ marginTop: "1rem" }}>
<input type="hidden" name="id" value={String(article.id)} />
<button type="submit" className="btn btn-primary">Delete article</button>
<button type="submit" className="btn btn-primary">{t("form.deleteArticle")}</button>
</form>
</main>
);
+4 -2
View File
@@ -1,4 +1,5 @@
import Link from "next/link";
import { getTranslations } from "next-intl/server";
import { Newspaper } from "lucide-react";
import { createArticle } from "@/actions/admin-articles";
import { ArticleForm } from "@/components/admin/article-form";
@@ -9,6 +10,7 @@ export default async function NewArticle({
searchParams: Promise<{ error?: string }>;
}) {
const { error } = await searchParams;
const t = await getTranslations("pages.admin.articles");
return (
<main>
@@ -17,8 +19,8 @@ export default async function NewArticle({
<Newspaper size={20} className="text-[var(--color-primary)]" />
</div>
<div>
<h1 className="m-0 text-xl font-extrabold text-[var(--color-text)]">New article</h1>
<p className="m-0 text-xs text-[var(--color-text-muted)] mt-0.5"><Link href="/admin/articles">← Articles</Link></p>
<h1 className="m-0 text-xl font-extrabold text-[var(--color-text)]">{t("form.title")}</h1>
<p className="m-0 text-xs text-[var(--color-text-muted)] mt-0.5"><Link href="/admin/articles">← {t("title")}</Link></p>
</div>
</div>
{error ? (
+15 -13
View File
@@ -1,4 +1,5 @@
import Link from "next/link";
import { getTranslations } from "next-intl/server";
import { Newspaper } from "lucide-react";
import { StatusCard } from "@/components/admin/dashboard";
import { deleteArticle } from "@/actions/admin-articles";
@@ -12,6 +13,7 @@ export default async function AdminArticles({
searchParams: Promise<{ error?: string }>;
}) {
const { error } = await searchParams;
const t = await getTranslations("pages.admin.articles");
const articles = await prisma.websiteArticles
.findMany({
orderBy: { createdAt: "desc" },
@@ -31,11 +33,11 @@ export default async function AdminArticles({
<Newspaper size={20} className="text-[var(--color-primary)]" />
</div>
<div className="flex-1">
<h1 className="m-0 text-xl font-extrabold text-[var(--color-text)]">Articles</h1>
<p className="m-0 text-xs text-[var(--color-text-muted)] mt-0.5">News articles (<code>website_articles</code>) shown on the public site. 50 most recent.</p>
<h1 className="m-0 text-xl font-extrabold text-[var(--color-text)]">{t("title")}</h1>
<p className="m-0 text-xs text-[var(--color-text-muted)] mt-0.5">{t("subtitle")}</p>
</div>
<Link href="/admin/articles/new" className="btn btn-primary">
+ New article
+ {t("newArticle")}
</Link>
</div>
@@ -46,22 +48,22 @@ export default async function AdminArticles({
) : null}
<div className="grid grid-cols-[repeat(auto-fit,minmax(180px,1fr))] gap-3.5 mb-6">
<StatusCard label="Articles shown" value={articles.length} icon="📰" />
<StatusCard label="Latest" value={latest} hint="most recent post" icon="🕒" />
<StatusCard label={t("articlesShown")} value={articles.length} icon="📰" />
<StatusCard label={t("latest")} value={latest} hint={t("mostRecentPost")} icon="🕒" />
</div>
<section className="mt-6">
<h2 className="admin-section-title">Recent articles</h2>
<h2 className="admin-section-title">{t("recentArticles")}</h2>
{articles.length === 0 ? (
<div className="admin-empty">No articles yet.</div>
<div className="admin-empty">{t("noArticles")}</div>
) : (
<table>
<thead>
<tr>
<th>Title</th>
<th>Slug</th>
<th>Created</th>
<th>Actions</th>
<th>{t("colTitle")}</th>
<th>{t("colSlug")}</th>
<th>{t("colCreated")}</th>
<th>{t("colActions")}</th>
</tr>
</thead>
<tbody>
@@ -81,7 +83,7 @@ export default async function AdminArticles({
<td>
<div className="flex gap-2">
<Link href={`/admin/articles/${a.id}`} className="btn btn-primary">
Edit
{t("edit")}
</Link>
<form action={deleteArticle} className="m-0">
<input type="hidden" name="id" value={String(a.id)} />
@@ -89,7 +91,7 @@ export default async function AdminArticles({
type="submit"
className="btn btn-danger"
>
Delete
{t("delete")}
</button>
</form>
</div>
+21 -19
View File
@@ -1,3 +1,4 @@
import { getTranslations } from "next-intl/server";
import { giveBadge } from "@/actions/admin-badges";
import { uploadBadge } from "@/actions/admin-badge-upload";
import { prisma } from "@/lib/prisma";
@@ -12,6 +13,7 @@ export default async function AdminBadges({
}: {
searchParams: Promise<{ uploaded?: string; error?: string }>;
}) {
const t = await getTranslations("pages.admin.badges");
const { uploaded, error } = await searchParams;
let badges: Awaited<ReturnType<typeof prisma.websiteBadges.findMany>> = [];
@@ -31,53 +33,53 @@ export default async function AdminBadges({
<Award size={20} className="text-[var(--color-primary)]" />
</div>
<div>
<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>
<h1 className="m-0 text-xl font-extrabold text-[var(--color-text)]">{t("title")}</h1>
<p className="m-0 text-xs text-[var(--color-text-muted)] mt-0.5">{t("subtitle")}</p>
</div>
</div>
{uploaded && (
<div className="mb-4 p-3 rounded-lg bg-green-600/10 text-green-600 dark:text-green-400 text-xs">
Uploaded Badge image <code>{uploaded}.gif</code> saved.
{t("uploadedMessage", { code: uploaded })}
</div>
)}
{error && (
<div className="mb-4 p-3 rounded-lg bg-red-600/10 text-red-600 dark:text-red-400 text-xs">
Error: {error}
{t("errorMessage", { error })}
</div>
)}
<section className="mt-6">
<h2 className="admin-section-title">Upload badge image</h2>
<h2 className="admin-section-title">{t("uploadTitle")}</h2>
<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.
{t("uploadSubtitle")}
</p>
<form action={uploadBadge} className="admin-card">
<div className="flex gap-2 flex-wrap items-center">
<input type="file" name="file" accept=".gif,.png" required />
<input
name="code"
placeholder="Badge code (e.g. ADM)"
placeholder={t("badgeCodePlaceholder")}
required
maxLength={64}
pattern="[A-Za-z0-9_-]{1,64}"
className="flex-1 min-w-[160px]"
/>
<button type="submit" className="btn btn-primary">
Upload
{t("uploadButton")}
</button>
</div>
</form>
</section>
<section className="mt-6">
<h2 className="admin-section-title">Give badge to user</h2>
<h2 className="admin-section-title">{t("giveTitle")}</h2>
<form action={giveBadge} className="admin-card">
<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={t("userIdPlaceholder")} required />
<input
name="code"
placeholder="Badge code (e.g. ADM)"
placeholder={t("badgeCodePlaceholder")}
required
maxLength={32}
className="flex-1 min-w-[160px]"
@@ -91,24 +93,24 @@ export default async function AdminBadges({
))}
</datalist>
<button type="submit" className="btn btn-primary">
Give badge
{t("giveButton")}
</button>
</div>
</form>
</section>
<section className="mt-6">
<h2 className="admin-section-title">Catalogue ({badges.length})</h2>
<h2 className="admin-section-title">{t("catalogueTitle")} ({badges.length})</h2>
{badges.length === 0 ? (
<div className="admin-empty">No badges in the catalogue.</div>
<div className="admin-empty">{t("noBadges")}</div>
) : (
<div className="overflow-x-auto">
<table>
<thead>
<tr>
<th>Code</th>
<th>Name</th>
<th>Description</th>
<th>{t("colCode")}</th>
<th>{t("colName")}</th>
<th>{t("colDescription")}</th>
<th />
</tr>
</thead>
@@ -130,12 +132,12 @@ export default async function AdminBadges({
name="userId"
type="number"
min={1}
placeholder="User ID"
placeholder={t("userIdPlaceholder")}
required
className="w-[90px]"
/>
<button type="submit" className="btn btn-outline">
Give
{t("giveButton")}
</button>
</form>
</td>
+24 -22
View File
@@ -2,6 +2,7 @@ import { createBan, liftBan } from "@/actions/admin-bans";
import { StatusCard } from "@/components/admin/dashboard";
import { prisma } from "@/lib/prisma";
import { Ban } from "lucide-react";
import { getTranslations } from "next-intl/server";
export const dynamic = "force-dynamic";
@@ -10,6 +11,7 @@ function fromUnix(ts: number): string {
}
export default async function AdminBans() {
const t = await getTranslations("pages.admin.bans");
const now = Math.floor(Date.now() / 1000);
let bans: Awaited<ReturnType<typeof prisma.ban.findMany>> = [];
try {
@@ -33,58 +35,58 @@ export default async function AdminBans() {
<Ban size={20} className="text-[var(--color-primary)]" />
</div>
<div>
<h1 className="m-0 text-xl font-extrabold text-[var(--color-text)]">Bans</h1>
<p className="m-0 text-xs text-[var(--color-text-muted)] mt-0.5">Active bans only (expiry in the future) · newest first.</p>
<h1 className="m-0 text-xl font-extrabold text-[var(--color-text)]">{t("title")}</h1>
<p className="m-0 text-xs text-[var(--color-text-muted)] mt-0.5">{t("subtitle")}</p>
</div>
</div>
<div className="grid grid-cols-[repeat(auto-fit,minmax(180px,1fr))] gap-3.5 mb-6">
<StatusCard
label="Active bans"
label={t("activeBans")}
value={bans.length}
state={bans.length > 0 ? "warn" : "ok"}
icon="🔨"
/>
<StatusCard
label="Permanent"
label={t("permanent")}
value={permanent}
state={permanent > 0 ? "danger" : "neutral"}
icon="⛔"
/>
<StatusCard label="Account bans" value={accountBans} icon="👤" />
<StatusCard label={t("accountBans")} value={accountBans} icon="👤" />
</div>
<form action={createBan} className="admin-card mb-6">
<h3 className="text-sm font-bold text-[var(--color-text)] m-0 mb-3">New ban</h3>
<h3 className="text-sm font-bold text-[var(--color-text)] m-0 mb-3">{t("newBan")}</h3>
<div className="flex gap-2 flex-wrap">
<input name="userId" type="number" min={1} placeholder="User ID" required />
<input name="userId" type="number" min={1} placeholder={t("userIdPlaceholder")} required />
<select name="type">
<option value="account">account</option>
<option value="ip">ip</option>
<option value="machine">machine</option>
<option value="super">super</option>
<option value="account">{t("typeAccount")}</option>
<option value="ip">{t("typeIp")}</option>
<option value="machine">{t("typeMachine")}</option>
<option value="super">{t("typeSuper")}</option>
</select>
<input name="hours" type="number" min={0} placeholder="Hours (0 = perm)" />
<input name="reason" placeholder="Reason" className="flex-1 min-w-[160px]" />
<input name="hours" type="number" min={0} placeholder={t("hoursPlaceholder")} />
<input name="reason" placeholder={t("reasonPlaceholder")} className="flex-1 min-w-[160px]" />
<button type="submit" className="btn btn-danger">
Ban
{t("ban")}
</button>
</div>
</form>
<section className="mt-6">
<h2 className="admin-section-title">Active bans ({bans.length})</h2>
<h2 className="admin-section-title">{t("activeBans")} ({bans.length})</h2>
{bans.length === 0 ? (
<div className="admin-empty">No active bans.</div>
<div className="admin-empty">{t("noBans")}</div>
) : (
<div className="overflow-x-auto">
<table>
<thead>
<tr>
<th>User ID</th>
<th>Type</th>
<th>Reason</th>
<th>Expires</th>
<th>{t("colUserId")}</th>
<th>{t("colType")}</th>
<th>{t("colReason")}</th>
<th>{t("colExpires")}</th>
<th />
</tr>
</thead>
@@ -98,7 +100,7 @@ export default async function AdminBans() {
<td>{b.banReason || "—"}</td>
<td>
{b.banExpire === 0 ? (
<span className="inline-block text-[0.72rem] font-bold px-2 py-0.5 rounded-full bg-red-500/22 text-[var(--color-text)]">permanent</span>
<span className="inline-block text-[0.72rem] font-bold px-2 py-0.5 rounded-full bg-red-500/22 text-[var(--color-text)]">{t("permanent")}</span>
) : (
<span className="text-sm text-gray-500 dark:text-gray-400 whitespace-nowrap">
{fromUnix(b.banExpire)}
@@ -109,7 +111,7 @@ export default async function AdminBans() {
<form action={liftBan}>
<input type="hidden" name="id" value={b.id} />
<button type="submit" className="btn btn-outline">
Lift
{t("lift")}
</button>
</form>
</td>
+20 -18
View File
@@ -1,4 +1,5 @@
import Link from "next/link";
import { getTranslations } from "next-intl/server";
import { notFound } from "next/navigation";
import { prisma } from "@/lib/prisma";
import { Calendar } from "lucide-react";
@@ -40,6 +41,8 @@ export default async function AdminCalendarCampaign({
}: {
params: Promise<{ id: string }>;
}) {
const t = await getTranslations("pages.admin.calendar");
const { id } = await params;
const campaignId = Number(id);
if (!Number.isInteger(campaignId)) notFound();
@@ -93,7 +96,7 @@ export default async function AdminCalendarCampaign({
return (
<main>
<p className="text-xs text-gray-500 dark:text-gray-400 mb-4">
<Link href="/admin/calendar">← Calendar campaigns</Link>
<Link href="/admin/calendar">← {t("title")}</Link>
</p>
<div className="admin-card flex items-center gap-5 mb-6">
@@ -106,31 +109,30 @@ export default async function AdminCalendarCampaign({
/>
) : null}
<div>
<h1 className="m-0 mb-1">{campaign.name || `Campaign #${campaign.id}`}</h1>
<h1 className="m-0 mb-1">{campaign.name || `#${campaign.id}`}</h1>
<p className="text-xs text-gray-500 dark:text-gray-400 m-0">
#{campaign.id} · {campaign.totalDays} day{campaign.totalDays === 1 ? "" : "s"} · starts{" "}
{fmtDate(campaign.startTimestamp)}
{disabled ? " · Disabled" : " · Enabled"}
{campaign.lockExpired === "1" ? " · Locks expired days" : ""}
{t("detail.campaignMeta", { id: campaign.id, days: campaign.totalDays, date: fmtDate(campaign.startTimestamp) })}
{disabled ? ` · ${t("detail.disabled")}` : ` · ${t("detail.enabled")}`}
{campaign.lockExpired === "1" ? ` · ${t("detail.locksExpired")}` : ""}
</p>
</div>
</div>
<h2 className="admin-section-title">Rewards</h2>
<h2 className="admin-section-title">{t("detail.rewards")}</h2>
{rewards.length === 0 ? (
<div className="admin-empty">No rewards configured for this campaign.</div>
<div className="admin-empty">{t("detail.noRewards")}</div>
) : (
<table className="w-full">
<thead>
<tr className="text-left text-[var(--muted)]">
<th>ID</th>
<th>Product</th>
<th>Item</th>
<th>Credits</th>
<th>Pixels</th>
<th>Points</th>
<th>Badge</th>
<th>Subscription</th>
<th>{t("detail.colId")}</th>
<th>{t("detail.colProduct")}</th>
<th>{t("detail.colItem")}</th>
<th>{t("detail.colCredits")}</th>
<th>{t("detail.colPixels")}</th>
<th>{t("detail.colPoints")}</th>
<th>{t("detail.colBadge")}</th>
<th>{t("detail.colSubscription")}</th>
</tr>
</thead>
<tbody>
@@ -143,7 +145,7 @@ export default async function AdminCalendarCampaign({
<td>{r.pixels > 0 ? r.pixels : <span className="text-sm text-gray-500 dark:text-gray-400">—</span>}</td>
<td>
{r.points > 0 ? (
`${r.points} (type ${r.pointsType})`
`${r.points} (${t("detail.typeLabel", { type: r.pointsType })})`
) : (
<span className="text-sm text-gray-500 dark:text-gray-400">—</span>
)}
@@ -161,7 +163,7 @@ export default async function AdminCalendarCampaign({
)}
<p className="text-xs text-gray-500 dark:text-gray-400 mt-4">
{rewards.length} reward{rewards.length === 1 ? "" : "s"}
{t("detail.rewardCount", { count: rewards.length })}
</p>
</main>
);
+14 -14
View File
@@ -1,4 +1,5 @@
import Link from "next/link";
import { getTranslations } from "next-intl/server";
import { StatusCard } from "@/components/admin/dashboard";
import { prisma } from "@/lib/prisma";
import { Calendar } from "lucide-react";
@@ -21,6 +22,8 @@ function fmtDate(ts: number): string {
}
export default async function AdminCalendar() {
const t = await getTranslations("pages.admin.calendar");
let campaigns: Campaign[] = [];
try {
campaigns = await prisma.calendarCampaigns.findMany({
@@ -39,7 +42,6 @@ export default async function AdminCalendar() {
campaigns = [];
}
// Tally reward counts per campaign in JS (avoid prisma.groupBy).
const rewardCounts = new Map<number, number>();
let totalRewards = 0;
try {
@@ -51,7 +53,6 @@ export default async function AdminCalendar() {
rewardCounts.set(r.campaignId, (rewardCounts.get(r.campaignId) ?? 0) + 1);
}
} catch {
// leave map empty on failure
}
const enabledCount = campaigns.filter((c) => c.enabled === "1").length;
@@ -63,24 +64,24 @@ export default async function AdminCalendar() {
<Calendar size={20} className="text-[var(--color-primary)]" />
</div>
<div>
<h1 className="m-0 text-xl font-extrabold text-[var(--color-text)]">Calendar campaigns</h1>
<p className="m-0 text-xs text-[var(--color-text-muted)] mt-0.5">Read-only · {campaigns.length} campaign{campaigns.length === 1 ? "" : "s"}.</p>
<h1 className="m-0 text-xl font-extrabold text-[var(--color-text)]">{t("title")}</h1>
<p className="m-0 text-xs text-[var(--color-text-muted)] mt-0.5">{t("subtitle", { count: campaigns.length })}</p>
</div>
</div>
<div className="grid grid-cols-[repeat(auto-fit,minmax(180px,1fr))] gap-3.5 mb-6">
<StatusCard label="Campaigns" value={campaigns.length} icon="📅" />
<StatusCard label={t("campaigns")} value={campaigns.length} icon="📅" />
<StatusCard
label="Enabled"
label={t("enabled")}
value={enabledCount}
state={enabledCount > 0 ? "ok" : "neutral"}
icon="✅"
/>
<StatusCard label="Total rewards" value={totalRewards} icon="🎁" />
<StatusCard label={t("totalRewards")} value={totalRewards} icon="🎁" />
</div>
{campaigns.length === 0 ? (
<div className="admin-empty">No calendar campaigns found.</div>
<div className="admin-empty">{t("noCampaigns")}</div>
) : (
<div className="grid cols-3">
{campaigns.map((c) => {
@@ -100,18 +101,17 @@ export default async function AdminCalendar() {
)}
<div className="body">
<div className="flex items-center justify-between gap-2 mb-1">
<h3 className="m-0">{c.name || `Campaign #${c.id}`}</h3>
<h3 className="m-0">{c.name || `#${c.id}`}</h3>
<span className={`inline-block text-[0.72rem] font-bold px-2 py-0.5 rounded-full ${enabled ? "bg-green-600/20 text-[var(--color-text)]" : "bg-red-500/22 text-[var(--color-text)]"}`}>
{enabled ? "enabled" : "disabled"}
{enabled ? t("statusEnabled") : t("statusDisabled")}
</span>
</div>
<p className="text-xs text-gray-500 dark:text-gray-400 m-0">
#{c.id} · {c.totalDays} day{c.totalDays === 1 ? "" : "s"} · {count} reward
{count === 1 ? "" : "s"}
{t("campaignMeta", { id: c.id, days: c.totalDays, rewards: count })}
</p>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
Starts {fmtDate(c.startTimestamp)}
{c.lockExpired === "1" ? " · Locks expired" : ""}
{t("startsAt", { date: fmtDate(c.startTimestamp) })}
{c.lockExpired === "1" ? ` · ${t("locksExpired")}` : ""}
</p>
</div>
</Link>
+22 -19
View File
@@ -1,4 +1,5 @@
import Link from "next/link";
import { getTranslations } from "next-intl/server";
import { notFound } from "next/navigation";
import { prisma } from "@/lib/prisma";
@@ -35,6 +36,8 @@ export default async function AdminCatalogPage({
}: {
params: Promise<{ id: string }>;
}) {
const t = await getTranslations("pages.admin.catalog");
const { id } = await params;
const pageId = Number(id);
if (!Number.isInteger(pageId)) notFound();
@@ -89,32 +92,32 @@ export default async function AdminCatalogPage({
return (
<main>
<p className="muted">
<Link href="/admin/catalog">← Catalog</Link>
<Link href="/admin/catalog">← {t("title")}</Link>
</p>
<h1>{page.caption}</h1>
<p className="muted">
Page #{page.id} · layout {page.pageLayout} · min rank {page.minRank} ·{" "}
{hidden ? "Hidden" : "Visible"}
{page.parentId > 0 ? ` · parent #${page.parentId}` : ""}
{page.clubOnly === "1" ? " · HC only" : ""}
{page.vipOnly === "1" ? " · VIP only" : ""}
{t("pageLabel", { id: page.id })} · {t("layoutLabel", { layout: page.pageLayout })} · {t("minRankLabel", { rank: page.minRank })} ·{" "}
{hidden ? t("statusHidden") : t("statusVisible")}
{page.parentId > 0 ? ` · ${t("parentLabel", { id: page.parentId })}` : ""}
{page.clubOnly === "1" ? ` · ${t("hcOnly")}` : ""}
{page.vipOnly === "1" ? ` · ${t("vipOnly")}` : ""}
</p>
<h2 style={{ marginTop: "1.5rem" }}>Items</h2>
<h2 style={{ marginTop: "1.5rem" }}>{t("items")}</h2>
{items.length === 0 ? (
<p className="muted">No items on this page.</p>
<p className="muted">{t("noItems")}</p>
) : (
<table style={{ width: "100%", borderCollapse: "collapse" }}>
<thead>
<tr style={{ textAlign: "left", color: "var(--muted)" }}>
<th>ID</th>
<th>Catalog name</th>
<th>Item IDs</th>
<th>Amount</th>
<th>Credits</th>
<th>Points</th>
<th>Limited</th>
<th>Order</th>
<th>{t("colId")}</th>
<th>{t("catalogName")}</th>
<th>{t("itemIds")}</th>
<th>{t("amount")}</th>
<th>{t("colCostCredits")}</th>
<th>{t("colCostPoints")}</th>
<th>{t("colLimited")}</th>
<th>{t("colOrder")}</th>
</tr>
</thead>
<tbody>
@@ -124,7 +127,7 @@ export default async function AdminCatalogPage({
<td>
{it.catalogName}
{it.clubOnly === "1" ? (
<span className="muted"> · HC</span>
<span className="muted"> · {t("hc")}</span>
) : null}
</td>
<td className="muted">{it.itemIds}</td>
@@ -138,7 +141,7 @@ export default async function AdminCatalogPage({
</td>
<td className="muted">
{it.costPoints > 0
? `${it.costPoints} (type ${it.pointsType})`
? `${it.costPoints} (${t("typeLabel", { type: it.pointsType })})`
: "—"}
</td>
<td className="muted">
@@ -154,7 +157,7 @@ export default async function AdminCatalogPage({
)}
<p className="muted" style={{ marginTop: "1rem" }}>
{items.length} item{items.length === 1 ? "" : "s"}
{t("itemCount", { count: items.length })}
</p>
</main>
);
+19 -17
View File
@@ -1,4 +1,5 @@
import Link from "next/link";
import { getTranslations } from "next-intl/server";
import { StatusCard } from "@/components/admin/dashboard";
import { prisma } from "@/lib/prisma";
import { Package } from "lucide-react";
@@ -17,6 +18,8 @@ type CatalogPageRow = {
};
export default async function AdminCatalog() {
const t = await getTranslations("pages.admin.catalog");
let pages: CatalogPageRow[] = [];
try {
pages = await prisma.catalogPages.findMany({
@@ -37,7 +40,6 @@ export default async function AdminCatalog() {
pages = [];
}
// Build a quick caption lookup so we can show parent names instead of raw ids.
const captionById = new Map<number, string>();
for (const p of pages) captionById.set(p.id, p.caption);
@@ -54,39 +56,39 @@ export default async function AdminCatalog() {
<Package size={20} className="text-[var(--color-primary)]" />
</div>
<div>
<h1 className="m-0 text-xl font-extrabold text-[var(--color-text)]">Catalog</h1>
<p className="m-0 text-xs text-[var(--color-text-muted)] mt-0.5">Browse catalog pages and the items they contain. Read-only.</p>
<h1 className="m-0 text-xl font-extrabold text-[var(--color-text)]">{t("title")}</h1>
<p className="m-0 text-xs text-[var(--color-text-muted)] mt-0.5">{t("subtitle")}</p>
</div>
</div>
<div className="grid grid-cols-[repeat(auto-fit,minmax(180px,1fr))] gap-3.5 mb-6">
<StatusCard label="Pages" value={pages.length} icon="📚" />
<StatusCard label="Visible" value={visibleCount} state="ok" icon="👁️" />
<StatusCard label={t("pages")} value={pages.length} icon="📚" />
<StatusCard label={t("visible")} value={visibleCount} state="ok" icon="👁️" />
<StatusCard
label="Hidden"
label={t("hidden")}
value={hiddenCount}
state={hiddenCount > 0 ? "warn" : "neutral"}
icon="🚫"
/>
<StatusCard label="Root pages" value={rootCount} icon="🗂️" />
<StatusCard label={t("rootPages")} value={rootCount} icon="🗂️" />
</div>
<section className="mt-6">
<h2>Pages ({pages.length})</h2>
<h2>{t("pages")} ({pages.length})</h2>
{pages.length === 0 ? (
<p className="muted">No catalog pages found.</p>
<p className="muted">{t("noPages")}</p>
) : (
<div style={{ overflowX: "auto" }}>
<table>
<thead>
<tr>
<th>ID</th>
<th>Caption</th>
<th>Parent</th>
<th>Layout</th>
<th>Min rank</th>
<th>Order</th>
<th>Status</th>
<th>{t("colId")}</th>
<th>{t("caption")}</th>
<th>{t("colParent")}</th>
<th>{t("colLayout")}</th>
<th>{t("colMinRank")}</th>
<th>{t("colOrder")}</th>
<th>{t("colStatus")}</th>
</tr>
</thead>
<tbody>
@@ -112,7 +114,7 @@ export default async function AdminCatalog() {
<td>{p.orderNum}</td>
<td>
<span className={`inline-block text-[0.72rem] font-bold px-2 py-0.5 rounded-full ${hidden ? "bg-red-500/22 text-[var(--color-text)]" : "bg-green-600/20 text-[var(--color-text)]"}`}>
{hidden ? "Hidden" : "Visible"}
{hidden ? t("statusHidden") : t("statusVisible")}
</span>
</td>
</tr>
+125 -123
View File
@@ -1,4 +1,5 @@
import os from "node:os";
import { getTranslations } from "next-intl/server";
import { Activity } from 'lucide-react';
import {
hotelAlert,
@@ -55,6 +56,8 @@ function uptime(seconds: number): string {
}
export default async function CommandoCentrum() {
const t = await getTranslations("pages.admin.commandocentrum");
const [onlineCount, totalUsers, errors, dbOk, emulatorOnline, maintenance, activity] =
await Promise.all([
prisma.user.count({ where: { online: "1" } }).catch(() => 0),
@@ -91,38 +94,38 @@ export default async function CommandoCentrum() {
<Activity size={20} className="text-[var(--color-primary)]" />
</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>
<h1 className="m-0 text-xl font-extrabold text-[var(--color-text)]">{t("title")}</h1>
<p className="m-0 text-xs text-[var(--color-text-muted)] mt-0.5">{t("subtitle")}</p>
</div>
</div>
{/* ── Live status ────────────────────────────────────────── */}
<div className="grid grid-cols-[repeat(auto-fit,minmax(180px,1fr))] gap-3.5 mb-6" style={{ marginTop: "1.1rem" }}>
<StatusCard
label="Players online"
label={t("playersOnline")}
value={onlineCount}
hint={totalUsers >= 0 ? `${totalUsers} accounts total` : "DB unavailable"}
hint={totalUsers >= 0 ? t("totalAccounts", { count: totalUsers }) : t("dbUnavailable")}
state="neutral"
icon="👤"
/>
<StatusCard
label="Emulator"
value={emulatorOnline ? "Online" : "Offline"}
hint={emulatorOnline ? "RCON reachable" : "RCON not responding"}
label={t("emulator")}
value={emulatorOnline ? t("online") : t("offline")}
hint={emulatorOnline ? t("rconReachable") : t("rconNotResponding")}
state={emulatorOnline ? "ok" : "danger"}
icon="🎮"
/>
<StatusCard
label="Database"
value={dbOk ? "Connected" : "Down"}
hint={dbOk ? "Queries responding" : "Cannot reach DB"}
label={t("database")}
value={dbOk ? t("connected") : t("down")}
hint={dbOk ? t("queriesResponding") : t("cannotReachDb")}
state={dbOk ? "ok" : "danger"}
icon="🗄️"
/>
<StatusCard
label="Emulator errors"
label={t("emulatorErrors")}
value={errors.length}
hint="last 50 logged"
hint={t("last50Logged")}
state={errors.length === 0 ? "ok" : "warn"}
icon="⚠️"
/>
@@ -130,7 +133,7 @@ export default async function CommandoCentrum() {
{/* ── Online users widget ───────────────────────────────── */}
<section className="admin-card mb-6">
<h2 className="admin-section-title">Online Users</h2>
<h2 className="admin-section-title">{t("onlineUsers")}</h2>
<OnlineUsersWidget
users={[]}
isLoading={false}
@@ -140,50 +143,50 @@ export default async function CommandoCentrum() {
{/* ── Server info + diagnostics ──────────────────────────── */}
<div className="grid cols-2">
<section className="admin-card p-5">
<h2 className="admin-section-title">Server info</h2>
<h2 className="admin-section-title">{t("serverInfo")}</h2>
<div className="grid grid-cols-[repeat(auto-fit,minmax(150px,1fr))] gap-2.5">
<InfoItem label="Node" value={process.version} />
<InfoItem label="Platform" value={`${process.platform} ${process.arch}`} />
<InfoItem label="App uptime" value={uptime(process.uptime())} />
<InfoItem label="Heap used" value={mb(process.memoryUsage().heapUsed)} />
<InfoItem label="RSS" value={mb(process.memoryUsage().rss)} />
<InfoItem label="Host uptime" value={uptime(os.uptime())} />
<InfoItem label="Load (1m)" value={os.loadavg()[0]?.toFixed(2) ?? "—"} />
<InfoItem label="CPUs" value={os.cpus().length} />
<InfoItem label={t("node")} value={process.version} />
<InfoItem label={t("platform")} value={`${process.platform} ${process.arch}`} />
<InfoItem label={t("appUptime")} value={uptime(process.uptime())} />
<InfoItem label={t("heapUsed")} value={mb(process.memoryUsage().heapUsed)} />
<InfoItem label={t("rss")} value={mb(process.memoryUsage().rss)} />
<InfoItem label={t("hostUptime")} value={uptime(os.uptime())} />
<InfoItem label={t("load1m")} value={os.loadavg()[0]?.toFixed(2) ?? "—"} />
<InfoItem label={t("cpus")} value={os.cpus().length} />
</div>
</section>
<section className="admin-card p-5">
<h2 className="admin-section-title">System diagnostics</h2>
<h2 className="admin-section-title">{t("systemDiagnostics")}</h2>
<div className="flex flex-col">
<DiagnosticRow
label="Database"
detail={dbOk ? "Connection healthy" : "Unreachable"}
label={t("database")}
detail={dbOk ? t("connectionHealthy") : t("unreachable")}
state={dbOk ? "ok" : "danger"}
/>
<DiagnosticRow
label="Emulator (RCON)"
detail={emulatorOnline ? "Responding to ping" : "No response — is it running?"}
label={t("emulatorRcon")}
detail={emulatorOnline ? t("respondingToPing") : t("noResponse")}
state={emulatorOnline ? "ok" : "warn"}
/>
<DiagnosticRow
label="RCON config"
detail={rconSet ? "RCON_HOST set" : "RCON_HOST not configured"}
label={t("rconConfig")}
detail={rconSet ? t("rconHostSet") : t("rconHostNotConfigured")}
state={rconSet ? "ok" : "warn"}
/>
<DiagnosticRow
label="2FA encryption key"
detail={appKeySet ? "APP_KEY configured" : "APP_KEY missing — 2FA secrets can't decrypt"}
label={t("twoFaEncryptionKey")}
detail={appKeySet ? t("appKeyConfigured") : t("appKeyMissing")}
state={appKeySet ? "ok" : "warn"}
/>
<DiagnosticRow
label="Email (SMTP)"
detail={smtpSet ? "SMTP_HOST set" : "Not configured — emails disabled"}
label={t("emailSmtp")}
detail={smtpSet ? t("smtpHostSet") : t("notConfigured")}
state={smtpSet ? "ok" : "warn"}
/>
<DiagnosticRow
label="Maintenance mode"
detail={maintenance ? "Site is in maintenance" : "Site is live"}
label={t("maintenanceMode")}
detail={maintenance ? t("siteIsInMaintenance") : t("siteIsLive")}
state={maintenance ? "warn" : "ok"}
/>
</div>
@@ -191,54 +194,54 @@ export default async function CommandoCentrum() {
</div>
<section className="mt-6">
<h2 className="admin-section-title">Emulator controls</h2>
<h2 className="admin-section-title">{t("emulatorControls")}</h2>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
<form action={updateCatalog} className="admin-card">
<h3 className="text-sm font-bold text-[var(--color-text)] mb-2">Update catalog</h3>
<h3 className="text-sm font-bold text-[var(--color-text)] mb-2">{t("updateCatalog")}</h3>
<p className="text-xs text-[var(--color-text-muted)] mb-4">
Reload catalog pages and items on the live server.
{t("updateCatalogDesc")}
</p>
<button type="submit" className="btn btn-primary">
Update catalog
{t("updateCatalog")}
</button>
</form>
<form action={updateWordFilter} className="admin-card">
<h3 className="text-sm font-bold text-[var(--color-text)] mb-2">Update word filter</h3>
<h3 className="text-sm font-bold text-[var(--color-text)] mb-2">{t("updateWordFilter")}</h3>
<p className="text-xs text-[var(--color-text-muted)] mb-4">
Reload the chat word filter from the database.
{t("updateWordFilterDesc")}
</p>
<button type="submit" className="btn btn-primary">
Update word filter
{t("updateWordFilter")}
</button>
</form>
<form action={updateNavigator} className="admin-card">
<h3 className="text-sm font-bold text-[var(--color-text)] mb-2">Update navigator</h3>
<h3 className="text-sm font-bold text-[var(--color-text)] mb-2">{t("updateNavigator")}</h3>
<p className="text-xs text-[var(--color-text-muted)] mb-4">
Reload navigator categories and room listings.
{t("updateNavigatorDesc")}
</p>
<button type="submit" className="btn btn-primary">
Update navigator
{t("updateNavigator")}
</button>
</form>
</div>
<form action={hotelAlert} className="admin-card mt-6">
<h3 className="text-sm font-bold text-[var(--color-text)] mb-2">Hotel alert</h3>
<h3 className="text-sm font-bold text-[var(--color-text)] mb-2">{t("hotelAlert")}</h3>
<p className="text-xs text-[var(--color-text-muted)] mb-3">
Broadcast a message to every connected user.
{t("hotelAlertDesc")}
</p>
<div className="flex gap-2">
<input
name="message"
required
maxLength={512}
placeholder="Message to broadcast…"
placeholder={t("messageToBroadcast")}
className="flex-1 min-w-[220px]"
/>
<button type="submit" className="btn btn-danger">
Send alert
{t("sendAlert")}
</button>
</div>
</form>
@@ -246,126 +249,126 @@ export default async function CommandoCentrum() {
{/* ── User RCON actions ──────────────────────────────────── */}
<section className="mt-6">
<h2 className="admin-section-title">User actions (RCON)</h2>
<h2 className="admin-section-title">{t("userActions")}</h2>
<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.
{t("userActionsDesc")}
</p>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
<form action={disconnectUser} className="admin-card">
<h3 className="text-sm font-bold text-[var(--color-text)] mb-2">Disconnect / Kick</h3>
<p className="text-xs text-[var(--color-text-muted)] mb-3">Force disconnect a user.</p>
<h3 className="text-sm font-bold text-[var(--color-text)] mb-2">{t("actions.disconnect")}</h3>
<p className="text-xs text-[var(--color-text-muted)] mb-3">{t("disconnectDesc")}</p>
<div className="space-y-2">
<input name="userId" type="number" min={1} placeholder="User ID" required className="w-full" />
<input name="username" type="text" placeholder="Username (required)" required className="w-full" />
<button type="submit" className="btn btn-danger w-full">Disconnect user</button>
<input name="userId" type="number" min={1} placeholder={t("userId")} required className="w-full" />
<input name="username" type="text" placeholder={t("usernameRequired")} required className="w-full" />
<button type="submit" className="btn btn-danger w-full">{t("actions.disconnect")}</button>
</div>
</form>
<form action={alertUser} className="admin-card">
<h3 className="text-sm font-bold text-[var(--color-text)] mb-2">Alert user</h3>
<p className="text-xs text-[var(--color-text-muted)] mb-3">Send a popup alert to a user.</p>
<h3 className="text-sm font-bold text-[var(--color-text)] mb-2">{t("actions.alert")}</h3>
<p className="text-xs text-[var(--color-text-muted)] mb-3">{t("alertDesc")}</p>
<div className="space-y-2">
<input name="userId" type="number" min={1} placeholder="User ID" required className="w-full" />
<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>
<input name="userId" type="number" min={1} placeholder={t("userId")} required className="w-full" />
<input name="message" type="text" placeholder={t("alertMessage")} required maxLength={512} className="w-full" />
<button type="submit" className="btn btn-primary w-full">{t("actions.alert")}</button>
</div>
</form>
<form action={forwardUser} className="admin-card">
<h3 className="text-sm font-bold text-[var(--color-text)] mb-2">Forward to room</h3>
<p className="text-xs text-[var(--color-text-muted)] mb-3">Teleport user to a room ID.</p>
<h3 className="text-sm font-bold text-[var(--color-text)] mb-2">{t("forwardToRoom")}</h3>
<p className="text-xs text-[var(--color-text-muted)] mb-3">{t("forwardDesc")}</p>
<div className="space-y-2">
<input name="userId" type="number" min={1} placeholder="User ID" required className="w-full" />
<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>
<input name="userId" type="number" min={1} placeholder={t("userId")} required className="w-full" />
<input name="roomId" type="number" min={1} placeholder={t("roomId")} required className="w-full" />
<button type="submit" className="btn btn-primary w-full">{t("forwardUser")}</button>
</div>
</form>
</div>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4 mt-4">
<form action={giveCredits} className="admin-card">
<h3 className="text-sm font-bold text-[var(--color-text)] mb-2">Give credits</h3>
<p className="text-xs text-[var(--color-text-muted)] mb-3">Add credits to a user.</p>
<h3 className="text-sm font-bold text-[var(--color-text)] mb-2">{t("giveCredits")}</h3>
<p className="text-xs text-[var(--color-text-muted)] mb-3">{t("giveCreditsDesc")}</p>
<div className="space-y-2">
<input name="userId" type="number" min={1} placeholder="User ID" required className="w-full" />
<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>
<input name="userId" type="number" min={1} placeholder={t("userId")} required className="w-full" />
<input name="credits" type="number" min={1} placeholder={t("amount")} required className="w-full" />
<button type="submit" className="btn btn-primary w-full">{t("giveCredits")}</button>
</div>
</form>
<form action={giveDuckets} className="admin-card">
<h3 className="text-sm font-bold text-[var(--color-text)] mb-2">Give duckets</h3>
<p className="text-xs text-[var(--color-text-muted)] mb-3">Add duckets to a user.</p>
<h3 className="text-sm font-bold text-[var(--color-text)] mb-2">{t("giveDuckets")}</h3>
<p className="text-xs text-[var(--color-text-muted)] mb-3">{t("giveDucketsDesc")}</p>
<div className="space-y-2">
<input name="userId" type="number" min={1} placeholder="User ID" required className="w-full" />
<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>
<input name="userId" type="number" min={1} placeholder={t("userId")} required className="w-full" />
<input name="amount" type="number" min={1} placeholder={t("amount")} required className="w-full" />
<button type="submit" className="btn btn-primary w-full">{t("giveDuckets")}</button>
</div>
</form>
<form action={giveDiamonds} className="admin-card">
<h3 className="text-sm font-bold text-[var(--color-text)] mb-2">Give diamonds</h3>
<p className="text-xs text-[var(--color-text-muted)] mb-3">Add diamonds to a user.</p>
<h3 className="text-sm font-bold text-[var(--color-text)] mb-2">{t("giveDiamonds")}</h3>
<p className="text-xs text-[var(--color-text-muted)] mb-3">{t("giveDiamondsDesc")}</p>
<div className="space-y-2">
<input name="userId" type="number" min={1} placeholder="User ID" required className="w-full" />
<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>
<input name="userId" type="number" min={1} placeholder={t("userId")} required className="w-full" />
<input name="amount" type="number" min={1} placeholder={t("amount")} required className="w-full" />
<button type="submit" className="btn btn-primary w-full">{t("giveDiamonds")}</button>
</div>
</form>
</div>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4 mt-4">
<form action={giveBadge} className="admin-card">
<h3 className="text-sm font-bold text-[var(--color-text)] mb-2">Give badge</h3>
<p className="text-xs text-[var(--color-text-muted)] mb-3">Give a badge code (e.g. HOF1).</p>
<h3 className="text-sm font-bold text-[var(--color-text)] mb-2">{t("giveBadge")}</h3>
<p className="text-xs text-[var(--color-text-muted)] mb-3">{t("giveBadgeDesc")}</p>
<div className="space-y-2">
<input name="userId" type="number" min={1} placeholder="User ID" required className="w-full" />
<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>
<input name="userId" type="number" min={1} placeholder={t("userId")} required className="w-full" />
<input name="badge" type="text" placeholder={t("badgeCode")} required maxLength={32} className="w-full" />
<button type="submit" className="btn btn-primary w-full">{t("giveBadge")}</button>
</div>
</form>
<form action={setMotto} className="admin-card">
<h3 className="text-sm font-bold text-[var(--color-text)] mb-2">Set motto</h3>
<p className="text-xs text-[var(--color-text-muted)] mb-3">Change user's motto (max 127 chars).</p>
<h3 className="text-sm font-bold text-[var(--color-text)] mb-2">{t("setMotto")}</h3>
<p className="text-xs text-[var(--color-text-muted)] mb-3">{t("setMottoDesc")}</p>
<div className="space-y-2">
<input name="userId" type="number" min={1} placeholder="User ID" required className="w-full" />
<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>
<input name="userId" type="number" min={1} placeholder={t("userId")} required className="w-full" />
<input name="motto" type="text" placeholder={t("newMotto")} required maxLength={127} className="w-full" />
<button type="submit" className="btn btn-primary w-full">{t("setMotto")}</button>
</div>
</form>
<form action={setRank} className="admin-card">
<h3 className="text-sm font-bold text-[var(--color-text)] mb-2">Set rank</h3>
<p className="text-xs text-[var(--color-text-muted)] mb-3">Change user rank (0–10).</p>
<h3 className="text-sm font-bold text-[var(--color-text)] mb-2">{t("setRank")}</h3>
<p className="text-xs text-[var(--color-text-muted)] mb-3">{t("setRankDesc")}</p>
<div className="space-y-2">
<input name="userId" type="number" min={1} placeholder="User ID" required className="w-full" />
<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>
<input name="userId" type="number" min={1} placeholder={t("userId")} required className="w-full" />
<input name="rank" type="number" min={0} max={10} placeholder={t("rankPlaceholder")} required className="w-full" />
<button type="submit" className="btn btn-primary w-full">{t("setRank")}</button>
</div>
</form>
</div>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4 mt-4">
<form action={executeCommand} className="admin-card">
<h3 className="text-sm font-bold text-[var(--color-text)] mb-2">Execute command</h3>
<p className="text-xs text-[var(--color-text-muted)] mb-3">Run a chat command as the user.</p>
<h3 className="text-sm font-bold text-[var(--color-text)] mb-2">{t("executeCommand")}</h3>
<p className="text-xs text-[var(--color-text-muted)] mb-3">{t("executeCommandDesc")}</p>
<div className="space-y-2">
<input name="userId" type="number" min={1} placeholder="User ID" required className="w-full" />
<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>
<input name="userId" type="number" min={1} placeholder={t("userId")} required className="w-full" />
<input name="command" type="text" placeholder={t("commandPlaceholder")} required maxLength={100} className="w-full" />
<button type="submit" className="btn btn-primary w-full">{t("execute")}</button>
</div>
</form>
<form action={sendGift} className="admin-card">
<h3 className="text-sm font-bold text-[var(--color-text)] mb-2">Send gift</h3>
<p className="text-xs text-[var(--color-text-muted)] mb-3">Send a furniture item as gift.</p>
<h3 className="text-sm font-bold text-[var(--color-text)] mb-2">{t("sendGift")}</h3>
<p className="text-xs text-[var(--color-text-muted)] mb-3">{t("sendGiftDesc")}</p>
<div className="space-y-2">
<input name="userId" type="number" min={1} placeholder="User ID" required className="w-full" />
<input name="itemId" type="number" min={1} placeholder="Furni ID" required className="w-full" />
<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>
<input name="userId" type="number" min={1} placeholder={t("userId")} required className="w-full" />
<input name="itemId" type="number" min={1} placeholder={t("furniId")} required className="w-full" />
<input name="message" type="text" placeholder={t("giftMessage")} maxLength={255} defaultValue={t("defaultGiftMessage")} className="w-full" />
<button type="submit" className="btn btn-primary w-full">{t("sendGift")}</button>
</div>
</form>
</div>
@@ -373,18 +376,18 @@ export default async function CommandoCentrum() {
{/* ── Staff activity ─────────────────────────────────────── */}
<section className="mt-6">
<h2 className="admin-section-title">Recent staff activity</h2>
<h2 className="admin-section-title">{t("recentStaffActivity")}</h2>
{activity.length === 0 ? (
<div className="admin-empty">No staff activity logged yet.</div>
<div className="admin-empty">{t("noStaffActivity")}</div>
) : (
<div className="admin-card overflow-x-auto">
<table>
<thead>
<tr>
<th>When</th>
<th>Staff</th>
<th>Action</th>
<th>Description</th>
<th>{t("colWhen")}</th>
<th>{t("colStaff")}</th>
<th>{t("colAction")}</th>
<th>{t("colDescription")}</th>
</tr>
</thead>
<tbody>
@@ -408,28 +411,27 @@ export default async function CommandoCentrum() {
{/* ── Emulator errors ────────────────────────────────────── */}
<section className="mt-6">
<h2 className="admin-section-title">Recent emulator errors</h2>
<h2 className="admin-section-title">{t("recentEmulatorErrors")}</h2>
{errors.length === 0 ? (
<div className="admin-empty">No emulator errors logged.</div>
<div className="admin-empty">{t("noEmulatorErrors")}</div>
) : (
<div className="admin-card overflow-x-auto">
<table>
<thead>
<tr>
<th>When</th>
<th>Type</th>
<th>Version</th>
<th>Stacktrace</th>
<th>{t("colWhen")}</th>
<th>{t("colType")}</th>
<th>{t("colVersion")}</th>
<th>{t("colStacktrace")}</th>
</tr>
</thead>
<tbody>
{errors.map((e) => {
const trace = decodeStacktrace(e.stacktrace);
// Truncate stacktraces to first 20 lines to avoid info disclosure.
const snippet = trace
? trace.split("\n").slice(0, 20).join("\n") +
(trace.split("\n").length > 20 ? "\n… (truncated)" : "")
: "(empty)";
(trace.split("\n").length > 20 ? "\n… " + t("truncated") : "")
: t("empty");
return (
<tr key={e.id}>
<td className="text-sm text-gray-500 dark:text-gray-400 whitespace-nowrap">
@@ -454,4 +456,4 @@ export default async function CommandoCentrum() {
</section>
</main>
);
}
}
+27 -25
View File
@@ -1,3 +1,4 @@
import { getTranslations } from "next-intl/server";
import {
createEmailTemplate,
deleteEmailTemplate,
@@ -8,6 +9,8 @@ import { prisma } from "@/lib/prisma";
export const dynamic = "force-dynamic";
export default async function AdminEmailTemplates() {
const t = await getTranslations("pages.admin.emailTemplates");
let templates: Awaited<ReturnType<typeof prisma.emailTemplates.findMany>> = [];
try {
templates = await prisma.emailTemplates.findMany({ orderBy: { name: "asc" } });
@@ -22,10 +25,9 @@ export default async function AdminEmailTemplates() {
<span className="text-lg">✉️</span>
</div>
<div>
<h1 className="m-0 text-xl font-extrabold text-[var(--color-text)]">Email Templates</h1>
<h1 className="m-0 text-xl font-extrabold text-[var(--color-text)]">{t("title")}</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).
{t.rich("subtitle", { code: (chunks) => <code>{chunks}</code> })}
</p>
</div>
</div>
@@ -34,79 +36,79 @@ export default async function AdminEmailTemplates() {
action={createEmailTemplate}
className="admin-card mb-6"
>
<h3 className="text-sm font-bold text-[var(--color-text)] mb-2">New template</h3>
<h3 className="text-sm font-bold text-[var(--color-text)] mb-2">{t("newTemplate")}</h3>
<div className="flex gap-2 flex-wrap items-center mb-3">
<input name="name" placeholder="name (unique)" required className="flex-1 min-w-[180px]" />
<input name="name" placeholder={t("form.namePlaceholder")} required className="flex-1 min-w-[180px]" />
<input
name="subject"
placeholder="subject"
placeholder={t("form.subjectPlaceholder")}
required
className="flex-1 min-w-[220px]"
/>
<input name="variables" placeholder="variables (comma-separated)" className="min-w-[200px]" />
<input name="variables" placeholder={t("form.variablesPlaceholder")} className="min-w-[200px]" />
<label className="flex items-center gap-2">
<input type="checkbox" name="isActive" defaultChecked className="w-auto" />
<span className="text-sm text-gray-500 dark:text-gray-400">Active</span>
<span className="text-sm text-gray-500 dark:text-gray-400">{t("form.active")}</span>
</label>
</div>
<textarea
name="body"
placeholder="Email body (HTML or text)"
placeholder={t("form.bodyPlaceholder")}
required
rows={5}
className="w-full resize-y mb-3"
/>
<button type="submit" className="btn btn-primary">
Create
{t("create")}
</button>
</form>
<div className="grid">
{templates.map((t) => {
const id = String(t.id);
{templates.map((template) => {
const id = String(template.id);
return (
<div key={id} className="admin-card mb-4">
<form action={updateEmailTemplate}>
<input type="hidden" name="id" value={id} />
<div className="flex gap-2 flex-wrap items-center mb-2">
<strong className="min-w-[160px]">{t.name}</strong>
<strong className="min-w-[160px]">{template.name}</strong>
<input
name="subject"
defaultValue={t.subject}
placeholder="subject"
defaultValue={template.subject}
placeholder={t("form.subjectPlaceholder")}
required
className="flex-1 min-w-[220px]"
/>
<input
name="variables"
defaultValue={t.variables ?? ""}
placeholder="variables"
defaultValue={template.variables ?? ""}
placeholder={t("form.variablesPlaceholder")}
className="min-w-[180px]"
/>
<label className="flex items-center gap-2">
<input
type="checkbox"
name="isActive"
defaultChecked={t.isActive}
defaultChecked={template.isActive}
className="w-auto"
/>
<span className="text-sm text-gray-500 dark:text-gray-400">Active</span>
<span className="text-sm text-gray-500 dark:text-gray-400">{t("form.active")}</span>
</label>
</div>
<textarea
name="body"
defaultValue={t.body}
defaultValue={template.body}
required
rows={6}
className="w-full resize-y mb-2"
/>
<div className="flex gap-2 items-center">
<button type="submit" className="btn btn-primary">
Save
{t("save")}
</button>
{t.updatedAt ? (
{template.updatedAt ? (
<span className="text-xs text-gray-500 dark:text-gray-400">
Updated {new Date(t.updatedAt).toISOString().slice(0, 16).replace("T", " ")}
{t("updated", { date: new Date(template.updatedAt).toISOString().slice(0, 16).replace("T", " ") })}
</span>
) : null}
</div>
@@ -114,14 +116,14 @@ export default async function AdminEmailTemplates() {
<form action={deleteEmailTemplate} className="mt-4">
<input type="hidden" name="id" value={id} />
<button type="submit" className="btn btn-danger">
Delete
{t("delete")}
</button>
</form>
</div>
);
})}
</div>
{templates.length === 0 ? <div className="admin-empty">No email templates yet.</div> : null}
{templates.length === 0 ? <div className="admin-empty">{t("noTemplates")}</div> : null}
</main>
);
}
+36 -33
View File
@@ -1,3 +1,4 @@
import { getTranslations } from "next-intl/server";
import { updateEmulatorSetting, updateEmulatorText } from "@/actions/admin-emulator";
import { StatusCard } from "@/components/admin/dashboard";
import { prisma } from "@/lib/prisma";
@@ -7,6 +8,8 @@ export const dynamic = "force-dynamic";
const TEXTS_LIMIT = 300;
export default async function AdminEmulator() {
const t = await getTranslations("pages.admin.emulator");
let settings: { key: string; value: string }[] = [];
try {
settings = await prisma.emulatorSettings.findMany({ orderBy: { key: "asc" } });
@@ -34,39 +37,39 @@ export default async function AdminEmulator() {
return (
<main>
<h1>Emulator Config</h1>
<h1>{t("title")}</h1>
<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).
{t.rich("subtitle", {
code: (chunks) => <code>{chunks}</code>,
})}
</p>
<div className="grid grid-cols-[repeat(auto-fit,minmax(180px,1fr))] gap-3.5 mb-6">
<StatusCard label="Settings keys" value={settings.length} icon="⚙️" />
<StatusCard label="Text keys" value={textsCount} icon="🗒️" />
<StatusCard label={t("settingsKeys")} value={settings.length} icon="⚙️" />
<StatusCard label={t("textKeys")} value={textsCount} icon="🗒️" />
</div>
<section className="mt-6">
<h2 className="admin-section-title">Settings</h2>
<h2 className="admin-section-title">{t("settings")}</h2>
<p className="text-xs text-gray-500 dark:text-gray-400 mb-3">
{settings.length} keys.
{t("keysCount", { count: settings.length })}
</p>
<form
action={updateEmulatorSetting}
className="admin-card mb-6"
>
<h3 className="text-sm font-bold text-[var(--color-text)] m-0 mb-3">Add / overwrite setting</h3>
<h3 className="text-sm font-bold text-[var(--color-text)] m-0 mb-3">{t("addOverwriteSetting")}</h3>
<div className="flex gap-2 flex-wrap">
<input name="key" placeholder="key" maxLength={100} required />
<input name="key" placeholder={t("key")} maxLength={100} required />
<input
name="value"
placeholder="value"
placeholder={t("value")}
maxLength={512}
className="flex-1 min-w-[200px]"
/>
<button type="submit" className="btn btn-primary">
Save
{t("save")}
</button>
</div>
</form>
@@ -74,8 +77,8 @@ export default async function AdminEmulator() {
<table>
<thead>
<tr>
<th className="w-[320px]">Key</th>
<th>Value</th>
<th className="w-[320px]">{t("colKey")}</th>
<th>{t("colValue")}</th>
</tr>
</thead>
<tbody>
@@ -97,7 +100,7 @@ export default async function AdminEmulator() {
className="flex-1"
/>
<button type="submit" className="btn btn-outline">
Save
{t("save")}
</button>
</form>
</td>
@@ -106,31 +109,32 @@ export default async function AdminEmulator() {
</tbody>
</table>
{settings.length === 0 ? (
<p className="text-xs text-gray-500 dark:text-gray-400">No emulator settings found.</p>
<p className="text-xs text-gray-500 dark:text-gray-400">{t("noSettings")}</p>
) : null}
</section>
<section className="mt-6">
<h2 className="admin-section-title">Texts</h2>
<h2 className="admin-section-title">{t("texts")}</h2>
<p className="text-xs text-gray-500 dark:text-gray-400 mb-3">
{textsCount} keys{textsTruncated ? ` (showing first ${texts.length})` : ""}.
{t("keysCount", { count: textsCount })}
{textsTruncated ? ` (${t("showingFirst", { count: texts.length })})` : ""}.
</p>
<form
action={updateEmulatorText}
className="admin-card mb-6"
>
<h3 className="text-sm font-bold text-[var(--color-text)] m-0 mb-3">Add / overwrite text</h3>
<h3 className="text-sm font-bold text-[var(--color-text)] m-0 mb-3">{t("addOverwriteText")}</h3>
<div className="flex gap-2 flex-wrap">
<input name="key" placeholder="key" maxLength={100} required />
<input name="key" placeholder={t("key")} maxLength={100} required />
<input
name="value"
placeholder="value"
placeholder={t("value")}
maxLength={4096}
className="flex-1 min-w-[200px]"
/>
<button type="submit" className="btn btn-primary">
Save
{t("save")}
</button>
</div>
</form>
@@ -138,30 +142,30 @@ export default async function AdminEmulator() {
<table>
<thead>
<tr>
<th className="w-[320px]">Key</th>
<th>Value</th>
<th className="w-[320px]">{t("colKey")}</th>
<th>{t("colValue")}</th>
</tr>
</thead>
<tbody>
{texts.map((t) => (
<tr key={t.key}>
{texts.map((txt) => (
<tr key={txt.key}>
<td>
<strong>{t.key}</strong>
<strong>{txt.key}</strong>
</td>
<td>
<form
action={updateEmulatorText}
className="flex gap-2"
>
<input type="hidden" name="key" value={t.key} />
<input type="hidden" name="key" value={txt.key} />
<input
name="value"
defaultValue={t.value}
defaultValue={txt.value}
maxLength={4096}
className="flex-1"
/>
<button type="submit" className="btn btn-outline">
Save
{t("save")}
</button>
</form>
</td>
@@ -170,12 +174,11 @@ export default async function AdminEmulator() {
</tbody>
</table>
{texts.length === 0 ? (
<div className="admin-empty">No emulator texts found.</div>
<div className="admin-empty">{t("noTexts")}</div>
) : null}
{textsTruncated ? (
<p className="muted">
Only the first {texts.length} of {textsCount} text keys are shown. Use the
add/overwrite form above to edit a key beyond this list.
{t("truncatedHint", { shown: texts.length, total: textsCount })}
</p>
) : null}
</section>
+12 -10
View File
@@ -1,4 +1,5 @@
import Link from "next/link";
import { getTranslations } from "next-intl/server";
import { HelpCircle } from "lucide-react";
import { StatusCard } from "@/components/admin/dashboard";
import { prisma } from "@/lib/prisma";
@@ -6,6 +7,7 @@ import { prisma } from "@/lib/prisma";
export const dynamic = "force-dynamic";
export default async function AdminHelpQuestions() {
const t = await getTranslations("pages.admin.helpCenter");
let entries: Awaited<
ReturnType<typeof prisma.websiteHelpCenterCategories.findMany>
> = [];
@@ -28,30 +30,30 @@ export default async function AdminHelpQuestions() {
<HelpCircle size={20} className="text-[var(--color-primary)]" />
</div>
<div className="flex-1">
<h1 className="m-0 text-xl font-extrabold text-[var(--color-text)]">Help Center</h1>
<p className="m-0 text-xs text-[var(--color-text-muted)] mt-0.5">Help-center FAQ entries (<code>website_help_center_categories</code>). Shown to visitors on the public help page, ordered by position.</p>
<h1 className="m-0 text-xl font-extrabold text-[var(--color-text)]">{t("title")}</h1>
<p className="m-0 text-xs text-[var(--color-text-muted)] mt-0.5">{t("subtitle")}</p>
</div>
<Link href="/admin/help-questions/new" className="btn btn-primary">
+ New entry
+ {t("newEntry")}
</Link>
</div>
<div className="grid grid-cols-[repeat(auto-fit,minmax(180px,1fr))] gap-3.5 mb-6">
<StatusCard label="Entries" value={entries.length} icon="❓" />
<StatusCard label="With button" value={withButton} icon="🔘" />
<StatusCard label="Small boxes" value={smallBoxes} icon="🗂️" />
<StatusCard label={t("entries")} value={entries.length} icon="❓" />
<StatusCard label={t("withButton")} value={withButton} icon="🔘" />
<StatusCard label={t("smallBoxes")} value={smallBoxes} icon="🗂️" />
</div>
<section className="mt-6">
<h2 className="admin-section-title">Entries</h2>
<h2 className="admin-section-title">{t("entries")}</h2>
{entries.length === 0 ? (
<div className="admin-empty">No help-center entries yet.</div>
<div className="admin-empty">{t("noEntries")}</div>
) : (
<table>
<thead>
<tr>
<th>Pos.</th>
<th>Name</th>
<th>{t("colTitle")}</th>
<th>Button</th>
<th>Small box</th>
<th />
@@ -71,7 +73,7 @@ export default async function AdminHelpQuestions() {
</span>
</td>
<td>
<Link href={`/admin/help-questions/${e.id}`}>Edit</Link>
<Link href={`/admin/help-questions/${e.id}`}>{t("edit")}</Link>
</td>
</tr>
))}
+27 -26
View File
@@ -1,4 +1,5 @@
import Link from "next/link";
import { getTranslations } from "next-intl/server";
import { deletePermission, upsertPermission } from "@/actions/admin-housekeeping";
import { StatusCard } from "@/components/admin/dashboard";
import { prisma } from "@/lib/prisma";
@@ -10,6 +11,8 @@ export default async function AdminHousekeeping({
}: {
searchParams: Promise<{ q?: string; mode?: "manage" | "import" }>;
}) {
const t = await getTranslations("pages.admin.housekeeping");
const sp = await searchParams;
const q = (sp.q ?? "").trim();
const mode = sp.mode === "import" ? "import" : "manage";
@@ -49,50 +52,48 @@ export default async function AdminHousekeeping({
<span className="text-lg">🔑</span>
</div>
<div>
<h1 className="m-0 text-xl font-extrabold text-[var(--color-text)]">Housekeeping Permissions</h1>
<p className="m-0 text-xs text-[var(--color-text-muted)] mt-0.5">
Minimum rank required to access each housekeeping permission key.
</p>
<h1 className="m-0 text-xl font-extrabold text-[var(--color-text)]">{t("title")}</h1>
<p className="m-0 text-xs text-[var(--color-text-muted)] mt-0.5">{t("subtitle")}</p>
</div>
</div>
<div className="admin-stat-row">
<span className="admin-stat-chip">{permissions.length} permissions</span>
<span className="admin-stat-chip">Highest min rank: {maxRank}</span>
<span className="admin-stat-chip">Total min rank: {totalMin}</span>
<span className="admin-stat-chip">{t("permissionsCount", { count: permissions.length })}</span>
<span className="admin-stat-chip">{t("highestMinRank", { rank: maxRank })}</span>
<span className="admin-stat-chip">{t("totalMinRank", { total: totalMin })}</span>
</div>
<div className="admin-filter-bar">
<form className="flex gap-2 flex-1">
<input name="q" defaultValue={q} placeholder="Search permission key..." className="flex-1 min-w-[200px]" />
<button type="submit" className="btn btn-primary">Search</button>
<input name="q" defaultValue={q} placeholder={t("searchPermission")} className="flex-1 min-w-[200px]" />
<button type="submit" className="btn btn-primary">{t("search")}</button>
</form>
<Link href="/admin/housekeeping?mode=manage" className="btn btn-outline">Manage</Link>
<Link href="/admin/housekeeping?mode=import&form=selected_ids" className="btn btn-outline">Import selection</Link>
<Link href="/admin/housekeeping?mode=manage" className="btn btn-outline">{t("manage")}</Link>
<Link href="/admin/housekeeping?mode=import&form=selected_ids" className="btn btn-outline">{t("importSelection")}</Link>
</div>
{mode === "manage" ? (
<section>
<h2 className="admin-section-title">Add / overwrite permission</h2>
<h2 className="admin-section-title">{t("addPermission")}</h2>
<form action={upsertPermission} className="admin-card">
<div className="flex gap-2 flex-wrap">
<input name="permission" placeholder="permission key" required className="min-w-[200px]" />
<input name="minRank" type="number" min={0} placeholder="Min rank" required className="w-[110px]" />
<input name="description" placeholder="description" className="flex-1 min-w-[160px]" />
<button type="submit" className="btn btn-primary">Save</button>
<input name="permission" placeholder={t("permissionKey")} required className="min-w-[200px]" />
<input name="minRank" type="number" min={0} placeholder={t("minRank")} required className="w-[110px]" />
<input name="description" placeholder={t("description")} className="flex-1 min-w-[160px]" />
<button type="submit" className="btn btn-primary">{t("save")}</button>
</div>
</form>
</section>
) : (
<section>
<h2 className="admin-section-title">Import Permissions</h2>
<h2 className="admin-section-title">{t("importPermissions")}</h2>
<p className="text-xs text-[var(--color-text-muted)] -mt-2 mb-3">
Paste comma-separated permission IDs to import them as a selection.
{t("importHint")}
</p>
<form action={upsertPermission} className="admin-card">
<div className="flex gap-2 flex-wrap">
<input name="q" placeholder="Permission IDs (comma-separated)" required className="flex-1 min-w-[200px]" />
<button type="submit" className="btn btn-primary">Import</button>
<input name="q" placeholder={t("importPlaceholder")} required className="flex-1 min-w-[200px]" />
<button type="submit" className="btn btn-primary">{t("import")}</button>
</div>
</form>
</section>
@@ -100,19 +101,19 @@ export default async function AdminHousekeeping({
<section>
<h2 className="admin-section-title">
Permissions <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)]">{filtered.length}</span>
{t("permissions")} <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)]">{filtered.length}</span>
</h2>
{filtered.length === 0 ? (
<div className="admin-empty">No housekeeping permissions match this search.</div>
<div className="admin-empty">{t("noPermissions")}</div>
) : (
<div className="overflow-x-auto">
<table>
<thead>
<tr>
<th className="w-[40px]">#</th>
<th>Permission</th>
<th>Description</th>
<th className="w-[200px]">Min rank</th>
<th>{t("colPermission")}</th>
<th>{t("colDescription")}</th>
<th className="w-[200px]">{t("colMinRank")}</th>
<th className="w-[80px]" />
</tr>
</thead>
@@ -136,7 +137,7 @@ export default async function AdminHousekeeping({
style={{ width: 90 }}
/>
<button type="submit" className="btn btn-outline">
Save
{t("save")}
</button>
</form>
</td>
+24 -22
View File
@@ -7,6 +7,7 @@ import {
} from "@/actions/admin-ip";
import { StatusCard } from "@/components/admin/dashboard";
import { prisma } from "@/lib/prisma";
import { getTranslations } from "next-intl/server";
export const dynamic = "force-dynamic";
@@ -22,6 +23,7 @@ function formatDate(d: Date | null): string {
}
export default async function AdminIp() {
const t = await getTranslations("pages.admin.ip");
let whitelist: IpRow[] = [];
let blacklist: IpRow[] = [];
@@ -50,20 +52,20 @@ export default async function AdminIp() {
<Shield size={20} className="text-[var(--color-primary)]" />
</div>
<div>
<h1 className="m-0 text-xl font-extrabold text-[var(--color-text)]">IP Management</h1>
<p className="m-0 text-xs text-[var(--color-text-muted)] mt-0.5">Allow or block IP addresses (and ASNs) at the website edge.</p>
<h1 className="m-0 text-xl font-extrabold text-[var(--color-text)]">{t("title")}</h1>
<p className="m-0 text-xs text-[var(--color-text-muted)] mt-0.5">{t("subtitle")}</p>
</div>
</div>
<div className="grid grid-cols-[repeat(auto-fit,minmax(180px,1fr))] gap-3.5 mb-6">
<StatusCard
label="Whitelisted"
label={t("whitelisted")}
value={whitelist.length}
state={whitelist.length > 0 ? "ok" : "neutral"}
icon="✅"
/>
<StatusCard
label="Blacklisted"
label={t("blacklisted")}
value={blacklist.length}
state={blacklist.length > 0 ? "danger" : "neutral"}
icon="🚫"
@@ -73,33 +75,33 @@ export default async function AdminIp() {
<div className="grid cols-2">
{/* ── Whitelist ──────────────────── */}
<section className="mt-6">
<h2 className="admin-section-title">Whitelist</h2>
<h2 className="admin-section-title">{t("whitelist")}</h2>
<form action={addWhitelist} className="admin-card mb-4">
<div className="flex gap-2 flex-wrap">
<input
name="ipAddress"
placeholder="IP address"
placeholder={t("ipAddressPlaceholder")}
required
className="flex-1 min-w-[140px]"
/>
<input name="asn" placeholder="ASN (optional)" className="w-[140px]" />
<input name="asn" placeholder={t("asnPlaceholder")} className="w-[140px]" />
<button type="submit" className="btn btn-secondary">
Add
{t("add")}
</button>
</div>
</form>
{whitelist.length === 0 ? (
<div className="admin-empty">No whitelisted IPs.</div>
<div className="admin-empty">{t("noWhitelistedIps")}</div>
) : (
<div className="overflow-x-auto">
<table>
<thead>
<tr>
<th>IP</th>
<th>ASN</th>
<th>Added</th>
<th>{t("colIp")}</th>
<th>{t("colAsn")}</th>
<th>{t("colAdded")}</th>
<th />
</tr>
</thead>
@@ -113,7 +115,7 @@ export default async function AdminIp() {
<form action={deleteWhitelist}>
<input type="hidden" name="id" value={String(w.id)} />
<button type="submit" className="btn btn-danger">
Remove
{t("remove")}
</button>
</form>
</td>
@@ -127,33 +129,33 @@ export default async function AdminIp() {
{/* ── Blacklist ──────────────────── */}
<section className="mt-6">
<h2 className="admin-section-title">Blacklist</h2>
<h2 className="admin-section-title">{t("blacklist")}</h2>
<form action={addBlacklist} className="admin-card mb-4">
<div className="flex gap-2 flex-wrap">
<input
name="ipAddress"
placeholder="IP address"
placeholder={t("ipAddressPlaceholder")}
required
className="flex-1 min-w-[140px]"
/>
<input name="asn" placeholder="ASN (optional)" className="w-[140px]" />
<input name="asn" placeholder={t("asnPlaceholder")} className="w-[140px]" />
<button type="submit" className="btn btn-danger">
Add
{t("add")}
</button>
</div>
</form>
{blacklist.length === 0 ? (
<div className="admin-empty">No blacklisted IPs.</div>
<div className="admin-empty">{t("noBlacklistedIps")}</div>
) : (
<div className="overflow-x-auto">
<table>
<thead>
<tr>
<th>IP</th>
<th>ASN</th>
<th>Added</th>
<th>{t("colIp")}</th>
<th>{t("colAsn")}</th>
<th>{t("colAdded")}</th>
<th />
</tr>
</thead>
@@ -167,7 +169,7 @@ export default async function AdminIp() {
<form action={deleteBlacklist}>
<input type="hidden" name="id" value={String(b.id)} />
<button type="submit" className="btn btn-danger">
Remove
{t("remove")}
</button>
</form>
</td>
+85 -76
View File
@@ -1,5 +1,6 @@
import { redirect } from "next/navigation";
import type { ReactNode } from "react";
import { getTranslations } from "next-intl/server";
import { AdminNavLink } from "@/components/admin/admin-nav-link";
import { requireStaff } from "@/lib/admin/guard";
import { prisma } from "@/lib/prisma";
@@ -15,77 +16,81 @@ import {
export const dynamic = "force-dynamic";
const NAV_GROUPS: { label: string; icon: ReactNode; items: { href: string; label: string; icon: ReactNode }[] }[] = [
{
label: "Overview", icon: <LayoutDashboard size={14} />,
items: [{ href: "/admin", label: "Dashboard", icon: <LayoutDashboard size={16} /> }],
},
{
label: "Content", icon: <Newspaper size={14} />,
items: [
{ href: "/admin/articles", label: "Articles", icon: <Newspaper size={16} /> },
{ href: "/admin/tags", label: "Tags", icon: <Tags size={16} /> },
{ href: "/admin/navigation", label: "Navigator", icon: <Compass size={16} /> },
{ href: "/admin/help-questions", label: "Help center", icon: <HelpCircle size={16} /> },
{ href: "/admin/ads", label: "Advertisements", icon: <Megaphone size={16} /> },
{ href: "/admin/writeable-boxes", label: "Writeable boxes", icon: <SquarePen size={16} /> },
{ href: "/admin/photos", label: "Photos", icon: <Image size={16} /> },
],
},
{
label: "Users & access", icon: <Shield size={14} />,
items: [
{ href: "/admin/users", label: "Users", icon: <Users size={16} /> },
{ href: "/admin/bans", label: "Bans", icon: <Ban size={16} /> },
{ href: "/admin/ip", label: "IP management", icon: <Globe size={16} /> },
{ href: "/admin/vpn", label: "VPN", icon: <Shield size={16} /> },
{ href: "/admin/applications", label: "Applications", icon: <ClipboardList size={16} /> },
{ href: "/admin/teams", label: "Teams", icon: <Group size={16} /> },
{ href: "/admin/permissions", label: "Permissions", icon: <Key size={16} /> },
],
},
{
label: "Economy", icon: <ShoppingCart size={14} />,
items: [
{ href: "/admin/catalog", label: "Catalog", icon: <Store size={16} /> },
{ href: "/admin/shop", label: "Shop", icon: <ShoppingCart size={16} /> },
{ href: "/admin/transactions", label: "Transactions", icon: <Receipt size={16} /> },
{ href: "/admin/vouchers", label: "Vouchers", icon: <Ticket size={16} /> },
{ href: "/admin/subscriptions", label: "Subscriptions", icon: <CreditCard size={16} /> },
{ href: "/admin/rare-values", label: "Rare values", icon: <Gem size={16} /> },
{ href: "/admin/badges", label: "Badges", icon: <Award size={16} /> },
{ href: "/admin/achievements", label: "Achievements", icon: <Trophy size={16} /> },
{ href: "/admin/calendar", label: "Calendar", icon: <Calendar size={16} /> },
],
},
{
label: "Radio", icon: <Radio size={14} />,
items: [
{ href: "/admin/radio", label: "Radio", icon: <Radio size={16} /> },
{ href: "/admin/radio/history", label: "Radio history", icon: <History size={16} /> },
],
},
{
label: "System", icon: <Settings size={14} />,
items: [
{ href: "/admin/commandocentrum", label: "Commandocentrum", icon: <Terminal size={16} /> },
{ href: "/admin/emulator", label: "Emulator", icon: <Monitor size={16} /> },
{ href: "/admin/alerts", label: "Alerts", icon: <Bell size={16} /> },
{ href: "/admin/logs", label: "Logs", icon: <FileText size={16} /> },
{ href: "/admin/wordfilter", label: "Word filter", icon: <Filter size={16} /> },
{ href: "/admin/maintenance", label: "Maintenance", icon: <Wrench size={16} /> },
{ href: "/admin/housekeeping", label: "Housekeeping", icon: <Settings size={16} /> },
{ href: "/admin/rooms", label: "Rooms", icon: <DoorOpen size={16} /> },
{ href: "/admin/email-templates", label: "Email templates", icon: <Mail size={16} /> },
{ href: "/admin/theme", label: "Theme", icon: <Palette size={16} /> },
{ href: "/admin/media", label: "Media", icon: <Image size={16} /> },
{ href: "/admin/settings", label: "Settings", icon: <Cog size={16} /> },
],
},
];
function getNavGroups(t: (key: string) => string) {
return [
{
label: t("overview"), icon: <LayoutDashboard size={14} />,
items: [{ href: "/admin", label: t("dashboard"), icon: <LayoutDashboard size={16} /> }],
},
{
label: t("content"), icon: <Newspaper size={14} />,
items: [
{ href: "/admin/articles", label: t("articles"), icon: <Newspaper size={16} /> },
{ href: "/admin/tags", label: t("tags"), icon: <Tags size={16} /> },
{ href: "/admin/navigation", label: t("navigator"), icon: <Compass size={16} /> },
{ href: "/admin/help-questions", label: t("helpCenter"), icon: <HelpCircle size={16} /> },
{ href: "/admin/ads", label: t("advertisements"), icon: <Megaphone size={16} /> },
{ href: "/admin/writeable-boxes", label: t("writeableBoxes"), icon: <SquarePen size={16} /> },
{ href: "/admin/photos", label: t("photos"), icon: <Image size={16} /> },
],
},
{
label: t("usersAccess"), icon: <Shield size={14} />,
items: [
{ href: "/admin/users", label: t("users"), icon: <Users size={16} /> },
{ href: "/admin/bans", label: t("bans"), icon: <Ban size={16} /> },
{ href: "/admin/ip", label: t("ipManagement"), icon: <Globe size={16} /> },
{ href: "/admin/vpn", label: t("vpn"), icon: <Shield size={16} /> },
{ href: "/admin/applications", label: t("applications"), icon: <ClipboardList size={16} /> },
{ href: "/admin/teams", label: t("teams"), icon: <Group size={16} /> },
{ href: "/admin/permissions", label: t("permissions"), icon: <Key size={16} /> },
],
},
{
label: t("economy"), icon: <ShoppingCart size={14} />,
items: [
{ href: "/admin/catalog", label: t("catalog"), icon: <Store size={16} /> },
{ href: "/admin/shop", label: t("shop"), icon: <ShoppingCart size={16} /> },
{ href: "/admin/transactions", label: t("transactions"), icon: <Receipt size={16} /> },
{ href: "/admin/vouchers", label: t("vouchers"), icon: <Ticket size={16} /> },
{ href: "/admin/subscriptions", label: t("subscriptions"), icon: <CreditCard size={16} /> },
{ href: "/admin/rare-values", label: t("rareValues"), icon: <Gem size={16} /> },
{ href: "/admin/badges", label: t("badges"), icon: <Award size={16} /> },
{ href: "/admin/achievements", label: t("achievements"), icon: <Trophy size={16} /> },
{ href: "/admin/calendar", label: t("calendar"), icon: <Calendar size={16} /> },
],
},
{
label: t("radio"), icon: <Radio size={14} />,
items: [
{ href: "/admin/radio", label: t("radio"), icon: <Radio size={16} /> },
{ href: "/admin/radio/history", label: t("radioHistory"), icon: <History size={16} /> },
],
},
{
label: t("system"), icon: <Settings size={14} />,
items: [
{ href: "/admin/commandocentrum", label: t("commandocentrum"), icon: <Terminal size={16} /> },
{ href: "/admin/emulator", label: t("emulator"), icon: <Monitor size={16} /> },
{ href: "/admin/alerts", label: t("alerts"), icon: <Bell size={16} /> },
{ href: "/admin/logs", label: t("logs"), icon: <FileText size={16} /> },
{ href: "/admin/wordfilter", label: t("wordFilter"), icon: <Filter size={16} /> },
{ href: "/admin/maintenance", label: t("maintenance"), icon: <Wrench size={16} /> },
{ href: "/admin/housekeeping", label: t("housekeeping"), icon: <Settings size={16} /> },
{ href: "/admin/rooms", label: t("rooms"), icon: <DoorOpen size={16} /> },
{ href: "/admin/email-templates", label: t("emailTemplates"), icon: <Mail size={16} /> },
{ href: "/admin/theme", label: t("theme"), icon: <Palette size={16} /> },
{ href: "/admin/media", label: t("media"), icon: <Image size={16} /> },
{ href: "/admin/settings", label: t("settings"), icon: <Cog size={16} /> },
],
},
];
}
export default async function AdminLayout({ children }: { children: ReactNode }) {
const staff = await requireStaff();
const t = await getTranslations("pages.admin.nav");
const navGroups = getNavGroups(t);
if (await siteSettings.getBool("force_staff_2fa", false)) {
const u = await prisma.user
@@ -105,8 +110,10 @@ export default async function AdminLayout({ children }: { children: ReactNode })
);
}
function Sidebar({ staff }: { staff: { id: number; username: string; rank: number } }) {
async function Sidebar({ staff }: { staff: { id: number; username: string; rank: number } }) {
const t = await getTranslations("pages.admin.nav");
const initial = staff.username.charAt(0).toUpperCase();
const navGroups = getNavGroups(t);
return (
<aside className="sticky top-0 self-start h-screen bg-gradient-to-b from-[#1e1b2e] to-[#151226] text-[#c8cbe0] shadow-xl flex flex-col">
@@ -116,12 +123,12 @@ function Sidebar({ staff }: { staff: { id: number; username: string; rank: numbe
</span>
<div className="min-w-0 flex-1">
<div className="font-semibold text-sm text-white truncate">{staff.username}</div>
<span className="inline-block mt-0.5 text-[0.6rem] font-bold uppercase tracking-widest text-[rgba(200,203,224,0.5)]">rank {staff.rank}</span>
<span className="inline-block mt-0.5 text-[0.6rem] font-bold uppercase tracking-widest text-[rgba(200,203,224,0.5)]">{t("rankLabel", { rank: staff.rank })}</span>
</div>
</div>
<nav className="flex-1 overflow-y-auto px-3 py-4 space-y-1">
{NAV_GROUPS.map((group) => (
{navGroups.map((group) => (
<div key={group.label}>
<div className="flex items-center gap-1.5 px-2 py-2 text-[0.6rem] font-bold uppercase tracking-[0.12em] text-[rgba(200,203,224,0.3)]">
{group.icon}
@@ -144,21 +151,23 @@ function Sidebar({ staff }: { staff: { id: number; username: string; rank: numbe
className="flex items-center gap-2.5 px-2.5 py-2 rounded-lg text-[#c8cbe0]/60 text-xs font-medium hover:text-white hover:bg-white/5 transition-all duration-150 no-underline"
>
<LogOut size={14} />
<span>Back to site</span>
<span>{t("backToSite")}</span>
</a>
</div>
</aside>
);
}
function Topbar({ staff }: { staff: { id: number; username: string; rank: number } }) {
async function Topbar({ staff }: { staff: { id: number; username: string; rank: number } }) {
const t = await getTranslations("pages.admin.nav");
return (
<header className="flex items-center justify-between gap-4 bg-[var(--color-surface)] border border-[var(--border)] rounded-2xl px-5 py-3 mb-5 shadow-card">
<div className="flex items-center gap-3">
<h2 className="m-0 text-base font-extrabold text-[var(--color-text)]">Housekeeping</h2>
<h2 className="m-0 text-base font-extrabold text-[var(--color-text)]">{t("housekeeping")}</h2>
<span className="hidden sm:inline-block w-px h-5 bg-[var(--border)]" />
<span className="hidden sm:inline text-xs text-[var(--color-text-muted)]">
Hotel management panel
{t("hotelManagementPanel")}
</span>
</div>
<div className="flex items-center gap-3">
@@ -166,7 +175,7 @@ function Topbar({ staff }: { staff: { id: number; username: string; rank: number
<strong className="text-[var(--color-text)]">{staff.username}</strong>
</span>
<span className="inline-flex items-center gap-1.5 text-[0.6rem] font-bold uppercase tracking-wider text-[#1a1a2e] bg-[var(--color-primary)] rounded-full px-2.5 py-1">
rank {staff.rank}
{t("rankLabel", { rank: staff.rank })}
</span>
</div>
</header>
+25 -23
View File
@@ -1,3 +1,4 @@
import { getTranslations } from "next-intl/server";
import { ScrollText } from "lucide-react";
import { StatusCard } from "@/components/admin/dashboard";
import { prisma } from "@/lib/prisma";
@@ -15,6 +16,8 @@ export default async function AdminStaffActivities({
}: {
searchParams: Promise<{ q?: string; page?: string; staffId?: string; action?: string }>;
}) {
const t = await getTranslations("pages.admin.logs");
const sp = await searchParams;
const q = (sp.q ?? "").trim();
const page = Math.max(1, Number(sp.page ?? "1") || 1);
@@ -87,16 +90,16 @@ export default async function AdminStaffActivities({
<ScrollText size={20} className="text-[var(--color-primary)]" />
</div>
<div>
<h1 className="m-0 text-xl font-extrabold text-[var(--color-text)]">Staff Activities</h1>
<p className="m-0 text-xs text-[var(--color-text-muted)] mt-0.5">Auditor trail of all staff actions in the hotel.</p>
<h1 className="m-0 text-xl font-extrabold text-[var(--color-text)]">{t("title")}</h1>
<p className="m-0 text-xs text-[var(--color-text-muted)] mt-0.5">{t("subtitle")}</p>
</div>
</div>
<div className="grid grid-cols-[repeat(auto-fit,minmax(180px,1fr))] gap-3.5 mb-6">
<StatusCard label="Total activities" value={total} icon="📋" />
<StatusCard label="On this page" value={activities.length} icon="📄" />
<StatusCard label={t("totalActivities")} value={total} icon="📋" />
<StatusCard label={t("onThisPage")} value={activities.length} icon="📄" />
<StatusCard
label="Recent (≤1min)"
label={t("recent")}
value={onlineCount}
state={onlineCount > 0 ? "ok" : "neutral"}
icon="🟢"
@@ -107,61 +110,61 @@ export default async function AdminStaffActivities({
<input
name="q"
defaultValue={q}
placeholder="Search action, description, IP..."
placeholder={t("searchPlaceholder")}
className="flex-1 min-w-[200px]"
/>
<input
name="staffId"
defaultValue={staffId?.toString()}
placeholder="Staff ID"
placeholder={t("staffId")}
className="w-[120px]"
/>
<input
name="action"
defaultValue={action ?? ""}
placeholder="Action type (e.g., rank_change)"
placeholder={t("actionType")}
className="w-[160px]"
/>
<button type="submit" className="btn btn-primary">
Filter
{t("filter")}
</button>
{(q || staffId || action) && (
<a href="/admin/logs" className="btn btn-outline">
Clear filters
{t("clearFilters")}
</a>
)}
</form>
<section className="mt-6">
<h2 className="admin-section-title">Activities ({total})</h2>
<h2 className="admin-section-title">{t("activities", { count: total })}</h2>
{activities.length === 0 ? (
<div className="admin-empty">No staff activities match this search.</div>
<div className="admin-empty">{t("noResults")}</div>
) : (
<div className="overflow-x-auto">
<table>
<thead>
<tr>
<th>When</th>
<th>Staff</th>
<th>Action</th>
<th>Description</th>
<th>Target</th>
<th>IP Address</th>
<th>{t("colWhen")}</th>
<th>{t("colStaff")}</th>
<th>{t("colAction")}</th>
<th>{t("colDescription")}</th>
<th>{t("colTarget")}</th>
<th>{t("colIp")}</th>
</tr>
</thead>
<tbody>
{activities.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 className="text-sm text-gray-500 dark:text-gray-400 whitespace-nowrap">#{Number(a.userId)} ({nameById.get(Number(a.userId)) ?? "unknown"})</td>
<td className="text-sm text-gray-500 dark:text-gray-400 whitespace-nowrap">{t("staffWithId", { id: Number(a.userId), name: nameById.get(Number(a.userId)) ?? "unknown" })}</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>
<td>
{a.targetType && a.targetId ? (
<span className="text-sm text-gray-500 dark:text-gray-400">{a.targetType === "user" ? "User " : ""}#{String(a.targetId)}</span>
<span className="text-sm text-gray-500 dark:text-gray-400">{a.targetType === "user" ? t("userPrefix") : ""}#{String(a.targetId)}</span>
) : (
<span className="text-sm text-gray-500 dark:text-gray-400">—</span>
)}
@@ -176,15 +179,14 @@ export default async function AdminStaffActivities({
</section>
<p className="text-xs text-gray-500 dark:text-gray-400">
Page {page} / {pages} · {total} activities
{t("pagination", { page, pages, total })}
{page < pages ? (
<>
{" · "}
<a href={`/admin/logs?q=${encodeURIComponent(q)}&staffId=${staffId || ""}&action=${action || ""}&page=${page + 1}`}>Next →</a>
<a href={`/admin/logs?q=${encodeURIComponent(q)}&staffId=${staffId || ""}&action=${action || ""}&page=${page + 1}`}>{t("next")} →</a>
</>
) : null}
</p>
</main>
);
}
+23 -24
View File
@@ -1,3 +1,4 @@
import { getTranslations } from "next-intl/server";
import { Wrench } from "lucide-react";
import { saveMaintenance } from "@/actions/admin-maintenance";
import { StatusCard } from "@/components/admin/dashboard";
@@ -12,8 +13,8 @@ const KEYS = [
] as const;
export default async function AdminMaintenance() {
// Read the three maintenance rows from website_settings. Isolated so a DB
// hiccup degrades to defaults rather than 500-ing the page.
const t = await getTranslations("pages.admin.maintenance");
let rows: { key: string; value: string }[] = [];
try {
rows = await prisma.websiteSetting.findMany({
@@ -40,56 +41,54 @@ export default async function AdminMaintenance() {
<Wrench size={20} className="text-[var(--color-primary)]" />
</div>
<div>
<h1 className="m-0 text-xl font-extrabold text-[var(--color-text)]">Maintenance Mode</h1>
<p className="m-0 text-xs text-[var(--color-text-muted)] mt-0.5">While maintenance is enabled, only staff (and users at or above the minimum login rank) can log into the hotel. These values are stored in <code>website_settings</code> and read live by the login flow.</p>
<h1 className="m-0 text-xl font-extrabold text-[var(--color-text)]">{t("title")}</h1>
<p className="m-0 text-xs text-[var(--color-text-muted)] mt-0.5">{t.rich("subtitle", { code: (chunks) => <code>{chunks}</code> })}</p>
</div>
</div>
<div className="grid grid-cols-[repeat(auto-fit,minmax(180px,1fr))] gap-3.5 mb-6">
<StatusCard
label="Maintenance"
value={enabled ? "ENABLED" : "Disabled"}
label={t("maintenance")}
value={enabled ? t("enabled") : t("disabled")}
state={enabled ? "danger" : "ok"}
hint={enabled ? "Hotel is locked down" : "Hotel is open to everyone"}
hint={enabled ? t("hotelLockedDown") : t("hotelOpen")}
icon={enabled ? "🚧" : "🟢"}
/>
<StatusCard
label="Min login rank"
label={t("minLoginRank")}
value={minRankValue}
hint="During maintenance"
hint={t("duringMaintenance")}
icon="🛡️"
/>
<StatusCard
label="Splash message"
value={message ? "Set" : "Empty"}
label={t("splashMessage")}
value={message ? t("set") : t("empty")}
state={message ? "ok" : "neutral"}
icon="💬"
/>
</div>
{/* ── Current state ─────────────────────────────────────── */}
<section className="mt-6">
<h2 className="admin-section-title">Current state</h2>
<h2 className="admin-section-title">{t("currentState")}</h2>
<div className="admin-card flex items-center gap-3">
<span className={`inline-block text-[0.72rem] font-bold px-2 py-0.5 rounded-full ${enabled ? "bg-red-500/22 text-[var(--color-text)]" : "bg-green-600/20 text-[var(--color-text)]"}`}>
{enabled ? "ENABLED" : "disabled"}
{enabled ? t("enabled") : t("disabled")}
</span>
<div>
<strong>
Maintenance is currently {enabled ? "ENABLED" : "disabled"}.
{t("maintenanceCurrently", { status: enabled ? t("enabled") : t("disabled") })}
</strong>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1 m-0">
{enabled
? `Only users with rank ${minRankValue} or higher can log in right now.`
: "The hotel is open to everyone."}
? t("onlyRankCanLogin", { rank: minRankValue })
: t("hotelOpenToEveryone")}
</p>
</div>
</div>
</section>
{/* ── Edit form ─────────────────────────────────────────── */}
<section className="mt-6">
<h2 className="admin-section-title">Settings</h2>
<h2 className="admin-section-title">{t("settings")}</h2>
<form action={saveMaintenance} className="admin-card">
<label htmlFor="mt-enabled" className="flex items-center gap-2 mb-4">
<input
@@ -100,12 +99,12 @@ export default async function AdminMaintenance() {
defaultChecked={enabled}
className="w-auto"
/>
<span>Enable maintenance mode</span>
<span>{t("enableMaintenance")}</span>
</label>
<div className="mb-4 max-w-[220px]">
<label htmlFor="mt-min-rank" className="text-sm text-gray-500 dark:text-gray-400 block mb-1">
Minimum login rank during maintenance
{t("minLoginRankLabel")}
</label>
<input
id="mt-min-rank"
@@ -120,20 +119,20 @@ export default async function AdminMaintenance() {
<div className="mb-4">
<label htmlFor="mt-message" className="text-sm text-gray-500 dark:text-gray-400 block mb-1">
Maintenance message (shown on the splash; basic HTML allowed)
{t("maintenanceMessageLabel")}
</label>
<textarea
id="mt-message"
name="message"
rows={8}
defaultValue={message}
placeholder="We're working hard on the hotel — back soon!"
placeholder={t("maintenanceMessagePlaceholder")}
className="w-full resize-y"
/>
</div>
<button type="submit" className="btn btn-primary">
Save changes
{t("saveChanges")}
</button>
</form>
</section>
+7 -4
View File
@@ -1,8 +1,11 @@
import Link from "next/link";
import { getTranslations } from "next-intl/server";
import { Image } from "lucide-react";
import { AdminMediaGrid } from "@/components/admin/media-grid";
export default function AdminMediaPage() {
export default async function AdminMediaPage() {
const t = await getTranslations("pages.admin.media");
return (
<main>
<div className="flex items-center gap-3 mb-6">
@@ -10,10 +13,10 @@ export default function AdminMediaPage() {
<Image size={20} className="text-[var(--color-primary)]" />
</div>
<div className="flex-1">
<h1 className="m-0 text-xl font-extrabold text-[var(--color-text)]">Media Library</h1>
<p className="m-0 text-xs text-[var(--color-text-muted)] mt-0.5">Upload and manage images for articles and other content.</p>
<h1 className="m-0 text-xl font-extrabold text-[var(--color-text)]">{t("title")}</h1>
<p className="m-0 text-xs text-[var(--color-text-muted)] mt-0.5">{t("subtitle")}</p>
</div>
<Link href="/admin/articles" className="text-sm text-gray-500 dark:text-gray-400" style={{ fontSize: "0.88rem" }}>← Articles</Link>
<Link href="/admin/articles" className="text-sm text-gray-500 dark:text-gray-400" style={{ fontSize: "0.88rem" }}>← {t("articles")}</Link>
</div>
<AdminMediaGrid />
</main>
+19 -17
View File
@@ -1,3 +1,4 @@
import { getTranslations } from "next-intl/server";
import { Navigation } from "lucide-react";
import { StatusCard } from "@/components/admin/dashboard";
import { prisma } from "@/lib/prisma";
@@ -35,6 +36,7 @@ function YesNo({ on }: { on: boolean }) {
}
export default async function AdminNavigation() {
const t = await getTranslations("pages.admin.navigation");
let flatcats: Flatcat[] = [];
try {
flatcats = await prisma.navigatorFlatcats.findMany({
@@ -108,22 +110,22 @@ export default async function AdminNavigation() {
<Navigation size={20} className="text-[var(--color-primary)]" />
</div>
<div>
<h1 className="m-0 text-xl font-extrabold text-[var(--color-text)]">Navigator</h1>
<p className="m-0 text-xs text-[var(--color-text-muted)] mt-0.5">Read-only · the navigator config is owned by the emulator. Edit it in your emulator tooling, not here.</p>
<h1 className="m-0 text-xl font-extrabold text-[var(--color-text)]">{t("title")}</h1>
<p className="m-0 text-xs text-[var(--color-text-muted)] mt-0.5">{t("subtitle")}</p>
</div>
</div>
<div className="grid grid-cols-[repeat(auto-fit,minmax(180px,1fr))] gap-3.5 mb-6">
<StatusCard label="Flat categories" value={flatcats.length} icon="📂" />
<StatusCard label={t("flatCategories")} value={flatcats.length} icon="📂" />
<StatusCard
label="Public (flatcats)"
label={t("publicFlatcats")}
value={publicFlatcats}
hint="marked public"
hint={t("markedPublic")}
icon="🌐"
/>
<StatusCard label="Public rooms" value={publics.length} icon="🚪" />
<StatusCard label={t("publicRooms")} value={publics.length} icon="🚪" />
<StatusCard
label="Visible rooms"
label={t("visibleRooms")}
value={visiblePublics}
state={visiblePublics > 0 ? "ok" : "neutral"}
icon="👁️"
@@ -132,18 +134,18 @@ export default async function AdminNavigation() {
{/* ── navigator_flatcats ───────────────────────────────── */}
<section className="mt-6">
<h2 className="admin-section-title">Flat categories</h2>
<h2 className="admin-section-title">{t("flatCategories")}</h2>
<p className="text-xs text-gray-500 dark:text-gray-400">
<code>navigator_flatcats</code> · {flatcats.length} rows
<code>navigator_flatcats</code> · {flatcats.length} {t("rows")}
</p>
{flatcats.length === 0 ? (
<div className="admin-empty">No flat categories found.</div>
<div className="admin-empty">{t("noCategories")}</div>
) : (
<div className="overflow-x-auto">
<table>
<thead>
<tr>
<th>ID</th>
<th>{t("colId")}</th>
<th>Caption</th>
<th>Caption (save)</th>
<th>Min rank</th>
@@ -151,7 +153,7 @@ export default async function AdminNavigation() {
<th>Can trade</th>
<th>Public</th>
<th>List type</th>
<th>Order</th>
<th>{t("colOrder")}</th>
</tr>
</thead>
<tbody>
@@ -180,19 +182,19 @@ export default async function AdminNavigation() {
{/* ── navigator_publics ────────────────────────────────── */}
<section className="mt-6">
<h2 className="admin-section-title">Public rooms</h2>
<h2 className="admin-section-title">{t("publicRooms")}</h2>
<p className="text-xs text-gray-500 dark:text-gray-400">
<code>navigator_publics</code> · {publics.length} rows
<code>navigator_publics</code> · {publics.length} {t("rows")}
</p>
{publics.length === 0 ? (
<div className="admin-empty">No public-room assignments found.</div>
<div className="admin-empty">{t("noPublicRooms")}</div>
) : (
<div className="overflow-x-auto">
<table>
<thead>
<tr>
<th>Public cat</th>
<th>Room</th>
<th>{t("colName")}</th>
<th>Visible</th>
</tr>
</thead>
@@ -204,7 +206,7 @@ export default async function AdminNavigation() {
<span className="text-sm text-gray-500 dark:text-gray-400">#{p.public_cat_id}</span>
</td>
<td>
{roomNameById.get(p.room_id) || "(unnamed)"}{" "}
{roomNameById.get(p.room_id) || t("unnamed")}{" "}
<span className="text-sm text-gray-500 dark:text-gray-400">#{p.room_id}</span>
</td>
<td>
+15 -13
View File
@@ -1,4 +1,5 @@
import Link from "next/link";
import { getTranslations } from "next-intl/server";
import { StatusCard } from "@/components/admin/dashboard";
import { prisma } from "@/lib/prisma";
import { Users, Newspaper, Ban, Database, Activity, ArrowRight } from "lucide-react";
@@ -12,6 +13,7 @@ function fromDate(d: Date | null | undefined): string {
type RankCount = { rank: number; count: number };
export default async function AdminDashboard() {
const t = await getTranslations("pages.admin.dashboard");
let users = -1;
let online = 0;
let articles = 0;
@@ -54,31 +56,31 @@ export default async function AdminDashboard() {
<Activity size={20} className="text-[var(--color-primary)]" />
</div>
<div>
<h1 className="m-0 text-xl font-extrabold text-[var(--color-text)]">Dashboard</h1>
<p className="m-0 text-xs text-[var(--color-text-muted)] mt-0.5">Hotel overview &amp; live status</p>
<h1 className="m-0 text-xl font-extrabold text-[var(--color-text)]">{t("title")}</h1>
<p className="m-0 text-xs text-[var(--color-text-muted)] mt-0.5">{t("subtitle")}</p>
</div>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4 mb-8">
<StatusCard label="Players online" value={online} hint={dbOk ? `${users} total accounts` : "DB unavailable"} state={dbOk ? "neutral" : "danger"} icon={<Users size={18} />} />
<StatusCard label="Articles" value={dbOk ? articles : "—"} state="neutral" icon={<Newspaper size={18} />} />
<StatusCard label="Active bans" value={dbOk ? bans : "—"} state={bans > 0 ? "warn" : "neutral"} icon={<Ban size={18} />} />
<StatusCard label="Database" value={dbOk ? "Connected" : "Down"} state={dbOk ? "ok" : "danger"} icon={<Database size={18} />} />
<StatusCard label={t("playersOnline")} value={online} hint={dbOk ? t("totalAccounts", { count: users }) : t("databaseDown")} state={dbOk ? "neutral" : "danger"} icon={<Users size={18} />} />
<StatusCard label={t("articles")} value={dbOk ? articles : "—"} state="neutral" icon={<Newspaper size={18} />} />
<StatusCard label={t("activeBans")} value={dbOk ? bans : "—"} state={bans > 0 ? "warn" : "neutral"} icon={<Ban size={18} />} />
<StatusCard label={t("database")} value={dbOk ? t("databaseConnected") : t("databaseDown")} state={dbOk ? "ok" : "danger"} icon={<Database size={18} />} />
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
<section className="admin-card p-5">
<h2 className="text-sm font-bold text-[var(--color-text)] mb-4 admin-section-title">Users by rank</h2>
<h2 className="text-sm font-bold text-[var(--color-text)] mb-4 admin-section-title">{t("usersByRank")}</h2>
{rankCounts.length === 0 ? (
<p className="text-xs text-[var(--color-text-muted)]">{dbOk ? "No accounts to break down yet." : "DB unavailable."}</p>
<p className="text-xs text-[var(--color-text-muted)]">{dbOk ? t("noAccountsToBreakDown") : t("databaseDown")}</p>
) : (
<div className="space-y-2.5">
{rankCounts.map((r) => {
const pct = maxRankCount > 0 ? Math.round((r.count / maxRankCount) * 100) : 0;
return (
<div key={r.rank} className="grid items-center gap-3" style={{ gridTemplateColumns: "5rem 1fr 2.5rem" }}>
<span className="text-xs font-medium text-[var(--color-text-muted)]">Rank {r.rank}</span>
<div className="h-3 rounded-md overflow-hidden bg-[var(--color-primary)]/10" role="img" aria-label={`Rank ${r.rank}: ${r.count} accounts`}>
<span className="text-xs font-medium text-[var(--color-text-muted)]">{t("rankLabel", { rank: r.rank })}</span>
<div className="h-3 rounded-md overflow-hidden bg-[var(--color-primary)]/10" role="img" aria-label={t("rankWithCount", { rank: r.rank, count: r.count })}>
<div className="h-full rounded-md bg-gradient-to-r from-[var(--color-primary)] to-[var(--color-primary-hover)] transition-all duration-500" style={{ width: `${Math.max(pct, r.count > 0 ? 4 : 0)}%` }} />
</div>
<strong className="text-xs text-right text-[var(--color-text-muted)]">{r.count}</strong>
@@ -91,13 +93,13 @@ export default async function AdminDashboard() {
<section className="admin-card p-5">
<div className="flex items-center justify-between mb-4">
<h2 className="text-sm font-bold text-[var(--color-text)] admin-section-title">Recent staff activity</h2>
<h2 className="text-sm font-bold text-[var(--color-text)] admin-section-title">{t("recentStaffActivity")}</h2>
<Link href="/admin/commandocentrum" className="text-xs font-medium text-[var(--color-primary)] hover:text-[var(--color-primary-hover)] flex items-center gap-1 no-underline">
View all <ArrowRight size={12} />
{t("viewAll")} <ArrowRight size={12} />
</Link>
</div>
{activity.length === 0 ? (
<p className="text-xs text-[var(--color-text-muted)]">No staff activity logged yet.</p>
<p className="text-xs text-[var(--color-text-muted)]">{t("noStaffActivityLogged")}</p>
) : (
<div className="space-y-0">
{activity.map((a, i) => (
+19 -17
View File
@@ -6,6 +6,7 @@ import {
import { StatusCard } from "@/components/admin/dashboard";
import { prisma } from "@/lib/prisma";
import { Shield } from "lucide-react";
import { getTranslations } from "next-intl/server";
export const dynamic = "force-dynamic";
@@ -20,6 +21,7 @@ type PermissionRow = {
};
export default async function AdminPermissions() {
const t = await getTranslations("pages.admin.permissions");
let permissions: PermissionRow[] = [];
try {
permissions = await prisma.websitePermissions.findMany({
@@ -40,52 +42,52 @@ export default async function AdminPermissions() {
<Shield size={20} className="text-[var(--color-primary)]" />
</div>
<div>
<h1 className="m-0 text-xl font-extrabold text-[var(--color-text)]">Permissions</h1>
<p className="m-0 text-xs text-[var(--color-text-muted)] mt-0.5">CMS-owned permissions (<code>website_permissions</code>). Each entry maps a permission name to the minimum rank allowed to use it.</p>
<h1 className="m-0 text-xl font-extrabold text-[var(--color-text)]">{t("title")}</h1>
<p className="m-0 text-xs text-[var(--color-text-muted)] mt-0.5">{t("subtitle")}</p>
</div>
</div>
<div className="grid grid-cols-[repeat(auto-fit,minmax(180px,1fr))] gap-3.5 mb-6">
<StatusCard label="Permissions" value={permissions.length} icon="🔑" />
<StatusCard label="Highest min rank" value={highestRank} icon="🎖️" />
<StatusCard label={t("permissions")} value={permissions.length} icon="🔑" />
<StatusCard label={t("highestMinRank")} value={highestRank} icon="🎖️" />
</div>
<form action={createPermission} className="admin-card mb-6">
<h3 className="text-sm font-bold text-[var(--color-text)] m-0 mb-3">Add / overwrite permission</h3>
<h3 className="text-sm font-bold text-[var(--color-text)] m-0 mb-3">{t("addPermission")}</h3>
<div className="flex gap-2 flex-wrap">
<input name="permission" placeholder="permission" required maxLength={255} />
<input name="permission" placeholder={t("permissionPlaceholder")} required maxLength={255} />
<input
name="minRank"
type="number"
min={0}
placeholder="min rank"
placeholder={t("minRankPlaceholder")}
defaultValue={1}
className="w-[110px]"
/>
<input
name="description"
placeholder="description"
placeholder={t("descriptionPlaceholder")}
className="flex-1 min-w-[160px]"
/>
<button type="submit" className="btn btn-primary">
Save
{t("save")}
</button>
</div>
</form>
<section className="mt-6">
<h2 className="admin-section-title">Permissions ({permissions.length})</h2>
<h2 className="admin-section-title">{t("permissions")} ({permissions.length})</h2>
{permissions.length === 0 ? (
<div className="admin-empty">No permissions found.</div>
<div className="admin-empty">{t("noPermissions")}</div>
) : (
<div className="overflow-x-auto">
<table>
<thead>
<tr>
<th>ID</th>
<th>Permission</th>
<th>Min rank</th>
<th>Description</th>
<th>{t("colId")}</th>
<th>{t("colPermission")}</th>
<th>{t("colMinRank")}</th>
<th>{t("colDescription")}</th>
<th />
</tr>
</thead>
@@ -127,12 +129,12 @@ export default async function AdminPermissions() {
form={`perm-${String(p.id)}`}
className="btn btn-primary"
>
Save
{t("save")}
</button>
<form action={deletePermission}>
<input type="hidden" name="id" value={String(p.id)} />
<button type="submit" className="btn btn-danger">
Delete
{t("delete")}
</button>
</form>
</td>
+13 -11
View File
@@ -1,3 +1,4 @@
import { getTranslations } from "next-intl/server";
import { deletePhoto } from "@/actions/admin-photos";
import { StatusCard } from "@/components/admin/dashboard";
import { prisma } from "@/lib/prisma";
@@ -6,6 +7,7 @@ import { Image } from "lucide-react";
export const dynamic = "force-dynamic";
export default async function AdminPhotos() {
const t = await getTranslations("pages.admin.photos");
let photos: Awaited<ReturnType<typeof prisma.cameraWeb.findMany>> = [];
try {
photos = await prisma.cameraWeb.findMany({
@@ -26,21 +28,21 @@ export default async function AdminPhotos() {
<Image size={20} className="text-[var(--color-primary)]" />
</div>
<div>
<h1 className="m-0 text-xl font-extrabold text-[var(--color-text)]">Photo Moderation</h1>
<p className="m-0 text-xs text-[var(--color-text-muted)] mt-0.5">Recent in-game camera photos. Deleting removes the record from <code>camera_web</code>.</p>
<h1 className="m-0 text-xl font-extrabold text-[var(--color-text)]">{t("title")}</h1>
<p className="m-0 text-xs text-[var(--color-text-muted)] mt-0.5">{t("subtitle")}</p>
</div>
</div>
<div className="grid grid-cols-[repeat(auto-fit,minmax(180px,1fr))] gap-3.5 mb-6">
<StatusCard label="Photos shown" value={photos.length} icon="📸" />
<StatusCard label="Rooms" value={uniqueRooms} hint="distinct rooms" icon="🚪" />
<StatusCard label="Users" value={uniqueUsers} hint="distinct photographers" icon="👤" />
<StatusCard label={t("photosShown")} value={photos.length} icon="📸" />
<StatusCard label={t("rooms")} value={uniqueRooms} hint={t("distinctRooms")} icon="🚪" />
<StatusCard label={t("users")} value={uniqueUsers} hint={t("distinctPhotographers")} icon="👤" />
</div>
<section className="mt-6">
<h2 className="admin-section-title">Recent photos</h2>
<h2 className="admin-section-title">{t("recentPhotos")}</h2>
{photos.length === 0 ? (
<div className="admin-empty">No photos found.</div>
<div className="admin-empty">{t("noPhotos")}</div>
) : (
<div className="grid cols-3">
{photos.map((p) => (
@@ -48,21 +50,21 @@ export default async function AdminPhotos() {
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={p.url}
alt={`Photo ${String(p.id)}`}
alt={t("photoAlt", { id: String(p.id) })}
style={{ width: "100%", height: 200, objectFit: "cover", display: "block", background: "#f1f5f9" }}
/>
<div style={{ padding: "0.85rem 1.05rem 1.05rem" }}>
<div style={{ display: "flex", justifyContent: "space-between", gap: "0.5rem", alignItems: "baseline" }}>
<strong>#{String(p.id)}</strong>
<span className="text-sm text-gray-500 dark:text-gray-400">Room {p.roomId}</span>
<span className="text-sm text-gray-500 dark:text-gray-400">{t("colRoom")} {p.roomId}</span>
</div>
<p className="text-xs text-gray-500 dark:text-gray-400" style={{ margin: "0.3rem 0 0" }}>
User {p.userId} ·{" "}
{t("colUser")} {p.userId} ·{" "}
{new Date(p.timestamp * 1000).toISOString().slice(0, 16).replace("T", " ")}
</p>
<form action={deletePhoto} style={{ marginTop: "0.75rem" }}>
<input type="hidden" name="id" value={String(p.id)} />
<button type="submit" className="btn btn-danger">Delete</button>
<button type="submit" className="btn btn-danger">{t("delete")}</button>
</form>
</div>
</div>
+43 -42
View File
@@ -3,6 +3,7 @@ import { createApiKey, deleteApiKey, toggleApiKey } from "@/actions/admin-radio-
import { StatusCard } from "@/components/admin/dashboard";
import { prisma } from "@/lib/prisma";
import { Key } from "lucide-react";
import { getTranslations } from "next-intl/server";
export const dynamic = "force-dynamic";
@@ -21,15 +22,16 @@ type ApiKey = {
};
function formatDate(d: Date | null): string {
return d ? d.toISOString().slice(0, 16).replace("T", " ") : "—";
return d ? d.toISOString().slice(0, 16).replace("T", " ") : "\u2014";
}
/** Show only the first 8 chars of the secret; the rest stays masked. */
function maskKey(key: string): string {
return key.length > 8 ? `${key.slice(0, 8)}…` : key;
return key.length > 8 ? `${key.slice(0, 8)}\u2026` : key;
}
export default async function AdminRadioApiKeysPage() {
const t = await getTranslations("pages.admin.radio");
let keys: ApiKey[] = [];
let dbError = false;
@@ -66,13 +68,13 @@ export default async function AdminRadioApiKeysPage() {
return (
<main>
<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/api-keys">API Keys</Link> ·{" "}
<Link href="/admin/radio/autodj">AutoDJ</Link> ·{" "}
<Link href="/admin/radio/embed">Embed</Link> ·{" "}
<Link href="/admin/radio/points">Points</Link> ·{" "}
<Link href="/admin/radio/monitoring">Monitoring</Link>
<Link href="/admin/radio">{t("title")}</Link> ·{" "}
<Link href="/admin/radio/settings">{t("settings")}</Link> ·{" "}
<Link href="/admin/radio/api-keys">{t("apiKeys")}</Link> ·{" "}
<Link href="/admin/radio/autodj">{t("autoDj")}</Link> ·{" "}
<Link href="/admin/radio/embed">{t("embed")}</Link> ·{" "}
<Link href="/admin/radio/points">{t("points")}</Link> ·{" "}
<Link href="/admin/radio/monitoring">{t("monitoring")}</Link>
</nav>
<section className="mt-6">
@@ -81,52 +83,51 @@ export default async function AdminRadioApiKeysPage() {
<Key size={20} className="text-[var(--color-primary)]" />
</div>
<div>
<h1 className="m-0 text-xl font-extrabold text-[var(--color-text)]">Radio API Keys</h1>
<p className="m-0 text-xs text-[var(--color-text-muted)] mt-0.5">API keys for external radio integrations (<code>radio_api_keys</code>). Keys are generated server-side and shown masked — copy the full value from the database when first issued.</p>
<h1 className="m-0 text-xl font-extrabold text-[var(--color-text)]">{t("apiKeysForm.title")}</h1>
<p className="m-0 text-xs text-[var(--color-text-muted)] mt-0.5">{t("apiKeysForm.subtitle")}</p>
</div>
</div>
{dbError ? (
<div className="admin-card" style={{ marginTop: "1rem" }}>
<p className="text-xs text-gray-500 dark:text-gray-400 m-0">
Could not load API keys (database unavailable).
{t("apiKeysForm.dbError")}
</p>
</div>
) : (
<div className="grid grid-cols-[repeat(auto-fit,minmax(180px,1fr))] gap-3.5 mb-6">
<StatusCard label="Total keys" value={total} icon="🔑" />
<StatusCard label={t("apiKeysForm.totalKeys")} value={total} icon="\u{1F511}" />
<StatusCard
label="Active"
label={t("apiKeysForm.active")}
value={activeCount}
state={activeCount > 0 ? "ok" : "neutral"}
icon="✅"
icon="\u{2705}"
/>
<StatusCard
label="Inactive"
label={t("apiKeysForm.inactive")}
value={inactiveCount}
state={inactiveCount > 0 ? "warn" : "neutral"}
icon="🚫"
icon="\u{1F6AB}"
/>
</div>
)}
</section>
<section className="mt-6">
<h2>Generate key</h2>
<h2>{t("apiKeysForm.generateKey")}</h2>
<p className="text-xs text-gray-500 dark:text-gray-400">
The secret key is generated automatically with cryptographic randomness — you do not enter
it.
{t("apiKeysForm.generateHint")}
</p>
<form action={createApiKey} className="admin-card">
<div className="grid cols-2">
<div>
<label htmlFor="new_name" style={{ display: "block", fontWeight: 700 }}>
Name
{t("apiKeysForm.name")}
</label>
<input
id="new_name"
name="name"
placeholder="AzureCast bridge"
placeholder={t("apiKeysForm.namePlaceholder")}
required
maxLength={255}
style={{ width: "100%" }}
@@ -134,7 +135,7 @@ export default async function AdminRadioApiKeysPage() {
</div>
<div>
<label htmlFor="new_rateLimit" style={{ display: "block", fontWeight: 700 }}>
Rate limit (requests/min)
{t("apiKeysForm.rateLimit")}
</label>
<input
id="new_rateLimit"
@@ -147,12 +148,12 @@ export default async function AdminRadioApiKeysPage() {
</div>
<div style={{ gridColumn: "1 / -1" }}>
<label htmlFor="new_allowedIps" style={{ display: "block", fontWeight: 700 }}>
Allowed IPs (optional)
{t("apiKeysForm.allowedIps")}
</label>
<input
id="new_allowedIps"
name="allowedIps"
placeholder="comma-separated, blank = any"
placeholder={t("apiKeysForm.allowedIpsPlaceholder")}
maxLength={255}
style={{ width: "100%" }}
/>
@@ -160,30 +161,30 @@ export default async function AdminRadioApiKeysPage() {
</div>
<div style={{ marginTop: "0.75rem" }}>
<button type="submit" className="btn btn-primary">
Generate key
{t("apiKeysForm.generate")}
</button>
</div>
</form>
</section>
<section className="mt-6">
<h2 className="admin-section-title">Existing keys ({total})</h2>
<h2 className="admin-section-title">{t("apiKeysForm.existingKeys", { count: total })}</h2>
{total === 0 ? (
<div className="admin-empty">No API keys yet — generate one above.</div>
<div className="admin-empty">{t("apiKeysForm.noKeys")}</div>
) : (
<div className="overflow-x-auto">
<table>
<thead>
<tr>
<th>#</th>
<th>Name</th>
<th>Key</th>
<th>Allowed IPs</th>
<th>Rate limit</th>
<th>Last used</th>
<th>Expires</th>
<th>Status</th>
<th>Actions</th>
<th>{t("apiKeysForm.colName")}</th>
<th>{t("apiKeysForm.colKey")}</th>
<th>{t("apiKeysForm.colAllowedIps")}</th>
<th>{t("apiKeysForm.colRateLimit")}</th>
<th>{t("apiKeysForm.colLastUsed")}</th>
<th>{t("apiKeysForm.colExpires")}</th>
<th>{t("apiKeysForm.colStatus")}</th>
<th>{t("apiKeysForm.colActions")}</th>
</tr>
</thead>
<tbody>
@@ -192,15 +193,15 @@ export default async function AdminRadioApiKeysPage() {
<td className="text-sm text-gray-500 dark:text-gray-400 whitespace-nowrap">{String(k.id)}</td>
<td>{k.name}</td>
<td>
<code title="Only the first 8 characters are shown">{maskKey(k.key)}</code>
<code title={t("apiKeysForm.maskedHint")}>{maskKey(k.key)}</code>
</td>
<td className="text-sm text-gray-500 dark:text-gray-400 whitespace-nowrap">{k.allowedIps || "any"}</td>
<td className="text-sm text-gray-500 dark:text-gray-400 whitespace-nowrap">{k.allowedIps || t("apiKeysForm.any")}</td>
<td className="text-sm text-gray-500 dark:text-gray-400 whitespace-nowrap">{k.rateLimit}/min</td>
<td className="text-sm text-gray-500 dark:text-gray-400 whitespace-nowrap">{formatDate(k.lastUsedAt)}</td>
<td className="text-sm text-gray-500 dark:text-gray-400 whitespace-nowrap">{formatDate(k.expiresAt)}</td>
<td>
<span className={`inline-block text-[0.72rem] font-bold px-2 py-0.5 rounded-full ${k.isActive ? "bg-green-600/20 text-[var(--color-text)]" : "bg-red-500/22 text-[var(--color-text)]"}`}>
{k.isActive ? "active" : "inactive"}
{k.isActive ? t("apiKeysForm.activeLabel") : t("apiKeysForm.inactiveLabel")}
</span>
</td>
<td>
@@ -208,13 +209,13 @@ export default async function AdminRadioApiKeysPage() {
<form action={toggleApiKey}>
<input type="hidden" name="id" value={String(k.id)} />
<button type="submit" className="btn">
{k.isActive ? "Deactivate" : "Activate"}
{k.isActive ? t("apiKeysForm.deactivate") : t("apiKeysForm.activate")}
</button>
</form>
<form action={deleteApiKey}>
<input type="hidden" name="id" value={String(k.id)} />
<button type="submit" className="btn btn-danger">
Delete
{t("apiKeysForm.delete")}
</button>
</form>
</div>
+60 -58
View File
@@ -3,6 +3,7 @@ import { createTrack, deleteTrack, toggleTrack } from '@/actions/admin-radio-aut
import { StatusCard } from '@/components/admin/dashboard';
import { prisma } from '@/lib/prisma';
import { Disc3 } from 'lucide-react';
import { getTranslations } from "next-intl/server";
export const dynamic = 'force-dynamic';
@@ -21,19 +22,20 @@ type Track = {
sortOrder: number;
};
/** Render an integer second count as m:ss, or an em dash when unset. */
function formatDuration(seconds: number | null): string {
if (seconds == null || seconds < 0) return '—';
if (seconds == null || seconds < 0) return '\u2014';
const m = Math.floor(seconds / 60);
const s = seconds % 60;
return `${m}:${String(s).padStart(2, '0')}`;
}
function formatDate(d: Date | null): string {
return d ? d.toISOString().slice(0, 16).replace('T', ' ') : '—';
return d ? d.toISOString().slice(0, 16).replace('T', ' ') : '\u2014';
}
export default async function AdminRadioAutoDjPage() {
const t = await getTranslations("pages.admin.radio");
let tracks: Track[] = [];
let dbError = false;
@@ -69,12 +71,12 @@ export default async function AdminRadioAutoDjPage() {
return (
<main>
<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> ·{' '}
<Link href="/admin/radio/ranks">Ranks</Link> ·{' '}
<Link href="/admin/radio/history">History</Link> ·{' '}
<Link href="/admin/radio/autodj">AutoDJ</Link>
<Link href="/admin/radio">{t("title")}</Link> ·{' '}
<Link href="/admin/radio/settings">{t("settings")}</Link> ·{' '}
<Link href="/admin/radio/banners">{t("banners.title")}</Link> ·{' '}
<Link href="/admin/radio/ranks">{t("ranks")}</Link> ·{' '}
<Link href="/admin/radio/history">{t("history")}</Link> ·{' '}
<Link href="/admin/radio/autodj">{t("autoDj")}</Link>
</nav>
<section className="mt-6">
@@ -83,91 +85,91 @@ export default async function AdminRadioAutoDjPage() {
<Disc3 size={20} className="text-[var(--color-primary)]" />
</div>
<div>
<h1 className="m-0 text-xl font-extrabold text-[var(--color-text)]">Radio AutoDJ</h1>
<p className="m-0 text-xs text-[var(--color-text-muted)] mt-0.5">Fallback playlist (<code>radio_auto_dj_playlist</code>) the radio rotates through when no live DJ is streaming. Ordered by sort order then title.</p>
<h1 className="m-0 text-xl font-extrabold text-[var(--color-text)]">{t("autoDjForm.title")}</h1>
<p className="m-0 text-xs text-[var(--color-text-muted)] mt-0.5">{t("autoDjForm.subtitle")}</p>
</div>
</div>
{dbError ? (
<div className="admin-card mb-6">
<p className="text-xs text-gray-500 dark:text-gray-400 m-0">
Could not load the AutoDJ playlist (database unavailable).
{t("autoDjForm.dbError")}
</p>
</div>
) : (
<div className="grid grid-cols-[repeat(auto-fit,minmax(180px,1fr))] gap-3.5 mb-6">
<StatusCard label="Tracks" value={tracks.length} icon="🎵" />
<StatusCard label={t("autoDjForm.tracks")} value={tracks.length} icon="\u{1F3B5}" />
<StatusCard
label="Active"
label={t("autoDjForm.active")}
value={activeCount}
state={activeCount > 0 ? 'ok' : 'neutral'}
icon="▶️"
icon="\u{25B6}\uFE0F"
/>
<StatusCard
label="Inactive"
label={t("autoDjForm.inactive")}
value={inactiveCount}
state={inactiveCount > 0 ? 'warn' : 'neutral'}
icon="⏸️"
icon="\u{23F8}\uFE0F"
/>
</div>
)}
</section>
<section className="mt-6">
<h2 className="admin-section-title">Add track</h2>
<h2 className="admin-section-title">{t("autoDjForm.addTrack")}</h2>
<form action={createTrack} className="admin-card">
<div className="grid cols-2">
<div>
<label htmlFor="new_title" style={{ display: 'block', fontWeight: 700 }}>
Title
{t("autoDjForm.title")}
</label>
<input
id="new_title"
name="title"
placeholder="Track title"
placeholder={t("autoDjForm.titlePlaceholder")}
required
style={{ width: '100%' }}
/>
</div>
<div>
<label htmlFor="new_artist" style={{ display: 'block', fontWeight: 700 }}>
Artist
{t("autoDjForm.artist")}
</label>
<input id="new_artist" name="artist" style={{ width: '100%' }} />
</div>
<div>
<label htmlFor="new_album" style={{ display: 'block', fontWeight: 700 }}>
Album
{t("autoDjForm.album")}
</label>
<input id="new_album" name="album" style={{ width: '100%' }} />
</div>
<div>
<label htmlFor="new_artworkUrl" style={{ display: 'block', fontWeight: 700 }}>
Artwork URL
{t("autoDjForm.artworkUrl")}
</label>
<input
id="new_artworkUrl"
name="artworkUrl"
placeholder="https://…/cover.png"
placeholder={t("autoDjForm.artworkUrlPlaceholder")}
style={{ width: '100%' }}
/>
</div>
<div>
<label htmlFor="new_duration" style={{ display: 'block', fontWeight: 700 }}>
Duration (seconds)
{t("autoDjForm.duration")}
</label>
<input
id="new_duration"
name="duration"
type="number"
min={0}
placeholder="e.g. 215"
placeholder={t("autoDjForm.durationPlaceholder")}
style={{ width: '100%' }}
/>
</div>
<div>
<label htmlFor="new_sortOrder" style={{ display: 'block', fontWeight: 700 }}>
Sort order
{t("autoDjForm.sortOrder")}
</label>
<input
id="new_sortOrder"
@@ -179,79 +181,79 @@ export default async function AdminRadioAutoDjPage() {
</div>
<div style={{ display: 'flex', alignItems: 'flex-end' }}>
<label style={{ display: 'inline-flex', gap: '0.4rem', alignItems: 'center' }}>
<input type="checkbox" name="isActive" value="1" defaultChecked /> Active
<input type="checkbox" name="isActive" value="1" defaultChecked /> {t("autoDjForm.active")}
</label>
</div>
</div>
<div style={{ marginTop: '0.75rem' }}>
<button type="submit" className="btn btn-primary">
Add track
{t("autoDjForm.addTrack")}
</button>
</div>
</form>
</section>
<section className="mt-6">
<h2 className="admin-section-title">Playlist ({tracks.length})</h2>
<h2 className="admin-section-title">{t("autoDjForm.playlist", { count: tracks.length })}</h2>
{tracks.length === 0 ? (
<div className="admin-empty">No AutoDJ tracks yet — add one above.</div>
<div className="admin-empty">{t("autoDjForm.noTracks")}</div>
) : (
<div className="overflow-x-auto">
<table>
<thead>
<tr>
<th>Order</th>
<th>Title</th>
<th>Artist</th>
<th>Album</th>
<th>Duration</th>
<th>Plays</th>
<th>Last played</th>
<th>Status</th>
<th>Actions</th>
<th>{t("autoDjForm.colOrder")}</th>
<th>{t("autoDjForm.colTitle")}</th>
<th>{t("autoDjForm.colArtist")}</th>
<th>{t("autoDjForm.colAlbum")}</th>
<th>{t("autoDjForm.colDuration")}</th>
<th>{t("autoDjForm.colPlays")}</th>
<th>{t("autoDjForm.colLastPlayed")}</th>
<th>{t("autoDjForm.colStatus")}</th>
<th>{t("autoDjForm.colActions")}</th>
</tr>
</thead>
<tbody>
{tracks.map((t) => (
<tr key={String(t.id)}>
<td className="text-sm text-gray-500 dark:text-gray-400 whitespace-nowrap">{t.sortOrder}</td>
{tracks.map((track) => (
<tr key={String(track.id)}>
<td className="text-sm text-gray-500 dark:text-gray-400 whitespace-nowrap">{track.sortOrder}</td>
<td>
<span className="inline-flex gap-2 items-center">
{t.artworkUrl ? (
{track.artworkUrl ? (
<img
src={t.artworkUrl}
src={track.artworkUrl}
alt=""
width={32}
height={32}
style={{ borderRadius: 4, objectFit: 'cover' }}
/>
) : null}
<strong>{t.title}</strong>
<strong>{track.title}</strong>
</span>
</td>
<td className="text-sm text-gray-500 dark:text-gray-400 whitespace-nowrap">{t.artist ?? '—'}</td>
<td className="text-sm text-gray-500 dark:text-gray-400 whitespace-nowrap">{t.album ?? '—'}</td>
<td className="text-sm text-gray-500 dark:text-gray-400 whitespace-nowrap">{formatDuration(t.duration)}</td>
<td className="text-sm text-gray-500 dark:text-gray-400 whitespace-nowrap">{t.playCount}</td>
<td className="text-sm text-gray-500 dark:text-gray-400 whitespace-nowrap">{formatDate(t.lastPlayedAt)}</td>
<td className="text-sm text-gray-500 dark:text-gray-400 whitespace-nowrap">{track.artist ?? '\u2014'}</td>
<td className="text-sm text-gray-500 dark:text-gray-400 whitespace-nowrap">{track.album ?? '\u2014'}</td>
<td className="text-sm text-gray-500 dark:text-gray-400 whitespace-nowrap">{formatDuration(track.duration)}</td>
<td className="text-sm text-gray-500 dark:text-gray-400 whitespace-nowrap">{track.playCount}</td>
<td className="text-sm text-gray-500 dark:text-gray-400 whitespace-nowrap">{formatDate(track.lastPlayedAt)}</td>
<td>
<span className={`inline-block text-[0.72rem] font-bold px-2 py-0.5 rounded-full ${t.isActive ? 'bg-green-600/20 text-[var(--color-text)]' : 'bg-red-500/22 text-[var(--color-text)]'}`}>
{t.isActive ? 'active' : 'inactive'}
<span className={`inline-block text-[0.72rem] font-bold px-2 py-0.5 rounded-full ${track.isActive ? 'bg-green-600/20 text-[var(--color-text)]' : 'bg-red-500/22 text-[var(--color-text)]'}`}>
{track.isActive ? t("autoDjForm.activeLabel") : t("autoDjForm.inactiveLabel")}
</span>
</td>
<td>
<div style={{ display: 'flex', gap: '0.4rem', flexWrap: 'wrap' }}>
<form action={toggleTrack}>
<input type="hidden" name="id" value={String(t.id)} />
<input type="hidden" name="isActive" value={t.isActive ? '0' : '1'} />
<input type="hidden" name="id" value={String(track.id)} />
<input type="hidden" name="isActive" value={track.isActive ? '0' : '1'} />
<button type="submit" className="btn">
{t.isActive ? 'Deactivate' : 'Activate'}
{track.isActive ? t("autoDjForm.deactivate") : t("autoDjForm.activate")}
</button>
</form>
<form action={deleteTrack}>
<input type="hidden" name="id" value={String(t.id)} />
<input type="hidden" name="id" value={String(track.id)} />
<button type="submit" className="btn btn-danger">
Delete
{t("autoDjForm.delete")}
</button>
</form>
</div>
+37 -34
View File
@@ -7,6 +7,7 @@ import {
import { prisma } from '@/lib/prisma';
import { StatusCard } from '@/components/admin/dashboard';
import { Image } from 'lucide-react';
import { getTranslations } from "next-intl/server";
export const dynamic = 'force-dynamic';
@@ -22,10 +23,12 @@ type Banner = {
};
function formatDate(d: Date | null): string {
return d ? d.toISOString().slice(0, 16).replace('T', ' ') : '—';
return d ? d.toISOString().slice(0, 16).replace('T', ' ') : '\u2014';
}
export default async function AdminRadioBannersPage() {
const t = await getTranslations("pages.admin.radio");
let banners: Banner[] = [];
let dbError = false;
@@ -54,11 +57,11 @@ export default async function AdminRadioBannersPage() {
return (
<main>
<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> ·{' '}
<Link href="/admin/radio/ranks">Ranks</Link> ·{' '}
<Link href="/admin/radio/history">History</Link>
<Link href="/admin/radio">{t("title")}</Link> ·{' '}
<Link href="/admin/radio/settings">{t("settings")}</Link> ·{' '}
<Link href="/admin/radio/banners">{t("banners.title")}</Link> ·{' '}
<Link href="/admin/radio/ranks">{t("ranks")}</Link> ·{' '}
<Link href="/admin/radio/history">{t("history")}</Link>
</nav>
<div className="flex items-center gap-3 mb-6">
@@ -66,60 +69,60 @@ export default async function AdminRadioBannersPage() {
<Image size={20} className="text-[var(--color-primary)]" />
</div>
<div>
<h1 className="m-0 text-xl font-extrabold text-[var(--color-text)]">Radio Banners</h1>
<p className="m-0 text-xs text-[var(--color-text-muted)] mt-0.5">Promotional banners shown on the radio page, ordered by sort order.</p>
<h1 className="m-0 text-xl font-extrabold text-[var(--color-text)]">{t("banners.heading")}</h1>
<p className="m-0 text-xs text-[var(--color-text-muted)] mt-0.5">{t("banners.description")}</p>
</div>
</div>
{dbError ? (
<div className="admin-card mb-6">
<p className="text-xs text-gray-500 dark:text-gray-400 m-0">
Could not load banners (database unavailable).
{t("banners.dbError")}
</p>
</div>
) : (
<div className="grid grid-cols-[repeat(auto-fit,minmax(180px,1fr))] gap-3.5 mb-6">
<StatusCard label="Banners" value={banners.length} icon="🖼️" />
<StatusCard label={t("banners.title")} value={banners.length} icon="\u{1F5BC}\uFE0F" />
<StatusCard
label="Active"
label={t("banners.statusActive")}
value={activeCount}
state={activeCount > 0 ? 'ok' : 'neutral'}
icon="✅"
icon="\u{2705}"
/>
<StatusCard
label="Hidden"
label={t("banners.statusHidden")}
value={hiddenCount}
state={hiddenCount > 0 ? 'warn' : 'neutral'}
icon="🚫"
icon="\u{1F6AB}"
/>
</div>
)}
<section className="mt-6">
<h2 className="admin-section-title">Add banner</h2>
<h2 className="admin-section-title">{t("banners.addBanner")}</h2>
<form action={createRadioBanner} className="admin-card">
<div className="grid cols-2">
<div>
<label htmlFor="new_imagePath" style={{ display: 'block', fontWeight: 700 }}>
Image path / URL
{t("banners.form.imagePath")}
</label>
<input
id="new_imagePath"
name="imagePath"
placeholder="/storage/radio/banner.png"
placeholder={t("banners.form.imagePathPlaceholder")}
required
style={{ width: '100%' }}
/>
</div>
<div>
<label htmlFor="new_title" style={{ display: 'block', fontWeight: 700 }}>
Title
{t("banners.form.title")}
</label>
<input id="new_title" name="title" style={{ width: '100%' }} />
</div>
<div style={{ gridColumn: '1 / -1' }}>
<label htmlFor="new_description" style={{ display: 'block', fontWeight: 700 }}>
Description
{t("banners.form.description")}
</label>
<textarea
id="new_description"
@@ -130,7 +133,7 @@ export default async function AdminRadioBannersPage() {
</div>
<div>
<label htmlFor="new_sortOrder" style={{ display: 'block', fontWeight: 700 }}>
Sort order
{t("banners.form.sortOrder")}
</label>
<input
id="new_sortOrder"
@@ -142,22 +145,22 @@ export default async function AdminRadioBannersPage() {
</div>
<div style={{ display: 'flex', alignItems: 'flex-end' }}>
<label style={{ display: 'inline-flex', gap: '0.4rem', alignItems: 'center' }}>
<input type="checkbox" name="isActive" value="1" defaultChecked /> Active
<input type="checkbox" name="isActive" value="1" defaultChecked /> {t("banners.form.active")}
</label>
</div>
</div>
<div style={{ marginTop: '0.75rem' }}>
<button type="submit" className="btn btn-primary">
Create banner
{t("banners.createBanner")}
</button>
</div>
</form>
</section>
<section className="mt-6">
<h2 className="admin-section-title">Existing banners ({banners.length})</h2>
<h2 className="admin-section-title">{t("banners.existing", { count: banners.length })}</h2>
{banners.length === 0 ? (
<div className="admin-empty">No banners yet — add one above.</div>
<div className="admin-empty">{t("banners.noBanners")}</div>
) : (
<div className="grid">
{banners.map((b) => (
@@ -165,9 +168,9 @@ export default async function AdminRadioBannersPage() {
<div className="flex justify-between items-baseline gap-3">
<strong>#{String(b.id)}</strong>
<span className="text-sm text-gray-500 dark:text-gray-400 inline-flex gap-2 items-center">
by user {String(b.userId)} · {formatDate(b.createdAt)} ·{' '}
{t("banners.byUser", { id: String(b.userId) })} · {formatDate(b.createdAt)} ·{' '}
<span className={`inline-block text-[0.72rem] font-bold px-2 py-0.5 rounded-full ${b.isActive ? 'bg-green-600/20 text-[var(--color-text)]' : 'bg-red-500/22 text-[var(--color-text)]'}`}>
{b.isActive ? 'active' : 'hidden'}
{b.isActive ? t("banners.active") : t("banners.hidden")}
</span>
</span>
</div>
@@ -175,7 +178,7 @@ export default async function AdminRadioBannersPage() {
{b.imagePath ? (
<img
src={b.imagePath}
alt={b.title ?? 'Radio banner'}
alt={b.title ?? t("banners.bannerAlt")}
className="article-img my-2"
/>
) : null}
@@ -188,7 +191,7 @@ export default async function AdminRadioBannersPage() {
htmlFor={`img_${b.id}`}
style={{ display: 'block', fontWeight: 700 }}
>
Image path / URL
{t("banners.form.imagePath")}
</label>
<input
id={`img_${b.id}`}
@@ -203,7 +206,7 @@ export default async function AdminRadioBannersPage() {
htmlFor={`title_${b.id}`}
style={{ display: 'block', fontWeight: 700 }}
>
Title
{t("banners.form.title")}
</label>
<input
id={`title_${b.id}`}
@@ -217,7 +220,7 @@ export default async function AdminRadioBannersPage() {
htmlFor={`desc_${b.id}`}
style={{ display: 'block', fontWeight: 700 }}
>
Description
{t("banners.form.description")}
</label>
<textarea
id={`desc_${b.id}`}
@@ -232,7 +235,7 @@ export default async function AdminRadioBannersPage() {
htmlFor={`sort_${b.id}`}
style={{ display: 'block', fontWeight: 700 }}
>
Sort order
{t("banners.form.sortOrder")}
</label>
<input
id={`sort_${b.id}`}
@@ -252,13 +255,13 @@ export default async function AdminRadioBannersPage() {
value="1"
defaultChecked={b.isActive}
/>{' '}
Active
{t("banners.form.active")}
</label>
</div>
</div>
<div style={{ marginTop: '0.6rem' }}>
<button type="submit" className="btn btn-primary">
Save
{t("banners.save")}
</button>
</div>
</form>
@@ -266,7 +269,7 @@ export default async function AdminRadioBannersPage() {
<form action={deleteRadioBanner} style={{ marginTop: '0.5rem' }}>
<input type="hidden" name="id" value={String(b.id)} />
<button type="submit" className="btn btn-danger">
Delete
{t("banners.delete")}
</button>
</form>
</div>
+30 -38
View File
@@ -4,6 +4,7 @@ import { siteSettings } from '@/lib/services/site-settings';
import { requireStaff } from '@/lib/admin/guard';
import { EmbedSnippet } from './embed-snippet';
import { Code } from 'lucide-react';
import { getTranslations } from "next-intl/server";
export const dynamic = 'force-dynamic';
@@ -12,9 +13,8 @@ export const metadata = { title: 'Radio Embed' };
export default async function AdminRadioEmbedPage() {
await requireStaff();
// Read radio embed-related website_settings. siteSettings.get() already
// swallows DB errors and serves fallbacks, but wrap defensively anyway so a
// throw never blanks the page.
const t = await getTranslations("pages.admin.radio");
let streamUrl = '';
let radioName = 'Radio';
let nowPlayingApiUrl = '';
@@ -33,10 +33,8 @@ export default async function AdminRadioEmbedPage() {
nowPlayingApiUrl = nowPlayingApiUrl.trim();
const hasStream = streamUrl !== '';
// Copy-ready embed snippets. Built server-side from the configured stream URL
// so what staff copy matches what the preview renders.
const audioSnippet = hasStream
? `<audio controls preload="none" src="${streamUrl}">\n Your browser does not support the audio element.\n</audio>`
? `<audio controls preload="none" src="${streamUrl}">\n ${t("embedPage.audioUnsupported")}\n</audio>`
: '';
const iframeSnippet = hasStream
@@ -46,12 +44,12 @@ export default async function AdminRadioEmbedPage() {
return (
<main>
<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/embed">Embed</Link> ·{' '}
<Link href="/admin/radio/banners">Banners</Link> ·{' '}
<Link href="/admin/radio/ranks">Ranks</Link> ·{' '}
<Link href="/admin/radio/history">History</Link>
<Link href="/admin/radio">{t("title")}</Link> ·{' '}
<Link href="/admin/radio/settings">{t("settings")}</Link> ·{' '}
<Link href="/admin/radio/embed">{t("embed")}</Link> ·{' '}
<Link href="/admin/radio/banners">{t("banners.title")}</Link> ·{' '}
<Link href="/admin/radio/ranks">{t("ranks")}</Link> ·{' '}
<Link href="/admin/radio/history">{t("history")}</Link>
</nav>
<section className="mt-6">
@@ -60,73 +58,67 @@ export default async function AdminRadioEmbedPage() {
<Code size={20} className="text-[var(--color-primary)]" />
</div>
<div>
<h1 className="m-0 text-xl font-extrabold text-[var(--color-text)]">Radio Embed</h1>
<p className="m-0 text-xs text-[var(--color-text-muted)] mt-0.5">Copy-ready embed code for the live stream. Read-only — nothing here writes to the database. Configure the stream URL in <Link href="/admin/radio/settings">Radio Settings</Link> (<code>radio_stream_url</code>).</p>
<h1 className="m-0 text-xl font-extrabold text-[var(--color-text)]">{t("embedPage.title")}</h1>
<p className="m-0 text-xs text-[var(--color-text-muted)] mt-0.5">{t("embedPage.subtitle")}</p>
</div>
</div>
<div className="grid grid-cols-[repeat(auto-fit,minmax(180px,1fr))] gap-3.5 mb-6">
<StatusCard
label="Stream URL"
value={hasStream ? 'Configured' : 'Missing'}
label={t("embedPage.streamUrl")}
value={hasStream ? t("embedPage.configured") : t("embedPage.missing")}
state={hasStream ? 'ok' : 'danger'}
icon="📻"
icon="\u{1F4BB}"
/>
<StatusCard label="Radio name" value={radioName} icon="🎙️" />
<StatusCard label={t("embedPage.radioName")} value={radioName} icon="\u{1F399}\uFE0F" />
<StatusCard
label="Now-playing API"
value={nowPlayingApiUrl ? 'Set' : 'Not set'}
label={t("embedPage.nowPlayingApi")}
value={nowPlayingApiUrl ? t("embedPage.set") : t("embedPage.notSet")}
state={nowPlayingApiUrl ? 'ok' : 'neutral'}
icon="🎵"
icon="\u{1F3B5}"
/>
</div>
</section>
{!hasStream ? (
<section className="mt-6">
<h2 className="admin-section-title">No stream URL configured</h2>
<h2 className="admin-section-title">{t("embedPage.noStreamUrl")}</h2>
<p className="text-xs text-gray-500 dark:text-gray-400">
There is no <code>radio_stream_url</code> set, so there is nothing to
embed yet. Open{' '}
<Link href="/admin/radio/settings">Radio Settings</Link> and fill in
the <strong>Main stream URL</strong> field under{' '}
<em>AzureCast &amp; Stream</em>. Once saved, return here to copy the
embed snippet and preview the player.
{t("embedPage.noStreamUrlText")}
</p>
<p>
<Link href="/admin/radio/settings" className="btn btn-primary">
Go to Radio Settings
{t("embedPage.goToSettings")}
</Link>
</p>
</section>
) : (
<>
<section className="mt-6">
<h2 className="admin-section-title">Live preview</h2>
<h2 className="admin-section-title">{t("embedPage.livePreview")}</h2>
<p className="text-xs text-gray-500 dark:text-gray-400">
This is the bare <code>&lt;audio&gt;</code> player as visitors will
see it. Press play to confirm the stream is reachable.
{t("embedPage.livePreviewText")}
</p>
<div className="admin-card">
{/* eslint-disable-next-line jsx-a11y/media-has-caption */}
<audio controls preload="none" src={streamUrl} style={{ width: '100%' }}>
Your browser does not support the audio element.
{t("embedPage.audioUnsupported")}
</audio>
<p className="text-xs text-gray-500 dark:text-gray-400" style={{ margin: '0.5rem 0 0', wordBreak: 'break-all' }}>
Source: <code>{streamUrl}</code>
{t("embedPage.source")}: <code>{streamUrl}</code>
</p>
</div>
</section>
<EmbedSnippet
title="Audio player snippet"
description="Drop-in HTML5 audio player pointed straight at the stream. Works anywhere plain HTML is allowed."
title={t("embedPage.audioSnippet")}
description={t("embedPage.audioSnippetDesc")}
snippet={audioSnippet}
/>
<EmbedSnippet
title="Iframe wrapper snippet"
description="Embeds the site's own radio player page in an iframe — keeps your styling and now-playing info. Adjust width/height to taste."
title={t("embedPage.iframeSnippet")}
description={t("embedPage.iframeSnippetDesc")}
snippet={iframeSnippet}
/>
</>
+31 -30
View File
@@ -2,6 +2,7 @@ import Link from 'next/link';
import { prisma } from '@/lib/prisma';
import { StatusCard } from '@/components/admin/dashboard';
import { Clock } from 'lucide-react';
import { getTranslations } from "next-intl/server";
export const dynamic = 'force-dynamic';
@@ -16,14 +17,13 @@ type History = {
};
function formatDate(d: Date | null): string {
return d ? d.toISOString().slice(0, 16).replace('T', ' ') : '—';
return d ? d.toISOString().slice(0, 16).replace('T', ' ') : '\u2014';
}
/** Human-readable airtime between two timestamps (e.g. "1h 23m"). */
function formatDuration(start: Date, end: Date | null): string {
if (!end) return '—';
if (!end) return '\u2014';
const ms = end.getTime() - start.getTime();
if (ms <= 0) return '—';
if (ms <= 0) return '\u2014';
const mins = Math.round(ms / 60000);
const h = Math.floor(mins / 60);
const m = mins % 60;
@@ -31,6 +31,8 @@ function formatDuration(start: Date, end: Date | null): string {
}
export default async function AdminRadioHistoryPage() {
const t = await getTranslations("pages.admin.radio");
let history: History[] = [];
let names = new Map<string, string>();
let dbError = false;
@@ -50,7 +52,6 @@ export default async function AdminRadioHistoryPage() {
take: 100,
});
// Resolve DJ usernames (users.id is Int, radio_history.user_id is BigInt).
const ids = Array.from(new Set(history.map((h) => Number(h.userId)))).filter(
(n) => Number.isFinite(n) && n > 0,
);
@@ -76,11 +77,11 @@ export default async function AdminRadioHistoryPage() {
return (
<main>
<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> ·{' '}
<Link href="/admin/radio/ranks">Ranks</Link> ·{' '}
<Link href="/admin/radio/history">History</Link>
<Link href="/admin/radio">{t("title")}</Link> ·{' '}
<Link href="/admin/radio/settings">{t("settings")}</Link> ·{' '}
<Link href="/admin/radio/banners">{t("banners.title")}</Link> ·{' '}
<Link href="/admin/radio/ranks">{t("ranks")}</Link> ·{' '}
<Link href="/admin/radio/history">{t("history")}</Link>
</nav>
<div className="flex items-center gap-3 mb-6">
@@ -88,49 +89,49 @@ export default async function AdminRadioHistoryPage() {
<Clock size={20} className="text-[var(--color-primary)]" />
</div>
<div>
<h1 className="m-0 text-xl font-extrabold text-[var(--color-text)]">Radio Play History</h1>
<p className="m-0 text-xs text-[var(--color-text-muted)] mt-0.5">Past DJ shows and airtime (<code>radio_history</code>), most recent first. Read-only log of who was on air, when, and how many listeners tuned in.</p>
<h1 className="m-0 text-xl font-extrabold text-[var(--color-text)]">{t("historyPage.title")}</h1>
<p className="m-0 text-xs text-[var(--color-text-muted)] mt-0.5">{t("historyPage.subtitle")}</p>
</div>
</div>
{dbError ? (
<div className="admin-card mb-6">
<p className="text-xs text-gray-500 dark:text-gray-400 m-0">
Could not load radio history (database unavailable).
{t("historyPage.dbError")}
</p>
</div>
) : (
<div className="grid grid-cols-[repeat(auto-fit,minmax(180px,1fr))] gap-3.5 mb-6">
<StatusCard label="Shows logged" value={history.length} icon="🎙️" />
<StatusCard label={t("historyPage.showsLogged")} value={history.length} icon="\u{1F399}\uFE0F" />
<StatusCard
label="On air now"
label={t("historyPage.onAirNow")}
value={liveCount}
state={liveCount > 0 ? 'ok' : 'neutral'}
icon="🔴"
icon="\u{1F534}"
/>
<StatusCard label="Unique DJs" value={uniqueDjs} icon="🧑‍🎤" />
<StatusCard label="Peak listeners" value={peakListeners} icon="👥" />
<StatusCard label={t("historyPage.uniqueDjs")} value={uniqueDjs} icon="\u{1F9D1}\u200D\u{1F3A4}" />
<StatusCard label={t("historyPage.peakListeners")} value={peakListeners} icon="\u{1F465}" />
</div>
)}
{!dbError && history.length === 0 ? (
<div className="admin-empty">No radio history recorded yet.</div>
<div className="admin-empty">{t("historyPage.empty")}</div>
) : null}
{history.length > 0 ? (
<section className="mt-6">
<h2 className="admin-section-title">Show log ({history.length})</h2>
<h2 className="admin-section-title">{t("historyPage.showLog", { count: history.length })}</h2>
<table>
<thead>
<tr>
<th>ID</th>
<th>DJ</th>
<th>Show</th>
<th>Started</th>
<th>Ended</th>
<th>Airtime</th>
<th>Listeners</th>
<th>Notes</th>
<th>{t("historyPage.colDj")}</th>
<th>{t("historyPage.colShow")}</th>
<th>{t("historyPage.colStarted")}</th>
<th>{t("historyPage.colEnded")}</th>
<th>{t("historyPage.colAirtime")}</th>
<th>{t("historyPage.colListeners")}</th>
<th>{t("historyPage.colNotes")}</th>
</tr>
</thead>
<tbody>
@@ -138,18 +139,18 @@ export default async function AdminRadioHistoryPage() {
<tr key={String(h.id)}>
<td>{String(h.id)}</td>
<td>{names.get(String(h.userId)) ?? `#${String(h.userId)}`}</td>
<td>{h.showName ?? '—'}</td>
<td>{h.showName ?? '\u2014'}</td>
<td className="text-sm text-gray-500 dark:text-gray-400 whitespace-nowrap">{formatDate(h.startedAt)}</td>
<td>
{h.endedAt ? (
<span className="text-sm text-gray-500 dark:text-gray-400">{formatDate(h.endedAt)}</span>
) : (
<span className="inline-block text-[0.72rem] font-bold px-2 py-0.5 rounded-full bg-green-600/20 text-[var(--color-text)]">on air</span>
<span className="inline-block text-[0.72rem] font-bold px-2 py-0.5 rounded-full bg-green-600/20 text-[var(--color-text)]">{t("historyPage.onAir")}</span>
)}
</td>
<td className="text-sm text-gray-500 dark:text-gray-400 whitespace-nowrap">{formatDuration(h.startedAt, h.endedAt)}</td>
<td>{h.listenersCount}</td>
<td className="text-sm text-gray-500 dark:text-gray-400 whitespace-nowrap">{h.notes ?? '—'}</td>
<td className="text-sm text-gray-500 dark:text-gray-400 whitespace-nowrap">{h.notes ?? '\u2014'}</td>
</tr>
))}
</tbody>
+19 -16
View File
@@ -3,6 +3,7 @@ import { prisma } from '@/lib/prisma';
import { deleteShout } from '@/actions/admin-radio-moderation';
import { StatusCard } from '@/components/admin/dashboard';
import { Shield } from 'lucide-react';
import { getTranslations } from "next-intl/server";
export const dynamic = 'force-dynamic';
@@ -15,10 +16,12 @@ type Shout = {
};
function formatDate(d: Date | null): string {
return d ? d.toISOString().slice(0, 16).replace('T', ' ') : '—';
return d ? d.toISOString().slice(0, 16).replace('T', ' ') : '\u2014';
}
export default async function AdminRadioModerationPage() {
const t = await getTranslations("pages.admin.radio");
let shouts: Shout[] = [];
try {
shouts = await prisma.radioShouts.findMany({
@@ -39,12 +42,12 @@ export default async function AdminRadioModerationPage() {
return (
<main>
<nav className="text-xs text-gray-500 dark:text-gray-400 mb-2">
<Link href="/admin/radio">Radio</Link> ·{' '}
<Link href="/admin/radio/moderation">Moderation</Link> ·{' '}
<Link href="/admin/radio/settings">Settings</Link> ·{' '}
<Link href="/admin/radio/banners">Banners</Link> ·{' '}
<Link href="/admin/radio/ranks">Ranks</Link> ·{' '}
<Link href="/admin/radio/history">History</Link>
<Link href="/admin/radio">{t("title")}</Link> ·{' '}
<Link href="/admin/radio/moderation">{t("moderation")}</Link> ·{' '}
<Link href="/admin/radio/settings">{t("settings")}</Link> ·{' '}
<Link href="/admin/radio/banners">{t("banners.title")}</Link> ·{' '}
<Link href="/admin/radio/ranks">{t("ranks")}</Link> ·{' '}
<Link href="/admin/radio/history">{t("history")}</Link>
</nav>
<div className="flex items-center gap-3 mb-6">
@@ -52,27 +55,27 @@ export default async function AdminRadioModerationPage() {
<Shield size={20} className="text-[var(--color-primary)]" />
</div>
<div>
<h1 className="m-0 text-xl font-extrabold text-[var(--color-text)]">DJ Moderation</h1>
<p className="m-0 text-xs text-[var(--color-text-muted)] mt-0.5">Review and remove radio shouts (50 most recent).</p>
<h1 className="m-0 text-xl font-extrabold text-[var(--color-text)]">{t("moderationPage.title")}</h1>
<p className="m-0 text-xs text-[var(--color-text-muted)] mt-0.5">{t("moderationPage.subtitle")}</p>
</div>
</div>
<div className="grid grid-cols-[repeat(auto-fit,minmax(180px,1fr))] gap-3.5 mb-6">
<StatusCard label="Recent shouts" value={shouts.length} icon="📣" />
<StatusCard label={t("shouts")} value={shouts.length} icon="\u{1F4E3}" />
</div>
<section className="mt-6">
<h2 className="admin-section-title">Recent Shouts ({shouts.length})</h2>
<h2 className="admin-section-title">{t("moderationPage.recentShouts", { count: shouts.length })}</h2>
{shouts.length === 0 ? (
<div className="admin-empty">No shouts to moderate.</div>
<div className="admin-empty">{t("noShouts")}</div>
) : (
<table>
<thead>
<tr>
<th>ID</th>
<th>User</th>
<th>Message</th>
<th>Posted</th>
<th>{t("user")}</th>
<th>{t("message")}</th>
<th>{t("posted")}</th>
<th />
</tr>
</thead>
@@ -87,7 +90,7 @@ export default async function AdminRadioModerationPage() {
<form action={deleteShout}>
<input type="hidden" name="id" value={String(s.id)} />
<button type="submit" className="btn btn-danger">
Delete
{t("deleteShout")}
</button>
</form>
</td>
+32 -58
View File
@@ -3,33 +3,22 @@ import { requireStaff } from '@/lib/admin/guard';
import { siteSettings } from '@/lib/services/site-settings';
import { StatusCard, DiagnosticRow } from '@/components/admin/dashboard';
import { Radio } from 'lucide-react';
import { getTranslations } from "next-intl/server";
export const dynamic = 'force-dynamic';
export const metadata = { title: 'Radio Monitoring' };
// READ-ONLY live probe of the configured radio endpoints. We never write to the
// DB here; we only read the relevant radio_* keys via siteSettings.get and do a
// best-effort, fail-soft server-side fetch of each URL with a short timeout so a
// slow/down stream provider can never hang the admin page.
const FETCH_TIMEOUT_MS = 4000;
type ProbeResult = {
/** true when the endpoint responded with a 2xx/3xx status within the timeout. */
reachable: boolean;
status: number | null;
/** Parsed JSON when the body was JSON, else null. */
json: unknown;
/** Raw text body (trimmed) when not JSON, else null. */
text: string | null;
error: string | null;
};
/**
* Fetch a URL server-side with a hard AbortController timeout. Always resolves
* (never throws) so the page renders even when the provider is unreachable.
*/
async function probe(url: string): Promise<ProbeResult> {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
@@ -80,11 +69,6 @@ function pickString(obj: Record<string, unknown>, key: string): string | null {
return typeof v === 'string' && v.trim() !== '' ? v.trim() : null;
}
/**
* Best-effort now-playing extraction. Supports the common AzureCast shape
* (now_playing.song.title / .artist), a flat { title, artist }, a bare string,
* or { now_playing: "..." }. Returns a single display string or null.
*/
function parseNowPlaying(result: ProbeResult): string | null {
if (result.json == null) {
return result.text && result.text.length <= 200 ? result.text : null;
@@ -93,7 +77,6 @@ function parseNowPlaying(result: ProbeResult): string | null {
if (typeof data === 'string') return data.trim() || null;
if (!isRecord(data)) return null;
// AzureCast: { now_playing: { song: { title, artist, text } } }
const np = data.now_playing;
if (typeof np === 'string' && np.trim() !== '') return np.trim();
if (isRecord(np)) {
@@ -109,7 +92,6 @@ function parseNowPlaying(result: ProbeResult): string | null {
}
}
// Flat shapes: { song: { title, artist } } or { title, artist } or { text }.
const song = data.song;
if (isRecord(song)) {
const title = pickString(song, 'title');
@@ -143,10 +125,6 @@ function findNumberDeep(value: unknown, keys: string[], depth = 0): number | nul
return null;
}
/**
* Best-effort listener count. Looks for listeners(.current/.total), num_listeners,
* unique_listeners, or a bare numeric body. Returns null when nothing parses.
*/
function parseListeners(result: ProbeResult): number | null {
if (result.json == null) {
if (result.text && Number.isFinite(Number(result.text.trim()))) {
@@ -171,13 +149,15 @@ function shortUrl(url: string): string {
const u = new URL(url);
return u.host + (u.pathname === '/' ? '' : u.pathname);
} catch {
return url.length > 60 ? `${url.slice(0, 57)}…` : url;
return url.length > 60 ? `${url.slice(0, 57)}\u2026` : url;
}
}
export default async function AdminRadioMonitoringPage() {
await requireStaff();
const t = await getTranslations("pages.admin.radio");
let nowPlayingUrl: string | null = null;
let listenersUrl: string | null = null;
let streamUrl: string | null = null;
@@ -191,16 +171,12 @@ export default async function AdminRadioMonitoringPage() {
streamUrl = null;
}
// Probe each configured endpoint (fail-soft). The now-playing endpoint doubles
// as our "stream reachable?" signal since it reflects the live provider.
const nowPlayingResult = nowPlayingUrl ? await probe(nowPlayingUrl) : null;
const listenersResult = listenersUrl ? await probe(listenersUrl) : null;
const nowPlaying = nowPlayingResult ? parseNowPlaying(nowPlayingResult) : null;
const listeners = listenersResult ? parseListeners(listenersResult) : null;
// Stream is "online" when its primary status source (now-playing, else
// listeners) responded successfully.
const streamProbe = nowPlayingResult ?? listenersResult;
const streamOnline = streamProbe?.reachable ?? false;
const streamConfigured = Boolean(nowPlayingUrl || listenersUrl);
@@ -210,19 +186,19 @@ export default async function AdminRadioMonitoringPage() {
url: string | null;
result: ProbeResult | null;
}[] = [
{ label: 'Now-playing API', url: nowPlayingUrl, result: nowPlayingResult },
{ label: 'Listeners API', url: listenersUrl, result: listenersResult },
{ label: t("monitoring.nowPlayingApi"), url: nowPlayingUrl, result: nowPlayingResult },
{ label: t("monitoring.listenersApi"), url: listenersUrl, result: listenersResult },
];
return (
<main>
<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/monitoring">Monitoring</Link> ·{' '}
<Link href="/admin/radio/banners">Banners</Link> ·{' '}
<Link href="/admin/radio/ranks">Ranks</Link> ·{' '}
<Link href="/admin/radio/history">History</Link>
<Link href="/admin/radio">{t("title")}</Link> ·{' '}
<Link href="/admin/radio/settings">{t("settings")}</Link> ·{' '}
<Link href="/admin/radio/monitoring">{t("monitoring.title")}</Link> ·{' '}
<Link href="/admin/radio/banners">{t("banners.title")}</Link> ·{' '}
<Link href="/admin/radio/ranks">{t("ranks")}</Link> ·{' '}
<Link href="/admin/radio/history">{t("history")}</Link>
</nav>
<section className="mt-6">
@@ -231,44 +207,42 @@ export default async function AdminRadioMonitoringPage() {
<Radio size={20} className="text-[var(--color-primary)]" />
</div>
<div>
<h1 className="m-0 text-xl font-extrabold text-[var(--color-text)]">Radio Monitoring</h1>
<p className="m-0 text-xs text-[var(--color-text-muted)] mt-0.5">Read-only live probe of the configured radio endpoints (<code>radio_now_playing_api_url</code>, <code>radio_listeners_api_url</code>). Each request fails soft with a {FETCH_TIMEOUT_MS / 1000}s timeout — no data is written. Configure URLs under <Link href="/admin/radio/settings">Settings</Link>.</p>
<h1 className="m-0 text-xl font-extrabold text-[var(--color-text)]">{t("monitoring.title")}</h1>
<p className="m-0 text-xs text-[var(--color-text-muted)] mt-0.5">{t("monitoring.subtitle", { timeout: FETCH_TIMEOUT_MS / 1000 })}</p>
</div>
</div>
<div className="grid grid-cols-[repeat(auto-fit,minmax(180px,1fr))] gap-3.5 mb-6">
<StatusCard
label="Stream"
label={t("monitoring.stream")}
value={
!streamConfigured ? 'Not configured' : streamOnline ? 'Online' : 'Offline'
!streamConfigured ? t("monitoring.notConfigured") : streamOnline ? t("monitoring.online") : t("monitoring.offline")
}
state={!streamConfigured ? 'neutral' : streamOnline ? 'ok' : 'danger'}
hint={streamUrl ? shortUrl(streamUrl) : undefined}
icon="📡"
icon="\u{1F4E1}"
/>
<StatusCard
label="Now playing"
value={nowPlaying ?? '—'}
label={t("monitoring.nowPlaying")}
value={nowPlaying ?? '\u2014'}
state={nowPlaying ? 'ok' : 'neutral'}
hint={!nowPlayingUrl ? 'no now-playing API set' : undefined}
icon="🎵"
hint={!nowPlayingUrl ? t("monitoring.noNowPlayingHint") : undefined}
icon="\u{1F3B5}"
/>
<StatusCard
label="Listeners"
value={listeners ?? '—'}
label={t("monitoring.listeners")}
value={listeners ?? '\u2014'}
state={listeners != null ? 'ok' : 'neutral'}
hint={!listenersUrl ? 'no listeners API set' : undefined}
icon="🎧"
hint={!listenersUrl ? t("monitoring.noListenersHint") : undefined}
icon="\u{1F3A7}"
/>
</div>
</section>
<section className="mt-6">
<h2 className="admin-section-title">Endpoint diagnostics</h2>
<h2 className="admin-section-title">{t("monitoring.endpointDiagnostics")}</h2>
<p className="text-xs text-gray-500 dark:text-gray-400">
<span className="inline-block text-[0.72rem] font-bold px-2 py-0.5 rounded-full bg-green-600/20 text-[var(--color-text)]">OK</span> reachable ·{' '}
<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)]">WARN</span> not configured ·{' '}
<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> fetch failed / timed out.
{t("monitoring.diagnosticsLegend")}
</p>
<div className="flex flex-col">
{endpoints.map((ep) => {
@@ -276,15 +250,15 @@ export default async function AdminRadioMonitoringPage() {
let detail: string;
if (!ep.url) {
state = 'warn';
detail = 'Not configured';
detail = t("monitoring.notConfigured");
} else if (ep.result?.reachable) {
state = 'ok';
detail = `${shortUrl(ep.url)}${
ep.result.status != null ? ` · HTTP ${ep.result.status}` : ''
ep.result.status != null ? ` \u00B7 HTTP ${ep.result.status}` : ''
}`;
} else {
state = 'danger';
detail = `${shortUrl(ep.url)} · ${ep.result?.error ?? 'unreachable'}`;
detail = `${shortUrl(ep.url)} \u00B7 ${ep.result?.error ?? t("monitoring.unreachable")}`;
}
return (
<DiagnosticRow
@@ -296,8 +270,8 @@ export default async function AdminRadioMonitoringPage() {
);
})}
<DiagnosticRow
label="Stream URL"
detail={streamUrl ? shortUrl(streamUrl) : 'Not configured'}
label={t("monitoring.streamUrl")}
detail={streamUrl ? shortUrl(streamUrl) : t("monitoring.notConfigured")}
state={streamUrl ? 'ok' : 'warn'}
/>
</div>
+44 -42
View File
@@ -2,6 +2,7 @@ import Link from 'next/link';
import { prisma } from '@/lib/prisma';
import { deleteShout } from '@/actions/admin-radio';
import { StatusCard } from '@/components/admin/dashboard';
import { getTranslations } from "next-intl/server";
export const dynamic = 'force-dynamic';
@@ -34,14 +35,13 @@ type Schedule = {
};
function formatDate(d: Date | null): string {
return d ? d.toISOString().slice(0, 16).replace('T', ' ') : '—';
return d ? d.toISOString().slice(0, 16).replace('T', ' ') : '\u2014';
}
function formatTime(d: Date | null): string {
return d ? d.toISOString().slice(11, 16) : '—';
return d ? d.toISOString().slice(11, 16) : '\u2014';
}
// Map an application status to an ok/danger/neutral pill tone.
function statusBadgeClass(status: string): string {
const s = status.toLowerCase();
if (s === 'approved' || s === 'accepted') return 'ok';
@@ -50,6 +50,8 @@ function statusBadgeClass(status: string): string {
}
export default async function AdminRadioPage() {
const t = await getTranslations("pages.admin.radio");
let shouts: Shout[] = [];
let applications: Application[] = [];
let schedules: Schedule[] = [];
@@ -109,47 +111,47 @@ export default async function AdminRadioPage() {
return (
<main>
<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> ·{' '}
<Link href="/admin/radio/ranks">Ranks</Link> ·{' '}
<Link href="/admin/radio/history">History</Link>
<Link href="/admin/radio">{t("title")}</Link> ·{' '}
<Link href="/admin/radio/settings">{t("settings")}</Link> ·{' '}
<Link href="/admin/radio/banners">{t("banners.title")}</Link> ·{' '}
<Link href="/admin/radio/ranks">{t("ranks")}</Link> ·{' '}
<Link href="/admin/radio/history">{t("history")}</Link>
</nav>
<h1>Radio Moderation</h1>
<h1>{t("title")}</h1>
<p className="text-xs text-gray-500 dark:text-gray-400">
Latest shouts, DJ applications and on-air schedules (25 most recent of each).
{t("subtitle")}
</p>
<div className="grid grid-cols-[repeat(auto-fit,minmax(180px,1fr))] gap-3.5 mb-6">
<StatusCard label="Recent shouts" value={shouts.length} icon="📣" />
<StatusCard label={t("shouts")} value={shouts.length} icon="\u{1F4E3}" />
<StatusCard
label="Pending applications"
label={t("pendingApplications")}
value={pendingApps}
state={pendingApps > 0 ? 'warn' : 'ok'}
icon="📝"
icon="\u{1F4DD}"
/>
<StatusCard label="Schedules" value={schedules.length} icon="🗓️" />
<StatusCard label={t("schedules")} value={schedules.length} icon="\u{1F5D3}\uFE0F" />
<StatusCard
label="Active schedules"
label={t("activeSchedules")}
value={activeSchedules}
state={activeSchedules > 0 ? 'ok' : 'neutral'}
icon="🎙️"
icon="\u{1F399}\uFE0F"
/>
</div>
<section className="mt-6">
<h2>Recent Shouts ({shouts.length})</h2>
<h2>{t("shouts")} ({shouts.length})</h2>
{shouts.length === 0 ? (
<p className="text-xs text-gray-500 dark:text-gray-400">No shouts to moderate.</p>
<p className="text-xs text-gray-500 dark:text-gray-400">{t("noShouts")}</p>
) : (
<table>
<thead>
<tr>
<th>ID</th>
<th>User</th>
<th>Message</th>
<th>Posted</th>
<th>{t("user")}</th>
<th>{t("message")}</th>
<th>{t("posted")}</th>
<th />
</tr>
</thead>
@@ -164,7 +166,7 @@ export default async function AdminRadioPage() {
<form action={deleteShout}>
<input type="hidden" name="id" value={String(s.id)} />
<button type="submit" className="btn btn-danger">
Delete
{t("deleteShout")}
</button>
</form>
</td>
@@ -176,21 +178,21 @@ export default async function AdminRadioPage() {
</section>
<section className="mt-6">
<h2>DJ Applications ({applications.length})</h2>
<h2>{t("applications")} ({applications.length})</h2>
{applications.length === 0 ? (
<p className="text-xs text-gray-500 dark:text-gray-400">No applications submitted.</p>
<p className="text-xs text-gray-500 dark:text-gray-400">{t("noApplications")}</p>
) : (
<table>
<thead>
<tr>
<th>ID</th>
<th>User</th>
<th>Real Name</th>
<th>Age</th>
<th>Style</th>
<th>{t("user")}</th>
<th>{t("realName")}</th>
<th>{t("age")}</th>
<th>{t("style")}</th>
<th>Discord</th>
<th>Status</th>
<th>Applied</th>
<th>{t("status")}</th>
<th>{t("applied")}</th>
</tr>
</thead>
<tbody>
@@ -200,8 +202,8 @@ export default async function AdminRadioPage() {
<td>{String(a.userId)}</td>
<td>{a.realName}</td>
<td>{a.age}</td>
<td>{a.musicStyle ?? '—'}</td>
<td>{a.discordUsername ?? '—'}</td>
<td>{a.musicStyle ?? '\u2014'}</td>
<td>{a.discordUsername ?? '\u2014'}</td>
<td>
<span className={`inline-block text-[0.72rem] font-bold px-2 py-0.5 rounded-full ${statusBadgeClass(a.status) === 'ok' ? 'bg-green-600/20 text-[var(--color-text)]' : statusBadgeClass(a.status) === 'danger' ? 'bg-red-500/22 text-[var(--color-text)]' : 'bg-[var(--color-primary)]/18 text-[var(--color-text)]'}`}>
{a.status}
@@ -216,20 +218,20 @@ export default async function AdminRadioPage() {
</section>
<section className="mt-6">
<h2>Schedules ({schedules.length})</h2>
<h2>{t("schedules")} ({schedules.length})</h2>
{schedules.length === 0 ? (
<p className="text-xs text-gray-500 dark:text-gray-400">No schedules configured.</p>
<p className="text-xs text-gray-500 dark:text-gray-400">{t("noSchedules")}</p>
) : (
<table>
<thead>
<tr>
<th>ID</th>
<th>User</th>
<th>Day</th>
<th>Start</th>
<th>End</th>
<th>Show</th>
<th>Active</th>
<th>{t("user")}</th>
<th>{t("day")}</th>
<th>{t("start")}</th>
<th>{t("end")}</th>
<th>{t("show")}</th>
<th>{t("active")}</th>
</tr>
</thead>
<tbody>
@@ -240,10 +242,10 @@ export default async function AdminRadioPage() {
<td>{sc.day}</td>
<td>{formatTime(sc.startTime)}</td>
<td>{formatTime(sc.endTime)}</td>
<td>{sc.showName ?? '—'}</td>
<td>{sc.showName ?? '\u2014'}</td>
<td>
<span className={`inline-block text-[0.72rem] font-bold px-2 py-0.5 rounded-full ${sc.isActive ? 'bg-green-600/20 text-[var(--color-text)]' : 'bg-red-500/22 text-[var(--color-text)]'}`}>
{sc.isActive ? 'active' : 'inactive'}
{sc.isActive ? t("active") : t("inactive")}
</span>
</td>
</tr>
+36 -37
View File
@@ -3,13 +3,12 @@ import { savePoints } from "@/actions/admin-radio-points";
import { StatusCard } from "@/components/admin/dashboard";
import { siteSettings } from "@/lib/services/site-settings";
import { Star } from "lucide-react";
import { getTranslations } from "next-intl/server";
export const dynamic = "force-dynamic";
export const metadata = { title: "Radio Points" };
// Defaults used when the website_settings rows are missing OR the DB is
// unreachable, so the editor still renders in local dev.
const DEFAULTS = {
radio_points_enabled: "0",
radio_points_per_minute: "1",
@@ -25,10 +24,10 @@ export default async function AdminRadioPointsPage({
}: {
searchParams: Promise<{ saved?: string }>;
}) {
const t = await getTranslations("pages.admin.radio");
const { saved } = await searchParams;
// Read current values with siteSettings.get(defaults); fall back to DEFAULTS
// on any read error so select()-typed values stay strings.
const values = {
radio_points_enabled: await siteSettings
.get("radio_points_enabled", DEFAULTS.radio_points_enabled)
@@ -59,12 +58,12 @@ export default async function AdminRadioPointsPage({
return (
<main>
<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/points">Points</Link> ·{" "}
<Link href="/admin/radio/banners">Banners</Link> ·{" "}
<Link href="/admin/radio/ranks">Ranks</Link> ·{" "}
<Link href="/admin/radio/history">History</Link>
<Link href="/admin/radio">{t("title")}</Link> ·{" "}
<Link href="/admin/radio/settings">{t("settings")}</Link> ·{" "}
<Link href="/admin/radio/points">{t("points")}</Link> ·{" "}
<Link href="/admin/radio/banners">{t("banners.title")}</Link> ·{" "}
<Link href="/admin/radio/ranks">{t("ranks")}</Link> ·{" "}
<Link href="/admin/radio/history">{t("history")}</Link>
</nav>
<section className="mt-6">
@@ -73,46 +72,46 @@ export default async function AdminRadioPointsPage({
<Star size={20} className="text-[var(--color-primary)]" />
</div>
<div>
<h1 className="m-0 text-xl font-extrabold text-[var(--color-text)]">Radio Listener Points</h1>
<p className="m-0 text-xs text-[var(--color-text-muted)] mt-0.5">Reward listeners with currency for time spent on the radio. Edits write to <code>website_settings</code> (<code>radio_points_*</code> keys) and bust the runtime settings cache. Booleans use <code>0</code> / <code>1</code>.</p>
<h1 className="m-0 text-xl font-extrabold text-[var(--color-text)]">{t("pointsForm.title")}</h1>
<p className="m-0 text-xs text-[var(--color-text-muted)] mt-0.5">{t("pointsForm.subtitle")}</p>
</div>
</div>
{saved === "1" ? (
<p>
<span className="inline-block text-[0.72rem] font-bold px-2 py-0.5 rounded-full bg-green-600/20 text-[var(--color-text)]">Saved</span>
<span className="inline-block text-[0.72rem] font-bold px-2 py-0.5 rounded-full bg-green-600/20 text-[var(--color-text)]">{t("pointsForm.saved")}</span>
</p>
) : null}
<div className="grid grid-cols-[repeat(auto-fit,minmax(180px,1fr))] gap-3.5 mb-6">
<StatusCard
label="Points"
value={enabled ? "Enabled" : "Disabled"}
label={t("pointsForm.points")}
value={enabled ? t("pointsForm.enabled") : t("pointsForm.disabled")}
state={enabled ? "ok" : "danger"}
icon="🎧"
icon="\u{1F3A7}"
/>
<StatusCard
label="Per minute"
label={t("pointsForm.perMinute")}
value={perMinute}
hint={`paid in ${selectedCurrency}`}
icon="⏱️"
hint={t("pointsForm.paidIn", { currency: selectedCurrency })}
icon="\u{23F1}\uFE0F"
/>
<StatusCard
label="Max / day"
value={maxPerDay === "0" ? "Unlimited" : maxPerDay}
icon="📈"
label={t("pointsForm.maxPerDay")}
value={maxPerDay === "0" ? t("pointsForm.unlimited") : maxPerDay}
icon="\u{1F4C8}"
/>
<StatusCard label="Min listeners" value={minListeners} icon="👥" />
<StatusCard label={t("pointsForm.minListeners")} value={minListeners} icon="\u{1F465}" />
</div>
</section>
<section className="mt-6">
<h2 className="admin-section-title">Settings</h2>
<h2 className="admin-section-title">{t("pointsForm.settings")}</h2>
<form action={savePoints}>
<div className="grid cols-2">
<div>
<label htmlFor="radio_points_enabled" style={{ display: "block", fontWeight: 700 }}>
Points enabled
{t("pointsForm.pointsEnabled")}
</label>
<select
id="radio_points_enabled"
@@ -120,17 +119,17 @@ export default async function AdminRadioPointsPage({
defaultValue={enabled ? "1" : "0"}
style={{ width: "100%" }}
>
<option value="1">Enabled</option>
<option value="0">Disabled</option>
<option value="1">{t("pointsForm.enabled")}</option>
<option value="0">{t("pointsForm.disabled")}</option>
</select>
<p className="text-xs text-gray-500 dark:text-gray-400" style={{ margin: "0.2rem 0 0" }}>
Master switch for awarding listener points.
{t("pointsForm.pointsEnabledHint")}
</p>
</div>
<div>
<label htmlFor="radio_points_currency" style={{ display: "block", fontWeight: 700 }}>
Currency
{t("pointsForm.currency")}
</label>
<select
id="radio_points_currency"
@@ -145,7 +144,7 @@ export default async function AdminRadioPointsPage({
))}
</select>
<p className="text-xs text-gray-500 dark:text-gray-400" style={{ margin: "0.2rem 0 0" }}>
Which wallet the points are paid into.
{t("pointsForm.currencyHint")}
</p>
</div>
@@ -154,7 +153,7 @@ export default async function AdminRadioPointsPage({
htmlFor="radio_points_per_minute"
style={{ display: "block", fontWeight: 700 }}
>
Points per minute
{t("pointsForm.pointsPerMinute")}
</label>
<input
id="radio_points_per_minute"
@@ -166,7 +165,7 @@ export default async function AdminRadioPointsPage({
style={{ width: "100%" }}
/>
<p className="text-xs text-gray-500 dark:text-gray-400" style={{ margin: "0.2rem 0 0" }}>
Amount awarded for each full minute of listening.
{t("pointsForm.pointsPerMinuteHint")}
</p>
</div>
@@ -175,7 +174,7 @@ export default async function AdminRadioPointsPage({
htmlFor="radio_points_max_per_day"
style={{ display: "block", fontWeight: 700 }}
>
Max points per day
{t("pointsForm.maxPerDay")}
</label>
<input
id="radio_points_max_per_day"
@@ -187,7 +186,7 @@ export default async function AdminRadioPointsPage({
style={{ width: "100%" }}
/>
<p className="text-xs text-gray-500 dark:text-gray-400" style={{ margin: "0.2rem 0 0" }}>
Daily cap per user. Use <code>0</code> for unlimited.
{t("pointsForm.maxPerDayHint")}
</p>
</div>
@@ -196,7 +195,7 @@ export default async function AdminRadioPointsPage({
htmlFor="radio_points_min_listeners"
style={{ display: "block", fontWeight: 700 }}
>
Minimum listeners
{t("pointsForm.minListeners")}
</label>
<input
id="radio_points_min_listeners"
@@ -208,14 +207,14 @@ export default async function AdminRadioPointsPage({
style={{ width: "100%" }}
/>
<p className="text-xs text-gray-500 dark:text-gray-400" style={{ margin: "0.2rem 0 0" }}>
Points are only awarded when at least this many people are tuned in.
{t("pointsForm.minListenersHint")}
</p>
</div>
</div>
<div className="flex justify-end mt-4">
<button type="submit" className="btn btn-primary">
Save points settings
{t("pointsForm.save")}
</button>
</div>
</form>
+29 -26
View File
@@ -7,6 +7,7 @@ import {
import { prisma } from '@/lib/prisma';
import { StatusCard } from '@/components/admin/dashboard';
import { Medal } from 'lucide-react';
import { getTranslations } from "next-intl/server";
export const dynamic = 'force-dynamic';
@@ -19,6 +20,8 @@ type Rank = {
};
export default async function AdminRadioRanksPage() {
const t = await getTranslations("pages.admin.radio");
let ranks: Rank[] = [];
let dbError = false;
@@ -43,11 +46,11 @@ export default async function AdminRadioRanksPage() {
return (
<main>
<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> ·{' '}
<Link href="/admin/radio/ranks">Ranks</Link> ·{' '}
<Link href="/admin/radio/history">History</Link>
<Link href="/admin/radio">{t("title")}</Link> ·{' '}
<Link href="/admin/radio/settings">{t("settings")}</Link> ·{' '}
<Link href="/admin/radio/banners">{t("banners.title")}</Link> ·{' '}
<Link href="/admin/radio/ranks">{t("ranks")}</Link> ·{' '}
<Link href="/admin/radio/history">{t("history")}</Link>
</nav>
<div className="flex items-center gap-3 mb-6">
@@ -55,69 +58,69 @@ export default async function AdminRadioRanksPage() {
<Medal size={20} className="text-[var(--color-primary)]" />
</div>
<div>
<h1 className="m-0 text-xl font-extrabold text-[var(--color-text)]">Radio Ranks</h1>
<p className="m-0 text-xs text-[var(--color-text-muted)] mt-0.5">DJ / staff ranks assignable on radio applications.</p>
<h1 className="m-0 text-xl font-extrabold text-[var(--color-text)]">{t("ranksForm.title")}</h1>
<p className="m-0 text-xs text-[var(--color-text-muted)] mt-0.5">{t("ranksForm.subtitle")}</p>
</div>
</div>
{dbError ? (
<div className="admin-card mb-6">
<p className="text-xs text-gray-500 dark:text-gray-400 m-0">
Could not load ranks (database unavailable).
{t("ranksForm.dbError")}
</p>
</div>
) : (
<div className="grid grid-cols-[repeat(auto-fit,minmax(180px,1fr))] gap-3.5 mb-6">
<StatusCard label="Ranks" value={ranks.length} icon="🎖️" />
<StatusCard label={t("ranks")} value={ranks.length} icon="\u{1F396}\uFE0F" />
<StatusCard
label="Active"
label={t("ranksForm.active")}
value={activeCount}
state={activeCount > 0 ? 'ok' : 'neutral'}
icon="✅"
icon="\u{2705}"
/>
</div>
)}
<section className="mt-6">
<h2 className="admin-section-title">Add rank</h2>
<h2 className="admin-section-title">{t("ranksForm.addRank")}</h2>
<form action={createRadioRank} className="admin-card">
<div className="grid cols-3">
<div>
<label htmlFor="new_name" style={{ display: 'block', fontWeight: 700 }}>
Name
{t("ranksForm.name")}
</label>
<input id="new_name" name="name" required style={{ width: '100%' }} />
</div>
<div>
<label htmlFor="new_badge" style={{ display: 'block', fontWeight: 700 }}>
Badge code
{t("ranksForm.badgeCode")}
</label>
<input id="new_badge" name="badgeCode" style={{ width: '100%' }} />
</div>
<div style={{ display: 'flex', alignItems: 'flex-end' }}>
<label style={{ display: 'inline-flex', gap: '0.4rem', alignItems: 'center' }}>
<input type="checkbox" name="isActive" value="1" defaultChecked /> Active
<input type="checkbox" name="isActive" value="1" defaultChecked /> {t("ranksForm.active")}
</label>
</div>
<div style={{ gridColumn: '1 / -1' }}>
<label htmlFor="new_desc" style={{ display: 'block', fontWeight: 700 }}>
Description
{t("ranksForm.description")}
</label>
<input id="new_desc" name="description" style={{ width: '100%' }} />
</div>
</div>
<div style={{ marginTop: '0.75rem' }}>
<button type="submit" className="btn btn-primary">
Create rank
{t("ranksForm.createRank")}
</button>
</div>
</form>
</section>
<section className="mt-6">
<h2 className="admin-section-title">Existing ranks ({ranks.length})</h2>
<h2 className="admin-section-title">{t("ranksForm.existing", { count: ranks.length })}</h2>
{ranks.length === 0 ? (
<div className="admin-empty">No ranks yet — add one above.</div>
<div className="admin-empty">{t("ranksForm.noRanks")}</div>
) : (
<div className="grid">
{ranks.map((r) => (
@@ -125,7 +128,7 @@ export default async function AdminRadioRanksPage() {
<div className="flex justify-between items-baseline gap-3 mb-2">
<strong>#{String(r.id)}</strong>
<span className={`inline-block text-[0.72rem] font-bold px-2 py-0.5 rounded-full ${r.isActive ? 'bg-green-600/20 text-[var(--color-text)]' : 'bg-red-500/22 text-[var(--color-text)]'}`}>
{r.isActive ? 'active' : 'inactive'}
{r.isActive ? t("ranksForm.activeLabel") : t("ranksForm.inactiveLabel")}
</span>
</div>
@@ -135,19 +138,19 @@ export default async function AdminRadioRanksPage() {
name="name"
defaultValue={r.name}
required
placeholder="Name"
placeholder={t("ranksForm.name")}
style={{ minWidth: 140 }}
/>
<input
name="badgeCode"
defaultValue={r.badgeCode ?? ''}
placeholder="Badge"
placeholder={t("ranksForm.badgeCode")}
style={{ minWidth: 100 }}
/>
<input
name="description"
defaultValue={r.description ?? ''}
placeholder="Description"
placeholder={t("ranksForm.description")}
className="flex-1 min-w-[160px]"
/>
<label
@@ -159,17 +162,17 @@ export default async function AdminRadioRanksPage() {
value="1"
defaultChecked={r.isActive}
/>{' '}
Active
{t("ranksForm.active")}
</label>
<button type="submit" className="btn btn-primary">
Save
{t("ranksForm.save")}
</button>
</form>
<form action={deleteRadioRank} style={{ marginTop: '0.5rem' }}>
<input type="hidden" name="id" value={String(r.id)} />
<button type="submit" className="btn btn-danger">
Delete rank #{String(r.id)}
{t("ranksForm.deleteRank", { id: String(r.id) })}
</button>
</form>
</div>
+27 -28
View File
@@ -3,11 +3,10 @@ import { saveRadioSetting, saveRadioSettings } from '@/actions/admin-radio-extra
import { prisma } from '@/lib/prisma';
import { StatusCard } from '@/components/admin/dashboard';
import { Settings } from 'lucide-react';
import { getTranslations } from "next-intl/server";
export const dynamic = 'force-dynamic';
// Curated radio_* keys (faithful to AtomCMS RadioSettingsSeeder), grouped for
// the editor. Any extra radio_* rows found in the DB are appended under "Other".
type Field = { key: string; comment: string };
type Group = { title: string; fields: Field[] };
@@ -184,6 +183,8 @@ const GROUPS: Group[] = [
const CURATED_KEYS = new Set(GROUPS.flatMap((g) => g.fields.map((f) => f.key)));
export default async function AdminRadioSettingsPage() {
const t = await getTranslations("pages.admin.radio");
let values: Map<string, string> = new Map();
let dbError = false;
let extra: Field[] = [];
@@ -205,13 +206,12 @@ export default async function AdminRadioSettingsPage() {
}
const groups: Group[] = extra.length
? [...GROUPS, { title: 'Other radio settings', fields: extra }]
? [...GROUPS, { title: t("settingsForm.otherSettings"), fields: extra }]
: GROUPS;
const allKeys = [...CURATED_KEYS, ...extra.map((e) => e.key)].join(',');
const totalKeys = CURATED_KEYS.size + extra.length;
// Curated keys that already have a non-empty stored value.
const configuredKeys = [...CURATED_KEYS].filter((k) => {
const v = values.get(k);
return v != null && v !== '';
@@ -221,11 +221,11 @@ export default async function AdminRadioSettingsPage() {
return (
<main>
<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> ·{' '}
<Link href="/admin/radio/ranks">Ranks</Link> ·{' '}
<Link href="/admin/radio/history">History</Link>
<Link href="/admin/radio">{t("title")}</Link> ·{' '}
<Link href="/admin/radio/settings">{t("settings")}</Link> ·{' '}
<Link href="/admin/radio/banners">{t("banners.title")}</Link> ·{' '}
<Link href="/admin/radio/ranks">{t("ranks")}</Link> ·{' '}
<Link href="/admin/radio/history">{t("history")}</Link>
</nav>
<div className="flex items-center gap-3 mb-6">
@@ -233,38 +233,37 @@ export default async function AdminRadioSettingsPage() {
<Settings size={20} className="text-[var(--color-primary)]" />
</div>
<div>
<h1 className="m-0 text-xl font-extrabold text-[var(--color-text)]">Radio Settings</h1>
<p className="m-0 text-xs text-[var(--color-text-muted)] mt-0.5">Edits write to <code>website_settings</code> (radio_* keys) and bust the runtime settings cache. Booleans use <code>0</code> / <code>1</code>.</p>
<h1 className="m-0 text-xl font-extrabold text-[var(--color-text)]">{t("settingsForm.title")}</h1>
<p className="m-0 text-xs text-[var(--color-text-muted)] mt-0.5">{t("settingsForm.subtitle")}</p>
</div>
</div>
{dbError ? (
<div className="admin-card mb-6">
<p className="text-xs text-gray-500 dark:text-gray-400 m-0">
Could not load settings (database unavailable). Defaults will be
created on save.
{t("settingsForm.dbError")}
</p>
</div>
) : (
<div className="grid grid-cols-[repeat(auto-fit,minmax(180px,1fr))] gap-3.5 mb-6">
<StatusCard
label="Radio"
value={radioEnabled ? 'Enabled' : 'Disabled'}
value={radioEnabled ? t("settingsForm.enabled") : t("settingsForm.disabled")}
state={radioEnabled ? 'ok' : 'danger'}
icon="📻"
icon="\u{1F4BB}"
/>
<StatusCard label="Total keys" value={totalKeys} icon="🔑" />
<StatusCard label={t("settingsForm.totalKeys")} value={totalKeys} icon="\u{1F511}" />
<StatusCard
label="Configured"
label={t("settingsForm.configured")}
value={`${configuredKeys}/${CURATED_KEYS.size}`}
hint="curated keys with a value"
icon="⚙️"
hint={t("settingsForm.curatedHint")}
icon="\u{2699}\uFE0F"
/>
<StatusCard
label="Custom keys"
label={t("settingsForm.customKeys")}
value={extra.length}
state={extra.length > 0 ? 'warn' : 'neutral'}
icon="➕"
icon="\u{2795}"
/>
</div>
)}
@@ -273,7 +272,7 @@ export default async function AdminRadioSettingsPage() {
<input type="hidden" name="__keys" value={allKeys} />
<div className="flex justify-end mb-4">
<button type="submit" className="btn btn-primary">
Save all
{t("settingsForm.saveAll")}
</button>
</div>
@@ -308,19 +307,19 @@ export default async function AdminRadioSettingsPage() {
<div className="flex justify-end">
<button type="submit" className="btn btn-primary">
Save all
{t("settingsForm.saveAll")}
</button>
</div>
</form>
<section className="admin-card" style={{ marginTop: '1.5rem' }}>
<h3 style={{ marginTop: 0 }}>Add / overwrite a single key</h3>
<h3 style={{ marginTop: 0 }}>{t("settingsForm.singleKey")}</h3>
<form action={saveRadioSetting} className="flex gap-2 flex-wrap">
<input name="key" placeholder="radio_custom_key" required />
<input name="value" placeholder="value" className="flex-1 min-w-[160px]" />
<input name="comment" placeholder="comment (optional)" />
<input name="key" placeholder={t("settingsForm.keyPlaceholder")} required />
<input name="value" placeholder={t("settingsForm.valuePlaceholder")} className="flex-1 min-w-[160px]" />
<input name="comment" placeholder={t("settingsForm.commentPlaceholder")} />
<button type="submit" className="btn btn-primary">
Save
{t("settingsForm.saveSingle")}
</button>
</form>
</section>
+30 -27
View File
@@ -1,3 +1,4 @@
import { getTranslations } from "next-intl/server";
import {
createCategory,
createValue,
@@ -13,6 +14,8 @@ export const dynamic = "force-dynamic";
const CURRENCY_TYPES = ["diamonds", "credits", "duckets", "points"];
export default async function AdminRareValues() {
const t = await getTranslations("pages.admin.rareValues");
let categories: Awaited<ReturnType<typeof prisma.websiteRareValueCategories.findMany>> = [];
try {
categories = await prisma.websiteRareValueCategories.findMany({
@@ -46,28 +49,28 @@ export default async function AdminRareValues() {
<Gem size={20} className="text-[var(--color-primary)]" />
</div>
<div>
<h1 className="m-0 text-xl font-extrabold text-[var(--color-text)]">Rare Values</h1>
<p className="m-0 text-xs text-[var(--color-text-muted)] mt-0.5">Catalog of rare furni and their trade values, grouped by category. Each category is rendered as a tab on the public rare values page.</p>
<h1 className="m-0 text-xl font-extrabold text-[var(--color-text)]">{t("title")}</h1>
<p className="m-0 text-xs text-[var(--color-text-muted)] mt-0.5">{t("subtitle")}</p>
</div>
</div>
<div className="grid grid-cols-[repeat(auto-fit,minmax(180px,1fr))] gap-3.5 mb-6">
<StatusCard label="Categories" value={categories.length} icon="🗂️" />
<StatusCard label="Rare items" value={values.length} icon="💎" />
<StatusCard label={t("categories")} value={categories.length} icon="🗂️" />
<StatusCard label={t("items")} value={values.length} icon="💎" />
</div>
<form action={createCategory} className="admin-card mb-6">
<h3 className="text-sm font-bold text-[var(--color-text)] m-0 mb-3">New category</h3>
<h3 className="text-sm font-bold text-[var(--color-text)] m-0 mb-3">{t("addCategory")}</h3>
<div className="flex gap-2 flex-wrap">
<input name="name" placeholder="Category name" required className="flex-1 min-w-[160px]" />
<input name="badge" placeholder="Badge code" required />
<input name="priority" type="number" min={1} defaultValue={1} placeholder="Priority" className="w-[110px]" />
<button type="submit" className="btn btn-primary">Add category</button>
<input name="name" placeholder={t("categoryForm.namePlaceholder")} required className="flex-1 min-w-[160px]" />
<input name="badge" placeholder={t("badgePlaceholder")} required />
<input name="priority" type="number" min={1} defaultValue={1} placeholder={t("priorityPlaceholder")} className="w-[110px]" />
<button type="submit" className="btn btn-primary">{t("addCategoryButton")}</button>
</div>
</form>
{categories.length === 0 ? (
<div className="admin-empty">No categories yet. Add one above to start.</div>
<div className="admin-empty">{t("noCategories")}</div>
) : null}
<div className="grid">
@@ -79,25 +82,25 @@ export default async function AdminRareValues() {
<div className="flex gap-3 items-baseline justify-between flex-wrap">
<div style={{ display: "flex", gap: "0.6rem", alignItems: "baseline", flexWrap: "wrap" }}>
<strong>{cat.name}</strong>
<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)]">badge {cat.badge}</span>
<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)]">priority {cat.priority}</span>
<span className="text-sm text-gray-500 dark:text-gray-400">{rows.length} item{rows.length === 1 ? "" : "s"}</span>
<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)]">{t("badgeLabel", { badge: cat.badge })}</span>
<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)]">{t("priorityLabel", { priority: cat.priority })}</span>
<span className="text-sm text-gray-500 dark:text-gray-400">{t("itemCount", { count: rows.length })}</span>
</div>
<form action={deleteCategory}>
<input type="hidden" name="id" value={catId} />
<button type="submit" className="btn btn-danger">Delete category</button>
<button type="submit" className="btn btn-danger">{t("deleteCategory")}</button>
</form>
</div>
<table style={{ marginTop: "0.75rem" }}>
<thead>
<tr>
<th>Name</th>
<th>Item ID</th>
<th>Credit value</th>
<th>Currency value</th>
<th>Currency</th>
<th>Icon</th>
<th>{t("colItemName")}</th>
<th>{t("colItemId")}</th>
<th>{t("colCredits")}</th>
<th>{t("colCurrencyValue")}</th>
<th>{t("colCurrency")}</th>
<th>{t("colIcon")}</th>
<th />
</tr>
</thead>
@@ -124,24 +127,24 @@ export default async function AdminRareValues() {
</table>
{rows.length === 0 ? (
<p className="text-xs text-gray-500 dark:text-gray-400" style={{ margin: "0.5rem 0 0" }}>
No values in this category yet.
{t("noValuesInCategory")}
</p>
) : null}
<form action={createValue} style={{ marginTop: "0.75rem" }}>
<input type="hidden" name="categoryId" value={catId} />
<div className="flex gap-2 flex-wrap">
<input name="name" placeholder="Item name" required className="flex-1 min-w-[140px]" />
<input name="itemId" type="number" min={1} placeholder="Item ID" className="w-[110px]" />
<input name="furnitureIcon" placeholder="Furniture icon" required className="min-w-[140px]" />
<input name="creditValue" placeholder="Credit value" className="w-[130px]" />
<input name="currencyValue" placeholder="Currency value" className="w-[130px]" />
<input name="name" placeholder={t("itemForm.itemNamePlaceholder")} required className="flex-1 min-w-[140px]" />
<input name="itemId" type="number" min={1} placeholder={t("itemForm.itemIdPlaceholder")} className="w-[110px]" />
<input name="furnitureIcon" placeholder={t("itemForm.iconPlaceholder")} required className="min-w-[140px]" />
<input name="creditValue" placeholder={t("itemForm.creditsPlaceholder")} className="w-[130px]" />
<input name="currencyValue" placeholder={t("itemForm.currencyValuePlaceholder")} className="w-[130px]" />
<select name="currencyType" defaultValue="diamonds">
{CURRENCY_TYPES.map((c) => (
<option key={c} value={c}>{c}</option>
))}
</select>
<button type="submit" className="btn btn-primary">Add value</button>
<button type="submit" className="btn btn-primary">{t("itemForm.addValue")}</button>
</div>
</form>
</div>
+23 -21
View File
@@ -1,4 +1,5 @@
import Link from "next/link";
import { getTranslations } from "next-intl/server";
import { notFound } from "next/navigation";
import { prisma } from "@/lib/prisma";
import { DoorOpen } from "lucide-react";
@@ -10,8 +11,9 @@ export default async function AdminRoomDetail({
}: {
params: Promise<{ id: string }>;
}) {
const t = await getTranslations("pages.admin.rooms");
const { id } = await params;
// rooms is an emulator-owned table → Int id.
const roomId = Number(id);
if (!Number.isInteger(roomId) || roomId <= 0) notFound();
@@ -25,36 +27,36 @@ export default async function AdminRoomDetail({
if (!room) notFound();
const fields: Array<[string, string]> = [
["ID", String(room.id)],
["Name", room.name || "(unnamed)"],
["Description", room.description || "—"],
["Owner", `${room.ownerName || "—"} (#${room.ownerId})`],
["State", room.state],
["Users", `${room.users} / ${room.usersMax}`],
["Category", String(room.category)],
["Score", String(room.score)],
["Model", room.model],
["Guild ID", room.guildId ? String(room.guildId) : "—"],
["Tags", room.tags || "—"],
["Password", room.password ? "yes" : "—"],
["Public", room.isPublic === "1" ? "yes" : "no"],
["Staff picked", room.isStaffPicked === "1" ? "yes" : "no"],
["For sale", room.isForSale === "1" ? "yes" : "no"],
["Trade mode", String(room.tradeMode)],
[t("colId"), String(room.id)],
[t("colName"), room.name || t("unnamed")],
[t("detail.description"), room.description || "—"],
[t("detail.owner"), `${room.ownerName || "—"} (#${room.ownerId})`],
[t("detail.state"), room.state],
[t("colUsers"), `${room.users} / ${room.usersMax}`],
[t("colCategory"), String(room.category)],
[t("detail.score"), String(room.score)],
[t("detail.model"), room.model],
[t("detail.guildId"), room.guildId ? String(room.guildId) : "—"],
[t("detail.tags"), room.tags || "—"],
[t("detail.password"), room.password ? t("detail.yes") : "—"],
[t("detail.public"), room.isPublic === "1" ? t("detail.yes") : t("detail.no")],
[t("detail.staffPicked"), room.isStaffPicked === "1" ? t("detail.yes") : t("detail.no")],
[t("detail.forSale"), room.isForSale === "1" ? t("detail.yes") : t("detail.no")],
[t("detail.tradeMode"), String(room.tradeMode)],
];
return (
<main>
<p className="text-sm text-gray-500 dark:text-gray-400 mb-1">
<Link href="/admin/rooms">← Rooms</Link>
<Link href="/admin/rooms">← {t("title")}</Link>
</p>
<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">
<DoorOpen size={20} className="text-[var(--color-primary)]" />
</div>
<div>
<h1 className="m-0 text-xl font-extrabold text-[var(--color-text)]">{room.name || "(unnamed)"}</h1>
<p className="m-0 text-xs text-[var(--color-text-muted)] mt-0.5">ID {room.id} · owner {room.ownerName || "—"} · {room.users}/{room.usersMax} users · {room.state}</p>
<h1 className="m-0 text-xl font-extrabold text-[var(--color-text)]">{room.name || t("unnamed")}</h1>
<p className="m-0 text-xs text-[var(--color-text-muted)] mt-0.5">{t("detail.subtitle", { id: room.id, owner: room.ownerName || "—", users: room.users, maxUsers: room.usersMax, state: room.state })}</p>
</div>
</div>
@@ -73,7 +75,7 @@ export default async function AdminRoomDetail({
{room.ownerId ? (
<p className="muted" style={{ marginTop: "1rem" }}>
<Link href={`/admin/users/${room.ownerId}`}>View owner →</Link>
<Link href={`/admin/users/${room.ownerId}`}>{t("detail.viewOwner")} →</Link>
</p>
) : null}
</main>
+21 -21
View File
@@ -1,4 +1,5 @@
import Link from "next/link";
import { getTranslations } from "next-intl/server";
import { StatusCard } from "@/components/admin/dashboard";
import { prisma } from "@/lib/prisma";
import { DoorOpen } from "lucide-react";
@@ -22,12 +23,12 @@ export default async function AdminRooms({
}: {
searchParams: Promise<{ q?: string; page?: string }>;
}) {
const t = await getTranslations("pages.admin.rooms");
const sp = await searchParams;
const q = (sp.q ?? "").trim();
const page = Math.max(1, Number(sp.page ?? "1") || 1);
// Search matches room name OR owner username (ownerName is denormalised on the
// emulator rooms table, so no join is needed).
const where = q
? { OR: [{ name: { contains: q } }, { ownerName: { contains: q } }] }
: {};
@@ -62,7 +63,6 @@ export default async function AdminRooms({
const qs = (p: number) =>
`/admin/rooms?q=${encodeURIComponent(q)}&page=${p}`;
// Page-local tallies (the full counts aren't fetched to keep the query cheap).
const openOnPage = rooms.filter((r) => r.state === "open").length;
const usersOnPage = rooms.reduce((sum, r) => sum + r.users, 0);
@@ -73,21 +73,21 @@ export default async function AdminRooms({
<DoorOpen size={20} className="text-[var(--color-primary)]" />
</div>
<div>
<h1 className="m-0 text-xl font-extrabold text-[var(--color-text)]">Rooms</h1>
<p className="m-0 text-xs text-[var(--color-text-muted)] mt-0.5">{q ? `Matching "${q}" · ` : ""}{total} room{total === 1 ? "" : "s"} total.</p>
<h1 className="m-0 text-xl font-extrabold text-[var(--color-text)]">{t("title")}</h1>
<p className="m-0 text-xs text-[var(--color-text-muted)] mt-0.5">{q ? t("matchingQuery", { query: q }) + " · " : ""}{t("totalRooms", { count: total })}</p>
</div>
</div>
<div className="grid grid-cols-[repeat(auto-fit,minmax(180px,1fr))] gap-3.5 mb-6">
<StatusCard label={q ? "Matching rooms" : "Total rooms"} value={total} icon="🏠" />
<StatusCard label="Open (this page)" value={openOnPage} state="ok" icon="🔓" />
<StatusCard label="Users in-room (this page)" value={usersOnPage} icon="👥" />
<StatusCard label={q ? t("matchingRooms") : t("totalRooms", { count: total })} value={total} icon="🏠" />
<StatusCard label={t("openThisPage")} value={openOnPage} state="ok" icon="🔓" />
<StatusCard label={t("usersInRoom")} value={usersOnPage} icon="👥" />
</div>
<form className="flex gap-2 mb-4">
<input name="q" defaultValue={q} placeholder="Search room or owner" />
<input name="q" defaultValue={q} placeholder={t("searchPlaceholder")} />
<button type="submit" className="btn btn-primary">
Search
{t("search")}
</button>
</form>
@@ -95,12 +95,12 @@ export default async function AdminRooms({
<table>
<thead>
<tr>
<th>ID</th>
<th>Name</th>
<th>Owner</th>
<th>Users</th>
<th>State</th>
<th>Cat</th>
<th>{t("colId")}</th>
<th>{t("colName")}</th>
<th>{t("colOwner")}</th>
<th>{t("colUsers")}</th>
<th>{t("colState")}</th>
<th>{t("colCategory")}</th>
</tr>
</thead>
<tbody>
@@ -108,7 +108,7 @@ export default async function AdminRooms({
<tr key={r.id}>
<td className="text-sm text-gray-500 dark:text-gray-400 whitespace-nowrap">{r.id}</td>
<td>
<Link href={`/admin/rooms/${r.id}`}>{r.name || "(unnamed)"}</Link>
<Link href={`/admin/rooms/${r.id}`}>{r.name || t("unnamed")}</Link>
</td>
<td>{r.ownerName || "—"}</td>
<td>
@@ -128,22 +128,22 @@ export default async function AdminRooms({
{rooms.length === 0 ? (
<p className="text-xs text-gray-500 dark:text-gray-400" style={{ marginTop: "1rem" }}>
No rooms found.
{t("noRooms")}
</p>
) : null}
<p className="text-xs text-gray-500 dark:text-gray-400" style={{ marginTop: "1rem" }}>
Page {page} / {pages} · {total} rooms
{t("pagination", { page, pages, total })}
{page > 1 ? (
<>
{" · "}
<Link href={qs(page - 1)}>← Prev</Link>
<Link href={qs(page - 1)}>← {t("prev")}</Link>
</>
) : null}
{page < pages ? (
<>
{" · "}
<Link href={qs(page + 1)}>Next →</Link>
<Link href={qs(page + 1)}>{t("next")} →</Link>
</>
) : null}
</p>
+15 -12
View File
@@ -1,3 +1,4 @@
import { getTranslations } from "next-intl/server";
import { createSetting, deleteSetting, updateSetting } from "@/actions/admin-settings";
import { StatusCard } from "@/components/admin/dashboard";
import { prisma } from "@/lib/prisma";
@@ -5,6 +6,8 @@ import { prisma } from "@/lib/prisma";
export const dynamic = "force-dynamic";
export default async function AdminSettings() {
const t = await getTranslations("pages.admin.settings");
let settings: Awaited<ReturnType<typeof prisma.websiteSetting.findMany>> = [];
try {
settings = await prisma.websiteSetting.findMany({ orderBy: { key: "asc" } });
@@ -21,36 +24,36 @@ export default async function AdminSettings() {
<span className="text-lg">⚙️</span>
</div>
<div>
<h1 className="m-0 text-xl font-extrabold text-[var(--color-text)]">CMS Settings</h1>
<h1 className="m-0 text-xl font-extrabold text-[var(--color-text)]">{t("title")}</h1>
<p className="m-0 text-xs text-[var(--color-text-muted)] mt-0.5">
Key/value config read at runtime via <code>siteSettings.get()</code>. Saving busts the cache.
{t.rich("subtitle", { code: (chunks) => <code>{chunks}</code> })}
</p>
</div>
</div>
<div className="admin-stat-row">
<span className="admin-stat-chip">{settings.length} settings</span>
<span className="admin-stat-chip">{documented} with comment</span>
<span className="admin-stat-chip">{t("settingsCount", { count: settings.length })}</span>
<span className="admin-stat-chip">{t("documentedCount", { count: documented })}</span>
</div>
<section>
<h2 className="admin-section-title">Add / overwrite</h2>
<h2 className="admin-section-title">{t("addOverwrite")}</h2>
<form action={createSetting} className="admin-card">
<div className="flex gap-2 flex-wrap">
<input name="key" placeholder="key" required />
<input name="value" placeholder="value" className="flex-1 min-w-[160px]" />
<input name="comment" placeholder="comment" />
<button type="submit" className="btn btn-primary">Save</button>
<input name="key" placeholder={t("form.keyPlaceholder")} required />
<input name="value" placeholder={t("form.valuePlaceholder")} className="flex-1 min-w-[160px]" />
<input name="comment" placeholder={t("form.commentPlaceholder")} />
<button type="submit" className="btn btn-primary">{t("save")}</button>
</div>
</form>
</section>
<section>
<h2 className="admin-section-title">
Settings <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)]">{settings.length}</span>
{t("settings")} <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)]">{settings.length}</span>
</h2>
{settings.length === 0 ? (
<div className="admin-empty">No settings yet.</div>
<div className="admin-empty">{t("noSettings")}</div>
) : (
<div className="grid">
{settings.map((s) => (
@@ -64,7 +67,7 @@ export default async function AdminSettings() {
<input type="hidden" name="key" value={s.key} />
<input name="value" defaultValue={s.value} style={{ flex: 1 }} />
<button type="submit" className="btn btn-outline">
Save
{t("save")}
</button>
</form>
<form action={deleteSetting}>
+18 -15
View File
@@ -1,4 +1,5 @@
import Link from "next/link";
import { getTranslations } from "next-intl/server";
import { notFound } from "next/navigation";
import { deleteShopArticle, updateShopArticle } from "@/actions/admin-shop";
import { prisma } from "@/lib/prisma";
@@ -11,6 +12,8 @@ export default async function EditShopArticle({
}: {
params: Promise<{ id: string }>;
}) {
const t = await getTranslations("pages.admin.shop");
const { id } = await params;
let pkg: Awaited<ReturnType<typeof prisma.websiteShopArticles.findUnique>> = null;
try {
@@ -27,9 +30,9 @@ export default async function EditShopArticle({
<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>
<h1 className="m-0 text-xl font-extrabold text-[var(--color-text)]">{t("editPackageTitle")}</h1>
<Link href="/admin/shop" className="text-xs text-[var(--color-text-muted)] hover:text-[var(--color-primary)]">
← Back to shop
← {t("backToShop")}
</Link>
</div>
</div>
@@ -37,26 +40,26 @@ export default async function EditShopArticle({
<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" />
<input name="name" defaultValue={pkg.name} placeholder={t("form.namePlaceholder")} required maxLength={255} className="w-full" />
<input name="info" defaultValue={pkg.info} placeholder={t("form.infoPlaceholder")} maxLength={255} className="w-full" />
<input name="icon" defaultValue={pkg.iconUrl} placeholder={t("form.iconPlaceholder")} maxLength={255} className="w-full" />
<input name="color" defaultValue={pkg.color} placeholder={t("form.colorPlaceholder")} maxLength={255} className="w-full" />
<input name="costs" type="number" min={0} defaultValue={pkg.costs} placeholder={t("form.costsPlaceholder")} required className="w-full" />
<input name="credits" type="number" min={0} defaultValue={pkg.credits ?? undefined} placeholder={t("form.creditsPlaceholder")} className="w-full" />
<input name="duckets" type="number" min={0} defaultValue={pkg.duckets ?? undefined} placeholder={t("form.ducketsPlaceholder")} className="w-full" />
<input name="diamonds" type="number" min={0} defaultValue={pkg.diamonds ?? undefined} placeholder={t("form.diamondsPlaceholder")} className="w-full" />
<input name="giveRank" type="number" min={0} defaultValue={pkg.giveRank ?? undefined} placeholder={t("form.giveRankPlaceholder")} className="w-full" />
<input name="badges" defaultValue={pkg.badges ?? ""} placeholder={t("form.badgesPlaceholder")} maxLength={255} className="w-full" />
<input name="position" type="number" min={0} defaultValue={pkg.position} placeholder={t("form.positionPlaceholder")} className="w-full" />
</div>
<div className="mt-6 flex gap-2">
<button type="submit" className="btn btn-primary">
Save
{t("form.save")}
</button>
<form action={deleteShopArticle}>
<input type="hidden" name="id" value={String(pkg.id)} />
<button type="submit" className="btn btn-danger">
Delete package
{t("form.deletePackage")}
</button>
</form>
</div>
+18 -15
View File
@@ -1,8 +1,11 @@
import Link from "next/link";
import { getTranslations } from "next-intl/server";
import { createShopArticle } from "@/actions/admin-shop";
import { Package } from "lucide-react";
export default function NewShopArticle() {
export default async function NewShopArticle() {
const t = await getTranslations("pages.admin.shop");
return (
<main>
<div className="flex items-center gap-3 mb-6">
@@ -10,30 +13,30 @@ export default function NewShopArticle() {
<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>
<h1 className="m-0 text-xl font-extrabold text-[var(--color-text)]">{t("newPackageTitle")}</h1>
<Link href="/admin/shop" className="text-xs text-[var(--color-text-muted)] hover:text-[var(--color-primary)]">
← Back to shop
← {t("backToShop")}
</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" />
<input name="name" placeholder={t("form.namePlaceholder")} required maxLength={255} className="w-full" />
<input name="info" placeholder={t("form.infoPlaceholder")} maxLength={255} className="w-full" />
<input name="icon" placeholder={t("form.iconPlaceholder")} maxLength={255} className="w-full" />
<input name="color" placeholder={t("form.colorPlaceholder")} maxLength={255} className="w-full" />
<input name="costs" type="number" min={0} placeholder={t("form.costsPlaceholder")} required className="w-full" />
<input name="credits" type="number" min={0} placeholder={t("form.creditsPlaceholder")} className="w-full" />
<input name="duckets" type="number" min={0} placeholder={t("form.ducketsPlaceholder")} className="w-full" />
<input name="diamonds" type="number" min={0} placeholder={t("form.diamondsPlaceholder")} className="w-full" />
<input name="giveRank" type="number" min={0} placeholder={t("form.giveRankPlaceholder")} className="w-full" />
<input name="badges" placeholder={t("form.badgesPlaceholder")} maxLength={255} className="w-full" />
<input name="position" type="number" min={0} placeholder={t("form.positionPlaceholder")} defaultValue={0} className="w-full" />
</div>
<div className="mt-6 text-right">
<button type="submit" className="btn btn-primary">
Create
{t("form.create")}
</button>
</div>
</form>
+27 -25
View File
@@ -1,4 +1,5 @@
import Link from "next/link";
import { getTranslations } from "next-intl/server";
import { StatusCard } from "@/components/admin/dashboard";
import { prisma } from "@/lib/prisma";
@@ -16,6 +17,8 @@ function statusState(status: string | null | undefined): "ok" | "danger" | undef
}
export default async function AdminShop() {
const t = await getTranslations("pages.admin.shop");
let packages: Awaited<ReturnType<typeof prisma.websiteShopArticles.findMany>> = [];
try {
packages = await prisma.websiteShopArticles.findMany({
@@ -41,21 +44,20 @@ export default async function AdminShop() {
return (
<main>
<div style={{ display: "flex", alignItems: "center", gap: "1rem" }}>
<h1 style={{ flex: 1 }}>Shop</h1>
<h1 style={{ flex: 1 }}>{t("title")}</h1>
<Link href="/admin/shop/new" className="btn btn-primary">
+ New package
{t("newPackage")}
</Link>
</div>
<p className="muted" style={{ marginTop: "-0.4rem" }}>
Website store packages (<code>website_shop_articles</code>). <code>costs</code> is the price
shown in the store; credits/duckets/diamonds/badges are what the buyer receives.
{t("subtitle")} (<code>website_shop_articles</code>). {t("tableHint")}
</p>
<div className="grid grid-cols-[repeat(auto-fit,minmax(180px,1fr))] gap-3.5 mb-6">
<StatusCard label="Packages" value={packages.length} icon="🛍️" />
<StatusCard label="Recent orders" value={orders.length} icon="🧾" />
<StatusCard label={t("packages")} value={packages.length} icon="🛍️" />
<StatusCard label={t("recentOrders")} value={orders.length} icon="🧾" />
<StatusCard
label="Paid orders"
label={t("paidOrders")}
value={paidOrders}
state={paidOrders > 0 ? "ok" : "neutral"}
icon="💳"
@@ -63,22 +65,22 @@ export default async function AdminShop() {
</div>
<section className="mt-6">
<h2>Packages ({packages.length})</h2>
<h2>{t("packages")} ({packages.length})</h2>
{packages.length === 0 ? (
<p className="muted">No shop packages yet.</p>
<p className="muted">{t("noPackages")}</p>
) : (
<div style={{ overflowX: "auto" }}>
<table>
<thead>
<tr>
<th>#</th>
<th>Name</th>
<th>Costs</th>
<th>Credits</th>
<th>Duckets</th>
<th>Diamonds</th>
<th>Rank</th>
<th>Position</th>
<th>{t("colName")}</th>
<th>{t("colPrice")}</th>
<th>{t("colCredits")}</th>
<th>{t("colDuckets")}</th>
<th>{t("colDiamonds")}</th>
<th>{t("colRank")}</th>
<th>{t("colPosition")}</th>
</tr>
</thead>
<tbody>
@@ -103,23 +105,23 @@ export default async function AdminShop() {
</section>
<section className="mt-6">
<h2>Recent orders ({orders.length})</h2>
<h2>{t("recentOrders")} ({orders.length})</h2>
<p className="muted" style={{ marginTop: "-0.4rem" }}>
Read-only PayPal transaction log (<code>website_paypal_transactions</code>).
{t("ordersHint")} (<code>website_paypal_transactions</code>).
</p>
{orders.length === 0 ? (
<p className="muted">No orders recorded.</p>
<p className="muted">{t("noOrders")}</p>
) : (
<div style={{ overflowX: "auto" }}>
<table>
<thead>
<tr>
<th>Transaction</th>
<th>User</th>
<th>Description</th>
<th>Amount</th>
<th>Status</th>
<th>Date</th>
<th>{t("colTransaction")}</th>
<th>{t("colUser")}</th>
<th>{t("colDescription")}</th>
<th>{t("colAmount")}</th>
<th>{t("colStatus")}</th>
<th>{t("colDate")}</th>
</tr>
</thead>
<tbody>
+15 -16
View File
@@ -1,4 +1,5 @@
import { CreditCard } from "lucide-react";
import { getTranslations } from "next-intl/server";
import { StatusCard } from "@/components/admin/dashboard";
import { prisma } from "@/lib/prisma";
@@ -16,12 +17,11 @@ function fmt(ts: number): string {
}
export default async function AdminSubscriptions() {
const t = await getTranslations("pages.admin.subscriptions");
const now = Math.floor(Date.now() / 1000);
let active: SubRow[] = [];
try {
// Schema has no timestamp_end column; expiry = timestamp_start + duration.
// Active subs are active=1 with a computed expiry still in the future.
const rows = await prisma.usersSubscriptions.findMany({
where: { active: 1 },
orderBy: { id: "desc" },
@@ -41,7 +41,6 @@ export default async function AdminSubscriptions() {
active = [];
}
// "Expiring soon" = within the next 7 days.
const soonCutoff = now + 7 * 24 * 60 * 60;
const expiringSoon = active.filter((s) => s.expiresAt <= soonCutoff).length;
const types = new Set(active.map((s) => s.subscriptionType ?? "—")).size;
@@ -53,16 +52,16 @@ export default async function AdminSubscriptions() {
<CreditCard size={20} className="text-[var(--color-primary)]" />
</div>
<div>
<h1 className="m-0 text-xl font-extrabold text-[var(--color-text)]">Subscriptions</h1>
<p className="m-0 text-xs text-[var(--color-text-muted)] mt-0.5">Active Habbo Club / VIP subscriptions (expiry &gt; now). Read-only.</p>
<h1 className="m-0 text-xl font-extrabold text-[var(--color-text)]">{t("title")}</h1>
<p className="m-0 text-xs text-[var(--color-text-muted)] mt-0.5">{t("subtitle")}</p>
</div>
</div>
<div className="grid grid-cols-[repeat(auto-fit,minmax(180px,1fr))] gap-3.5 mb-6">
<StatusCard label="Active subs" value={active.length} icon="⭐" />
<StatusCard label="Types" value={types} icon="🏷️" />
<StatusCard label={t("activeSubs")} value={active.length} icon="⭐" />
<StatusCard label={t("types")} value={types} icon="🏷️" />
<StatusCard
label="Expiring ≤ 7d"
label={t("expiringSoon")}
value={expiringSoon}
state={expiringSoon > 0 ? "warn" : "ok"}
icon="⏳"
@@ -70,16 +69,16 @@ export default async function AdminSubscriptions() {
</div>
<section className="mt-6">
<h2 className="admin-section-title">Active subscriptions ({active.length})</h2>
<h2 className="admin-section-title">{t("activeSubscriptions")} ({active.length})</h2>
<div className="admin-card">
<table>
<thead>
<tr>
<th>Sub ID</th>
<th>User ID</th>
<th>Type</th>
<th>Status</th>
<th>Expires</th>
<th>{t("colSubId")}</th>
<th>{t("colUserId")}</th>
<th>{t("colType")}</th>
<th>{t("colStatus")}</th>
<th>{t("colExpires")}</th>
</tr>
</thead>
<tbody>
@@ -92,7 +91,7 @@ export default async function AdminSubscriptions() {
<td>{s.subscriptionType ?? "—"}</td>
<td>
<span className={`inline-block text-[0.72rem] font-bold px-2 py-0.5 rounded-full ${soon ? "bg-[var(--color-primary)]/18 text-[var(--color-text)]" : "bg-green-600/20 text-[var(--color-text)]"}`}>
{soon ? "Expiring" : "Active"}
{soon ? t("statusExpiring") : t("statusActive")}
</span>
</td>
<td className="text-sm text-gray-500 dark:text-gray-400 whitespace-nowrap">{fmt(s.expiresAt)}</td>
@@ -101,7 +100,7 @@ export default async function AdminSubscriptions() {
})}
</tbody>
</table>
{active.length === 0 ? <div className="admin-empty">No active subscriptions.</div> : null}
{active.length === 0 ? <div className="admin-empty">{t("noSubscriptions")}</div> : null}
</div>
</section>
</main>
+36 -34
View File
@@ -1,3 +1,4 @@
import { getTranslations } from "next-intl/server";
import { Hash } from "lucide-react";
import { createTag, deleteTag, updateTag } from '@/actions/admin-tags';
import { StatusCard } from '@/components/admin/dashboard';
@@ -17,6 +18,7 @@ function formatDate(d: Date | null): string {
}
export default async function AdminTagsPage() {
const t = await getTranslations("pages.admin.tags");
let tags: Tag[] = [];
let usage = new Map<string, number>();
let dbError = false;
@@ -49,29 +51,29 @@ export default async function AdminTagsPage() {
<Hash size={20} className="text-[var(--color-primary)]" />
</div>
<div>
<h1 className="m-0 text-xl font-extrabold text-[var(--color-text)]">Tags</h1>
<p className="m-0 text-xs text-[var(--color-text-muted)] mt-0.5">Article tags / categories (<code>tags</code>). Each tag has a label and a background colour, and can be attached to articles via <code>taggables</code>.</p>
<h1 className="m-0 text-xl font-extrabold text-[var(--color-text)]">{t("title")}</h1>
<p className="m-0 text-xs text-[var(--color-text-muted)] mt-0.5">{t("subtitle")}</p>
</div>
</div>
{dbError ? (
<div className="admin-card" style={{ marginBottom: '1.5rem' }}>
<p className="text-xs text-gray-500 dark:text-gray-400 m-0">
Could not load tags (database unavailable).
{t("databaseError")}
</p>
</div>
) : (
<div className="grid grid-cols-[repeat(auto-fit,minmax(180px,1fr))] gap-3.5 mb-6">
<StatusCard label="Tags" value={tags.length} icon="🏷️" />
<StatusCard label={t("totalTags")} value={tags.length} icon="🏷️" />
<StatusCard
label="In use"
label={t("inUse")}
value={inUse}
hint="attached to content"
hint={t("attachedToContent")}
state={inUse > 0 ? 'ok' : 'neutral'}
icon="🔗"
/>
<StatusCard
label="Unused"
label={t("unused")}
value={unused}
state={unused > 0 ? 'warn' : 'ok'}
icon="🫙"
@@ -80,16 +82,16 @@ export default async function AdminTagsPage() {
)}
<form action={createTag} className="admin-card mb-6">
<h3 style={{ marginTop: 0 }}>Add tag</h3>
<h3 style={{ marginTop: 0 }}>{t("addTag")}</h3>
<div className="grid cols-2">
<div>
<label htmlFor="new_name" style={{ display: 'block', fontWeight: 700 }}>
Name
{t("name")}
</label>
<input
id="new_name"
name="name"
placeholder="e.g. Events"
placeholder={t("namePlaceholder")}
required
maxLength={255}
style={{ width: '100%' }}
@@ -100,7 +102,7 @@ export default async function AdminTagsPage() {
htmlFor="new_backgroundColor"
style={{ display: 'block', fontWeight: 700 }}
>
Background colour
{t("backgroundColour")}
</label>
<input
id="new_backgroundColor"
@@ -114,84 +116,84 @@ export default async function AdminTagsPage() {
</div>
<div style={{ marginTop: '0.75rem' }}>
<button type="submit" className="btn btn-primary">
Create tag
{t("createTag")}
</button>
</div>
</form>
<section className="mt-6">
<h2 className="admin-section-title">All tags</h2>
<h2 className="admin-section-title">{t("allTags")}</h2>
{tags.length === 0 ? (
<div className="admin-empty">No tags yet.</div>
<div className="admin-empty">{t("noTags")}</div>
) : (
<table>
<thead>
<tr>
<th>ID</th>
<th>Tag</th>
<th>Colour</th>
<th>Used by</th>
<th>Created</th>
<th>{t("colId")}</th>
<th>{t("colTag")}</th>
<th>{t("colColour")}</th>
<th>{t("colUsedBy")}</th>
<th>{t("colCreated")}</th>
<th />
</tr>
</thead>
<tbody>
{tags.map((t) => (
<tr key={String(t.id)}>
<td className="text-sm text-gray-500 dark:text-gray-400 whitespace-nowrap">{String(t.id)}</td>
{tags.map((tag) => (
<tr key={String(tag.id)}>
<td className="text-sm text-gray-500 dark:text-gray-400 whitespace-nowrap">{String(tag.id)}</td>
<td>
<span
style={{
display: 'inline-block',
padding: '0.1rem 0.5rem',
borderRadius: '0.4rem',
background: t.backgroundColor,
background: tag.backgroundColor,
color: '#fff',
}}
>
{t.name}
{tag.name}
</span>
</td>
<td className="text-sm text-gray-500 dark:text-gray-400 whitespace-nowrap">
<code>{t.backgroundColor}</code>
<code>{tag.backgroundColor}</code>
</td>
<td>
<span
className={`inline-block text-[0.72rem] font-bold px-2 py-0.5 rounded-full ${
(usage.get(String(t.id)) ?? 0) > 0 ? 'bg-green-600/20 text-[var(--color-text)]' : 'bg-[var(--color-primary)]/18 text-[var(--color-text)]'
(usage.get(String(tag.id)) ?? 0) > 0 ? 'bg-green-600/20 text-[var(--color-text)]' : 'bg-[var(--color-primary)]/18 text-[var(--color-text)]'
}`}
>
{usage.get(String(t.id)) ?? 0}
{usage.get(String(tag.id)) ?? 0}
</span>
</td>
<td className="text-sm text-gray-500 dark:text-gray-400 whitespace-nowrap">{formatDate(t.createdAt)}</td>
<td className="text-sm text-gray-500 dark:text-gray-400 whitespace-nowrap">{formatDate(tag.createdAt)}</td>
<td>
<form
action={updateTag}
style={{ display: 'flex', gap: '0.4rem', alignItems: 'center' }}
>
<input type="hidden" name="id" value={String(t.id)} />
<input type="hidden" name="id" value={String(tag.id)} />
<input
name="name"
defaultValue={t.name}
defaultValue={tag.name}
required
maxLength={255}
style={{ width: 140 }}
/>
<input
name="backgroundColor"
defaultValue={t.backgroundColor}
defaultValue={tag.backgroundColor}
maxLength={10}
className="w-[90px]"
/>
<button type="submit" className="btn btn-primary">
Save
{t("updateTag")}
</button>
</form>
<form action={deleteTag} style={{ marginTop: '0.4rem' }}>
<input type="hidden" name="id" value={String(t.id)} />
<input type="hidden" name="id" value={String(tag.id)} />
<button type="submit" className="btn btn-danger">
Delete
{t("deleteTag")}
</button>
</form>
</td>
+38 -36
View File
@@ -2,6 +2,7 @@ import { Users } from "lucide-react";
import { createTeam, deleteTeam } from "@/actions/admin-teams";
import { StatusCard } from "@/components/admin/dashboard";
import { prisma } from "@/lib/prisma";
import { getTranslations } from "next-intl/server";
export const dynamic = "force-dynamic";
@@ -15,6 +16,7 @@ type TeamRow = {
};
export default async function AdminTeams() {
const t = await getTranslations("pages.admin.teams");
let teams: TeamRow[] = [];
try {
teams = await prisma.websiteTeams.findMany({
@@ -41,80 +43,80 @@ export default async function AdminTeams() {
<Users size={20} className="text-[var(--color-primary)]" />
</div>
<div>
<h1 className="m-0 text-xl font-extrabold text-[var(--color-text)]">Teams</h1>
<p className="m-0 text-xs text-[var(--color-text-muted)] mt-0.5">Staff rank definitions shown on the public team page.</p>
<h1 className="m-0 text-xl font-extrabold text-[var(--color-text)]">{t("title")}</h1>
<p className="m-0 text-xs text-[var(--color-text-muted)] mt-0.5">{t("subtitle")}</p>
</div>
</div>
<div className="grid grid-cols-[repeat(auto-fit,minmax(180px,1fr))] gap-3.5 mb-6">
<StatusCard label="Team ranks" value={teams.length} icon="🛡️" />
<StatusCard label={t("teamRanks")} value={teams.length} icon="🛡️" />
<StatusCard
label="Visible"
label={t("visible")}
value={teams.length - hidden}
state={teams.length - hidden > 0 ? "ok" : "neutral"}
icon="👁️"
/>
<StatusCard label="Hidden" value={hidden} icon="🙈" />
<StatusCard label={t("hidden")} value={hidden} icon="🙈" />
</div>
<div className="admin-card" style={{ marginBottom: "1.5rem" }}>
<h3 style={{ marginTop: 0 }}>New team rank</h3>
<h3 style={{ marginTop: 0 }}>{t("newTeamRank")}</h3>
<form action={createTeam} style={{ display: "grid", gap: "0.6rem" }}>
<div className="grid cols-2">
<label style={{ display: "grid", gap: "0.25rem" }}>
<span className="text-sm text-gray-500 dark:text-gray-400">Rank name</span>
<input name="rankName" placeholder="e.g. Moderator" required />
<span className="text-sm text-gray-500 dark:text-gray-400">{t("rankName")}</span>
<input name="rankName" placeholder={t("rankNamePlaceholder")} required />
</label>
<label style={{ display: "grid", gap: "0.25rem" }}>
<span className="text-sm text-gray-500 dark:text-gray-400">Badge code</span>
<input name="badge" placeholder="e.g. ADM" />
<span className="text-sm text-gray-500 dark:text-gray-400">{t("badgeCode")}</span>
<input name="badge" placeholder={t("badgeCodePlaceholder")} />
</label>
<label style={{ display: "grid", gap: "0.25rem" }}>
<span className="text-sm text-gray-500 dark:text-gray-400">Job description</span>
<input name="jobDescription" placeholder="Short description" />
<span className="text-sm text-gray-500 dark:text-gray-400">{t("jobDescription")}</span>
<input name="jobDescription" placeholder={t("jobDescriptionPlaceholder")} />
</label>
<label style={{ display: "grid", gap: "0.25rem" }}>
<span className="text-sm text-gray-500 dark:text-gray-400">Staff color</span>
<input name="staffColor" defaultValue="#327fa8" placeholder="#327fa8" />
<span className="text-sm text-gray-500 dark:text-gray-400">{t("staffColor")}</span>
<input name="staffColor" defaultValue="#327fa8" placeholder={t("staffColorPlaceholder")} />
</label>
</div>
<label style={{ display: "flex", alignItems: "center", gap: "0.4rem" }}>
<input type="checkbox" name="hiddenRank" style={{ width: "auto" }} />
<span className="text-sm text-gray-500 dark:text-gray-400">Hidden rank</span>
<span className="text-sm text-gray-500 dark:text-gray-400">{t("hiddenRank")}</span>
</label>
<div>
<button type="submit" className="btn btn-primary">
Create team
{t("createTeam")}
</button>
</div>
</form>
</div>
<section className="mt-6">
<h2 className="admin-section-title">Ranks ({teams.length})</h2>
<h2 className="admin-section-title">{t("ranksTitle", { count: teams.length })}</h2>
{teams.length === 0 ? (
<div className="admin-empty">No teams yet.</div>
<div className="admin-empty">{t("noTeams")}</div>
) : (
<div className="overflow-x-auto">
<table>
<thead>
<tr>
<th>ID</th>
<th>Rank name</th>
<th>Badge</th>
<th>Job description</th>
<th>Color</th>
<th>Visibility</th>
<th>{t("colId")}</th>
<th>{t("rankName")}</th>
<th>{t("badge")}</th>
<th>{t("jobDescription")}</th>
<th>{t("color")}</th>
<th>{t("visibility")}</th>
<th />
</tr>
</thead>
<tbody>
{teams.map((t) => (
<tr key={String(t.id)}>
<td className="text-sm text-gray-500 dark:text-gray-400 whitespace-nowrap">{String(t.id)}</td>
<td>{t.rankName}</td>
<td className="text-sm text-gray-500 dark:text-gray-400 whitespace-nowrap">{t.badge ?? "—"}</td>
<td className="text-sm text-gray-500 dark:text-gray-400 whitespace-nowrap">{t.jobDescription ?? "—"}</td>
{teams.map((team) => (
<tr key={String(team.id)}>
<td className="text-sm text-gray-500 dark:text-gray-400 whitespace-nowrap">{String(team.id)}</td>
<td>{team.rankName}</td>
<td className="text-sm text-gray-500 dark:text-gray-400 whitespace-nowrap">{team.badge ?? "—"}</td>
<td className="text-sm text-gray-500 dark:text-gray-400 whitespace-nowrap">{team.jobDescription ?? "—"}</td>
<td>
<span
style={{
@@ -130,22 +132,22 @@ export default async function AdminTeams() {
height: 14,
borderRadius: 4,
border: "1px solid var(--border-color)",
background: t.staffColor,
background: team.staffColor,
}}
/>
{t.staffColor}
{team.staffColor}
</span>
</td>
<td>
<span className={`inline-block text-[0.72rem] font-bold px-2 py-0.5 rounded-full ${t.hiddenRank ? "bg-[var(--color-primary)]/18 text-[var(--color-text)]" : "bg-green-600/20 text-[var(--color-text)]"}`}>
{t.hiddenRank ? "hidden" : "visible"}
<span className={`inline-block text-[0.72rem] font-bold px-2 py-0.5 rounded-full ${team.hiddenRank ? "bg-[var(--color-primary)]/18 text-[var(--color-text)]" : "bg-green-600/20 text-[var(--color-text)]"}`}>
{team.hiddenRank ? t("hidden") : t("visible")}
</span>
</td>
<td>
<form action={deleteTeam}>
<input type="hidden" name="id" value={String(t.id)} />
<input type="hidden" name="id" value={String(team.id)} />
<button type="submit" className="btn btn-danger">
Delete
{t("delete")}
</button>
</form>
</td>
+17 -17
View File
@@ -1,3 +1,4 @@
import { getTranslations } from "next-intl/server";
import { applyPreset, saveTheme } from "@/actions/admin-theme";
import { siteSettings } from "@/lib/services/site-settings";
import { FONTS, PRESETS } from "@/lib/theme-presets";
@@ -35,7 +36,6 @@ const HEADINGS: { key: string; label: string; def: string }[] = [
const ALL_COLORS = [...COLOR_FIELDS, ...BUTTON_FIELDS];
/** A <input type=color> needs #rrggbb — fall back to the default otherwise. */
function toHex(value: string | null, def: string): string {
return value && /^#[0-9a-fA-F]{6}$/.test(value) ? value : def;
}
@@ -72,6 +72,8 @@ export default async function AdminTheme({
}: {
searchParams: Promise<{ saved?: string; preset?: string }>;
}) {
const t = await getTranslations("pages.admin.theme");
const sp = await searchParams;
const current = Object.fromEntries(
@@ -95,27 +97,26 @@ export default async function AdminTheme({
<span className="text-lg">🎨</span>
</div>
<div>
<h1 className="m-0 text-xl font-extrabold text-[var(--color-text)]">Theme</h1>
<h1 className="m-0 text-xl font-extrabold text-[var(--color-text)]">{t("title")}</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).
{t.rich("subtitle", { code: (chunks) => <code>{chunks}</code> })}
</p>
</div>
</div>
{sp.saved ? (
<div className="mb-4 p-3 rounded-lg bg-green-600/10 text-green-600 dark:text-green-400 text-xs">
Theme saved
{t("saved")}
</div>
) : null}
{sp.preset ? (
<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}”
{t("presetApplied", { preset: sp.preset })}
</div>
) : null}
<section className="mt-6">
<h2 className="admin-section-title">Presets</h2>
<h2 className="admin-section-title">{t("presetsTitle")}</h2>
<div className="flex flex-wrap gap-2">
{Object.entries(PRESETS).map(([name, palette]) => (
<form key={name} action={applyPreset}>
@@ -148,25 +149,25 @@ export default async function AdminTheme({
<form action={saveTheme}>
<section className="mt-6">
<h2 className="admin-section-title">Colours</h2>
<h2 className="admin-section-title">{t("colours")}</h2>
<div className="admin-card">
{colorGrid(COLOR_FIELDS, current)}
</div>
</section>
<section className="mt-6">
<h2 className="admin-section-title">Buttons & links</h2>
<h2 className="admin-section-title">{t("buttonsLinks")}</h2>
<div className="admin-card">
{colorGrid(BUTTON_FIELDS, current)}
</div>
</section>
<section className="mt-6">
<h2 className="admin-section-title">Typography & layout</h2>
<h2 className="admin-section-title">{t("typographyLayout")}</h2>
<div className="admin-card">
<div className="flex flex-wrap gap-5 items-center">
<label className="flex items-center gap-2">
<span className="text-sm">Font</span>
<span className="text-sm">{t("font")}</span>
<select name="font_family" defaultValue={fontKey}>
{Object.entries(FONTS).map(([key, f]) => (
<option key={key} value={key}>
@@ -189,7 +190,7 @@ export default async function AdminTheme({
</label>
))}
<label className="flex items-center gap-2">
<span className="text-sm">Border radius (px)</span>
<span className="text-sm">{t("borderRadius")}</span>
<input
name="border_radius"
type="number"
@@ -204,22 +205,21 @@ export default async function AdminTheme({
</section>
<section className="mt-6">
<h2 className="admin-section-title">Custom CSS</h2>
<h2 className="admin-section-title">{t("cssTitle")}</h2>
<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
above don’t cover.
{t("cssSubtitle")}
</p>
<textarea
name="custom_css"
defaultValue={customCss}
rows={8}
spellCheck={false}
placeholder={"/* e.g. */\n.nav-item { letter-spacing: 0.5px; }"}
placeholder={t("cssPlaceholder")}
className="w-full font-mono text-sm"
/>
<div className="mt-4 text-right">
<button type="submit" className="btn btn-primary">
Save theme
{t("saveCss")}
</button>
</div>
</section>
+32 -36
View File
@@ -1,5 +1,6 @@
import { ArrowLeftRight } from "lucide-react";
import Link from "next/link";
import { getTranslations } from "next-intl/server";
import { StatusCard } from "@/components/admin/dashboard";
import { requireStaff } from "@/lib/admin/guard";
import { prisma } from "@/lib/prisma";
@@ -8,13 +9,6 @@ export const dynamic = "force-dynamic";
const PER_PAGE = 25;
// Read-only listing of PayPal / top-up transactions recorded by the CMS
// (website_paypal_transactions). No mutations — this page never writes.
//
// website_* ids are BigInt; users.id (the payer) is the emulator-owned Int.
// The model has no Prisma relation to User, so payer usernames are resolved in
// one batched lookup keyed by userId.
type Txn = Awaited<ReturnType<typeof prisma.websitePaypalTransactions.findMany>>[number];
const PAID_STATUS = new Set(["completed", "paid", "success", "approved"]);
@@ -51,6 +45,8 @@ export default async function AdminTransactions({
}) {
await requireStaff();
const t = await getTranslations("pages.admin.transactions");
const sp = await searchParams;
const page = Math.max(1, Number(sp.page ?? "1") || 1);
@@ -94,21 +90,21 @@ export default async function AdminTransactions({
<ArrowLeftRight size={20} className="text-[var(--color-primary)]" />
</div>
<div>
<h1 className="m-0 text-xl font-extrabold text-[var(--color-text)]">Transactions</h1>
<p className="m-0 text-xs text-[var(--color-text-muted)] mt-0.5">Read-only · PayPal / top-up transactions recorded by the CMS, newest first.</p>
<h1 className="m-0 text-xl font-extrabold text-[var(--color-text)]">{t("title")}</h1>
<p className="m-0 text-xs text-[var(--color-text-muted)] mt-0.5">{t("subtitle")}</p>
</div>
</div>
<div className="grid grid-cols-[repeat(auto-fit,minmax(180px,1fr))] gap-3.5 mb-6">
<StatusCard label="Total transactions" value={total} icon="🧾" />
<StatusCard label={t("totalTransactions")} value={total} icon="🧾" />
<StatusCard
label="Paid (this page)"
label={t("pagePaid")}
value={pagePaid}
state={pagePaid > 0 ? "ok" : "neutral"}
icon="💳"
/>
<StatusCard
label="Failed (this page)"
label={t("pageFailed")}
value={pageFailed}
state={pageFailed > 0 ? "danger" : "neutral"}
icon="⚠️"
@@ -116,49 +112,49 @@ export default async function AdminTransactions({
</div>
<section className="mt-6">
<h2 className="admin-section-title">Transactions ({total})</h2>
<h2 className="admin-section-title">{t("title")} ({total})</h2>
{transactions.length === 0 ? (
<div className="admin-empty">No transactions.</div>
<div className="admin-empty">{t("noTransactions")}</div>
) : (
<div className="overflow-x-auto">
<table>
<thead>
<tr>
<th>ID</th>
<th>Payer</th>
<th>Amount</th>
<th>Package</th>
<th>Status</th>
<th>Transaction</th>
<th>Date</th>
<th>{t("colId")}</th>
<th>{t("colPayer")}</th>
<th>{t("colAmount")}</th>
<th>{t("colPackage")}</th>
<th>{t("colStatus")}</th>
<th>{t("colTransaction")}</th>
<th>{t("colDate")}</th>
</tr>
</thead>
<tbody>
{transactions.map((t) => {
const state = statusState(t.status);
{transactions.map((x) => {
const state = statusState(x.status);
return (
<tr key={String(t.id)}>
<td className="text-sm text-gray-500 dark:text-gray-400 whitespace-nowrap">{String(t.id)}</td>
<tr key={String(x.id)}>
<td className="text-sm text-gray-500 dark:text-gray-400 whitespace-nowrap">{String(x.id)}</td>
<td>
<Link href={`/admin/users/${t.userId}`}>
{names.get(t.userId) ?? `#${t.userId}`}
<Link href={`/admin/users/${x.userId}`}>
{names.get(x.userId) ?? `#${x.userId}`}
</Link>
</td>
<td>{formatAmount(t.amount, t.currency)}</td>
<td>{t.description ?? "—"}</td>
<td>{formatAmount(x.amount, x.currency)}</td>
<td>{x.description ?? "—"}</td>
<td>
{t.status ? (
{x.status ? (
<span
className={`inline-block text-[0.72rem] font-bold px-2 py-0.5 rounded-full ${state === "ok" ? "bg-green-600/20 text-[var(--color-text)]" : state === "danger" ? "bg-red-500/22 text-[var(--color-text)]" : "bg-[var(--color-primary)]/18 text-[var(--color-text)]"}`}
>
{t.status}
{x.status}
</span>
) : (
<span className="text-sm text-gray-500 dark:text-gray-400">—</span>
)}
</td>
<td className="text-sm text-gray-500 dark:text-gray-400 whitespace-nowrap">{t.transactionId}</td>
<td className="text-sm text-gray-500 dark:text-gray-400 whitespace-nowrap">{fromDate(t.createdAt)}</td>
<td className="text-sm text-gray-500 dark:text-gray-400 whitespace-nowrap">{x.transactionId}</td>
<td className="text-sm text-gray-500 dark:text-gray-400 whitespace-nowrap">{fromDate(x.createdAt)}</td>
</tr>
);
})}
@@ -169,17 +165,17 @@ export default async function AdminTransactions({
</section>
<p className="text-xs text-gray-500 dark:text-gray-400">
Page {page} / {pages} · {total} transactions
{t("pagination", { page, pages, total })}
{page > 1 ? (
<>
{" · "}
<Link href={`/admin/transactions?page=${page - 1}`}>← Prev</Link>
<Link href={`/admin/transactions?page=${page - 1}`}>← {t("prev")}</Link>
</>
) : null}
{page < pages ? (
<>
{" · "}
<Link href={`/admin/transactions?page=${page + 1}`}>Next →</Link>
<Link href={`/admin/transactions?page=${page + 1}`}>{t("next")} →</Link>
</>
) : null}
</p>
+19 -17
View File
@@ -4,6 +4,7 @@ import { updateUser } from "@/actions/admin-user-edit";
import { requireStaff } from "@/lib/admin/guard";
import { User } from "lucide-react";
import { prisma } from "@/lib/prisma";
import { getTranslations } from "next-intl/server";
export const dynamic = "force-dynamic";
@@ -20,6 +21,7 @@ export default async function AdminUserEdit({
await requireStaff();
const { id } = await params;
const t = await getTranslations("pages.admin.users.editForm");
const userId = Number(id);
if (!Number.isInteger(userId) || userId <= 0) notFound();
@@ -66,10 +68,10 @@ export default async function AdminUserEdit({
return (
<main>
<p className="text-sm text-gray-500 dark:text-gray-400">
<Link href="/admin/users">← Users</Link>
<Link href="/admin/users">← {t("title")}</Link>
</p>
<h1>Edit user</h1>
<p className="text-xs text-gray-500 dark:text-gray-400">Could not load this user right now. Try again shortly.</p>
<h1>{t("title")}</h1>
<p className="text-xs text-gray-500 dark:text-gray-400">{t("loadError")}</p>
</main>
);
}
@@ -86,8 +88,8 @@ export default async function AdminUserEdit({
<User size={20} className="text-[var(--color-primary)]" />
</div>
<div>
<h1 className="m-0 text-xl font-extrabold text-[var(--color-text)]">Edit {user.username}</h1>
<p className="m-0 text-xs text-[var(--color-text-muted)] mt-0.5">ID {user.id}</p>
<h1 className="m-0 text-xl font-extrabold text-[var(--color-text)]">{t("title", { username: user.username })}</h1>
<p className="m-0 text-xs text-[var(--color-text-muted)] mt-0.5">{t("idLabel", { id: user.id })}</p>
</div>
</div>
@@ -96,56 +98,56 @@ export default async function AdminUserEdit({
<div className="grid cols-2">
<label>
Email
{t("email")}
<input name="mail" type="email" defaultValue={user.mail ?? ""} maxLength={500} />
</label>
<label>
Rank
{t("rank")}
<input name="rank" type="number" min={1} defaultValue={user.rank} />
</label>
<label>
Motto
{t("motto")}
<input name="motto" defaultValue={user.motto} maxLength={127} />
</label>
<label>
Look
{t("look")}
<input name="look" defaultValue={user.look} maxLength={256} />
</label>
<label>
Credits
{t("credits")}
<input name="credits" type="number" min={0} defaultValue={user.credits} />
</label>
<label>
Pixels
{t("pixels")}
<input name="pixels" type="number" min={0} defaultValue={user.pixels} />
</label>
<label>
Points
{t("points")}
<input name="points" type="number" min={0} defaultValue={user.points} />
</label>
<label>
Duckets
{t("duckets")}
<input name="duckets" type="number" min={0} defaultValue={duckets} />
</label>
<label>
Diamonds
{t("diamonds")}
<input name="diamonds" type="number" min={0} defaultValue={diamonds} />
</label>
</div>
<p className="text-xs text-gray-500 dark:text-gray-400">Currency fields set the exact balance, not a delta. Passwords are not editable here.</p>
<p className="text-xs text-gray-500 dark:text-gray-400">{t("hint")}</p>
<div className="flex gap-2 mt-2">
<button type="submit" className="btn btn-primary">Save changes</button>
<Link href={`/admin/users/${user.id}`} className="btn">Cancel</Link>
<button type="submit" className="btn btn-primary">{t("save")}</button>
<Link href={`/admin/users/${user.id}`} className="btn">{t("cancel")}</Link>
</div>
</form>
</main>
+33 -31
View File
@@ -10,6 +10,7 @@ import {
import { InfoItem, StatusCard } from "@/components/admin/dashboard";
import { User } from "lucide-react";
import { prisma } from "@/lib/prisma";
import { getTranslations } from "next-intl/server";
export const dynamic = "force-dynamic";
@@ -19,6 +20,7 @@ export default async function AdminUserDetail({
params: Promise<{ id: string }>;
}) {
const { id } = await params;
const t = await getTranslations("pages.admin.users");
const userId = Number(id);
const user = await prisma.user
.findUnique({
@@ -33,7 +35,7 @@ export default async function AdminUserDetail({
return (
<main>
<p className="text-sm text-gray-500 dark:text-gray-400 mb-2">
<Link href="/admin/users">← Users</Link>
<Link href="/admin/users">← {t("title")}</Link>
</p>
<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">
@@ -41,91 +43,91 @@ export default async function AdminUserDetail({
</div>
<div>
<h1 className="m-0 text-xl font-extrabold text-[var(--color-text)]">{user.username}</h1>
<p className="m-0 text-xs text-[var(--color-text-muted)] mt-0.5">{user.motto ? user.motto : "No motto set."}</p>
<p className="m-0 text-xs text-[var(--color-text-muted)] mt-0.5">{user.motto ? user.motto : t("detail.noMotto")}</p>
</div>
</div>
<div className="grid grid-cols-[repeat(auto-fit,minmax(180px,1fr))] gap-3.5 mb-6">
<StatusCard label="User ID" value={user.id} icon="🆔" />
<StatusCard label="Rank" value={user.rank} icon="🎖️" />
<StatusCard label="Credits" value={user.credits} icon="💰" />
<StatusCard label={t("detail.userId")} value={user.id} icon="🆔" />
<StatusCard label={t("detail.rank")} value={user.rank} icon="🎖️" />
<StatusCard label={t("detail.credits")} value={user.credits} icon="💰" />
<StatusCard
label="Status"
value={isOnline ? "Online" : "Offline"}
label={t("detail.online")}
value={isOnline ? t("detail.online") : t("offline")}
state={isOnline ? "ok" : "neutral"}
icon={isOnline ? "🟢" : "⚪"}
/>
</div>
<section className="mt-6">
<h2 className="admin-section-title">Overview</h2>
<h2 className="admin-section-title">{t("detail.overview")}</h2>
<div className="grid grid-cols-[repeat(auto-fit,minmax(150px,1fr))] gap-2.5">
<InfoItem label="Username" value={user.username} />
<InfoItem label={t("detail.username")} value={user.username} />
<InfoItem
label="Status"
label={t("detail.online")}
value={
<span className={`inline-block text-[0.72rem] font-bold px-2 py-0.5 rounded-full ${isOnline ? "bg-green-600/20 text-[var(--color-text)]" : "bg-[var(--color-primary)]/18 text-[var(--color-text)]"}`}>
{isOnline ? "online" : "offline"}
{isOnline ? t("detail.online") : t("offline")}
</span>
}
/>
<InfoItem label="Rank" value={user.rank} />
<InfoItem label="Credits" value={user.credits} />
<InfoItem label="Look" value={<code>{user.look || "—"}</code>} />
<InfoItem label={t("detail.rank")} value={user.rank} />
<InfoItem label={t("detail.credits")} value={user.credits} />
<InfoItem label={t("detail.look")} value={<code>{user.look || "—"}</code>} />
</div>
</section>
<section className="mt-6">
<h2 className="admin-section-title">Actions</h2>
<h2 className="admin-section-title">{t("detail.actions")}</h2>
<div className="grid cols-2">
<form action={giveCurrency} className="admin-card">
<input type="hidden" name="userId" value={user.id} />
<h3 className="text-sm font-bold text-[var(--color-text)] m-0 mb-3">Give currency</h3>
<h3 className="text-sm font-bold text-[var(--color-text)] m-0 mb-3">{t("detail.giveCurrency")}</h3>
<select name="type">
<option value="credits">Credits</option>
<option value="duckets">Duckets</option>
<option value="diamonds">Diamonds</option>
<option value="points">Points</option>
<option value="credits">{t("detail.credits")}</option>
<option value="duckets">{t("detail.duckets")}</option>
<option value="diamonds">{t("detail.diamonds")}</option>
<option value="points">{t("detail.points")}</option>
</select>{" "}
<input name="amount" type="number" min={1} placeholder="Amount" />{" "}
<input name="amount" type="number" min={1} placeholder={t("detail.amountPlaceholder")} />{" "}
<button type="submit" className="btn btn-primary">
Give
{t("detail.give")}
</button>
</form>
<form action={setMotto} className="admin-card">
<input type="hidden" name="userId" value={user.id} />
<h3 className="text-sm font-bold text-[var(--color-text)] m-0 mb-3">Set motto</h3>
<h3 className="text-sm font-bold text-[var(--color-text)] m-0 mb-3">{t("detail.setMotto")}</h3>
<input name="motto" defaultValue={user.motto} maxLength={127} />{" "}
<button type="submit" className="btn btn-primary">
Save
{t("detail.save")}
</button>
</form>
<form action={setRank} className="admin-card">
<input type="hidden" name="userId" value={user.id} />
<h3 className="text-sm font-bold text-[var(--color-text)] m-0 mb-3">Set rank</h3>
<h3 className="text-sm font-bold text-[var(--color-text)] m-0 mb-3">{t("detail.setRank")}</h3>
<input name="rank" type="number" min={1} defaultValue={user.rank} />{" "}
<button type="submit" className="btn btn-primary">
Set
{t("detail.set")}
</button>
</form>
<form action={alertUser} className="admin-card">
<input type="hidden" name="userId" value={user.id} />
<h3 className="text-sm font-bold text-[var(--color-text)] m-0 mb-3">Alert</h3>
<input name="message" placeholder="Message" />{" "}
<h3 className="text-sm font-bold text-[var(--color-text)] m-0 mb-3">{t("detail.sendAlert")}</h3>
<input name="message" placeholder={t("detail.messagePlaceholder")} />{" "}
<button type="submit" className="btn btn-primary">
Send
{t("detail.send")}
</button>
</form>
<form action={disconnectUser} className="admin-card">
<input type="hidden" name="userId" value={user.id} />
<input type="hidden" name="username" value={user.username} />
<h3 className="text-sm font-bold text-[var(--color-text)] m-0 mb-3">Disconnect</h3>
<h3 className="text-sm font-bold text-[var(--color-text)] m-0 mb-3">{t("detail.disconnectUser")}</h3>
<button type="submit" className="btn btn-danger">
Disconnect from hotel
{t("detail.disconnectFromHotel")}
</button>
</form>
</div>
+24 -22
View File
@@ -2,6 +2,7 @@ import Link from "next/link";
import { StatusCard } from "@/components/admin/dashboard";
import { ConfirmAction } from "@/components/admin/confirm-action";
import { prisma } from "@/lib/prisma";
import { getTranslations } from "next-intl/server";
export const dynamic = "force-dynamic";
@@ -13,6 +14,7 @@ export default async function AdminUsers({
searchParams: Promise<{ q?: string; page?: string; form?: string }>;
}) {
const sp = await searchParams;
const t = await getTranslations("pages.admin.users");
const q = (sp.q ?? "").trim();
const page = Math.max(1, Number(sp.page ?? "1") || 1);
const where = q ? { username: { contains: q } } : {};
@@ -33,16 +35,16 @@ export default async function AdminUsers({
return (
<main>
<h1>Users</h1>
<h1>{t("title")}</h1>
<p className="muted" style={{ marginTop: "-0.4rem" }}>
{q ? `Search results for “${q}”.` : "Most recent accounts first."}
{q ? t("searchResults", { count: total, query: q }) : t("subtitle")}
</p>
<div className="grid grid-cols-[repeat(auto-fit,minmax(180px,1fr))] gap-3.5 mb-6">
<StatusCard label="Total users" value={total} icon="👥" />
<StatusCard label="On this page" value={users.length} icon="📄" />
<StatusCard label={t("title")} value={total} icon="👥" />
<StatusCard label={t("onThisPage")} value={users.length} icon="📄" />
<StatusCard
label="Online (page)"
label={t("colOnline")}
value={onlineOnPage}
state={onlineOnPage > 0 ? "ok" : "neutral"}
icon="🟢"
@@ -56,29 +58,29 @@ export default async function AdminUsers({
<input
name="q"
defaultValue={q}
placeholder="Search username"
placeholder={t("searchPlaceholder")}
style={{ flex: 1, minWidth: 200 }}
/>
<button type="submit" className="btn btn-primary">
Search
{t("search")}
</button>
</form>
<section className="mt-6">
<h2>Accounts ({total})</h2>
<h2>{t("title")} ({total})</h2>
{users.length === 0 ? (
<p className="muted">No users match this search.</p>
<p className="muted">{t("noResults")}</p>
) : (
<div style={{ overflowX: "auto" }}>
<table className="w-full">
<thead>
<tr>
<th>ID</th>
<th>Username</th>
<th>Rank</th>
<th>Credits</th>
<th>Status</th>
<th style={{ width: "200px" }}>Actions</th>
<th>{t("colId")}</th>
<th>{t("colUsername")}</th>
<th>{t("colRank")}</th>
<th>{t("detail.credits")}</th>
<th>{t("colOnline")}</th>
<th style={{ width: "200px" }}>{t("colActions")}</th>
</tr>
</thead>
<tbody>
@@ -92,15 +94,15 @@ export default async function AdminUsers({
<td>{u.credits}</td>
<td>
<span className={`inline-block text-[0.72rem] font-bold px-2 py-0.5 rounded-full ${u.online === "1" ? "bg-green-600/20 text-[var(--color-text)]" : "bg-[var(--color-primary)]/18 text-[var(--color-text)]"}`}>
{u.online === "1" ? "online" : "offline"}
{u.online === "1" ? t("colOnline") : t("offline")}
</span>
</td>
<td>
<div style={{ display: "flex", gap: "0.25rem", flexWrap: "wrap", justifyContent: "flex-start" }}>
<Link href={`/admin/users/${u.id}/edit`} className="btn btn-outline" style={{ fontSize: "0.75rem", padding: "0.25rem 0.5rem" }}>Edit</Link>
<ConfirmAction message={`Set rank for ${u.username}?`} action="set_rank" userId={u.id} className="btn btn-outline" style={{ fontSize: "0.75rem", padding: "0.25rem 0.5rem" }}>Set Rank</ConfirmAction>
<ConfirmAction message={`Disconnect ${u.username}?`} action="disconnect" userId={u.id} className="btn btn-danger" style={{ fontSize: "0.75rem", padding: "0.25rem 0.5rem" }}>Disconnect</ConfirmAction>
<ConfirmAction message={`Send alert to ${u.username}?`} action="alert" userId={u.id} className="btn btn-primary" style={{ fontSize: "0.75rem", padding: "0.25rem 0.5rem" }}>Alert</ConfirmAction>
<Link href={`/admin/users/${u.id}/edit`} className="btn btn-outline" style={{ fontSize: "0.75rem", padding: "0.25rem 0.5rem" }}>{t("edit")}</Link>
<ConfirmAction message={t("confirmSetRank", { username: u.username })} action="set_rank" userId={u.id} className="btn btn-outline" style={{ fontSize: "0.75rem", padding: "0.25rem 0.5rem" }}>{t("setRank")}</ConfirmAction>
<ConfirmAction message={t("confirmDisconnect", { username: u.username })} action="disconnect" userId={u.id} className="btn btn-danger" style={{ fontSize: "0.75rem", padding: "0.25rem 0.5rem" }}>{t("disconnect")}</ConfirmAction>
<ConfirmAction message={t("confirmAlert", { username: u.username })} action="alert" userId={u.id} className="btn btn-primary" style={{ fontSize: "0.75rem", padding: "0.25rem 0.5rem" }}>{t("alert")}</ConfirmAction>
</div>
</td>
</tr>
@@ -112,11 +114,11 @@ export default async function AdminUsers({
</section>
<p className="muted">
Page {page} / {pages} · {total} users
{t("pagination", { page, pages, total })}
{page < pages ? (
<>
{" · "}
<Link href={`/admin/users?q=${encodeURIComponent(q)}&page=${page + 1}`}>Next →</Link>
<Link href={`/admin/users?q=${encodeURIComponent(q)}&page=${page + 1}`}>{t("next")} →</Link>
</>
) : null}
</p>
+28 -26
View File
@@ -1,4 +1,5 @@
import { Gift } from "lucide-react";
import { getTranslations } from "next-intl/server";
import { createVoucher, deleteVoucher } from "@/actions/admin-vouchers";
import { StatusCard } from "@/components/admin/dashboard";
import { prisma } from "@/lib/prisma";
@@ -21,6 +22,7 @@ function fmtDate(d: Date | null): string {
}
export default async function AdminVouchers() {
const t = await getTranslations("pages.admin.vouchers");
const now = Date.now();
let vouchers: VoucherRow[] = [];
@@ -61,27 +63,27 @@ export default async function AdminVouchers() {
<Gift size={20} className="text-[var(--color-primary)]" />
</div>
<div>
<h1 className="m-0 text-xl font-extrabold text-[var(--color-text)]">Shop Vouchers</h1>
<p className="m-0 text-xs text-[var(--color-text-muted)] mt-0.5">Codes redeemable on the website shop for a balance top-up. Each user can use a given voucher once, up to its max uses.</p>
<h1 className="m-0 text-xl font-extrabold text-[var(--color-text)]">{t("title")}</h1>
<p className="m-0 text-xs text-[var(--color-text-muted)] mt-0.5">{t("subtitle")}</p>
</div>
</div>
<div className="grid grid-cols-[repeat(auto-fit,minmax(180px,1fr))] gap-3.5 mb-6">
<StatusCard label="Vouchers" value={vouchers.length} icon="🎟️" />
<StatusCard label={t("vouchers")} value={vouchers.length} icon="🎟️" />
<StatusCard
label="Active"
label={t("active")}
value={active}
state={active > 0 ? "ok" : "neutral"}
icon="✅"
/>
<StatusCard
label="Used up"
label={t("usedUp")}
value={usedUpCount}
state={usedUpCount > 0 ? "warn" : "neutral"}
icon="📉"
/>
<StatusCard
label="Expired"
label={t("expired")}
value={expiredCount}
state={expiredCount > 0 ? "danger" : "neutral"}
icon="⏰"
@@ -89,40 +91,40 @@ export default async function AdminVouchers() {
</div>
<form action={createVoucher} className="admin-card mb-6">
<h3 style={{ marginTop: 0 }}>New voucher</h3>
<h3 style={{ marginTop: 0 }}>{t("newVoucher")}</h3>
<div className="grid cols-2" style={{ marginBottom: "0.75rem" }}>
<label style={{ display: "flex", flexDirection: "column", gap: "0.3rem" }}>
<span className="text-sm text-gray-500 dark:text-gray-400">Code</span>
<input name="code" placeholder="SUMMER2026" maxLength={255} required />
<span className="text-sm text-gray-500 dark:text-gray-400">{t("form.code")}</span>
<input name="code" placeholder={t("form.codePlaceholder")} maxLength={255} required />
</label>
<label style={{ display: "flex", flexDirection: "column", gap: "0.3rem" }}>
<span className="text-sm text-gray-500 dark:text-gray-400">Amount (balance added)</span>
<input name="amount" type="number" min={1} placeholder="50" required />
<span className="text-sm text-gray-500 dark:text-gray-400">{t("form.amount")}</span>
<input name="amount" type="number" min={1} placeholder={t("form.amountPlaceholder")} required />
</label>
<label style={{ display: "flex", flexDirection: "column", gap: "0.3rem" }}>
<span className="text-sm text-gray-500 dark:text-gray-400">Max uses</span>
<span className="text-sm text-gray-500 dark:text-gray-400">{t("form.maxUses")}</span>
<input name="maxUses" type="number" min={1} defaultValue={1} required />
</label>
<label style={{ display: "flex", flexDirection: "column", gap: "0.3rem" }}>
<span className="text-sm text-gray-500 dark:text-gray-400">Expires at (optional)</span>
<span className="text-sm text-gray-500 dark:text-gray-400">{t("form.expiresAt")}</span>
<input name="expiresAt" type="datetime-local" />
</label>
</div>
<button type="submit" className="btn btn-primary">Create voucher</button>
<button type="submit" className="btn btn-primary">{t("form.create")}</button>
</form>
<section className="mt-6">
<h2 className="admin-section-title">Vouchers ({vouchers.length})</h2>
<h2 className="admin-section-title">{t("title")} ({vouchers.length})</h2>
<div className="admin-card">
<table>
<thead>
<tr>
<th>Code</th>
<th>Amount</th>
<th>Uses</th>
<th>Status</th>
<th>Expires</th>
<th>Created</th>
<th>{t("colCode")}</th>
<th>{t("colAmount")}</th>
<th>{t("colUses")}</th>
<th>{t("colStatus")}</th>
<th>{t("colExpires")}</th>
<th>{t("colCreated")}</th>
<th />
</tr>
</thead>
@@ -141,11 +143,11 @@ export default async function AdminVouchers() {
</td>
<td>
{expired ? (
<span className="inline-block text-[0.72rem] font-bold px-2 py-0.5 rounded-full bg-red-500/22 text-[var(--color-text)]">Expired</span>
<span className="inline-block text-[0.72rem] font-bold px-2 py-0.5 rounded-full bg-red-500/22 text-[var(--color-text)]">{t("statusExpired")}</span>
) : usedUp ? (
<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)]">Used up</span>
<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)]">{t("statusUsedUp")}</span>
) : (
<span className="inline-block text-[0.72rem] font-bold px-2 py-0.5 rounded-full bg-green-600/20 text-[var(--color-text)]">Active</span>
<span className="inline-block text-[0.72rem] font-bold px-2 py-0.5 rounded-full bg-green-600/20 text-[var(--color-text)]">{t("statusActive")}</span>
)}
</td>
<td className="text-sm text-gray-500 dark:text-gray-400 whitespace-nowrap">{fmtDate(v.expiresAt)}</td>
@@ -154,7 +156,7 @@ export default async function AdminVouchers() {
<form action={deleteVoucher}>
<input type="hidden" name="id" value={v.id.toString()} />
<button type="submit" className="btn btn-danger">
Delete
{t("delete")}
</button>
</form>
</td>
@@ -163,7 +165,7 @@ export default async function AdminVouchers() {
})}
</tbody>
</table>
{vouchers.length === 0 ? <div className="admin-empty">No vouchers yet.</div> : null}
{vouchers.length === 0 ? <div className="admin-empty">{t("noVouchers")}</div> : null}
</div>
</section>
</main>
+22 -22
View File
@@ -3,6 +3,7 @@ import Link from "next/link";
import { saveVpn } from "@/actions/admin-vpn";
import { StatusCard } from "@/components/admin/dashboard";
import { siteSettings } from "@/lib/services/site-settings";
import { getTranslations } from "next-intl/server";
export const dynamic = "force-dynamic";
export const metadata = { title: "VPN" };
@@ -19,6 +20,7 @@ export default async function AdminVpn({
searchParams: Promise<{ saved?: string }>;
}) {
const sp = await searchParams;
const t = await getTranslations("pages.admin.vpn");
// Read current values (graceful fallbacks via siteSettings / DEFAULTS).
const enabled = await siteSettings.getBool("vpn_block_enabled", false);
@@ -37,8 +39,8 @@ export default async function AdminVpn({
<ShieldBan size={20} className="text-[var(--color-primary)]" />
</div>
<div>
<h1 className="m-0 text-xl font-extrabold text-[var(--color-text)]">VPN / Proxy Detection</h1>
<p className="m-0 text-xs text-[var(--color-text-muted)] mt-0.5">Block registrations from detected VPN/proxy IPs at sign-up. Settings are stored in <code>website_settings</code> (booleans use <code>0</code> / <code>1</code>). This is distinct from the raw IP allow/deny list — manage that under <Link href="/admin/ip">IP Management</Link>.</p>
<h1 className="m-0 text-xl font-extrabold text-[var(--color-text)]">{t("title")}</h1>
<p className="m-0 text-xs text-[var(--color-text-muted)] mt-0.5">{t.rich("subtitle", { ipLink: (chunks) => <Link href="/admin/ip">{chunks}</Link> })}</p>
</div>
</div>
@@ -46,23 +48,23 @@ export default async function AdminVpn({
{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)]">
VPN settings saved
{t("settingsSaved")}
</p>
) : null}
<div className="grid grid-cols-[repeat(auto-fit,minmax(180px,1fr))] gap-3.5 mb-6">
<StatusCard
label="Protection"
value={enabled ? "On" : "Off"}
label={t("protection")}
value={enabled ? t("on") : t("off")}
state={enabled ? "ok" : "danger"}
hint={enabled ? `Provider: ${provider}` : "Registrations not screened"}
hint={enabled ? t("providerLabel", { provider }) : t("notScreened")}
icon="🛡️"
/>
</div>
</section>
<section className="mt-6">
<h2 className="admin-section-title">Settings</h2>
<h2 className="admin-section-title">{t("settings")}</h2>
<form action={saveVpn} className="admin-card">
<div style={{ marginBottom: "1rem" }}>
<label style={{ display: "flex", alignItems: "center", gap: "0.6rem" }}>
@@ -73,7 +75,7 @@ export default async function AdminVpn({
defaultChecked={enabled}
/>
<span style={{ fontWeight: 700 }}>
Block registrations from detected VPN/proxy IPs
{t("blockOnDetect")}
</span>
</label>
</div>
@@ -84,7 +86,7 @@ export default async function AdminVpn({
htmlFor="vpn_provider"
style={{ display: "block", fontWeight: 700, fontSize: "0.85rem" }}
>
Provider
{t("provider")}
</label>
<select
id="vpn_provider"
@@ -99,7 +101,7 @@ export default async function AdminVpn({
))}
</select>
<p className="text-xs text-gray-500 dark:text-gray-400" style={{ margin: "0.2rem 0 0" }}>
Detection service used to score incoming IPs.
{t("providerHint")}
</p>
</div>
@@ -108,18 +110,18 @@ export default async function AdminVpn({
htmlFor="vpn_api_key"
style={{ display: "block", fontWeight: 700, fontSize: "0.85rem" }}
>
API key
{t("apiKey")}
</label>
<input
id="vpn_api_key"
name="vpn_api_key"
defaultValue={apiKey}
autoComplete="off"
placeholder="Provider API key"
placeholder={t("apiKeyPlaceholder")}
style={{ width: "100%" }}
/>
<p className="text-xs text-gray-500 dark:text-gray-400" style={{ margin: "0.2rem 0 0" }}>
Required by the selected provider (leave blank for none).
{t("apiKeyHint")}
</p>
</div>
</div>
@@ -129,37 +131,35 @@ export default async function AdminVpn({
htmlFor="vpn_block_message"
style={{ display: "block", fontWeight: 700, fontSize: "0.85rem" }}
>
Block message
{t("blockMessage")}
</label>
<input
id="vpn_block_message"
name="vpn_block_message"
defaultValue={blockMessage}
placeholder="Shown to users blocked for using a VPN/proxy"
placeholder={t("blockMessagePlaceholder")}
style={{ width: "100%" }}
/>
<p className="text-xs text-gray-500 dark:text-gray-400" style={{ margin: "0.2rem 0 0" }}>
Message displayed when a registration is rejected.
{t("blockMessageHint")}
</p>
</div>
<div style={{ marginTop: "1rem", display: "flex", justifyContent: "flex-end" }}>
<button type="submit" className="btn btn-primary">
Save
{t("save")}
</button>
</div>
</form>
</section>
<section className="mt-6">
<h2 className="admin-section-title">Raw IP allow / deny list</h2>
<h2 className="admin-section-title">{t("rawIpTitle")}</h2>
<p className="text-xs text-gray-500 dark:text-gray-400">
VPN detection screens unknown IPs automatically. To explicitly allow
or block specific IP addresses or ASNs (overriding detection), use{" "}
<Link href="/admin/ip">IP Management</Link>.
{t.rich("rawIpDescription", { ipLink: (chunks) => <Link href="/admin/ip">{chunks}</Link> })}
</p>
<Link href="/admin/ip" className="btn btn-secondary">
Open IP Management
{t("openIpManagement")}
</Link>
</section>
</main>
+14 -11
View File
@@ -1,3 +1,4 @@
import { getTranslations } from "next-intl/server";
import { Filter } from "lucide-react";
import { addWord, deleteWord } from "@/actions/admin-wordfilter";
import { StatusCard } from "@/components/admin/dashboard";
@@ -8,6 +9,8 @@ export const dynamic = "force-dynamic";
type WordRow = { id: bigint; word: string };
export default async function AdminWordFilter() {
const t = await getTranslations("pages.admin.wordfilter");
let words: WordRow[] = [];
try {
words = await prisma.websiteWordfilter.findMany({
@@ -26,28 +29,28 @@ export default async function AdminWordFilter() {
<Filter size={20} className="text-[var(--color-primary)]" />
</div>
<div>
<h1 className="m-0 text-xl font-extrabold text-[var(--color-text)]">Word Filter</h1>
<p className="m-0 text-xs text-[var(--color-text-muted)] mt-0.5">Words added here are blocked in-game. Saving pushes the list to the emulator.</p>
<h1 className="m-0 text-xl font-extrabold text-[var(--color-text)]">{t("title")}</h1>
<p className="m-0 text-xs text-[var(--color-text-muted)] mt-0.5">{t("subtitle")}</p>
</div>
</div>
<div className="grid grid-cols-[repeat(auto-fit,minmax(180px,1fr))] gap-3.5 mb-6">
<StatusCard label="Filtered words" value={words.length} icon="🚫" />
<StatusCard label={t("filteredWords")} value={words.length} icon="🚫" />
</div>
<section className="mt-6">
<h2 className="admin-section-title">Add word</h2>
<h2 className="admin-section-title">{t("addWord")}</h2>
<form action={addWord} className="admin-card">
<div className="flex gap-2 flex-wrap">
<input
name="word"
placeholder="Word to filter"
placeholder={t("wordToFilter")}
required
maxLength={255}
className="flex-1 min-w-[200px]"
/>
<button type="submit" className="btn btn-primary">
Add
{t("add")}
</button>
</div>
</form>
@@ -55,17 +58,17 @@ export default async function AdminWordFilter() {
<section className="mt-6">
<h2 className="admin-section-title">
Words <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)]">{words.length}</span>
{t("words")} <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)]">{words.length}</span>
</h2>
{words.length === 0 ? (
<div className="admin-empty">No filtered words.</div>
<div className="admin-empty">{t("noWords")}</div>
) : (
<div className="admin-card" style={{ padding: 0, overflowX: "auto" }}>
<table>
<thead>
<tr>
<th>ID</th>
<th>Word</th>
<th>{t("colId")}</th>
<th>{t("colWord")}</th>
<th />
</tr>
</thead>
@@ -80,7 +83,7 @@ export default async function AdminWordFilter() {
<form action={deleteWord}>
<input type="hidden" name="id" value={String(w.id)} />
<button type="submit" className="btn btn-danger">
Delete
{t("delete")}
</button>
</form>
</td>
+25 -23
View File
@@ -1,3 +1,4 @@
import { getTranslations } from "next-intl/server";
import { Layout } from "lucide-react";
import { createBox, deleteBox, toggleBox, updateBox } from "@/actions/admin-writeable-boxes";
import { StatusCard } from "@/components/admin/dashboard";
@@ -8,6 +9,7 @@ export const dynamic = "force-dynamic";
export const metadata = { title: "Writeable boxes" };
export default async function AdminWriteableBoxes() {
const t = await getTranslations("pages.admin.writeableBoxes");
const boxes = await prisma.websiteWriteableBoxes
.findMany({
orderBy: [{ position: "asc" }, { id: "asc" }],
@@ -24,37 +26,37 @@ export default async function AdminWriteableBoxes() {
<Layout size={20} className="text-[var(--color-primary)]" />
</div>
<div>
<h1 className="m-0 text-xl font-extrabold text-[var(--color-text)]">Writeable Boxes</h1>
<p className="m-0 text-xs text-[var(--color-text-muted)] mt-0.5">Content panels stored in <code>website_writeable_boxes</code> and ordered by <code>position</code>. <strong>Active</strong> boxes are shown on the public home page; hidden ones are kept here as drafts.</p>
<h1 className="m-0 text-xl font-extrabold text-[var(--color-text)]">{t("title")}</h1>
<p className="m-0 text-xs text-[var(--color-text-muted)] mt-0.5">{t("subtitle")}</p>
</div>
</div>
<section className="mt-6">
<div className="grid grid-cols-[repeat(auto-fit,minmax(180px,1fr))] gap-3.5 mb-6">
<StatusCard label="Total boxes" value={boxes.length} icon="🗂️" />
<StatusCard label={t("totalBoxes")} value={boxes.length} icon="🗂️" />
<StatusCard
label="Active"
label={t("active")}
value={activeCount}
state={activeCount > 0 ? "ok" : "neutral"}
hint="shown on the public home page"
hint={t("shownOnHomePage")}
icon="✅"
/>
</div>
</section>
<section className="mt-6">
<h2 className="admin-section-title">New box</h2>
<h2 className="admin-section-title">{t("newBox")}</h2>
<form action={createBox} className="grid cols-2">
<div>
<label htmlFor="new_title" style={{ display: "block", fontWeight: 700 }}>
Title
{t("form.name")}
</label>
<input id="new_title" name="title" required maxLength={255} style={{ width: "100%" }} />
</div>
<div>
<label htmlFor="new_icon" style={{ display: "block", fontWeight: 700 }}>
Icon (emoji)
{t("form.iconEmoji")}
</label>
<input
id="new_icon"
@@ -66,7 +68,7 @@ export default async function AdminWriteableBoxes() {
</div>
<div>
<label htmlFor="new_position" style={{ display: "block", fontWeight: 700 }}>
Position
{t("position")}
</label>
<input
id="new_position"
@@ -79,12 +81,12 @@ export default async function AdminWriteableBoxes() {
</div>
<div style={{ display: "flex", alignItems: "flex-end" }}>
<label style={{ display: "flex", alignItems: "center", gap: "0.4rem", fontWeight: 700 }}>
<input type="checkbox" name="isActive" value="1" defaultChecked /> Active
<input type="checkbox" name="isActive" value="1" defaultChecked /> {t("active")}
</label>
</div>
<div style={{ gridColumn: "1 / -1" }}>
<label htmlFor="new_content" style={{ display: "block", fontWeight: 700 }}>
Content (HTML)
{t("form.content")}
</label>
<textarea
id="new_content"
@@ -95,16 +97,16 @@ export default async function AdminWriteableBoxes() {
</div>
<div style={{ gridColumn: "1 / -1", display: "flex", justifyContent: "flex-end" }}>
<button type="submit" className="btn btn-primary">
+ Create box
{t("form.save")}
</button>
</div>
</form>
</section>
<section className="mt-6">
<h2 className="admin-section-title">Boxes ({boxes.length})</h2>
<h2 className="admin-section-title">{t("boxes")} ({boxes.length})</h2>
{boxes.length === 0 ? (
<div className="admin-empty">No writeable boxes yet.</div>
<div className="admin-empty">{t("noBoxes")}</div>
) : (
<div style={{ display: "flex", flexDirection: "column", gap: "1.25rem" }}>
{boxes.map((box) => (
@@ -121,7 +123,7 @@ export default async function AdminWriteableBoxes() {
{box.icon ? <span aria-hidden>{box.icon}</span> : null}
<strong className="flex-1">{box.title}</strong>
<span className={`inline-block text-[0.72rem] font-bold px-2 py-0.5 rounded-full ${box.isActive ? "bg-green-600/20 text-[var(--color-text)]" : "bg-red-500/22 text-[var(--color-text)]"}`}>
{box.isActive ? "Active" : "Hidden"}
{box.isActive ? t("active") : t("hidden")}
</span>
</div>
@@ -132,7 +134,7 @@ export default async function AdminWriteableBoxes() {
htmlFor={`title_${box.id}`}
style={{ display: "block", fontWeight: 700 }}
>
Title
{t("form.name")}
</label>
<input
id={`title_${box.id}`}
@@ -148,7 +150,7 @@ export default async function AdminWriteableBoxes() {
htmlFor={`icon_${box.id}`}
style={{ display: "block", fontWeight: 700 }}
>
Icon (emoji)
{t("form.iconEmoji")}
</label>
<input
id={`icon_${box.id}`}
@@ -163,7 +165,7 @@ export default async function AdminWriteableBoxes() {
htmlFor={`position_${box.id}`}
style={{ display: "block", fontWeight: 700 }}
>
Position
{t("position")}
</label>
<input
id={`position_${box.id}`}
@@ -189,7 +191,7 @@ export default async function AdminWriteableBoxes() {
value="1"
defaultChecked={box.isActive}
/>{" "}
Active
{t("active")}
</label>
</div>
<div style={{ gridColumn: "1 / -1" }}>
@@ -197,7 +199,7 @@ export default async function AdminWriteableBoxes() {
htmlFor={`content_${box.id}`}
style={{ display: "block", fontWeight: 700 }}
>
Content (HTML)
{t("form.content")}
</label>
<textarea
id={`content_${box.id}`}
@@ -209,7 +211,7 @@ export default async function AdminWriteableBoxes() {
</div>
<div style={{ gridColumn: "1 / -1", display: "flex", justifyContent: "flex-end" }}>
<button type="submit" className="btn btn-primary">
Save
{t("form.save")}
</button>
</div>
</form>
@@ -227,13 +229,13 @@ export default async function AdminWriteableBoxes() {
<input type="hidden" name="id" value={String(box.id)} />
<input type="hidden" name="next" value={box.isActive ? "0" : "1"} />
<button type="submit" className="btn">
{box.isActive ? "Hide" : "Activate"}
{box.isActive ? t("hide") : t("activate")}
</button>
</form>
<form action={deleteBox}>
<input type="hidden" name="id" value={String(box.id)} />
<button type="submit" className="btn btn-danger">
Delete
{t("delete")}
</button>
</form>
</div>
+1308
View File
File diff suppressed because it is too large. Load diff
+1310 -1
View File
File diff suppressed because it is too large. Load diff
+1308
View File
File diff suppressed because it is too large. Load diff
+1308
View File
File diff suppressed because it is too large. Load diff
+1308
View File
File diff suppressed because it is too large. Load diff
+1308
View File
File diff suppressed because it is too large. Load diff