Translate admin panel to all 6 languages
This commit is contained in:
1 parent
f381aa987f
commit
befa4ec282
63 files changed
+9353
-1443
No files matched your search
@@ -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
@@ -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>
|
||||
))}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
))}
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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 & 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) => (
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 & 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><audio></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}
|
||||
/>
|
||||
</>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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 > 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
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in new issue
Block a user