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>
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
+1310
-1
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
Reference in new issue
Block a user