Translate admin panel to all 6 languages

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

No files matched your search

+15 -14
View File
@@ -1,4 +1,5 @@
import { Award } from "lucide-react"; import { Award } from "lucide-react";
import { getTranslations } from "next-intl/server";
import { StatusCard } from "@/components/admin/dashboard"; import { StatusCard } from "@/components/admin/dashboard";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
@@ -15,6 +16,8 @@ type Achievement = {
}; };
export default async function AdminAchievements() { export default async function AdminAchievements() {
const t = await getTranslations("pages.admin.achievements");
let achievements: Achievement[] = []; let achievements: Achievement[] = [];
try { try {
achievements = await prisma.achievements.findMany({ achievements = await prisma.achievements.findMany({
@@ -33,7 +36,6 @@ export default async function AdminAchievements() {
achievements = []; achievements = [];
} }
// Group by category (already sorted by category, then name, then level).
const groups = new Map<string, Achievement[]>(); const groups = new Map<string, Achievement[]>();
for (const a of achievements) { for (const a of achievements) {
const list = groups.get(a.category) ?? []; const list = groups.get(a.category) ?? [];
@@ -41,7 +43,6 @@ export default async function AdminAchievements() {
groups.set(a.category, list); 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; const distinctNames = new Set(achievements.map((a) => a.name)).size;
return ( return (
@@ -51,19 +52,19 @@ export default async function AdminAchievements() {
<Award size={20} className="text-[var(--color-primary)]" /> <Award size={20} className="text-[var(--color-primary)]" />
</div> </div>
<div> <div>
<h1 className="m-0 text-xl font-extrabold text-[var(--color-text)]">Achievements</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">Read-only · {achievements.length} rows across {groups.size} categories.</p> <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> </div>
<div className="grid grid-cols-[repeat(auto-fit,minmax(180px,1fr))] gap-3.5 mb-6"> <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={t("achievementRows")} value={achievements.length} icon="🏆" />
<StatusCard label="Distinct achievements" value={distinctNames} icon="🎖️" /> <StatusCard label={t("distinctAchievements")} value={distinctNames} icon="🎖️" />
<StatusCard label="Categories" value={groups.size} icon="🗂️" /> <StatusCard label={t("categories")} value={groups.size} icon="🗂️" />
</div> </div>
{groups.size === 0 ? ( {groups.size === 0 ? (
<div className="admin-empty">No achievements found.</div> <div className="admin-empty">{t("noAchievements")}</div>
) : ( ) : (
[...groups.entries()].map(([category, rows]) => ( [...groups.entries()].map(([category, rows]) => (
<section key={category} className="mt-6"> <section key={category} className="mt-6">
@@ -75,12 +76,12 @@ export default async function AdminAchievements() {
<table> <table>
<thead> <thead>
<tr> <tr>
<th>Name</th> <th>{t("colName")}</th>
<th>Level</th> <th>{t("colLevel")}</th>
<th>Progress needed</th> <th>{t("colProgress")}</th>
<th>Reward type</th> <th>{t("colRewardType")}</th>
<th>Reward amount</th> <th>{t("colRewardAmount")}</th>
<th>Points</th> <th>{t("colPoints")}</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
+13 -11
View File
@@ -1,4 +1,5 @@
import Link from "next/link"; import Link from "next/link";
import { getTranslations } from "next-intl/server";
import { Megaphone } from "lucide-react"; import { Megaphone } from "lucide-react";
import { StatusCard } from "@/components/admin/dashboard"; import { StatusCard } from "@/components/admin/dashboard";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
@@ -6,6 +7,7 @@ import { prisma } from "@/lib/prisma";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
export default async function AdminAds() { export default async function AdminAds() {
const t = await getTranslations("pages.admin.ads");
let ads: Awaited<ReturnType<typeof prisma.websiteAds.findMany>> = []; let ads: Awaited<ReturnType<typeof prisma.websiteAds.findMany>> = [];
try { try {
ads = await prisma.websiteAds.findMany({ ads = await prisma.websiteAds.findMany({
@@ -27,30 +29,30 @@ export default async function AdminAds() {
<Megaphone size={20} className="text-[var(--color-primary)]" /> <Megaphone size={20} className="text-[var(--color-primary)]" />
</div> </div>
<div className="flex-1"> <div className="flex-1">
<h1 className="m-0 text-xl font-extrabold text-[var(--color-text)]">Advertisements</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">Website advertisement banners (<code>website_ads</code>). Each ad is an image URL shown across the site.</p> <p className="m-0 text-xs text-[var(--color-text-muted)] mt-0.5">{t("subtitle")}</p>
</div> </div>
<Link href="/admin/ads/new" className="btn btn-primary"> <Link href="/admin/ads/new" className="btn btn-primary">
+ New ad + {t("newAd")}
</Link> </Link>
</div> </div>
<div className="grid grid-cols-[repeat(auto-fit,minmax(180px,1fr))] gap-3.5 mb-6"> <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={t("adsCount")} value={ads.length} icon="🖼️" />
<StatusCard label="Latest" value={latest} hint="most recent ad" icon="🕒" /> <StatusCard label={t("latest")} value={latest} hint={t("mostRecentAd")} icon="🕒" />
</div> </div>
<section className="mt-6"> <section className="mt-6">
<h2 className="admin-section-title">All ads</h2> <h2 className="admin-section-title">{t("allAds")}</h2>
{ads.length === 0 ? ( {ads.length === 0 ? (
<div className="admin-empty">No advertisements yet.</div> <div className="admin-empty">{t("noAds")}</div>
) : ( ) : (
<table> <table>
<thead> <thead>
<tr> <tr>
<th>Preview</th> <th>{t("colPreview")}</th>
<th>Image URL</th> <th>{t("colImage")}</th>
<th>Created</th> <th>{t("colCreated")}</th>
<th /> <th />
</tr> </tr>
</thead> </thead>
@@ -68,7 +70,7 @@ export default async function AdminAds() {
{ad.createdAt ? ad.createdAt.toISOString().slice(0, 10) : "—"} {ad.createdAt ? ad.createdAt.toISOString().slice(0, 10) : "—"}
</td> </td>
<td> <td>
<Link href={`/admin/ads/${ad.id}`}>Edit</Link> <Link href={`/admin/ads/${ad.id}`}>{t("edit")}</Link>
</td> </td>
</tr> </tr>
))} ))}
File diff suppressed because it is too large. Load diff
+10 -8
View File
@@ -3,6 +3,7 @@ import { ClipboardList } from "lucide-react";
import { dismissApplication } from "@/actions/admin-applications"; import { dismissApplication } from "@/actions/admin-applications";
import { StatusCard } from "@/components/admin/dashboard"; import { StatusCard } from "@/components/admin/dashboard";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
import { getTranslations } from "next-intl/server";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
@@ -19,6 +20,7 @@ function formatDate(d: Date | null): string {
} }
export default async function AdminApplications() { export default async function AdminApplications() {
const t = await getTranslations("pages.admin.applications");
let applications: ApplicationRow[] = []; let applications: ApplicationRow[] = [];
try { try {
applications = await prisma.websiteStaffApplications.findMany({ applications = await prisma.websiteStaffApplications.findMany({
@@ -51,14 +53,14 @@ export default async function AdminApplications() {
<ClipboardList size={20} className="text-[var(--color-primary)]" /> <ClipboardList size={20} className="text-[var(--color-primary)]" />
</div> </div>
<div> <div>
<h1 className="m-0 text-xl font-extrabold text-[var(--color-text)]">Staff Applications</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">Pending applications from players requesting a staff rank.</p> <p className="m-0 text-xs text-[var(--color-text-muted)] mt-0.5">{t("subtitle")}</p>
</div> </div>
</div> </div>
<div className="grid grid-cols-[repeat(auto-fit,minmax(180px,1fr))] gap-3.5 mb-6"> <div className="grid grid-cols-[repeat(auto-fit,minmax(180px,1fr))] gap-3.5 mb-6">
<StatusCard <StatusCard
label="Pending applications" label={t("pending")}
value={applications.length} value={applications.length}
state={applications.length > 0 ? "warn" : "ok"} state={applications.length > 0 ? "warn" : "ok"}
icon="📨" icon="📨"
@@ -66,9 +68,9 @@ export default async function AdminApplications() {
</div> </div>
<section className="mt-6"> <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 ? ( {applications.length === 0 ? (
<div className="admin-empty">No pending applications.</div> <div className="admin-empty">{t("noApplications")}</div>
) : ( ) : (
<div className="grid cols-2"> <div className="grid cols-2">
{applications.map((a) => { {applications.map((a) => {
@@ -80,10 +82,10 @@ export default async function AdminApplications() {
{username ? ( {username ? (
<Link href={`/admin/users/${a.userId}`}>{username}</Link> <Link href={`/admin/users/${a.userId}`}>{username}</Link>
) : ( ) : (
<>User #{a.userId}</> <>{t("userNumber", { id: a.userId })}</>
)} )}
</h3> </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> </div>
<p className="text-xs text-gray-500 dark:text-gray-400 m-0 mb-2"> <p className="text-xs text-gray-500 dark:text-gray-400 m-0 mb-2">
{formatDate(a.createdAt)} {formatDate(a.createdAt)}
@@ -92,7 +94,7 @@ export default async function AdminApplications() {
<form action={dismissApplication}> <form action={dismissApplication}>
<input type="hidden" name="id" value={String(a.id)} /> <input type="hidden" name="id" value={String(a.id)} />
<button type="submit" className="btn btn-danger"> <button type="submit" className="btn btn-danger">
Dismiss {t("dismiss")}
</button> </button>
</form> </form>
</article> </article>
+5 -3
View File
@@ -1,4 +1,5 @@
import Link from "next/link"; import Link from "next/link";
import { getTranslations } from "next-intl/server";
import { Newspaper } from "lucide-react"; import { Newspaper } from "lucide-react";
import { notFound } from "next/navigation"; import { notFound } from "next/navigation";
import { deleteArticle, updateArticle } from "@/actions/admin-articles"; import { deleteArticle, updateArticle } from "@/actions/admin-articles";
@@ -16,6 +17,7 @@ export default async function EditArticle({
}) { }) {
const { id } = await params; const { id } = await params;
const { error } = await searchParams; const { error } = await searchParams;
const t = await getTranslations("pages.admin.articles");
const article = await prisma.websiteArticles.findUnique({ const article = await prisma.websiteArticles.findUnique({
where: { id: BigInt(id) }, where: { id: BigInt(id) },
select: { id: true, slug: true, title: true, shortStory: true, fullStory: true, image: true }, 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)]" /> <Newspaper size={20} className="text-[var(--color-primary)]" />
</div> </div>
<div> <div>
<h1 className="m-0 text-xl font-extrabold text-[var(--color-text)]">Edit article</h1> <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">← Articles</Link></p> <p className="m-0 text-xs text-[var(--color-text-muted)] mt-0.5"><Link href="/admin/articles">← {t("title")}</Link></p>
</div> </div>
</div> </div>
{error ? ( {error ? (
@@ -53,7 +55,7 @@ export default async function EditArticle({
/> />
<form action={deleteArticle} style={{ marginTop: "1rem" }}> <form action={deleteArticle} style={{ marginTop: "1rem" }}>
<input type="hidden" name="id" value={String(article.id)} /> <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> </form>
</main> </main>
); );
+4 -2
View File
@@ -1,4 +1,5 @@
import Link from "next/link"; import Link from "next/link";
import { getTranslations } from "next-intl/server";
import { Newspaper } from "lucide-react"; import { Newspaper } from "lucide-react";
import { createArticle } from "@/actions/admin-articles"; import { createArticle } from "@/actions/admin-articles";
import { ArticleForm } from "@/components/admin/article-form"; import { ArticleForm } from "@/components/admin/article-form";
@@ -9,6 +10,7 @@ export default async function NewArticle({
searchParams: Promise<{ error?: string }>; searchParams: Promise<{ error?: string }>;
}) { }) {
const { error } = await searchParams; const { error } = await searchParams;
const t = await getTranslations("pages.admin.articles");
return ( return (
<main> <main>
@@ -17,8 +19,8 @@ export default async function NewArticle({
<Newspaper size={20} className="text-[var(--color-primary)]" /> <Newspaper size={20} className="text-[var(--color-primary)]" />
</div> </div>
<div> <div>
<h1 className="m-0 text-xl font-extrabold text-[var(--color-text)]">New article</h1> <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">← Articles</Link></p> <p className="m-0 text-xs text-[var(--color-text-muted)] mt-0.5"><Link href="/admin/articles">← {t("title")}</Link></p>
</div> </div>
</div> </div>
{error ? ( {error ? (
+15 -13
View File
@@ -1,4 +1,5 @@
import Link from "next/link"; import Link from "next/link";
import { getTranslations } from "next-intl/server";
import { Newspaper } from "lucide-react"; import { Newspaper } from "lucide-react";
import { StatusCard } from "@/components/admin/dashboard"; import { StatusCard } from "@/components/admin/dashboard";
import { deleteArticle } from "@/actions/admin-articles"; import { deleteArticle } from "@/actions/admin-articles";
@@ -12,6 +13,7 @@ export default async function AdminArticles({
searchParams: Promise<{ error?: string }>; searchParams: Promise<{ error?: string }>;
}) { }) {
const { error } = await searchParams; const { error } = await searchParams;
const t = await getTranslations("pages.admin.articles");
const articles = await prisma.websiteArticles const articles = await prisma.websiteArticles
.findMany({ .findMany({
orderBy: { createdAt: "desc" }, orderBy: { createdAt: "desc" },
@@ -31,11 +33,11 @@ export default async function AdminArticles({
<Newspaper size={20} className="text-[var(--color-primary)]" /> <Newspaper size={20} className="text-[var(--color-primary)]" />
</div> </div>
<div className="flex-1"> <div className="flex-1">
<h1 className="m-0 text-xl font-extrabold text-[var(--color-text)]">Articles</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">News articles (<code>website_articles</code>) shown on the public site. 50 most recent.</p> <p className="m-0 text-xs text-[var(--color-text-muted)] mt-0.5">{t("subtitle")}</p>
</div> </div>
<Link href="/admin/articles/new" className="btn btn-primary"> <Link href="/admin/articles/new" className="btn btn-primary">
+ New article + {t("newArticle")}
</Link> </Link>
</div> </div>
@@ -46,22 +48,22 @@ export default async function AdminArticles({
) : null} ) : null}
<div className="grid grid-cols-[repeat(auto-fit,minmax(180px,1fr))] gap-3.5 mb-6"> <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={t("articlesShown")} value={articles.length} icon="📰" />
<StatusCard label="Latest" value={latest} hint="most recent post" icon="🕒" /> <StatusCard label={t("latest")} value={latest} hint={t("mostRecentPost")} icon="🕒" />
</div> </div>
<section className="mt-6"> <section className="mt-6">
<h2 className="admin-section-title">Recent articles</h2> <h2 className="admin-section-title">{t("recentArticles")}</h2>
{articles.length === 0 ? ( {articles.length === 0 ? (
<div className="admin-empty">No articles yet.</div> <div className="admin-empty">{t("noArticles")}</div>
) : ( ) : (
<table> <table>
<thead> <thead>
<tr> <tr>
<th>Title</th> <th>{t("colTitle")}</th>
<th>Slug</th> <th>{t("colSlug")}</th>
<th>Created</th> <th>{t("colCreated")}</th>
<th>Actions</th> <th>{t("colActions")}</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@@ -81,7 +83,7 @@ export default async function AdminArticles({
<td> <td>
<div className="flex gap-2"> <div className="flex gap-2">
<Link href={`/admin/articles/${a.id}`} className="btn btn-primary"> <Link href={`/admin/articles/${a.id}`} className="btn btn-primary">
Edit {t("edit")}
</Link> </Link>
<form action={deleteArticle} className="m-0"> <form action={deleteArticle} className="m-0">
<input type="hidden" name="id" value={String(a.id)} /> <input type="hidden" name="id" value={String(a.id)} />
@@ -89,7 +91,7 @@ export default async function AdminArticles({
type="submit" type="submit"
className="btn btn-danger" className="btn btn-danger"
> >
Delete {t("delete")}
</button> </button>
</form> </form>
</div> </div>
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+20 -18
View File
@@ -1,4 +1,5 @@
import Link from "next/link"; import Link from "next/link";
import { getTranslations } from "next-intl/server";
import { notFound } from "next/navigation"; import { notFound } from "next/navigation";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
import { Calendar } from "lucide-react"; import { Calendar } from "lucide-react";
@@ -40,6 +41,8 @@ export default async function AdminCalendarCampaign({
}: { }: {
params: Promise<{ id: string }>; params: Promise<{ id: string }>;
}) { }) {
const t = await getTranslations("pages.admin.calendar");
const { id } = await params; const { id } = await params;
const campaignId = Number(id); const campaignId = Number(id);
if (!Number.isInteger(campaignId)) notFound(); if (!Number.isInteger(campaignId)) notFound();
@@ -93,7 +96,7 @@ export default async function AdminCalendarCampaign({
return ( return (
<main> <main>
<p className="text-xs text-gray-500 dark:text-gray-400 mb-4"> <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> </p>
<div className="admin-card flex items-center gap-5 mb-6"> <div className="admin-card flex items-center gap-5 mb-6">
@@ -106,31 +109,30 @@ export default async function AdminCalendarCampaign({
/> />
) : null} ) : null}
<div> <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"> <p className="text-xs text-gray-500 dark:text-gray-400 m-0">
#{campaign.id} · {campaign.totalDays} day{campaign.totalDays === 1 ? "" : "s"} · starts{" "} {t("detail.campaignMeta", { id: campaign.id, days: campaign.totalDays, date: fmtDate(campaign.startTimestamp) })}
{fmtDate(campaign.startTimestamp)} {disabled ? ` · ${t("detail.disabled")}` : ` · ${t("detail.enabled")}`}
{disabled ? " · Disabled" : " · Enabled"} {campaign.lockExpired === "1" ? ` · ${t("detail.locksExpired")}` : ""}
{campaign.lockExpired === "1" ? " · Locks expired days" : ""}
</p> </p>
</div> </div>
</div> </div>
<h2 className="admin-section-title">Rewards</h2> <h2 className="admin-section-title">{t("detail.rewards")}</h2>
{rewards.length === 0 ? ( {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"> <table className="w-full">
<thead> <thead>
<tr className="text-left text-[var(--muted)]"> <tr className="text-left text-[var(--muted)]">
<th>ID</th> <th>{t("detail.colId")}</th>
<th>Product</th> <th>{t("detail.colProduct")}</th>
<th>Item</th> <th>{t("detail.colItem")}</th>
<th>Credits</th> <th>{t("detail.colCredits")}</th>
<th>Pixels</th> <th>{t("detail.colPixels")}</th>
<th>Points</th> <th>{t("detail.colPoints")}</th>
<th>Badge</th> <th>{t("detail.colBadge")}</th>
<th>Subscription</th> <th>{t("detail.colSubscription")}</th>
</tr> </tr>
</thead> </thead>
<tbody> <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.pixels > 0 ? r.pixels : <span className="text-sm text-gray-500 dark:text-gray-400">—</span>}</td>
<td> <td>
{r.points > 0 ? ( {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> <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"> <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> </p>
</main> </main>
); );
+14 -14
View File
@@ -1,4 +1,5 @@
import Link from "next/link"; import Link from "next/link";
import { getTranslations } from "next-intl/server";
import { StatusCard } from "@/components/admin/dashboard"; import { StatusCard } from "@/components/admin/dashboard";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
import { Calendar } from "lucide-react"; import { Calendar } from "lucide-react";
@@ -21,6 +22,8 @@ function fmtDate(ts: number): string {
} }
export default async function AdminCalendar() { export default async function AdminCalendar() {
const t = await getTranslations("pages.admin.calendar");
let campaigns: Campaign[] = []; let campaigns: Campaign[] = [];
try { try {
campaigns = await prisma.calendarCampaigns.findMany({ campaigns = await prisma.calendarCampaigns.findMany({
@@ -39,7 +42,6 @@ export default async function AdminCalendar() {
campaigns = []; campaigns = [];
} }
// Tally reward counts per campaign in JS (avoid prisma.groupBy).
const rewardCounts = new Map<number, number>(); const rewardCounts = new Map<number, number>();
let totalRewards = 0; let totalRewards = 0;
try { try {
@@ -51,7 +53,6 @@ export default async function AdminCalendar() {
rewardCounts.set(r.campaignId, (rewardCounts.get(r.campaignId) ?? 0) + 1); rewardCounts.set(r.campaignId, (rewardCounts.get(r.campaignId) ?? 0) + 1);
} }
} catch { } catch {
// leave map empty on failure
} }
const enabledCount = campaigns.filter((c) => c.enabled === "1").length; 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)]" /> <Calendar size={20} className="text-[var(--color-primary)]" />
</div> </div>
<div> <div>
<h1 className="m-0 text-xl font-extrabold text-[var(--color-text)]">Calendar campaigns</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">Read-only · {campaigns.length} campaign{campaigns.length === 1 ? "" : "s"}.</p> <p className="m-0 text-xs text-[var(--color-text-muted)] mt-0.5">{t("subtitle", { count: campaigns.length })}</p>
</div> </div>
</div> </div>
<div className="grid grid-cols-[repeat(auto-fit,minmax(180px,1fr))] gap-3.5 mb-6"> <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 <StatusCard
label="Enabled" label={t("enabled")}
value={enabledCount} value={enabledCount}
state={enabledCount > 0 ? "ok" : "neutral"} state={enabledCount > 0 ? "ok" : "neutral"}
icon="✅" icon="✅"
/> />
<StatusCard label="Total rewards" value={totalRewards} icon="🎁" /> <StatusCard label={t("totalRewards")} value={totalRewards} icon="🎁" />
</div> </div>
{campaigns.length === 0 ? ( {campaigns.length === 0 ? (
<div className="admin-empty">No calendar campaigns found.</div> <div className="admin-empty">{t("noCampaigns")}</div>
) : ( ) : (
<div className="grid cols-3"> <div className="grid cols-3">
{campaigns.map((c) => { {campaigns.map((c) => {
@@ -100,18 +101,17 @@ export default async function AdminCalendar() {
)} )}
<div className="body"> <div className="body">
<div className="flex items-center justify-between gap-2 mb-1"> <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)]"}`}> <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> </span>
</div> </div>
<p className="text-xs text-gray-500 dark:text-gray-400 m-0"> <p className="text-xs text-gray-500 dark:text-gray-400 m-0">
#{c.id} · {c.totalDays} day{c.totalDays === 1 ? "" : "s"} · {count} reward {t("campaignMeta", { id: c.id, days: c.totalDays, rewards: count })}
{count === 1 ? "" : "s"}
</p> </p>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1"> <p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
Starts {fmtDate(c.startTimestamp)} {t("startsAt", { date: fmtDate(c.startTimestamp) })}
{c.lockExpired === "1" ? " · Locks expired" : ""} {c.lockExpired === "1" ? ` · ${t("locksExpired")}` : ""}
</p> </p>
</div> </div>
</Link> </Link>
+22 -19
View File
@@ -1,4 +1,5 @@
import Link from "next/link"; import Link from "next/link";
import { getTranslations } from "next-intl/server";
import { notFound } from "next/navigation"; import { notFound } from "next/navigation";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
@@ -35,6 +36,8 @@ export default async function AdminCatalogPage({
}: { }: {
params: Promise<{ id: string }>; params: Promise<{ id: string }>;
}) { }) {
const t = await getTranslations("pages.admin.catalog");
const { id } = await params; const { id } = await params;
const pageId = Number(id); const pageId = Number(id);
if (!Number.isInteger(pageId)) notFound(); if (!Number.isInteger(pageId)) notFound();
@@ -89,32 +92,32 @@ export default async function AdminCatalogPage({
return ( return (
<main> <main>
<p className="muted"> <p className="muted">
<Link href="/admin/catalog">← Catalog</Link> <Link href="/admin/catalog">← {t("title")}</Link>
</p> </p>
<h1>{page.caption}</h1> <h1>{page.caption}</h1>
<p className="muted"> <p className="muted">
Page #{page.id} · layout {page.pageLayout} · min rank {page.minRank} ·{" "} {t("pageLabel", { id: page.id })} · {t("layoutLabel", { layout: page.pageLayout })} · {t("minRankLabel", { rank: page.minRank })} ·{" "}
{hidden ? "Hidden" : "Visible"} {hidden ? t("statusHidden") : t("statusVisible")}
{page.parentId > 0 ? ` · parent #${page.parentId}` : ""} {page.parentId > 0 ? ` · ${t("parentLabel", { id: page.parentId })}` : ""}
{page.clubOnly === "1" ? " · HC only" : ""} {page.clubOnly === "1" ? ` · ${t("hcOnly")}` : ""}
{page.vipOnly === "1" ? " · VIP only" : ""} {page.vipOnly === "1" ? ` · ${t("vipOnly")}` : ""}
</p> </p>
<h2 style={{ marginTop: "1.5rem" }}>Items</h2> <h2 style={{ marginTop: "1.5rem" }}>{t("items")}</h2>
{items.length === 0 ? ( {items.length === 0 ? (
<p className="muted">No items on this page.</p> <p className="muted">{t("noItems")}</p>
) : ( ) : (
<table style={{ width: "100%", borderCollapse: "collapse" }}> <table style={{ width: "100%", borderCollapse: "collapse" }}>
<thead> <thead>
<tr style={{ textAlign: "left", color: "var(--muted)" }}> <tr style={{ textAlign: "left", color: "var(--muted)" }}>
<th>ID</th> <th>{t("colId")}</th>
<th>Catalog name</th> <th>{t("catalogName")}</th>
<th>Item IDs</th> <th>{t("itemIds")}</th>
<th>Amount</th> <th>{t("amount")}</th>
<th>Credits</th> <th>{t("colCostCredits")}</th>
<th>Points</th> <th>{t("colCostPoints")}</th>
<th>Limited</th> <th>{t("colLimited")}</th>
<th>Order</th> <th>{t("colOrder")}</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@@ -124,7 +127,7 @@ export default async function AdminCatalogPage({
<td> <td>
{it.catalogName} {it.catalogName}
{it.clubOnly === "1" ? ( {it.clubOnly === "1" ? (
<span className="muted"> · HC</span> <span className="muted"> · {t("hc")}</span>
) : null} ) : null}
</td> </td>
<td className="muted">{it.itemIds}</td> <td className="muted">{it.itemIds}</td>
@@ -138,7 +141,7 @@ export default async function AdminCatalogPage({
</td> </td>
<td className="muted"> <td className="muted">
{it.costPoints > 0 {it.costPoints > 0
? `${it.costPoints} (type ${it.pointsType})` ? `${it.costPoints} (${t("typeLabel", { type: it.pointsType })})`
: "—"} : "—"}
</td> </td>
<td className="muted"> <td className="muted">
@@ -154,7 +157,7 @@ export default async function AdminCatalogPage({
)} )}
<p className="muted" style={{ marginTop: "1rem" }}> <p className="muted" style={{ marginTop: "1rem" }}>
{items.length} item{items.length === 1 ? "" : "s"} {t("itemCount", { count: items.length })}
</p> </p>
</main> </main>
); );
+19 -17
View File
@@ -1,4 +1,5 @@
import Link from "next/link"; import Link from "next/link";
import { getTranslations } from "next-intl/server";
import { StatusCard } from "@/components/admin/dashboard"; import { StatusCard } from "@/components/admin/dashboard";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
import { Package } from "lucide-react"; import { Package } from "lucide-react";
@@ -17,6 +18,8 @@ type CatalogPageRow = {
}; };
export default async function AdminCatalog() { export default async function AdminCatalog() {
const t = await getTranslations("pages.admin.catalog");
let pages: CatalogPageRow[] = []; let pages: CatalogPageRow[] = [];
try { try {
pages = await prisma.catalogPages.findMany({ pages = await prisma.catalogPages.findMany({
@@ -37,7 +40,6 @@ export default async function AdminCatalog() {
pages = []; pages = [];
} }
// Build a quick caption lookup so we can show parent names instead of raw ids.
const captionById = new Map<number, string>(); const captionById = new Map<number, string>();
for (const p of pages) captionById.set(p.id, p.caption); 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)]" /> <Package size={20} className="text-[var(--color-primary)]" />
</div> </div>
<div> <div>
<h1 className="m-0 text-xl font-extrabold text-[var(--color-text)]">Catalog</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">Browse catalog pages and the items they contain. Read-only.</p> <p className="m-0 text-xs text-[var(--color-text-muted)] mt-0.5">{t("subtitle")}</p>
</div> </div>
</div> </div>
<div className="grid grid-cols-[repeat(auto-fit,minmax(180px,1fr))] gap-3.5 mb-6"> <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={t("pages")} value={pages.length} icon="📚" />
<StatusCard label="Visible" value={visibleCount} state="ok" icon="👁️" /> <StatusCard label={t("visible")} value={visibleCount} state="ok" icon="👁️" />
<StatusCard <StatusCard
label="Hidden" label={t("hidden")}
value={hiddenCount} value={hiddenCount}
state={hiddenCount > 0 ? "warn" : "neutral"} state={hiddenCount > 0 ? "warn" : "neutral"}
icon="🚫" icon="🚫"
/> />
<StatusCard label="Root pages" value={rootCount} icon="🗂️" /> <StatusCard label={t("rootPages")} value={rootCount} icon="🗂️" />
</div> </div>
<section className="mt-6"> <section className="mt-6">
<h2>Pages ({pages.length})</h2> <h2>{t("pages")} ({pages.length})</h2>
{pages.length === 0 ? ( {pages.length === 0 ? (
<p className="muted">No catalog pages found.</p> <p className="muted">{t("noPages")}</p>
) : ( ) : (
<div style={{ overflowX: "auto" }}> <div style={{ overflowX: "auto" }}>
<table> <table>
<thead> <thead>
<tr> <tr>
<th>ID</th> <th>{t("colId")}</th>
<th>Caption</th> <th>{t("caption")}</th>
<th>Parent</th> <th>{t("colParent")}</th>
<th>Layout</th> <th>{t("colLayout")}</th>
<th>Min rank</th> <th>{t("colMinRank")}</th>
<th>Order</th> <th>{t("colOrder")}</th>
<th>Status</th> <th>{t("colStatus")}</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@@ -112,7 +114,7 @@ export default async function AdminCatalog() {
<td>{p.orderNum}</td> <td>{p.orderNum}</td>
<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)]"}`}> <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> </span>
</td> </td>
</tr> </tr>
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
+12 -10
View File
@@ -1,4 +1,5 @@
import Link from "next/link"; import Link from "next/link";
import { getTranslations } from "next-intl/server";
import { HelpCircle } from "lucide-react"; import { HelpCircle } from "lucide-react";
import { StatusCard } from "@/components/admin/dashboard"; import { StatusCard } from "@/components/admin/dashboard";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
@@ -6,6 +7,7 @@ import { prisma } from "@/lib/prisma";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
export default async function AdminHelpQuestions() { export default async function AdminHelpQuestions() {
const t = await getTranslations("pages.admin.helpCenter");
let entries: Awaited< let entries: Awaited<
ReturnType<typeof prisma.websiteHelpCenterCategories.findMany> ReturnType<typeof prisma.websiteHelpCenterCategories.findMany>
> = []; > = [];
@@ -28,30 +30,30 @@ export default async function AdminHelpQuestions() {
<HelpCircle size={20} className="text-[var(--color-primary)]" /> <HelpCircle size={20} className="text-[var(--color-primary)]" />
</div> </div>
<div className="flex-1"> <div className="flex-1">
<h1 className="m-0 text-xl font-extrabold text-[var(--color-text)]">Help Center</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">Help-center FAQ entries (<code>website_help_center_categories</code>). Shown to visitors on the public help page, ordered by position.</p> <p className="m-0 text-xs text-[var(--color-text-muted)] mt-0.5">{t("subtitle")}</p>
</div> </div>
<Link href="/admin/help-questions/new" className="btn btn-primary"> <Link href="/admin/help-questions/new" className="btn btn-primary">
+ New entry + {t("newEntry")}
</Link> </Link>
</div> </div>
<div className="grid grid-cols-[repeat(auto-fit,minmax(180px,1fr))] gap-3.5 mb-6"> <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={t("entries")} value={entries.length} icon="❓" />
<StatusCard label="With button" value={withButton} icon="🔘" /> <StatusCard label={t("withButton")} value={withButton} icon="🔘" />
<StatusCard label="Small boxes" value={smallBoxes} icon="🗂️" /> <StatusCard label={t("smallBoxes")} value={smallBoxes} icon="🗂️" />
</div> </div>
<section className="mt-6"> <section className="mt-6">
<h2 className="admin-section-title">Entries</h2> <h2 className="admin-section-title">{t("entries")}</h2>
{entries.length === 0 ? ( {entries.length === 0 ? (
<div className="admin-empty">No help-center entries yet.</div> <div className="admin-empty">{t("noEntries")}</div>
) : ( ) : (
<table> <table>
<thead> <thead>
<tr> <tr>
<th>Pos.</th> <th>Pos.</th>
<th>Name</th> <th>{t("colTitle")}</th>
<th>Button</th> <th>Button</th>
<th>Small box</th> <th>Small box</th>
<th /> <th />
@@ -71,7 +73,7 @@ export default async function AdminHelpQuestions() {
</span> </span>
</td> </td>
<td> <td>
<Link href={`/admin/help-questions/${e.id}`}>Edit</Link> <Link href={`/admin/help-questions/${e.id}`}>{t("edit")}</Link>
</td> </td>
</tr> </tr>
))} ))}
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
+7 -4
View File
@@ -1,8 +1,11 @@
import Link from "next/link"; import Link from "next/link";
import { getTranslations } from "next-intl/server";
import { Image } from "lucide-react"; import { Image } from "lucide-react";
import { AdminMediaGrid } from "@/components/admin/media-grid"; import { AdminMediaGrid } from "@/components/admin/media-grid";
export default function AdminMediaPage() { export default async function AdminMediaPage() {
const t = await getTranslations("pages.admin.media");
return ( return (
<main> <main>
<div className="flex items-center gap-3 mb-6"> <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)]" /> <Image size={20} className="text-[var(--color-primary)]" />
</div> </div>
<div className="flex-1"> <div className="flex-1">
<h1 className="m-0 text-xl font-extrabold text-[var(--color-text)]">Media Library</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">Upload and manage images for articles and other content.</p> <p className="m-0 text-xs text-[var(--color-text-muted)] mt-0.5">{t("subtitle")}</p>
</div> </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> </div>
<AdminMediaGrid /> <AdminMediaGrid />
</main> </main>
File diff suppressed because it is too large. Load diff
+15 -13
View File
@@ -1,4 +1,5 @@
import Link from "next/link"; import Link from "next/link";
import { getTranslations } from "next-intl/server";
import { StatusCard } from "@/components/admin/dashboard"; import { StatusCard } from "@/components/admin/dashboard";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
import { Users, Newspaper, Ban, Database, Activity, ArrowRight } from "lucide-react"; 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 }; type RankCount = { rank: number; count: number };
export default async function AdminDashboard() { export default async function AdminDashboard() {
const t = await getTranslations("pages.admin.dashboard");
let users = -1; let users = -1;
let online = 0; let online = 0;
let articles = 0; let articles = 0;
@@ -54,31 +56,31 @@ export default async function AdminDashboard() {
<Activity size={20} className="text-[var(--color-primary)]" /> <Activity size={20} className="text-[var(--color-primary)]" />
</div> </div>
<div> <div>
<h1 className="m-0 text-xl font-extrabold text-[var(--color-text)]">Dashboard</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">Hotel overview &amp; live status</p> <p className="m-0 text-xs text-[var(--color-text-muted)] mt-0.5">{t("subtitle")}</p>
</div> </div>
</div> </div>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4 mb-8"> <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={t("playersOnline")} value={online} hint={dbOk ? t("totalAccounts", { count: users }) : t("databaseDown")} state={dbOk ? "neutral" : "danger"} icon={<Users size={18} />} />
<StatusCard label="Articles" value={dbOk ? articles : "—"} state="neutral" icon={<Newspaper size={18} />} /> <StatusCard label={t("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={t("activeBans")} 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("database")} value={dbOk ? t("databaseConnected") : t("databaseDown")} state={dbOk ? "ok" : "danger"} icon={<Database size={18} />} />
</div> </div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6"> <div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
<section className="admin-card p-5"> <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 ? ( {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"> <div className="space-y-2.5">
{rankCounts.map((r) => { {rankCounts.map((r) => {
const pct = maxRankCount > 0 ? Math.round((r.count / maxRankCount) * 100) : 0; const pct = maxRankCount > 0 ? Math.round((r.count / maxRankCount) * 100) : 0;
return ( return (
<div key={r.rank} className="grid items-center gap-3" style={{ gridTemplateColumns: "5rem 1fr 2.5rem" }}> <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> <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={`Rank ${r.rank}: ${r.count} accounts`}> <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 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> </div>
<strong className="text-xs text-right text-[var(--color-text-muted)]">{r.count}</strong> <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"> <section className="admin-card p-5">
<div className="flex items-center justify-between mb-4"> <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"> <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> </Link>
</div> </div>
{activity.length === 0 ? ( {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"> <div className="space-y-0">
{activity.map((a, i) => ( {activity.map((a, i) => (
+19 -17
View File
@@ -6,6 +6,7 @@ import {
import { StatusCard } from "@/components/admin/dashboard"; import { StatusCard } from "@/components/admin/dashboard";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
import { Shield } from "lucide-react"; import { Shield } from "lucide-react";
import { getTranslations } from "next-intl/server";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
@@ -20,6 +21,7 @@ type PermissionRow = {
}; };
export default async function AdminPermissions() { export default async function AdminPermissions() {
const t = await getTranslations("pages.admin.permissions");
let permissions: PermissionRow[] = []; let permissions: PermissionRow[] = [];
try { try {
permissions = await prisma.websitePermissions.findMany({ permissions = await prisma.websitePermissions.findMany({
@@ -40,52 +42,52 @@ export default async function AdminPermissions() {
<Shield size={20} className="text-[var(--color-primary)]" /> <Shield size={20} className="text-[var(--color-primary)]" />
</div> </div>
<div> <div>
<h1 className="m-0 text-xl font-extrabold text-[var(--color-text)]">Permissions</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">CMS-owned permissions (<code>website_permissions</code>). Each entry maps a permission name to the minimum rank allowed to use it.</p> <p className="m-0 text-xs text-[var(--color-text-muted)] mt-0.5">{t("subtitle")}</p>
</div> </div>
</div> </div>
<div className="grid grid-cols-[repeat(auto-fit,minmax(180px,1fr))] gap-3.5 mb-6"> <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={t("permissions")} value={permissions.length} icon="🔑" />
<StatusCard label="Highest min rank" value={highestRank} icon="🎖️" /> <StatusCard label={t("highestMinRank")} value={highestRank} icon="🎖️" />
</div> </div>
<form action={createPermission} className="admin-card mb-6"> <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"> <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 <input
name="minRank" name="minRank"
type="number" type="number"
min={0} min={0}
placeholder="min rank" placeholder={t("minRankPlaceholder")}
defaultValue={1} defaultValue={1}
className="w-[110px]" className="w-[110px]"
/> />
<input <input
name="description" name="description"
placeholder="description" placeholder={t("descriptionPlaceholder")}
className="flex-1 min-w-[160px]" className="flex-1 min-w-[160px]"
/> />
<button type="submit" className="btn btn-primary"> <button type="submit" className="btn btn-primary">
Save {t("save")}
</button> </button>
</div> </div>
</form> </form>
<section className="mt-6"> <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 ? ( {permissions.length === 0 ? (
<div className="admin-empty">No permissions found.</div> <div className="admin-empty">{t("noPermissions")}</div>
) : ( ) : (
<div className="overflow-x-auto"> <div className="overflow-x-auto">
<table> <table>
<thead> <thead>
<tr> <tr>
<th>ID</th> <th>{t("colId")}</th>
<th>Permission</th> <th>{t("colPermission")}</th>
<th>Min rank</th> <th>{t("colMinRank")}</th>
<th>Description</th> <th>{t("colDescription")}</th>
<th /> <th />
</tr> </tr>
</thead> </thead>
@@ -127,12 +129,12 @@ export default async function AdminPermissions() {
form={`perm-${String(p.id)}`} form={`perm-${String(p.id)}`}
className="btn btn-primary" className="btn btn-primary"
> >
Save {t("save")}
</button> </button>
<form action={deletePermission}> <form action={deletePermission}>
<input type="hidden" name="id" value={String(p.id)} /> <input type="hidden" name="id" value={String(p.id)} />
<button type="submit" className="btn btn-danger"> <button type="submit" className="btn btn-danger">
Delete {t("delete")}
</button> </button>
</form> </form>
</td> </td>
+13 -11
View File
@@ -1,3 +1,4 @@
import { getTranslations } from "next-intl/server";
import { deletePhoto } from "@/actions/admin-photos"; import { deletePhoto } from "@/actions/admin-photos";
import { StatusCard } from "@/components/admin/dashboard"; import { StatusCard } from "@/components/admin/dashboard";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
@@ -6,6 +7,7 @@ import { Image } from "lucide-react";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
export default async function AdminPhotos() { export default async function AdminPhotos() {
const t = await getTranslations("pages.admin.photos");
let photos: Awaited<ReturnType<typeof prisma.cameraWeb.findMany>> = []; let photos: Awaited<ReturnType<typeof prisma.cameraWeb.findMany>> = [];
try { try {
photos = await prisma.cameraWeb.findMany({ photos = await prisma.cameraWeb.findMany({
@@ -26,21 +28,21 @@ export default async function AdminPhotos() {
<Image size={20} className="text-[var(--color-primary)]" /> <Image size={20} className="text-[var(--color-primary)]" />
</div> </div>
<div> <div>
<h1 className="m-0 text-xl font-extrabold text-[var(--color-text)]">Photo Moderation</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">Recent in-game camera photos. Deleting removes the record from <code>camera_web</code>.</p> <p className="m-0 text-xs text-[var(--color-text-muted)] mt-0.5">{t("subtitle")}</p>
</div> </div>
</div> </div>
<div className="grid grid-cols-[repeat(auto-fit,minmax(180px,1fr))] gap-3.5 mb-6"> <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={t("photosShown")} value={photos.length} icon="📸" />
<StatusCard label="Rooms" value={uniqueRooms} hint="distinct rooms" icon="🚪" /> <StatusCard label={t("rooms")} value={uniqueRooms} hint={t("distinctRooms")} icon="🚪" />
<StatusCard label="Users" value={uniqueUsers} hint="distinct photographers" icon="👤" /> <StatusCard label={t("users")} value={uniqueUsers} hint={t("distinctPhotographers")} icon="👤" />
</div> </div>
<section className="mt-6"> <section className="mt-6">
<h2 className="admin-section-title">Recent photos</h2> <h2 className="admin-section-title">{t("recentPhotos")}</h2>
{photos.length === 0 ? ( {photos.length === 0 ? (
<div className="admin-empty">No photos found.</div> <div className="admin-empty">{t("noPhotos")}</div>
) : ( ) : (
<div className="grid cols-3"> <div className="grid cols-3">
{photos.map((p) => ( {photos.map((p) => (
@@ -48,21 +50,21 @@ export default async function AdminPhotos() {
{/* eslint-disable-next-line @next/next/no-img-element */} {/* eslint-disable-next-line @next/next/no-img-element */}
<img <img
src={p.url} 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" }} style={{ width: "100%", height: 200, objectFit: "cover", display: "block", background: "#f1f5f9" }}
/> />
<div style={{ padding: "0.85rem 1.05rem 1.05rem" }}> <div style={{ padding: "0.85rem 1.05rem 1.05rem" }}>
<div style={{ display: "flex", justifyContent: "space-between", gap: "0.5rem", alignItems: "baseline" }}> <div style={{ display: "flex", justifyContent: "space-between", gap: "0.5rem", alignItems: "baseline" }}>
<strong>#{String(p.id)}</strong> <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> </div>
<p className="text-xs text-gray-500 dark:text-gray-400" style={{ margin: "0.3rem 0 0" }}> <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", " ")} {new Date(p.timestamp * 1000).toISOString().slice(0, 16).replace("T", " ")}
</p> </p>
<form action={deletePhoto} style={{ marginTop: "0.75rem" }}> <form action={deletePhoto} style={{ marginTop: "0.75rem" }}>
<input type="hidden" name="id" value={String(p.id)} /> <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> </form>
</div> </div>
</div> </div>
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
+19 -16
View File
@@ -3,6 +3,7 @@ import { prisma } from '@/lib/prisma';
import { deleteShout } from '@/actions/admin-radio-moderation'; import { deleteShout } from '@/actions/admin-radio-moderation';
import { StatusCard } from '@/components/admin/dashboard'; import { StatusCard } from '@/components/admin/dashboard';
import { Shield } from 'lucide-react'; import { Shield } from 'lucide-react';
import { getTranslations } from "next-intl/server";
export const dynamic = 'force-dynamic'; export const dynamic = 'force-dynamic';
@@ -15,10 +16,12 @@ type Shout = {
}; };
function formatDate(d: Date | null): string { 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() { export default async function AdminRadioModerationPage() {
const t = await getTranslations("pages.admin.radio");
let shouts: Shout[] = []; let shouts: Shout[] = [];
try { try {
shouts = await prisma.radioShouts.findMany({ shouts = await prisma.radioShouts.findMany({
@@ -39,12 +42,12 @@ export default async function AdminRadioModerationPage() {
return ( return (
<main> <main>
<nav className="text-xs text-gray-500 dark:text-gray-400 mb-2"> <nav className="text-xs text-gray-500 dark:text-gray-400 mb-2">
<Link href="/admin/radio">Radio</Link> ·{' '} <Link href="/admin/radio">{t("title")}</Link> ·{' '}
<Link href="/admin/radio/moderation">Moderation</Link> ·{' '} <Link href="/admin/radio/moderation">{t("moderation")}</Link> ·{' '}
<Link href="/admin/radio/settings">Settings</Link> ·{' '} <Link href="/admin/radio/settings">{t("settings")}</Link> ·{' '}
<Link href="/admin/radio/banners">Banners</Link> ·{' '} <Link href="/admin/radio/banners">{t("banners.title")}</Link> ·{' '}
<Link href="/admin/radio/ranks">Ranks</Link> ·{' '} <Link href="/admin/radio/ranks">{t("ranks")}</Link> ·{' '}
<Link href="/admin/radio/history">History</Link> <Link href="/admin/radio/history">{t("history")}</Link>
</nav> </nav>
<div className="flex items-center gap-3 mb-6"> <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)]" /> <Shield size={20} className="text-[var(--color-primary)]" />
</div> </div>
<div> <div>
<h1 className="m-0 text-xl font-extrabold text-[var(--color-text)]">DJ Moderation</h1> <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">Review and remove radio shouts (50 most recent).</p> <p className="m-0 text-xs text-[var(--color-text-muted)] mt-0.5">{t("moderationPage.subtitle")}</p>
</div> </div>
</div> </div>
<div className="grid grid-cols-[repeat(auto-fit,minmax(180px,1fr))] gap-3.5 mb-6"> <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> </div>
<section className="mt-6"> <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 ? ( {shouts.length === 0 ? (
<div className="admin-empty">No shouts to moderate.</div> <div className="admin-empty">{t("noShouts")}</div>
) : ( ) : (
<table> <table>
<thead> <thead>
<tr> <tr>
<th>ID</th> <th>ID</th>
<th>User</th> <th>{t("user")}</th>
<th>Message</th> <th>{t("message")}</th>
<th>Posted</th> <th>{t("posted")}</th>
<th /> <th />
</tr> </tr>
</thead> </thead>
@@ -87,7 +90,7 @@ export default async function AdminRadioModerationPage() {
<form action={deleteShout}> <form action={deleteShout}>
<input type="hidden" name="id" value={String(s.id)} /> <input type="hidden" name="id" value={String(s.id)} />
<button type="submit" className="btn btn-danger"> <button type="submit" className="btn btn-danger">
Delete {t("deleteShout")}
</button> </button>
</form> </form>
</td> </td>
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
File diff suppressed because it is too large. Load diff
+23 -21
View File
@@ -1,4 +1,5 @@
import Link from "next/link"; import Link from "next/link";
import { getTranslations } from "next-intl/server";
import { notFound } from "next/navigation"; import { notFound } from "next/navigation";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
import { DoorOpen } from "lucide-react"; import { DoorOpen } from "lucide-react";
@@ -10,8 +11,9 @@ export default async function AdminRoomDetail({
}: { }: {
params: Promise<{ id: string }>; params: Promise<{ id: string }>;
}) { }) {
const t = await getTranslations("pages.admin.rooms");
const { id } = await params; const { id } = await params;
// rooms is an emulator-owned table → Int id.
const roomId = Number(id); const roomId = Number(id);
if (!Number.isInteger(roomId) || roomId <= 0) notFound(); if (!Number.isInteger(roomId) || roomId <= 0) notFound();
@@ -25,36 +27,36 @@ export default async function AdminRoomDetail({
if (!room) notFound(); if (!room) notFound();
const fields: Array<[string, string]> = [ const fields: Array<[string, string]> = [
["ID", String(room.id)], [t("colId"), String(room.id)],
["Name", room.name || "(unnamed)"], [t("colName"), room.name || t("unnamed")],
["Description", room.description || "—"], [t("detail.description"), room.description || "—"],
["Owner", `${room.ownerName || "—"} (#${room.ownerId})`], [t("detail.owner"), `${room.ownerName || "—"} (#${room.ownerId})`],
["State", room.state], [t("detail.state"), room.state],
["Users", `${room.users} / ${room.usersMax}`], [t("colUsers"), `${room.users} / ${room.usersMax}`],
["Category", String(room.category)], [t("colCategory"), String(room.category)],
["Score", String(room.score)], [t("detail.score"), String(room.score)],
["Model", room.model], [t("detail.model"), room.model],
["Guild ID", room.guildId ? String(room.guildId) : "—"], [t("detail.guildId"), room.guildId ? String(room.guildId) : "—"],
["Tags", room.tags || "—"], [t("detail.tags"), room.tags || "—"],
["Password", room.password ? "yes" : "—"], [t("detail.password"), room.password ? t("detail.yes") : "—"],
["Public", room.isPublic === "1" ? "yes" : "no"], [t("detail.public"), room.isPublic === "1" ? t("detail.yes") : t("detail.no")],
["Staff picked", room.isStaffPicked === "1" ? "yes" : "no"], [t("detail.staffPicked"), room.isStaffPicked === "1" ? t("detail.yes") : t("detail.no")],
["For sale", room.isForSale === "1" ? "yes" : "no"], [t("detail.forSale"), room.isForSale === "1" ? t("detail.yes") : t("detail.no")],
["Trade mode", String(room.tradeMode)], [t("detail.tradeMode"), String(room.tradeMode)],
]; ];
return ( return (
<main> <main>
<p className="text-sm text-gray-500 dark:text-gray-400 mb-1"> <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> </p>
<div className="flex items-center gap-3 mb-6"> <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"> <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)]" /> <DoorOpen size={20} className="text-[var(--color-primary)]" />
</div> </div>
<div> <div>
<h1 className="m-0 text-xl font-extrabold text-[var(--color-text)]">{room.name || "(unnamed)"}</h1> <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">ID {room.id} · owner {room.ownerName || "—"} · {room.users}/{room.usersMax} users · {room.state}</p> <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>
</div> </div>
@@ -73,7 +75,7 @@ export default async function AdminRoomDetail({
{room.ownerId ? ( {room.ownerId ? (
<p className="muted" style={{ marginTop: "1rem" }}> <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> </p>
) : null} ) : null}
</main> </main>
File diff suppressed because it is too large. Load diff
+15 -12
View File
@@ -1,3 +1,4 @@
import { getTranslations } from "next-intl/server";
import { createSetting, deleteSetting, updateSetting } from "@/actions/admin-settings"; import { createSetting, deleteSetting, updateSetting } from "@/actions/admin-settings";
import { StatusCard } from "@/components/admin/dashboard"; import { StatusCard } from "@/components/admin/dashboard";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
@@ -5,6 +6,8 @@ import { prisma } from "@/lib/prisma";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
export default async function AdminSettings() { export default async function AdminSettings() {
const t = await getTranslations("pages.admin.settings");
let settings: Awaited<ReturnType<typeof prisma.websiteSetting.findMany>> = []; let settings: Awaited<ReturnType<typeof prisma.websiteSetting.findMany>> = [];
try { try {
settings = await prisma.websiteSetting.findMany({ orderBy: { key: "asc" } }); settings = await prisma.websiteSetting.findMany({ orderBy: { key: "asc" } });
@@ -21,36 +24,36 @@ export default async function AdminSettings() {
<span className="text-lg">⚙️</span> <span className="text-lg">⚙️</span>
</div> </div>
<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"> <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> </p>
</div> </div>
</div> </div>
<div className="admin-stat-row"> <div className="admin-stat-row">
<span className="admin-stat-chip">{settings.length} settings</span> <span className="admin-stat-chip">{t("settingsCount", { count: settings.length })}</span>
<span className="admin-stat-chip">{documented} with comment</span> <span className="admin-stat-chip">{t("documentedCount", { count: documented })}</span>
</div> </div>
<section> <section>
<h2 className="admin-section-title">Add / overwrite</h2> <h2 className="admin-section-title">{t("addOverwrite")}</h2>
<form action={createSetting} className="admin-card"> <form action={createSetting} className="admin-card">
<div className="flex gap-2 flex-wrap"> <div className="flex gap-2 flex-wrap">
<input name="key" placeholder="key" required /> <input name="key" placeholder={t("form.keyPlaceholder")} required />
<input name="value" placeholder="value" className="flex-1 min-w-[160px]" /> <input name="value" placeholder={t("form.valuePlaceholder")} className="flex-1 min-w-[160px]" />
<input name="comment" placeholder="comment" /> <input name="comment" placeholder={t("form.commentPlaceholder")} />
<button type="submit" className="btn btn-primary">Save</button> <button type="submit" className="btn btn-primary">{t("save")}</button>
</div> </div>
</form> </form>
</section> </section>
<section> <section>
<h2 className="admin-section-title"> <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> </h2>
{settings.length === 0 ? ( {settings.length === 0 ? (
<div className="admin-empty">No settings yet.</div> <div className="admin-empty">{t("noSettings")}</div>
) : ( ) : (
<div className="grid"> <div className="grid">
{settings.map((s) => ( {settings.map((s) => (
@@ -64,7 +67,7 @@ export default async function AdminSettings() {
<input type="hidden" name="key" value={s.key} /> <input type="hidden" name="key" value={s.key} />
<input name="value" defaultValue={s.value} style={{ flex: 1 }} /> <input name="value" defaultValue={s.value} style={{ flex: 1 }} />
<button type="submit" className="btn btn-outline"> <button type="submit" className="btn btn-outline">
Save {t("save")}
</button> </button>
</form> </form>
<form action={deleteSetting}> <form action={deleteSetting}>
+18 -15
View File
@@ -1,4 +1,5 @@
import Link from "next/link"; import Link from "next/link";
import { getTranslations } from "next-intl/server";
import { notFound } from "next/navigation"; import { notFound } from "next/navigation";
import { deleteShopArticle, updateShopArticle } from "@/actions/admin-shop"; import { deleteShopArticle, updateShopArticle } from "@/actions/admin-shop";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
@@ -11,6 +12,8 @@ export default async function EditShopArticle({
}: { }: {
params: Promise<{ id: string }>; params: Promise<{ id: string }>;
}) { }) {
const t = await getTranslations("pages.admin.shop");
const { id } = await params; const { id } = await params;
let pkg: Awaited<ReturnType<typeof prisma.websiteShopArticles.findUnique>> = null; let pkg: Awaited<ReturnType<typeof prisma.websiteShopArticles.findUnique>> = null;
try { try {
@@ -27,9 +30,9 @@ export default async function EditShopArticle({
<Package size={20} className="text-[var(--color-primary)]" /> <Package size={20} className="text-[var(--color-primary)]" />
</div> </div>
<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)]"> <Link href="/admin/shop" className="text-xs text-[var(--color-text-muted)] hover:text-[var(--color-primary)]">
← Back to shop ← {t("backToShop")}
</Link> </Link>
</div> </div>
</div> </div>
@@ -37,26 +40,26 @@ export default async function EditShopArticle({
<form action={updateShopArticle} className="admin-card"> <form action={updateShopArticle} className="admin-card">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4"> <div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<input type="hidden" name="id" value={String(pkg.id)} /> <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="name" defaultValue={pkg.name} placeholder={t("form.namePlaceholder")} required maxLength={255} className="w-full" />
<input name="info" defaultValue={pkg.info} placeholder="Info / description" 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="Icon URL" 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="Color (e.g. #ffcc00)" 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="Costs (store price)" required 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="Credits given (optional)" 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="Duckets given (optional)" 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="Diamonds given (optional)" 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="Give rank (optional)" 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="Badge codes (optional)" maxLength={255} 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="Position" className="w-full" /> <input name="position" type="number" min={0} defaultValue={pkg.position} placeholder={t("form.positionPlaceholder")} className="w-full" />
</div> </div>
<div className="mt-6 flex gap-2"> <div className="mt-6 flex gap-2">
<button type="submit" className="btn btn-primary"> <button type="submit" className="btn btn-primary">
Save {t("form.save")}
</button> </button>
<form action={deleteShopArticle}> <form action={deleteShopArticle}>
<input type="hidden" name="id" value={String(pkg.id)} /> <input type="hidden" name="id" value={String(pkg.id)} />
<button type="submit" className="btn btn-danger"> <button type="submit" className="btn btn-danger">
Delete package {t("form.deletePackage")}
</button> </button>
</form> </form>
</div> </div>
+18 -15
View File
@@ -1,8 +1,11 @@
import Link from "next/link"; import Link from "next/link";
import { getTranslations } from "next-intl/server";
import { createShopArticle } from "@/actions/admin-shop"; import { createShopArticle } from "@/actions/admin-shop";
import { Package } from "lucide-react"; import { Package } from "lucide-react";
export default function NewShopArticle() { export default async function NewShopArticle() {
const t = await getTranslations("pages.admin.shop");
return ( return (
<main> <main>
<div className="flex items-center gap-3 mb-6"> <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)]" /> <Package size={20} className="text-[var(--color-primary)]" />
</div> </div>
<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)]"> <Link href="/admin/shop" className="text-xs text-[var(--color-text-muted)] hover:text-[var(--color-primary)]">
← Back to shop ← {t("backToShop")}
</Link> </Link>
</div> </div>
</div> </div>
<form action={createShopArticle} className="admin-card"> <form action={createShopArticle} className="admin-card">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4"> <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="name" placeholder={t("form.namePlaceholder")} required maxLength={255} className="w-full" />
<input name="info" placeholder="Info / description" maxLength={255} className="w-full" /> <input name="info" placeholder={t("form.infoPlaceholder")} maxLength={255} className="w-full" />
<input name="icon" placeholder="Icon" maxLength={255} className="w-full" /> <input name="icon" placeholder={t("form.iconPlaceholder")} maxLength={255} className="w-full" />
<input name="color" placeholder="Color (e.g. #ffcc00)" 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="Costs (store price)" required 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="Credits given (optional)" 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="Duckets given (optional)" 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="Diamonds given (optional)" 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="Give rank (optional)" className="w-full" /> <input name="giveRank" type="number" min={0} placeholder={t("form.giveRankPlaceholder")} className="w-full" />
<input name="badges" placeholder="Badge codes (optional)" maxLength={255} className="w-full" /> <input name="badges" placeholder={t("form.badgesPlaceholder")} maxLength={255} className="w-full" />
<input name="position" type="number" min={0} placeholder="Position" defaultValue={0} className="w-full" /> <input name="position" type="number" min={0} placeholder={t("form.positionPlaceholder")} defaultValue={0} className="w-full" />
</div> </div>
<div className="mt-6 text-right"> <div className="mt-6 text-right">
<button type="submit" className="btn btn-primary"> <button type="submit" className="btn btn-primary">
Create {t("form.create")}
</button> </button>
</div> </div>
</form> </form>
File diff suppressed because it is too large. Load diff
+15 -16
View File
@@ -1,4 +1,5 @@
import { CreditCard } from "lucide-react"; import { CreditCard } from "lucide-react";
import { getTranslations } from "next-intl/server";
import { StatusCard } from "@/components/admin/dashboard"; import { StatusCard } from "@/components/admin/dashboard";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
@@ -16,12 +17,11 @@ function fmt(ts: number): string {
} }
export default async function AdminSubscriptions() { export default async function AdminSubscriptions() {
const t = await getTranslations("pages.admin.subscriptions");
const now = Math.floor(Date.now() / 1000); const now = Math.floor(Date.now() / 1000);
let active: SubRow[] = []; let active: SubRow[] = [];
try { 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({ const rows = await prisma.usersSubscriptions.findMany({
where: { active: 1 }, where: { active: 1 },
orderBy: { id: "desc" }, orderBy: { id: "desc" },
@@ -41,7 +41,6 @@ export default async function AdminSubscriptions() {
active = []; active = [];
} }
// "Expiring soon" = within the next 7 days.
const soonCutoff = now + 7 * 24 * 60 * 60; const soonCutoff = now + 7 * 24 * 60 * 60;
const expiringSoon = active.filter((s) => s.expiresAt <= soonCutoff).length; const expiringSoon = active.filter((s) => s.expiresAt <= soonCutoff).length;
const types = new Set(active.map((s) => s.subscriptionType ?? "—")).size; 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)]" /> <CreditCard size={20} className="text-[var(--color-primary)]" />
</div> </div>
<div> <div>
<h1 className="m-0 text-xl font-extrabold text-[var(--color-text)]">Subscriptions</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">Active Habbo Club / VIP subscriptions (expiry &gt; now). Read-only.</p> <p className="m-0 text-xs text-[var(--color-text-muted)] mt-0.5">{t("subtitle")}</p>
</div> </div>
</div> </div>
<div className="grid grid-cols-[repeat(auto-fit,minmax(180px,1fr))] gap-3.5 mb-6"> <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={t("activeSubs")} value={active.length} icon="⭐" />
<StatusCard label="Types" value={types} icon="🏷️" /> <StatusCard label={t("types")} value={types} icon="🏷️" />
<StatusCard <StatusCard
label="Expiring ≤ 7d" label={t("expiringSoon")}
value={expiringSoon} value={expiringSoon}
state={expiringSoon > 0 ? "warn" : "ok"} state={expiringSoon > 0 ? "warn" : "ok"}
icon="⏳" icon="⏳"
@@ -70,16 +69,16 @@ export default async function AdminSubscriptions() {
</div> </div>
<section className="mt-6"> <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"> <div className="admin-card">
<table> <table>
<thead> <thead>
<tr> <tr>
<th>Sub ID</th> <th>{t("colSubId")}</th>
<th>User ID</th> <th>{t("colUserId")}</th>
<th>Type</th> <th>{t("colType")}</th>
<th>Status</th> <th>{t("colStatus")}</th>
<th>Expires</th> <th>{t("colExpires")}</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@@ -92,7 +91,7 @@ export default async function AdminSubscriptions() {
<td>{s.subscriptionType ?? "—"}</td> <td>{s.subscriptionType ?? "—"}</td>
<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)]"}`}> <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> </span>
</td> </td>
<td className="text-sm text-gray-500 dark:text-gray-400 whitespace-nowrap">{fmt(s.expiresAt)}</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> </tbody>
</table> </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> </div>
</section> </section>
</main> </main>
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
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
+14 -11
View File
@@ -1,3 +1,4 @@
import { getTranslations } from "next-intl/server";
import { Filter } from "lucide-react"; import { Filter } from "lucide-react";
import { addWord, deleteWord } from "@/actions/admin-wordfilter"; import { addWord, deleteWord } from "@/actions/admin-wordfilter";
import { StatusCard } from "@/components/admin/dashboard"; import { StatusCard } from "@/components/admin/dashboard";
@@ -8,6 +9,8 @@ export const dynamic = "force-dynamic";
type WordRow = { id: bigint; word: string }; type WordRow = { id: bigint; word: string };
export default async function AdminWordFilter() { export default async function AdminWordFilter() {
const t = await getTranslations("pages.admin.wordfilter");
let words: WordRow[] = []; let words: WordRow[] = [];
try { try {
words = await prisma.websiteWordfilter.findMany({ words = await prisma.websiteWordfilter.findMany({
@@ -26,28 +29,28 @@ export default async function AdminWordFilter() {
<Filter size={20} className="text-[var(--color-primary)]" /> <Filter size={20} className="text-[var(--color-primary)]" />
</div> </div>
<div> <div>
<h1 className="m-0 text-xl font-extrabold text-[var(--color-text)]">Word Filter</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">Words added here are blocked in-game. Saving pushes the list to the emulator.</p> <p className="m-0 text-xs text-[var(--color-text-muted)] mt-0.5">{t("subtitle")}</p>
</div> </div>
</div> </div>
<div className="grid grid-cols-[repeat(auto-fit,minmax(180px,1fr))] gap-3.5 mb-6"> <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> </div>
<section className="mt-6"> <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"> <form action={addWord} className="admin-card">
<div className="flex gap-2 flex-wrap"> <div className="flex gap-2 flex-wrap">
<input <input
name="word" name="word"
placeholder="Word to filter" placeholder={t("wordToFilter")}
required required
maxLength={255} maxLength={255}
className="flex-1 min-w-[200px]" className="flex-1 min-w-[200px]"
/> />
<button type="submit" className="btn btn-primary"> <button type="submit" className="btn btn-primary">
Add {t("add")}
</button> </button>
</div> </div>
</form> </form>
@@ -55,17 +58,17 @@ export default async function AdminWordFilter() {
<section className="mt-6"> <section className="mt-6">
<h2 className="admin-section-title"> <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> </h2>
{words.length === 0 ? ( {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" }}> <div className="admin-card" style={{ padding: 0, overflowX: "auto" }}>
<table> <table>
<thead> <thead>
<tr> <tr>
<th>ID</th> <th>{t("colId")}</th>
<th>Word</th> <th>{t("colWord")}</th>
<th /> <th />
</tr> </tr>
</thead> </thead>
@@ -80,7 +83,7 @@ export default async function AdminWordFilter() {
<form action={deleteWord}> <form action={deleteWord}>
<input type="hidden" name="id" value={String(w.id)} /> <input type="hidden" name="id" value={String(w.id)} />
<button type="submit" className="btn btn-danger"> <button type="submit" className="btn btn-danger">
Delete {t("delete")}
</button> </button>
</form> </form>
</td> </td>
File diff suppressed because it is too large. Load diff
+1308
View File
File diff suppressed because it is too large. Load diff
+1309
View File
File diff suppressed because it is too large. Load diff
+1308
View File
File diff suppressed because it is too large. Load diff
+1308
View File
File diff suppressed because it is too large. Load diff
+1308
View File
File diff suppressed because it is too large. Load diff
+1308
View File
File diff suppressed because it is too large. Load diff