Revert "Merge pull request 'Complete Housekeeping migration and /ase cutover' (#52) from codex/housekeeping-complete into main"
CI / check (push) Successful in 27s
CI / release (push) Skipped
CI / deploy (push) Successful in 43s

This reverts commit 488b6e57c4, reversing
changes made to b506b4499a.
This commit is contained in:
Simo committed 2026-08-30 21:31:34 +02:00
1 parent 488b6e57c4
commit b1ddda66ff
802 files changed
+61370 -76659

No files matched your search

+1 -1
View File
@@ -8,7 +8,7 @@ import { TopHeader } from "@/components/top-header";
import { auth } from "@/lib/auth";
/**
* Public site chrome. Route group `(site)` keeps this off `/ase` and `/client`,
* Public site chrome. Route group `(site)` keeps this off `/admin` and `/client`,
* so housekeeping is never constrained by the public max-w-7xl grid.
*/
export default async function SiteLayout({
@@ -5,8 +5,8 @@ import { satisfiesCapability } from "@/features/housekeeping/foundation/capabili
import { buildHousekeepingNavigation } from "@/features/housekeeping/foundation/navigation";
import { createHousekeepingRegistry } from "@/features/housekeeping/foundation/registry";
import { getHousekeepingCapabilityContext } from "@/features/housekeeping/foundation/server-capability-context";
import { HousekeepingShell } from "@/features/housekeeping/foundation/shell/housekeeping-shell";
import { HOUSEKEEPING_MANIFESTS } from "@/features/housekeeping/manifests";
import { HousekeepingShell } from "@/features/housekeeping/shell";
const MESSAGE_PREFIX = "pages.housekeeping.";
@@ -18,7 +18,7 @@ function namespaceKey(key: string): string {
return key.slice(MESSAGE_PREFIX.length);
}
export default async function HousekeepingDomainLayout({
export default async function AdminNextDomainLayout({
children,
params,
}: {
@@ -49,20 +49,8 @@ export default async function HousekeepingDomainLayout({
primaryNavigation: translate("navigation.primary"),
contextualNavigation: translate("navigation.contextual"),
command: translate("preview.commandDisabled"),
preview: translate("preview.badge"),
backToSite: translate("preview.backToSite"),
operatorRank: translate("navigation.operatorRank", {
rank: context.actor.rank,
}),
commandDeck: {
placeholder: translate("commandDeck.placeholder"),
navigation: translate("commandDeck.navigation"),
commands: translate("commandDeck.commands"),
entities: translate("commandDeck.entities"),
loading: translate("commandDeck.loading"),
empty: translate("commandDeck.empty"),
partial: translate("commandDeck.partial"),
close: translate("commandDeck.close"),
},
}}
>
{children}
+45
View File
@@ -0,0 +1,45 @@
import { notFound } from "next/navigation";
import { getTranslations } from "next-intl/server";
import { HousekeepingPageShell } from "@/features/housekeeping/foundation/page/housekeeping-page-shell";
import { HousekeepingPageState } from "@/features/housekeeping/foundation/page/housekeeping-page-state";
import { createHousekeepingRegistry } from "@/features/housekeeping/foundation/registry";
import { HOUSEKEEPING_MANIFESTS } from "@/features/housekeeping/manifests";
const MESSAGE_PREFIX = "pages.housekeeping.";
function namespaceKey(key: string): string {
if (!key.startsWith(MESSAGE_PREFIX)) {
throw new Error(`invalid housekeeping message key: ${key}`);
}
return key.slice(MESSAGE_PREFIX.length);
}
export default async function AdminNextDomainPage({
params,
}: {
params: Promise<{ domain: string }>;
}) {
const { domain } = await params;
const registry = createHousekeepingRegistry(HOUSEKEEPING_MANIFESTS);
const activeDomain = registry.domains.find((entry) => entry.id === domain);
if (!activeDomain) notFound();
const translate = await getTranslations("pages.housekeeping");
return (
<HousekeepingPageShell
title={translate(namespaceKey(activeDomain.labelKey) as never)}
description={translate(
namespaceKey(activeDomain.descriptionKey) as never,
)}
>
<HousekeepingPageState
state="empty"
title={translate("states.empty.title")}
description={translate("states.empty.description")}
/>
</HousekeepingPageShell>
);
}
+17
View File
@@ -0,0 +1,17 @@
import { notFound } from "next/navigation";
import type { ReactNode } from "react";
import { env } from "@/env";
import { isHousekeepingPreviewEnabled } from "@/features/housekeeping/foundation/preview-gate";
export default function AdminNextLayout({ children }: { children: ReactNode }) {
if (
!isHousekeepingPreviewEnabled({
nodeEnv: env.NODE_ENV,
flag: env.HOUSEKEEPING_NEXT_PREVIEW_ENABLED,
})
) {
notFound();
}
return children;
}
+17
View File
@@ -0,0 +1,17 @@
import { notFound, redirect } from "next/navigation";
import { satisfiesCapability } from "@/features/housekeeping/foundation/capability-context";
import { createHousekeepingRegistry } from "@/features/housekeeping/foundation/registry";
import { getHousekeepingCapabilityContext } from "@/features/housekeeping/foundation/server-capability-context";
import { HOUSEKEEPING_MANIFESTS } from "@/features/housekeeping/manifests";
export default async function AdminNextPage() {
const context = await getHousekeepingCapabilityContext();
const registry = createHousekeepingRegistry(HOUSEKEEPING_MANIFESTS);
const firstVisibleDomain = registry.domains.find((domain) =>
satisfiesCapability(context, domain.capability),
);
if (!firstVisibleDomain) notFound();
redirect(firstVisibleDomain.previewHref);
}
+117
View File
@@ -0,0 +1,117 @@
import { asc } from "drizzle-orm";
import { redirect } from "next/navigation";
import { getTranslations } from "next-intl/server";
import { StatusCard } from "@/components/admin/dashboard";
import { Achievements, db } from "@/lib/db";
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
type Achievement = {
name: string;
category: string;
level: number;
rewardAmount: number;
rewardType: number;
points: number | null;
progressNeeded: number;
};
export default async function AdminAchievements() {
const { session, permissions } = await getAdminContext();
if (!canAccess(permissions, PERMS.CATALOG_VIEW, session.user.rank)) {
redirect("/admin");
}
const t = await getTranslations("pages.admin.achievements");
let achievements: Achievement[];
try {
achievements = await db
.select({
name: Achievements.name,
category: Achievements.category,
level: Achievements.level,
rewardAmount: Achievements.rewardAmount,
rewardType: Achievements.rewardType,
points: Achievements.points,
progressNeeded: Achievements.progressNeeded,
})
.from(Achievements)
.orderBy(
asc(Achievements.category),
asc(Achievements.name),
asc(Achievements.level),
);
} catch {
achievements = [];
}
const groups = new Map<string, Achievement[]>();
for (const a of achievements) {
const list = groups.get(a.category) ?? [];
list.push(a);
groups.set(a.category, list);
}
const distinctNames = new Set(achievements.map((a) => a.name)).size;
return (
<main>
<div className="grid grid-cols-[repeat(auto-fit,minmax(180px,1fr))] gap-3.5 mb-6">
<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">{t("noAchievements")}</div>
) : (
[...groups.entries()].map(([category, rows]) => (
<section key={category} className="mt-6">
<h2 className="admin-section-title">
{category}{" "}
<span className="inline-block text-[0.72rem] font-bold px-2 py-0.5 rounded-full bg-[var(--admin-accent)]/18 text-[var(--admin-text)]">
{rows.length}
</span>
</h2>
<div className="admin-card p-0 overflow-x-auto">
<table>
<thead>
<tr>
<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>
{rows.map((a) => (
<tr key={`${a.name}-${a.level}`}>
<td>
<strong>{a.name}</strong>
</td>
<td>{a.level}</td>
<td>{a.progressNeeded}</td>
<td>{a.rewardType}</td>
<td>{a.rewardAmount}</td>
<td>{a.points ?? 0}</td>
</tr>
))}
</tbody>
</table>
</div>
</section>
))
)}
</main>
);
}
+71
View File
@@ -0,0 +1,71 @@
import { eq } from "drizzle-orm";
import { Megaphone } from "lucide-react";
import { notFound, redirect } from "next/navigation";
import { getTranslations } from "next-intl/server";
import { updateAd } from "@/actions/admin-ads";
import Link from "@/components/link";
import { Button } from "@/components/ui/button";
import { db, WebsiteAds } from "@/lib/db";
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
import { EditAdDeleteButton } from "../edit-ad-delete-button";
export default async function EditAd({
params,
}: {
params: Promise<{ id: string }>;
}) {
const { session, permissions } = await getAdminContext();
if (!canAccess(permissions, PERMS.PAGES_VIEW, session.user.rank)) {
redirect("/admin");
}
const { id } = await params;
let ad: typeof WebsiteAds.$inferSelect | null = null;
try {
const [row] = await db
.select()
.from(WebsiteAds)
.where(eq(WebsiteAds.id, BigInt(id)))
.limit(1);
ad = row ?? null;
} catch {
notFound();
}
if (!ad) notFound();
const t = await getTranslations("pages.admin.ads");
return (
<main>
<div className="flex items-center gap-3 mb-6">
<div className="w-10 h-10 rounded-xl bg-gradient-to-br from-[var(--admin-accent)]/20 to-[var(--admin-accent)]/5 grid place-items-center">
<Megaphone size={20} className="text-[var(--admin-accent)]" />
</div>
<div>
<h1 className="m-0 text-xl font-extrabold text-[var(--admin-text)]">
{t("edit")} #{String(ad.id)}
</h1>
<p className="m-0 text-xs text-[var(--admin-text-muted)] mt-0.5">
<Link href="/admin/ads">← {t("title")}</Link>
</p>
</div>
</div>
<form action={updateAd} className="admin-card">
<input type="hidden" name="id" value={String(ad.id)} />
<input
name="image"
defaultValue={ad.image}
placeholder={t("form.imagePlaceholder")}
required
maxLength={255}
/>
<Button type="submit" variant="default">
{t("form.save")}
</Button>
</form>
<div style={{ marginTop: "1rem" }}>
<EditAdDeleteButton id={String(ad.id)} />
</div>
</main>
);
}
+86
View File
@@ -0,0 +1,86 @@
"use client";
import { Trash2 } from "lucide-react";
import { useTranslations } from "next-intl";
import { deleteAd } from "@/actions/admin-ads";
import { useConfirmDialog } from "@/components/admin/confirm-dialog";
import Link from "@/components/link";
import { Button } from "@/components/ui/button";
import { useServerAction } from "@/hooks/use-server-action";
export type AdRow = {
id: string;
image: string;
createdAt: string | null;
};
export function AdsTable({ ads }: { ads: AdRow[] }) {
const t = useTranslations("pages.admin.ads");
const { run, isPending } = useServerAction();
const { confirm, dialog: confirmDialog } = useConfirmDialog();
async function handleDelete(row: AdRow) {
const ok = await confirm({
title: t("delete"),
description: t("confirmDelete"),
confirmLabel: t("delete"),
cancelLabel: t("form.cancel"),
});
if (!ok) return;
run(() => deleteAd({ id: row.id }), {
successMessage: t("deleteSuccess"),
errorMessage: t("deleteError"),
});
}
return (
<>
{confirmDialog}
<table>
<thead>
<tr>
<th>{t("colPreview")}</th>
<th>{t("colImage")}</th>
<th>{t("colCreated")}</th>
<th>{t("colActions")}</th>
</tr>
</thead>
<tbody>
{ads.map((ad) => (
<tr key={ad.id}>
<td>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={ad.image}
alt=""
style={{ maxHeight: 40, maxWidth: 120 }}
/>
</td>
<td className="text-sm theme-text-muted dark:theme-text-muted whitespace-nowrap">
<code>{ad.image}</code>
</td>
<td className="text-sm theme-text-muted dark:theme-text-muted whitespace-nowrap">
{ad.createdAt ?? "—"}
</td>
<td>
<div className="flex items-center gap-2">
<Link href={`/admin/ads/${ad.id}`}>{t("edit")}</Link>
<Button
type="button"
variant="destructive"
size="sm"
disabled={isPending}
onClick={() => handleDelete(ad)}
>
<Trash2 className="h-3.5 w-3.5" />
{t("delete")}
</Button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</>
);
}
@@ -0,0 +1,44 @@
"use client";
import { Trash2 } from "lucide-react";
import { useTranslations } from "next-intl";
import { deleteAd } from "@/actions/admin-ads";
import { useConfirmDialog } from "@/components/admin/confirm-dialog";
import { Button } from "@/components/ui/button";
import { useServerAction } from "@/hooks/use-server-action";
export function EditAdDeleteButton({ id }: { id: string }) {
const t = useTranslations("pages.admin.ads");
const { run, isPending } = useServerAction();
const { confirm, dialog: confirmDialog } = useConfirmDialog();
async function handleDelete() {
const ok = await confirm({
title: t("delete"),
description: t("confirmDelete"),
confirmLabel: t("delete"),
cancelLabel: t("form.cancel"),
});
if (!ok) return;
run(() => deleteAd({ id }), {
successMessage: t("deleteSuccess"),
errorMessage: t("deleteError"),
redirectTo: "/admin/ads",
});
}
return (
<>
{confirmDialog}
<Button
type="button"
variant="destructive"
disabled={isPending}
onClick={handleDelete}
>
<Trash2 className="mr-2 h-4 w-4" />
{t("form.deleteAd")}
</Button>
</>
);
}
+37
View File
@@ -0,0 +1,37 @@
import { Megaphone } from "lucide-react";
import { redirect } from "next/navigation";
import { createAd } from "@/actions/admin-ads";
import Link from "@/components/link";
import { Button } from "@/components/ui/button";
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
export default async function NewAd() {
const { session, permissions } = await getAdminContext();
if (!canAccess(permissions, PERMS.PAGES_EDIT, session.user.rank)) {
redirect("/admin");
}
return (
<main>
<div className="flex items-center gap-3 mb-6">
<div className="w-10 h-10 rounded-xl bg-gradient-to-br from-[var(--admin-accent)]/20 to-[var(--admin-accent)]/5 grid place-items-center">
<Megaphone size={20} className="text-[var(--admin-accent)]" />
</div>
<div>
<h1 className="m-0 text-xl font-extrabold text-[var(--admin-text)]">
New advertisement
</h1>
<p className="m-0 text-xs text-[var(--admin-text-muted)] mt-0.5">
<Link href="/admin/ads">← Advertisements</Link>
</p>
</div>
</div>
<form action={createAd} className="admin-card">
<input name="image" placeholder="Image URL" required maxLength={255} />
<Button type="submit" variant="default">
Create
</Button>
</form>
</main>
);
}
+66
View File
@@ -0,0 +1,66 @@
import { desc } from "drizzle-orm";
import { redirect } from "next/navigation";
import { getTranslations } from "next-intl/server";
import { StatusCard } from "@/components/admin/dashboard";
import Link from "@/components/link";
import { Button } from "@/components/ui/button";
import { db, WebsiteAds } from "@/lib/db";
import { formatDate } from "@/lib/format-date";
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
import { AdsTable } from "./ads-table";
export default async function AdminAds() {
const { session, permissions } = await getAdminContext();
if (!canAccess(permissions, PERMS.PAGES_VIEW, session.user.rank)) {
redirect("/admin");
}
const t = await getTranslations("pages.admin.ads");
let ads: (typeof WebsiteAds.$inferSelect)[] = [];
try {
ads = await db
.select()
.from(WebsiteAds)
.orderBy(desc(WebsiteAds.id))
.limit(500);
} catch {
ads = [];
}
const latest = ads[0]?.createdAt ? formatDate(ads[0].createdAt, "date") : "—";
return (
<main>
<div className="flex justify-end mb-6">
<Button variant="default" asChild>
<Link href="/admin/ads/new">+ {t("newAd")}</Link>
</Button>
</div>
<div className="grid grid-cols-[repeat(auto-fit,minmax(180px,1fr))] gap-3.5 mb-6">
<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">{t("allAds")}</h2>
{ads.length === 0 ? (
<div className="admin-empty">{t("noAds")}</div>
) : (
<AdsTable
ads={ads.map((ad) => ({
id: String(ad.id),
image: ad.image,
createdAt: ad.createdAt ? formatDate(ad.createdAt, "date") : null,
}))}
/>
)}
</section>
</main>
);
}
+1
View File
@@ -0,0 +1 @@
export { default } from "../loading";
+179
View File
@@ -0,0 +1,179 @@
import { desc } from "drizzle-orm";
import { redirect } from "next/navigation";
import { getTranslations } from "next-intl/server";
import { markAllAlertsRead, sendHotelAlert } from "@/actions/admin-alerts";
import { StatusCard } from "@/components/admin/dashboard";
import { Button } from "@/components/ui/button";
import { AlertLogs, db } from "@/lib/db";
import { formatDate } from "@/lib/format-date";
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
const CRITICAL = new Set(["critical", "error", "danger"]);
const WARNING = new Set(["warning", "warn"]);
function severityColor(severity: string): string {
switch (severity.toLowerCase()) {
case "critical":
case "error":
case "danger":
return "var(--color-danger)";
case "warning":
case "warn":
return "var(--admin-accent)";
case "success":
return "var(--color-secondary)";
case "info":
case "notice":
return "var(--color-accent)";
default:
return "var(--admin-text-muted)";
}
}
function Badge({ label, color }: { label: string; color: string }) {
return (
<span
style={{
display: "inline-block",
padding: "0.1rem 0.55rem",
borderRadius: "999px",
background: color,
color: "#fff",
fontSize: "0.72rem",
fontWeight: 700,
letterSpacing: "0.02em",
textTransform: "uppercase",
whiteSpace: "nowrap",
}}
>
{label}
</span>
);
}
export default async function AdminAlerts() {
const { session, permissions } = await getAdminContext();
if (!canAccess(permissions, PERMS.NOTIFICATIONS_VIEW, session.user.rank)) {
redirect("/admin");
}
const t = await getTranslations("pages.admin.alerts");
let alerts: (typeof AlertLogs.$inferSelect)[] = [];
try {
alerts = await db
.select()
.from(AlertLogs)
.orderBy(desc(AlertLogs.id))
.limit(100);
} catch {
alerts = [];
}
const critical = alerts.filter((a) =>
CRITICAL.has((a.severity || "").toLowerCase()),
).length;
const warnings = alerts.filter((a) =>
WARNING.has((a.severity || "").toLowerCase()),
).length;
const unread = alerts.filter((a) => !a.isRead).length;
return (
<main>
<div className="grid grid-cols-[repeat(auto-fit,minmax(180px,1fr))] gap-3.5 mb-6">
<StatusCard label={t("alertsShown")} value={alerts.length} icon="🔔" />
<StatusCard
label={t("critical")}
value={critical}
state={critical > 0 ? "danger" : "ok"}
icon="🚨"
/>
<StatusCard
label={t("warnings")}
value={warnings}
state={warnings > 0 ? "warn" : "neutral"}
icon="⚠️"
/>
<StatusCard
label={t("unread")}
value={unread}
state={unread > 0 ? "warn" : "ok"}
icon="📨"
/>
</div>
<form action={sendHotelAlert} className="admin-card mb-6">
<h3 className="mt-0">{t("form.send")}</h3>
<p className="text-xs theme-text-muted dark:theme-text-muted">
{t.rich("form.description", {
code: (chunks) => <code>{chunks}</code>,
})}
</p>
<div className="flex gap-2 flex-wrap items-start">
<textarea
name="message"
required
maxLength={1000}
rows={2}
placeholder={t("form.messagePlaceholder")}
className="flex-1 min-w-[240px]"
/>
<Button type="submit" variant="default">
{t("form.send")}
</Button>
</div>
</form>
<section className="admin-card">
<div className="mb-3 flex flex-wrap items-center justify-between gap-2">
<h3 className="m-0">{t("recentAlerts", { count: alerts.length })}</h3>
{unread > 0 ? (
<form action={markAllAlertsRead}>
<Button type="submit" variant="outline" size="sm">
{t("markAllRead")}
</Button>
</form>
) : null}
</div>
{alerts.length === 0 ? (
<div className="admin-empty">{t("noAlerts")}</div>
) : (
<div className="overflow-x-auto">
<table>
<thead>
<tr>
<th>{t("colWhen")}</th>
<th>{t("colSeverity")}</th>
<th>{t("colType")}</th>
<th>{t("colMessage")}</th>
</tr>
</thead>
<tbody>
{alerts.map((a) => (
<tr key={String(a.id)}>
<td className="text-sm theme-text-muted dark:theme-text-muted whitespace-nowrap">
{formatDate(a.createdAt)}
</td>
<td>
<Badge
label={a.severity || "—"}
color={severityColor(a.severity)}
/>
</td>
<td>
<Badge
label={a.type || "—"}
color="var(--admin-text-muted)"
/>
</td>
<td>{a.message}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</section>
</main>
);
}
+333
View File
@@ -0,0 +1,333 @@
import { count, gte, inArray, sql } from "drizzle-orm";
import { MessageSquare, Shield, Terminal, Users } from "lucide-react";
import { redirect } from "next/navigation";
import { getLocale, getTranslations } from "next-intl/server";
import { StatsCard } from "@/components/admin/stats-card";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Ban, db, User } from "@/lib/db";
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
import { redisCache } from "@/lib/redis-cache";
type DayRow = {
label: string;
chats: number;
commands: number;
bans: number;
registrations: number;
};
type ActivityData = {
dailyData: DayRow[];
weekChats: number;
weekCommands: number;
weekBans: number;
weekRegistrations: number;
topChatters: { userId: number; username: string; messages: number }[];
};
async function safeCount(
fn: () => Promise<number>,
): Promise<{ value: number; ok: boolean }> {
try {
return { value: await fn(), ok: true };
} catch {
return { value: 0, ok: false };
}
}
async function rawRows<T>(query: ReturnType<typeof sql>): Promise<T[]> {
const [rows] = (await db.execute(query)) as unknown as [T[], unknown];
return rows ?? [];
}
async function safeRawCount(
query: ReturnType<typeof sql>,
): Promise<{ value: number; ok: boolean }> {
try {
const rows = await rawRows<{ count: bigint }>(query);
return { value: Number(rows[0]?.count ?? 0), ok: true };
} catch {
return { value: 0, ok: false };
}
}
async function getActivityData(
locale: string,
): Promise<ActivityData & { degraded: boolean }> {
const now = Math.floor(Date.now() / 1000);
const todayStart = now - (now % 86400);
const weekAgo = now - 7 * 86400;
const rangeStart = todayStart - 6 * 86400;
let degraded = false;
const emptyDays: DayRow[] = [];
for (let i = 6; i >= 0; i--) {
const dayStart = todayStart - i * 86400;
const date = new Date(dayStart * 1000);
emptyDays.push({
label: date.toLocaleDateString(locale, {
weekday: "short",
month: "short",
day: "numeric",
}),
chats: 0,
commands: 0,
bans: 0,
registrations: 0,
});
}
const toMap = (rows: { day_bucket: bigint; count: bigint }[]) =>
new Map(rows.map((r) => [Number(r.day_bucket), Number(r.count)]));
let chatMap = new Map<number, number>();
let cmdMap = new Map<number, number>();
let banMap = new Map<number, number>();
let regMap = new Map<number, number>();
try {
const [chatRows, cmdRows, banRows, regRows] = await Promise.all([
rawRows<{ day_bucket: bigint; count: bigint }>(sql`
SELECT FLOOR(timestamp / 86400) AS day_bucket, COUNT(*) AS count
FROM chatlogs_room WHERE timestamp >= ${rangeStart} GROUP BY day_bucket`).catch(
() => [],
),
rawRows<{ day_bucket: bigint; count: bigint }>(sql`
SELECT FLOOR(timestamp / 86400) AS day_bucket, COUNT(*) AS count
FROM commandlogs WHERE timestamp >= ${rangeStart} GROUP BY day_bucket`).catch(
() => [],
),
rawRows<{ day_bucket: bigint; count: bigint }>(sql`
SELECT FLOOR(timestamp / 86400) AS day_bucket, COUNT(*) AS count
FROM bans WHERE timestamp >= ${rangeStart} GROUP BY day_bucket`).catch(
() => [],
),
rawRows<{ day_bucket: bigint; count: bigint }>(sql`
SELECT FLOOR(account_created / 86400) AS day_bucket, COUNT(*) AS count
FROM users WHERE account_created >= ${rangeStart} GROUP BY day_bucket`).catch(
() => [],
),
]);
chatMap = toMap(chatRows);
cmdMap = toMap(cmdRows);
banMap = toMap(banRows);
regMap = toMap(regRows);
} catch {
degraded = true;
}
const dailyData = emptyDays.map((day, idx) => {
const dayStart = todayStart - (6 - idx) * 86400;
const bucket = Math.floor(dayStart / 86400);
return {
...day,
chats: chatMap.get(bucket) ?? 0,
commands: cmdMap.get(bucket) ?? 0,
bans: banMap.get(bucket) ?? 0,
registrations: regMap.get(bucket) ?? 0,
};
});
const [weekChats, weekCommands, weekBans, weekRegistrations] =
await Promise.all([
safeRawCount(sql`
SELECT COUNT(*) AS count FROM chatlogs_room WHERE timestamp >= ${weekAgo}`),
safeRawCount(sql`
SELECT COUNT(*) AS count FROM commandlogs WHERE timestamp >= ${weekAgo}`),
safeCount(async () =>
Number(
(
await db
.select({ total: count() })
.from(Ban)
.where(gte(Ban.timestamp, weekAgo))
)[0]?.total ?? 0,
),
),
safeCount(async () =>
Number(
(
await db
.select({ total: count() })
.from(User)
.where(gte(User.accountCreated, weekAgo))
)[0]?.total ?? 0,
),
),
]);
for (const r of [weekChats, weekCommands, weekBans, weekRegistrations]) {
if (!r.ok) degraded = true;
}
let topChatters: ActivityData["topChatters"] = [];
try {
const rows = await rawRows<{ userId: number; messages: bigint }>(sql`
SELECT user_from_id AS userId, COUNT(*) AS messages
FROM chatlogs_room WHERE timestamp >= ${weekAgo}
GROUP BY user_from_id ORDER BY messages DESC LIMIT 10
`);
const userIds = rows.map((c) => Number(c.userId)).filter((n) => n > 0);
const users =
userIds.length > 0
? await db
.select({ id: User.id, username: User.username })
.from(User)
.where(inArray(User.id, userIds))
: [];
const userMap = new Map(users.map((u) => [u.id, u.username]));
topChatters = rows.map((c) => {
const userId = Number(c.userId);
return {
userId,
username: userMap.get(userId) ?? "",
messages: Number(c.messages),
};
});
} catch {
topChatters = [];
degraded = true;
}
return {
dailyData,
weekChats: weekChats.value,
weekCommands: weekCommands.value,
weekBans: weekBans.value,
weekRegistrations: weekRegistrations.value,
topChatters,
degraded,
};
}
export default async function ActivityPage() {
const { session, permissions } = await getAdminContext();
if (!canAccess(permissions, PERMS.ANALYTICS_VIEW, session.user.rank)) {
redirect("/admin");
}
const locale = await getLocale();
const t = await getTranslations("pages.admin.analytics.activity");
const tRoot = await getTranslations("pages.admin.analytics");
const data = await redisCache(`admin:analytics:activity:${locale}`, 120, () =>
getActivityData(locale),
);
return (
<div className="space-y-6">
{data.degraded ? (
<div className="rounded-lg bg-[var(--admin-error-subtle)] p-3 text-xs text-[var(--admin-error)]">
{tRoot("loadError")}
</div>
) : null}
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
<StatsCard
title={t("chatMessages")}
value={data.weekChats.toLocaleString(locale)}
description={t("last7Days")}
icon={<MessageSquare className="h-4 w-4" />}
/>
<StatsCard
title={t("commands")}
value={data.weekCommands.toLocaleString(locale)}
description={t("last7Days")}
icon={<Terminal className="h-4 w-4" />}
/>
<StatsCard
title={t("bans")}
value={data.weekBans.toLocaleString(locale)}
description={t("last7Days")}
icon={<Shield className="h-4 w-4" />}
/>
<StatsCard
title={t("registrations")}
value={data.weekRegistrations.toLocaleString(locale)}
description={t("last7Days")}
icon={<Users className="h-4 w-4" />}
/>
</div>
<div className="grid gap-6 md:grid-cols-2">
<Card>
<CardHeader>
<CardTitle>{t("dailyBreakdownTitle")}</CardTitle>
</CardHeader>
<CardContent>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b text-muted-foreground">
<th className="text-left py-2 font-medium">
{t("colDay")}
</th>
<th className="text-right py-2 font-medium">
{t("colChats")}
</th>
<th className="text-right py-2 font-medium">
{t("colCmds")}
</th>
<th className="text-right py-2 font-medium">
{t("colBans")}
</th>
<th className="text-right py-2 font-medium">
{t("colRegs")}
</th>
</tr>
</thead>
<tbody>
{data.dailyData.map((day) => (
<tr key={day.label} className="border-b last:border-0">
<td className="py-2">{day.label}</td>
<td className="text-right tabular-nums">
{day.chats.toLocaleString(locale)}
</td>
<td className="text-right tabular-nums">
{day.commands.toLocaleString(locale)}
</td>
<td className="text-right tabular-nums">{day.bans}</td>
<td className="text-right tabular-nums">
{day.registrations}
</td>
</tr>
))}
</tbody>
</table>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>{t("mostActiveUsersTitle")}</CardTitle>
</CardHeader>
<CardContent>
{data.topChatters.length === 0 ? (
<div className="admin-empty">{t("noChatActivity")}</div>
) : (
<div className="divide-y">
{data.topChatters.map((user, i) => (
<div
key={user.userId}
className="flex items-center gap-3 py-2.5 first:pt-0 last:pb-0"
>
<span className="flex h-6 w-6 items-center justify-center rounded-full bg-accent text-xs font-bold">
{i + 1}
</span>
<span className="flex-1 text-sm font-medium">
{user.username || t("userFallback", { id: user.userId })}
</span>
<span className="text-sm tabular-nums text-muted-foreground">
{t("msgCount", {
count: user.messages.toLocaleString(locale),
})}
</span>
</div>
))}
</div>
)}
</CardContent>
</Card>
</div>
</div>
);
}
+346
View File
@@ -0,0 +1,346 @@
import { and, count, eq, gte, sql, sum } from "drizzle-orm";
import { ArrowLeftRight } from "lucide-react";
import { redirect } from "next/navigation";
import { getLocale, getTranslations } from "next-intl/server";
import { RevenueChart } from "@/components/admin/revenue-chart";
import { StatsCard } from "@/components/admin/stats-card";
import { CurrencyIcon } from "@/components/shared/currency-icon";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import {
db,
LogsShopPurchases,
MarketplaceItems,
RoomTradeLog,
User,
} from "@/lib/db";
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
import { redisCache } from "@/lib/redis-cache";
type EconomyData = {
totalCredits: number;
totalPixels: number;
totalPoints: number;
shopPurchases: number;
shopCreditsSpent: number;
shopPointsSpent: number;
marketplaceActive: number;
marketplaceSold: number;
weekTrades: number;
revenueData: { label: string; credits: number; points: number }[];
};
const EMPTY: EconomyData = {
totalCredits: 0,
totalPixels: 0,
totalPoints: 0,
shopPurchases: 0,
shopCreditsSpent: 0,
shopPointsSpent: 0,
marketplaceActive: 0,
marketplaceSold: 0,
weekTrades: 0,
revenueData: [],
};
async function safeCount(fn: () => Promise<number>): Promise<number> {
try {
return await fn();
} catch {
return 0;
}
}
async function rawRows<T>(query: ReturnType<typeof sql>): Promise<T[]> {
const [rows] = (await db.execute(query)) as unknown as [T[], unknown];
return rows ?? [];
}
async function getEconomyData(
locale: string,
): Promise<{ data: EconomyData; degraded: boolean }> {
const now = Math.floor(Date.now() / 1000);
const todayStart = now - (now % 86400);
const weekAgo = now - 7 * 86400;
const rangeStart = todayStart - 6 * 86400;
let degraded = false;
try {
const [creditsAgg, pixelsAgg, pointsAgg] = await Promise.all([
db
.select({ total: sum(User.credits) })
.from(User)
.catch(() => {
degraded = true;
return [{ total: null }];
}),
db
.select({ total: sum(User.pixels) })
.from(User)
.catch(() => {
degraded = true;
return [{ total: null }];
}),
db
.select({ total: sum(User.points) })
.from(User)
.catch(() => {
degraded = true;
return [{ total: null }];
}),
]);
const totalCredits = Number(creditsAgg[0]?.total ?? 0);
const totalPixels = Number(pixelsAgg[0]?.total ?? 0);
const totalPoints = Number(pointsAgg[0]?.total ?? 0);
let shopPurchases = 0;
let shopCreditsSpent = 0;
let shopPointsSpent = 0;
try {
const [shopRevenue] = await db
.select({
purchases: count(),
credits: sum(LogsShopPurchases.costCredits),
points: sum(LogsShopPurchases.costPoints),
})
.from(LogsShopPurchases)
.where(gte(LogsShopPurchases.timestamp, weekAgo));
shopPurchases = Number(shopRevenue?.purchases ?? 0);
shopCreditsSpent = Number(shopRevenue?.credits ?? 0);
shopPointsSpent = Number(shopRevenue?.points ?? 0);
} catch {
degraded = true;
}
const [marketplaceActive, marketplaceSold, weekTrades] = await Promise.all([
safeCount(async () =>
Number(
(
await db
.select({ total: count() })
.from(MarketplaceItems)
.where(eq(MarketplaceItems.state, 1))
)[0]?.total ?? 0,
),
),
safeCount(async () =>
Number(
(
await db
.select({ total: count() })
.from(MarketplaceItems)
.where(
and(
eq(MarketplaceItems.state, 2),
gte(MarketplaceItems.soldTimestamp, weekAgo),
),
)
)[0]?.total ?? 0,
),
),
safeCount(async () =>
Number(
(
await db
.select({ total: count() })
.from(RoomTradeLog)
.where(gte(RoomTradeLog.timestamp, weekAgo))
)[0]?.total ?? 0,
),
),
]);
const revenueData: EconomyData["revenueData"] = [];
for (let i = 6; i >= 0; i--) {
const dayStart = todayStart - i * 86400;
const date = new Date(dayStart * 1000);
revenueData.push({
label: date.toLocaleDateString(locale, { weekday: "short" }),
credits: 0,
points: 0,
});
}
try {
const revRows = await rawRows<{
day_bucket: bigint;
credits: number | bigint;
points: number | bigint;
}>(sql`
SELECT FLOOR(timestamp / 86400) AS day_bucket,
COALESCE(SUM(cost_credits), 0) AS credits,
COALESCE(SUM(cost_points), 0) AS points
FROM logs_shop_purchases
WHERE timestamp >= ${rangeStart}
GROUP BY day_bucket ORDER BY day_bucket
`);
const revMap = new Map(
revRows.map((r) => [
Number(r.day_bucket),
{ credits: Number(r.credits), points: Number(r.points) },
]),
);
for (let i = 0; i < revenueData.length; i++) {
const dayStart = todayStart - (6 - i) * 86400;
const bucket = Math.floor(dayStart / 86400);
const entry = revMap.get(bucket);
if (entry) {
revenueData[i] = { ...revenueData[i], ...entry };
}
}
} catch {
degraded = true;
}
return {
degraded,
data: {
totalCredits,
totalPixels,
totalPoints,
shopPurchases,
shopCreditsSpent,
shopPointsSpent,
marketplaceActive,
marketplaceSold,
weekTrades,
revenueData,
},
};
} catch {
return { data: EMPTY, degraded: true };
}
}
export default async function EconomyPage() {
const { session, permissions } = await getAdminContext();
if (!canAccess(permissions, PERMS.ANALYTICS_VIEW, session.user.rank)) {
redirect("/admin");
}
const locale = await getLocale();
const t = await getTranslations("pages.admin.analytics.economy");
const tRoot = await getTranslations("pages.admin.analytics");
const { data, degraded } = await redisCache(
`admin:analytics:economy:${locale}`,
120,
() => getEconomyData(locale),
);
return (
<div className="space-y-6">
{degraded ? (
<div className="rounded-lg bg-[var(--admin-error-subtle)] p-3 text-xs text-[var(--admin-error)]">
{tRoot("loadError")}
</div>
) : null}
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
<StatsCard
title={t("creditsInCirculation")}
value={data.totalCredits.toLocaleString(locale)}
description={t("creditsDescription")}
icon={<CurrencyIcon kind="credits" size={16} />}
/>
<StatsCard
title={t("pixelsInCirculation")}
value={data.totalPixels.toLocaleString(locale)}
description={t("pixelsDescription")}
icon={<CurrencyIcon kind="pixels" size={16} />}
/>
<StatsCard
title={t("pointsInCirculation")}
value={data.totalPoints.toLocaleString(locale)}
description={t("pointsDescription")}
icon={<CurrencyIcon kind="diamonds" size={16} />}
/>
<StatsCard
title={t("weeklyTrades")}
value={data.weekTrades.toLocaleString(locale)}
description={t("marketplaceListings", {
count: data.marketplaceActive,
})}
icon={<ArrowLeftRight className="h-4 w-4" />}
/>
</div>
<RevenueChart data={data.revenueData} />
<div className="grid gap-6 md:grid-cols-3">
<Card>
<CardHeader>
<CardTitle>{t("shopRevenueTitle")}</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-2">
<div className="flex justify-between">
<span className="text-muted-foreground">{t("purchases")}</span>
<span className="font-medium">{data.shopPurchases}</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">
{t("creditsSpent")}
</span>
<span className="font-medium">
{data.shopCreditsSpent.toLocaleString(locale)}
</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">
{t("pointsSpent")}
</span>
<span className="font-medium">
{data.shopPointsSpent.toLocaleString(locale)}
</span>
</div>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>{t("marketplaceTitle")}</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-2">
<div className="flex justify-between">
<span className="text-muted-foreground">
{t("activeListings")}
</span>
<span className="font-medium">{data.marketplaceActive}</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">{t("sold7d")}</span>
<span className="font-medium">{data.marketplaceSold}</span>
</div>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>{t("currencyDistributionTitle")}</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-2">
<div className="flex justify-between">
<span className="text-muted-foreground">{t("credits")}</span>
<span className="font-medium">
{data.totalCredits.toLocaleString(locale)}
</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">{t("pixels")}</span>
<span className="font-medium">
{data.totalPixels.toLocaleString(locale)}
</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">{t("points")}</span>
<span className="font-medium">
{data.totalPoints.toLocaleString(locale)}
</span>
</div>
</div>
</CardContent>
</Card>
</div>
</div>
);
}
+365
View File
@@ -0,0 +1,365 @@
import { count, eq, gt, gte, sql } from "drizzle-orm";
import {
ArrowLeftRight,
Clock,
DoorOpen,
MessageSquare,
TrendingUp,
Users,
} from "lucide-react";
import { redirect } from "next/navigation";
import { getLocale, getTranslations } from "next-intl/server";
import { DashboardChart } from "@/components/admin/dashboard-chart";
import { PeakHoursHeatmap } from "@/components/admin/peak-hours-heatmap";
import { StatsCard } from "@/components/admin/stats-card";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Ban, db, Rooms, RoomTradeLog, User } from "@/lib/db";
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
import { redisCache } from "@/lib/redis-cache";
type AnalyticsData = {
totalUsers: number;
onlineUsers: number;
weekRegistrations: number;
monthRegistrations: number;
totalRooms: number;
activeRooms: number;
weekChatMessages: number;
weekTrades: number;
weekBans: number;
registrationChart: { label: string; value: number }[];
peakHours: { grid: number[][]; max: number };
};
const EMPTY: AnalyticsData = {
totalUsers: 0,
onlineUsers: 0,
weekRegistrations: 0,
monthRegistrations: 0,
totalRooms: 0,
activeRooms: 0,
weekChatMessages: 0,
weekTrades: 0,
weekBans: 0,
registrationChart: [],
peakHours: {
grid: Array.from({ length: 7 }, () => Array(24).fill(0)),
max: 0,
},
};
async function safeCount(
fn: () => Promise<number>,
): Promise<{ value: number; ok: boolean }> {
try {
return { value: await fn(), ok: true };
} catch {
return { value: 0, ok: false };
}
}
async function rawRows<T>(query: ReturnType<typeof sql>): Promise<T[]> {
const [rows] = (await db.execute(query)) as unknown as [T[], unknown];
return rows ?? [];
}
async function getAnalyticsData(
locale: string,
): Promise<{ data: AnalyticsData; degraded: boolean }> {
const now = Math.floor(Date.now() / 1000);
const todayStart = now - (now % 86400);
const weekAgo = now - 7 * 86400;
const monthAgo = now - 30 * 86400;
let degraded = false;
try {
const [
totalUsers,
onlineUsers,
weekRegistrations,
monthRegistrations,
totalRooms,
activeRooms,
weekChatMessages,
weekTrades,
weekBans,
] = await Promise.all([
safeCount(async () =>
Number((await db.select({ total: count() }).from(User))[0]?.total ?? 0),
),
safeCount(async () =>
Number(
(
await db
.select({ total: count() })
.from(User)
.where(eq(User.online, "1"))
)[0]?.total ?? 0,
),
),
safeCount(async () =>
Number(
(
await db
.select({ total: count() })
.from(User)
.where(gte(User.accountCreated, weekAgo))
)[0]?.total ?? 0,
),
),
safeCount(async () =>
Number(
(
await db
.select({ total: count() })
.from(User)
.where(gte(User.accountCreated, monthAgo))
)[0]?.total ?? 0,
),
),
safeCount(async () =>
Number(
(await db.select({ total: count() }).from(Rooms))[0]?.total ?? 0,
),
),
safeCount(async () =>
Number(
(
await db
.select({ total: count() })
.from(Rooms)
.where(gt(Rooms.users, 0))
)[0]?.total ?? 0,
),
),
(async () => {
try {
const rows = await rawRows<{ count: bigint }>(sql`
SELECT COUNT(*) AS count FROM chatlogs_room WHERE timestamp >= ${weekAgo}`);
return { value: Number(rows[0]?.count ?? 0), ok: true };
} catch {
return { value: 0, ok: false };
}
})(),
safeCount(async () =>
Number(
(
await db
.select({ total: count() })
.from(RoomTradeLog)
.where(gte(RoomTradeLog.timestamp, weekAgo))
)[0]?.total ?? 0,
),
),
safeCount(async () =>
Number(
(
await db
.select({ total: count() })
.from(Ban)
.where(gte(Ban.timestamp, weekAgo))
)[0]?.total ?? 0,
),
),
]);
for (const r of [
totalUsers,
onlineUsers,
weekRegistrations,
monthRegistrations,
totalRooms,
activeRooms,
weekChatMessages,
weekTrades,
weekBans,
]) {
if (!r.ok) degraded = true;
}
let registrationChart: { label: string; value: number }[] = [];
try {
const regRows = await rawRows<{ day_bucket: bigint; count: bigint }>(sql`
SELECT FLOOR(account_created / 86400) AS day_bucket, COUNT(*) AS count
FROM users
WHERE account_created >= ${monthAgo}
GROUP BY day_bucket ORDER BY day_bucket
`);
const regCountMap = new Map(
regRows.map((r) => [Number(r.day_bucket), Number(r.count)]),
);
for (let i = 29; i >= 0; i--) {
const dayStart = todayStart - i * 86400;
const bucket = Math.floor(dayStart / 86400);
const date = new Date(dayStart * 1000);
registrationChart.push({
label: date.toLocaleDateString(locale, {
month: "short",
day: "numeric",
}),
value: regCountMap.get(bucket) ?? 0,
});
}
} catch {
registrationChart = [];
degraded = true;
}
const grid: number[][] = Array.from({ length: 7 }, () => Array(24).fill(0));
let max = 0;
try {
const peakRows = await rawRows<{ d: number; h: number; c: bigint }>(sql`
SELECT ((DAYOFWEEK(FROM_UNIXTIME(last_online)) + 5) % 7) AS d,
HOUR(FROM_UNIXTIME(last_online)) AS h,
COUNT(*) AS c
FROM users
WHERE last_online >= ${weekAgo}
GROUP BY d, h
`);
for (const r of peakRows) {
const day = Number(r.d);
const hour = Number(r.h);
if (day < 0 || day > 6 || hour < 0 || hour > 23) continue;
const cnt = Number(r.c);
grid[day][hour] = cnt;
if (cnt > max) max = cnt;
}
} catch {
degraded = true;
}
return {
degraded,
data: {
totalUsers: totalUsers.value,
onlineUsers: onlineUsers.value,
weekRegistrations: weekRegistrations.value,
monthRegistrations: monthRegistrations.value,
totalRooms: totalRooms.value,
activeRooms: activeRooms.value,
weekChatMessages: weekChatMessages.value,
weekTrades: weekTrades.value,
weekBans: weekBans.value,
registrationChart,
peakHours: { grid, max },
},
};
} catch {
return { data: EMPTY, degraded: true };
}
}
export default async function AnalyticsPage() {
const { session, permissions } = await getAdminContext();
if (!canAccess(permissions, PERMS.ANALYTICS_VIEW, session.user.rank)) {
redirect("/admin");
}
const locale = await getLocale();
const t = await getTranslations("pages.admin.analytics");
const { data, degraded } = await redisCache(
`admin:analytics:overview:${locale}`,
120,
() => getAnalyticsData(locale),
);
return (
<div className="space-y-6">
{degraded ? (
<div className="rounded-lg bg-[var(--admin-error-subtle)] p-3 text-xs text-[var(--admin-error)]">
{t("loadError")}
</div>
) : null}
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
<StatsCard
title={t("totalUsers")}
value={data.totalUsers.toLocaleString(locale)}
description={t("onlineNow", { count: data.onlineUsers })}
icon={<Users className="h-4 w-4" />}
/>
<StatsCard
title={t("weekRegistrations")}
value={data.weekRegistrations.toLocaleString(locale)}
description={t("thisMonth", { count: data.monthRegistrations })}
icon={<TrendingUp className="h-4 w-4" />}
/>
<StatsCard
title={t("activeRooms")}
value={data.activeRooms.toLocaleString(locale)}
description={t("totalRooms", { count: data.totalRooms })}
icon={<DoorOpen className="h-4 w-4" />}
/>
<StatsCard
title={t("weeklyActivity")}
value={data.weekChatMessages.toLocaleString(locale)}
description={t("tradesAndBans", {
trades: data.weekTrades,
bans: data.weekBans,
})}
icon={<MessageSquare className="h-4 w-4" />}
/>
</div>
<div className="grid gap-6 md:grid-cols-2">
<DashboardChart
title={t("registrationsChartTitle")}
data={data.registrationChart}
color="hsl(262, 83%, 58%)"
description={t("registrationsChartDescription")}
/>
<PeakHoursHeatmap data={data.peakHours} />
</div>
<div className="grid gap-6 md:grid-cols-3">
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<MessageSquare className="h-4 w-4" /> {t("chatActivity")}
</CardTitle>
</CardHeader>
<CardContent>
<div className="text-3xl font-bold">
{data.weekChatMessages.toLocaleString(locale)}
</div>
<p className="text-sm text-muted-foreground">
{t("messagesLast7Days")}
</p>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<ArrowLeftRight className="h-4 w-4" /> {t("trades")}
</CardTitle>
</CardHeader>
<CardContent>
<div className="text-3xl font-bold">
{data.weekTrades.toLocaleString(locale)}
</div>
<p className="text-sm text-muted-foreground">
{t("tradesLast7Days")}
</p>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Clock className="h-4 w-4" /> {t("retention")}
</CardTitle>
</CardHeader>
<CardContent>
<div className="text-3xl font-bold">
{data.totalUsers > 0
? Math.round((data.onlineUsers / data.totalUsers) * 100)
: 0}
%
</div>
<p className="text-sm text-muted-foreground">
{t("usersOnlineOverTotal")}
</p>
</CardContent>
</Card>
</div>
</div>
);
}
+110
View File
@@ -0,0 +1,110 @@
import { desc, inArray } from "drizzle-orm";
import { redirect } from "next/navigation";
import { getTranslations } from "next-intl/server";
import { dismissApplication } from "@/actions/admin-applications";
import { StatusCard } from "@/components/admin/dashboard";
import Link from "@/components/link";
import { Button } from "@/components/ui/button";
import { db, User, WebsiteStaffApplications } from "@/lib/db";
import { formatDate } from "@/lib/format-date";
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
type ApplicationRow = {
id: bigint;
userId: number;
rankId: number;
content: string;
createdAt: Date | null;
};
export default async function AdminApplications() {
const { session, permissions } = await getAdminContext();
if (!canAccess(permissions, PERMS.USERS_VIEW, session.user.rank)) {
redirect("/admin");
}
const t = await getTranslations("pages.admin.applications");
let applications: ApplicationRow[];
try {
applications = await db
.select()
.from(WebsiteStaffApplications)
.orderBy(desc(WebsiteStaffApplications.createdAt))
.limit(100);
} catch {
applications = [];
}
// Resolve applicant usernames (no relation defined on the model).
const userMap = new Map<number, string>();
if (applications.length > 0) {
try {
const ids = Array.from(new Set(applications.map((a) => a.userId)));
const users = await db
.select({ id: User.id, username: User.username })
.from(User)
.where(inArray(User.id, ids));
for (const u of users) userMap.set(u.id, u.username);
} catch {
// no DB — fall back to raw ids
}
}
return (
<main>
<div className="grid grid-cols-[repeat(auto-fit,minmax(180px,1fr))] gap-3.5 mb-6">
<StatusCard
label={t("pending")}
value={applications.length}
state={applications.length > 0 ? "warn" : "ok"}
icon="📨"
/>
</div>
<section className="mt-6">
<h2 className="admin-section-title">
{t("applicationsTitle", { count: applications.length })}
</h2>
{applications.length === 0 ? (
<div className="admin-empty">{t("noApplications")}</div>
) : (
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
{applications.map((a) => {
const username = userMap.get(a.userId);
return (
<article key={String(a.id)} className="admin-card p-3">
<div className="flex items-baseline justify-between gap-2 mb-1">
<h3 className="m-0 text-sm font-semibold text-[var(--admin-text)]">
{username ? (
<Link href={`/admin/users/${a.userId}`}>
{username}
</Link>
) : (
t("userNumber", { id: a.userId })
)}
</h3>
<span className="inline-block text-[0.72rem] font-bold px-2 py-0.5 rounded-full bg-[var(--admin-accent)]/18 text-[var(--admin-text)]">
{t("rankLabel", { rank: a.rankId })}
</span>
</div>
<p className="text-xs theme-text-muted dark:theme-text-muted m-0 mb-2">
{formatDate(a.createdAt, "datetime", "—")}
</p>
<p className="text-sm text-[var(--admin-text)] whitespace-pre-wrap m-0 mb-3">
{a.content}
</p>
<form action={dismissApplication}>
<input type="hidden" name="id" value={String(a.id)} />
<Button type="submit" variant="destructive">
{t("dismiss")}
</Button>
</form>
</article>
);
})}
</div>
)}
</section>
</main>
);
}
+84
View File
@@ -0,0 +1,84 @@
import { eq } from "drizzle-orm";
import { Newspaper } from "lucide-react";
import { notFound, redirect } from "next/navigation";
import { getTranslations } from "next-intl/server";
import { deleteArticle, updateArticle } from "@/actions/admin-articles";
import { ArticleForm } from "@/components/admin/article-form";
import Link from "@/components/link";
import { Button } from "@/components/ui/button";
import { db, WebsiteArticles } from "@/lib/db";
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
export default async function EditArticle({
params,
searchParams,
}: {
params: Promise<{ id: string }>;
searchParams: Promise<{ error?: string }>;
}) {
const { session, permissions } = await getAdminContext();
if (!canAccess(permissions, PERMS.NEWS_VIEW, session.user.rank)) {
redirect("/admin");
}
const { id } = await params;
const { error } = await searchParams;
const t = await getTranslations("pages.admin.articles");
const [article] = await db
.select({
id: WebsiteArticles.id,
slug: WebsiteArticles.slug,
title: WebsiteArticles.title,
shortStory: WebsiteArticles.shortStory,
fullStory: WebsiteArticles.fullStory,
image: WebsiteArticles.image,
})
.from(WebsiteArticles)
.where(eq(WebsiteArticles.id, BigInt(id)))
.limit(1)
.catch(() => []);
if (!article) notFound();
return (
<main>
<div className="flex items-center gap-3 mb-6">
<div className="w-10 h-10 rounded-xl bg-gradient-to-br from-[var(--admin-accent)]/20 to-[var(--admin-accent)]/5 grid place-items-center">
<Newspaper size={20} className="text-[var(--admin-accent)]" />
</div>
<div>
<h1 className="m-0 text-xl font-extrabold text-[var(--admin-text)]">
{t("form.title")}
</h1>
<p className="m-0 text-xs text-[var(--admin-text-muted)] mt-0.5">
<Link href="/admin/articles">← {t("title")}</Link>
</p>
</div>
</div>
{error ? (
<p className="text-xs text-[var(--admin-error)] dark:text-[var(--admin-error)] mb-4">
Error: {error}
</p>
) : null}
<ArticleForm
action={async (fd) => {
"use server";
fd.set("id", String(article.id));
await updateArticle(fd);
}}
defaultValues={{
title: article.title,
slug: article.slug,
image: article.image,
shortStory: article.shortStory,
fullStory: article.fullStory,
}}
/>
<form action={deleteArticle} style={{ marginTop: "1rem" }}>
<input type="hidden" name="id" value={String(article.id)} />
<Button type="submit" variant="default">
{t("form.deleteArticle")}
</Button>
</form>
</main>
);
}
+45
View File
@@ -0,0 +1,45 @@
import { Newspaper } from "lucide-react";
import { redirect } from "next/navigation";
import { getTranslations } from "next-intl/server";
import { createArticle } from "@/actions/admin-articles";
import { ArticleForm } from "@/components/admin/article-form";
import Link from "@/components/link";
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
export default async function NewArticle({
searchParams,
}: {
searchParams: Promise<{ error?: string }>;
}) {
const { session, permissions } = await getAdminContext();
if (!canAccess(permissions, PERMS.NEWS_EDIT, session.user.rank)) {
redirect("/admin");
}
const { error } = await searchParams;
const t = await getTranslations("pages.admin.articles");
return (
<main>
<div className="flex items-center gap-3 mb-6">
<div className="w-10 h-10 rounded-xl bg-gradient-to-br from-[var(--admin-accent)]/20 to-[var(--admin-accent)]/5 grid place-items-center">
<Newspaper size={20} className="text-[var(--admin-accent)]" />
</div>
<div>
<h1 className="m-0 text-xl font-extrabold text-[var(--admin-text)]">
{t("form.title")}
</h1>
<p className="m-0 text-xs text-[var(--admin-text-muted)] mt-0.5">
<Link href="/admin/articles">← {t("title")}</Link>
</p>
</div>
</div>
{error ? (
<p className="text-xs text-[var(--admin-error)] dark:text-[var(--admin-error)] mb-4">
Error: {error}
</p>
) : null}
<ArticleForm action={createArticle} />
</main>
);
}
+107
View File
@@ -0,0 +1,107 @@
import { desc, inArray } from "drizzle-orm";
import { Newspaper } from "lucide-react";
import { redirect } from "next/navigation";
import { getTranslations } from "next-intl/server";
import { AdminPageShell } from "@/components/admin/admin-page-shell";
import { ArticleCard } from "@/components/admin/article-card";
import { StatusCard } from "@/components/admin/dashboard";
import Link from "@/components/link";
import { Button } from "@/components/ui/button";
import { db, User, WebsiteArticles } from "@/lib/db";
import { formatDate } from "@/lib/format-date";
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
export default async function AdminArticles({
searchParams,
}: {
searchParams: Promise<{ error?: string }>;
}) {
const { session, permissions } = await getAdminContext();
if (!canAccess(permissions, PERMS.NEWS_VIEW, session.user.rank)) {
redirect("/admin");
}
const { error } = await searchParams;
const t = await getTranslations("pages.admin.articles");
const articles = await db
.select({
id: WebsiteArticles.id,
title: WebsiteArticles.title,
slug: WebsiteArticles.slug,
image: WebsiteArticles.image,
createdAt: WebsiteArticles.createdAt,
userId: WebsiteArticles.userId,
})
.from(WebsiteArticles)
.orderBy(desc(WebsiteArticles.createdAt))
.limit(50)
.catch(() => []);
const authorMap = new Map<number, string>();
const authorIds = articles
.map((a) => a.userId)
.filter((id): id is number => Boolean(id));
if (authorIds.length > 0) {
const authors = await db
.select({ id: User.id, username: User.username })
.from(User)
.where(inArray(User.id, authorIds))
.catch(() => []);
for (const u of authors) authorMap.set(u.id, u.username);
}
const latest = articles[0]?.createdAt
? formatDate(articles[0].createdAt, "date")
: "—";
return (
<AdminPageShell
icon={Newspaper}
title={t("title")}
subtitle={t("subtitle")}
actions={
<Button variant="default" asChild>
<Link href="/admin/articles/new">{t("newArticle")}</Link>
</Button>
}
>
{error ? (
<p className="text-xs text-[var(--admin-error)] mb-4">Error: {error}</p>
) : null}
<div className="grid grid-cols-[repeat(auto-fit,minmax(180px,1fr))] gap-3.5 mb-6">
<StatusCard
label={t("articlesShown")}
value={articles.length}
icon="📰"
/>
<StatusCard
label={t("latest")}
value={latest}
hint={t("mostRecentPost")}
icon="🕒"
/>
</div>
<section className="mt-2">
<h2 className="admin-section-title">{t("recentArticles")}</h2>
{articles.length === 0 ? (
<div className="admin-empty">{t("noArticles")}</div>
) : (
<div className="grid grid-cols-[repeat(auto-fill,minmax(260px,1fr))] gap-4">
{articles.map((a) => (
<ArticleCard
key={String(a.id)}
id={String(a.id)}
title={a.title}
image={a.image}
createdAt={a.createdAt ? a.createdAt.toISOString() : null}
author={authorMap.get(a.userId ?? -1)}
/>
))}
</div>
)}
</section>
</AdminPageShell>
);
}
+160
View File
@@ -0,0 +1,160 @@
import { asc } from "drizzle-orm";
import { redirect } from "next/navigation";
import { getTranslations } from "next-intl/server";
import { uploadBadge } from "@/actions/admin-badge-upload";
import { giveBadge } from "@/actions/admin-badges";
import { Button } from "@/components/ui/button";
import { db, WebsiteBadges } from "@/lib/db";
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
export const metadata = { title: "Badges" };
export default async function AdminBadges({
searchParams,
}: {
searchParams: Promise<{ uploaded?: string; error?: string }>;
}) {
const { session, permissions } = await getAdminContext();
if (!canAccess(permissions, PERMS.CATALOG_VIEW, session.user.rank)) {
redirect("/admin");
}
const t = await getTranslations("pages.admin.badges");
const { uploaded, error } = await searchParams;
let badges: (typeof WebsiteBadges.$inferSelect)[] = [];
try {
badges = await db
.select()
.from(WebsiteBadges)
.orderBy(asc(WebsiteBadges.badgeName))
.limit(500);
} catch {
badges = [];
}
return (
<main>
{uploaded && (
<div className="mb-4 p-3 rounded-lg bg-[var(--admin-success-subtle)] text-[var(--admin-success)] dark:text-[var(--admin-success)] text-xs">
{t("uploadedMessage", { code: uploaded })}
</div>
)}
{error && (
<div className="mb-4 p-3 rounded-lg bg-[var(--admin-error-subtle)] text-[var(--admin-error)] dark:text-[var(--admin-error)] text-xs">
{t("errorMessage", { error })}
</div>
)}
<section className="mt-6">
<h2 className="admin-section-title">{t("uploadTitle")}</h2>
<p className="text-xs text-[var(--admin-text-muted)] -mt-2 mb-3">
{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={t("badgeCodePlaceholder")}
required
maxLength={64}
pattern="[A-Za-z0-9_-]{1,64}"
className="flex-1 min-w-[160px]"
/>
<Button type="submit" variant="default">
{t("uploadButton")}
</Button>
</div>
</form>
</section>
<section className="mt-6">
<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={t("userIdPlaceholder")}
required
/>
<input
name="code"
placeholder={t("badgeCodePlaceholder")}
required
maxLength={32}
className="flex-1 min-w-[160px]"
list="badge-codes"
/>
<datalist id="badge-codes">
{badges.map((b) => (
<option key={String(b.id)} value={b.badgeKey}>
{b.badgeName}
</option>
))}
</datalist>
<Button type="submit" variant="default">
{t("giveButton")}
</Button>
</div>
</form>
</section>
<section className="mt-6">
<h2 className="admin-section-title">
{t("catalogueTitle")} ({badges.length})
</h2>
{badges.length === 0 ? (
<div className="admin-empty">{t("noBadges")}</div>
) : (
<div className="overflow-x-auto">
<table>
<thead>
<tr>
<th>{t("colCode")}</th>
<th>{t("colName")}</th>
<th>{t("colDescription")}</th>
<th />
</tr>
</thead>
<tbody>
{badges.map((b) => (
<tr key={String(b.id)}>
<td>
<code>{b.badgeKey}</code>
</td>
<td>{b.badgeName}</td>
<td className="text-sm theme-text-muted dark:theme-text-muted">
{b.badgeDescription}
</td>
<td>
<form
action={giveBadge}
className="flex gap-2 items-center"
>
<input type="hidden" name="code" value={b.badgeKey} />
<input
name="userId"
type="number"
min={1}
placeholder={t("userIdPlaceholder")}
required
className="w-[90px]"
/>
<Button type="submit" variant="outline">
{t("giveButton")}
</Button>
</form>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</section>
</main>
);
}
+288
View File
@@ -0,0 +1,288 @@
"use client";
import { Pencil, Plus, Trash2 } from "lucide-react";
import { useState } from "react";
import { createBanner, deleteBanner, updateBanner } from "@/actions/banners";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { DateTimePicker } from "@/components/ui/date-time-picker";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
import { useServerAction } from "@/hooks/use-server-action";
interface Banner {
id: number;
title: string;
subtitle: string;
image: string;
link: string;
color: string;
isActive: number;
sortOrder: number;
startDate: string | null;
endDate: string | null;
}
interface Props {
banners: Banner[];
}
export function BannersManager({ banners }: Props) {
const { run, isPending } = useServerAction();
const [showForm, setShowForm] = useState(false);
const [editingId, setEditingId] = useState<number | null>(null);
const [form, setForm] = useState({
title: "",
subtitle: "",
image: "",
link: "",
color: "",
isActive: 1,
sortOrder: 0,
startDate: "" as string | null,
endDate: "" as string | null,
});
function resetForm() {
setForm({
title: "",
subtitle: "",
image: "",
link: "",
color: "",
isActive: 1,
sortOrder: 0,
startDate: "",
endDate: "",
});
setEditingId(null);
setShowForm(false);
}
function handleEdit(b: Banner) {
setForm({
title: b.title,
subtitle: b.subtitle,
image: b.image,
link: b.link,
color: b.color,
isActive: b.isActive,
sortOrder: b.sortOrder,
startDate: b.startDate,
endDate: b.endDate,
});
setEditingId(b.id);
setShowForm(true);
}
function handleSubmit(e: React.FormEvent) {
e.preventDefault();
const data = {
...form,
startDate: form.startDate || null,
endDate: form.endDate || null,
};
if (editingId) {
run(() => updateBanner({ id: editingId, ...data }), {
successMessage: "Banner updated!",
});
} else {
run(() => createBanner(data), { successMessage: "Banner created!" });
}
resetForm();
}
return (
<div className="space-y-4">
<div className="flex justify-end">
<Button
onClick={() => {
resetForm();
setShowForm(!showForm);
}}
>
<Plus className="h-4 w-4 mr-2" /> New Banner
</Button>
</div>
{showForm && (
<Card>
<CardHeader>
<CardTitle>{editingId ? "Edit Banner" : "New Banner"}</CardTitle>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-2">
<Label>Title</Label>
<Input
value={form.title}
onChange={(e) =>
setForm({ ...form, title: e.target.value })
}
required
/>
</div>
<div className="space-y-2">
<Label>Subtitle</Label>
<Input
value={form.subtitle}
onChange={(e) =>
setForm({ ...form, subtitle: e.target.value })
}
/>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-2">
<Label>Image URL</Label>
<Input
value={form.image}
onChange={(e) =>
setForm({ ...form, image: e.target.value })
}
required
/>
</div>
<div className="space-y-2">
<Label>Link URL</Label>
<Input
value={form.link}
onChange={(e) => setForm({ ...form, link: e.target.value })}
/>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div className="space-y-2">
<Label>Color</Label>
<Input
type="color"
value={form.color || "#000000"}
onChange={(e) =>
setForm({ ...form, color: e.target.value })
}
/>
</div>
<div className="space-y-2">
<Label>Start Date</Label>
<DateTimePicker
value={form.startDate}
onChange={(d) =>
setForm({
...form,
startDate: d ? d.toISOString() : null,
})
}
placeholder="Select start date"
/>
</div>
<div className="space-y-2">
<Label>End Date</Label>
<DateTimePicker
value={form.endDate}
onChange={(d) =>
setForm({ ...form, endDate: d ? d.toISOString() : null })
}
placeholder="Select end date"
/>
</div>
</div>
<div className="flex gap-6">
<div className="flex items-center gap-2">
<Switch
checked={form.isActive === 1}
onCheckedChange={(v) =>
setForm({ ...form, isActive: v ? 1 : 0 })
}
/>
<Label>Active</Label>
</div>
<div className="space-y-2 flex items-center gap-2">
<Label>Order</Label>
<Input
type="number"
className="w-20"
value={form.sortOrder}
onChange={(e) =>
setForm({ ...form, sortOrder: Number(e.target.value) })
}
/>
</div>
</div>
<div className="flex gap-2 justify-end">
<Button type="button" variant="outline" onClick={resetForm}>
Cancel
</Button>
<Button type="submit" disabled={isPending}>
{editingId ? "Update" : "Create"}
</Button>
</div>
</form>
</CardContent>
</Card>
)}
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{banners.length === 0 && (
<p className="text-muted-foreground col-span-full text-center py-8">
No banners yet.
</p>
)}
{banners.map((b) => (
<Card key={b.id} className="overflow-hidden">
{b.image && (
<div
className="h-32 bg-cover bg-center"
style={{ backgroundImage: `url(${b.image})` }}
/>
)}
<CardContent className="p-4">
<div className="flex items-start justify-between">
<div>
<h3 className="font-medium">{b.title}</h3>
{b.subtitle && (
<p className="text-xs text-muted-foreground">
{b.subtitle}
</p>
)}
<div className="flex gap-1 mt-2">
<Badge variant={b.isActive ? "default" : "outline"}>
{b.isActive ? "Active" : "Inactive"}
</Badge>
{b.startDate && (
<Badge variant="secondary">Scheduled</Badge>
)}
</div>
</div>
<div className="flex gap-1">
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={() => handleEdit(b)}
>
<Pencil className="h-3.5 w-3.5" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={() =>
run(() => deleteBanner({ id: b.id }), {
successMessage: "Banner deleted",
})
}
>
<Trash2 className="h-3.5 w-3.5 text-destructive" />
</Button>
</div>
</div>
</CardContent>
</Card>
))}
</div>
</div>
);
}
+23
View File
@@ -0,0 +1,23 @@
import { asc } from "drizzle-orm";
import { redirect } from "next/navigation";
import { db, WebsiteBanner } from "@/lib/db";
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
import { BannersManager } from "./banners-manager";
export default async function BannersPage() {
const { session, permissions } = await getAdminContext();
if (!canAccess(permissions, PERMS.BANNERS_VIEW, session.user.rank)) {
redirect("/admin");
}
const banners = await db
.select()
.from(WebsiteBanner)
.orderBy(asc(WebsiteBanner.sortOrder));
return (
<div className="space-y-6">
<BannersManager banners={banners} />
</div>
);
}
+148
View File
@@ -0,0 +1,148 @@
import { desc, eq, gt, or } from "drizzle-orm";
import { Ban as BanIcon } from "lucide-react";
import { redirect } from "next/navigation";
import { getTranslations } from "next-intl/server";
import { createBan, liftBan } from "@/actions/admin-bans";
import { AdminPageShell } from "@/components/admin/admin-page-shell";
import { StatusCard } from "@/components/admin/dashboard";
import { Button } from "@/components/ui/button";
import { unixNow } from "@/lib/bans";
import { Ban, db } from "@/lib/db";
import { formatDate } from "@/lib/format-date";
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
export default async function AdminBans() {
const { session, permissions } = await getAdminContext();
if (!canAccess(permissions, PERMS.BANS_VIEW, session.user.rank)) {
redirect("/admin");
}
const t = await getTranslations("pages.admin.bans");
const now = unixNow();
let bans: (typeof Ban.$inferSelect)[] = [];
try {
bans = await db
.select()
.from(Ban)
.where(or(eq(Ban.banExpire, 0), gt(Ban.banExpire, now)))
.orderBy(desc(Ban.timestamp))
.limit(100);
} catch {
bans = [];
}
const permanent = bans.filter((b) => b.banExpire === 0).length;
const accountBans = bans.filter((b) => b.type === "account").length;
return (
<AdminPageShell icon={BanIcon} title={t("title")} subtitle={t("subtitle")}>
<main>
<div className="grid grid-cols-[repeat(auto-fit,minmax(180px,1fr))] gap-3.5 mb-6">
<StatusCard
label={t("activeBans")}
value={bans.length}
state={bans.length > 0 ? "warn" : "ok"}
icon="🔨"
/>
<StatusCard
label={t("permanent")}
value={permanent}
state={permanent > 0 ? "danger" : "neutral"}
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(--admin-text)] m-0 mb-3">
{t("newBan")}
</h3>
<div className="flex gap-2 flex-wrap">
<input
name="userId"
type="number"
min={1}
placeholder={t("userIdPlaceholder")}
required
/>
<select name="type">
<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={t("hoursPlaceholder")}
/>
<input
name="reason"
placeholder={t("reasonPlaceholder")}
className="flex-1 min-w-[160px]"
/>
<Button type="submit" variant="destructive">
{t("ban")}
</Button>
</div>
</form>
<section className="mt-6">
<h2 className="admin-section-title">
{t("activeBans")} ({bans.length})
</h2>
{bans.length === 0 ? (
<div className="admin-empty">{t("noBans")}</div>
) : (
<div className="overflow-x-auto">
<table>
<thead>
<tr>
<th>{t("colUserId")}</th>
<th>{t("colType")}</th>
<th>{t("colReason")}</th>
<th>{t("colExpires")}</th>
<th />
</tr>
</thead>
<tbody>
{bans.map((b) => (
<tr key={b.id}>
<td>{b.userId}</td>
<td>
<span className="inline-block text-[0.72rem] font-bold px-2 py-0.5 rounded-full bg-[var(--admin-accent)]/18 text-[var(--admin-text)]">
{b.type}
</span>
</td>
<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-[var(--admin-error-subtle)] text-[var(--admin-text)]">
{t("permanent")}
</span>
) : (
<span className="text-sm theme-text-muted dark:theme-text-muted whitespace-nowrap">
{formatDate(new Date(b.banExpire * 1000))}
</span>
)}
</td>
<td>
<form action={liftBan}>
<input type="hidden" name="id" value={b.id} />
<Button type="submit" variant="outline">
{t("lift")}
</Button>
</form>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</section>
</main>
</AdminPageShell>
);
}
+211
View File
@@ -0,0 +1,211 @@
import { asc, eq } from "drizzle-orm";
import { notFound, redirect } from "next/navigation";
import { getTranslations } from "next-intl/server";
import Link from "@/components/link";
import { CalendarCampaigns, CalendarRewards, db } from "@/lib/db";
import { formatDate } from "@/lib/format-date";
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
type Campaign = {
id: number;
name: string;
image: string;
startTimestamp: number;
totalDays: number;
lockExpired: string;
enabled: string;
};
type Reward = {
id: number;
campaignId: number;
productName: string;
customImage: string;
credits: number;
pixels: number;
points: number;
pointsType: number;
badge: string;
itemId: number;
subscriptionType: string | null;
subscriptionDays: number;
};
export default async function AdminCalendarCampaign({
params,
}: {
params: Promise<{ id: string }>;
}) {
const { session, permissions } = await getAdminContext();
if (!canAccess(permissions, PERMS.SHOP_VIEW, session.user.rank)) {
redirect("/admin");
}
const t = await getTranslations("pages.admin.calendar");
const { id } = await params;
const campaignId = Number(id);
if (!Number.isInteger(campaignId)) notFound();
let campaign: Campaign | null = null;
try {
const [row] = await db
.select({
id: CalendarCampaigns.id,
name: CalendarCampaigns.name,
image: CalendarCampaigns.image,
startTimestamp: CalendarCampaigns.startTimestamp,
totalDays: CalendarCampaigns.totalDays,
lockExpired: CalendarCampaigns.lockExpired,
enabled: CalendarCampaigns.enabled,
})
.from(CalendarCampaigns)
.where(eq(CalendarCampaigns.id, campaignId))
.limit(1);
campaign = row ?? null;
} catch {
campaign = null;
}
if (!campaign) notFound();
let rewards: Reward[] = [];
try {
rewards = await db
.select({
id: CalendarRewards.id,
campaignId: CalendarRewards.campaignId,
productName: CalendarRewards.productName,
customImage: CalendarRewards.customImage,
credits: CalendarRewards.credits,
pixels: CalendarRewards.pixels,
points: CalendarRewards.points,
pointsType: CalendarRewards.pointsType,
badge: CalendarRewards.badge,
itemId: CalendarRewards.itemId,
subscriptionType: CalendarRewards.subscriptionType,
subscriptionDays: CalendarRewards.subscriptionDays,
})
.from(CalendarRewards)
.where(eq(CalendarRewards.campaignId, campaignId))
.orderBy(asc(CalendarRewards.id))
.limit(500);
} catch {
rewards = [];
}
const disabled = campaign.enabled !== "1";
return (
<main>
<p className="text-xs theme-text-muted dark:theme-text-muted mb-4">
<Link href="/admin/calendar">← {t("title")}</Link>
</p>
<div className="admin-card flex items-center gap-5 mb-6">
{campaign.image ? (
// eslint-disable-next-line @next/next/no-img-element
<img
className="w-24 h-24 object-cover"
src={campaign.image}
alt={campaign.name}
/>
) : null}
<div>
<h1 className="m-0 mb-1">{campaign.name || `#${campaign.id}`}</h1>
<p className="text-xs theme-text-muted dark:theme-text-muted m-0">
{t("detail.campaignMeta", {
id: campaign.id,
days: campaign.totalDays,
date: formatDate(
new Date(campaign.startTimestamp * 1000),
"date",
),
})}
{disabled
? ` · ${t("detail.disabled")}`
: ` · ${t("detail.enabled")}`}
{campaign.lockExpired === "1"
? ` · ${t("detail.locksExpired")}`
: ""}
</p>
</div>
</div>
<h2 className="admin-section-title">{t("detail.rewards")}</h2>
{rewards.length === 0 ? (
<div className="admin-empty">{t("detail.noRewards")}</div>
) : (
<table className="w-full">
<thead>
<tr className="text-left text-[var(--muted)]">
<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>
{rewards.map((r) => (
<tr key={r.id} className="border-t border-[var(--admin-border)]">
<td>{r.id}</td>
<td>
{r.productName || (
<span className="text-sm theme-text-muted dark:theme-text-muted">
—
</span>
)}
</td>
<td className="text-sm theme-text-muted dark:theme-text-muted whitespace-nowrap">
{r.itemId > 0 ? r.itemId : "—"}
</td>
<td>
{r.credits > 0 ? (
r.credits
) : (
<span className="text-sm theme-text-muted dark:theme-text-muted">
—
</span>
)}
</td>
<td>
{r.pixels > 0 ? (
r.pixels
) : (
<span className="text-sm theme-text-muted dark:theme-text-muted">
—
</span>
)}
</td>
<td>
{r.points > 0 ? (
`${r.points} (${t("detail.typeLabel", { type: r.pointsType })})`
) : (
<span className="text-sm theme-text-muted dark:theme-text-muted">
—
</span>
)}
</td>
<td className="text-sm theme-text-muted dark:theme-text-muted whitespace-nowrap">
{r.badge || "—"}
</td>
<td className="text-sm theme-text-muted dark:theme-text-muted whitespace-nowrap">
{r.subscriptionType
? `${r.subscriptionType} · ${r.subscriptionDays}d`
: "—"}
</td>
</tr>
))}
</tbody>
</table>
)}
<p className="text-xs theme-text-muted dark:theme-text-muted mt-4">
{t("detail.rewardCount", { count: rewards.length })}
</p>
</main>
);
}
+139
View File
@@ -0,0 +1,139 @@
import { count, desc } from "drizzle-orm";
import { redirect } from "next/navigation";
import { getTranslations } from "next-intl/server";
import { StatusCard } from "@/components/admin/dashboard";
import Link from "@/components/link";
import { CalendarCampaigns, CalendarRewards, db } from "@/lib/db";
import { formatDate } from "@/lib/format-date";
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
type Campaign = {
id: number;
name: string;
image: string;
startTimestamp: number;
totalDays: number;
lockExpired: string;
enabled: string;
};
export default async function AdminCalendar() {
const { session, permissions } = await getAdminContext();
if (!canAccess(permissions, PERMS.SHOP_VIEW, session.user.rank)) {
redirect("/admin");
}
const t = await getTranslations("pages.admin.calendar");
let campaigns: Campaign[] = [];
let loadError = false;
try {
campaigns = await db
.select({
id: CalendarCampaigns.id,
name: CalendarCampaigns.name,
image: CalendarCampaigns.image,
startTimestamp: CalendarCampaigns.startTimestamp,
totalDays: CalendarCampaigns.totalDays,
lockExpired: CalendarCampaigns.lockExpired,
enabled: CalendarCampaigns.enabled,
})
.from(CalendarCampaigns)
.orderBy(desc(CalendarCampaigns.id))
.limit(200);
} catch {
campaigns = [];
loadError = true;
}
const rewardCounts = new Map<number, number>();
let totalRewards = 0;
try {
const grouped = await db
.select({
campaignId: CalendarRewards.campaignId,
total: count(),
})
.from(CalendarRewards)
.groupBy(CalendarRewards.campaignId);
for (const g of grouped) {
rewardCounts.set(g.campaignId, Number(g.total));
totalRewards += Number(g.total);
}
} catch {
loadError = true;
}
const enabledCount = campaigns.filter((c) => c.enabled === "1").length;
return (
<main>
{loadError ? (
<div className="mb-4 rounded-lg bg-[var(--admin-error-subtle)] p-3 text-xs text-[var(--admin-error)]">
Could not load some calendar data (database unavailable).
</div>
) : null}
<div className="grid grid-cols-[repeat(auto-fit,minmax(180px,1fr))] gap-3.5 mb-6">
<StatusCard label={t("campaigns")} value={campaigns.length} icon="📅" />
<StatusCard
label={t("enabled")}
value={enabledCount}
state={enabledCount > 0 ? "ok" : "neutral"}
icon="✅"
/>
<StatusCard label={t("totalRewards")} value={totalRewards} icon="🎁" />
</div>
{campaigns.length === 0 ? (
<div className="admin-empty">{t("noCampaigns")}</div>
) : (
<div className="grid grid-cols-1 gap-4 md:grid-cols-3">
{campaigns.map((c) => {
const enabled = c.enabled === "1";
const count = rewardCounts.get(c.id) ?? 0;
return (
<Link
key={c.id}
href={`/admin/calendar/${c.id}`}
className="admin-card hover article"
>
{c.image ? (
// eslint-disable-next-line @next/next/no-img-element
<img className="article-img" src={c.image} alt={c.name} />
) : (
<div className="article-img" />
)}
<div className="body">
<div className="flex items-center justify-between gap-2 mb-1">
<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-[var(--admin-success-subtle)] text-[var(--admin-text)]" : "bg-[var(--admin-error-subtle)] text-[var(--admin-text)]"}`}
>
{enabled ? t("statusEnabled") : t("statusDisabled")}
</span>
</div>
<p className="text-xs theme-text-muted dark:theme-text-muted m-0">
{t("campaignMeta", {
id: c.id,
days: c.totalDays,
rewards: count,
})}
</p>
<p className="text-xs theme-text-muted dark:theme-text-muted mt-1">
{t("startsAt", {
date: formatDate(
new Date(c.startTimestamp * 1000),
"date",
),
})}
{c.lockExpired === "1" ? ` · ${t("locksExpired")}` : ""}
</p>
</div>
</Link>
);
})}
</div>
)}
</main>
);
}
@@ -0,0 +1,128 @@
"use client";
import { Languages, Package, Settings } from "lucide-react";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { type CatalogItemData, CatalogItemsTable } from "./catalog-items-table";
import { CatalogPageForm } from "./catalog-page-form";
import { CatalogTranslateTab } from "./catalog-translate-tab";
interface CatalogPageData {
id: number;
caption: string;
parentId: number;
pageLayout: string;
enabled: string;
visible: string;
minRank: number;
clubOnly: string;
orderNum: number;
iconImage: number;
iconColor: number;
pageHeadline: string;
pageTeaser: string;
pageSpecial: string | null;
pageText1: string | null;
pageText2: string | null;
pageTextDetails: string | null;
pageTextTeaser: string | null;
}
interface BaseItemData {
id: number;
publicName: string;
itemName: string;
spriteId: number;
type: string;
}
interface CatalogDetailTabsProps {
catalogPage: CatalogPageData;
items: CatalogItemData[];
baseItems: BaseItemData[];
catalogNameMap: Record<number, string>;
childPages: { id: number; caption: string; enabled: string }[];
pageId: number;
canEdit: boolean;
furniDataIdList: number[];
furniDescriptionMap: Record<number, string>;
furniRevisionMap: Record<number, { classname: string; revision: number }>;
allPages: { id: number; caption: string }[];
interactionTypes: string[];
gamedataHotel: string;
}
export function CatalogDetailTabs({
catalogPage,
items,
baseItems,
catalogNameMap,
childPages,
pageId,
canEdit,
furniDataIdList,
furniDescriptionMap,
furniRevisionMap,
allPages,
interactionTypes,
gamedataHotel,
}: CatalogDetailTabsProps) {
return (
<Tabs defaultValue="settings">
<TabsList className="overflow-x-auto flex-nowrap w-full">
<TabsTrigger value="settings" className="gap-2">
<Settings className="h-4 w-4" />
Settings
</TabsTrigger>
<TabsTrigger value="items" className="gap-2">
<Package className="h-4 w-4" />
Items ({items.length})
</TabsTrigger>
<TabsTrigger value="translate" className="gap-2">
<Languages className="h-4 w-4" />
Translate ({baseItems.length}){(() => {
const furniSet = new Set(furniDataIdList);
const missing = baseItems.filter((b) => !furniSet.has(b.id)).length;
return missing > 0 ? (
<span className="ml-1 rounded-full bg-[var(--admin-warning-subtle)] text-[var(--admin-warning)] text-[10px] px-1.5 py-0.5 leading-none font-medium">
{missing}
</span>
) : null;
})()}
</TabsTrigger>
</TabsList>
<TabsContent value="settings" className="mt-4">
<CatalogPageForm
catalogPage={catalogPage}
childPages={childPages}
canEdit={canEdit}
allPages={allPages}
/>
</TabsContent>
<TabsContent value="items" className="mt-4">
<CatalogItemsTable
items={items}
pageId={pageId}
pageLayout={catalogPage.pageLayout}
canEdit={canEdit}
interactionTypes={interactionTypes}
furniRevisionMap={furniRevisionMap}
allPages={allPages}
/>
</TabsContent>
<TabsContent value="translate" className="mt-4">
<CatalogTranslateTab
baseItems={baseItems}
catalogNameMap={catalogNameMap}
canEdit={canEdit}
furniDataIdList={furniDataIdList}
furniDescriptionMap={furniDescriptionMap}
furniRevisionMap={furniRevisionMap}
gamedataHotel={gamedataHotel}
/>
</TabsContent>
</Tabs>
);
}
@@ -0,0 +1,10 @@
/**
* Re-export barrel for backward compatibility.
* Implementation moved to ./catalog-items-table/ directory.
*/
export { CatalogItemsTable } from "./catalog-items-table/catalog-items-table";
export { FurniIcon } from "./catalog-items-table/furni-icon";
export type {
BaseItemData,
CatalogItemData,
} from "./catalog-items-table/types";
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,281 @@
"use client";
import { Check, ChevronsUpDown, Star } from "lucide-react";
import { useMemo, useState } from "react";
import { Button } from "@/components/ui/button";
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from "@/components/ui/command";
import { Label } from "@/components/ui/label";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { Switch } from "@/components/ui/switch";
import { cn } from "@/lib/utils";
// ── Toggle field ──────────────────────────────────────────────
export function ToggleField({
label,
checked,
value,
onChange,
disabled,
}: {
label: string;
/** Boolean checked state */
checked?: boolean;
/** Number or string (0/1 or '0'/'1') — converted to boolean automatically */
value?: number | string;
onChange: (v: boolean) => void;
disabled?: boolean;
}) {
const isChecked = checked ?? (value === 1 || value === "1");
return (
<div className="flex items-center justify-between gap-2">
<Label className="text-xs">{label}</Label>
<Switch
checked={isChecked}
onCheckedChange={onChange}
disabled={disabled}
size="sm"
/>
</div>
);
}
// ── Interaction Type Combobox ──────────────────────────────────
export function InteractionTypeCombobox({
value,
onChange,
types,
disabled,
}: {
value: string;
onChange: (v: string) => void;
types: string[];
disabled?: boolean;
}) {
const [open, setOpen] = useState(false);
const [inputValue, setInputValue] = useState("");
const filtered = useMemo(() => {
if (!inputValue.trim()) return types.slice(0, 50);
const q = inputValue.toLowerCase();
return types.filter((t) => t.toLowerCase().includes(q)).slice(0, 50);
}, [types, inputValue]);
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={open}
disabled={disabled}
className="w-full justify-between font-normal h-8 text-sm"
>
<span className="truncate">{value || "Select type..."}</span>
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent
className="w-[--radix-popover-trigger-width] p-0"
align="start"
>
<Command shouldFilter={false}>
<CommandInput
placeholder="Search or type custom..."
value={inputValue}
onValueChange={setInputValue}
/>
<CommandList>
<CommandEmpty>
{inputValue.trim() ? (
<button
type="button"
className="w-full px-2 py-1.5 text-left text-sm hover:bg-accent rounded"
onClick={() => {
onChange(inputValue.trim());
setOpen(false);
setInputValue("");
}}
>
Use &quot;{inputValue.trim()}&quot;
</button>
) : (
"No types found."
)}
</CommandEmpty>
<CommandGroup>
{filtered.map((t) => (
<CommandItem
key={t}
value={t}
onSelect={() => {
onChange(t);
setOpen(false);
setInputValue("");
}}
>
<Check
className={cn(
"mr-2 h-4 w-4",
value === t ? "opacity-100" : "opacity-0",
)}
/>
<span className="truncate">{t}</span>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
);
}
// ── Move Page Combobox with smart suggestions ─────────────────
export function MovePageCombobox({
value,
onChange,
pages,
currentPageId,
suggestedPageIds,
}: {
value: number | null;
onChange: (id: number | null) => void;
pages: { id: number; caption: string }[];
currentPageId: number;
/** Page IDs to show first as suggestions (from move-suggestions.ts) */
suggestedPageIds?: number[];
}) {
const [open, setOpen] = useState(false);
const options = useMemo(
() => pages.filter((p) => p.id !== currentPageId),
[pages, currentPageId],
);
const selectedPage = value ? pages.find((p) => p.id === value) : null;
// Build suggested pages list
const suggested = useMemo(() => {
if (!suggestedPageIds?.length) return [];
const pageMap = new Map(options.map((p) => [p.id, p]));
return suggestedPageIds.map((id) => pageMap.get(id)).filter(Boolean) as {
id: number;
caption: string;
}[];
}, [suggestedPageIds, options]);
// Non-suggested pages (exclude those already in suggestions)
const suggestedSet = useMemo(
() => new Set(suggested.map((s) => s.id)),
[suggested],
);
const remaining = useMemo(
() => options.filter((p) => !suggestedSet.has(p.id)),
[options, suggestedSet],
);
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={open}
className="w-full justify-between font-normal"
>
{selectedPage ? (
<span className="truncate">
<span className="text-muted-foreground font-mono text-xs mr-1.5">
#{selectedPage.id}
</span>
{selectedPage.caption}
</span>
) : (
<span className="text-muted-foreground">Select target page...</span>
)}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent
className="w-[--radix-popover-trigger-width] p-0"
align="start"
>
<Command>
<CommandInput placeholder="Search pages..." />
<CommandList>
<CommandEmpty>No page found.</CommandEmpty>
{/* Suggested pages */}
{suggested.length > 0 && (
<CommandGroup heading="Suggested">
{suggested.map((page) => (
<CommandItem
key={`s-${page.id}`}
value={`${page.id} ${page.caption}`}
onSelect={() => {
onChange(page.id);
setOpen(false);
}}
>
<Check
className={cn(
"mr-2 h-4 w-4",
value === page.id ? "opacity-100" : "opacity-0",
)}
/>
<Star className="mr-1.5 h-3 w-3 text-[var(--admin-warning)]" />
<span className="text-muted-foreground font-mono text-xs mr-1.5">
#{page.id}
</span>
<span className="truncate">{page.caption}</span>
</CommandItem>
))}
</CommandGroup>
)}
{/* All pages */}
<CommandGroup
heading={suggested.length > 0 ? "All pages" : undefined}
>
{remaining.map((page) => (
<CommandItem
key={page.id}
value={`${page.id} ${page.caption}`}
onSelect={() => {
onChange(page.id);
setOpen(false);
}}
>
<Check
className={cn(
"mr-2 h-4 w-4",
value === page.id ? "opacity-100" : "opacity-0",
)}
/>
<span className="text-muted-foreground font-mono text-xs mr-1.5">
#{page.id}
</span>
<span className="truncate">{page.caption}</span>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
);
}
@@ -0,0 +1,48 @@
"use client";
import { Image as ImageIcon } from "lucide-react";
import { getHabboCdnIconUrl, getLocalIconUrl } from "@/lib/furni/classname";
export function FurniIcon({
classname,
revision,
}: {
classname: string;
revision: number;
}) {
if (!classname) {
return (
<div className="w-10 h-10 rounded bg-muted flex items-center justify-center">
<ImageIcon className="h-4 w-4 text-muted-foreground" />
</div>
);
}
const localUrl = getLocalIconUrl(classname);
function handleError(e: React.SyntheticEvent<HTMLImageElement>) {
const img = e.target as HTMLImageElement;
const stage = img.dataset.fallback;
if (!stage && revision > 0) {
img.dataset.fallback = "cdn";
img.src = getHabboCdnIconUrl(classname, revision);
} else {
img.dataset.fallback = "none";
img.style.opacity = "0";
}
}
return (
<div className="w-10 h-10 rounded bg-muted/50 flex items-center justify-center overflow-hidden">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={localUrl}
alt={classname}
className="max-w-full max-h-full object-contain"
style={{ imageRendering: "pixelated" }}
onError={handleError}
loading="lazy"
/>
</div>
);
}
@@ -0,0 +1,99 @@
"use client";
import { Loader2, Music } from "lucide-react";
import { useEffect, useState } from "react";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
getCachedSongPickerOptions,
invalidateSongPickerCache,
loadSongPickerOptions,
type SoundtrackOption,
} from "@/lib/client-cache/song-picker-cache";
// Re-export so existing `import { invalidateSongPickerCache } from '.../song-picker'`
// callsites keep working. New code should import from the lib module directly.
export { invalidateSongPickerCache };
interface Props {
value: number;
onChange: (id: number) => void;
disabled?: boolean;
}
function formatLength(seconds: number): string {
if (!seconds || seconds <= 0) return "";
const m = Math.floor(seconds / 60);
const s = seconds % 60;
return `${m}:${String(s).padStart(2, "0")}`;
}
export function SongPicker({ value, onChange, disabled }: Props) {
const initial = getCachedSongPickerOptions();
const [options, setOptions] = useState<SoundtrackOption[]>(initial ?? []);
const [loading, setLoading] = useState(!initial);
useEffect(() => {
if (getCachedSongPickerOptions()) return;
let cancelled = false;
loadSongPickerOptions()
.then((items) => {
if (!cancelled) setOptions(items);
})
.catch(() => {
if (!cancelled) setOptions([]);
})
.finally(() => {
if (!cancelled) setLoading(false);
});
return () => {
cancelled = true;
};
}, []);
return (
<Select
value={String(value)}
onValueChange={(v) => onChange(parseInt(v, 10) || 0)}
disabled={disabled || loading}
>
<SelectTrigger>
{loading ? (
<span className="flex items-center gap-2 text-muted-foreground">
<Loader2 className="h-3 w-3 animate-spin" /> Loading…
</span>
) : (
<SelectValue placeholder="No song" />
)}
</SelectTrigger>
<SelectContent>
<SelectItem value="0">
<span className="text-muted-foreground">— No song —</span>
</SelectItem>
{options.map((opt) => (
<SelectItem key={opt.id} value={String(opt.id)}>
<span className="inline-flex items-center gap-2">
<Music className="h-3 w-3 text-muted-foreground" />
<span className="font-medium">{opt.name}</span>
{opt.author && (
<span className="text-xs text-muted-foreground">
— {opt.author}
</span>
)}
{opt.length > 0 && (
<span className="text-xs text-muted-foreground font-mono">
{formatLength(opt.length)}
</span>
)}
</span>
</SelectItem>
))}
</SelectContent>
</Select>
);
}
@@ -0,0 +1,87 @@
export interface BaseItemData {
id: number;
spriteId: number;
publicName: string;
itemName: string;
type: string;
width: number;
length: number;
stackHeight: number;
allowStack: number;
allowSit: number;
allowLay: number;
allowWalk: number;
allowGift: number;
allowTrade: number;
allowRecycle: number;
allowMarketplaceSell: number;
allowInventoryStack: number;
interactionType: string;
interactionModesCount: number;
vendingIds: string;
customparams: string;
effectIdMale: number;
effectIdFemale: number;
clothingOnWalk: string;
}
export interface CatalogItemData {
id: number;
catalogName: string;
itemIds: string;
costCredits: number;
costPoints: number;
pointsType: number;
amount: number;
limitedSells: number;
limitedStack: number;
orderNumber: number;
offerId: number;
songId: number;
haveOffer: string;
clubOnly: string;
extradata: string;
baseName: string;
baseItemName: string;
spriteId: number;
baseItem: BaseItemData | null;
}
export interface InlineEdit {
costCredits: number;
costPoints: number;
pointsType: number;
amount: number;
}
export const POINTS_TYPES: Record<number, { label: string; color: string }> = {
0: { label: "Duckets", color: "text-[var(--admin-info)]" },
5: { label: "Diamonds", color: "text-[var(--admin-accent-text)]" },
101: { label: "Snowflakes", color: "text-[var(--admin-info)]" },
103: { label: "Hearts", color: "text-[var(--admin-error)]" },
104: { label: "Stars", color: "text-[var(--admin-warning)]" },
105: { label: "Shells", color: "text-[var(--admin-warning)]" },
};
export const DEFAULT_NEW_ITEM = {
catalogName: "",
itemIds: "0",
costCredits: 0,
costPoints: 0,
pointsType: 0,
amount: 1,
limitedSells: 0,
limitedStack: 0,
orderNumber: 1,
offerId: -1,
songId: 0,
haveOffer: "1",
clubOnly: "0",
extradata: "",
};
export function getPointsLabel(type: number) {
return (
POINTS_TYPES[type] || { label: `Type ${type}`, color: "theme-text-muted" }
);
}
@@ -0,0 +1,738 @@
"use client";
import {
Check,
ChevronsUpDown,
FileText,
FolderTree,
Loader2,
Package,
Palette,
Save,
Settings,
Trash2,
} from "lucide-react";
import { useEffect, useRef, useState } from "react";
import { deleteCatalogPage, updateCatalogPage } from "@/actions/catalog";
import { CatalogImagePicker } from "@/components/admin/catalog/catalog-image-picker";
import { IconPicker } from "@/components/admin/catalog/icon-picker";
import { ImagePreview } from "@/components/admin/catalog-manager/image-preview";
import { CatalogIcon, LAYOUT_COLORS } from "@/components/admin/catalog-tree";
import { useConfirmDialog } from "@/components/admin/confirm-dialog";
import Link from "@/components/link";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from "@/components/ui/command";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Textarea } from "@/components/ui/textarea";
import { useServerAction } from "@/hooks/use-server-action";
import {
CATALOG_LAYOUTS,
type CatalogLayout,
LAYOUT_DESCRIPTIONS,
} from "@/lib/catalog-layouts";
import { cn } from "@/lib/utils";
interface CatalogPageData {
id: number;
caption: string;
parentId: number;
pageLayout: string;
enabled: string;
visible: string;
minRank: number;
clubOnly: string;
orderNum: number;
iconImage: number;
iconColor: number;
pageHeadline: string;
pageTeaser: string;
pageSpecial: string | null;
pageText1: string | null;
pageText2: string | null;
pageTextDetails: string | null;
pageTextTeaser: string | null;
}
interface CatalogPageFormProps {
catalogPage: CatalogPageData;
childPages: { id: number; caption: string; enabled: string }[];
canEdit: boolean;
allPages: { id: number; caption: string }[];
}
export function CatalogPageForm({
catalogPage,
childPages,
canEdit,
allPages,
}: CatalogPageFormProps) {
const { isPending, run } = useServerAction();
const { confirm, dialog: confirmDialog } = useConfirmDialog();
const formRef = useRef<HTMLFormElement>(null);
const [form, setForm] = useState({
caption: catalogPage.caption,
parentId: catalogPage.parentId,
pageLayout: catalogPage.pageLayout,
enabled: catalogPage.enabled as "0" | "1",
visible: catalogPage.visible as "0" | "1",
minRank: catalogPage.minRank,
clubOnly: catalogPage.clubOnly as "0" | "1",
orderNum: catalogPage.orderNum,
iconImage: catalogPage.iconImage,
iconColor: catalogPage.iconColor,
pageHeadline: catalogPage.pageHeadline,
pageTeaser: catalogPage.pageTeaser,
pageSpecial: catalogPage.pageSpecial || "",
pageText1: catalogPage.pageText1 || "",
pageText2: catalogPage.pageText2 || "",
pageTextDetails: catalogPage.pageTextDetails || "",
pageTextTeaser: catalogPage.pageTextTeaser || "",
});
// Ctrl+S / Cmd+S to save
useEffect(() => {
if (!canEdit) return;
function onKeyDown(e: KeyboardEvent) {
if ((e.metaKey || e.ctrlKey) && e.key === "s") {
e.preventDefault();
formRef.current?.requestSubmit();
}
}
document.addEventListener("keydown", onKeyDown);
return () => document.removeEventListener("keydown", onKeyDown);
}, [canEdit]);
function update<K extends keyof typeof form>(
key: K,
value: (typeof form)[K],
) {
setForm((prev) => ({ ...prev, [key]: value }));
}
function handleSubmit(e: React.FormEvent) {
e.preventDefault();
run(() => updateCatalogPage({ id: catalogPage.id, ...form }), {
successMessage: "Page updated and catalog synced.",
errorMessage: "Failed to update.",
});
}
async function handleDelete() {
const childCount = childPages.length;
const description =
childCount > 0
? `Delete this catalog page? Its ${childCount} child page(s) will be moved to the parent. All items on this page will be deleted.`
: "Delete this catalog page and all its items?";
const ok = await confirm({
title: "Delete catalog page",
description,
confirmLabel: "Delete",
});
if (!ok) return;
run(() => deleteCatalogPage({ id: catalogPage.id }), {
successMessage: "Page deleted.",
redirectTo: "/admin/catalog",
});
}
const layoutColorClass =
LAYOUT_COLORS[form.pageLayout] || "bg-muted text-muted-foreground";
return (
<>
{confirmDialog}
<form ref={formRef} onSubmit={handleSubmit}>
<div className="grid gap-4 lg:grid-cols-3">
{/* ── Left column (2/3): tabs ─────────────────────────── */}
<div className="lg:col-span-2 space-y-4">
<Card>
<CardContent className="pt-4">
<Tabs defaultValue="general">
<TabsList className="mb-4">
<TabsTrigger value="general" className="gap-2">
<Settings className="h-4 w-4" />
General
</TabsTrigger>
<TabsTrigger value="texts" className="gap-2">
<FileText className="h-4 w-4" />
Texts
</TabsTrigger>
<TabsTrigger value="appearance" className="gap-2">
<Palette className="h-4 w-4" />
Appearance
</TabsTrigger>
</TabsList>
{/* ── General ──────────────────────────────────── */}
<TabsContent value="general" className="space-y-4">
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="caption">Caption</Label>
<Input
id="caption"
value={form.caption}
onChange={(e) => update("caption", e.target.value)}
disabled={!canEdit}
/>
</div>
<div className="space-y-2">
<Label>Parent Page</Label>
<ParentCombobox
value={form.parentId}
onChange={(id) => update("parentId", id)}
pages={allPages}
currentPageId={catalogPage.id}
disabled={!canEdit}
/>
</div>
</div>
<div className="space-y-2">
<Label>Layout</Label>
<LayoutSelect
value={form.pageLayout}
onChange={(v) => update("pageLayout", v)}
disabled={!canEdit}
/>
<p className="text-xs text-muted-foreground">
{LAYOUT_DESCRIPTIONS[
form.pageLayout as CatalogLayout
] ?? "Custom layout"}
</p>
</div>
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="orderNum">Order</Label>
<Input
id="orderNum"
type="number"
value={form.orderNum}
onChange={(e) =>
update(
"orderNum",
parseInt(e.target.value, 10) || 0,
)
}
disabled={!canEdit}
/>
</div>
<div className="space-y-2">
<Label htmlFor="minRank">Min Rank</Label>
<Input
id="minRank"
type="number"
min={1}
max={100}
value={form.minRank}
onChange={(e) =>
update("minRank", parseInt(e.target.value, 10) || 1)
}
disabled={!canEdit}
/>
</div>
</div>
</TabsContent>
{/* ── Texts ────────────────────────────────────── */}
<TabsContent value="texts" className="space-y-4">
<div className="space-y-1.5">
<Label>Headline</Label>
<CatalogImagePicker
value={form.pageHeadline}
onChange={(v) => update("pageHeadline", v)}
type="header"
label="Headline image"
disabled={!canEdit}
/>
<ImagePreview value={form.pageHeadline} height={50} />
</div>
<div className="space-y-1.5">
<Label>Teaser</Label>
<CatalogImagePicker
value={form.pageTeaser}
onChange={(v) => update("pageTeaser", v)}
type="teaser"
label="Teaser image"
disabled={!canEdit}
/>
<ImagePreview value={form.pageTeaser} height={50} />
</div>
<div className="grid gap-4 sm:grid-cols-2">
<TextareaField
id="pageText1"
label="Text 1"
value={form.pageText1}
onChange={(v) => update("pageText1", v)}
disabled={!canEdit}
rows={4}
max={5000}
/>
<TextareaField
id="pageText2"
label="Text 2"
value={form.pageText2}
onChange={(v) => update("pageText2", v)}
disabled={!canEdit}
rows={4}
max={5000}
/>
</div>
<div className="grid gap-4 sm:grid-cols-2">
<TextareaField
id="pageTextDetails"
label="Details"
value={form.pageTextDetails}
onChange={(v) => update("pageTextDetails", v)}
disabled={!canEdit}
rows={3}
max={5000}
/>
<TextareaField
id="pageTextTeaser"
label="Text Teaser"
value={form.pageTextTeaser}
onChange={(v) => update("pageTextTeaser", v)}
disabled={!canEdit}
rows={3}
max={5000}
/>
</div>
<TextareaField
id="pageSpecial"
label="Special"
value={form.pageSpecial}
onChange={(v) => update("pageSpecial", v)}
disabled={!canEdit}
rows={2}
max={2048}
/>
</TabsContent>
{/* ── Appearance ───────────────────────────────── */}
<TabsContent value="appearance" className="space-y-4">
<div className="space-y-2">
<Label>Icon</Label>
<div className="flex items-center gap-3">
<IconPicker
value={form.iconImage}
onChange={(id) => update("iconImage", id)}
disabled={!canEdit}
/>
<p className="text-xs text-muted-foreground">
Icon shown in the catalog tree navigation.
</p>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="iconColor">Icon Color</Label>
<div className="flex items-center gap-2">
<Input
id="iconColor"
type="number"
min={0}
className="w-32"
value={form.iconColor}
onChange={(e) =>
update(
"iconColor",
parseInt(e.target.value, 10) || 0,
)
}
disabled={!canEdit}
/>
<p className="text-xs text-muted-foreground">
Legacy tint value. Leave at default unless you need to
match a specific theme.
</p>
</div>
</div>
</TabsContent>
</Tabs>
</CardContent>
</Card>
{/* Child pages list */}
{childPages.length > 0 && (
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-base flex items-center gap-2">
<FolderTree className="h-4 w-4" />
Child Pages ({childPages.length})
</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-1">
{childPages.map((child) => (
<Link
key={child.id}
href={`/admin/catalog/${child.id}`}
className="flex items-center justify-between rounded-md px-3 py-2 hover:bg-accent text-sm"
>
<span>
{child.caption}
<span className="ml-2 text-muted-foreground font-mono text-xs">
#{child.id}
</span>
</span>
<Badge
variant={
child.enabled === "1" ? "default" : "outline"
}
>
{child.enabled === "1" ? "Enabled" : "Disabled"}
</Badge>
</Link>
))}
</div>
</CardContent>
</Card>
)}
</div>
{/* ── Right column (1/3): status sticky ────────────────── */}
<div className="lg:col-span-1">
<Card className="lg:sticky lg:top-4">
<CardHeader className="pb-3">
<CardTitle className="text-base">Status & Preview</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{/* Preview */}
<div className="flex items-center gap-3 rounded-md border bg-muted/30 p-3">
<div className="flex h-12 w-12 items-center justify-center rounded bg-background shrink-0">
<CatalogIcon iconImage={form.iconImage} size={36} />
</div>
<div className="min-w-0">
<p className="truncate text-sm font-medium">
{form.caption || "Untitled"}
</p>
<Badge
className={cn(
"mt-1 h-5 px-1.5 text-[10px] font-mono border-0",
layoutColorClass,
)}
>
{form.pageLayout}
</Badge>
</div>
</div>
{/* Toggles */}
<div className="space-y-3">
<ToggleRow
id="tg-enabled"
label="Enabled"
description="Active in the catalog"
checked={form.enabled === "1"}
onChange={(v) => update("enabled", v ? "1" : "0")}
disabled={!canEdit}
/>
<ToggleRow
id="tg-visible"
label="Visible"
description="Shown in navigation"
checked={form.visible === "1"}
onChange={(v) => update("visible", v ? "1" : "0")}
disabled={!canEdit}
/>
<ToggleRow
id="tg-club"
label="Club Only"
description="Requires HC subscription"
checked={form.clubOnly === "1"}
onChange={(v) => update("clubOnly", v ? "1" : "0")}
disabled={!canEdit}
/>
</div>
{/* Stats */}
<div className="flex items-center gap-4 border-t pt-3 text-xs text-muted-foreground">
<span className="flex items-center gap-1">
<FolderTree className="h-3.5 w-3.5" />
{childPages.length} sub
</span>
<span className="flex items-center gap-1">
<Package className="h-3.5 w-3.5" />#{catalogPage.id}
</span>
</div>
{/* Actions */}
{canEdit && (
<div className="space-y-2 border-t pt-3">
<Button
type="submit"
disabled={isPending}
className="w-full"
>
{isPending ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Save className="mr-2 h-4 w-4" />
)}
Save & Sync
</Button>
<Button
type="button"
variant="destructive"
onClick={handleDelete}
disabled={isPending}
className="w-full"
>
<Trash2 className="mr-2 h-4 w-4" />
Delete Page
</Button>
<p className="text-center text-[10px] text-muted-foreground">
Press <kbd className="rounded border px-1">Ctrl+S</kbd> to
save
</p>
</div>
)}
</CardContent>
</Card>
</div>
</div>
</form>
</>
);
}
// ── Toggle row ─────────────────────────────────────────────────
function ToggleRow({
id,
label,
description,
checked,
onChange,
disabled,
}: {
id: string;
label: string;
description: string;
checked: boolean;
onChange: (v: boolean) => void;
disabled?: boolean;
}) {
return (
<div className="flex items-start justify-between gap-3">
<div className="min-w-0 flex-1">
<Label htmlFor={id} className="text-sm font-medium cursor-pointer">
{label}
</Label>
<p className="text-[11px] text-muted-foreground">{description}</p>
</div>
<Switch
id={id}
checked={checked}
onCheckedChange={onChange}
disabled={disabled}
/>
</div>
);
}
function TextareaField({
id,
label,
value,
onChange,
disabled,
rows = 3,
max,
}: {
id: string;
label: string;
value: string;
onChange: (v: string) => void;
disabled?: boolean;
rows?: number;
max?: number;
}) {
return (
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label htmlFor={id}>{label}</Label>
{max && (
<span className="text-[10px] tabular-nums text-muted-foreground">
{value.length}/{max}
</span>
)}
</div>
<Textarea
id={id}
rows={rows}
value={value}
onChange={(e) => onChange(e.target.value)}
disabled={disabled}
maxLength={max}
/>
</div>
);
}
// ── Layout select with color swatch ────────────────────────────
function LayoutSelect({
value,
onChange,
disabled,
}: {
value: string;
onChange: (v: string) => void;
disabled?: boolean;
}) {
return (
<Select value={value} onValueChange={onChange} disabled={disabled}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{CATALOG_LAYOUTS.map((layout) => {
const colorClass =
LAYOUT_COLORS[layout] || "bg-muted text-muted-foreground";
return (
<SelectItem key={layout} value={layout}>
<span className="flex items-center gap-2">
<span
className={cn(
"inline-block h-2 w-2 rounded-full",
colorClass,
)}
/>
{layout}
</span>
</SelectItem>
);
})}
</SelectContent>
</Select>
);
}
// ── Parent page combobox with search ────────────────────────────
function ParentCombobox({
value,
onChange,
pages,
currentPageId,
disabled,
}: {
value: number;
onChange: (id: number) => void;
pages: { id: number; caption: string }[];
currentPageId: number;
disabled?: boolean;
}) {
const [open, setOpen] = useState(false);
const options = pages.filter((p) => p.id !== currentPageId);
const selected =
value === -1
? { id: -1, caption: "Root" }
: pages.find((p) => p.id === value);
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
type="button"
variant="outline"
role="combobox"
aria-expanded={open}
disabled={disabled}
className="w-full justify-between font-normal"
>
{selected ? (
<span className="truncate">
<span className="text-muted-foreground font-mono text-xs mr-1.5">
{selected.id === -1 ? "—" : `#${selected.id}`}
</span>
{selected.caption}
</span>
) : (
<span className="text-muted-foreground">Select parent...</span>
)}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent
className="w-[--radix-popover-trigger-width] p-0"
align="start"
>
<Command>
<CommandInput placeholder="Search pages..." />
<CommandList>
<CommandEmpty>No page found.</CommandEmpty>
<CommandGroup>
<CommandItem
value="root"
onSelect={() => {
onChange(-1);
setOpen(false);
}}
>
<Check
className={cn(
"mr-2 h-4 w-4",
value === -1 ? "opacity-100" : "opacity-0",
)}
/>
<span className="text-muted-foreground font-mono text-xs mr-1.5">
—
</span>
Root
</CommandItem>
{options.map((page) => (
<CommandItem
key={page.id}
value={`${page.id} ${page.caption}`}
onSelect={() => {
onChange(page.id);
setOpen(false);
}}
>
<Check
className={cn(
"mr-2 h-4 w-4",
value === page.id ? "opacity-100" : "opacity-0",
)}
/>
<span className="text-muted-foreground font-mono text-xs mr-1.5">
#{page.id}
</span>
<span className="truncate">{page.caption}</span>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
);
}
@@ -0,0 +1,617 @@
"use client";
import {
AlertTriangle,
CheckCircle2,
Database,
FileJson,
Filter,
Loader2,
RotateCcw,
Save,
ShoppingCart,
Sparkles,
} from "lucide-react";
import { useRouter } from "next/navigation";
import { useCallback, useEffect, useMemo, useState } from "react";
import { toast } from "sonner";
import { translateCatalogItems } from "@/actions/catalog-items";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Checkbox } from "@/components/ui/checkbox";
import { Input } from "@/components/ui/input";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { useServerAction } from "@/hooks/use-server-action";
import {
habboGamedataHotelLabel,
habboGamedataHotelShort,
normalizeHabboGamedataHotel,
} from "@/lib/habbo-gamedata-hotel";
import { FurniIcon } from "./catalog-items-table";
interface BaseItemForTranslation {
id: number;
publicName: string;
itemName: string;
spriteId: number;
type: string;
}
interface CatalogTranslateTabProps {
baseItems: BaseItemForTranslation[];
catalogNameMap: Record<number, string>;
canEdit: boolean;
furniDataIdList: number[];
furniDescriptionMap: Record<number, string>;
furniRevisionMap?: Record<number, { classname: string; revision: number }>;
/** CMS setting `habbo_gamedata_hotel` — which official Habbo locale to suggest from */
gamedataHotel?: string;
}
type FilterMode = "all" | "missing" | "modified";
export function CatalogTranslateTab({
baseItems,
catalogNameMap,
canEdit,
furniDataIdList,
furniDescriptionMap,
furniRevisionMap = {},
gamedataHotel = "it",
}: CatalogTranslateTabProps) {
const router = useRouter();
const { run, isPending } = useServerAction();
const [isSuggesting, setIsSuggesting] = useState(false);
const [filterMode, setFilterMode] = useState<FilterMode>("all");
const [activeHotel, setActiveHotel] = useState(() =>
normalizeHabboGamedataHotel(gamedataHotel),
);
useEffect(() => {
setActiveHotel(normalizeHabboGamedataHotel(gamedataHotel));
}, [gamedataHotel]);
// Always refresh from CMS settings so the button label tracks hotel changes
// without requiring a full page remount after saving Settings.
useEffect(() => {
let cancelled = false;
async function refreshHotel() {
try {
const res = await fetch("/api/admin/catalog/suggest", {
cache: "no-store",
});
if (!res.ok) return;
const data = (await res.json()) as { hotel?: string };
if (!cancelled && typeof data.hotel === "string") {
setActiveHotel(normalizeHabboGamedataHotel(data.hotel));
}
} catch {
// keep prop/default
}
}
void refreshHotel();
const onFocus = () => void refreshHotel();
const onVisibility = () => {
if (document.visibilityState === "visible") void refreshHotel();
};
window.addEventListener("focus", onFocus);
document.addEventListener("visibilitychange", onVisibility);
return () => {
cancelled = true;
window.removeEventListener("focus", onFocus);
document.removeEventListener("visibilitychange", onVisibility);
};
}, []);
const hotelShort = habboGamedataHotelShort(activeHotel);
const hotelLabel = habboGamedataHotelLabel(activeHotel);
const furniDataIdSet = useMemo(
() => new Set(furniDataIdList),
[furniDataIdList],
);
const [selected, setSelected] = useState<Set<number>>(new Set());
// ── Editable state: names + descriptions ─────────────────────────
const [editedNames, setEditedNames] = useState<Record<number, string>>(
Object.fromEntries(baseItems.map((item) => [item.id, item.publicName])),
);
const [editedDescs, setEditedDescs] = useState<Record<number, string>>(
Object.fromEntries(
baseItems.map((item) => [item.id, furniDescriptionMap[item.id] ?? ""]),
),
);
// Track which items have name or description changes
const isNameChanged = useCallback(
(item: BaseItemForTranslation) => editedNames[item.id] !== item.publicName,
[editedNames],
);
const isDescChanged = useCallback(
(item: BaseItemForTranslation) =>
editedDescs[item.id] !== (furniDescriptionMap[item.id] ?? ""),
[editedDescs, furniDescriptionMap],
);
const changedItems = useMemo(
() =>
baseItems.filter((item) => isNameChanged(item) || isDescChanged(item)),
[baseItems, isNameChanged, isDescChanged],
);
const missingItems = useMemo(
() => baseItems.filter((item) => !furniDataIdSet.has(item.id)),
[baseItems, furniDataIdSet],
);
const filteredItems = useMemo(() => {
switch (filterMode) {
case "missing":
return baseItems.filter((item) => !furniDataIdSet.has(item.id));
case "modified":
return baseItems.filter(
(item) => isNameChanged(item) || isDescChanged(item),
);
default:
return baseItems;
}
}, [baseItems, filterMode, furniDataIdSet, isNameChanged, isDescChanged]);
const toggleOne = useCallback((id: number) => {
setSelected((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
}, []);
const toggleAll = useCallback(() => {
setSelected((prev) =>
prev.size === filteredItems.length
? new Set()
: new Set(filteredItems.map((i) => i.id)),
);
}, [filteredItems]);
// Items to act on: selected ones if any, otherwise all
const targetItems = useMemo(() => {
if (selected.size === 0) return baseItems;
return baseItems.filter((i) => selected.has(i.id));
}, [baseItems, selected]);
function handleNameChange(id: number, value: string) {
setEditedNames((prev) => ({ ...prev, [id]: value }));
}
function handleDescChange(id: number, value: string) {
setEditedDescs((prev) => ({ ...prev, [id]: value }));
}
function handleReset() {
setEditedNames(
Object.fromEntries(baseItems.map((item) => [item.id, item.publicName])),
);
setEditedDescs(
Object.fromEntries(
baseItems.map((item) => [item.id, furniDescriptionMap[item.id] ?? ""]),
),
);
}
async function handleSuggestFromHabboFurni() {
if (targetItems.length === 0) return;
setIsSuggesting(true);
try {
// Collect classnames from selected items (or all if none selected)
const classnames = targetItems
.map((item) => item.itemName)
.filter(Boolean);
if (classnames.length === 0) {
toast.info("No classnames found to look up.");
setIsSuggesting(false);
return;
}
// Batch in chunks of 50 to avoid too-long URLs
const CHUNK_SIZE = 50;
const allSuggestions: Record<
string,
{ name: string; description: string }
> = {};
let suggestHotel = activeHotel;
for (let i = 0; i < classnames.length; i += CHUNK_SIZE) {
const chunk = classnames.slice(i, i + CHUNK_SIZE);
const res = await fetch(
`/api/admin/catalog/suggest?classnames=${encodeURIComponent(chunk.join(","))}`,
);
if (res.ok) {
const data = await res.json();
if (typeof data.hotel === "string") {
suggestHotel = normalizeHabboGamedataHotel(data.hotel);
setActiveHotel(suggestHotel);
}
Object.assign(allSuggestions, data.suggestions || {});
}
}
const suggestLabel = habboGamedataHotelLabel(suggestHotel);
// Apply suggestions to items that have a match (names + descriptions)
let nameCount = 0;
let descCount = 0;
const newNames = { ...editedNames };
const newDescs = { ...editedDescs };
for (const item of targetItems) {
const suggestion = allSuggestions[item.itemName];
if (!suggestion) continue;
if (suggestion.name && suggestion.name !== item.publicName) {
newNames[item.id] = suggestion.name;
nameCount++;
}
const currentDesc = furniDescriptionMap[item.id] ?? "";
if (suggestion.description && suggestion.description !== currentDesc) {
newDescs[item.id] = suggestion.description;
descCount++;
}
}
setEditedNames(newNames);
setEditedDescs(newDescs);
const total = nameCount + descCount;
if (total > 0) {
const parts = [];
if (nameCount > 0) parts.push(`${nameCount} name(s)`);
if (descCount > 0) parts.push(`${descCount} description(s)`);
toast.success(
`${parts.join(" + ")} suggested from ${suggestLabel}. Review and save.`,
);
} else {
toast.info(
`No new suggestions from ${suggestLabel} (${Object.keys(allSuggestions).length} looked up, all already match).`,
);
}
} catch (err) {
toast.error(`Suggest error: ${(err as Error).message}`);
} finally {
setIsSuggesting(false);
}
}
async function handleSaveAll() {
if (changedItems.length === 0) return;
run(
() =>
translateCatalogItems({
items: changedItems.map((item) => ({
id: item.id,
publicName: editedNames[item.id],
description: editedDescs[item.id],
})),
}),
{
successMessage: "Translations saved.",
errorMessage: "Failed to save translations.",
onSuccess: (data) => {
const d = data as Record<string, number | boolean>;
const parts = [];
if (d.namesUpdated && (d.namesUpdated as number) > 0)
parts.push(`${d.namesUpdated} name(s) updated`);
if (d.descriptionsUpdated && (d.descriptionsUpdated as number) > 0)
parts.push(`${d.descriptionsUpdated} description(s) updated`);
if (parts.length === 0)
parts.push(`${d.updated ?? 0} item(s) processed`);
if (d.furniDataUpdated) parts.push("FurnitureData.json synced");
if (d.furniDataInserted && (d.furniDataInserted as number) > 0) {
parts.push(
`${d.furniDataInserted} new item(s) added to FurnitureData.json`,
);
}
parts.push("emulator cache refreshed");
toast.success(`${parts.join(" · ")}.`);
router.refresh();
},
},
);
}
return (
<Card>
<CardHeader className="flex flex-row items-center justify-between gap-4">
<div className="min-w-0">
<CardTitle>Translate Item Names</CardTitle>
<p className="text-sm text-muted-foreground mt-1">
Edit the public name and description of furniture items. Saving will
update:
</p>
<div className="flex gap-3 mt-2 flex-wrap">
<span className="inline-flex items-center gap-1 text-xs text-muted-foreground">
<Database className="h-3 w-3" /> items_base (name)
</span>
<span className="inline-flex items-center gap-1 text-xs text-muted-foreground">
<ShoppingCart className="h-3 w-3" /> catalogName (auto)
</span>
<span className="inline-flex items-center gap-1 text-xs text-muted-foreground">
<FileJson className="h-3 w-3" /> FurnitureData.json (name + desc)
</span>
</div>
{missingItems.length > 0 && (
<div className="flex items-center gap-1.5 mt-2 text-xs text-[var(--admin-warning)] dark:text-[var(--admin-warning)]">
<AlertTriangle className="h-3.5 w-3.5" />
<span>
{missingItems.length} item(s) missing from FurnitureData.json —
will be auto-inserted on save
</span>
</div>
)}
</div>
{canEdit && (
<div className="flex gap-2 flex-shrink-0 flex-wrap justify-end">
<Button
variant="outline"
size="sm"
onClick={handleSuggestFromHabboFurni}
disabled={isSuggesting || isPending || targetItems.length === 0}
title={`Suggest names + descriptions from ${hotelLabel}. Change hotel in Admin → Settings → Game assets.`}
>
{isSuggesting ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Sparkles className="mr-2 h-4 w-4" />
)}
Suggest {hotelShort}
{selected.size > 0 ? ` (${selected.size})` : ""}
</Button>
<Button
variant="outline"
size="sm"
onClick={handleReset}
disabled={changedItems.length === 0 || isPending}
>
<RotateCcw className="mr-2 h-4 w-4" />
Reset
</Button>
<Button
size="sm"
onClick={handleSaveAll}
disabled={isPending || changedItems.length === 0}
>
{isPending ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Save className="mr-2 h-4 w-4" />
)}
Save All ({changedItems.length})
</Button>
</div>
)}
</CardHeader>
<CardContent>
{/* Filter bar */}
{baseItems.length > 0 && (
<div className="flex items-center gap-2 mb-4">
<Filter className="h-4 w-4 text-muted-foreground" />
<div className="flex gap-1">
<Button
variant={filterMode === "all" ? "default" : "outline"}
size="sm"
className="h-7 text-xs"
onClick={() => setFilterMode("all")}
>
All ({baseItems.length})
</Button>
<Button
variant={filterMode === "missing" ? "default" : "outline"}
size="sm"
className="h-7 text-xs"
onClick={() => setFilterMode("missing")}
disabled={missingItems.length === 0}
>
<AlertTriangle className="mr-1 h-3 w-3" />
Missing ({missingItems.length})
</Button>
<Button
variant={filterMode === "modified" ? "default" : "outline"}
size="sm"
className="h-7 text-xs"
onClick={() => setFilterMode("modified")}
disabled={changedItems.length === 0}
>
Modified ({changedItems.length})
</Button>
</div>
</div>
)}
{baseItems.length === 0 ? (
<p className="text-sm text-muted-foreground text-center py-8">
No items to translate in this page.
</p>
) : filteredItems.length === 0 ? (
<p className="text-sm text-muted-foreground text-center py-8">
No items match the current filter.
</p>
) : (
<TooltipProvider>
<div className="rounded-md border overflow-auto max-h-[600px]">
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-[40px] px-2">
<Checkbox
checked={
filteredItems.length > 0 &&
selected.size === filteredItems.length
}
onCheckedChange={toggleAll}
aria-label="Select all"
/>
</TableHead>
<TableHead className="w-[50px]"></TableHead>
<TableHead className="w-[60px]">ID</TableHead>
<TableHead className="w-[80px]">Type</TableHead>
<TableHead>Item Name</TableHead>
<TableHead className="min-w-[200px]">Public Name</TableHead>
<TableHead className="min-w-[200px]">Description</TableHead>
<TableHead className="w-[100px]">Status</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filteredItems.map((item) => {
const nameChanged = isNameChanged(item);
const descChanged = isDescChanged(item);
const isChanged = nameChanged || descChanged;
const isMissing = !furniDataIdSet.has(item.id);
const currentCatalogName = catalogNameMap[item.id];
return (
<TableRow
key={item.id}
className={
isChanged
? "bg-[var(--admin-warning-subtle)] dark:bg-[var(--admin-warning-subtle)]"
: isMissing
? "bg-[var(--admin-warning-subtle)] dark:bg-[var(--admin-warning-subtle)]"
: undefined
}
>
<TableCell className="px-2">
<Checkbox
checked={selected.has(item.id)}
onCheckedChange={() => toggleOne(item.id)}
aria-label={`Select ${item.itemName}`}
/>
</TableCell>
<TableCell className="px-1">
{(() => {
const revInfo = furniRevisionMap[item.id];
return revInfo ? (
<FurniIcon
classname={revInfo.classname}
revision={revInfo.revision}
/>
) : (
<FurniIcon
classname={item.itemName}
revision={0}
/>
);
})()}
</TableCell>
<TableCell className="font-mono text-xs">
{item.id}
</TableCell>
<TableCell>
<Badge
variant={
item.type === "s" ? "default" : "secondary"
}
className="text-xs"
>
{item.type === "s"
? "Floor"
: item.type === "i"
? "Wall"
: item.type}
</Badge>
</TableCell>
<TableCell className="text-xs text-muted-foreground font-mono">
{item.itemName}
</TableCell>
<TableCell>
<Input
value={editedNames[item.id] || ""}
onChange={(e) =>
handleNameChange(item.id, e.target.value)
}
disabled={!canEdit}
className={`h-8 text-sm ${nameChanged ? "border-[var(--admin-warning-border)] dark:border-[var(--admin-warning-border)]" : ""}`}
/>
{currentCatalogName &&
currentCatalogName !== item.publicName && (
<p className="text-[11px] text-muted-foreground mt-0.5 truncate">
catalog: {currentCatalogName}
</p>
)}
</TableCell>
<TableCell>
<Input
value={editedDescs[item.id] || ""}
onChange={(e) =>
handleDescChange(item.id, e.target.value)
}
disabled={!canEdit}
placeholder="No description"
className={`h-8 text-sm ${descChanged ? "border-[var(--admin-warning-border)] dark:border-[var(--admin-warning-border)]" : ""}`}
/>
</TableCell>
<TableCell>
<div className="flex items-center gap-1.5">
{isChanged ? (
<Badge
variant="secondary"
className="text-xs bg-[var(--admin-warning-subtle)] text-[var(--admin-warning)]"
>
Modified
</Badge>
) : (
<span className="text-xs text-muted-foreground">
—
</span>
)}
{isMissing ? (
<Tooltip>
<TooltipTrigger asChild>
<AlertTriangle className="h-3.5 w-3.5 text-[var(--admin-warning)] flex-shrink-0" />
</TooltipTrigger>
<TooltipContent>
<p>Missing from FurnitureData.json</p>
<p className="text-xs text-muted-foreground">
Will be auto-inserted on save
</p>
</TooltipContent>
</Tooltip>
) : (
<Tooltip>
<TooltipTrigger asChild>
<CheckCircle2 className="h-3.5 w-3.5 text-[var(--admin-success)] flex-shrink-0" />
</TooltipTrigger>
<TooltipContent>
In FurnitureData.json
</TooltipContent>
</Tooltip>
)}
</div>
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</div>
</TooltipProvider>
)}
</CardContent>
</Card>
);
}
+44
View File
@@ -0,0 +1,44 @@
import { Skeleton } from "@/components/ui/skeleton";
export default function CatalogDetailLoading() {
return (
<div className="space-y-6">
{/* Breadcrumb */}
<div className="flex items-center gap-2">
<Skeleton className="h-4 w-16" />
<Skeleton className="h-4 w-4" />
<Skeleton className="h-4 w-24" />
<Skeleton className="h-4 w-4" />
<Skeleton className="h-4 w-20" />
</div>
{/* Title */}
<div>
<Skeleton className="h-9 w-64" />
<Skeleton className="h-5 w-40 mt-2" />
</div>
{/* Tabs */}
<div className="space-y-4">
<div className="flex gap-2 border-b pb-2">
{Array.from({ length: 4 }, (_, i) => i).map((n) => (
<Skeleton
key={`skeleton-tab-${n}`}
className="h-9 w-24 rounded-md"
/>
))}
</div>
{/* Tab content */}
<div className="rounded-lg border bg-card p-6 space-y-4">
{Array.from({ length: 6 }, (_, i) => i).map((n) => (
<div key={`skeleton-field-${n}`} className="space-y-2">
<Skeleton className="h-4 w-24" />
<Skeleton className="h-10 w-full" />
</div>
))}
</div>
</div>
</div>
);
}
+139
View File
@@ -0,0 +1,139 @@
import { asc, eq } from "drizzle-orm";
import { ChevronRight } from "lucide-react";
import { notFound, redirect } from "next/navigation";
import { Fragment } from "react";
import Link from "@/components/link";
import { CatalogPages, db } from "@/lib/db";
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
import { loadCatalogItemsData } from "@/lib/services/catalog-items-loader";
import { getAncestors } from "@/lib/services/catalog-tree";
import { CatalogDetailTabs } from "./catalog-detail-tabs";
export default async function CatalogEditPage({
params,
}: {
params: Promise<{ locale: string; id: string }>;
}) {
const { id } = await params;
const { session, permissions } = await getAdminContext();
if (!canAccess(permissions, PERMS.CATALOG_VIEW, session.user.rank)) {
redirect("/admin");
}
const pageId = parseInt(id, 10);
if (Number.isNaN(pageId)) notFound();
const [catalogPage] = await db
.select()
.from(CatalogPages)
.where(eq(CatalogPages.id, pageId))
.limit(1);
if (!catalogPage) notFound();
const [children, itemsData, ancestors] = await Promise.all([
db
.select({
id: CatalogPages.id,
caption: CatalogPages.caption,
enabled: CatalogPages.enabled,
})
.from(CatalogPages)
.where(eq(CatalogPages.parentId, pageId))
.orderBy(asc(CatalogPages.orderNum)),
loadCatalogItemsData(pageId),
getAncestors(catalogPage.parentId),
]);
const {
items: catalogItems,
baseItems,
catalogNameMap,
furniDataIdList,
furniDescriptionMap,
furniRevisionMap,
interactionTypes,
allPages,
gamedataHotel,
} = itemsData;
const canEdit = canAccess(permissions, PERMS.CATALOG_EDIT, session.user.rank);
return (
<div className="space-y-6">
{/* Breadcrumb */}
<nav className="flex items-center gap-1 text-sm text-muted-foreground">
<Link
href={"/admin/catalog"}
className="hover:text-foreground transition-colors"
>
Catalog
</Link>
{ancestors.map((bc) => (
<Fragment key={bc.id}>
<ChevronRight className="h-3 w-3 flex-shrink-0" />
<Link
href={`/admin/catalog/${bc.id}`}
className="hover:text-foreground transition-colors"
>
{bc.caption}
</Link>
</Fragment>
))}
<ChevronRight className="h-3 w-3 flex-shrink-0" />
<span className="text-foreground font-medium">
{catalogPage.caption}
</span>
</nav>
{/* Title */}
<div>
<h1 className="text-3xl font-bold tracking-tight">
{catalogPage.caption}
<span className="ml-2 text-base font-normal text-muted-foreground">
#{catalogPage.id}
</span>
</h1>
<p className="text-muted-foreground">
Layout: {catalogPage.pageLayout} &middot; {catalogItems.length} items
</p>
</div>
{/* Tabs */}
<CatalogDetailTabs
catalogPage={{
id: catalogPage.id,
caption: catalogPage.caption,
parentId: catalogPage.parentId,
pageLayout: catalogPage.pageLayout,
enabled: catalogPage.enabled,
visible: catalogPage.visible,
minRank: catalogPage.minRank,
clubOnly: catalogPage.clubOnly,
orderNum: catalogPage.orderNum,
iconImage: catalogPage.iconImage,
iconColor: catalogPage.iconColor,
pageHeadline: catalogPage.pageHeadline,
pageTeaser: catalogPage.pageTeaser,
pageSpecial: catalogPage.pageSpecial,
pageText1: catalogPage.pageText1,
pageText2: catalogPage.pageText2,
pageTextDetails: catalogPage.pageTextDetails,
pageTextTeaser: catalogPage.pageTextTeaser,
}}
items={catalogItems}
baseItems={baseItems}
catalogNameMap={catalogNameMap}
childPages={children}
pageId={catalogPage.id}
canEdit={canEdit}
furniDataIdList={furniDataIdList}
furniDescriptionMap={furniDescriptionMap}
furniRevisionMap={furniRevisionMap}
allPages={allPages}
interactionTypes={interactionTypes}
gamedataHotel={gamedataHotel}
/>
</div>
);
}
@@ -0,0 +1,67 @@
import { asc, eq } from "drizzle-orm";
import { notFound, redirect } from "next/navigation";
import { CatalogItemsBc, CatalogPagesBc, db } from "@/lib/db";
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
import { BcPageDetail } from "../bc-manager";
export default async function BcPageDetailPage({
params: paramsPromise,
}: {
params: Promise<{ locale: string; id: string }>;
}) {
const { id: idStr } = await paramsPromise;
const id = parseInt(idStr, 10);
if (Number.isNaN(id)) notFound();
const { session, permissions } = await getAdminContext();
if (!canAccess(permissions, PERMS.CATALOG_VIEW, session.user.rank)) {
redirect("/admin");
}
const canEdit = canAccess(permissions, PERMS.CATALOG_EDIT, session.user.rank);
const [bcPage] = await db
.select()
.from(CatalogPagesBc)
.where(eq(CatalogPagesBc.id, id))
.limit(1);
if (!bcPage) notFound();
const bcItems = await db
.select()
.from(CatalogItemsBc)
.where(eq(CatalogItemsBc.pageId, id))
.orderBy(asc(CatalogItemsBc.orderNumber));
return (
<BcPageDetail
page={{
id: bcPage.id,
parentId: bcPage.parentId,
caption: bcPage.caption,
pageLayout: bcPage.pageLayout,
iconColor: bcPage.iconColor,
iconImage: bcPage.iconImage,
orderNum: bcPage.orderNum,
visible: bcPage.visible,
enabled: bcPage.enabled,
pageHeadline: bcPage.pageHeadline,
pageTeaser: bcPage.pageTeaser,
pageSpecial: bcPage.pageSpecial ?? "",
pageText1: bcPage.pageText1 ?? "",
pageText2: bcPage.pageText2 ?? "",
pageTextDetails: bcPage.pageTextDetails ?? "",
pageTextTeaser: bcPage.pageTextTeaser ?? "",
}}
items={bcItems.map((i) => ({
id: i.id,
pageId: i.pageId,
itemIds: i.itemIds,
catalogName: i.catalogName,
orderNumber: i.orderNumber,
extradata: i.extradata,
}))}
canEdit={canEdit}
/>
);
}
@@ -0,0 +1,608 @@
"use client";
import {
ArrowLeft,
HardHat,
Loader2,
Package,
Pencil,
Plus,
Save,
Trash2,
} from "lucide-react";
import { useEffect, useRef, useState } from "react";
import {
createBcItem,
deleteBcItem,
updateBcItem,
updateBcPage,
} from "@/actions/catalog-bc";
import { useConfirmDialog } from "@/components/admin/confirm-dialog";
import Link from "@/components/link";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { Textarea } from "@/components/ui/textarea";
import { useServerAction } from "@/hooks/use-server-action";
// ── Types ────────────────────────────────────────────────────────────
export interface BcPageData {
id: number;
parentId: number;
caption: string;
pageLayout: string;
iconColor: number;
iconImage: number;
orderNum: number;
visible: string;
enabled: string;
pageHeadline: string;
pageTeaser: string;
pageSpecial: string;
pageText1: string;
pageText2: string;
pageTextDetails: string;
pageTextTeaser: string;
}
export interface BcItemData {
id: number;
pageId: number;
itemIds: string;
catalogName: string;
orderNumber: number;
extradata: string;
}
interface BcPageDetailProps {
page: BcPageData;
items: BcItemData[];
canEdit: boolean;
}
// ── Main Component ───────────────────────────────────────────────────
export function BcPageDetail({ page, items, canEdit }: BcPageDetailProps) {
const { isPending, run } = useServerAction();
const { confirm, dialog: confirmDialog } = useConfirmDialog();
const formRef = useRef<HTMLFormElement>(null);
const [editingItem, setEditingItem] = useState<BcItemData | null>(null);
const [addingItem, setAddingItem] = useState(false);
const [form, setForm] = useState({
caption: page.caption,
orderNum: page.orderNum,
enabled: page.enabled as "0" | "1",
visible: page.visible as "0" | "1",
pageHeadline: page.pageHeadline,
pageTeaser: page.pageTeaser,
pageSpecial: page.pageSpecial,
pageText1: page.pageText1,
pageText2: page.pageText2,
pageTextDetails: page.pageTextDetails,
pageTextTeaser: page.pageTextTeaser,
});
// Ctrl+S / Cmd+S to save
useEffect(() => {
if (!canEdit) return;
function onKeyDown(e: KeyboardEvent) {
if ((e.metaKey || e.ctrlKey) && e.key === "s") {
e.preventDefault();
formRef.current?.requestSubmit();
}
}
document.addEventListener("keydown", onKeyDown);
return () => document.removeEventListener("keydown", onKeyDown);
}, [canEdit]);
function update<K extends keyof typeof form>(
key: K,
value: (typeof form)[K],
) {
setForm((prev) => ({ ...prev, [key]: value }));
}
function handleSubmit(e: React.FormEvent) {
e.preventDefault();
run(() => updateBcPage({ id: page.id, ...form }), {
successMessage: "BC page updated and catalog synced.",
errorMessage: "Failed to update.",
});
}
return (
<div className="space-y-6">
{confirmDialog}
{/* Header */}
<div className="flex items-center gap-3">
<Link
href="/admin/catalog?catalog=bc"
className="flex items-center gap-1 text-sm text-muted-foreground hover:text-foreground transition-colors"
>
<ArrowLeft className="h-4 w-4" />
BC Catalog
</Link>
<span className="text-muted-foreground">/</span>
<div className="flex items-center gap-2">
<HardHat className="h-5 w-5 text-[var(--admin-warning)]" />
<h1 className="text-2xl font-bold tracking-tight">{page.caption}</h1>
<Badge variant="outline" className="font-mono text-xs">
#{page.id}
</Badge>
<Badge variant="secondary" className="font-mono text-xs">
{page.pageLayout}
</Badge>
</div>
</div>
<form ref={formRef} onSubmit={handleSubmit}>
<div className="grid gap-4 lg:grid-cols-3">
{/* ── Left column: form fields ─────────────── */}
<div className="lg:col-span-2 space-y-4">
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-base">Page Settings</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="caption">Caption</Label>
<Input
id="caption"
value={form.caption}
onChange={(e) => update("caption", e.target.value)}
disabled={!canEdit}
/>
</div>
<div className="space-y-2">
<Label htmlFor="orderNum">Order</Label>
<Input
id="orderNum"
type="number"
value={form.orderNum}
onChange={(e) =>
update("orderNum", parseInt(e.target.value, 10) || 0)
}
disabled={!canEdit}
/>
</div>
</div>
<div className="space-y-2">
<Label>Headline</Label>
<Input
value={form.pageHeadline}
onChange={(e) => update("pageHeadline", e.target.value)}
disabled={!canEdit}
placeholder="Headline image path"
/>
</div>
<div className="space-y-2">
<Label>Teaser</Label>
<Input
value={form.pageTeaser}
onChange={(e) => update("pageTeaser", e.target.value)}
disabled={!canEdit}
placeholder="Teaser image path"
/>
</div>
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label>Text 1</Label>
<Textarea
value={form.pageText1}
onChange={(e) => update("pageText1", e.target.value)}
disabled={!canEdit}
rows={3}
/>
</div>
<div className="space-y-2">
<Label>Text 2</Label>
<Textarea
value={form.pageText2}
onChange={(e) => update("pageText2", e.target.value)}
disabled={!canEdit}
rows={3}
/>
</div>
</div>
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label>Details</Label>
<Textarea
value={form.pageTextDetails}
onChange={(e) =>
update("pageTextDetails", e.target.value)
}
disabled={!canEdit}
rows={3}
/>
</div>
<div className="space-y-2">
<Label>Text Teaser</Label>
<Textarea
value={form.pageTextTeaser}
onChange={(e) => update("pageTextTeaser", e.target.value)}
disabled={!canEdit}
rows={3}
/>
</div>
</div>
<div className="space-y-2">
<Label>Special</Label>
<Textarea
value={form.pageSpecial}
onChange={(e) => update("pageSpecial", e.target.value)}
disabled={!canEdit}
rows={2}
/>
</div>
</CardContent>
</Card>
{/* Items */}
<Card>
<CardHeader className="pb-3">
<div className="flex items-center justify-between">
<CardTitle className="text-base flex items-center gap-2">
<Package className="h-4 w-4" />
Items ({items.length})
</CardTitle>
{canEdit && (
<Button
type="button"
variant="outline"
size="sm"
className="h-7 text-xs"
onClick={() => setAddingItem(true)}
>
<Plus className="h-3 w-3 mr-1" />
Add Item
</Button>
)}
</div>
</CardHeader>
<CardContent>
{items.length === 0 ? (
<p className="text-sm text-muted-foreground text-center py-4">
No items in this page.
</p>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead className="text-xs">ID</TableHead>
<TableHead className="text-xs">Name</TableHead>
<TableHead className="text-xs">Item IDs</TableHead>
<TableHead className="text-xs">Order</TableHead>
<TableHead className="text-xs">Extra Data</TableHead>
{canEdit && (
<TableHead className="text-xs w-20">
Actions
</TableHead>
)}
</TableRow>
</TableHeader>
<TableBody>
{items.map((item) => (
<TableRow key={item.id}>
<TableCell className="text-xs font-mono">
{item.id}
</TableCell>
<TableCell className="text-xs font-medium">
{item.catalogName}
</TableCell>
<TableCell className="text-xs font-mono text-muted-foreground">
{item.itemIds}
</TableCell>
<TableCell className="text-xs">
{item.orderNumber}
</TableCell>
<TableCell className="text-xs text-muted-foreground max-w-32 truncate">
{item.extradata || "\u2014"}
</TableCell>
{canEdit && (
<TableCell>
<div className="flex gap-1">
<Button
type="button"
variant="ghost"
size="sm"
className="h-6 w-6 p-0"
onClick={() => setEditingItem(item)}
>
<Pencil className="h-3 w-3" />
</Button>
<Button
type="button"
variant="ghost"
size="sm"
className="h-6 w-6 p-0 text-[var(--admin-error)] hover:text-[var(--admin-error)]"
onClick={async () => {
const ok = await confirm({
title: "Delete BC item",
description: `Delete item "${item.catalogName}"?`,
confirmLabel: "Delete",
});
if (!ok) return;
run(() => deleteBcItem({ id: item.id }), {
successMessage: "Item deleted.",
});
}}
>
<Trash2 className="h-3 w-3" />
</Button>
</div>
</TableCell>
)}
</TableRow>
))}
</TableBody>
</Table>
)}
</CardContent>
</Card>
</div>
{/* ── Right column: status ─────────────────── */}
<div className="lg:col-span-1">
<Card className="lg:sticky lg:top-4">
<CardHeader className="pb-3">
<CardTitle className="text-base">Status</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-3">
<ToggleRow
id="tg-enabled"
label="Enabled"
description="Active in the BC catalog"
checked={form.enabled === "1"}
onChange={(v) => update("enabled", v ? "1" : "0")}
disabled={!canEdit}
/>
<ToggleRow
id="tg-visible"
label="Visible"
description="Shown in BC navigation"
checked={form.visible === "1"}
onChange={(v) => update("visible", v ? "1" : "0")}
disabled={!canEdit}
/>
</div>
{/* Stats */}
<div className="flex items-center gap-4 border-t pt-3 text-xs text-muted-foreground">
<span>
Layout: <span className="font-mono">{page.pageLayout}</span>
</span>
<span>
Parent:{" "}
<span className="font-mono">
{page.parentId <= 0 ? "Root" : `#${page.parentId}`}
</span>
</span>
</div>
{/* Actions */}
{canEdit && (
<div className="space-y-2 border-t pt-3">
<Button
type="submit"
disabled={isPending}
className="w-full"
>
{isPending ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Save className="mr-2 h-4 w-4" />
)}
Save & Sync
</Button>
<p className="text-center text-[10px] text-muted-foreground">
Press <kbd className="rounded border px-1">Ctrl+S</kbd> to
save
</p>
</div>
)}
</CardContent>
</Card>
</div>
</div>
</form>
{/* Edit Item Dialog */}
{editingItem && (
<ItemDialog
item={editingItem}
mode="edit"
isPending={isPending}
onClose={() => setEditingItem(null)}
onSubmit={(data) => {
run(() => updateBcItem({ id: editingItem.id, ...data }), {
successMessage: "Item updated.",
onSuccess: () => setEditingItem(null),
});
}}
/>
)}
{/* Add Item Dialog */}
{addingItem && (
<ItemDialog
item={{
id: 0,
pageId: page.id,
itemIds: "",
catalogName: "",
orderNumber: 1,
extradata: "",
}}
mode="add"
isPending={isPending}
onClose={() => setAddingItem(false)}
onSubmit={(data) => {
run(() => createBcItem({ pageId: page.id, ...data }), {
successMessage: "Item created.",
onSuccess: () => setAddingItem(false),
});
}}
/>
)}
</div>
);
}
// ── Toggle row ───────────────────────────────────────────────────────
function ToggleRow({
id,
label,
description,
checked,
onChange,
disabled,
}: {
id: string;
label: string;
description: string;
checked: boolean;
onChange: (v: boolean) => void;
disabled?: boolean;
}) {
return (
<div className="flex items-start justify-between gap-3">
<div className="min-w-0 flex-1">
<Label htmlFor={id} className="text-sm font-medium cursor-pointer">
{label}
</Label>
<p className="text-[11px] text-muted-foreground">{description}</p>
</div>
<Switch
id={id}
checked={checked}
onCheckedChange={onChange}
disabled={disabled}
/>
</div>
);
}
// ── Item Dialog ──────────────────────────────────────────────────────
function ItemDialog({
item,
mode,
isPending,
onClose,
onSubmit,
}: {
item: BcItemData;
mode: "add" | "edit";
isPending: boolean;
onClose: () => void;
onSubmit: (data: {
itemIds: string;
catalogName: string;
orderNumber: number;
extradata: string;
}) => void;
}) {
const [form, setForm] = useState({
itemIds: item.itemIds,
catalogName: item.catalogName,
orderNumber: item.orderNumber,
extradata: item.extradata,
});
return (
<Dialog open onOpenChange={(open) => !open && onClose()}>
<DialogContent>
<DialogHeader>
<DialogTitle>
{mode === "add" ? "Add BC Item" : `Edit Item #${item.id}`}
</DialogTitle>
</DialogHeader>
<div className="space-y-4 py-2">
<div className="space-y-2">
<Label>Catalog Name</Label>
<Input
value={form.catalogName}
onChange={(e) =>
setForm((f) => ({ ...f, catalogName: e.target.value }))
}
/>
</div>
<div className="space-y-2">
<Label>Item IDs (semicolon-separated)</Label>
<Input
value={form.itemIds}
onChange={(e) =>
setForm((f) => ({ ...f, itemIds: e.target.value }))
}
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label>Order</Label>
<Input
type="number"
value={form.orderNumber}
onChange={(e) =>
setForm((f) => ({
...f,
orderNumber: parseInt(e.target.value, 10) || 0,
}))
}
/>
</div>
<div className="space-y-2">
<Label>Extra Data</Label>
<Input
value={form.extradata}
onChange={(e) =>
setForm((f) => ({ ...f, extradata: e.target.value }))
}
placeholder="Optional"
/>
</div>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={onClose} disabled={isPending}>
Cancel
</Button>
<Button
onClick={() => onSubmit(form)}
disabled={isPending || !form.catalogName || !form.itemIds}
>
{isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
{mode === "add" ? "Add Item" : "Save"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,5 @@
import { redirect } from "next/navigation";
export default function BuilderClubPage() {
redirect("/admin/catalog?catalog=bc");
}
+158
View File
@@ -0,0 +1,158 @@
"use client";
import { Loader2, Plus } from "lucide-react";
import { useRouter } from "next/navigation";
import { useEffect, useState } from "react";
import { toast } from "sonner";
import { createCatalogPage } from "@/actions/catalog";
import { createBcPage } from "@/actions/catalog-bc";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { useServerAction } from "@/hooks/use-server-action";
import { CATALOG_LAYOUTS } from "@/lib/catalog-layouts";
interface CatalogActionsProps {
catalogType?: "normal" | "bc";
/** Prefill parent and auto-open create dialog (from ?newPageParent=). */
initialParentId?: string | null;
}
export function CatalogActions({
catalogType = "normal",
initialParentId = null,
}: CatalogActionsProps) {
const { run, isPending } = useServerAction();
const router = useRouter();
const [open, setOpen] = useState(false);
const [caption, setCaption] = useState("");
const [parentId, setParentId] = useState("-1");
const [pageLayout, setPageLayout] = useState("default_3x3");
const isBc = catalogType === "bc";
useEffect(() => {
if (initialParentId == null || initialParentId === "") return;
const n = Number.parseInt(initialParentId, 10);
if (!Number.isFinite(n)) return;
setParentId(String(n));
setOpen(true);
if (typeof window === "undefined") return;
const url = new URL(window.location.href);
if (url.searchParams.has("newPageParent")) {
url.searchParams.delete("newPageParent");
const qs = url.searchParams.toString();
router.replace(`${url.pathname}${qs ? `?${qs}` : ""}`, {
scroll: false,
});
}
}, [initialParentId, router]);
function handleCreate() {
if (!caption.trim()) {
toast.error("Caption is required");
return;
}
const input = {
caption: caption.trim(),
parentId: parseInt(parentId, 10),
pageLayout,
};
const fn = isBc
? () => createBcPage(input)
: () => createCatalogPage(input);
run(fn, {
successMessage: `Catalog page "${caption.trim()}" created.`,
onSuccess: (data) => {
setOpen(false);
setCaption("");
setParentId("-1");
if (data.id) {
window.location.href = isBc
? `/admin/catalog/builder-club/${data.id}`
: `/admin/catalog/${data.id}`;
}
},
});
}
return (
<Dialog open={open} onOpenChange={setOpen}>
<Button type="button" onClick={() => setOpen(true)}>
<Plus className="mr-2 h-4 w-4" />
New Page
</Button>
<DialogContent>
<DialogHeader>
<DialogTitle>
{isBc ? "Create Builder Club Page" : "Create Catalog Page"}
</DialogTitle>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="caption">Caption</Label>
<Input
id="caption"
value={caption}
onChange={(e) => setCaption(e.target.value)}
placeholder="Page name"
/>
</div>
<div className="space-y-2">
<Label htmlFor="parentId">Parent ID (-1 = root)</Label>
<Input
id="parentId"
type="number"
value={parentId}
onChange={(e) => setParentId(e.target.value)}
/>
</div>
<div className="space-y-2">
<Label>Layout</Label>
<Select value={pageLayout} onValueChange={setPageLayout}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{CATALOG_LAYOUTS.map((layout) => (
<SelectItem key={layout} value={layout}>
{layout}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<Button
onClick={handleCreate}
disabled={isPending}
className="w-full"
>
{isPending ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Plus className="mr-2 h-4 w-4" />
)}
Create Page
</Button>
</div>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,13 @@
"use client";
import dynamic from "next/dynamic";
export const CatalogManagerDialog = dynamic(
() =>
import("@/components/admin/catalog-manager/catalog-manager-dialog").then(
(m) => ({
default: m.CatalogManagerDialog,
}),
),
{ ssr: false },
);
+211
View File
@@ -0,0 +1,211 @@
"use client";
import { Pencil } from "lucide-react";
import { deleteCatalogPage, toggleCatalogPage } from "@/actions/catalog";
import { deleteBcTreePage, toggleBcPage } from "@/actions/catalog-bc";
import { DataTable } from "@/components/admin/data-table";
import Link from "@/components/link";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { useServerAction } from "@/hooks/use-server-action";
import type { DataTableColumn, PaginatedResult } from "@/types/common";
interface CatalogPageRow {
id: number;
caption: string;
parentId: number;
parentCaption: string;
pageLayout: string;
enabled: string;
visible: string;
minRank: number;
orderNum: number;
itemCount: number;
childCount: number;
}
interface CatalogTableProps {
data: PaginatedResult<CatalogPageRow>;
canEdit: boolean;
catalogType?: "normal" | "bc";
}
export function CatalogTable({
data,
canEdit,
catalogType = "normal",
}: CatalogTableProps) {
const { run, isPending } = useServerAction();
const isBc = catalogType === "bc";
// Parent URLs stay in the same catalog; root takes you to the catalog index.
const detailHref = (id: number) =>
isBc ? `/admin/catalog/builder-club/${id}` : `/admin/catalog/${id}`;
function handleToggle(id: number, action: "toggleEnabled" | "toggleVisible") {
if (isBc) {
const field = action === "toggleEnabled" ? "enabled" : "visible";
run(() => toggleBcPage({ id, field }), { successMessage: "Updated." });
} else {
run(() => toggleCatalogPage({ id, action }), {
successMessage: "Updated.",
});
}
}
const columns: DataTableColumn<CatalogPageRow>[] = [
{ key: "id", label: "ID", sortable: true },
{
key: "caption",
label: "Caption",
sortable: true,
render: (value, row) => (
<Link
href={detailHref(row.id)}
className="font-medium text-primary hover:underline"
>
{String(value)}
</Link>
),
},
{
key: "parentId",
label: "Parent",
sortable: true,
render: (_, row) =>
row.parentId === -1 ? (
<span className="text-xs text-muted-foreground">Root</span>
) : (
<Link
href={detailHref(row.parentId)}
className="text-xs hover:underline"
>
{row.parentCaption}
</Link>
),
},
{
key: "pageLayout",
label: "Layout",
render: (value) => <Badge variant="secondary">{String(value)}</Badge>,
},
{
key: "enabled",
label: "Enabled",
sortable: true,
filterKey: "filter_enabled",
filterOptions: [
{ label: "Enabled", value: "1" },
{ label: "Disabled", value: "0" },
],
render: (value, row) =>
canEdit ? (
<button
type="button"
onClick={() => handleToggle(row.id, "toggleEnabled")}
disabled={isPending}
className="cursor-pointer disabled:opacity-50"
>
<Badge variant={value === "1" ? "default" : "outline"}>
{value === "1" ? "Yes" : "No"}
</Badge>
</button>
) : (
<Badge variant={value === "1" ? "default" : "outline"}>
{value === "1" ? "Yes" : "No"}
</Badge>
),
},
{
key: "visible",
label: "Visible",
sortable: true,
filterKey: "filter_visible",
filterOptions: [
{ label: "Visible", value: "1" },
{ label: "Hidden", value: "0" },
],
render: (value, row) =>
canEdit ? (
<button
type="button"
onClick={() => handleToggle(row.id, "toggleVisible")}
disabled={isPending}
className="cursor-pointer disabled:opacity-50"
>
<Badge variant={value === "1" ? "default" : "outline"}>
{value === "1" ? "Yes" : "No"}
</Badge>
</button>
) : (
<Badge variant={value === "1" ? "default" : "outline"}>
{value === "1" ? "Yes" : "No"}
</Badge>
),
},
...(isBc
? []
: [
{
key: "minRank" as const,
label: "Min Rank",
sortable: true,
} satisfies DataTableColumn<CatalogPageRow>,
]),
{
key: "itemCount",
label: "Items",
render: (value) => <span>{String(value)}</span>,
},
{
key: "childCount",
label: "Children",
render: (value) => <span>{String(value)}</span>,
},
{
key: "actions",
label: "Actions",
render: (_, row) => (
<Button variant="ghost" size="icon" asChild>
<Link href={detailHref(row.id)}>
<Pencil className="h-4 w-4" />
</Link>
</Button>
),
},
];
async function handleBulkAction(action: string, ids: (number | string)[]) {
if (action !== "delete" || ids.length === 0) return;
for (const id of ids) {
const numId = typeof id === "string" ? Number(id) : id;
if (Number.isInteger(numId) && numId > 0) {
run(() =>
isBc
? deleteBcTreePage({ pageId: numId, mode: "reparent" })
: deleteCatalogPage({ id: numId }),
);
}
}
}
return (
<DataTable
data={data}
columns={columns}
searchPlaceholder="Search by caption, layout or item..."
bulkActions={
canEdit
? [
{
label: "Delete Selected",
value: "delete",
variant: "destructive",
},
]
: undefined
}
onBulkAction={canEdit ? handleBulkAction : undefined}
/>
);
}
@@ -0,0 +1,100 @@
"use client";
import { FolderTree, HardHat, List, Package } from "lucide-react";
import { useRouter, useSearchParams } from "next/navigation";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
interface CatalogViewToggleProps {
currentView: string;
catalogType?: string;
}
export function CatalogViewToggle({
currentView,
catalogType = "normal",
}: CatalogViewToggleProps) {
const router = useRouter();
const searchParams = useSearchParams();
function setView(view: string) {
const params = new URLSearchParams(searchParams.toString());
if (view === "tree") {
params.delete("view");
} else {
params.set("view", view);
}
router.push(`?${params.toString()}`);
}
function setCatalog(type: string) {
const params = new URLSearchParams(searchParams.toString());
if (type === "normal") {
params.delete("catalog");
} else {
params.set("catalog", type);
}
params.delete("view"); // reset to tree when switching
router.push(`?${params.toString()}`);
}
return (
<div className="flex items-center gap-2">
{/* Catalog type selector */}
<div className="flex items-center rounded-md border bg-card">
<Button
variant="ghost"
size="sm"
className={cn(
"h-8 rounded-r-none",
catalogType === "normal" && "bg-accent",
)}
onClick={() => setCatalog("normal")}
>
<Package className="mr-1.5 h-4 w-4" />
Catalog
</Button>
<Button
variant="ghost"
size="sm"
className={cn(
"h-8 rounded-l-none",
catalogType === "bc" && "bg-accent",
)}
onClick={() => setCatalog("bc")}
>
<HardHat className="mr-1.5 h-4 w-4" />
Builder Club
</Button>
</div>
{/* View mode selector */}
<div className="flex items-center rounded-md border bg-card">
<Button
variant="ghost"
size="sm"
className={cn(
"h-8 rounded-r-none",
currentView === "tree" && "bg-accent",
)}
onClick={() => setView("tree")}
>
<FolderTree className="mr-1.5 h-4 w-4" />
Tree
</Button>
<Button
variant="ghost"
size="sm"
className={cn(
"h-8 rounded-l-none",
currentView === "table" && "bg-accent",
)}
onClick={() => setView("table")}
>
<List className="mr-1.5 h-4 w-4" />
Table
</Button>
</div>
</div>
);
}
+44
View File
@@ -0,0 +1,44 @@
import { Skeleton } from "@/components/ui/skeleton";
export default function CatalogLoading() {
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<Skeleton className="h-9 w-48" />
<Skeleton className="h-5 w-64 mt-2" />
</div>
<div className="flex items-center gap-2">
<Skeleton className="h-10 w-24" />
<Skeleton className="h-10 w-28" />
</div>
</div>
{/* Tree skeleton */}
<div className="rounded-lg border bg-card">
<div className="border-b px-4 py-3 flex items-center justify-between">
<div className="flex items-center gap-2">
<Skeleton className="h-4 w-4" />
<Skeleton className="h-4 w-32" />
<Skeleton className="h-5 w-16 rounded-full" />
</div>
<Skeleton className="h-8 w-56" />
</div>
<div className="p-2 space-y-1">
{Array.from({ length: 12 }, (_, i) => i).map((n) => (
<div
key={`skeleton-tree-${n}`}
className="flex items-center gap-2 px-2 py-1.5"
style={{ paddingLeft: `${(n % 3) * 20 + 8}px` }}
>
<Skeleton className="h-5 w-5 shrink-0" />
<Skeleton className="h-5 w-5 shrink-0" />
<Skeleton className="h-4 flex-1 max-w-48" />
<Skeleton className="h-5 w-8 rounded-full" />
</div>
))}
</div>
</div>
</div>
);
}
@@ -0,0 +1,5 @@
import { CatalogMaintenancePanel } from "@/components/admin/catalog/catalog-maintenance-panel";
export default function CatalogMaintenancePage() {
return <CatalogMaintenancePanel />;
}
+526
View File
@@ -0,0 +1,526 @@
import {
and,
asc,
count,
desc,
eq,
inArray,
like,
or,
type SQL,
sql,
} from "drizzle-orm";
import type { AnyMySqlColumn } from "drizzle-orm/mysql-core";
import { Store } from "lucide-react";
import { redirect } from "next/navigation";
import { getTranslations } from "next-intl/server";
import { AdminPageShell } from "@/components/admin/admin-page-shell";
import { CatalogFixAllButton } from "@/components/admin/catalog/catalog-fix-all-button";
import { CatalogTree } from "@/components/admin/catalog-tree";
import { calcPagination, parseListParams } from "@/lib/admin-helpers";
import {
CatalogItems,
CatalogItemsBc,
CatalogPages,
CatalogPagesBc,
db,
} from "@/lib/db";
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
import { getCatalogItemCounts } from "@/lib/services/catalog-tree";
import { CatalogActions } from "./catalog-actions";
import { CatalogManagerDialog } from "./catalog-manager-dialog-wrapper";
import { CatalogTable } from "./catalog-table";
import { CatalogViewToggle } from "./catalog-view-toggle";
async function rawRows<T>(query: ReturnType<typeof sql>): Promise<T[]> {
const [rows] = (await db.execute(query)) as unknown as [T[], unknown];
return rows ?? [];
}
export default async function CatalogPagesPage({
params: paramsPromise,
searchParams,
}: {
params: Promise<{ locale: string }>;
searchParams: Promise<Record<string, string>>;
}) {
await paramsPromise;
const { session, permissions } = await getAdminContext();
if (!canAccess(permissions, PERMS.CATALOG_VIEW, session.user.rank)) {
redirect("/admin");
}
const canEdit = canAccess(permissions, PERMS.CATALOG_EDIT, session.user.rank);
const t = await getTranslations("pages.admin.catalog");
const params = await searchParams;
const sp = new URLSearchParams(params);
const { search, perPage, page, sort, order } = parseListParams(sp);
const viewMode = sp.get("view") || "tree";
const catalogType = sp.get("catalog") || "normal";
type TreeNodeShape = {
id: number;
caption: string;
parentId: number;
enabled: string;
visible: string;
iconImage: number;
iconColor: number;
pageLayout: string;
childCount: number;
itemCount: number;
};
if (catalogType === "bc") {
const [totalBcPagesRows, totalBcItemsRows, enabledBcPagesRows] =
await Promise.all([
db.select({ total: count() }).from(CatalogPagesBc),
db.select({ total: count() }).from(CatalogItemsBc),
db
.select({ total: count() })
.from(CatalogPagesBc)
.where(eq(CatalogPagesBc.enabled, "1")),
]);
const totalBcPages = Number(totalBcPagesRows[0]?.total ?? 0);
const totalBcItems = Number(totalBcItemsRows[0]?.total ?? 0);
const enabledBcPages = Number(enabledBcPagesRows[0]?.total ?? 0);
let bcRootPages: TreeNodeShape[] = [];
if (viewMode === "tree") {
const bcRootRaw = await db
.select()
.from(CatalogPagesBc)
.where(
or(eq(CatalogPagesBc.parentId, -1), eq(CatalogPagesBc.parentId, 0)),
)
.orderBy(asc(CatalogPagesBc.orderNum));
const bcRootIds = bcRootRaw.map((p) => p.id);
const [bcChildCounts, bcItemCounts] = await Promise.all([
bcRootIds.length > 0
? db
.select({
parentId: CatalogPagesBc.parentId,
total: count(),
})
.from(CatalogPagesBc)
.where(inArray(CatalogPagesBc.parentId, bcRootIds))
.groupBy(CatalogPagesBc.parentId)
: [],
bcRootIds.length > 0
? db
.select({
pageId: CatalogItemsBc.pageId,
total: count(),
})
.from(CatalogItemsBc)
.where(inArray(CatalogItemsBc.pageId, bcRootIds))
.groupBy(CatalogItemsBc.pageId)
: [],
]);
const bcChildMap = Object.fromEntries(
bcChildCounts.map((c) => [c.parentId, Number(c.total)]),
);
const bcItemMap = Object.fromEntries(
bcItemCounts.map((c) => [c.pageId, Number(c.total)]),
);
bcRootPages = bcRootRaw.map((p) => ({
id: p.id,
caption: p.caption,
parentId: p.parentId,
enabled: String(p.enabled),
visible: String(p.visible),
iconImage: p.iconImage,
iconColor: p.iconColor,
pageLayout: p.pageLayout,
childCount: bcChildMap[p.id] ?? 0,
itemCount: bcItemMap[p.id] ?? 0,
}));
}
let bcRows: {
id: number;
caption: string;
parentId: number;
parentCaption: string;
pageLayout: string;
enabled: string;
visible: string;
minRank: number;
orderNum: number;
itemCount: number;
childCount: number;
}[] = [];
let bcPagination = { total: 0, page: 1, perPage, lastPage: 0 };
if (viewMode === "table") {
const parentFilter = sp.get("parent");
const parentId =
parentFilter !== null ? parseInt(parentFilter, 10) : undefined;
const conditions: SQL[] = [];
const hasParent = parentId !== undefined && !Number.isNaN(parentId);
if (search) {
conditions.push(like(CatalogPagesBc.caption, `%${search}%`));
}
if (hasParent) conditions.push(eq(CatalogPagesBc.parentId, parentId));
const where = conditions.length > 0 ? and(...conditions) : undefined;
const sortKeyMap: Record<string, AnyMySqlColumn> = {
parentId: CatalogPagesBc.parentId,
pageLayout: CatalogPagesBc.pageLayout,
orderNum: CatalogPagesBc.orderNum,
caption: CatalogPagesBc.caption,
id: CatalogPagesBc.id,
};
const sortCol = sort
? (sortKeyMap[sort] ?? CatalogPagesBc.orderNum)
: CatalogPagesBc.orderNum;
const orderBy =
(sort ? order : "asc") === "desc" ? desc(sortCol) : asc(sortCol);
const [pages, totalRows] = await Promise.all([
db
.select()
.from(CatalogPagesBc)
.where(where)
.orderBy(orderBy)
.limit(perPage)
.offset((page - 1) * perPage),
db.select({ total: count() }).from(CatalogPagesBc).where(where),
]);
const total = Number(totalRows[0]?.total ?? 0);
bcPagination = calcPagination(total, page, perPage);
const pageIds = pages.map((p) => p.id);
const parentIds = [
...new Set(pages.map((p) => p.parentId).filter((id) => id > 0)),
];
const [childCounts, itemCounts, parentPages] = await Promise.all([
pageIds.length > 0
? db
.select({
parentId: CatalogPagesBc.parentId,
total: count(),
})
.from(CatalogPagesBc)
.where(inArray(CatalogPagesBc.parentId, pageIds))
.groupBy(CatalogPagesBc.parentId)
: [],
pageIds.length > 0
? db
.select({
pageId: CatalogItemsBc.pageId,
total: count(),
})
.from(CatalogItemsBc)
.where(inArray(CatalogItemsBc.pageId, pageIds))
.groupBy(CatalogItemsBc.pageId)
: [],
parentIds.length > 0
? db
.select({
id: CatalogPagesBc.id,
caption: CatalogPagesBc.caption,
})
.from(CatalogPagesBc)
.where(inArray(CatalogPagesBc.id, parentIds))
: [],
]);
const childCountMap = Object.fromEntries(
childCounts.map((c) => [c.parentId, Number(c.total)]),
);
const itemCountMap = Object.fromEntries(
itemCounts.map((c) => [c.pageId, Number(c.total)]),
);
const parentNameMap = Object.fromEntries(
parentPages.map((p) => [p.id, p.caption]),
);
bcRows = pages.map((p) => ({
id: p.id,
caption: p.caption,
parentId: p.parentId,
parentCaption:
p.parentId === -1
? "Root"
: (parentNameMap[p.parentId] ?? `#${p.parentId}`),
pageLayout: String(p.pageLayout),
enabled: String(p.enabled),
visible: String(p.visible),
minRank: 0,
orderNum: p.orderNum,
itemCount: itemCountMap[p.id] ?? 0,
childCount: childCountMap[p.id] ?? 0,
}));
}
return (
<AdminPageShell icon={Store} title={t("title")} subtitle={t("subtitle")}>
<div className="space-y-6">
<div className="flex items-center justify-between">
<p className="text-muted-foreground">
{totalBcPages} BC pages ({enabledBcPages} enabled) &middot;{" "}
{totalBcItems} items
</p>
<div className="flex items-center gap-2">
<CatalogViewToggle currentView={viewMode} catalogType="bc" />
{canEdit && (
<CatalogManagerDialog
catalogType="bc"
canEdit={canEdit}
totals={{
totalPages: totalBcPages,
enabledPages: enabledBcPages,
totalItems: totalBcItems,
}}
initialRoots={bcRootPages.map((p) => ({
id: p.id,
caption: p.caption,
iconImage: p.iconImage,
enabled: p.enabled,
orderNum: 0,
}))}
/>
)}
{canEdit && (
<CatalogActions
catalogType="bc"
initialParentId={sp.get("newPageParent")}
/>
)}
<CatalogFixAllButton />
</div>
</div>
{viewMode === "tree" ? (
<CatalogTree rootPages={bcRootPages} catalogType="bc" />
) : (
<CatalogTable
data={{ rows: bcRows, ...bcPagination }}
canEdit={canEdit}
catalogType="bc"
/>
)}
</div>
</AdminPageShell>
);
}
const [totalPagesRows, totalItemsRows, enabledPagesRows] = await Promise.all([
db.select({ total: count() }).from(CatalogPages),
db.select({ total: count() }).from(CatalogItems),
db
.select({ total: count() })
.from(CatalogPages)
.where(eq(CatalogPages.enabled, "1")),
]);
const totalPages = Number(totalPagesRows[0]?.total ?? 0);
const totalItems = Number(totalItemsRows[0]?.total ?? 0);
const enabledPages = Number(enabledPagesRows[0]?.total ?? 0);
const rootPagesRaw = await db
.select()
.from(CatalogPages)
.where(or(eq(CatalogPages.parentId, -1), eq(CatalogPages.parentId, 0)))
.orderBy(asc(CatalogPages.orderNum));
const rootIds = rootPagesRaw.map((p) => p.id);
const [rootChildCounts, rootItemCountMap] = await Promise.all([
rootIds.length > 0
? db
.select({
parentId: CatalogPages.parentId,
total: count(),
})
.from(CatalogPages)
.where(inArray(CatalogPages.parentId, rootIds))
.groupBy(CatalogPages.parentId)
: [],
getCatalogItemCounts(rootIds),
]);
const rootChildMap = Object.fromEntries(
rootChildCounts.map((c) => [c.parentId, Number(c.total)]),
);
const rootItemMap = Object.fromEntries(rootItemCountMap);
const rootPages: TreeNodeShape[] = rootPagesRaw.map((p) => ({
id: p.id,
caption: p.caption,
parentId: p.parentId,
enabled: String(p.enabled),
visible: String(p.visible),
iconImage: p.iconImage,
iconColor: p.iconColor,
pageLayout: p.pageLayout,
childCount: rootChildMap[p.id] ?? 0,
itemCount: rootItemMap[p.id] ?? 0,
}));
let rows: {
id: number;
caption: string;
parentId: number;
parentCaption: string;
pageLayout: string;
enabled: string;
visible: string;
minRank: number;
orderNum: number;
itemCount: number;
childCount: number;
}[] = [];
let pagination = { total: 0, page: 1, perPage, lastPage: 0 };
if (viewMode === "table") {
const parentFilter = sp.get("parent");
const parentId =
parentFilter !== null ? parseInt(parentFilter, 10) : undefined;
const conditions: SQL[] = [];
const hasParent = parentId !== undefined && !Number.isNaN(parentId);
if (search) {
const searchPattern = `%${search}%`;
const parentClause = hasParent
? sql`AND cp.parent_id = ${parentId}`
: sql``;
const matchedIds = await rawRows<{ id: number }>(sql`
SELECT DISTINCT cp.id FROM catalog_pages cp
LEFT JOIN catalog_items ci ON CAST(ci.page_id AS CHAR) = CAST(cp.id AS CHAR)
LEFT JOIN items_base ib ON FIND_IN_SET(ib.id, REPLACE(ci.item_ids, ';', ','))
WHERE (cp.caption LIKE ${searchPattern}
OR cp.page_layout LIKE ${searchPattern}
OR ci.catalog_name LIKE ${searchPattern}
OR ib.public_name LIKE ${searchPattern}
OR ib.item_name LIKE ${searchPattern})
${parentClause}
`);
const ids = matchedIds.map((r) => r.id);
if (ids.length > 0) conditions.push(inArray(CatalogPages.id, ids));
else conditions.push(sql`1 = 0`);
} else if (hasParent) {
conditions.push(eq(CatalogPages.parentId, parentId));
}
const where = conditions.length > 0 ? and(...conditions) : undefined;
const sortColMap: Record<string, AnyMySqlColumn> = {
caption: CatalogPages.caption,
parentId: CatalogPages.parentId,
pageLayout: CatalogPages.pageLayout,
enabled: CatalogPages.enabled,
orderNum: CatalogPages.orderNum,
id: CatalogPages.id,
minRank: CatalogPages.minRank,
};
const sortCol = sort
? (sortColMap[sort] ?? CatalogPages.orderNum)
: CatalogPages.orderNum;
const orderBy =
(sort ? order : "asc") === "desc" ? desc(sortCol) : asc(sortCol);
const [pages, totalRows] = await Promise.all([
db
.select()
.from(CatalogPages)
.where(where)
.orderBy(orderBy)
.limit(perPage)
.offset((page - 1) * perPage),
db.select({ total: count() }).from(CatalogPages).where(where),
]);
const total = Number(totalRows[0]?.total ?? 0);
pagination = calcPagination(total, page, perPage);
const pageIds = pages.map((p) => p.id);
const parentIds = [
...new Set(pages.map((p) => p.parentId).filter((id) => id > 0)),
];
const [childCounts, itemCountMapRaw, parentPages] = await Promise.all([
pageIds.length > 0
? db
.select({
parentId: CatalogPages.parentId,
total: count(),
})
.from(CatalogPages)
.where(inArray(CatalogPages.parentId, pageIds))
.groupBy(CatalogPages.parentId)
: [],
getCatalogItemCounts(pageIds),
parentIds.length > 0
? db
.select({ id: CatalogPages.id, caption: CatalogPages.caption })
.from(CatalogPages)
.where(inArray(CatalogPages.id, parentIds))
: [],
]);
const childCountMap = Object.fromEntries(
childCounts.map((c) => [c.parentId, Number(c.total)]),
);
const itemCountMap = Object.fromEntries(itemCountMapRaw);
const parentNameMap = Object.fromEntries(
parentPages.map((p) => [p.id, p.caption]),
);
rows = pages.map((p) => ({
id: p.id,
caption: p.caption,
parentId: p.parentId,
parentCaption:
p.parentId === -1
? "Root"
: (parentNameMap[p.parentId] ?? `#${p.parentId}`),
pageLayout: p.pageLayout,
enabled: p.enabled,
visible: p.visible,
minRank: p.minRank,
orderNum: p.orderNum,
itemCount: itemCountMap[p.id] ?? 0,
childCount: childCountMap[p.id] ?? 0,
}));
}
return (
<AdminPageShell icon={Store} title={t("title")} subtitle={t("subtitle")}>
<div className="space-y-6">
<div className="flex items-center justify-between">
<p className="text-muted-foreground">
{totalPages} pages ({enabledPages} enabled) &middot; {totalItems}{" "}
items
</p>
<div className="flex items-center gap-2">
<CatalogViewToggle currentView={viewMode} catalogType="normal" />
{canEdit && (
<CatalogManagerDialog
catalogType="normal"
canEdit={canEdit}
totals={{
totalPages,
enabledPages,
totalItems,
}}
initialRoots={rootPages.map((p) => ({
id: p.id,
caption: p.caption,
iconImage: p.iconImage,
enabled: p.enabled,
orderNum: 0,
}))}
/>
)}
{canEdit && (
<CatalogActions initialParentId={sp.get("newPageParent")} />
)}
<CatalogFixAllButton />
</div>
</div>
{viewMode === "tree" ? (
<CatalogTree rootPages={rootPages} catalogType="normal" />
) : (
<CatalogTable data={{ rows, ...pagination }} canEdit={canEdit} />
)}
</div>
</AdminPageShell>
);
}
@@ -0,0 +1,798 @@
"use client";
import { useTranslations } from "next-intl";
import { useState } from "react";
import {
alertUser,
disconnectUser,
executeCommand,
forwardUser,
giveBadge,
giveCredits,
giveDiamonds,
giveDuckets,
hotelAlert,
sendGift,
setMotto,
setRank,
updateCatalog,
updateNavigator,
updateWordFilter,
} from "@/actions/commandocentrum";
import { Button } from "@/components/ui/button";
import { useServerAction } from "@/hooks/use-server-action";
const RCON_ERROR = "RCON command failed. Is the emulator running?";
export function CommandocentrumControls() {
const t = useTranslations("pages.admin.commandocentrum");
const { run, isPending } = useServerAction();
const [hotelMessage, setHotelMessage] = useState("");
const [disconnect, setDisconnect] = useState({ userId: "", username: "" });
const [alert, setAlert] = useState({ userId: "", message: "" });
const [forward, setForward] = useState({ userId: "", roomId: "" });
const [credits, setCredits] = useState({ userId: "", credits: "" });
const [duckets, setDuckets] = useState({ userId: "", amount: "" });
const [diamonds, setDiamonds] = useState({ userId: "", amount: "" });
const [badge, setBadge] = useState({ userId: "", badge: "" });
const [motto, setMottoForm] = useState({ userId: "", motto: "" });
const [rank, setRankForm] = useState({ userId: "", rank: "" });
const [command, setCommand] = useState({ userId: "", command: "" });
const [gift, setGift] = useState({
userId: "",
itemId: "",
message: t("defaultGiftMessage"),
});
return (
<>
<section className="mt-6">
<h2 className="admin-section-title">{t("emulatorControls")}</h2>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
<div className="admin-card">
<h3 className="text-sm font-bold text-[var(--admin-text)] mb-2">
{t("updateCatalog")}
</h3>
<p className="text-xs text-[var(--admin-text-muted)] mb-4">
{t("updateCatalogDesc")}
</p>
<Button
type="button"
variant="default"
disabled={isPending}
onClick={() =>
run(() => updateCatalog(), {
successMessage: "Catalog updated.",
errorMessage: RCON_ERROR,
})
}
>
{t("updateCatalog")}
</Button>
</div>
<div className="admin-card">
<h3 className="text-sm font-bold text-[var(--admin-text)] mb-2">
{t("updateWordFilter")}
</h3>
<p className="text-xs text-[var(--admin-text-muted)] mb-4">
{t("updateWordFilterDesc")}
</p>
<Button
type="button"
variant="default"
disabled={isPending}
onClick={() =>
run(() => updateWordFilter(), {
successMessage: "Word filter updated.",
errorMessage: RCON_ERROR,
})
}
>
{t("updateWordFilter")}
</Button>
</div>
<div className="admin-card">
<h3 className="text-sm font-bold text-[var(--admin-text)] mb-2">
{t("updateNavigator")}
</h3>
<p className="text-xs text-[var(--admin-text-muted)] mb-4">
{t("updateNavigatorDesc")}
</p>
<Button
type="button"
variant="default"
disabled={isPending}
onClick={() =>
run(() => updateNavigator(), {
successMessage: "Navigator updated.",
errorMessage: RCON_ERROR,
})
}
>
{t("updateNavigator")}
</Button>
</div>
</div>
<div className="admin-card mt-6">
<h3 className="text-sm font-bold text-[var(--admin-text)] mb-2">
{t("hotelAlert")}
</h3>
<p className="text-xs text-[var(--admin-text-muted)] mb-3">
{t("hotelAlertDesc")}
</p>
<div className="flex gap-2">
<input
value={hotelMessage}
onChange={(e) => setHotelMessage(e.target.value)}
required
maxLength={512}
placeholder={t("messageToBroadcast")}
className="flex-1 min-w-[220px]"
disabled={isPending}
/>
<Button
type="button"
variant="destructive"
disabled={isPending || !hotelMessage.trim()}
onClick={() =>
run(() => hotelAlert({ message: hotelMessage }), {
successMessage: "Hotel alert sent.",
errorMessage: RCON_ERROR,
onSuccess: () => setHotelMessage(""),
})
}
>
{t("sendAlert")}
</Button>
</div>
</div>
</section>
<section className="mt-6">
<h2 className="admin-section-title">{t("userActions")}</h2>
<p className="text-xs text-[var(--admin-text-muted)] mb-4">
{t("userActionsDesc")}
</p>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
<div className="admin-card">
<h3 className="text-sm font-bold text-[var(--admin-text)] mb-2">
{t("actions.disconnect")}
</h3>
<p className="text-xs text-[var(--admin-text-muted)] mb-3">
{t("disconnectDesc")}
</p>
<div className="space-y-2">
<input
type="number"
min={1}
placeholder={t("userId")}
value={disconnect.userId}
onChange={(e) =>
setDisconnect((s) => ({ ...s, userId: e.target.value }))
}
className="w-full"
disabled={isPending}
/>
<input
type="text"
placeholder={t("usernameRequired")}
value={disconnect.username}
onChange={(e) =>
setDisconnect((s) => ({ ...s, username: e.target.value }))
}
className="w-full"
disabled={isPending}
/>
<Button
type="button"
variant="destructive"
className="w-full"
disabled={
isPending || !disconnect.userId || !disconnect.username.trim()
}
onClick={() =>
run(
() =>
disconnectUser({
userId: Number(disconnect.userId),
username: disconnect.username,
}),
{
successMessage: "User disconnected.",
errorMessage: RCON_ERROR,
onSuccess: () =>
setDisconnect({ userId: "", username: "" }),
},
)
}
>
{t("actions.disconnect")}
</Button>
</div>
</div>
<div className="admin-card">
<h3 className="text-sm font-bold text-[var(--admin-text)] mb-2">
{t("actions.alert")}
</h3>
<p className="text-xs text-[var(--admin-text-muted)] mb-3">
{t("alertDesc")}
</p>
<div className="space-y-2">
<input
type="number"
min={1}
placeholder={t("userId")}
value={alert.userId}
onChange={(e) =>
setAlert((s) => ({ ...s, userId: e.target.value }))
}
className="w-full"
disabled={isPending}
/>
<input
type="text"
placeholder={t("alertMessage")}
value={alert.message}
onChange={(e) =>
setAlert((s) => ({ ...s, message: e.target.value }))
}
maxLength={512}
className="w-full"
disabled={isPending}
/>
<Button
type="button"
variant="default"
className="w-full"
disabled={isPending || !alert.userId || !alert.message.trim()}
onClick={() =>
run(
() =>
alertUser({
userId: Number(alert.userId),
message: alert.message,
}),
{
successMessage: "Alert sent.",
errorMessage: RCON_ERROR,
onSuccess: () => setAlert({ userId: "", message: "" }),
},
)
}
>
{t("actions.alert")}
</Button>
</div>
</div>
<div className="admin-card">
<h3 className="text-sm font-bold text-[var(--admin-text)] mb-2">
{t("forwardToRoom")}
</h3>
<p className="text-xs text-[var(--admin-text-muted)] mb-3">
{t("forwardDesc")}
</p>
<div className="space-y-2">
<input
type="number"
min={1}
placeholder={t("userId")}
value={forward.userId}
onChange={(e) =>
setForward((s) => ({ ...s, userId: e.target.value }))
}
className="w-full"
disabled={isPending}
/>
<input
type="number"
min={1}
placeholder={t("roomId")}
value={forward.roomId}
onChange={(e) =>
setForward((s) => ({ ...s, roomId: e.target.value }))
}
className="w-full"
disabled={isPending}
/>
<Button
type="button"
variant="default"
className="w-full"
disabled={isPending || !forward.userId || !forward.roomId}
onClick={() =>
run(
() =>
forwardUser({
userId: Number(forward.userId),
roomId: Number(forward.roomId),
}),
{
successMessage: "User forwarded.",
errorMessage: RCON_ERROR,
onSuccess: () => setForward({ userId: "", roomId: "" }),
},
)
}
>
{t("forwardUser")}
</Button>
</div>
</div>
</div>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4 mt-4">
<div className="admin-card">
<h3 className="text-sm font-bold text-[var(--admin-text)] mb-2">
{t("giveCredits")}
</h3>
<p className="text-xs text-[var(--admin-text-muted)] mb-3">
{t("giveCreditsDesc")}
</p>
<div className="space-y-2">
<input
type="number"
min={1}
placeholder={t("userId")}
value={credits.userId}
onChange={(e) =>
setCredits((s) => ({ ...s, userId: e.target.value }))
}
className="w-full"
disabled={isPending}
/>
<input
type="number"
min={1}
placeholder={t("amount")}
value={credits.credits}
onChange={(e) =>
setCredits((s) => ({ ...s, credits: e.target.value }))
}
className="w-full"
disabled={isPending}
/>
<Button
type="button"
variant="default"
className="w-full"
disabled={isPending || !credits.userId || !credits.credits}
onClick={() =>
run(
() =>
giveCredits({
userId: Number(credits.userId),
credits: Number(credits.credits),
}),
{
successMessage: "Credits sent.",
errorMessage: RCON_ERROR,
onSuccess: () => setCredits({ userId: "", credits: "" }),
},
)
}
>
{t("giveCredits")}
</Button>
</div>
</div>
<div className="admin-card">
<h3 className="text-sm font-bold text-[var(--admin-text)] mb-2">
{t("giveDuckets")}
</h3>
<p className="text-xs text-[var(--admin-text-muted)] mb-3">
{t("giveDucketsDesc")}
</p>
<div className="space-y-2">
<input
type="number"
min={1}
placeholder={t("userId")}
value={duckets.userId}
onChange={(e) =>
setDuckets((s) => ({ ...s, userId: e.target.value }))
}
className="w-full"
disabled={isPending}
/>
<input
type="number"
min={1}
placeholder={t("amount")}
value={duckets.amount}
onChange={(e) =>
setDuckets((s) => ({ ...s, amount: e.target.value }))
}
className="w-full"
disabled={isPending}
/>
<Button
type="button"
variant="default"
className="w-full"
disabled={isPending || !duckets.userId || !duckets.amount}
onClick={() =>
run(
() =>
giveDuckets({
userId: Number(duckets.userId),
amount: Number(duckets.amount),
}),
{
successMessage: "Duckets sent.",
errorMessage: RCON_ERROR,
onSuccess: () => setDuckets({ userId: "", amount: "" }),
},
)
}
>
{t("giveDuckets")}
</Button>
</div>
</div>
<div className="admin-card">
<h3 className="text-sm font-bold text-[var(--admin-text)] mb-2">
{t("giveDiamonds")}
</h3>
<p className="text-xs text-[var(--admin-text-muted)] mb-3">
{t("giveDiamondsDesc")}
</p>
<div className="space-y-2">
<input
type="number"
min={1}
placeholder={t("userId")}
value={diamonds.userId}
onChange={(e) =>
setDiamonds((s) => ({ ...s, userId: e.target.value }))
}
className="w-full"
disabled={isPending}
/>
<input
type="number"
min={1}
placeholder={t("amount")}
value={diamonds.amount}
onChange={(e) =>
setDiamonds((s) => ({ ...s, amount: e.target.value }))
}
className="w-full"
disabled={isPending}
/>
<Button
type="button"
variant="default"
className="w-full"
disabled={isPending || !diamonds.userId || !diamonds.amount}
onClick={() =>
run(
() =>
giveDiamonds({
userId: Number(diamonds.userId),
amount: Number(diamonds.amount),
}),
{
successMessage: "Diamonds sent.",
errorMessage: RCON_ERROR,
onSuccess: () => setDiamonds({ userId: "", amount: "" }),
},
)
}
>
{t("giveDiamonds")}
</Button>
</div>
</div>
</div>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4 mt-4">
<div className="admin-card">
<h3 className="text-sm font-bold text-[var(--admin-text)] mb-2">
{t("giveBadge")}
</h3>
<p className="text-xs text-[var(--admin-text-muted)] mb-3">
{t("giveBadgeDesc")}
</p>
<div className="space-y-2">
<input
type="number"
min={1}
placeholder={t("userId")}
value={badge.userId}
onChange={(e) =>
setBadge((s) => ({ ...s, userId: e.target.value }))
}
className="w-full"
disabled={isPending}
/>
<input
type="text"
placeholder={t("badgeCode")}
value={badge.badge}
onChange={(e) =>
setBadge((s) => ({ ...s, badge: e.target.value }))
}
maxLength={32}
className="w-full"
disabled={isPending}
/>
<Button
type="button"
variant="default"
className="w-full"
disabled={isPending || !badge.userId || !badge.badge.trim()}
onClick={() =>
run(
() =>
giveBadge({
userId: Number(badge.userId),
badge: badge.badge,
}),
{
successMessage: "Badge granted.",
errorMessage: RCON_ERROR,
onSuccess: () => setBadge({ userId: "", badge: "" }),
},
)
}
>
{t("giveBadge")}
</Button>
</div>
</div>
<div className="admin-card">
<h3 className="text-sm font-bold text-[var(--admin-text)] mb-2">
{t("setMotto")}
</h3>
<p className="text-xs text-[var(--admin-text-muted)] mb-3">
{t("setMottoDesc")}
</p>
<div className="space-y-2">
<input
type="number"
min={1}
placeholder={t("userId")}
value={motto.userId}
onChange={(e) =>
setMottoForm((s) => ({ ...s, userId: e.target.value }))
}
className="w-full"
disabled={isPending}
/>
<input
type="text"
placeholder={t("newMotto")}
value={motto.motto}
onChange={(e) =>
setMottoForm((s) => ({ ...s, motto: e.target.value }))
}
maxLength={127}
className="w-full"
disabled={isPending}
/>
<Button
type="button"
variant="default"
className="w-full"
disabled={isPending || !motto.userId || !motto.motto.trim()}
onClick={() =>
run(
() =>
setMotto({
userId: Number(motto.userId),
motto: motto.motto,
}),
{
successMessage: "Motto updated.",
errorMessage: RCON_ERROR,
onSuccess: () => setMottoForm({ userId: "", motto: "" }),
},
)
}
>
{t("setMotto")}
</Button>
</div>
</div>
<div className="admin-card">
<h3 className="text-sm font-bold text-[var(--admin-text)] mb-2">
{t("setRank")}
</h3>
<p className="text-xs text-[var(--admin-text-muted)] mb-3">
{t("setRankDesc")}
</p>
<div className="space-y-2">
<input
type="number"
min={1}
placeholder={t("userId")}
value={rank.userId}
onChange={(e) =>
setRankForm((s) => ({ ...s, userId: e.target.value }))
}
className="w-full"
disabled={isPending}
/>
<input
type="number"
min={0}
max={10}
placeholder={t("rankPlaceholder")}
value={rank.rank}
onChange={(e) =>
setRankForm((s) => ({ ...s, rank: e.target.value }))
}
className="w-full"
disabled={isPending}
/>
<Button
type="button"
variant="default"
className="w-full"
disabled={isPending || !rank.userId || rank.rank === ""}
onClick={() =>
run(
() =>
setRank({
userId: Number(rank.userId),
rank: Number(rank.rank),
}),
{
successMessage: "Rank updated.",
errorMessage: RCON_ERROR,
onSuccess: () => setRankForm({ userId: "", rank: "" }),
},
)
}
>
{t("setRank")}
</Button>
</div>
</div>
</div>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4 mt-4">
<div className="admin-card">
<h3 className="text-sm font-bold text-[var(--admin-text)] mb-2">
{t("executeCommand")}
</h3>
<p className="text-xs text-[var(--admin-text-muted)] mb-3">
{t("executeCommandDesc")}
</p>
<div className="space-y-2">
<input
type="number"
min={1}
placeholder={t("userId")}
value={command.userId}
onChange={(e) =>
setCommand((s) => ({ ...s, userId: e.target.value }))
}
className="w-full"
disabled={isPending}
/>
<input
type="text"
placeholder={t("commandPlaceholder")}
value={command.command}
onChange={(e) =>
setCommand((s) => ({ ...s, command: e.target.value }))
}
maxLength={100}
className="w-full"
disabled={isPending}
/>
<Button
type="button"
variant="default"
className="w-full"
disabled={
isPending || !command.userId || !command.command.trim()
}
onClick={() =>
run(
() =>
executeCommand({
userId: Number(command.userId),
command: command.command,
}),
{
successMessage: "Command executed.",
errorMessage: RCON_ERROR,
onSuccess: () => setCommand({ userId: "", command: "" }),
},
)
}
>
{t("execute")}
</Button>
</div>
</div>
<div className="admin-card">
<h3 className="text-sm font-bold text-[var(--admin-text)] mb-2">
{t("sendGift")}
</h3>
<p className="text-xs text-[var(--admin-text-muted)] mb-3">
{t("sendGiftDesc")}
</p>
<div className="space-y-2">
<input
type="number"
min={1}
placeholder={t("userId")}
value={gift.userId}
onChange={(e) =>
setGift((s) => ({ ...s, userId: e.target.value }))
}
className="w-full"
disabled={isPending}
/>
<input
type="number"
min={1}
placeholder={t("furniId")}
value={gift.itemId}
onChange={(e) =>
setGift((s) => ({ ...s, itemId: e.target.value }))
}
className="w-full"
disabled={isPending}
/>
<input
type="text"
placeholder={t("giftMessage")}
value={gift.message}
onChange={(e) =>
setGift((s) => ({ ...s, message: e.target.value }))
}
maxLength={255}
className="w-full"
disabled={isPending}
/>
<Button
type="button"
variant="default"
className="w-full"
disabled={isPending || !gift.userId || !gift.itemId}
onClick={() =>
run(
() =>
sendGift({
userId: Number(gift.userId),
itemId: Number(gift.itemId),
message: gift.message,
}),
{
successMessage: "Gift sent.",
errorMessage: RCON_ERROR,
onSuccess: () =>
setGift({
userId: "",
itemId: "",
message: t("defaultGiftMessage"),
}),
},
)
}
>
{t("sendGift")}
</Button>
</div>
</div>
</div>
</section>
</>
);
}
@@ -0,0 +1 @@
export { default } from "../loading";
+330
View File
@@ -0,0 +1,330 @@
import os from "node:os";
import { count, desc, inArray } from "drizzle-orm";
import { Terminal } from "lucide-react";
import { redirect } from "next/navigation";
import { getTranslations } from "next-intl/server";
import { AdminPageShell } from "@/components/admin/admin-page-shell";
import {
DiagnosticRow,
InfoItem,
OnlineUsersWidget,
StatusCard,
} from "@/components/admin/dashboard";
import { fetchOpsHealth } from "@/lib/admin/ops-health";
import { fetchOpsOnlineUsers } from "@/lib/admin/ops-online-users";
import { db, EmulatorErrors, StaffActivities, User } from "@/lib/db";
import { formatDate } from "@/lib/format-date";
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
import { siteSettings } from "@/lib/services/site-settings";
import { CommandocentrumControls } from "./commandocentrum-controls";
function decodeStacktrace(raw: unknown): string {
if (raw == null) return "";
try {
if (typeof raw === "string") return raw;
return Buffer.from(raw as Uint8Array).toString("utf8");
} catch {
return "";
}
}
function mb(bytes: number): string {
return `${Math.round(bytes / 1024 / 1024)} MB`;
}
function uptime(seconds: number): string {
const d = Math.floor(seconds / 86400);
const h = Math.floor((seconds % 86400) / 3600);
const m = Math.floor((seconds % 3600) / 60);
return d > 0 ? `${d}d ${h}h ${m}m` : h > 0 ? `${h}h ${m}m` : `${m}m`;
}
export default async function CommandoCentrum() {
const { session, permissions } = await getAdminContext();
if (!canAccess(permissions, PERMS.RCON_EXECUTE, session.user.rank)) {
redirect("/admin");
}
const t = await getTranslations("pages.admin.commandocentrum");
const [totalUsersRows, errors, health, maintenance, activity, onlineRoster] =
await Promise.all([
db
.select({ total: count() })
.from(User)
.catch(() => [{ total: -1 }]),
db
.select()
.from(EmulatorErrors)
.orderBy(desc(EmulatorErrors.timestamp))
.limit(50)
.catch(() => []),
fetchOpsHealth(),
siteSettings.getBool("maintenance_enabled", false),
db
.select()
.from(StaffActivities)
.orderBy(desc(StaffActivities.createdAt))
.limit(12)
.catch(() => [] as (typeof StaffActivities.$inferSelect)[]),
fetchOpsOnlineUsers(40),
]);
const totalUsers = Number(totalUsersRows[0]?.total ?? -1);
const dbOk = health.dbOk;
const redisOk = health.redisOk;
const emulatorOnline = health.emulatorOk;
const onlineCount = onlineRoster.count;
const onlineUsers = onlineRoster.users;
const staffIds = Array.from(
new Set(activity.map((a) => Number(a.userId))),
).filter((n) => n > 0);
let nameById = new Map<number, string>();
if (staffIds.length) {
const users = await db
.select({ id: User.id, username: User.username })
.from(User)
.where(inArray(User.id, staffIds))
.catch(() => []);
nameById = new Map(users.map((u) => [u.id, u.username]));
}
const appKeySet = !!process.env.APP_KEY;
const smtpSet = !!process.env.SMTP_HOST;
const rconSet = !!process.env.RCON_HOST;
return (
<AdminPageShell icon={Terminal} title={t("title")} subtitle={t("subtitle")}>
<main>
{/* ── Live status ────────────────────────────────────────── */}
<div
className="grid grid-cols-[repeat(auto-fit,minmax(180px,1fr))] gap-3.5 mb-6"
style={{ marginTop: "1.1rem" }}
>
<StatusCard
label={t("playersOnline")}
value={onlineCount}
hint={
totalUsers >= 0
? t("totalAccounts", { count: totalUsers })
: t("dbUnavailable")
}
state="neutral"
icon="👤"
/>
<StatusCard
label={t("emulator")}
value={emulatorOnline ? t("online") : t("offline")}
hint={emulatorOnline ? t("rconReachable") : t("rconNotResponding")}
state={emulatorOnline ? "ok" : "danger"}
icon="🎮"
/>
<StatusCard
label={t("database")}
value={dbOk ? t("connected") : t("down")}
hint={dbOk ? t("queriesResponding") : t("cannotReachDb")}
state={dbOk ? "ok" : "danger"}
icon="🗄️"
/>
<StatusCard
label={t("emulatorErrors")}
value={errors.length}
hint={t("last50Logged")}
state={errors.length === 0 ? "ok" : "warn"}
icon="⚠️"
/>
</div>
{/* ── Online users widget ───────────────────────────────── */}
<section id="online" className="admin-card mb-6 scroll-mt-4">
<h2 className="admin-section-title">{t("onlineUsers")}</h2>
<OnlineUsersWidget users={onlineUsers} isLoading={false} />
</section>
{/* ── Server info + diagnostics ──────────────────────────── */}
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<section className="admin-card p-5">
<h2 className="admin-section-title">{t("serverInfo")}</h2>
<div className="grid grid-cols-[repeat(auto-fit,minmax(150px,1fr))] gap-2.5">
<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">{t("systemDiagnostics")}</h2>
<div className="flex flex-col">
<DiagnosticRow
label={t("database")}
detail={dbOk ? t("connectionHealthy") : t("unreachable")}
state={dbOk ? "ok" : "danger"}
/>
<DiagnosticRow
label="Redis"
detail={
redisOk === true
? "PONG"
: redisOk === false
? "Unreachable"
: "REDIS_URL not configured"
}
state={
redisOk === true
? "ok"
: redisOk === false
? "danger"
: "warn"
}
/>
<DiagnosticRow
label={t("emulatorRcon")}
detail={
emulatorOnline ? t("respondingToPing") : t("noResponse")
}
state={emulatorOnline ? "ok" : "warn"}
/>
<DiagnosticRow
label={t("rconConfig")}
detail={rconSet ? t("rconHostSet") : t("rconHostNotConfigured")}
state={rconSet ? "ok" : "warn"}
/>
<DiagnosticRow
label={t("twoFaEncryptionKey")}
detail={appKeySet ? t("appKeyConfigured") : t("appKeyMissing")}
state={appKeySet ? "ok" : "warn"}
/>
<DiagnosticRow
label={t("emailSmtp")}
detail={smtpSet ? t("smtpHostSet") : t("notConfigured")}
state={smtpSet ? "ok" : "warn"}
/>
<DiagnosticRow
label={t("maintenanceMode")}
detail={
maintenance ? t("siteIsInMaintenance") : t("siteIsLive")
}
state={maintenance ? "warn" : "ok"}
/>
</div>
</section>
</div>
<CommandocentrumControls />
{/* ── Staff activity ─────────────────────────────────────── */}
<section className="mt-6">
<h2 className="admin-section-title">{t("recentStaffActivity")}</h2>
{activity.length === 0 ? (
<div className="admin-empty">{t("noStaffActivity")}</div>
) : (
<div className="admin-card overflow-x-auto">
<table>
<thead>
<tr>
<th>{t("colWhen")}</th>
<th>{t("colStaff")}</th>
<th>{t("colAction")}</th>
<th>{t("colDescription")}</th>
</tr>
</thead>
<tbody>
{activity.map((a) => (
<tr key={String(a.id)}>
<td className="text-sm theme-text-muted dark:theme-text-muted whitespace-nowrap">
{formatDate(a.createdAt, "datetime-seconds")}
</td>
<td>
{nameById.get(Number(a.userId)) ?? `#${a.userId}`}
</td>
<td>
<span className="inline-block text-[0.6rem] font-bold px-2 py-0.5 rounded-full bg-[var(--admin-accent)]/18 text-[var(--admin-text)]">
{a.action}
</span>
</td>
<td className="text-sm theme-text-muted dark:theme-text-muted">
{a.description}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</section>
{/* ── Emulator errors ────────────────────────────────────── */}
<section className="mt-6">
<h2 className="admin-section-title">{t("recentEmulatorErrors")}</h2>
{errors.length === 0 ? (
<div className="admin-empty">{t("noEmulatorErrors")}</div>
) : (
<div className="admin-card overflow-x-auto">
<table>
<thead>
<tr>
<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);
const snippet = trace
? trace.split("\n").slice(0, 20).join("\n") +
(trace.split("\n").length > 20
? `\n… ${t("truncated")}`
: "")
: t("empty");
return (
<tr key={e.id}>
<td className="text-sm theme-text-muted dark:theme-text-muted whitespace-nowrap">
{formatDate(
e.timestamp ? new Date(e.timestamp * 1000) : null,
"datetime-seconds",
)}
</td>
<td>{e.type}</td>
<td className="text-sm theme-text-muted dark:theme-text-muted whitespace-nowrap">
{e.version}
</td>
<td>
<pre className="text-xs p-2 bg-[var(--admin-canvas)] text-[var(--color-text-readable)] rounded overflow-auto max-h-[160px]">
{snippet}
</pre>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
</section>
</main>
</AdminPageShell>
);
}
+93
View File
@@ -0,0 +1,93 @@
import { count, desc } from "drizzle-orm";
import { redirect } from "next/navigation";
import { getTranslations } from "next-intl/server";
import { Card, CardContent } from "@/components/ui/card";
import { db, EmulatorErrors } from "@/lib/db";
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
export default async function EmulatorErrorsPage({
searchParams,
}: {
searchParams: Promise<Record<string, string>>;
}) {
const { session, permissions } = await getAdminContext();
if (!canAccess(permissions, PERMS.DEVOPS_VIEW, session.user.rank)) {
redirect("/admin");
}
const t = await getTranslations("pages.admin.devops");
const params = await searchParams;
const page = Math.max(1, Number(params.page) || 1);
const perPage = 50;
const [errors, totalRows] = await Promise.all([
db
.select()
.from(EmulatorErrors)
.orderBy(desc(EmulatorErrors.id))
.limit(perPage)
.offset((page - 1) * perPage),
db.select({ total: count() }).from(EmulatorErrors),
]);
const total = Number(totalRows[0]?.total ?? 0);
const lastPage = Math.ceil(total / perPage);
return (
<div className="space-y-6">
<p className="text-muted-foreground">
{total} total errors — showing page {page} of {lastPage}
</p>
<Card>
<CardContent className="p-0">
{errors.length === 0 ? (
<div className="admin-empty">{t("noEmulatorErrors")}</div>
) : (
<div className="divide-y">
{errors.map((error) => (
<div key={error.id} className="p-4">
<div className="flex items-center justify-between mb-2">
<span className="font-mono text-xs text-muted-foreground">
#{error.id}
</span>
<span className="text-xs text-muted-foreground">
{new Date(error.timestamp * 1000).toLocaleString()}
</span>
</div>
<pre className="text-xs whitespace-pre-wrap break-all font-mono bg-muted p-2 rounded">
{Buffer.from(error.stacktrace).toString("utf-8")}
</pre>
</div>
))}
</div>
)}
</CardContent>
</Card>
{lastPage > 1 && (
<div className="flex justify-center gap-2">
{page > 1 && (
<a
href={`?page=${page - 1}`}
className="text-sm text-primary hover:underline"
>
Previous
</a>
)}
<span className="text-sm text-muted-foreground">
Page {page} of {lastPage}
</span>
{page < lastPage && (
<a
href={`?page=${page + 1}`}
className="text-sm text-primary hover:underline"
>
Next
</a>
)}
</div>
)}
</div>
);
}
@@ -0,0 +1,65 @@
"use client";
import { RefreshCw } from "lucide-react";
import { useEffect, useState } from "react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
export function HealthCheckClient() {
const [status, setStatus] = useState<"checking" | "online" | "offline">(
"checking",
);
const [checking, setChecking] = useState(false);
async function checkEmulator() {
setChecking(true);
setStatus("checking");
try {
const res = await fetch("/api/admin/devops/health");
if (res.ok) {
const data = await res.json();
setStatus(data.emulator ? "online" : "offline");
} else {
setStatus("offline");
}
} catch {
setStatus("offline");
} finally {
setChecking(false);
}
}
// biome-ignore lint/correctness/useExhaustiveDependencies: explicitly chosen here, see surrounding code
useEffect(() => {
checkEmulator();
}, []);
return (
<div className="flex items-center gap-2">
<Badge
variant={
status === "online"
? "default"
: status === "offline"
? "destructive"
: "secondary"
}
>
{status === "checking"
? "Checking..."
: status === "online"
? "Online"
: "Offline"}
</Badge>
<Button
variant="ghost"
size="icon"
className="h-6 w-6"
onClick={checkEmulator}
disabled={checking}
>
<RefreshCw className={`h-3 w-3 ${checking ? "animate-spin" : ""}`} />
</Button>
</div>
);
}
+1
View File
@@ -0,0 +1 @@
export { default } from "../loading";
+182
View File
@@ -0,0 +1,182 @@
import { count, desc, eq, gte } from "drizzle-orm";
import { AlertTriangle, Database, HeartPulse, Server } from "lucide-react";
import { redirect } from "next/navigation";
import { getTranslations } from "next-intl/server";
import Link from "@/components/link";
import { Badge } from "@/components/ui/badge";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { fetchOpsHealth } from "@/lib/admin/ops-health";
import { db, EmulatorErrors, EmulatorSettings } from "@/lib/db";
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
import { HealthCheckClient } from "./health-check-client";
async function getDevOpsData() {
const now = Math.floor(Date.now() / 1000);
const dayAgo = now - 86400;
const [health, recentErrors, errorsTodayRows, emuVersionRows] =
await Promise.all([
fetchOpsHealth(),
db
.select()
.from(EmulatorErrors)
.orderBy(desc(EmulatorErrors.id))
.limit(20),
db
.select({ total: count() })
.from(EmulatorErrors)
.where(gte(EmulatorErrors.timestamp, dayAgo)),
db
.select({ value: EmulatorSettings.value })
.from(EmulatorSettings)
.where(eq(EmulatorSettings.key, "version"))
.limit(1),
]);
return {
dbStatus: health.dbOk ? ("healthy" as const) : ("error" as const),
dbLatency: health.dbLatencyMs,
redisOk: health.redisOk,
recentErrors,
errorsToday: Number(errorsTodayRows[0]?.total ?? 0),
emuVersion: emuVersionRows[0]?.value ?? "Unknown",
onlineUsers: health.onlineUsers,
};
}
export default async function DevOpsPage() {
const { session, permissions } = await getAdminContext();
if (!canAccess(permissions, PERMS.DEVOPS_VIEW, session.user.rank)) {
redirect("/admin");
}
const t = await getTranslations("pages.admin.devops");
const data = await getDevOpsData();
return (
<div className="space-y-6">
{/* Status cards */}
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-5">
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Database</CardTitle>
<Database className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<Badge
variant={data.dbStatus === "healthy" ? "default" : "destructive"}
>
{data.dbStatus === "healthy" ? "Healthy" : "Error"}
</Badge>
<p className="text-xs text-muted-foreground mt-1">
Latency: {data.dbLatency}ms
</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Redis</CardTitle>
<Server className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<Badge
variant={
data.redisOk === false
? "destructive"
: data.redisOk === true
? "default"
: "secondary"
}
>
{data.redisOk === true
? "Healthy"
: data.redisOk === false
? "Error"
: "Not configured"}
</Badge>
<p className="text-xs text-muted-foreground mt-1">
Cache / rate limits / sessions
</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Emulator</CardTitle>
<Server className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<HealthCheckClient />
<p className="text-xs text-muted-foreground mt-1">
Version: {data.emuVersion}
</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Online Users</CardTitle>
<HeartPulse className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{data.onlineUsers}</div>
<p className="text-xs text-muted-foreground mt-1">
<Link
href="/admin/commandocentrum#online"
className="text-[var(--admin-accent)] underline-offset-2 hover:underline"
>
Live roster in CommandoCentrum
</Link>
</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Errors (24h)</CardTitle>
<AlertTriangle className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{data.errorsToday}</div>
<p className="text-xs text-muted-foreground">
Emulator errors today
</p>
</CardContent>
</Card>
</div>
{/* Recent errors */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<AlertTriangle className="h-4 w-4" /> Recent Emulator Errors
</CardTitle>
</CardHeader>
<CardContent>
{data.recentErrors.length === 0 ? (
<div className="admin-empty">{t("noRecentErrors")}</div>
) : (
<div className="space-y-2 max-h-[500px] overflow-y-auto">
{data.recentErrors.map((error) => (
<div key={error.id} className="rounded-md border p-3 text-sm">
<div className="flex items-center justify-between mb-1">
<span className="font-mono text-xs text-muted-foreground">
#{error.id}
</span>
<span className="text-xs text-muted-foreground">
{new Date(error.timestamp * 1000).toLocaleString()}
</span>
</div>
<pre className="text-xs whitespace-pre-wrap break-all font-mono text-destructive">
{Buffer.from(error.stacktrace).toString("utf-8")}
</pre>
</div>
))}
</div>
)}
</CardContent>
</Card>
</div>
);
}
+151
View File
@@ -0,0 +1,151 @@
import { asc } from "drizzle-orm";
import { redirect } from "next/navigation";
import { getTranslations } from "next-intl/server";
import {
createEmailTemplate,
deleteEmailTemplate,
updateEmailTemplate,
} from "@/actions/admin-email-templates";
import { Button } from "@/components/ui/button";
import { db, EmailTemplates } from "@/lib/db";
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
export default async function AdminEmailTemplates() {
const { session, permissions } = await getAdminContext();
if (!canAccess(permissions, PERMS.PAGES_VIEW, session.user.rank)) {
redirect("/admin");
}
const t = await getTranslations("pages.admin.emailTemplates");
let templates: (typeof EmailTemplates.$inferSelect)[] = [];
try {
templates = await db
.select()
.from(EmailTemplates)
.orderBy(asc(EmailTemplates.name));
} catch {
templates = [];
}
return (
<main>
<form action={createEmailTemplate} className="admin-card mb-6">
<h3 className="text-sm font-bold text-[var(--admin-text)] mb-2">
{t("newTemplate")}
</h3>
<div className="flex gap-2 flex-wrap items-center mb-3">
<input
name="name"
placeholder={t("form.namePlaceholder")}
required
className="flex-1 min-w-[180px]"
/>
<input
name="subject"
placeholder={t("form.subjectPlaceholder")}
required
className="flex-1 min-w-[220px]"
/>
<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 theme-text-muted dark:theme-text-muted">
{t("form.active")}
</span>
</label>
</div>
<textarea
name="body"
placeholder={t("form.bodyPlaceholder")}
required
rows={5}
className="w-full resize-y mb-3"
/>
<Button type="submit" variant="default">
{t("create")}
</Button>
</form>
<div className="grid">
{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]">{template.name}</strong>
<input
name="subject"
defaultValue={template.subject}
placeholder={t("form.subjectPlaceholder")}
required
className="flex-1 min-w-[220px]"
/>
<input
name="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={template.isActive}
className="w-auto"
/>
<span className="text-sm theme-text-muted dark:theme-text-muted">
{t("form.active")}
</span>
</label>
</div>
<textarea
name="body"
defaultValue={template.body}
required
rows={6}
className="w-full resize-y mb-2"
/>
<div className="flex gap-2 items-center">
<Button type="submit" variant="default">
{t("save")}
</Button>
{template.updatedAt ? (
<span className="text-xs theme-text-muted dark:theme-text-muted">
{t("updated", {
date: new Date(template.updatedAt)
.toISOString()
.slice(0, 16)
.replace("T", " "),
})}
</span>
) : null}
</div>
</form>
<form action={deleteEmailTemplate} className="mt-4">
<input type="hidden" name="id" value={id} />
<Button type="submit" variant="destructive">
{t("delete")}
</Button>
</form>
</div>
);
})}
</div>
{templates.length === 0 ? (
<div className="admin-empty">{t("noTemplates")}</div>
) : null}
</main>
);
}
+196
View File
@@ -0,0 +1,196 @@
import { asc, count } from "drizzle-orm";
import { redirect } from "next/navigation";
import { getTranslations } from "next-intl/server";
import {
updateEmulatorSetting,
updateEmulatorText,
} from "@/actions/admin-emulator";
import { StatusCard } from "@/components/admin/dashboard";
import { Button } from "@/components/ui/button";
import { db, EmulatorSettings, EmulatorTexts } from "@/lib/db";
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
const TEXTS_LIMIT = 300;
export default async function AdminEmulator() {
const { session, permissions } = await getAdminContext();
if (!canAccess(permissions, PERMS.SETTINGS_VIEW, session.user.rank)) {
redirect("/admin");
}
const t = await getTranslations("pages.admin.emulator");
let settings: { key: string; value: string }[] = [];
try {
settings = await db
.select({ key: EmulatorSettings.key, value: EmulatorSettings.value })
.from(EmulatorSettings)
.orderBy(asc(EmulatorSettings.key));
} catch {
settings = [];
}
let texts: { key: string; value: string }[] = [];
let textsCount = 0;
try {
const [row] = await db.select({ total: count() }).from(EmulatorTexts);
textsCount = Number(row?.total ?? 0);
} catch {
textsCount = 0;
}
try {
texts = await db
.select({ key: EmulatorTexts.key, value: EmulatorTexts.value })
.from(EmulatorTexts)
.orderBy(asc(EmulatorTexts.key))
.limit(TEXTS_LIMIT);
} catch {
texts = [];
}
const textsTruncated = textsCount > texts.length;
return (
<main>
<div className="grid grid-cols-[repeat(auto-fit,minmax(180px,1fr))] gap-3.5 mb-6">
<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">{t("settings")}</h2>
<p className="text-xs theme-text-muted dark:theme-text-muted mb-3">
{t("keysCount", { count: settings.length })}
</p>
<form action={updateEmulatorSetting} className="admin-card mb-6">
<h3 className="text-sm font-bold text-[var(--admin-text)] m-0 mb-3">
{t("addOverwriteSetting")}
</h3>
<div className="flex gap-2 flex-wrap">
<input name="key" placeholder={t("key")} maxLength={100} required />
<input
name="value"
placeholder={t("value")}
maxLength={512}
className="flex-1 min-w-[200px]"
/>
<Button type="submit" variant="default">
{t("save")}
</Button>
</div>
</form>
<table>
<thead>
<tr>
<th className="w-[320px]">{t("colKey")}</th>
<th>{t("colValue")}</th>
</tr>
</thead>
<tbody>
{settings.map((s) => (
<tr key={s.key}>
<td>
<strong>{s.key}</strong>
</td>
<td>
<form action={updateEmulatorSetting} className="flex gap-2">
<input type="hidden" name="key" value={s.key} />
<input
name="value"
defaultValue={s.value}
maxLength={512}
className="flex-1"
/>
<Button type="submit" variant="outline">
{t("save")}
</Button>
</form>
</td>
</tr>
))}
</tbody>
</table>
{settings.length === 0 ? (
<p className="text-xs theme-text-muted dark:theme-text-muted">
{t("noSettings")}
</p>
) : null}
</section>
<section className="mt-6">
<h2 className="admin-section-title">{t("texts")}</h2>
<p className="text-xs theme-text-muted dark:theme-text-muted mb-3">
{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(--admin-text)] m-0 mb-3">
{t("addOverwriteText")}
</h3>
<div className="flex gap-2 flex-wrap">
<input name="key" placeholder={t("key")} maxLength={100} required />
<input
name="value"
placeholder={t("value")}
maxLength={4096}
className="flex-1 min-w-[200px]"
/>
<Button type="submit" variant="default">
{t("save")}
</Button>
</div>
</form>
<table>
<thead>
<tr>
<th className="w-[320px]">{t("colKey")}</th>
<th>{t("colValue")}</th>
</tr>
</thead>
<tbody>
{texts.map((txt) => (
<tr key={txt.key}>
<td>
<strong>{txt.key}</strong>
</td>
<td>
<form action={updateEmulatorText} className="flex gap-2">
<input type="hidden" name="key" value={txt.key} />
<input
name="value"
defaultValue={txt.value}
maxLength={4096}
className="flex-1"
/>
<Button type="submit" variant="outline">
{t("save")}
</Button>
</form>
</td>
</tr>
))}
</tbody>
</table>
{texts.length === 0 ? (
<div className="admin-empty">{t("noTexts")}</div>
) : null}
{textsTruncated ? (
<p className="muted">
{t("truncatedHint", { shown: texts.length, total: textsCount })}
</p>
) : null}
</section>
</main>
);
}
+76
View File
@@ -0,0 +1,76 @@
"use client";
import { LayoutDashboard, RefreshCw } from "lucide-react";
import { useTranslations } from "next-intl";
import { useEffect } from "react";
import Link from "@/components/link";
import { Button } from "@/components/ui/button";
/**
* Admin route-segment error boundary. Renders inside the admin layout shell.
*/
export default function AdminErrorPage({
error,
reset,
}: {
error: Error & { digest?: string };
reset: () => void;
}) {
const t = useTranslations("pages.error");
const tNav = useTranslations("admin.nav");
useEffect(() => {
console.error(error);
}, [error]);
return (
<div
className="rounded-xl border p-8 max-w-lg mx-auto mt-8 text-center"
style={{
borderColor: "var(--admin-border, rgba(0,0,0,0.08))",
background: "var(--admin-surface, #fff)",
}}
role="alert"
>
<p
className="text-sm font-bold uppercase tracking-wider mb-2"
style={{ color: "var(--admin-muted, #6b7280)" }}
>
{t("code")}
</p>
<h1 className="text-xl font-extrabold mb-2">{t("title")}</h1>
<p
className="text-sm mb-1"
style={{ color: "var(--admin-muted, #6b7280)" }}
>
{t("subtitle")}
</p>
<p
className="text-sm mb-6"
style={{ color: "var(--admin-muted, #6b7280)" }}
>
{t("body")}
</p>
<div className="flex flex-wrap gap-3 justify-center">
<Button type="button" variant="default" onClick={() => reset()}>
<RefreshCw className="size-4" aria-hidden />
{t("tryAgain")}
</Button>
<Button variant="outline" asChild>
<Link href="/admin">
<LayoutDashboard className="size-4" aria-hidden />
{tNav("dashboard")}
</Link>
</Button>
</div>
{error.digest ? (
<p
className="mt-6 text-xs font-mono"
style={{ color: "var(--admin-muted, #9ca3af)" }}
>
{t("reference", { digest: error.digest })}
</p>
) : null}
</div>
);
}
+215
View File
@@ -0,0 +1,215 @@
"use client";
import { Plus, Trash2 } from "lucide-react";
import { useTranslations } from "next-intl";
import { useState } from "react";
import { addEventPrize, deleteEventPrize } from "@/actions/events";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { useServerAction } from "@/hooks/use-server-action";
import type { EventPrizeInput } from "@/lib/validators/event";
type PrizeType = "badge" | "credits" | "pixels" | "points" | "item";
interface Prize {
id: number;
position: number;
prizeType: string;
badgeCode: string | null;
credits: number;
pixels: number;
points: number;
itemId: number | null;
description: string;
}
interface Props {
eventId: number;
prizes: Prize[];
}
export function EventPrizesManager({ eventId, prizes }: Props) {
const t = useTranslations("pages.admin.events");
const { run, isPending } = useServerAction();
const [showForm, setShowForm] = useState(false);
const [form, setForm] = useState<EventPrizeInput>({
eventId,
position: prizes.length + 1,
prizeType: "badge",
badgeCode: "",
credits: 0,
pixels: 0,
points: 0,
description: "",
});
function handleAdd(e: React.FormEvent) {
e.preventDefault();
run(() => addEventPrize(form), {
successMessage: "Prize added!",
onSuccess: () => {
setShowForm(false);
setForm({
...form,
position: form.position + 1,
badgeCode: "",
description: "",
});
},
});
}
return (
<Card>
<CardHeader className="flex flex-row items-center justify-between">
<CardTitle>{t("statPrizes")}</CardTitle>
<Button size="sm" onClick={() => setShowForm(!showForm)}>
<Plus className="h-4 w-4 mr-1" /> {t("addPrize")}
</Button>
</CardHeader>
<CardContent className="space-y-4">
{showForm && (
<form
onSubmit={handleAdd}
className="border rounded-lg p-4 space-y-3"
>
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
<div className="space-y-1">
<Label>Position</Label>
<Input
type="number"
value={form.position}
onChange={(e) =>
setForm({ ...form, position: Number(e.target.value) })
}
/>
</div>
<div className="space-y-1">
<Label>Type</Label>
<Select
value={form.prizeType}
onValueChange={(v) =>
setForm({ ...form, prizeType: v as PrizeType })
}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="badge">Badge</SelectItem>
<SelectItem value="credits">Credits</SelectItem>
<SelectItem value="pixels">Pixels</SelectItem>
<SelectItem value="points">Points</SelectItem>
<SelectItem value="item">Item</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-1">
<Label>Badge Code</Label>
<Input
value={form.badgeCode ?? ""}
onChange={(e) =>
setForm({ ...form, badgeCode: e.target.value })
}
placeholder="ACH_..."
/>
</div>
<div className="space-y-1">
<Label>Credits</Label>
<Input
type="number"
value={form.credits}
onChange={(e) =>
setForm({ ...form, credits: Number(e.target.value) })
}
/>
</div>
</div>
<div className="space-y-1">
<Label>Description</Label>
<Input
value={form.description}
onChange={(e) =>
setForm({ ...form, description: e.target.value })
}
/>
</div>
<div className="flex gap-2 justify-end">
<Button
type="button"
variant="outline"
size="sm"
onClick={() => setShowForm(false)}
>
Cancel
</Button>
<Button type="submit" size="sm" disabled={isPending}>
Add
</Button>
</div>
</form>
)}
{prizes.length === 0 && !showForm && (
<div className="admin-empty">{t("noPrizes")}</div>
)}
{prizes.map((prize) => (
<div
key={prize.id}
className="flex items-center justify-between border rounded-lg px-4 py-3"
>
<div className="flex items-center gap-3">
<span className="flex h-8 w-8 items-center justify-center rounded-full bg-accent font-bold text-sm">
#{prize.position}
</span>
<div>
<div className="flex items-center gap-2">
<Badge variant="secondary">{prize.prizeType}</Badge>
{prize.badgeCode && (
<span className="text-sm font-mono">{prize.badgeCode}</span>
)}
{prize.credits > 0 && (
<span className="text-sm">{prize.credits} credits</span>
)}
{prize.pixels > 0 && (
<span className="text-sm">{prize.pixels} pixels</span>
)}
{prize.points > 0 && (
<span className="text-sm">{prize.points} points</span>
)}
</div>
{prize.description && (
<p className="text-xs text-muted-foreground">
{prize.description}
</p>
)}
</div>
</div>
<Button
variant="ghost"
size="icon"
onClick={() =>
run(() => deleteEventPrize({ id: prize.id }), {
successMessage: "Prize removed",
})
}
>
<Trash2 className="h-4 w-4 text-destructive" />
</Button>
</div>
))}
</CardContent>
</Card>
);
}
@@ -0,0 +1,48 @@
"use client";
import { useTranslations } from "next-intl";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
interface Registration {
id: number;
userId: number;
username: string;
createdAt: Date;
}
interface Props {
registrations: Registration[];
}
export function EventRegistrations({ registrations }: Props) {
const t = useTranslations("pages.admin.events");
return (
<Card>
<CardHeader>
<CardTitle>
{t("registrationsTitle", { count: registrations.length })}
</CardTitle>
</CardHeader>
<CardContent>
{registrations.length === 0 ? (
<div className="admin-empty">{t("noRegistrations")}</div>
) : (
<div className="divide-y max-h-[400px] overflow-y-auto">
{registrations.map((r) => (
<div
key={r.id}
className="flex items-center justify-between py-2"
>
<span className="text-sm font-medium">{r.username}</span>
<span className="text-xs text-muted-foreground">
{new Date(r.createdAt).toLocaleString()}
</span>
</div>
))}
</div>
)}
</CardContent>
</Card>
);
}
+104
View File
@@ -0,0 +1,104 @@
"use client";
import { Plus } from "lucide-react";
import { useTranslations } from "next-intl";
import { useState } from "react";
import { addEventWinner } from "@/actions/events";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { useServerAction } from "@/hooks/use-server-action";
interface Winner {
id: number;
userId: number;
position: number;
username: string;
createdAt: Date;
}
interface Props {
eventId: number;
winners: Winner[];
}
export function EventWinnersManager({ eventId, winners }: Props) {
const t = useTranslations("pages.admin.events");
const { run, isPending } = useServerAction();
const [showForm, setShowForm] = useState(false);
const [userId, setUserId] = useState("");
const [position, setPosition] = useState(winners.length + 1);
function handleAdd(e: React.FormEvent) {
e.preventDefault();
run(() => addEventWinner({ eventId, userId: Number(userId), position }), {
successMessage: "Winner added!",
onSuccess: () => {
setShowForm(false);
setUserId("");
setPosition(position + 1);
},
});
}
return (
<Card>
<CardHeader className="flex flex-row items-center justify-between">
<CardTitle>{t("statWinners")}</CardTitle>
<Button size="sm" onClick={() => setShowForm(!showForm)}>
<Plus className="h-4 w-4 mr-1" /> {t("addWinner")}
</Button>
</CardHeader>
<CardContent className="space-y-3">
{showForm && (
<form
onSubmit={handleAdd}
className="border rounded-lg p-4 flex gap-3 items-end"
>
<div className="space-y-1">
<Label>User ID</Label>
<Input
type="number"
value={userId}
onChange={(e) => setUserId(e.target.value)}
required
placeholder="12345"
/>
</div>
<div className="space-y-1">
<Label>Position</Label>
<Input
type="number"
value={position}
onChange={(e) => setPosition(Number(e.target.value))}
min={1}
/>
</div>
<Button type="submit" size="sm" disabled={isPending}>
Add
</Button>
</form>
)}
{winners.length === 0 && !showForm && (
<div className="admin-empty">{t("noWinners")}</div>
)}
<div className="divide-y">
{winners.map((w) => (
<div key={w.id} className="flex items-center gap-3 py-2.5">
<span className="flex h-7 w-7 items-center justify-center rounded-full bg-primary text-primary-foreground text-xs font-bold">
{w.position}
</span>
<span className="font-medium text-sm">{w.username}</span>
<span className="text-xs text-muted-foreground ml-auto">
{new Date(w.createdAt).toLocaleDateString()}
</span>
</div>
))}
</div>
</CardContent>
</Card>
);
}
+213
View File
@@ -0,0 +1,213 @@
import { asc, eq, inArray } from "drizzle-orm";
import { CalendarDays, Gift, Trophy, Users } from "lucide-react";
import { notFound, redirect } from "next/navigation";
import { getTranslations } from "next-intl/server";
import { AdminBreadcrumb } from "@/components/admin/admin-breadcrumb";
import { Badge } from "@/components/ui/badge";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { db, User, WebsiteEvent, WebsiteEventType } from "@/lib/db";
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
import { EventForm } from "../event-form";
import { EventPrizesManager } from "./event-prizes";
import { EventRegistrations } from "./event-registrations";
import { EventWinnersManager } from "./event-winners";
export default async function EventDetailPage({
params,
}: {
params: Promise<{ locale: string; id: string }>;
}) {
const { id } = await params;
const { session, permissions } = await getAdminContext();
if (!canAccess(permissions, PERMS.EVENTS_EDIT, session.user.rank)) {
redirect("/admin/events");
}
const eventId = parseInt(id, 10);
if (Number.isNaN(eventId)) notFound();
const t = await getTranslations("pages.admin.events");
const tHubs = await getTranslations("pages.admin.hubs");
const event = await db.query.WebsiteEvent.findFirst({
where: eq(WebsiteEvent.id, eventId),
with: {
type: true,
prizes: { orderBy: (p, { asc: a }) => [a(p.position)] },
winners: { orderBy: (w, { asc: a }) => [a(w.position)] },
registrations: { orderBy: (r, { desc: d }) => [d(r.createdAt)] },
},
});
if (!event) notFound();
const eventTypes = await db
.select()
.from(WebsiteEventType)
.where(eq(WebsiteEventType.isActive, 1))
.orderBy(asc(WebsiteEventType.name));
// Fetch usernames for registrations and winners
const userIds = [
...event.registrations.map((r) => r.userId),
...event.winners.map((w) => w.userId),
];
const users =
userIds.length > 0
? await db
.select({ id: User.id, username: User.username })
.from(User)
.where(inArray(User.id, [...new Set(userIds)]))
: [];
const userMap = new Map(users.map((u) => [u.id, u.username]));
const statusLabel: Record<string, string> = {
published: t("statusPublished"),
draft: t("statusDraft"),
cancelled: t("statusCancelled"),
completed: t("statusCompleted"),
};
return (
<div className="space-y-6">
<div>
<AdminBreadcrumb
items={[
{ label: tHubs("tabs.events"), href: "/admin/events" },
{ label: event.title },
]}
/>
<div className="flex items-center gap-3">
<h1 className="m-0 text-2xl font-extrabold text-[var(--admin-text)]">
{event.title}
</h1>
<Badge
variant={
event.status === "published"
? "default"
: event.status === "cancelled"
? "destructive"
: "secondary"
}
>
{statusLabel[event.status] ?? event.status}
</Badge>
</div>
<p className="m-0 mt-1 text-sm text-[var(--admin-text-muted)]">
{event.type.name} &middot; {new Date(event.startsAt).toLocaleString()}
</p>
</div>
{/* Stats */}
<div className="grid gap-4 md:grid-cols-4">
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">
{t("statRegistrations")}
</CardTitle>
<Users className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">
{event.registrations.length}
</div>
{event.maxPlayers && (
<p className="text-xs text-muted-foreground">
{t("maxPlayers", { count: event.maxPlayers })}
</p>
)}
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">
{t("statPrizes")}
</CardTitle>
<Gift className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{event.prizes.length}</div>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">
{t("statWinners")}
</CardTitle>
<Trophy className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{event.winners.length}</div>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">
{t("statTime")}
</CardTitle>
<CalendarDays className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-sm font-medium">
{new Date(event.startsAt) > new Date()
? t("timeUpcoming")
: t("timePast")}
</div>
{event.endsAt && (
<p className="text-xs text-muted-foreground">
{t("endsAt", {
date: new Date(event.endsAt).toLocaleString(),
})}
</p>
)}
</CardContent>
</Card>
</div>
{/* Edit form */}
<EventForm
eventTypes={eventTypes}
defaultValues={{
id: event.id,
title: event.title,
description: event.description,
typeId: event.typeId,
roomId: event.roomId,
startsAt: event.startsAt,
endsAt: event.endsAt,
maxPlayers: event.maxPlayers,
isRecurring: event.isRecurring,
recurrenceRule: event.recurrenceRule,
status: event.status as
| "draft"
| "published"
| "cancelled"
| "completed",
image: event.image,
}}
/>
{/* Prizes */}
<EventPrizesManager eventId={event.id} prizes={event.prizes} />
{/* Winners */}
<EventWinnersManager
eventId={event.id}
winners={event.winners.map((w) => ({
...w,
username:
userMap.get(w.userId) ?? t("userFallback", { id: w.userId }),
}))}
/>
{/* Registrations */}
<EventRegistrations
registrations={event.registrations.map((r) => ({
...r,
username:
userMap.get(r.userId) ?? t("userFallback", { id: r.userId }),
}))}
/>
</div>
);
}
+35
View File
@@ -0,0 +1,35 @@
import { asc, eq } from "drizzle-orm";
import { redirect } from "next/navigation";
import { getTranslations } from "next-intl/server";
import { db, WebsiteEventType } from "@/lib/db";
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
import { EventForm } from "../event-form";
export default async function CreateEventPage() {
const { session, permissions } = await getAdminContext();
if (!canAccess(permissions, PERMS.EVENTS_EDIT, session.user.rank)) {
redirect("/admin/events");
}
const t = await getTranslations("pages.admin.events");
const eventTypes = await db
.select()
.from(WebsiteEventType)
.where(eq(WebsiteEventType.isActive, 1))
.orderBy(asc(WebsiteEventType.name));
return (
<div className="space-y-6">
<div>
<h1 className="m-0 text-2xl font-extrabold text-[var(--admin-text)]">
{t("createTitle")}
</h1>
<p className="m-0 mt-1 text-sm text-[var(--admin-text-muted)]">
{t("createSubtitle")}
</p>
</div>
<EventForm eventTypes={eventTypes} />
</div>
);
}
+230
View File
@@ -0,0 +1,230 @@
"use client";
import { useTranslations } from "next-intl";
import { useForm } from "react-hook-form";
import { createEvent, updateEvent } from "@/actions/events";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { DateTimePicker } from "@/components/ui/date-time-picker";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Textarea } from "@/components/ui/textarea";
import { useServerAction } from "@/hooks/use-server-action";
import type {
CreateEventInput,
UpdateEventInput,
} from "@/lib/validators/event";
interface EventType {
id: number;
name: string;
slug: string;
color: string;
}
interface EventFormProps {
eventTypes: EventType[];
defaultValues?: Partial<CreateEventInput> & { id?: number };
}
interface EventFormValues {
title: string;
description: string;
image?: string | null;
maxPlayers?: string | number | null;
roomId?: string | number | null;
typeId: number;
status: string;
isRecurring: number;
startsAt?: Date | null;
endsAt?: Date | null;
}
export function EventForm({ eventTypes, defaultValues }: EventFormProps) {
const t = useTranslations("pages.admin.events");
const { run, isPending } = useServerAction();
const isEditing = !!defaultValues?.id;
const form = useForm<EventFormValues>({
defaultValues: {
title: "",
description: "",
typeId: eventTypes[0]?.id ?? 0,
status: "draft",
isRecurring: 0,
...defaultValues,
},
});
function onSubmit(data: EventFormValues) {
if (isEditing && defaultValues?.id) {
run(
() =>
updateEvent({ id: defaultValues.id, ...data } as UpdateEventInput & {
id: number;
}),
{
successMessage: t("toastUpdated"),
},
);
} else {
run(() => createEvent(data as CreateEventInput), {
successMessage: t("toastCreated"),
onSuccess: (result) => {
const data = result.data as Record<string, unknown> | undefined;
if (data?.id) {
window.location.href = `/admin/events/${data.id}`;
}
},
});
}
}
return (
<form onSubmit={form.handleSubmit(onSubmit)}>
<Card>
<CardHeader>
<CardTitle>
{isEditing ? t("formEditTitle") : t("formDetailsTitle")}
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="title">{t("formTitle")}</Label>
<Input id="title" {...form.register("title")} />
{form.formState.errors.title && (
<p className="text-sm text-destructive">
{form.formState.errors.title.message}
</p>
)}
</div>
<div className="space-y-2">
<Label htmlFor="typeId">{t("formEventType")}</Label>
<Select
value={String(form.watch("typeId"))}
onValueChange={(v) => form.setValue("typeId", Number(v))}
>
<SelectTrigger>
<SelectValue placeholder={t("formSelectType")} />
</SelectTrigger>
<SelectContent>
{eventTypes.map((et) => (
<SelectItem key={et.id} value={String(et.id)}>
{et.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="description">{t("formDescription")}</Label>
<Textarea
id="description"
rows={4}
{...form.register("description")}
/>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div className="space-y-2">
<Label>{t("formStartsAt")}</Label>
<DateTimePicker
value={form.watch("startsAt")}
onChange={(d) =>
form.setValue("startsAt", d, { shouldDirty: true })
}
placeholder={t("formSelectStart")}
/>
</div>
<div className="space-y-2">
<Label>{t("formEndsAt")}</Label>
<DateTimePicker
value={form.watch("endsAt")}
onChange={(d) =>
form.setValue("endsAt", d, { shouldDirty: true })
}
placeholder={t("formSelectEnd")}
/>
</div>
<div className="space-y-2">
<Label htmlFor="maxPlayers">{t("formMaxPlayers")}</Label>
<Input
id="maxPlayers"
type="number"
{...form.register("maxPlayers")}
placeholder={t("formUnlimited")}
/>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="roomId">{t("formRoomId")}</Label>
<Input
id="roomId"
type="number"
{...form.register("roomId")}
placeholder={t("formOptional")}
/>
</div>
<div className="space-y-2">
<Label htmlFor="status">{t("colStatus")}</Label>
<Select
value={form.watch("status")}
onValueChange={(v) =>
form.setValue("status", v as CreateEventInput["status"])
}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="draft">{t("statusDraft")}</SelectItem>
<SelectItem value="published">
{t("statusPublished")}
</SelectItem>
<SelectItem value="cancelled">
{t("statusCancelled")}
</SelectItem>
<SelectItem value="completed">
{t("statusCompleted")}
</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="image">{t("formImageUrl")}</Label>
<Input
id="image"
{...form.register("image")}
placeholder="https://..."
/>
</div>
<div className="flex justify-end">
<Button type="submit" disabled={isPending}>
{isPending
? t("formSaving")
: isEditing
? t("formUpdate")
: t("formCreate")}
</Button>
</div>
</CardContent>
</Card>
</form>
);
}
+140
View File
@@ -0,0 +1,140 @@
"use client";
import { Pencil, Trash2 } from "lucide-react";
import { useTranslations } from "next-intl";
import { deleteEvent } from "@/actions/events";
import { useConfirmDialog } from "@/components/admin/confirm-dialog";
import { DataTable } from "@/components/admin/data-table";
import Link from "@/components/link";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { useServerAction } from "@/hooks/use-server-action";
import type { DataTableColumn, PaginatedResult } from "@/types/common";
interface EventRow {
id: number;
title: string;
typeName: string;
typeColor: string;
startsAt: Date;
endsAt: Date | null;
status: string;
hostUserId: number;
}
const statusVariants: Record<
string,
"default" | "secondary" | "outline" | "destructive"
> = {
published: "default",
draft: "secondary",
cancelled: "destructive",
completed: "outline",
};
interface EventsTableProps {
data: PaginatedResult<EventRow>;
}
export function EventsTable({ data }: EventsTableProps) {
const t = useTranslations("pages.admin.events");
const { run, isPending } = useServerAction();
const { confirm, dialog: confirmDialog } = useConfirmDialog();
const statusLabel: Record<string, string> = {
published: t("statusPublished"),
draft: t("statusDraft"),
cancelled: t("statusCancelled"),
completed: t("statusCompleted"),
};
async function handleDelete(row: EventRow) {
const ok = await confirm({
title: "Delete event",
description: `Delete "${row.title}"? This cannot be undone.`,
confirmLabel: "Delete",
cancelLabel: "Cancel",
});
if (!ok) return;
run(() => deleteEvent({ id: row.id }), {
successMessage: "Event deleted!",
errorMessage: "Failed to delete event.",
});
}
const columns: DataTableColumn<EventRow>[] = [
{ key: "id", label: t("colId"), sortable: true },
{
key: "title",
label: t("colTitle"),
sortable: true,
render: (value) => <span className="font-medium">{String(value)}</span>,
},
{
key: "typeName",
label: t("colType"),
sortable: false,
render: (_, row) => (
<Badge style={{ backgroundColor: row.typeColor, color: "#fff" }}>
{row.typeName}
</Badge>
),
},
{
key: "startsAt",
label: t("colStarts"),
sortable: true,
render: (value) => (
<span className="text-sm">
{new Date(value as unknown as string).toLocaleString()}
</span>
),
},
{
key: "status",
label: t("colStatus"),
sortable: true,
render: (value) => {
const key = String(value);
return (
<Badge variant={statusVariants[key] ?? "secondary"}>
{statusLabel[key] ?? key}
</Badge>
);
},
},
{
key: "actions",
label: t("colActions"),
render: (_, row) => (
<div className="flex items-center gap-1">
<Button variant="ghost" size="icon" asChild>
<Link href={`/admin/events/${row.id}`}>
<Pencil className="h-4 w-4" />
</Link>
</Button>
<Button
variant="ghost"
size="icon"
disabled={isPending}
onClick={() => handleDelete(row)}
aria-label="Delete event"
>
<Trash2 className="h-4 w-4 text-destructive" />
</Button>
</div>
),
},
];
return (
<>
<DataTable
data={data}
columns={columns}
searchPlaceholder={t("searchPlaceholder")}
/>
{confirmDialog}
</>
);
}
+83
View File
@@ -0,0 +1,83 @@
import { count, desc, eq, like } from "drizzle-orm";
import { getTranslations } from "next-intl/server";
import Link from "@/components/link";
import { Button } from "@/components/ui/button";
import { fetchAdminList } from "@/lib/admin-list";
import { db, WebsiteEvent, WebsiteEventType } from "@/lib/db";
import { PERMS } from "@/lib/permissions";
import { EventsTable } from "./events-table";
export default async function EventsPage({
params: paramsPromise,
searchParams,
}: {
params: Promise<{ locale: string }>;
searchParams: Promise<Record<string, string>>;
}) {
const t = await getTranslations("pages.admin.events");
const result = await fetchAdminList(
{
permission: PERMS.EVENTS_VIEW,
fetch: async ({ search, page, perPage }) => {
const [rows, totals] = await Promise.all([
db
.select({
id: WebsiteEvent.id,
title: WebsiteEvent.title,
startsAt: WebsiteEvent.startsAt,
endsAt: WebsiteEvent.endsAt,
status: WebsiteEvent.status,
hostUserId: WebsiteEvent.hostUserId,
typeName: WebsiteEventType.name,
typeColor: WebsiteEventType.color,
})
.from(WebsiteEvent)
.leftJoin(
WebsiteEventType,
eq(WebsiteEvent.typeId, WebsiteEventType.id),
)
.where(search ? like(WebsiteEvent.title, `%${search}%`) : undefined)
.orderBy(desc(WebsiteEvent.startsAt))
.limit(perPage)
.offset((page - 1) * perPage),
db.select({ total: count() }).from(WebsiteEvent),
]);
return {
rows: rows.map((e) => ({
id: e.id,
title: e.title,
typeName: e.typeName ?? "",
typeColor: e.typeColor ?? "",
startsAt: e.startsAt,
endsAt: e.endsAt,
status: e.status,
hostUserId: e.hostUserId,
})),
total: totals[0]?.total ?? 0,
};
},
},
paramsPromise,
searchParams,
);
return (
<div className="space-y-6">
<div className="flex justify-end">
<Button variant="default" asChild>
<Link href="/admin/events/create">{t("newEvent")}</Link>
</Button>
</div>
<EventsTable
data={{
rows: result.rows,
total: result.total,
page: result.page,
perPage: result.perPage,
lastPage: result.lastPage,
}}
/>
</div>
);
}
@@ -0,0 +1,237 @@
"use client";
import { Pencil, Plus, Trash2 } from "lucide-react";
import { useState } from "react";
import {
createEventType,
deleteEventType,
updateEventType,
} from "@/actions/events";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { useServerAction } from "@/hooks/use-server-action";
interface EventType {
id: number;
name: string;
slug: string;
description: string;
color: string;
icon: string;
isActive: number;
minRank: number;
}
interface Props {
eventTypes: EventType[];
}
export function EventTypesManager({ eventTypes }: Props) {
const { run, isPending } = useServerAction();
const [showForm, setShowForm] = useState(false);
const [editingId, setEditingId] = useState<number | null>(null);
const [form, setForm] = useState({
name: "",
slug: "",
description: "",
color: "#3b82f6",
icon: "calendar",
minRank: 0,
});
function resetForm() {
setForm({
name: "",
slug: "",
description: "",
color: "#3b82f6",
icon: "calendar",
minRank: 0,
});
setEditingId(null);
setShowForm(false);
}
function handleEdit(et: EventType) {
setForm({
name: et.name,
slug: et.slug,
description: et.description,
color: et.color,
icon: et.icon,
minRank: et.minRank,
});
setEditingId(et.id);
setShowForm(true);
}
function handleSubmit(e: React.FormEvent) {
e.preventDefault();
if (editingId) {
run(() => updateEventType({ id: editingId, ...form }), {
successMessage: "Event type updated!",
onSuccess: resetForm,
});
} else {
run(() => createEventType(form), {
successMessage: "Event type created!",
onSuccess: resetForm,
});
}
}
function handleDelete(id: number) {
run(() => deleteEventType({ id }), {
successMessage: "Event type deleted!",
});
}
return (
<div className="space-y-4">
<div className="flex justify-end">
<Button onClick={() => setShowForm(!showForm)}>
<Plus className="h-4 w-4 mr-2" />
New Type
</Button>
</div>
{showForm && (
<Card>
<CardHeader>
<CardTitle>
{editingId ? "Edit Event Type" : "New Event Type"}
</CardTitle>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div className="space-y-2">
<Label>Name</Label>
<Input
value={form.name}
onChange={(e) => setForm({ ...form, name: e.target.value })}
required
/>
</div>
<div className="space-y-2">
<Label>Slug</Label>
<Input
value={form.slug}
onChange={(e) => setForm({ ...form, slug: e.target.value })}
required
placeholder="game-night"
/>
</div>
<div className="space-y-2">
<Label>Color</Label>
<Input
type="color"
value={form.color}
onChange={(e) =>
setForm({ ...form, color: e.target.value })
}
/>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-2">
<Label>Description</Label>
<Input
value={form.description}
onChange={(e) =>
setForm({ ...form, description: e.target.value })
}
/>
</div>
<div className="space-y-2">
<Label>Rank minimo (0 = tutti)</Label>
<Input
type="number"
min={0}
max={7}
value={form.minRank}
onChange={(e) =>
setForm({
...form,
minRank: parseInt(e.target.value, 10) || 0,
})
}
/>
<p className="text-[10px] text-muted-foreground">
0=Tutti, 3=Mod+, 4=Staff+, 6=Admin+
</p>
</div>
</div>
<div className="flex gap-2 justify-end">
<Button type="button" variant="outline" onClick={resetForm}>
Cancel
</Button>
<Button type="submit" disabled={isPending}>
{isPending ? "Saving..." : editingId ? "Update" : "Create"}
</Button>
</div>
</form>
</CardContent>
</Card>
)}
<Card>
<CardContent className="p-0">
<div className="divide-y">
{eventTypes.length === 0 && (
<div className="p-6 text-center text-muted-foreground">
No event types yet. Create your first one above.
</div>
)}
{eventTypes.map((et) => (
<div
key={et.id}
className="flex items-center justify-between px-4 py-3"
>
<div className="flex items-center gap-3">
<div
className="w-3 h-3 rounded-full"
style={{ backgroundColor: et.color }}
/>
<div>
<span className="font-medium">{et.name}</span>
<span className="ml-2 text-xs text-muted-foreground">
{et.slug}
</span>
</div>
<Badge variant={et.isActive ? "default" : "outline"}>
{et.isActive ? "Active" : "Inactive"}
</Badge>
{et.minRank > 0 && (
<Badge variant="secondary" className="text-[10px]">
Rank {et.minRank}+
</Badge>
)}
</div>
<div className="flex gap-1">
<Button
variant="ghost"
size="icon"
onClick={() => handleEdit(et)}
>
<Pencil className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
onClick={() => handleDelete(et.id)}
>
<Trash2 className="h-4 w-4 text-destructive" />
</Button>
</div>
</div>
))}
</div>
</CardContent>
</Card>
</div>
);
}
+23
View File
@@ -0,0 +1,23 @@
import { asc } from "drizzle-orm";
import { redirect } from "next/navigation";
import { db, WebsiteEventType } from "@/lib/db";
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
import { EventTypesManager } from "./event-types-manager";
export default async function EventTypesPage() {
const { session, permissions } = await getAdminContext();
if (!canAccess(permissions, PERMS.EVENTS_EDIT, session.user.rank)) {
redirect("/admin/events");
}
const eventTypes = await db
.select()
.from(WebsiteEventType)
.orderBy(asc(WebsiteEventType.name));
return (
<div className="space-y-6">
<EventTypesManager eventTypes={eventTypes} />
</div>
);
}
+155
View File
@@ -0,0 +1,155 @@
"use client";
import Image from "next/image";
import { useTranslations } from "next-intl";
import { useRef, useState } from "react";
import { deleteFavicon, saveFavicon } from "@/actions/save-favicon";
import { useConfirmDialog } from "@/components/admin/confirm-dialog";
import { Button } from "@/components/ui/button";
import { FaviconGenerator } from "./favicon-generator";
export function FaviconForm({ currentUrl }: { currentUrl: string | null }) {
const t = useTranslations("pages.admin.favicon");
const { confirm, dialog: confirmDialog } = useConfirmDialog();
const [uploading, setUploading] = useState(false);
const [deleting, setDeleting] = useState(false);
const [preview, setPreview] = useState<string | null>(currentUrl);
const [message, setMessage] = useState<{
type: "success" | "error";
text: string;
} | null>(null);
const formRef = useRef<HTMLFormElement>(null);
async function handleUpload(formData: FormData) {
setUploading(true);
setMessage(null);
const result = await saveFavicon(formData);
setUploading(false);
if (result.success && result.url) {
setPreview(result.url);
setMessage({ type: "success", text: t("saved") });
formRef.current?.reset();
} else {
setMessage({ type: "error", text: result.error ?? t("error") });
}
}
async function handleDelete() {
const ok = await confirm({
title: t("delete"),
description: t("confirmDelete"),
confirmLabel: t("delete"),
cancelLabel: t("cancel"),
});
if (!ok) return;
setDeleting(true);
setMessage(null);
const result = await deleteFavicon();
setDeleting(false);
if (result.success) {
setPreview(null);
setMessage({ type: "success", text: t("deleted") });
} else {
setMessage({ type: "error", text: result.error ?? t("error") });
}
}
async function handleGenerated(blob: Blob) {
setUploading(true);
setMessage(null);
const file = new File([blob], "favicon.png", { type: "image/png" });
const fd = new FormData();
fd.append("file", file);
const result = await saveFavicon(fd);
setUploading(false);
if (result.success && result.url) {
setPreview(result.url);
setMessage({ type: "success", text: t("saved") });
} else {
setMessage({ type: "error", text: result.error ?? t("error") });
}
}
return (
<div className="grid gap-6 lg:grid-cols-2">
{confirmDialog}
<div className="admin-card">
<h3 className="text-sm font-extrabold text-[var(--admin-text)] mb-4">
{t("uploadLabel")}
</h3>
{preview && (
<div className="mb-6">
<label
htmlFor="favicon-current"
className="block text-xs font-bold text-[var(--admin-text-muted)] uppercase tracking-wider mb-2"
>
{t("current")}
</label>
<div className="flex items-center gap-4">
<Image
id="favicon-current"
src={preview}
alt="Favicon preview"
width={64}
height={64}
className="rounded-lg border-2 border-[var(--admin-border)] object-contain bg-[var(--admin-surface)]"
unoptimized
/>
<div className="text-xs text-[var(--admin-text-muted)] break-all">
{preview}
</div>
</div>
</div>
)}
<form action={handleUpload} ref={formRef}>
<div className="mb-4">
<input
type="file"
name="file"
accept=".png,.jpg,.jpeg,.gif,.webp,.ico,.svg"
required
className="block w-full text-sm text-[var(--color-text-readable)] file:mr-3 file:py-2 file:px-4 file:rounded-lg file:border-0 file:text-sm file:font-semibold file:bg-[var(--admin-accent)] file:text-[var(--color-primary-foreground-readable)] cursor-pointer"
/>
<p className="mt-1 text-xs text-[var(--admin-text-muted)]">
{t("uploadHint")}
</p>
</div>
<div className="flex gap-2">
<Button type="submit" disabled={uploading} variant="default">
{uploading ? t("uploading") : t("upload")}
</Button>
{preview && (
<Button
type="button"
onClick={handleDelete}
disabled={deleting}
variant="destructive"
>
{deleting ? t("deleting") : t("delete")}
</Button>
)}
</div>
</form>
{message && (
<div
className={`mt-4 p-3 rounded-lg text-sm ${
message.type === "success"
? "bg-[var(--admin-success-subtle)] text-[var(--admin-success)] dark:text-[var(--admin-success)]"
: "bg-[var(--admin-error-subtle)] text-[var(--admin-error)] dark:text-[var(--admin-error)]"
}`}
>
{message.text}
</div>
)}
</div>
<FaviconGenerator onSave={handleGenerated} />
</div>
);
}
+209
View File
@@ -0,0 +1,209 @@
"use client";
import { useTranslations } from "next-intl";
import { useCallback, useEffect, useRef, useState } from "react";
import { Button } from "@/components/ui/button";
const SIZE = 64;
const HALF = SIZE / 2;
interface Props {
onSave: (blob: Blob) => void;
}
export function FaviconGenerator({ onSave }: Props) {
const t = useTranslations("pages.admin.favicon.generator");
const [bgColor, setBgColor] = useState("#6366f1");
const [textColor, setTextColor] = useState("#ffffff");
const [text, setText] = useState("A");
const [rounded, setRounded] = useState(true);
const canvasRef = useRef<HTMLCanvasElement>(null);
const render = useCallback(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const ctx = canvas.getContext("2d");
if (!ctx) return;
canvas.width = SIZE;
canvas.height = SIZE;
// Background
ctx.clearRect(0, 0, SIZE, SIZE);
ctx.fillStyle = bgColor;
if (rounded) {
const r = 12;
ctx.beginPath();
ctx.moveTo(r, 0);
ctx.lineTo(SIZE - r, 0);
ctx.quadraticCurveTo(SIZE, 0, SIZE, r);
ctx.lineTo(SIZE, SIZE - r);
ctx.quadraticCurveTo(SIZE, SIZE, SIZE - r, SIZE);
ctx.lineTo(r, SIZE);
ctx.quadraticCurveTo(0, SIZE, 0, SIZE - r);
ctx.lineTo(0, r);
ctx.quadraticCurveTo(0, 0, r, 0);
ctx.closePath();
ctx.fill();
} else {
ctx.fillRect(0, 0, SIZE, SIZE);
}
// Text
const displayText = text.trim() || "?";
const fontSize =
displayText.length > 2 ? 28 : displayText.length > 1 ? 32 : 40;
ctx.fillStyle = textColor;
ctx.font = `bold ${fontSize}px Arial, sans-serif`;
ctx.textAlign = "center";
ctx.textBaseline = "middle";
ctx.fillText(displayText, HALF, HALF + 1);
}, [bgColor, textColor, text, rounded]);
useEffect(() => {
render();
}, [render]);
function handleSave() {
const canvas = canvasRef.current;
if (!canvas) return;
canvas.toBlob((blob) => {
if (blob) onSave(blob);
}, "image/png");
}
const swatches = [
"#6366f1",
"#3b82f6",
"#06b6d4",
"#10b981",
"#f59e0b",
"#ef4444",
"#ec4899",
"#8b5cf6",
"#1e1b4b",
"#1f2937",
"#000000",
"#ffffff",
];
return (
<div className="admin-card">
<h3 className="text-sm font-extrabold text-[var(--admin-text)] mb-4">
{t("title")}
</h3>
<div className="flex justify-center mb-6">
<canvas
ref={canvasRef}
width={SIZE}
height={SIZE}
className="border-2 border-[var(--admin-border)] rounded-xl shadow-lg"
style={{ width: 128, height: 128, imageRendering: "pixelated" }}
/>
</div>
<div className="space-y-4">
<div>
<label
htmlFor="favicon-text"
className="block text-xs font-bold text-[var(--admin-text-muted)] uppercase tracking-wider mb-2"
>
{t("text")}
</label>
<input
id="favicon-text"
type="text"
value={text}
onChange={(e) => setText(e.target.value.slice(0, 4))}
maxLength={4}
className="w-full"
placeholder="A"
/>
</div>
<div>
<label
htmlFor="favicon-bgColor"
className="block text-xs font-bold text-[var(--admin-text-muted)] uppercase tracking-wider mb-2"
>
{t("bgColor")}
</label>
<div className="flex items-center gap-2 mb-2">
<input
id="favicon-bgColor"
type="color"
value={bgColor}
onChange={(e) => setBgColor(e.target.value)}
className="w-10 h-10 rounded-lg cursor-pointer border border-[var(--admin-border)]"
/>
<span className="text-xs text-[var(--admin-text-muted)] font-mono">
{bgColor}
</span>
</div>
<div className="flex flex-wrap gap-1.5">
{swatches.map((c) => (
<button
key={c}
type="button"
onClick={() => setBgColor(c)}
className={`w-7 h-7 rounded-lg border-2 transition-all ${
bgColor === c
? "border-[var(--admin-accent)] scale-110"
: "border-transparent"
}`}
style={{ backgroundColor: c }}
/>
))}
</div>
</div>
<div>
<label
htmlFor="favicon-textColor"
className="block text-xs font-bold text-[var(--admin-text-muted)] uppercase tracking-wider mb-2"
>
{t("textColor")}
</label>
<div className="flex items-center gap-2">
<input
id="favicon-textColor"
type="color"
value={textColor}
onChange={(e) => setTextColor(e.target.value)}
className="w-10 h-10 rounded-lg cursor-pointer border border-[var(--admin-border)]"
/>
<span className="text-xs text-[var(--admin-text-muted)] font-mono">
{textColor}
</span>
</div>
</div>
<div className="flex items-center gap-2">
<input
type="checkbox"
id="rounded"
checked={rounded}
onChange={(e) => setRounded(e.target.checked)}
className="rounded"
/>
<label
htmlFor="rounded"
className="text-sm text-[var(--admin-text)] cursor-pointer"
>
{t("rounded")}
</label>
</div>
<Button
type="button"
onClick={handleSave}
variant="default"
className="w-full"
>
{t("save")}
</Button>
</div>
</div>
);
}
+30
View File
@@ -0,0 +1,30 @@
import { eq } from "drizzle-orm";
import { redirect } from "next/navigation";
import { db, WebsiteSetting } from "@/lib/db";
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
import { FaviconForm } from "./favicon-form";
export default async function AdminFaviconPage() {
const { session, permissions } = await getAdminContext();
if (!canAccess(permissions, PERMS.SETTINGS_VIEW, session.user.rank)) {
redirect("/admin");
}
let faviconUrl: string | null = null;
try {
const [setting] = await db
.select({ value: WebsiteSetting.value })
.from(WebsiteSetting)
.where(eq(WebsiteSetting.key, "cms_favicon"))
.limit(1);
faviconUrl = setting?.value ?? null;
} catch {
faviconUrl = null;
}
return (
<main>
<FaviconForm currentUrl={faviconUrl} />
</main>
);
}
+218
View File
@@ -0,0 +1,218 @@
import { asc, count, eq, inArray } from "drizzle-orm";
import { notFound, redirect } from "next/navigation";
import { getTranslations } from "next-intl/server";
import { disbandGuild } from "@/actions/admin-guilds";
import Link from "@/components/link";
import { Button } from "@/components/ui/button";
import { db, Guilds, GuildsForumsThreads, GuildsMembers, User } from "@/lib/db";
import { formatDate } from "@/lib/format-date";
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
export default async function AdminGuildDetailPage({
params,
}: {
params: Promise<{ id: string }>;
}) {
const { session, permissions } = await getAdminContext();
if (!canAccess(permissions, PERMS.USERS_VIEW, session.user.rank)) {
redirect("/admin");
}
const canEdit = canAccess(permissions, PERMS.USERS_EDIT, session.user.rank);
const t = await getTranslations("pages.admin.guilds");
const { id: rawId } = await params;
const id = Number(rawId);
if (!Number.isFinite(id) || id <= 0) notFound();
const [guild] = await db
.select({
id: Guilds.id,
name: Guilds.name,
description: Guilds.description,
userId: Guilds.userId,
roomId: Guilds.roomId,
badge: Guilds.badge,
dateCreated: Guilds.dateCreated,
state: Guilds.state,
forum: Guilds.forum,
})
.from(Guilds)
.where(eq(Guilds.id, id))
.limit(1);
if (!guild) notFound();
const [ownerRows, members, memberCountRows, threadCountRows] =
await Promise.all([
db
.select({ id: User.id, username: User.username })
.from(User)
.where(eq(User.id, guild.userId))
.limit(1)
.catch(() => []),
db
.select({
userId: GuildsMembers.userId,
levelId: GuildsMembers.levelId,
memberSince: GuildsMembers.memberSince,
})
.from(GuildsMembers)
.where(eq(GuildsMembers.guildId, id))
.orderBy(asc(GuildsMembers.levelId), asc(GuildsMembers.memberSince))
.limit(100)
.catch(() => []),
db
.select({ total: count() })
.from(GuildsMembers)
.where(eq(GuildsMembers.guildId, id))
.catch(() => [{ total: 0 }]),
db
.select({ total: count() })
.from(GuildsForumsThreads)
.where(eq(GuildsForumsThreads.guildId, id))
.catch(() => [{ total: 0 }]),
]);
const owner = ownerRows[0] ?? null;
const memberCount = Number(memberCountRows[0]?.total ?? 0);
const threadCount = Number(threadCountRows[0]?.total ?? 0);
const memberUserIds = members.map((m) => m.userId).filter((uid) => uid > 0);
const memberUsers =
memberUserIds.length > 0
? await db
.select({ id: User.id, username: User.username })
.from(User)
.where(inArray(User.id, memberUserIds))
.catch(() => [])
: [];
const usernameById = new Map(memberUsers.map((u) => [u.id, u.username]));
return (
<main className="space-y-6">
<div className="flex flex-wrap items-start justify-between gap-4">
<div className="flex items-center gap-4">
{guild.badge ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={`https://habbo-stories-content.s3.amazonaws.com/badge-part-images/${guild.badge}.gif`}
alt=""
width={64}
height={64}
className="h-16 w-16 object-contain"
/>
) : null}
<div>
<h1 className="m-0 text-xl font-bold">
{guild.name || `#${guild.id}`}
</h1>
<p className="muted m-0 text-sm">#{guild.id}</p>
</div>
</div>
<div className="flex flex-wrap gap-2">
<Button variant="ghost" size="sm" asChild>
<Link href="/admin/guilds">{t("back")}</Link>
</Button>
<Button variant="ghost" size="sm" asChild>
<Link href={`/guilds/${guild.id}`} target="_blank">
{t("viewPublic")}
</Link>
</Button>
{canEdit ? (
<form action={disbandGuild}>
<input type="hidden" name="id" value={guild.id} />
<Button
type="submit"
variant="ghost"
size="sm"
style={{ color: "var(--admin-error)" }}
>
{t("disband")}
</Button>
</form>
) : null}
</div>
</div>
{guild.description ? (
<p className="text-sm text-[var(--admin-text-muted)]">
{guild.description}
</p>
) : null}
<div className="grid grid-cols-[repeat(auto-fit,minmax(160px,1fr))] gap-3">
<div className="admin-card">
<div className="text-xs muted">{t("colOwner")}</div>
<div>
{owner ? (
<Link href={`/admin/users/show/${owner.id}`}>
{owner.username}
</Link>
) : (
`#${guild.userId}`
)}
</div>
</div>
<div className="admin-card">
<div className="text-xs muted">{t("colMembers")}</div>
<div className="text-lg font-bold">{memberCount}</div>
</div>
<div className="admin-card">
<div className="text-xs muted">{t("colThreads")}</div>
<div className="text-lg font-bold">{threadCount}</div>
</div>
<div className="admin-card">
<div className="text-xs muted">{t("colCreated")}</div>
<div>{formatDate(new Date(guild.dateCreated * 1000))}</div>
</div>
<div className="admin-card">
<div className="text-xs muted">{t("colRoom")}</div>
<div>
{guild.roomId > 0 ? (
<Link href={`/admin/rooms/show/${guild.roomId}`}>
#{guild.roomId}
</Link>
) : (
"—"
)}
</div>
</div>
</div>
<section>
<h2 className="admin-section-title">
{t("members")} ({Math.min(members.length, memberCount)})
</h2>
{members.length === 0 ? (
<div className="admin-empty">{t("noMembers")}</div>
) : (
<div className="admin-card overflow-x-auto" style={{ padding: 0 }}>
<table>
<thead>
<tr>
<th>{t("colUser")}</th>
<th>{t("colLevel")}</th>
<th>{t("colJoined")}</th>
</tr>
</thead>
<tbody>
{members.map((m) => (
<tr key={`${m.userId}-${m.memberSince}`}>
<td>
<Link href={`/admin/users/show/${m.userId}`}>
{usernameById.get(m.userId) ?? `#${m.userId}`}
</Link>
</td>
<td className="muted">{m.levelId}</td>
<td className="muted">
{formatDate(new Date(m.memberSince * 1000))}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</section>
</main>
);
}
+212
View File
@@ -0,0 +1,212 @@
import { count, desc, eq, inArray, like, or } from "drizzle-orm";
import { Shield } from "lucide-react";
import { redirect } from "next/navigation";
import { getTranslations } from "next-intl/server";
import { AdminPageShell } from "@/components/admin/admin-page-shell";
import { AdminSimplePager } from "@/components/admin/admin-simple-pager";
import { StatusCard } from "@/components/admin/dashboard";
import Link from "@/components/link";
import { Button } from "@/components/ui/button";
import { calcPagination, parseListParams } from "@/lib/admin-helpers";
import { db, Guilds, GuildsMembers, User } from "@/lib/db";
import { formatDate } from "@/lib/format-date";
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
export default async function AdminGuildsPage({
searchParams,
}: {
searchParams: Promise<Record<string, string>>;
}) {
const { session, permissions } = await getAdminContext();
if (!canAccess(permissions, PERMS.USERS_VIEW, session.user.rank)) {
redirect("/admin");
}
const t = await getTranslations("pages.admin.guilds");
const raw = await searchParams;
const parsed = parseListParams(new URLSearchParams(raw));
const q = parsed.search.trim();
let total = 0;
let rows: Array<{
id: number;
name: string;
description: string;
userId: number;
badge: string;
dateCreated: number;
roomId: number;
}> = [];
let loadError = false;
try {
const where = q
? or(
like(Guilds.name, `%${q}%`),
like(Guilds.description, `%${q}%`),
...(Number.isFinite(Number(q)) && Number(q) > 0
? [eq(Guilds.id, Number(q))]
: []),
)
: undefined;
const [totalRow] = await db
.select({ total: count() })
.from(Guilds)
.where(where);
total = Number(totalRow?.total ?? 0);
const pagination = calcPagination(total, parsed.page, parsed.perPage);
rows = await db
.select({
id: Guilds.id,
name: Guilds.name,
description: Guilds.description,
userId: Guilds.userId,
badge: Guilds.badge,
dateCreated: Guilds.dateCreated,
roomId: Guilds.roomId,
})
.from(Guilds)
.where(where)
.orderBy(desc(Guilds.id))
.limit(pagination.perPage)
.offset(pagination.offset);
} catch {
loadError = true;
}
const pagination = calcPagination(total, parsed.page, parsed.perPage);
const ownerIds = [
...new Set(rows.map((r) => r.userId).filter((id) => id > 0)),
];
const guildIds = rows.map((r) => r.id);
const [owners, memberGroups] = await Promise.all([
ownerIds.length
? db
.select({ id: User.id, username: User.username })
.from(User)
.where(inArray(User.id, ownerIds))
.catch(() => [])
: Promise.resolve([]),
guildIds.length
? db
.select({
guildId: GuildsMembers.guildId,
total: count(),
})
.from(GuildsMembers)
.where(inArray(GuildsMembers.guildId, guildIds))
.groupBy(GuildsMembers.guildId)
.catch(() => [])
: Promise.resolve([]),
]);
const ownerById = new Map(owners.map((u) => [u.id, u.username]));
const membersByGuild = new Map(
memberGroups.map((g) => [g.guildId, Number(g.total)]),
);
return (
<AdminPageShell icon={Shield} title={t("title")} subtitle={t("subtitle")}>
<main>
{loadError ? (
<div className="mb-4 rounded-lg bg-[var(--admin-error-subtle)] p-3 text-xs text-[var(--admin-error)]">
{t("loadError")}
</div>
) : null}
<form className="mb-4 flex gap-2" method="get">
<input
type="search"
name="search"
defaultValue={q}
placeholder={t("searchPlaceholder")}
className="admin-input flex-1"
/>
<Button type="submit" variant="default" size="sm">
{t("search")}
</Button>
</form>
<div className="grid grid-cols-[repeat(auto-fit,minmax(180px,1fr))] gap-3.5 mb-6">
<StatusCard label={t("totalGuilds")} value={total} icon="🛡️" />
</div>
<section>
<h2 className="admin-section-title">
{t("listHeading", { count: total })}
</h2>
{rows.length === 0 ? (
<div className="admin-empty">{t("empty")}</div>
) : (
<div className="admin-card overflow-x-auto" style={{ padding: 0 }}>
<table>
<thead>
<tr>
<th>{t("colId")}</th>
<th>{t("colBadge")}</th>
<th>{t("colName")}</th>
<th>{t("colOwner")}</th>
<th>{t("colMembers")}</th>
<th>{t("colCreated")}</th>
</tr>
</thead>
<tbody>
{rows.map((g) => (
<tr key={g.id}>
<td className="muted">{g.id}</td>
<td>
{g.badge ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={`https://habbo-stories-content.s3.amazonaws.com/badge-part-images/${g.badge}.gif`}
alt=""
width={40}
height={40}
className="h-10 w-10 object-contain"
/>
) : (
<span className="muted">—</span>
)}
</td>
<td>
<Link
href={`/admin/guilds/${g.id}`}
className="font-medium hover:underline"
>
{g.name || `#${g.id}`}
</Link>
{g.description ? (
<div className="muted text-xs line-clamp-1 max-w-xs">
{g.description}
</div>
) : null}
</td>
<td>
<Link href={`/admin/users/show/${g.userId}`}>
{ownerById.get(g.userId) ?? `#${g.userId}`}
</Link>
</td>
<td>{membersByGuild.get(g.id) ?? 0}</td>
<td className="muted">
{formatDate(new Date(g.dateCreated * 1000), "date")}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
<AdminSimplePager
page={pagination.page}
lastPage={pagination.lastPage}
total={total}
basePath="/admin/guilds"
params={q ? { search: q } : undefined}
/>
</section>
</main>
</AdminPageShell>
);
}
+142
View File
@@ -0,0 +1,142 @@
import { eq } from "drizzle-orm";
import { HelpCircle } from "lucide-react";
import { notFound, redirect } from "next/navigation";
import { deleteHelpQuestion, updateHelpQuestion } from "@/actions/admin-help";
import Link from "@/components/link";
import { Button } from "@/components/ui/button";
import { db, WebsiteHelpCenterCategories } from "@/lib/db";
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
export default async function EditHelpQuestion({
params,
searchParams,
}: {
params: Promise<{ id: string }>;
searchParams: Promise<{ error?: string }>;
}) {
const { session, permissions } = await getAdminContext();
if (!canAccess(permissions, PERMS.PAGES_VIEW, session.user.rank)) {
redirect("/admin");
}
const { id } = await params;
const { error } = await searchParams;
let entry: typeof WebsiteHelpCenterCategories.$inferSelect | null = null;
try {
const [row] = await db
.select()
.from(WebsiteHelpCenterCategories)
.where(eq(WebsiteHelpCenterCategories.id, BigInt(id)))
.limit(1);
entry = row ?? null;
} catch {
notFound();
}
if (!entry) notFound();
return (
<main>
<div className="flex items-center gap-3 mb-6">
<div className="w-10 h-10 rounded-xl bg-gradient-to-br from-[var(--admin-accent)]/20 to-[var(--admin-accent)]/5 grid place-items-center">
<HelpCircle size={20} className="text-[var(--admin-accent)]" />
</div>
<div className="flex-1">
<h1 className="m-0 text-xl font-extrabold text-[var(--admin-text)]">
Edit help-center entry
</h1>
<p className="m-0 text-xs text-[var(--admin-text-muted)] mt-0.5">
<Link href="/admin/help-questions">← Help Center</Link>
</p>
</div>
</div>
{error ? (
<p
className="text-xs theme-text-muted dark:theme-text-muted mb-4"
style={{ color: "#dc2626" }}
>
Error: {error}
</p>
) : null}
<form
action={updateHelpQuestion}
className="admin-card"
style={{ display: "grid", gap: "0.6rem" }}
>
<input type="hidden" name="id" value={String(entry.id)} />
<input
name="name"
defaultValue={entry.name}
placeholder="Name / title"
required
maxLength={255}
/>
<textarea
name="content"
defaultValue={entry.content}
placeholder="Content"
rows={6}
required
/>
<input
name="position"
type="number"
min={1}
defaultValue={entry.position}
placeholder="Position"
/>
<input
name="imageUrl"
defaultValue={entry.imageUrl ?? ""}
placeholder="Image URL (optional)"
maxLength={255}
/>
<input
name="buttonText"
defaultValue={entry.buttonText ?? ""}
placeholder="Button text (optional)"
maxLength={255}
/>
<input
name="buttonUrl"
defaultValue={entry.buttonUrl ?? ""}
placeholder="Button URL (optional)"
maxLength={255}
/>
<label className="flex gap-1.5 items-center">
Button color
<input
name="buttonColor"
type="color"
defaultValue={entry.buttonColor}
/>
</label>
<label className="flex gap-1.5 items-center">
Button border color
<input
name="buttonBorderColor"
type="color"
defaultValue={entry.buttonBorderColor}
/>
</label>
<label className="flex gap-1.5 items-center">
<input
name="smallBox"
type="checkbox"
value="1"
defaultChecked={entry.smallBox}
/>
Small box
</label>
<Button type="submit" variant="default">
Save
</Button>
</form>
<form action={deleteHelpQuestion} style={{ marginTop: "1rem" }}>
<input type="hidden" name="id" value={String(entry.id)} />
<Button type="submit" variant="destructive">
Delete entry
</Button>
</form>
</main>
);
}
+95
View File
@@ -0,0 +1,95 @@
import { HelpCircle } from "lucide-react";
import { redirect } from "next/navigation";
import { createHelpQuestion } from "@/actions/admin-help";
import Link from "@/components/link";
import { Button } from "@/components/ui/button";
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
export default async function NewHelpQuestion({
searchParams,
}: {
searchParams: Promise<{ error?: string }>;
}) {
const { session, permissions } = await getAdminContext();
if (!canAccess(permissions, PERMS.PAGES_EDIT, session.user.rank)) {
redirect("/admin");
}
const { error } = await searchParams;
return (
<main>
<div className="flex items-center gap-3 mb-6">
<div className="w-10 h-10 rounded-xl bg-gradient-to-br from-[var(--admin-accent)]/20 to-[var(--admin-accent)]/5 grid place-items-center">
<HelpCircle size={20} className="text-[var(--admin-accent)]" />
</div>
<div className="flex-1">
<h1 className="m-0 text-xl font-extrabold text-[var(--admin-text)]">
New help-center entry
</h1>
<p className="m-0 text-xs text-[var(--admin-text-muted)] mt-0.5">
<Link href="/admin/help-questions">← Help Center</Link>
</p>
</div>
</div>
{error ? (
<p
className="text-xs theme-text-muted dark:theme-text-muted mb-4"
style={{ color: "#dc2626" }}
>
Error: {error}
</p>
) : null}
<form
action={createHelpQuestion}
className="admin-card"
style={{ display: "grid", gap: "0.6rem" }}
>
<input
name="name"
placeholder="Name / title"
required
maxLength={255}
/>
<textarea name="content" placeholder="Content" rows={6} required />
<input
name="position"
type="number"
min={1}
defaultValue={1}
placeholder="Position"
/>
<input
name="imageUrl"
placeholder="Image URL (optional)"
maxLength={255}
/>
<input
name="buttonText"
placeholder="Button text (optional)"
maxLength={255}
/>
<input
name="buttonUrl"
placeholder="Button URL (optional)"
maxLength={255}
/>
<label className="flex gap-1.5 items-center">
Button color
<input name="buttonColor" type="color" defaultValue="#eeb425" />
</label>
<label className="flex gap-1.5 items-center">
Button border color
<input name="buttonBorderColor" type="color" defaultValue="#facc15" />
</label>
<label className="flex gap-1.5 items-center">
<input name="smallBox" type="checkbox" value="1" />
Small box
</label>
<Button type="submit" variant="default">
Create
</Button>
</form>
</main>
);
}
+95
View File
@@ -0,0 +1,95 @@
import { asc } from "drizzle-orm";
import { redirect } from "next/navigation";
import { getTranslations } from "next-intl/server";
import { StatusCard } from "@/components/admin/dashboard";
import Link from "@/components/link";
import { Button } from "@/components/ui/button";
import { db, WebsiteHelpCenterCategories } from "@/lib/db";
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
export default async function AdminHelpQuestions() {
const { session, permissions } = await getAdminContext();
if (!canAccess(permissions, PERMS.PAGES_VIEW, session.user.rank)) {
redirect("/admin");
}
const t = await getTranslations("pages.admin.helpCenter");
let entries: (typeof WebsiteHelpCenterCategories.$inferSelect)[] = [];
try {
entries = await db
.select()
.from(WebsiteHelpCenterCategories)
.orderBy(
asc(WebsiteHelpCenterCategories.position),
asc(WebsiteHelpCenterCategories.id),
)
.limit(500);
} catch {
entries = [];
}
const smallBoxes = entries.filter((e) => e.smallBox).length;
const withButton = entries.filter((e) => e.buttonText).length;
return (
<main>
<div className="flex justify-end mb-6">
<Button variant="default" asChild>
<Link href="/admin/help-questions/new">+ {t("newEntry")}</Link>
</Button>
</div>
<div className="grid grid-cols-[repeat(auto-fit,minmax(180px,1fr))] gap-3.5 mb-6">
<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">{t("entries")}</h2>
{entries.length === 0 ? (
<div className="admin-empty">{t("noEntries")}</div>
) : (
<table>
<thead>
<tr>
<th>Pos.</th>
<th>{t("colTitle")}</th>
<th>Button</th>
<th>Small box</th>
<th />
</tr>
</thead>
<tbody>
{entries.map((e) => (
<tr key={String(e.id)}>
<td className="text-sm theme-text-muted dark:theme-text-muted whitespace-nowrap">
{e.position}
</td>
<td>
<Link href={`/admin/help-questions/${e.id}`}>{e.name}</Link>
</td>
<td className="text-sm theme-text-muted dark:theme-text-muted whitespace-nowrap">
{e.buttonText || "—"}
</td>
<td>
<span
className={`inline-block text-[0.72rem] font-bold px-2 py-0.5 rounded-full ${e.smallBox ? "bg-[var(--admin-success-subtle)] text-[var(--admin-text)]" : "bg-[var(--admin-accent)]/18 text-[var(--admin-text)]"}`}
>
{e.smallBox ? "Yes" : "No"}
</span>
</td>
<td>
<Link href={`/admin/help-questions/${e.id}`}>
{t("edit")}
</Link>
</td>
</tr>
))}
</tbody>
</table>
)}
</section>
</main>
);
}
@@ -0,0 +1,327 @@
"use client";
import { ArrowLeft, Loader2, Mail, Send, Shield, User } from "lucide-react";
import { useTranslations } from "next-intl";
import { useEffect, useRef, useState } from "react";
import {
closeHelpCenterTicket,
liftBanFromHelpTicket,
reopenHelpCenterTicket,
replyHelpCenterTicket,
} from "@/actions/admin-help-tickets";
import Link from "@/components/link";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Textarea } from "@/components/ui/textarea";
import { useServerAction } from "@/hooks/use-server-action";
interface ThreadMessage {
key: string;
userId: number;
username: string;
isStaff: boolean;
content: string;
createdAt: string;
}
interface HelpTicketInfo {
id: string;
title: string;
open: boolean;
createdAt: string;
updatedAt: string;
categoryName?: string | null;
creator: {
id: number;
username: string;
rank: number;
mail: string;
} | null;
}
export function AdminHelpTicketDetail({
ticket,
messages: initialMessages,
canEdit,
canLiftBan = false,
hasActiveBan = false,
listHref = "/admin/help-tickets",
hideRequesterContact = false,
}: {
ticket: HelpTicketInfo;
messages: ThreadMessage[];
canEdit: boolean;
/** Staff may lift requester bans (USERS_BAN). */
canLiftBan?: boolean;
hasActiveBan?: boolean;
listHref?: string;
/** Hide email/IP-style contact for mod-lite surfaces. */
hideRequesterContact?: boolean;
}) {
const t = useTranslations("pages.admin.helpTickets");
const [messages, setMessages] = useState(initialMessages);
const [reply, setReply] = useState("");
const { run, isPending } = useServerAction();
const messagesEndRef = useRef<HTMLDivElement>(null);
useEffect(() => {
setMessages(initialMessages);
}, [initialMessages]);
// biome-ignore lint/correctness/useExhaustiveDependencies: scroll when thread updates
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
}, [messages]);
function handleReply() {
if (!reply.trim() || isPending) return;
run(
() =>
replyHelpCenterTicket({
ticketId: ticket.id,
content: reply.trim(),
}),
{
successMessage: t("success.replied"),
onSuccess: () => setReply(""),
},
);
}
function handleClose() {
run(() => closeHelpCenterTicket({ ticketId: ticket.id }), {
successMessage: t("success.closed"),
});
}
function handleReopen() {
run(() => reopenHelpCenterTicket({ ticketId: ticket.id }), {
successMessage: t("success.reopened"),
});
}
function handleLiftBan() {
if (!ticket.creator || isPending) return;
run(() => liftBanFromHelpTicket({ ticketId: ticket.id }), {
successMessage: t("success.banLifted"),
});
}
return (
<div className="space-y-6">
<div className="flex items-start gap-4">
<Link href={listHref}>
<Button variant="ghost" size="icon" aria-label={t("backToList")}>
<ArrowLeft className="h-4 w-4" />
</Button>
</Link>
<div className="flex-1">
<div className="flex items-center gap-2 mb-1">
<span className="text-sm text-muted-foreground font-mono">
#{ticket.id}
</span>
<Badge variant={ticket.open ? "default" : "secondary"}>
{ticket.open ? t("statusOpen") : t("statusClosed")}
</Badge>
{ticket.categoryName ? (
<Badge variant="outline">{ticket.categoryName}</Badge>
) : null}
{hasActiveBan ? (
<Badge variant="destructive">{t("requesterBanned")}</Badge>
) : null}
</div>
<h1 className="text-2xl font-bold">{ticket.title}</h1>
</div>
</div>
<div className="grid gap-6 lg:grid-cols-[1fr_300px]">
<div className="space-y-4">
<Card className="overflow-hidden">
<CardHeader className="pb-2 border-b">
<CardTitle className="text-sm">{t("threadTitle")}</CardTitle>
</CardHeader>
<div className="max-h-[600px] overflow-y-auto p-4 space-y-4">
{messages.map((msg) => (
<div key={msg.key} className="flex gap-3">
<div className="shrink-0">
<div className="w-9 h-9 rounded-full bg-accent flex items-center justify-center text-xs font-bold">
{msg.isStaff ? (
<Shield className="h-4 w-4 text-primary" />
) : (
<User className="h-4 w-4" />
)}
</div>
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<span
className={`text-xs font-semibold ${msg.isStaff ? "text-primary" : ""}`}
>
{msg.username}
{msg.isStaff ? ` (${t("staffBadge")})` : ""}
</span>
<span className="text-[10px] text-muted-foreground">
{new Date(msg.createdAt).toLocaleString()}
</span>
</div>
<div
className={`rounded-lg px-4 py-2.5 text-sm leading-relaxed ${
msg.isStaff
? "bg-primary/10 border border-primary/20"
: "bg-muted"
}`}
>
<p className="whitespace-pre-wrap break-words">
{msg.content}
</p>
</div>
</div>
</div>
))}
<div ref={messagesEndRef} />
</div>
{canEdit && ticket.open && (
<div className="border-t p-4">
<div className="flex gap-2">
<Textarea
value={reply}
onChange={(e) => setReply(e.target.value)}
placeholder={t("replyPlaceholder")}
rows={3}
maxLength={5000}
className="resize-none"
onKeyDown={(e) => {
if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) {
e.preventDefault();
handleReply();
}
}}
/>
<Button
onClick={handleReply}
disabled={isPending || !reply.trim()}
size="icon"
className="shrink-0 h-auto"
aria-label={t("replySubmit")}
>
{isPending ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Send className="h-4 w-4" />
)}
</Button>
</div>
<p className="text-[10px] text-muted-foreground mt-1">
{t("replyHint")}
</p>
</div>
)}
{!ticket.open && (
<div className="border-t p-4 text-center text-sm text-muted-foreground">
{t("closedHint")}
</div>
)}
</Card>
</div>
<div className="space-y-4">
{(canEdit || (canLiftBan && hasActiveBan)) && (
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm">{t("actionsTitle")}</CardTitle>
</CardHeader>
<CardContent className="space-y-2">
{canLiftBan && hasActiveBan && ticket.creator ? (
<Button
variant="destructive"
size="sm"
className="w-full"
disabled={isPending}
onClick={handleLiftBan}
>
{t("liftBanSubmit")}
</Button>
) : null}
{canEdit && ticket.open ? (
<Button
variant="outline"
size="sm"
className="w-full"
disabled={isPending}
onClick={handleClose}
>
{t("closeSubmit")}
</Button>
) : null}
{canEdit && !ticket.open ? (
<Button
variant="outline"
size="sm"
className="w-full"
disabled={isPending}
onClick={handleReopen}
>
{t("reopenSubmit")}
</Button>
) : null}
</CardContent>
</Card>
)}
{ticket.creator && (
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm">{t("requesterTitle")}</CardTitle>
</CardHeader>
<CardContent className="space-y-2">
<Link
href={
hideRequesterContact
? `/u/${encodeURIComponent(ticket.creator.username)}`
: `/admin/users/show/${ticket.creator.id}`
}
className="flex items-center gap-2 text-sm font-medium hover:text-primary"
>
<User className="h-3.5 w-3.5" />
{ticket.creator.username}
<Badge variant="outline" className="text-[10px]">
{t("rankLabel", { rank: ticket.creator.rank })}
</Badge>
</Link>
{!hideRequesterContact && ticket.creator.mail ? (
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<Mail className="h-3 w-3" />
{ticket.creator.mail}
</div>
) : null}
</CardContent>
</Card>
)}
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm">{t("detailsTitle")}</CardTitle>
</CardHeader>
<CardContent className="space-y-2 text-xs text-muted-foreground">
<div className="flex justify-between gap-2">
<span>{t("colCreated")}</span>
<span>{new Date(ticket.createdAt).toLocaleString()}</span>
</div>
<div className="flex justify-between gap-2">
<span>{t("colUpdated")}</span>
<span>{new Date(ticket.updatedAt).toLocaleString()}</span>
</div>
<div className="flex justify-between gap-2">
<span>{t("messageCountLabel")}</span>
<span>{messages.length}</span>
</div>
</CardContent>
</Card>
</div>
</div>
</div>
);
}
+182
View File
@@ -0,0 +1,182 @@
import { and, asc, eq, gt, inArray, or } from "drizzle-orm";
import { notFound, redirect } from "next/navigation";
import { getTranslations } from "next-intl/server";
import { TicketQueueBanner } from "@/components/admin/ticket-queue-banner";
import { getMinStaffRank } from "@/lib/admin/min-staff-rank";
import { fetchTicketQueueOpenCounts } from "@/lib/admin/ticket-queue-counts";
import { positiveBigInt } from "@/lib/api";
import { unixNow } from "@/lib/bans";
import {
Ban,
db,
User,
WebsiteHelpCenterCategories,
WebsiteHelpCenterTicketReplies,
WebsiteHelpCenterTickets,
} from "@/lib/db";
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
import { AdminHelpTicketDetail } from "./admin-help-ticket-detail";
export default async function AdminHelpTicketDetailPage({
params,
}: {
params: Promise<{ id: string }>;
}) {
const { session, permissions } = await getAdminContext();
if (!canAccess(permissions, PERMS.TICKETS_VIEW, session.user.rank)) {
redirect("/admin");
}
const t = await getTranslations("pages.admin.helpTickets");
const { id } = await params;
const ticketId = positiveBigInt(id);
if (!ticketId) notFound();
const [ticket] = await db
.select({
id: WebsiteHelpCenterTickets.id,
userId: WebsiteHelpCenterTickets.userId,
categoryId: WebsiteHelpCenterTickets.categoryId,
title: WebsiteHelpCenterTickets.title,
content: WebsiteHelpCenterTickets.content,
open: WebsiteHelpCenterTickets.open,
createdAt: WebsiteHelpCenterTickets.createdAt,
updatedAt: WebsiteHelpCenterTickets.updatedAt,
})
.from(WebsiteHelpCenterTickets)
.where(eq(WebsiteHelpCenterTickets.id, ticketId))
.limit(1);
if (!ticket) notFound();
const replies = await db
.select({
id: WebsiteHelpCenterTicketReplies.id,
userId: WebsiteHelpCenterTicketReplies.userId,
content: WebsiteHelpCenterTicketReplies.content,
createdAt: WebsiteHelpCenterTicketReplies.createdAt,
})
.from(WebsiteHelpCenterTicketReplies)
.where(eq(WebsiteHelpCenterTicketReplies.ticketId, ticket.id))
.orderBy(asc(WebsiteHelpCenterTicketReplies.id));
const authorIds = [
...new Set([
...(ticket.userId != null ? [ticket.userId] : []),
...replies.map((r) => r.userId),
]),
];
const now = unixNow();
const [users, categoryRows, activeBanRows] = await Promise.all([
authorIds.length > 0
? db
.select({
id: User.id,
username: User.username,
rank: User.rank,
mail: User.mail,
})
.from(User)
.where(inArray(User.id, authorIds))
: Promise.resolve([]),
ticket.categoryId != null
? db
.select({ name: WebsiteHelpCenterCategories.name })
.from(WebsiteHelpCenterCategories)
.where(eq(WebsiteHelpCenterCategories.id, ticket.categoryId))
.limit(1)
: Promise.resolve([]),
ticket.userId != null
? db
.select({ id: Ban.id })
.from(Ban)
.where(
and(
eq(Ban.userId, ticket.userId),
or(eq(Ban.banExpire, 0), gt(Ban.banExpire, now)),
),
)
.limit(1)
: Promise.resolve([]),
]);
const category = categoryRows[0] ?? null;
const activeBan = activeBanRows[0] ?? null;
const userById = new Map(users.map((u) => [u.id, u]));
const openerId = ticket.userId;
const minStaffRank = await getMinStaffRank();
const messages = [
{
key: "opening",
userId: openerId ?? 0,
username:
openerId != null
? (userById.get(openerId)?.username ?? `#${openerId}`)
: "—",
isStaff: false,
content: ticket.content,
createdAt: (ticket.createdAt ?? new Date()).toISOString(),
},
...replies.map((r) => {
const user = userById.get(r.userId);
const isStaff =
r.userId !== openerId && (user?.rank ?? 0) >= minStaffRank;
return {
key: String(r.id),
userId: r.userId,
username: user?.username ?? `#${r.userId}`,
isStaff,
content: r.content,
createdAt: (r.createdAt ?? new Date()).toISOString(),
};
}),
];
const creator = openerId != null ? userById.get(openerId) : undefined;
const canEdit = canAccess(permissions, PERMS.TICKETS_EDIT, session.user.rank);
const canLiftBan = canAccess(permissions, PERMS.USERS_BAN, session.user.rank);
const isBanAppeal =
/ban\s*appeal/i.test(ticket.title) ||
/ban\s*appeal/i.test(category?.name ?? "");
const queues = await fetchTicketQueueOpenCounts();
return (
<div className="space-y-6">
<TicketQueueBanner
hint={t("queueHint")}
otherHref="/admin/tickets"
otherLabel={t("queueOtherCta")}
countsLine={t("queueCounts", {
here: queues.helpOpen,
other: queues.cmsOpen,
})}
/>
<AdminHelpTicketDetail
ticket={{
id: String(ticket.id),
title: ticket.title,
open: ticket.open,
createdAt: (ticket.createdAt ?? new Date()).toISOString(),
updatedAt: (ticket.updatedAt ?? new Date()).toISOString(),
categoryName: category?.name ?? (isBanAppeal ? "Ban appeal" : null),
creator: creator
? {
id: creator.id,
username: creator.username,
rank: creator.rank,
mail: creator.mail ?? "",
}
: null,
}}
messages={messages}
canEdit={canEdit}
canLiftBan={canLiftBan}
hasActiveBan={!!activeBan}
/>
</div>
);
}
@@ -0,0 +1,91 @@
"use client";
import { useTranslations } from "next-intl";
import { DataTable } from "@/components/admin/data-table";
import Link from "@/components/link";
import { Badge } from "@/components/ui/badge";
import type { DataTableColumn, PaginatedResult } from "@/types/common";
export interface HelpTicketRow {
id: string;
title: string;
user: string;
userId: number | null;
replies: number;
open: boolean;
date: string;
updated: string;
}
export function HelpTicketsTable({
data,
basePath = "/admin/help-tickets",
}: {
data: PaginatedResult<HelpTicketRow>;
basePath?: string;
}) {
const t = useTranslations("pages.admin.helpTickets");
const columns: DataTableColumn<HelpTicketRow>[] = [
{
key: "title",
label: t("colTicket"),
sortable: true,
render: (_value, row) => (
<div className="min-w-0">
<div className="flex items-center gap-2">
<span className="text-xs text-muted-foreground font-mono">
#{row.id}
</span>
<Link
href={`${basePath}/${row.id}`}
className="font-medium truncate hover:underline"
>
{row.title}
</Link>
</div>
<div className="text-xs text-muted-foreground">
{t("meta", { user: row.user, count: row.replies })}
</div>
</div>
),
},
{
key: "open",
label: t("colStatus"),
sortable: true,
filterKey: "filter_status",
filterOptions: [
{ label: t("presetOpen"), value: "open" },
{ label: t("statusClosed"), value: "closed" },
],
render: (value) => {
const open = value === true || value === "true";
return (
<Badge
variant={open ? "default" : "secondary"}
className="text-[0.7rem]"
>
{open ? t("statusOpen") : t("statusClosed")}
</Badge>
);
},
},
{ key: "user", label: t("colUser"), sortable: true },
{ key: "date", label: t("colCreated"), sortable: true },
{ key: "updated", label: t("colUpdated"), sortable: true },
];
return (
<DataTable
data={data}
columns={columns}
searchPlaceholder={t("searchPlaceholder")}
presets={[
{ label: t("presetOpen"), params: { filter_status: "open" } },
{ label: t("statusClosed"), params: { filter_status: "closed" } },
{ label: t("presetAll"), params: { filter_status: "all" } },
]}
/>
);
}
+214
View File
@@ -0,0 +1,214 @@
import {
and,
asc,
count,
desc,
eq,
inArray,
like,
or,
type SQL,
} from "drizzle-orm";
import { redirect } from "next/navigation";
import { getTranslations } from "next-intl/server";
import { StatusCard } from "@/components/admin/dashboard";
import { TicketQueueBanner } from "@/components/admin/ticket-queue-banner";
import { fetchTicketQueueOpenCounts } from "@/lib/admin/ticket-queue-counts";
import { calcPagination, parseListParams } from "@/lib/admin-helpers";
import {
db,
User,
WebsiteHelpCenterTicketReplies,
WebsiteHelpCenterTickets,
} from "@/lib/db";
import { formatDate } from "@/lib/format-date";
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
import { type HelpTicketRow, HelpTicketsTable } from "./help-tickets-table";
export default async function AdminHelpTicketsPage({
searchParams,
}: {
searchParams: Promise<Record<string, string>>;
}) {
const { session, permissions } = await getAdminContext();
if (!canAccess(permissions, PERMS.TICKETS_VIEW, session.user.rank)) {
redirect("/admin");
}
const t = await getTranslations("pages.admin.helpTickets");
const raw = await searchParams;
const parsed = parseListParams(new URLSearchParams(raw));
const statusFilter = raw.filter_status || "open";
const conditions: SQL[] = [];
if (statusFilter === "open") {
conditions.push(eq(WebsiteHelpCenterTickets.open, true));
} else if (statusFilter === "closed") {
conditions.push(eq(WebsiteHelpCenterTickets.open, false));
}
if (parsed.search.trim()) {
const q = parsed.search.trim();
const asId = /^\d+$/.test(q) ? BigInt(q) : null;
const matchingUsers = await db
.select({ id: User.id })
.from(User)
.where(like(User.username, `%${q}%`))
.limit(50)
.catch(() => []);
const searchFilter = or(
like(WebsiteHelpCenterTickets.title, `%${q}%`),
like(WebsiteHelpCenterTickets.content, `%${q}%`),
...(matchingUsers.length > 0
? [
inArray(
WebsiteHelpCenterTickets.userId,
matchingUsers.map((u) => u.id),
),
]
: []),
...(asId !== null ? [eq(WebsiteHelpCenterTickets.id, asId)] : []),
);
if (searchFilter) conditions.push(searchFilter);
}
const where = conditions.length > 0 ? and(...conditions) : undefined;
const orderCol =
parsed.sort === "title"
? WebsiteHelpCenterTickets.title
: parsed.sort === "open"
? WebsiteHelpCenterTickets.open
: parsed.sort === "user"
? WebsiteHelpCenterTickets.userId
: parsed.sort === "date"
? WebsiteHelpCenterTickets.createdAt
: parsed.sort === "id"
? WebsiteHelpCenterTickets.id
: WebsiteHelpCenterTickets.updatedAt;
const orderBy = parsed.order === "asc" ? asc(orderCol) : desc(orderCol);
const [totalRows, openRows, closedRows, queues] = await Promise.all([
db
.select({ total: count() })
.from(WebsiteHelpCenterTickets)
.where(where)
.catch(() => [{ total: 0 }]),
db
.select({ total: count() })
.from(WebsiteHelpCenterTickets)
.where(eq(WebsiteHelpCenterTickets.open, true))
.catch(() => [{ total: 0 }]),
db
.select({ total: count() })
.from(WebsiteHelpCenterTickets)
.where(eq(WebsiteHelpCenterTickets.open, false))
.catch(() => [{ total: 0 }]),
fetchTicketQueueOpenCounts(),
]);
const total = Number(totalRows[0]?.total ?? 0);
const openCount = Number(openRows[0]?.total ?? 0);
const closedCount = Number(closedRows[0]?.total ?? 0);
const pagination = calcPagination(total, parsed.page, parsed.perPage);
const tickets = await db
.select({
id: WebsiteHelpCenterTickets.id,
userId: WebsiteHelpCenterTickets.userId,
title: WebsiteHelpCenterTickets.title,
open: WebsiteHelpCenterTickets.open,
createdAt: WebsiteHelpCenterTickets.createdAt,
updatedAt: WebsiteHelpCenterTickets.updatedAt,
})
.from(WebsiteHelpCenterTickets)
.where(where)
.orderBy(orderBy)
.limit(pagination.perPage)
.offset(pagination.offset)
.catch(() => []);
const ticketIds = tickets.map((ticket) => ticket.id);
const userIds = [
...new Set(
tickets
.map((ticket) => ticket.userId)
.filter((id): id is number => id != null),
),
];
const [replyGroups, users] = await Promise.all([
ticketIds.length > 0
? db
.select({
ticketId: WebsiteHelpCenterTicketReplies.ticketId,
total: count(),
})
.from(WebsiteHelpCenterTicketReplies)
.where(inArray(WebsiteHelpCenterTicketReplies.ticketId, ticketIds))
.groupBy(WebsiteHelpCenterTicketReplies.ticketId)
.catch(() => [])
: Promise.resolve([]),
userIds.length > 0
? db
.select({ id: User.id, username: User.username })
.from(User)
.where(inArray(User.id, userIds))
.catch(() => [])
: Promise.resolve([]),
]);
const replyCountByTicket = new Map(
replyGroups.map((g) => [String(g.ticketId), Number(g.total)]),
);
const usernameById = new Map(users.map((u) => [u.id, u.username]));
const rows: HelpTicketRow[] = tickets.map((ticket) => ({
id: String(ticket.id),
title: ticket.title,
user:
ticket.userId != null
? (usernameById.get(ticket.userId) ?? `#${ticket.userId}`)
: "—",
userId: ticket.userId,
replies: replyCountByTicket.get(String(ticket.id)) ?? 0,
open: ticket.open,
date: formatDate(ticket.createdAt, "date"),
updated: formatDate(ticket.updatedAt, "date"),
}));
return (
<div className="space-y-6">
<TicketQueueBanner
hint={t("queueHint")}
otherHref="/admin/tickets"
otherLabel={t("queueOtherCta")}
countsLine={t("queueCounts", {
here: queues.helpOpen,
other: queues.cmsOpen,
})}
/>
<div className="grid grid-cols-[repeat(auto-fit,minmax(180px,1fr))] gap-3.5">
<StatusCard label={t("statusOpen")} value={openCount} icon="🎫" />
<StatusCard label={t("statusClosed")} value={closedCount} icon="✅" />
</div>
{total === 0 && !parsed.search && statusFilter === "open" ? (
<div className="admin-empty">{t("noTickets")}</div>
) : (
<HelpTicketsTable
data={{
rows,
total,
page: pagination.page,
perPage: pagination.perPage,
lastPage: pagination.lastPage,
}}
/>
)}
</div>
);
}
+177
View File
@@ -0,0 +1,177 @@
"use client";
import { useRouter } from "next/navigation";
import { useLocale, useTranslations } from "next-intl";
import { useCallback, useMemo, useState } from "react";
import { exportPermissions } from "@/actions/admin-housekeeping";
import { Button } from "@/components/ui/button";
/* ────────── Export Section ────────── */
export function ExportSection() {
const t = useTranslations("pages.admin.housekeeping");
const [data, setData] = useState<string | null>(null);
const [copying, setCopying] = useState(false);
const [exporting, setExporting] = useState(false);
const handleExport = useCallback(async () => {
setExporting(true);
try {
const json = await exportPermissions();
setData(json);
} finally {
setExporting(false);
}
}, []);
const handleCopy = useCallback(async () => {
if (!data) return;
setCopying(true);
try {
await navigator.clipboard.writeText(data);
} catch {
/* fallback */
}
setTimeout(() => setCopying(false), 1500);
}, [data]);
return (
<section>
<h2 className="admin-section-title">{t("exportTitle")}</h2>
<p className="text-xs text-[var(--admin-text-muted)] -mt-2 mb-3">
{t("exportHint")}
</p>
<div>
<Button
type="button"
onClick={handleExport}
disabled={exporting}
variant="outline"
className="mb-3"
>
{exporting ? t("loading") : t("export")}
</Button>
{data && (
<div className="relative">
<pre className="admin-card font-mono text-xs overflow-auto max-h-[300px] p-3">
{data}
</pre>
<Button
type="button"
onClick={handleCopy}
variant="outline"
size="sm"
className="absolute top-2 right-2"
>
{copying ? t("copied") : t("copy")}
</Button>
</div>
)}
</div>
</section>
);
}
/* ────────── Audit Section ────────── */
interface AuditEntry {
id: number;
userId: number;
username: string | null;
action: string;
target: string;
targetId: number | null;
createdAt: string;
}
interface AuditSectionProps {
initialLogs: {
rows: AuditEntry[];
total: number;
page: number;
perPage: number;
lastPage: number;
} | null;
}
export function AuditSection({ initialLogs }: AuditSectionProps) {
const t = useTranslations("pages.admin.housekeeping");
const locale = useLocale();
const router = useRouter();
const dateFmt = useMemo(
() =>
new Intl.DateTimeFormat(locale, {
dateStyle: "short",
timeStyle: "short",
}),
[locale],
);
if (!initialLogs || initialLogs.rows.length === 0) {
return (
<section>
<h2 className="admin-section-title">{t("auditTitle")}</h2>
<p className="text-xs text-[var(--admin-text-muted)] -mt-2 mb-3">
{t("auditHint")}
</p>
<div className="admin-card">
<p className="text-sm opacity-70">{t("auditLogsAt")}</p>
<Button
type="button"
onClick={() => router.push("/admin/logs/audit")}
variant="outline"
className="mt-2"
>
{t("viewAuditLogs")}
</Button>
</div>
</section>
);
}
return (
<section>
<h2 className="admin-section-title">{t("auditTitle")}</h2>
<p className="text-xs text-[var(--admin-text-muted)] -mt-2 mb-3">
{t("auditHint")}
</p>
<div className="overflow-x-auto">
<table>
<thead>
<tr>
<th className="w-[160px]">{t("auditTime")}</th>
<th className="w-[120px]">{t("auditUser")}</th>
<th>{t("auditAction")}</th>
<th>{t("auditTarget")}</th>
</tr>
</thead>
<tbody>
{initialLogs.rows.map((log) => (
<tr key={log.id}>
<td className="text-xs whitespace-nowrap">
{dateFmt.format(new Date(log.createdAt))}
</td>
<td className="text-xs">{log.username ?? "System"}</td>
<td className="text-xs font-mono">{log.action}</td>
<td className="text-xs">
{log.target}
{log.targetId != null ? ` #${log.targetId}` : ""}
</td>
</tr>
))}
</tbody>
</table>
</div>
<div className="mt-3 text-xs opacity-60 text-center">
{t("auditLogsAt")}{" "}
<button
type="button"
onClick={() => router.push("/admin/logs/audit")}
className="underline hover:no-underline"
>
{t("viewAuditLogs")}
</button>
</div>
</section>
);
}
+363
View File
@@ -0,0 +1,363 @@
import { asc, eq } from "drizzle-orm";
import { redirect } from "next/navigation";
import { getTranslations } from "next-intl/server";
import Link from "@/components/link";
import {
AclModelPermission,
AclPermission,
AclRole,
db,
WebsiteHousekeepingPermissions,
} from "@/lib/db";
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
import { getAuditLogs } from "@/lib/services/audit";
import { AuditSection, ExportSection } from "./client";
export default async function AdminHousekeeping({
searchParams,
}: {
searchParams: Promise<{
tab?:
| "overview"
| "manage"
| "import"
| "ranks"
| "presets"
| "test"
| "audit"
| "compare";
group?: string;
}>;
}) {
const ctx = await getAdminContext();
if (!canAccess(ctx.permissions, PERMS.SETTINGS_VIEW, ctx.session.user.rank)) {
redirect("/admin");
}
const t = await getTranslations("pages.admin.housekeeping");
const sp = await searchParams;
const tab = sp.tab ?? "overview";
const allPermissions = await db
.select()
.from(WebsiteHousekeepingPermissions)
.orderBy(
asc(WebsiteHousekeepingPermissions.groupName),
asc(WebsiteHousekeepingPermissions.permission),
)
.catch((): (typeof WebsiteHousekeepingPermissions.$inferSelect)[] => []);
const ranks = allPermissions.map((p) => p.minRank);
const maxRank = ranks.length ? Math.max(...ranks) : 0;
const totalMin = ranks.reduce((sum, r) => sum + r, 0);
const groupCounts: Record<string, number> = {};
for (const p of allPermissions) {
const g: string = p.groupName ?? "Uncategorized";
groupCounts[g] = (groupCounts[g] || 0) + 1;
}
const rankGroups: Record<
string,
(typeof WebsiteHousekeepingPermissions.$inferSelect)[]
> = {};
for (const p of allPermissions) {
const rk = String(p.minRank);
if (!rankGroups[rk]) rankGroups[rk] = [];
rankGroups[rk].push(p);
}
const aclSlugs = await db
.select({ slug: AclPermission.slug })
.from(AclPermission)
.catch(() => [] as { slug: string }[]);
const aclSlugSet = new Set(aclSlugs.map((p) => p.slug));
const aclRolePerms = await db
.select({
roleSlug: AclRole.slug,
permissionSlug: AclPermission.slug,
})
.from(AclModelPermission)
.innerJoin(
AclPermission,
eq(AclModelPermission.permissionId, AclPermission.id),
)
.innerJoin(AclRole, eq(AclModelPermission.modelId, AclRole.id))
.where(eq(AclModelPermission.modelType, "Role"))
.catch((): { roleSlug: string; permissionSlug: string }[] => []);
const aclMinRankBySlug = new Map<string, number>();
for (const row of aclRolePerms) {
const roleSlug = row.roleSlug;
if (!roleSlug.startsWith("rank_")) continue;
const rank = Number(roleSlug.slice(5));
if (!Number.isFinite(rank)) continue;
const slug = row.permissionSlug;
const prev = aclMinRankBySlug.get(slug);
if (prev === undefined || rank < prev) aclMinRankBySlug.set(slug, rank);
}
const compareRows = allPermissions.map((p) => {
const inAcl = aclSlugSet.has(p.permission);
const aclMin = aclMinRankBySlug.get(p.permission);
return {
permission: p.permission,
hkMinRank: p.minRank,
inAcl,
aclMinRank: aclMin ?? null,
aligned: inAcl && aclMin !== undefined ? aclMin === p.minRank : false,
};
});
const missingInAcl = compareRows.filter((r) => !r.inAcl).length;
const rankMismatch = compareRows.filter(
(r) => r.inAcl && r.aclMinRank !== null && r.aclMinRank !== r.hkMinRank,
).length;
const alignedCount = compareRows.filter((r) => r.aligned).length;
const auditLogs = await getAuditLogs({
search: "housekeeping",
perPage: 25,
}).catch(() => null);
return (
<main>
<div
className="mb-4 rounded-lg border border-[var(--admin-warning-border)] bg-[var(--admin-warning-subtle)] px-4 py-3 text-sm text-[var(--admin-text)]"
role="status"
>
<p className="m-0 font-semibold">{t("legacyBannerTitle")}</p>
<p className="m-0 mt-1 text-[var(--admin-text-muted)]">
{t("legacyBannerBody")}{" "}
<Link
href="/admin/permissions"
className="font-medium text-[var(--admin-accent)] underline-offset-2 hover:underline"
>
{t("legacyBannerCta")}
</Link>
</p>
<p className="m-0 mt-2 text-xs text-[var(--admin-text-muted)]">
{t("writesRetired")}
</p>
</div>
<div className="admin-tabs mb-4">
<Link
href="/admin/housekeeping?tab=overview"
className={`admin-tab ${tab === "overview" ? "active" : ""}`}
>
{t("tabOverview")}
</Link>
<Link
href="/admin/housekeeping?tab=compare"
className={`admin-tab ${tab === "compare" ? "active" : ""}`}
>
{t("tabCompare")}
</Link>
<Link
href="/admin/housekeeping?tab=ranks"
className={`admin-tab ${tab === "ranks" ? "active" : ""}`}
>
{t("tabRanks")}
</Link>
<Link
href="/admin/housekeeping?tab=import"
className={`admin-tab ${tab === "import" ? "active" : ""}`}
>
{t("tabExportOnly")}
</Link>
<Link
href="/admin/housekeeping?tab=audit"
className={`admin-tab ${tab === "audit" ? "active" : ""}`}
>
{t("tabAudit")}
</Link>
</div>
{tab === "overview" && (
<>
<div className="admin-stat-row">
<span className="admin-stat-chip">
{t("permissionsCount", { count: allPermissions.length })}
</span>
<span className="admin-stat-chip">
{t("highestMinRank", { rank: maxRank })}
</span>
<span className="admin-stat-chip">
{t("totalMinRank", { total: totalMin })}
</span>
<span className="admin-stat-chip">
{t("groupCount", { count: Object.keys(groupCounts).length })}
</span>
<span className="admin-stat-chip">
{t("compareAligned", { count: alignedCount })}
</span>
<span className="admin-stat-chip">
{t("compareMissingAcl", { count: missingInAcl })}
</span>
<span className="admin-stat-chip">
{t("compareMismatch", { count: rankMismatch })}
</span>
</div>
<section className="mb-6">
<h2 className="admin-section-title">{t("perGroup")}</h2>
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
{Object.entries(groupCounts)
.sort(([a], [b]) => a.localeCompare(b))
.map(([group, count]) => (
<div key={group} className="admin-card p-3">
<div className="font-semibold text-sm">{group}</div>
<div className="text-xs opacity-60">
{count} {count === 1 ? t("permission") : t("permissions")}
</div>
</div>
))}
</div>
</section>
<section>
<h2 className="admin-section-title">{t("perRank")}</h2>
<div className="overflow-x-auto">
<table>
<thead>
<tr>
<th>{t("rank")}</th>
<th>{t("count")}</th>
<th>{t("permissions")}</th>
</tr>
</thead>
<tbody>
{Object.entries(rankGroups)
.sort(([a], [b]) => Number(b) - Number(a))
.map(([rank, perms]) => (
<tr key={rank}>
<td className="font-semibold">Rank {rank}</td>
<td>{perms.length}</td>
<td className="text-xs opacity-70">
{perms.map((p) => p.permission).join(", ")}
</td>
</tr>
))}
</tbody>
</table>
</div>
</section>
</>
)}
{tab === "compare" && (
<section>
<h2 className="admin-section-title">{t("tabCompare")}</h2>
<p className="text-xs text-[var(--admin-text-muted)] -mt-2 mb-3">
{t("compareHint")}
</p>
<div className="overflow-x-auto">
<table>
<thead>
<tr>
<th>{t("colPermission")}</th>
<th>{t("colMinRank")}</th>
<th>{t("colAclPresent")}</th>
<th>{t("colAclMinRank")}</th>
<th>{t("colStatus")}</th>
</tr>
</thead>
<tbody>
{compareRows.map((row) => {
const status = !row.inAcl
? t("statusMissingAcl")
: row.aligned
? t("statusAligned")
: t("statusMismatch");
return (
<tr key={row.permission}>
<td>
<code className="text-xs">{row.permission}</code>
</td>
<td>{row.hkMinRank}</td>
<td>{row.inAcl ? "✓" : "—"}</td>
<td>{row.aclMinRank ?? "—"}</td>
<td className="text-xs">{status}</td>
</tr>
);
})}
</tbody>
</table>
</div>
</section>
)}
{tab === "import" && (
<div className="mt-2">
<p className="mb-4 text-sm text-[var(--admin-text-muted)]">
{t("exportOnlyHint")}
</p>
<ExportSection />
</div>
)}
{tab === "ranks" && (
<section>
<h2 className="admin-section-title">{t("perRank")}</h2>
<p className="text-xs text-[var(--admin-text-muted)] -mt-2 mb-3">
{t("rankOverviewHint")}
</p>
{Object.keys(rankGroups).length === 0 ? (
<div className="admin-empty">{t("noPermissions")}</div>
) : (
<div className="space-y-4">
{Object.entries(rankGroups)
.sort(([a], [b]) => Number(b) - Number(a))
.map(([rank, perms]) => {
const groupsInRank: Record<string, typeof perms> = {};
for (const p of perms) {
const g = p.groupName ?? "Uncategorized";
if (!groupsInRank[g]) groupsInRank[g] = [];
groupsInRank[g].push(p);
}
return (
<div key={rank} className="admin-card">
<div className="flex items-center justify-between mb-2">
<h3 className="text-lg font-bold">
{t("rank")} {rank}
</h3>
<span className="text-sm opacity-60">
{perms.length} {t("permissions")}
</span>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-2">
{Object.entries(groupsInRank)
.sort(([a], [b]) => a.localeCompare(b))
.map(([group, groupPerms]) => (
<div key={group}>
<div className="text-xs font-semibold uppercase tracking-wider opacity-60 mb-1">
{group}
</div>
<ul className="list-disc pl-4 text-xs space-y-0.5">
{groupPerms.map((p) => (
<li key={p.permission}>
{p.permission}
{p.dependsOn ? (
<span className="opacity-50 ml-1">
&rarr; {p.dependsOn}
</span>
) : null}
</li>
))}
</ul>
</div>
))}
</div>
</div>
);
})}
</div>
)}
</section>
)}
{tab === "audit" && <AuditSection initialLogs={auditLogs} />}
</main>
);
}
+926
View File
@@ -0,0 +1,926 @@
"use client";
import {
AlertCircle,
Check,
CheckCircle2,
ClipboardList,
ExternalLink,
Loader2,
SearchX,
} from "lucide-react";
import { useCallback, useRef, useState } from "react";
import { flushSync } from "react-dom";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { adminFetch } from "@/lib/admin-fetch";
interface AuditIssue {
type: string;
severity: "error" | "warning" | "info";
classname?: string;
itemId?: number;
catalogId?: number;
message: string;
sourceName?: string;
}
interface MissingFromSource {
sourceName: string;
sourceId: string;
classname: string;
itemName: string;
}
interface UnrepairableItem {
classname: string;
itemId: number;
missing: Array<"nitro" | "icon">;
}
interface AuditSummary {
totalItemsBase: number;
totalCatalogItems: number;
missingCatalog: number;
orphanedCatalog: number;
missingNitro: number;
missingIcon: number;
duplicates: number;
missingFromSourcesTotal: number;
unrepairableCount: number;
iconsRepaired: number;
iconsStillMissing: number;
nitrosRepaired: number;
nitrosStillMissing: number;
catalogSqlCount: number;
catalogSqlApplied: number;
furniDataAdded: number;
furniDataDuplicatesRemoved: number;
orphanedRemoved: number;
orphanedCleaned: number;
duplicatesMerged: number;
duplicateRowsRemoved: number;
remappedReferences: number;
}
interface RepairStats {
succeeded: number;
failed: number;
skipped: number;
stillMissing: number;
}
interface AuditEvent {
type: string;
message?: string;
current?: number;
total?: number;
section?: string;
issues?: AuditIssue[];
missingFromSources?: MissingFromSource[];
unrepairable?: UnrepairableItem[];
summary?: AuditSummary;
repair?: RepairStats;
repairNitro?: RepairStats;
sql?: string;
sqlCount?: number;
applied?: number;
sqlFailed?: number;
furniData?: {
added: number;
removedDuplicates: number;
removedIdConflicts: number;
};
structure?: {
orphanedRemoved: number;
orphanedCleaned: number;
duplicatesMerged: number;
duplicateRowsRemoved: number;
remappedReferences: number;
};
}
const TYPE_LABELS: Record<string, string> = {
missing_catalog: "No catalog entry",
orphaned_catalog: "Orphaned catalog reference",
missing_nitro: "Missing .nitro file",
missing_icon: "Missing icon file",
duplicate_classname: "Duplicate classname",
source_fetch_failed: "Source fetch failed",
};
function AuditCheckbox({
checked,
disabled,
onChange,
children,
}: {
checked: boolean;
disabled?: boolean;
onChange: (value: boolean) => void;
children: React.ReactNode;
}) {
return (
// biome-ignore lint/a11y/useSemanticElements: custom checkbox so the checked state shows a visible checkmark on every admin theme
<button
type="button"
role="checkbox"
aria-checked={checked}
disabled={disabled}
onClick={() => onChange(!checked)}
className={`flex items-center gap-2 text-sm text-muted-foreground cursor-pointer select-none ${
disabled ? "opacity-50 cursor-not-allowed" : ""
}`}
>
<span
aria-hidden
className={`inline-flex h-4 w-4 shrink-0 items-center justify-center rounded-[3px] border-2 transition-colors ${
checked
? "border-[var(--admin-accent)] bg-[var(--admin-accent)]"
: "border-[var(--admin-border)] bg-transparent"
}`}
>
{checked && <Check className="h-3 w-3 text-[var(--admin-surface)]" />}
</span>
<span className="pointer-events-none">{children}</span>
</button>
);
}
export function AuditClient() {
const [loading, setLoading] = useState(false);
const [progress, setProgress] = useState("");
const [activeTab, setActiveTab] = useState<
"issues" | "sources" | "unrepairable"
>("issues");
const [repairEnabled, setRepairEnabled] = useState(true);
const [repairNitros, setRepairNitros] = useState(true);
const [generateSql, setGenerateSql] = useState(false);
const [applySql, setApplySql] = useState(false);
const [repairFurniData, setRepairFurniData] = useState(false);
const [repairStructure, setRepairStructure] = useState(false);
const [checkFurniDataIds, setCheckFurniDataIds] = useState(false);
const [issues, setIssues] = useState<AuditIssue[]>([]);
const [missingFromSources, setMissingFromSources] = useState<
MissingFromSource[]
>([]);
const [unrepairable, setUnrepairable] = useState<UnrepairableItem[]>([]);
const [summary, setSummary] = useState<AuditSummary | null>(null);
const [repairStats, setRepairStats] = useState<RepairStats | null>(null);
const [nitroRepairStats, setNitroRepairStats] = useState<RepairStats | null>(
null,
);
const [sqlText, setSqlText] = useState<string | null>(null);
const [sqlApplied, setSqlApplied] = useState<{
applied: number;
failed: number;
} | null>(null);
const [furniDataResult, setFurniDataResult] = useState<{
added: number;
removedDuplicates: number;
removedIdConflicts: number;
} | null>(null);
const [structureResult, setStructureResult] = useState<{
orphanedRemoved: number;
orphanedCleaned: number;
duplicatesMerged: number;
duplicateRowsRemoved: number;
remappedReferences: number;
} | null>(null);
const [checkProgress, setCheckProgress] = useState<{
section: string;
current: number;
total: number;
} | null>(null);
const abortRef = useRef<AbortController | null>(null);
const runAudit = useCallback(async () => {
setLoading(true);
setProgress("Starting audit…");
setIssues([]);
setMissingFromSources([]);
setUnrepairable([]);
setSummary(null);
setRepairStats(null);
setNitroRepairStats(null);
setSqlText(null);
setSqlApplied(null);
setFurniDataResult(null);
setStructureResult(null);
setCheckProgress(null);
setActiveTab("issues");
const abort = new AbortController();
abortRef.current = abort;
try {
const res = await adminFetch("/api/admin/import/audit", {
method: "POST",
signal: abort.signal,
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
repair: repairEnabled,
repairNitros,
sql: generateSql || applySql,
applySql,
repairFurniData,
repairStructure,
checkFurniDataIds,
}),
});
if (!res.body) {
toast.error("No response stream");
setLoading(false);
return;
}
if (!res.headers.get("content-type")?.includes("text/event-stream")) {
let errMsg = "Audit failed";
try {
const json = await res.json();
errMsg = json.error || errMsg;
} catch {
// ignore
}
toast.error(errMsg);
setLoading(false);
return;
}
const reader = res.body.getReader();
const decoder = new TextDecoder();
let buf = "";
while (true) {
const { value, done } = await reader.read();
if (done) break;
buf += decoder.decode(value, { stream: true });
const parts = buf.split("\n\n");
buf = parts.pop() ?? "";
for (const part of parts) {
if (!part.startsWith("data: ")) continue;
try {
const evt = JSON.parse(part.slice(6)) as AuditEvent;
flushSync(() => {
if (evt.type === "progress") {
setProgress(evt.message ?? "");
}
if (evt.type === "items_loaded") {
setProgress(`Loaded ${evt.total} items_base entries`);
}
if (evt.type === "catalog_loaded") {
setProgress(`Loaded ${evt.total} catalog_items entries`);
}
if (evt.type === "checking") {
setProgress(`Checking ${evt.section}…`);
if (evt.total) {
setCheckProgress({
section: evt.section ?? "",
current: evt.current ?? 0,
total: evt.total,
});
}
}
if (evt.type === "repair_complete") {
if (evt.repair) setRepairStats(evt.repair);
if (evt.repairNitro) setNitroRepairStats(evt.repairNitro);
}
if (evt.type === "sql_ready") {
setSqlText(evt.sql ?? null);
}
if (evt.type === "sql_applied") {
setSqlApplied({
applied: evt.applied ?? 0,
failed: evt.sqlFailed ?? 0,
});
}
if (evt.type === "furnidata_repair_complete") {
if (evt.furniData) setFurniDataResult(evt.furniData);
}
if (evt.type === "structure_repair_complete") {
if (evt.structure) setStructureResult(evt.structure);
}
if (evt.type === "batch_complete") {
setIssues(evt.issues ?? []);
setMissingFromSources(evt.missingFromSources ?? []);
setUnrepairable(evt.unrepairable ?? []);
if (evt.summary) setSummary(evt.summary);
setCheckProgress(null);
setProgress("Done");
const errCount =
evt.issues?.filter((i) => i.severity === "error").length ?? 0;
const warnCount =
evt.issues?.filter((i) => i.severity === "warning").length ??
0;
const legacyCount = evt.unrepairable?.length ?? 0;
if (errCount > 0) {
toast.error(`${errCount} error(s) found`);
} else if (warnCount > 0) {
toast.warning("Warnings found");
} else if (legacyCount > 0) {
toast.info(`${legacyCount} legacy item(s) can't be repaired`);
} else {
toast.success("No issues found");
}
}
if (evt.type === "error") {
setProgress(`ERROR: ${evt.message ?? ""}`);
toast.error(evt.message ?? "Audit failed");
}
});
} catch {
// skip parse errors
}
}
}
} catch (err) {
if ((err as Error).name !== "AbortError") {
toast.error("Audit request failed");
} else {
toast.info("Audit cancelled");
}
} finally {
setLoading(false);
abortRef.current = null;
}
}, [
repairEnabled,
repairNitros,
generateSql,
applySql,
repairFurniData,
repairStructure,
checkFurniDataIds,
]);
const errors = issues.filter((i) => i.severity === "error");
const warnings = issues.filter((i) => i.severity === "warning");
const downloadSql = () => {
if (!sqlText) return;
const blob = new Blob([sqlText], { type: "text/sql" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `catalog-repair-${new Date().toISOString().slice(0, 10)}.sql`;
a.click();
URL.revokeObjectURL(url);
};
return (
<div className="space-y-6">
{!summary && (
<div className="flex items-center gap-2 flex-wrap">
<Button onClick={runAudit} disabled={loading} size="lg">
{loading ? (
<Loader2 className="w-4 h-4 animate-spin mr-2" />
) : (
<ClipboardList className="w-4 h-4 mr-2" />
)}
{loading ? progress || "Running…" : "Run Full Catalog Audit"}
</Button>
<AuditCheckbox
checked={repairEnabled}
disabled={loading}
onChange={setRepairEnabled}
>
Repair missing icons
</AuditCheckbox>
<AuditCheckbox
checked={repairNitros}
disabled={loading}
onChange={setRepairNitros}
>
Repair missing .nitro
</AuditCheckbox>
<AuditCheckbox
checked={generateSql}
disabled={loading}
onChange={setGenerateSql}
>
Generate catalog SQL
</AuditCheckbox>
<AuditCheckbox
checked={applySql}
disabled={loading}
onChange={setApplySql}
>
Apply SQL to database
</AuditCheckbox>
<AuditCheckbox
checked={repairFurniData}
disabled={loading}
onChange={setRepairFurniData}
>
Repair FurnitureData.json (add missing + dedupe)
</AuditCheckbox>
<AuditCheckbox
checked={checkFurniDataIds}
disabled={loading}
onChange={setCheckFurniDataIds}
>
Check FurnitureData.json for spriteId conflicts
</AuditCheckbox>
<AuditCheckbox
checked={repairStructure}
disabled={loading}
onChange={setRepairStructure}
>
Repair structural issues (orphaned refs + duplicates)
</AuditCheckbox>
{loading && (
<Button
variant="outline"
size="lg"
onClick={() => abortRef.current?.abort()}
>
Cancel
</Button>
)}
</div>
)}
{loading && checkProgress && (
<div className="text-sm text-muted-foreground">
{checkProgress.section}: {checkProgress.current} /{" "}
{checkProgress.total}
</div>
)}
{summary && (
<>
{/* Summary cards */}
<div className="grid gap-3 grid-cols-2 sm:grid-cols-4">
<div className="rounded-xl border bg-card p-3 sm:p-4 space-y-1">
<div className="text-xl sm:text-2xl font-bold">
{summary.totalItemsBase}
</div>
<div className="text-xs text-muted-foreground">
items_base entries
</div>
</div>
<div className="rounded-xl border bg-card p-3 sm:p-4 space-y-1">
<div className="text-xl sm:text-2xl font-bold">
{summary.totalCatalogItems}
</div>
<div className="text-xs text-muted-foreground">
catalog_items entries
</div>
</div>
<div
className={`rounded-xl border p-3 sm:p-4 space-y-1 ${errors.length > 0 ? "border-[var(--admin-error-border)] bg-[var(--admin-error-subtle)]" : "bg-card"}`}
>
<div
className={`text-xl sm:text-2xl font-bold ${errors.length > 0 ? "text-[var(--admin-error)]" : ""}`}
>
{errors.length}
</div>
<div className="text-xs text-muted-foreground">Errors</div>
</div>
<div
className={`rounded-xl border p-3 sm:p-4 space-y-1 ${warnings.length > 0 ? "border-[var(--admin-warning-border)] bg-[var(--admin-warning-subtle)]" : "bg-card"}`}
>
<div
className={`text-xl sm:text-2xl font-bold ${warnings.length > 0 ? "text-[var(--admin-warning)]" : ""}`}
>
{warnings.length}
</div>
<div className="text-xs text-muted-foreground">Warnings</div>
</div>
</div>
{/* Quick stats */}
<div className="grid gap-2 sm:grid-cols-3 text-sm">
<div className="flex items-center gap-2">
{summary.missingCatalog > 0 ? (
<AlertCircle className="w-4 h-4 text-[var(--admin-warning)]" />
) : (
<CheckCircle2 className="w-4 h-4 text-[var(--admin-success)]" />
)}
<span>{summary.missingCatalog} items without catalog entry</span>
</div>
<div className="flex items-center gap-2">
{summary.orphanedCatalog > 0 ? (
<AlertCircle className="w-4 h-4 text-[var(--admin-error)]" />
) : (
<CheckCircle2 className="w-4 h-4 text-[var(--admin-success)]" />
)}
<span>{summary.orphanedCatalog} orphaned catalog references</span>
</div>
<div className="flex items-center gap-2">
{summary.missingNitro > 0 ? (
<AlertCircle className="w-4 h-4 text-[var(--admin-error)]" />
) : (
<CheckCircle2 className="w-4 h-4 text-[var(--admin-success)]" />
)}
<span>{summary.missingNitro} items without .nitro</span>
</div>
<div className="flex items-center gap-2">
{summary.missingIcon > 0 ? (
<AlertCircle className="w-4 h-4 text-[var(--admin-warning)]" />
) : (
<CheckCircle2 className="w-4 h-4 text-[var(--admin-success)]" />
)}
<span>{summary.missingIcon} items without icon</span>
</div>
<div className="flex items-center gap-2">
{summary.duplicates > 0 ? (
<AlertCircle className="w-4 h-4 text-[var(--admin-error)]" />
) : (
<CheckCircle2 className="w-4 h-4 text-[var(--admin-success)]" />
)}
<span>{summary.duplicates} duplicate classnames</span>
</div>
<div className="flex items-center gap-2">
{summary.missingFromSourcesTotal > 0 ? (
<AlertCircle className="w-4 h-4 text-[var(--admin-warning)]" />
) : (
<CheckCircle2 className="w-4 h-4 text-[var(--admin-success)]" />
)}
<span>
{summary.missingFromSourcesTotal} items missing from clone
sources
</span>
</div>
<div className="flex items-center gap-2">
{summary.unrepairableCount > 0 ? (
<AlertCircle className="w-4 h-4 text-[var(--admin-text-muted)]" />
) : (
<CheckCircle2 className="w-4 h-4 text-[var(--admin-success)]" />
)}
<span>
{summary.unrepairableCount} item(s) can't be repaired
(legacy/broken)
</span>
</div>
{repairStats && (
<div className="flex items-center gap-2 col-span-full">
<span className="text-xs text-muted-foreground">
Icon repair:{" "}
<span className="text-[var(--admin-success)] font-medium">
{repairStats.succeeded} fixed
</span>
{repairStats.failed > 0 && (
<>
{" · "}
<span className="text-[var(--admin-error)] font-medium">
{repairStats.failed} failed
</span>
</>
)}
{repairStats.skipped > 0 && (
<>
{" · "}
<span className="text-[var(--admin-text-muted)]">
{repairStats.skipped} skipped
</span>
</>
)}
{repairStats.stillMissing > 0 && (
<>
{" · "}
<span className="text-[var(--admin-warning)] font-medium">
{repairStats.stillMissing} still missing
</span>
</>
)}
</span>
</div>
)}
{nitroRepairStats && (
<div className="flex items-center gap-2 col-span-full">
<span className="text-xs text-muted-foreground">
Nitro repair:{" "}
<span className="text-[var(--admin-success)] font-medium">
{nitroRepairStats.succeeded} fixed
</span>
{nitroRepairStats.failed > 0 && (
<>
{" · "}
<span className="text-[var(--admin-error)] font-medium">
{nitroRepairStats.failed} failed
</span>
</>
)}
{nitroRepairStats.skipped > 0 && (
<>
{" · "}
<span className="text-[var(--admin-text-muted)]">
{nitroRepairStats.skipped} skipped
</span>
</>
)}
{nitroRepairStats.stillMissing > 0 && (
<>
{" · "}
<span className="text-[var(--admin-warning)] font-medium">
{nitroRepairStats.stillMissing} still missing
</span>
</>
)}
</span>
</div>
)}
{sqlText && (
<div className="flex items-center gap-2 col-span-full">
<span className="text-xs text-muted-foreground">
Catalog SQL:{" "}
<span className="font-medium">
{summary.catalogSqlCount} statement(s) generated
</span>
{sqlApplied && (
<>
{" · "}
<span className="text-[var(--admin-success)] font-medium">
{sqlApplied.applied} applied
</span>
{sqlApplied.failed > 0 && (
<>
{" · "}
<span className="text-[var(--admin-error)] font-medium">
{sqlApplied.failed} failed
</span>
</>
)}
</>
)}
</span>
<Button variant="outline" size="sm" onClick={downloadSql}>
Download .sql
</Button>
</div>
)}
{furniDataResult && (
<div className="flex items-center gap-2 col-span-full">
<span className="text-xs text-muted-foreground">
FurnitureData repair:{" "}
<span className="text-[var(--admin-success)] font-medium">
{furniDataResult.added} added
</span>
{furniDataResult.removedDuplicates > 0 && (
<>
{" · "}
<span className="text-[var(--admin-warning)] font-medium">
{furniDataResult.removedDuplicates} duplicates removed
</span>
</>
)}
{furniDataResult.removedIdConflicts > 0 && (
<>
{" · "}
<span className="text-[var(--admin-warning)] font-medium">
{furniDataResult.removedIdConflicts} spriteId conflicts
fixed
</span>
</>
)}
</span>
</div>
)}
{structureResult && (
<div className="flex items-center gap-2 col-span-full">
<span className="text-xs text-muted-foreground">
Structural repair:{" "}
{structureResult.orphanedRemoved > 0 && (
<>
<span className="text-[var(--admin-success)] font-medium">
{structureResult.orphanedRemoved} orphaned rows removed
</span>
{" · "}
</>
)}
{structureResult.orphanedCleaned > 0 && (
<>
<span className="font-medium">
{structureResult.orphanedCleaned} cleaned
</span>
{" · "}
</>
)}
{structureResult.duplicatesMerged > 0 && (
<>
<span className="text-[var(--admin-success)] font-medium">
{structureResult.duplicatesMerged} classname(s) merged
</span>
{" · "}
</>
)}
{structureResult.duplicateRowsRemoved > 0 && (
<>
<span className="text-[var(--admin-warning)] font-medium">
{structureResult.duplicateRowsRemoved} duplicate rows
removed
</span>
{" · "}
</>
)}
<span className="text-[var(--admin-text-muted)]">
{structureResult.remappedReferences} reference(s) remapped
</span>
</span>
</div>
)}
</div>
{/* Tabs */}
{issues.length > 0 ||
missingFromSources.length > 0 ||
unrepairable.length > 0 ? (
<div className="border-b border-border">
<div className="flex gap-4">
<button
type="button"
onClick={() => setActiveTab("issues")}
className={`pb-2 text-sm font-medium border-b-2 transition-colors ${
activeTab === "issues"
? "border-primary text-foreground"
: "border-transparent text-muted-foreground hover:text-foreground"
}`}
>
Issues ({issues.length})
</button>
<button
type="button"
onClick={() => setActiveTab("sources")}
className={`pb-2 text-sm font-medium border-b-2 transition-colors ${
activeTab === "sources"
? "border-primary text-foreground"
: "border-transparent text-muted-foreground hover:text-foreground"
}`}
>
Missing from sources ({missingFromSources.length})
</button>
<button
type="button"
onClick={() => setActiveTab("unrepairable")}
className={`pb-2 text-sm font-medium border-b-2 transition-colors ${
activeTab === "unrepairable"
? "border-primary text-foreground"
: "border-transparent text-muted-foreground hover:text-foreground"
}`}
>
Unrepairable (legacy) ({unrepairable.length})
</button>
</div>
</div>
) : null}
{/* Issues list */}
{activeTab === "issues" && issues.length > 0 && (
<div className="space-y-2">
{issues.map((issue) => (
<div
key={issue.message}
className={`rounded-xl border p-4 text-sm ${
issue.severity === "error"
? "border-[var(--admin-error-border)] bg-[var(--admin-error-subtle)]"
: issue.severity === "warning"
? "border-[var(--admin-warning-border)] bg-[var(--admin-warning-subtle)]"
: "bg-card"
}`}
>
<div className="flex items-start gap-2">
<AlertCircle
className={`w-4 h-4 mt-0.5 shrink-0 ${
issue.severity === "error"
? "text-[var(--admin-error)]"
: issue.severity === "warning"
? "text-[var(--admin-warning)]"
: "text-[var(--admin-text-muted)]"
}`}
/>
<div className="space-y-0.5 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<span
className={`font-medium ${
issue.severity === "error"
? "text-[var(--admin-error)]"
: issue.severity === "warning"
? "text-[var(--admin-warning)]"
: "text-[var(--admin-text-muted)]"
}`}
>
{TYPE_LABELS[issue.type] || issue.type}
</span>
{issue.classname && (
<code className="text-xs bg-background/50 px-1.5 py-0.5 rounded font-mono break-all max-w-[200px]">
{issue.classname}
</code>
)}
{issue.sourceName && (
<span className="text-xs text-muted-foreground">
source: {issue.sourceName}
</span>
)}
</div>
<p className="text-muted-foreground">{issue.message}</p>
</div>
</div>
</div>
))}
</div>
)}
{/* Missing from sources list */}
{activeTab === "sources" && missingFromSources.length > 0 && (
<div className="space-y-2">
{missingFromSources.map((item) => (
<div
key={`${item.sourceId}-${item.classname}`}
className="rounded-xl border bg-card p-3 text-sm flex items-center gap-2"
>
<ExternalLink className="w-3.5 h-3.5 text-muted-foreground shrink-0" />
<code className="font-mono text-xs">{item.classname}</code>
<span className="text-xs text-muted-foreground">
— {item.itemName}
</span>
<span className="text-xs text-muted-foreground ml-auto">
in {item.sourceName}
</span>
</div>
))}
</div>
)}
{/* Unrepairable (legacy) list */}
{activeTab === "unrepairable" && unrepairable.length > 0 && (
<div className="space-y-2">
{unrepairable.map((item) => (
<div
key={item.classname}
className="rounded-xl border bg-card p-3 text-sm flex items-center gap-2"
>
<AlertCircle className="w-3.5 h-3.5 text-[var(--admin-text-muted)] shrink-0" />
<code className="font-mono text-xs">{item.classname}</code>
<span className="text-xs text-muted-foreground">
(ID {item.itemId})
</span>
<span className="text-xs text-[var(--admin-warning)] ml-auto">
missing {item.missing.join(" + ")}
</span>
</div>
))}
</div>
)}
{activeTab === "issues" &&
issues.length === 0 &&
missingFromSources.length === 0 && (
<div className="rounded-xl border bg-card p-6 sm:p-12 text-center text-muted-foreground">
<CheckCircle2 className="w-10 h-10 sm:w-12 sm:h-12 mx-auto mb-3 text-[var(--admin-success)]" />
<p className="text-base sm:text-lg font-medium">
No issues found
</p>
<p className="text-sm">Your catalog is clean!</p>
</div>
)}
<Button
variant="outline"
onClick={() => {
setSummary(null);
setIssues([]);
setMissingFromSources([]);
setUnrepairable([]);
setRepairStats(null);
setNitroRepairStats(null);
setSqlText(null);
setSqlApplied(null);
setFurniDataResult(null);
setStructureResult(null);
setProgress("");
setCheckProgress(null);
}}
>
Run Again
</Button>
</>
)}
{!loading && !summary && (
<div className="rounded-xl border bg-card p-6 sm:p-12 text-center text-muted-foreground">
<SearchX className="w-10 h-10 sm:w-12 sm:h-12 mx-auto mb-3 opacity-40" />
<p>
Run a full audit to check for missing catalog entries, broken
references, missing files, and items available from clone sources.
</p>
</div>
)}
</div>
);
}
@@ -0,0 +1,119 @@
"use client";
import { Loader2 } from "lucide-react";
import { useEffect, useState } from "react";
import { toast } from "sonner";
import { getBadgeData, updateBadge } from "@/actions/badges";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { useServerAction } from "@/hooks/use-server-action";
interface BadgeEditorDialogProps {
code: string;
open: boolean;
onOpenChange: (open: boolean) => void;
/** Called after a successful save so the parent can refresh state */
onSaved?: () => void;
}
export function BadgeEditorDialog({
code,
open,
onOpenChange,
onSaved,
}: BadgeEditorDialogProps) {
const [name, setName] = useState("");
const [desc, setDesc] = useState("");
const [loading, setLoading] = useState(false);
const { run, isPending: saving } = useServerAction();
// Fetch current values when dialog opens
useEffect(() => {
if (!open || !code) return;
setLoading(true);
getBadgeData({ code })
.then((result) => {
if (result.ok) {
const data = result.data as { name: string; desc: string };
setName(data.name ?? "");
setDesc(data.desc ?? "");
}
})
.catch(() => {
toast.error("Failed to load badge data");
})
.finally(() => setLoading(false));
}, [open, code]);
function handleSave() {
run(() => updateBadge({ code, name, desc }), {
successMessage: `Badge ${code} updated`,
errorMessage: "Save failed",
onSuccess: () => {
onSaved?.();
onOpenChange(false);
},
});
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
Edit Badge — {code}
</DialogTitle>
</DialogHeader>
{loading ? (
<div className="flex items-center justify-center py-8">
<Loader2 className="w-6 h-6 animate-spin text-muted-foreground" />
</div>
) : (
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="badge-name">Name</Label>
<Input
id="badge-name"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Badge name"
/>
</div>
<div className="space-y-2">
<Label htmlFor="badge-desc">Description</Label>
<Input
id="badge-desc"
value={desc}
onChange={(e) => setDesc(e.target.value)}
placeholder="Badge description"
/>
</div>
</div>
)}
<DialogFooter>
<Button
variant="outline"
onClick={() => onOpenChange(false)}
disabled={saving}
>
Cancel
</Button>
<Button onClick={handleSave} disabled={loading || saving}>
{saving && <Loader2 className="w-4 h-4 animate-spin mr-1" />}
Save
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,555 @@
"use client";
import {
AlertCircle,
BadgeCheck,
Check,
CheckCircle2,
Download,
Globe,
Loader2,
Package,
Pencil,
Search,
X,
} from "lucide-react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { toast } from "sonner";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { adminFetch } from "@/lib/admin-fetch";
import { cn } from "@/lib/utils";
import { BadgeEditorDialog } from "./badge-editor-dialog";
interface BadgeItem {
id: number;
code: string;
name: string;
description: string;
hotel: string;
url_habbo: string;
url_habboassets: string;
alreadyImported: boolean;
}
const PAGE_SIZE = 100;
export function ImportBadgesClient() {
const [searchTerm, setSearchTerm] = useState("");
const [activeSearch, setActiveSearch] = useState("");
const [badges, setBadges] = useState<BadgeItem[]>([]);
const [offset, setOffset] = useState(0);
const [hasMore, setHasMore] = useState(false);
const [loading, setLoading] = useState(false);
const [importingCode, setImportingCode] = useState<string | null>(null);
const [error, setError] = useState("");
const [allHotels, setAllHotels] = useState(true);
const [editingCode, setEditingCode] = useState<string | null>(null);
const [selected, setSelected] = useState<Set<string>>(new Set());
const [batchImporting, setBatchImporting] = useState(false);
const searchRef = useRef<HTMLInputElement>(null);
const fetchBadges = useCallback(
async (search: string, off: number, all: boolean) => {
setLoading(true);
setError("");
try {
const params = new URLSearchParams({ offset: String(off) });
if (search) params.set("search", search);
if (all) params.set("allHotels", "1");
const res = await fetch(`/api/admin/import/badges?${params}`);
const data = await res.json();
if (!res.ok) {
setError(data.error || "Failed to load");
return;
}
const list = data.badges || [];
setBadges(list);
setHasMore(list.length >= PAGE_SIZE);
setSelected(new Set());
} catch {
setError("Network error");
} finally {
setLoading(false);
}
},
[],
);
// Auto-load on mount and when allHotels changes
// biome-ignore lint/correctness/useExhaustiveDependencies: explicitly chosen here, see surrounding code
useEffect(() => {
setOffset(0);
fetchBadges(activeSearch, 0, allHotels);
}, [fetchBadges, allHotels]);
// "/" keyboard shortcut to focus search
useEffect(() => {
function onKeyDown(e: KeyboardEvent) {
if (e.key === "/" && !e.metaKey && !e.ctrlKey && !e.altKey) {
const tag = (e.target as HTMLElement)?.tagName;
if (tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT") return;
e.preventDefault();
searchRef.current?.focus();
}
}
document.addEventListener("keydown", onKeyDown);
return () => document.removeEventListener("keydown", onKeyDown);
}, []);
function doSearch() {
setActiveSearch(searchTerm);
setOffset(0);
fetchBadges(searchTerm, 0, allHotels);
}
function goPage(direction: "prev" | "next") {
const newOffset =
direction === "next"
? offset + PAGE_SIZE
: Math.max(0, offset - PAGE_SIZE);
setOffset(newOffset);
fetchBadges(activeSearch, newOffset, allHotels);
}
async function postImportBadge(badge: BadgeItem) {
const res = await adminFetch("/api/admin/import/badges", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
code: badge.code,
name: badge.name,
description: badge.description,
}),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) {
return {
ok: false as const,
error:
(data as { error?: string }).error ||
`Failed to import ${badge.code}`,
};
}
return { ok: true as const };
}
async function doImport(badge: BadgeItem) {
setImportingCode(badge.code);
try {
const result = await postImportBadge(badge);
if (result.ok) {
toast.success(`${badge.code} imported`);
setBadges((prev) =>
prev.map((b) =>
b.code === badge.code ? { ...b, alreadyImported: true } : b,
),
);
setSelected((prev) => {
const n = new Set(prev);
n.delete(badge.code);
return n;
});
} else {
toast.error(result.error);
}
} catch {
toast.error(`Network error importing ${badge.code}`);
} finally {
setImportingCode(null);
}
}
async function doBatchImport() {
const toImport = badges.filter(
(b) => selected.has(b.code) && !b.alreadyImported,
);
if (toImport.length === 0) return;
setBatchImporting(true);
let ok = 0,
fail = 0;
for (const badge of toImport) {
try {
const result = await postImportBadge(badge);
if (result.ok) {
ok++;
setBadges((prev) =>
prev.map((b) =>
b.code === badge.code ? { ...b, alreadyImported: true } : b,
),
);
} else {
fail++;
}
} catch {
fail++;
}
}
setBatchImporting(false);
setSelected(new Set());
if (ok > 0) toast.success(`${ok} badge(s) imported`);
if (fail > 0) toast.error(`${fail} failed`);
}
function toggleSelect(code: string) {
setSelected((prev) => {
const next = new Set(prev);
if (next.has(code)) next.delete(code);
else next.add(code);
return next;
});
}
const importableBadges = useMemo(
() => badges.filter((b) => !b.alreadyImported),
[badges],
);
function toggleSelectAll() {
if (selected.size === importableBadges.length) {
setSelected(new Set());
} else {
setSelected(new Set(importableBadges.map((b) => b.code)));
}
}
// Stats
const importedCount = useMemo(
() => badges.filter((b) => b.alreadyImported).length,
[badges],
);
const currentPage = Math.floor(offset / PAGE_SIZE) + 1;
return (
<TooltipProvider delayDuration={300}>
<div className="space-y-4">
{/* Stats bar */}
{badges.length > 0 && (
<div className="flex items-center gap-3 flex-wrap">
<div className="flex items-center gap-1.5 text-sm">
<Package className="h-4 w-4 text-muted-foreground" />
<span className="font-medium">{badges.length}</span>
<span className="text-muted-foreground">results</span>
</div>
<div className="flex items-center gap-1.5 text-sm">
<CheckCircle2 className="h-4 w-4 text-[var(--admin-success)]" />
<span className="font-medium text-[var(--admin-success)]">
{importedCount}
</span>
<span className="text-muted-foreground">imported</span>
</div>
{importableBadges.length > 0 && (
<div className="flex items-center gap-1.5 text-sm">
<Download className="h-4 w-4 text-muted-foreground" />
<span className="font-medium">{importableBadges.length}</span>
<span className="text-muted-foreground">available</span>
</div>
)}
</div>
)}
{/* Search bar */}
<div className="flex gap-2">
<div className="relative flex-1 max-w-md">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
ref={searchRef}
placeholder="Search badges by name... /"
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") doSearch();
if (e.key === "Escape") {
setSearchTerm("");
searchRef.current?.blur();
}
}}
className="pl-9 pr-8"
/>
{searchTerm && (
<button
type="button"
onClick={() => {
setSearchTerm("");
setActiveSearch("");
fetchBadges("", 0, allHotels);
}}
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
>
<X className="h-3.5 w-3.5" />
</button>
)}
</div>
<Button onClick={doSearch} size="sm" disabled={loading}>
{loading ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
<Search className="w-4 h-4" />
)}
</Button>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant={allHotels ? "default" : "outline"}
size="sm"
onClick={() => setAllHotels((v) => !v)}
>
<Globe className="w-4 h-4 mr-1" />
{allHotels ? "All" : "IT"}
</Button>
</TooltipTrigger>
<TooltipContent>
<p>
{allHotels
? "Showing all hotels — click for Italian only"
: "Showing Italian only — click for all hotels"}
</p>
</TooltipContent>
</Tooltip>
</div>
{/* Bulk actions */}
{importableBadges.length > 0 && (
<div className="flex items-center gap-2 flex-wrap">
<Button
variant="outline"
size="sm"
onClick={toggleSelectAll}
className="text-xs h-8"
disabled={batchImporting}
>
{selected.size === importableBadges.length
? "Deselect All"
: `Select All (${importableBadges.length})`}
</Button>
{selected.size > 0 && (
<Button
size="sm"
className="h-8"
onClick={doBatchImport}
disabled={batchImporting}
>
{batchImporting ? (
<Loader2 className="w-4 h-4 animate-spin mr-1" />
) : (
<Download className="w-4 h-4 mr-1" />
)}
Import {selected.size} selected
</Button>
)}
</div>
)}
{/* Error */}
{error && (
<div className="rounded-lg border px-4 py-3 text-sm bg-destructive/10 border-destructive/20 text-destructive flex items-center gap-2">
<AlertCircle className="w-4 h-4 shrink-0" />
{error}
</div>
)}
{/* Loading skeleton */}
{loading && badges.length === 0 && (
<div className="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 gap-3">
{Array.from({ length: 18 }, (_, i) => i).map((n) => (
<div
key={`skeleton-badge-${n}`}
className="rounded-lg border bg-card p-3 animate-pulse"
>
<div className="h-12 w-12 bg-muted rounded mx-auto mb-2" />
<div className="h-4 bg-muted rounded w-3/4 mx-auto mb-1" />
<div className="h-3 bg-muted rounded w-1/2 mx-auto mb-2" />
<div className="h-7 bg-muted rounded" />
</div>
))}
</div>
)}
{/* Empty state */}
{!loading && badges.length === 0 && !error && (
<div className="text-center py-12 text-muted-foreground">
<BadgeCheck className="w-12 h-12 mx-auto mb-3 opacity-50" />
<p className="text-sm">
No badges found{activeSearch ? ` for "${activeSearch}"` : ""}
</p>
{!activeSearch && (
<p className="text-xs mt-1">
Try searching by badge name or code
</p>
)}
</div>
)}
{/* Badge grid */}
{badges.length > 0 && (
<div
className={cn(
"grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 gap-3",
loading && "opacity-50",
)}
>
{badges.map((badge) => {
const isSelected = selected.has(badge.code);
return (
<div
key={badge.code}
className={cn(
"rounded-lg border overflow-hidden bg-card group relative hover:border-border transition-all",
badge.alreadyImported &&
"ring-1 ring-[var(--admin-success)]/30",
isSelected && "ring-2 ring-primary border-primary",
)}
>
{/* Checkbox for non-imported */}
{!badge.alreadyImported && (
<button
type="button"
onClick={() => toggleSelect(badge.code)}
className={cn(
"absolute top-1.5 left-1.5 z-10 w-5 h-5 rounded border-2 flex items-center justify-center transition-colors",
isSelected
? "bg-primary border-primary text-primary-foreground"
: "bg-background/80 border-muted-foreground/30 hover:border-muted-foreground",
)}
>
{isSelected && <Check className="w-3 h-3" />}
</button>
)}
{/* Badge image area */}
<div className="flex items-center justify-center h-20 bg-accent/20">
<img
src={badge.url_habbo}
alt={badge.code}
className="image-pixelated drop-shadow-sm"
style={{ maxHeight: 48, maxWidth: 48 }}
onError={(e) => {
const img = e.target as HTMLImageElement;
if (!img.dataset.fallback) {
img.dataset.fallback = "1";
img.src = badge.url_habboassets;
} else {
img.style.opacity = "0.3";
}
}}
/>
</div>
{/* Info */}
<div className="px-2.5 py-2 border-t space-y-1">
<div className="text-xs font-bold truncate">
{badge.code}
</div>
{badge.name && (
<Tooltip>
<TooltipTrigger asChild>
<div className="text-[11px] text-muted-foreground truncate cursor-default">
{badge.name}
</div>
</TooltipTrigger>
<TooltipContent side="bottom" className="max-w-64">
<p className="font-medium">{badge.name}</p>
{badge.description && (
<p className="text-muted-foreground text-xs mt-1">
{badge.description}
</p>
)}
</TooltipContent>
</Tooltip>
)}
{allHotels && badge.hotel && (
<Badge variant="outline" className="text-[9px] px-1 h-4">
{badge.hotel.toUpperCase()}
</Badge>
)}
{/* Action */}
<div className="pt-1">
{badge.alreadyImported ? (
<div className="flex items-center gap-1">
<Badge className="text-[10px] px-1.5 py-0 bg-[var(--admin-success-subtle)] text-[var(--admin-success)] border-0 gap-0.5 flex-1 justify-center">
<Check className="w-2.5 h-2.5" />
Imported
</Badge>
<Button
variant="ghost"
size="sm"
className="h-5 w-5 p-0 shrink-0"
title="Edit badge name/description"
onClick={() => setEditingCode(badge.code)}
>
<Pencil className="w-3 h-3" />
</Button>
</div>
) : (
<Button
size="sm"
variant="outline"
className="w-full text-xs h-7"
onClick={() => doImport(badge)}
disabled={
importingCode === badge.code || batchImporting
}
>
{importingCode === badge.code ? (
<Loader2 className="w-3 h-3 animate-spin mr-1" />
) : (
<Download className="w-3 h-3 mr-1" />
)}
Import
</Button>
)}
</div>
</div>
</div>
);
})}
</div>
)}
{/* Pagination */}
{(offset > 0 || hasMore) && (
<div className="flex items-center justify-between">
<span className="text-sm text-muted-foreground">
Page {currentPage}
</span>
<div className="flex gap-1">
<Button
variant="outline"
size="sm"
disabled={offset <= 0 || loading}
onClick={() => goPage("prev")}
>
&larr; Prev
</Button>
<Button
variant="outline"
size="sm"
disabled={!hasMore || loading}
onClick={() => goPage("next")}
>
Next &rarr;
</Button>
</div>
</div>
)}
{/* Badge Editor Dialog */}
<BadgeEditorDialog
code={editingCode ?? ""}
open={!!editingCode}
onOpenChange={(open) => {
if (!open) setEditingCode(null);
}}
/>
</div>
</TooltipProvider>
);
}
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,914 @@
"use client";
import {
AlertCircle,
Check,
Download,
Loader2,
Package,
Search,
Shirt,
Trash2,
X,
} from "lucide-react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { toast } from "sonner";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { adminFetch } from "@/lib/admin-fetch";
import { getAvatarUrl } from "@/lib/imager";
import { runSseImport } from "@/lib/sse-client";
import { cn } from "@/lib/utils";
// ── Shared types ─────────────────────────────────────────────────────────────
interface FigureItem {
lib: string;
revision: number;
nitroExists: boolean;
previewType?: string;
previewId?: number;
}
interface ClothingSetItem {
setType: string;
setId: number;
gender: string;
club: number;
sellable: boolean;
present: boolean;
}
type Mode = "libs" | "sets";
const PER_PAGE = 100;
// ── Avatar preview (renders a mini avatar wearing the item) ───────────────────
// A default dressed avatar; the previewed part overrides/adds its slot so the
// item is shown on a complete figure.
const BASE_FIGURE: Record<string, string> = {
hd: "hd-180-1",
hr: "hr-828-45",
ch: "ch-665-92",
lg: "lg-700-82",
sh: "sh-705-62",
};
// Head-worn slots → render head-only (zoomed) so the item fills the frame;
// everything else renders the full body.
const HEAD_TYPES = new Set(["hd", "hr", "ha", "he", "ea", "fa", "fc"]);
function buildPreviewFigure(type: string, id: number): string {
const parts: Record<string, string> = { ...BASE_FIGURE };
parts[type] = `${type}-${id}`; // default color
return Object.values(parts).join(".");
}
function AvatarPreview({ type, id }: { type?: string; id?: number }) {
const [failed, setFailed] = useState(false);
if (failed || !type || id === undefined) {
return <Shirt className="w-8 h-8 text-muted-foreground/60" />;
}
const figure = buildPreviewFigure(type, id);
return (
<img
src={getAvatarUrl(figure, {
direction: 2,
headDirection: 3,
headOnly: HEAD_TYPES.has(type),
})}
alt={`${type}-${id}`}
className="max-h-full object-contain image-pixelated"
onError={() => setFailed(true)}
/>
);
}
// ── Librerie (v1) view ────────────────────────────────────────────────────────
function LibsView() {
const [searchTerm, setSearchTerm] = useState("");
const [activeSearch, setActiveSearch] = useState("");
const [items, setItems] = useState<FigureItem[]>([]);
const [page, setPage] = useState(1);
const [total, setTotal] = useState(0);
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
const [selected, setSelected] = useState<Set<string>>(new Set());
const [batchProgress, setBatchProgress] = useState<{
done: number;
total: number;
} | null>(null);
const [busyLib, setBusyLib] = useState<string | null>(null);
const batchAbortRef = useRef<AbortController | null>(null);
const fetchItems = useCallback(async (search: string, pageNum: number) => {
setLoading(true);
setError("");
try {
const params = new URLSearchParams({ page: String(pageNum) });
if (search) params.set("search", search);
const res = await adminFetch(`/api/admin/import/clothing?${params}`);
const data = await res.json();
if (!res.ok) {
setError(data.error || "Failed to load");
return;
}
setItems(data.items || []);
setTotal(data.meta?.total || 0);
setSelected(new Set());
} catch {
setError("Network error");
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
fetchItems("", 1);
}, [fetchItems]);
function doSearch() {
setActiveSearch(searchTerm);
setPage(1);
fetchItems(searchTerm, 1);
}
function goPage(dir: "prev" | "next") {
const next = dir === "next" ? page + 1 : Math.max(1, page - 1);
setPage(next);
fetchItems(activeSearch, next);
}
const importable = useMemo(
() => items.filter((p) => !p.nitroExists),
[items],
);
const importedCount = useMemo(
() => items.filter((p) => p.nitroExists).length,
[items],
);
const lastPage = Math.max(Math.ceil(total / PER_PAGE), 1);
function toggle(lib: string) {
setSelected((prev) => {
const n = new Set(prev);
if (n.has(lib)) n.delete(lib);
else n.add(lib);
return n;
});
}
function markDone(lib: string) {
setItems((prev) =>
prev.map((p) => (p.lib === lib ? { ...p, nitroExists: true } : p)),
);
}
async function importViaSse(toImport: FigureItem[]) {
batchAbortRef.current?.abort();
const abort = new AbortController();
batchAbortRef.current = abort;
setBatchProgress({ done: 0, total: toImport.length });
let done = 0;
try {
await runSseImport(
"/api/admin/import/clothing/batch",
{ items: toImport, concurrency: 3 },
(label) => {
done++;
markDone(label);
setBatchProgress({ done, total: toImport.length });
},
(succeeded, failed) => {
setSelected(new Set());
if (succeeded > 0) toast.success(`${succeeded} libraries imported`);
if (failed > 0) toast.error(`${failed} failed`);
},
abort.signal,
() => {
done++;
setBatchProgress({ done, total: toImport.length });
},
);
} catch (err) {
if ((err as Error)?.name === "AbortError") {
toast.info("Import cancelled");
} else {
throw err;
}
} finally {
if (batchAbortRef.current === abort) batchAbortRef.current = null;
setBatchProgress(null);
}
}
async function importOne(p: FigureItem) {
setBusyLib(p.lib);
try {
await importViaSse([p]);
} finally {
setBusyLib(null);
}
}
async function importSelected() {
const toImport = items.filter((p) => selected.has(p.lib) && !p.nitroExists);
if (toImport.length === 0) return;
await importViaSse(toImport);
}
async function remove(p: FigureItem) {
setBusyLib(p.lib);
try {
const res = await adminFetch(
`/api/admin/import/clothing?lib=${encodeURIComponent(p.lib)}`,
{
method: "DELETE",
},
);
const data = await res.json();
if (res.ok) {
toast.success(`${p.lib} removed`);
setItems((prev) =>
prev.map((x) => (x.lib === p.lib ? { ...x, nitroExists: false } : x)),
);
} else {
toast.error(data.error || "Delete failed");
}
} finally {
setBusyLib(null);
}
}
return (
<div className="space-y-4">
{(items.length > 0 || total > 0) && (
<div className="flex items-center gap-3 flex-wrap">
<div className="flex items-center gap-1.5 text-sm">
<Package className="h-4 w-4 text-muted-foreground" />
<span className="font-medium">{total}</span>
<span className="text-muted-foreground">libraries</span>
</div>
<div className="flex items-center gap-1.5 text-sm">
<Check className="h-4 w-4 text-[var(--admin-success)]" />
<span className="font-medium text-[var(--admin-success)]">
{importedCount}
</span>
<span className="text-muted-foreground">on this page</span>
</div>
<div className="flex items-center gap-1.5 text-sm">
<Download className="h-4 w-4 text-muted-foreground" />
<span className="font-medium">{importable.length}</span>
<span className="text-muted-foreground">missing here</span>
</div>
</div>
)}
<div className="flex gap-2 flex-wrap">
<div className="relative flex-1 max-w-md">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Search libraries (e.g. Hair_F, shirt_M)..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") doSearch();
if (e.key === "Escape") {
setSearchTerm("");
setActiveSearch("");
setPage(1);
fetchItems("", 1);
}
}}
className="pl-9 pr-8"
/>
{searchTerm && (
<button
type="button"
onClick={() => {
setSearchTerm("");
setActiveSearch("");
setPage(1);
fetchItems("", 1);
}}
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
>
<X className="h-3.5 w-3.5" />
</button>
)}
</div>
<Button onClick={doSearch} size="sm" disabled={loading}>
{loading ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
<Search className="w-4 h-4" />
)}
</Button>
</div>
{importable.length > 0 && (
<div className="flex items-center gap-2 flex-wrap">
<Button
variant="outline"
size="sm"
className="text-xs h-8"
disabled={!!batchProgress}
onClick={() =>
setSelected((prev) =>
prev.size === importable.length
? new Set()
: new Set(importable.map((p) => p.lib)),
)
}
>
{selected.size === importable.length
? "Deselect page"
: `Select page (${importable.length})`}
</Button>
{selected.size > 0 && (
<Button
size="sm"
className="h-8"
onClick={importSelected}
disabled={!!batchProgress}
>
{batchProgress ? (
<Loader2 className="w-4 h-4 animate-spin mr-1" />
) : (
<Download className="w-4 h-4 mr-1" />
)}
Import {selected.size} selected
</Button>
)}
{batchProgress && (
<>
<span className="text-xs text-muted-foreground">
{batchProgress.done}/{batchProgress.total}
</span>
<Button
size="sm"
variant="outline"
className="h-8"
onClick={() => batchAbortRef.current?.abort()}
>
Cancel
</Button>
</>
)}
</div>
)}
{error && (
<div className="rounded-lg border px-4 py-3 text-sm bg-destructive/10 border-destructive/20 text-destructive flex items-center gap-2">
<AlertCircle className="w-4 h-4 shrink-0" />
{error}
</div>
)}
{!loading && items.length === 0 && !error && (
<div className="text-center py-12 text-muted-foreground">
<Shirt className="w-12 h-12 mx-auto mb-3 opacity-50" />
<p className="text-sm">
No libraries found{activeSearch ? ` for "${activeSearch}"` : ""}
</p>
</div>
)}
{items.length > 0 && (
<div
className={cn(
"grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 gap-3",
loading && "opacity-50",
)}
>
{items.map((p) => {
const isSelected = selected.has(p.lib);
return (
<div
key={p.lib}
className={cn(
"rounded-lg border overflow-hidden bg-card relative hover:border-border transition-all",
p.nitroExists && "ring-1 ring-[var(--admin-success)]/30",
isSelected && "ring-2 ring-primary border-primary",
)}
>
{!p.nitroExists && (
<button
type="button"
onClick={() => toggle(p.lib)}
className={cn(
"absolute top-1.5 left-1.5 z-10 w-5 h-5 rounded border-2 flex items-center justify-center transition-colors",
isSelected
? "bg-primary border-primary text-primary-foreground"
: "bg-background/80 border-muted-foreground/30 hover:border-muted-foreground",
)}
>
{isSelected && <Check className="w-3 h-3" />}
</button>
)}
<div className="flex items-center justify-center h-28 bg-accent/20">
<AvatarPreview type={p.previewType} id={p.previewId} />
</div>
<div className="px-2.5 py-2 border-t space-y-1">
<div className="text-xs font-bold truncate" title={p.lib}>
{p.lib}
</div>
<div className="pt-1">
{p.nitroExists ? (
<div className="flex items-center gap-1">
<Badge className="text-[10px] px-1.5 py-0 bg-[var(--admin-success-subtle)] text-[var(--admin-success)] border-0 gap-0.5 flex-1 justify-center">
<Check className="w-2.5 h-2.5" />
Imported
</Badge>
<Button
variant="ghost"
size="sm"
className="h-5 w-5 p-0 shrink-0"
title="Remove library"
disabled={busyLib === p.lib || !!batchProgress}
onClick={() => remove(p)}
>
<Trash2 className="w-3 h-3" />
</Button>
</div>
) : (
<Button
size="sm"
variant="outline"
className="w-full text-xs h-7"
disabled={busyLib === p.lib || !!batchProgress}
onClick={() => importOne(p)}
>
{busyLib === p.lib ? (
<Loader2 className="w-3 h-3 animate-spin mr-1" />
) : (
<Download className="w-3 h-3 mr-1" />
)}
Import
</Button>
)}
</div>
</div>
</div>
);
})}
</div>
)}
{total > PER_PAGE && (
<div className="flex items-center justify-between">
<span className="text-sm text-muted-foreground">
Page {page} / {lastPage}
</span>
<div className="flex gap-1">
<Button
variant="outline"
size="sm"
disabled={page <= 1 || loading}
onClick={() => goPage("prev")}
>
&larr; Prev
</Button>
<Button
variant="outline"
size="sm"
disabled={page >= lastPage || loading}
onClick={() => goPage("next")}
>
Next &rarr;
</Button>
</div>
</div>
)}
</div>
);
}
// ── Vestiti / Sets (v2) view ──────────────────────────────────────────────────
function SetsView() {
const [searchTerm, setSearchTerm] = useState("");
const [activeSearch, setActiveSearch] = useState("");
const [items, setItems] = useState<ClothingSetItem[]>([]);
const [page, setPage] = useState(1);
const [total, setTotal] = useState(0);
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
const [selected, setSelected] = useState<Set<string>>(new Set());
const [batchProgress, setBatchProgress] = useState<{
done: number;
total: number;
} | null>(null);
const [busyKey, setBusyKey] = useState<string | null>(null);
const batchAbortRef = useRef<AbortController | null>(null);
function itemKey(it: ClothingSetItem): string {
return `${it.setType}-${it.setId}`;
}
const fetchItems = useCallback(async (search: string, pageNum: number) => {
setLoading(true);
setError("");
try {
const params = new URLSearchParams({ page: String(pageNum) });
if (search) params.set("search", search);
const res = await adminFetch(`/api/admin/import/clothing/sets?${params}`);
const data = await res.json();
if (!res.ok) {
setError(data.error || "Failed to load");
return;
}
setItems(data.items || []);
setTotal(data.meta?.total || 0);
setSelected(new Set());
} catch {
setError("Network error");
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
fetchItems("", 1);
}, [fetchItems]);
function doSearch() {
setActiveSearch(searchTerm);
setPage(1);
fetchItems(searchTerm, 1);
}
function goPage(dir: "prev" | "next") {
const next = dir === "next" ? page + 1 : Math.max(1, page - 1);
setPage(next);
fetchItems(activeSearch, next);
}
const importable = useMemo(() => items.filter((it) => !it.present), [items]);
const presentCount = useMemo(
() => items.filter((it) => it.present).length,
[items],
);
const lastPage = Math.max(Math.ceil(total / PER_PAGE), 1);
function toggle(key: string) {
setSelected((prev) => {
const n = new Set(prev);
if (n.has(key)) n.delete(key);
else n.add(key);
return n;
});
}
function markDone(label: string) {
setItems((prev) =>
prev.map((it) => (itemKey(it) === label ? { ...it, present: true } : it)),
);
}
async function importViaSse(toImport: ClothingSetItem[]) {
batchAbortRef.current?.abort();
const abort = new AbortController();
batchAbortRef.current = abort;
setBatchProgress({ done: 0, total: toImport.length });
let done = 0;
try {
await runSseImport(
"/api/admin/import/clothing/sets/batch",
{
items: toImport.map((it) => ({
setType: it.setType,
setId: it.setId,
})),
concurrency: 3,
},
(label) => {
done++;
markDone(label);
setBatchProgress({ done, total: toImport.length });
},
(succeeded, failed) => {
setSelected(new Set());
if (succeeded > 0) toast.success(`${succeeded} sets imported`);
if (failed > 0) toast.error(`${failed} failed`);
},
abort.signal,
() => {
done++;
setBatchProgress({ done, total: toImport.length });
},
);
} catch (err) {
if ((err as Error)?.name === "AbortError") {
toast.info("Import cancelled");
} else {
throw err;
}
} finally {
if (batchAbortRef.current === abort) batchAbortRef.current = null;
setBatchProgress(null);
}
}
async function importOne(it: ClothingSetItem) {
const key = itemKey(it);
setBusyKey(key);
try {
await importViaSse([it]);
} finally {
setBusyKey(null);
}
}
async function importSelected() {
const toImport = items.filter(
(it) => selected.has(itemKey(it)) && !it.present,
);
if (toImport.length === 0) return;
await importViaSse(toImport);
}
return (
<div className="space-y-4">
{(items.length > 0 || total > 0) && (
<div className="flex items-center gap-3 flex-wrap">
<div className="flex items-center gap-1.5 text-sm">
<Package className="h-4 w-4 text-muted-foreground" />
<span className="font-medium">{total}</span>
<span className="text-muted-foreground">sets</span>
</div>
<div className="flex items-center gap-1.5 text-sm">
<Check className="h-4 w-4 text-[var(--admin-success)]" />
<span className="font-medium text-[var(--admin-success)]">
{presentCount}
</span>
<span className="text-muted-foreground">on this page</span>
</div>
<div className="flex items-center gap-1.5 text-sm">
<Download className="h-4 w-4 text-muted-foreground" />
<span className="font-medium">{importable.length}</span>
<span className="text-muted-foreground">missing here</span>
</div>
</div>
)}
<div className="flex gap-2 flex-wrap">
<div className="relative flex-1 max-w-md">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Search by set id or type (e.g. hr, ch, 1027)..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") doSearch();
if (e.key === "Escape") {
setSearchTerm("");
setActiveSearch("");
setPage(1);
fetchItems("", 1);
}
}}
className="pl-9 pr-8"
/>
{searchTerm && (
<button
type="button"
onClick={() => {
setSearchTerm("");
setActiveSearch("");
setPage(1);
fetchItems("", 1);
}}
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
>
<X className="h-3.5 w-3.5" />
</button>
)}
</div>
<Button onClick={doSearch} size="sm" disabled={loading}>
{loading ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
<Search className="w-4 h-4" />
)}
</Button>
</div>
{importable.length > 0 && (
<div className="flex items-center gap-2 flex-wrap">
<Button
variant="outline"
size="sm"
className="text-xs h-8"
disabled={!!batchProgress}
onClick={() =>
setSelected((prev) =>
prev.size === importable.length
? new Set()
: new Set(importable.map((it) => itemKey(it))),
)
}
>
{selected.size === importable.length
? "Deselect page"
: `Select page (${importable.length})`}
</Button>
{selected.size > 0 && (
<Button
size="sm"
className="h-8"
onClick={importSelected}
disabled={!!batchProgress}
>
{batchProgress ? (
<Loader2 className="w-4 h-4 animate-spin mr-1" />
) : (
<Download className="w-4 h-4 mr-1" />
)}
Import {selected.size} selected
</Button>
)}
{batchProgress && (
<>
<span className="text-xs text-muted-foreground">
{batchProgress.done}/{batchProgress.total}
</span>
<Button
size="sm"
variant="outline"
className="h-8"
onClick={() => batchAbortRef.current?.abort()}
>
Cancel
</Button>
</>
)}
</div>
)}
{error && (
<div className="rounded-lg border px-4 py-3 text-sm bg-destructive/10 border-destructive/20 text-destructive flex items-center gap-2">
<AlertCircle className="w-4 h-4 shrink-0" />
{error}
</div>
)}
{!loading && items.length === 0 && !error && (
<div className="text-center py-12 text-muted-foreground">
<Shirt className="w-12 h-12 mx-auto mb-3 opacity-50" />
<p className="text-sm">
No sets found{activeSearch ? ` for "${activeSearch}"` : ""}
</p>
</div>
)}
{items.length > 0 && (
<div
className={cn(
"grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 gap-3",
loading && "opacity-50",
)}
>
{items.map((it) => {
const key = itemKey(it);
const isSelected = selected.has(key);
return (
<div
key={key}
className={cn(
"rounded-lg border overflow-hidden bg-card relative hover:border-border transition-all",
it.present && "ring-1 ring-[var(--admin-success)]/30",
isSelected && "ring-2 ring-primary border-primary",
)}
>
{!it.present && (
<button
type="button"
onClick={() => toggle(key)}
className={cn(
"absolute top-1.5 left-1.5 z-10 w-5 h-5 rounded border-2 flex items-center justify-center transition-colors",
isSelected
? "bg-primary border-primary text-primary-foreground"
: "bg-background/80 border-muted-foreground/30 hover:border-muted-foreground",
)}
>
{isSelected && <Check className="w-3 h-3" />}
</button>
)}
<div className="flex items-center justify-center h-28 bg-accent/20">
<AvatarPreview type={it.setType} id={it.setId} />
</div>
<div className="px-2.5 py-2 border-t space-y-1">
<div className="text-xs font-bold truncate" title={key}>
{it.setType}-{it.setId}
</div>
<div className="flex items-center gap-1 flex-wrap">
<Badge
variant="outline"
className="text-[10px] px-1.5 py-0"
>
{it.gender}
</Badge>
{it.club > 0 && (
<Badge className="text-[10px] px-1.5 py-0 bg-[var(--admin-warning-subtle)] text-[var(--admin-warning)] border-0">
HC
</Badge>
)}
{it.sellable && (
<Badge className="text-[10px] px-1.5 py-0 bg-[var(--admin-info-subtle)] text-[var(--admin-info)] border-0">
$
</Badge>
)}
</div>
<div className="pt-1">
{it.present ? (
<Badge className="text-[10px] px-1.5 py-0 bg-[var(--admin-success-subtle)] text-[var(--admin-success)] border-0 gap-0.5 w-full justify-center">
<Check className="w-2.5 h-2.5" />
Imported
</Badge>
) : (
<Button
size="sm"
variant="outline"
className="w-full text-xs h-7"
disabled={busyKey === key || !!batchProgress}
onClick={() => importOne(it)}
>
{busyKey === key ? (
<Loader2 className="w-3 h-3 animate-spin mr-1" />
) : (
<Download className="w-3 h-3 mr-1" />
)}
Import
</Button>
)}
</div>
</div>
</div>
);
})}
</div>
)}
{total > PER_PAGE && (
<div className="flex items-center justify-between">
<span className="text-sm text-muted-foreground">
Page {page} / {lastPage}
</span>
<div className="flex gap-1">
<Button
variant="outline"
size="sm"
disabled={page <= 1 || loading}
onClick={() => goPage("prev")}
>
&larr; Prev
</Button>
<Button
variant="outline"
size="sm"
disabled={page >= lastPage || loading}
onClick={() => goPage("next")}
>
Next &rarr;
</Button>
</div>
</div>
)}
</div>
);
}
// ── Root component with mode toggle ──────────────────────────────────────────
export function ImportClothingClient() {
const [mode, setMode] = useState<Mode>("libs");
return (
<div className="space-y-4">
<div className="flex gap-1 rounded-lg border p-1 w-fit">
<Button
size="sm"
variant={mode === "libs" ? "default" : "ghost"}
className="h-7 text-xs"
onClick={() => setMode("libs")}
>
Libraries
</Button>
<Button
size="sm"
variant={mode === "sets" ? "default" : "ghost"}
className="h-7 text-xs"
onClick={() => setMode("sets")}
>
Sets
</Button>
</div>
{mode === "libs" ? <LibsView /> : <SetsView />}
</div>
);
}
@@ -0,0 +1,431 @@
// src/app/[locale]/admin/import/effects/import-effects-client.tsx
"use client";
import {
AlertCircle,
Check,
Download,
Loader2,
Music,
Package,
Search,
Sparkles,
Trash2,
X,
} from "lucide-react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { toast } from "sonner";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { adminFetch } from "@/lib/admin-fetch";
import { runSseImport } from "@/lib/sse-client";
import { cn } from "@/lib/utils";
interface EffectItem {
id: string;
lib: string;
type: string;
revision: number;
present: boolean;
nitroExists: boolean;
}
type Filter = "all" | "fx" | "dance";
export function ImportEffectsClient() {
const [searchTerm, setSearchTerm] = useState("");
const [effects, setEffects] = useState<EffectItem[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
const [filter, setFilter] = useState<Filter>("all");
const [selected, setSelected] = useState<Set<string>>(new Set());
const [batchProgress, setBatchProgress] = useState<{
done: number;
total: number;
} | null>(null);
const [busyLib, setBusyLib] = useState<string | null>(null);
const batchAbortRef = useRef<AbortController | null>(null);
const fetchEffects = useCallback(async (search: string) => {
setLoading(true);
setError("");
try {
const params = new URLSearchParams();
if (search) params.set("search", search);
const res = await adminFetch(`/api/admin/import/effects?${params}`);
const data = await res.json();
if (!res.ok) {
setError(data.error || "Failed to load");
return;
}
setEffects(data.effects || []);
setSelected(new Set());
} catch {
setError("Network error");
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
fetchEffects("");
}, [fetchEffects]);
const visible = useMemo(
() => effects.filter((e) => filter === "all" || e.type === filter),
[effects, filter],
);
const importable = useMemo(
() => visible.filter((e) => !e.present || !e.nitroExists),
[visible],
);
const importedCount = useMemo(
() => visible.filter((e) => e.present && e.nitroExists).length,
[visible],
);
function toggle(lib: string) {
setSelected((prev) => {
const n = new Set(prev);
if (n.has(lib)) n.delete(lib);
else n.add(lib);
return n;
});
}
function markDone(lib: string) {
setEffects((prev) =>
prev.map((e) =>
e.lib === lib ? { ...e, present: true, nitroExists: true } : e,
),
);
}
async function importViaSse(items: EffectItem[]) {
batchAbortRef.current?.abort();
const abort = new AbortController();
batchAbortRef.current = abort;
setBatchProgress({ done: 0, total: items.length });
let done = 0;
try {
await runSseImport(
"/api/admin/import/effects/batch",
{ items, concurrency: 3 },
(lib) => {
done++;
markDone(lib);
setBatchProgress({ done, total: items.length });
},
(succeeded, failed) => {
setSelected(new Set());
if (succeeded > 0) toast.success(`${succeeded} effect(s) imported`);
if (failed > 0) toast.error(`${failed} failed`);
},
abort.signal,
() => {
done++;
setBatchProgress({ done, total: items.length });
},
);
} catch (err) {
if ((err as Error)?.name === "AbortError") {
toast.info("Import cancelled");
} else {
throw err;
}
} finally {
if (batchAbortRef.current === abort) batchAbortRef.current = null;
setBatchProgress(null);
}
}
async function importOne(e: EffectItem) {
setBusyLib(e.lib);
try {
await importViaSse([e]);
} finally {
setBusyLib(null);
}
}
async function importSelected() {
const items = visible.filter(
(e) => selected.has(e.lib) && (!e.present || !e.nitroExists),
);
if (items.length === 0) return;
await importViaSse(items);
}
async function remove(e: EffectItem) {
setBusyLib(e.lib);
try {
const res = await adminFetch(
`/api/admin/import/effects?lib=${encodeURIComponent(e.lib)}`,
{
method: "DELETE",
},
);
const data = await res.json();
if (res.ok) {
toast.success(`${e.lib} removed`);
setEffects((prev) =>
prev.map((x) =>
x.lib === e.lib ? { ...x, present: false, nitroExists: false } : x,
),
);
} else {
toast.error(data.error || "Delete failed");
}
} finally {
setBusyLib(null);
}
}
return (
<div className="space-y-4">
{/* Stats */}
{effects.length > 0 && (
<div className="flex items-center gap-3 flex-wrap">
<div className="flex items-center gap-1.5 text-sm">
<Package className="h-4 w-4 text-muted-foreground" />
<span className="font-medium">{visible.length}</span>
<span className="text-muted-foreground">effects</span>
</div>
<div className="flex items-center gap-1.5 text-sm">
<Check className="h-4 w-4 text-[var(--admin-success)]" />
<span className="font-medium text-[var(--admin-success)]">
{importedCount}
</span>
<span className="text-muted-foreground">imported</span>
</div>
<div className="flex items-center gap-1.5 text-sm">
<Download className="h-4 w-4 text-muted-foreground" />
<span className="font-medium">{importable.length}</span>
<span className="text-muted-foreground">available</span>
</div>
</div>
)}
{/* Search + filter */}
<div className="flex gap-2 flex-wrap">
<div className="relative flex-1 max-w-md">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Search effects by lib / id..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") fetchEffects(searchTerm);
if (e.key === "Escape") {
setSearchTerm("");
fetchEffects("");
}
}}
className="pl-9 pr-8"
/>
{searchTerm && (
<button
type="button"
onClick={() => {
setSearchTerm("");
fetchEffects("");
}}
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
>
<X className="h-3.5 w-3.5" />
</button>
)}
</div>
<Button
onClick={() => fetchEffects(searchTerm)}
size="sm"
disabled={loading}
>
{loading ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
<Search className="w-4 h-4" />
)}
</Button>
{(["all", "fx", "dance"] as Filter[]).map((f) => (
<Button
key={f}
size="sm"
variant={filter === f ? "default" : "outline"}
onClick={() => setFilter(f)}
>
{f.toUpperCase()}
</Button>
))}
</div>
{/* Bulk actions */}
{importable.length > 0 && (
<div className="flex items-center gap-2 flex-wrap">
<Button
variant="outline"
size="sm"
className="text-xs h-8"
disabled={!!batchProgress}
onClick={() =>
setSelected((prev) =>
prev.size === importable.length
? new Set()
: new Set(importable.map((e) => e.lib)),
)
}
>
{selected.size === importable.length
? "Deselect All"
: `Select All (${importable.length})`}
</Button>
{selected.size > 0 && (
<Button
size="sm"
className="h-8"
onClick={importSelected}
disabled={!!batchProgress}
>
{batchProgress ? (
<Loader2 className="w-4 h-4 animate-spin mr-1" />
) : (
<Download className="w-4 h-4 mr-1" />
)}
Import {selected.size} selected
</Button>
)}
{batchProgress && (
<>
<span className="text-xs text-muted-foreground">
{batchProgress.done}/{batchProgress.total}
</span>
<Button
size="sm"
variant="outline"
className="h-8"
onClick={() => batchAbortRef.current?.abort()}
>
Cancel
</Button>
</>
)}
</div>
)}
{/* Error */}
{error && (
<div className="rounded-lg border px-4 py-3 text-sm bg-destructive/10 border-destructive/20 text-destructive flex items-center gap-2">
<AlertCircle className="w-4 h-4 shrink-0" />
{error}
</div>
)}
{/* Empty */}
{!loading && visible.length === 0 && !error && (
<div className="text-center py-12 text-muted-foreground">
<Sparkles className="w-12 h-12 mx-auto mb-3 opacity-50" />
<p className="text-sm">No effects found</p>
</div>
)}
{/* Grid */}
{visible.length > 0 && (
<div
className={cn(
"grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 gap-3",
loading && "opacity-50",
)}
>
{visible.map((e) => {
const imported = e.present && e.nitroExists;
const isSelected = selected.has(e.lib);
return (
<div
key={e.lib}
className={cn(
"rounded-lg border overflow-hidden bg-card relative hover:border-border transition-all",
imported && "ring-1 ring-[var(--admin-success)]/30",
isSelected && "ring-2 ring-primary border-primary",
)}
>
{!imported && (
<button
type="button"
onClick={() => toggle(e.lib)}
className={cn(
"absolute top-1.5 left-1.5 z-10 w-5 h-5 rounded border-2 flex items-center justify-center transition-colors",
isSelected
? "bg-primary border-primary text-primary-foreground"
: "bg-background/80 border-muted-foreground/30 hover:border-muted-foreground",
)}
>
{isSelected && <Check className="w-3 h-3" />}
</button>
)}
<div
className={cn(
"flex flex-col items-center justify-center h-20 gap-1",
e.type === "dance"
? "bg-gradient-to-br from-[var(--admin-warning)]/25 to-[var(--admin-warning)]/10"
: "bg-gradient-to-br from-[var(--admin-accent)]/25 to-[var(--admin-accent)]/10",
)}
>
{e.type === "dance" ? (
<Music className="w-7 h-7 text-[var(--admin-warning)]/80" />
) : (
<Sparkles className="w-7 h-7 text-[var(--admin-accent-text)]" />
)}
<span className="text-[10px] font-semibold uppercase tracking-wide text-muted-foreground">
{e.type}
</span>
</div>
<div className="px-2.5 py-2 border-t space-y-1">
<div className="text-xs font-bold truncate">{e.lib}</div>
<div className="text-[10px] text-muted-foreground truncate">
id {e.id}
</div>
<div className="pt-1">
{imported ? (
<div className="flex items-center gap-1">
<Badge className="text-[10px] px-1.5 py-0 bg-[var(--admin-success-subtle)] text-[var(--admin-success)] border-0 gap-0.5 flex-1 justify-center">
<Check className="w-2.5 h-2.5" />
Imported
</Badge>
<Button
variant="ghost"
size="sm"
className="h-5 w-5 p-0 shrink-0"
title="Remove effect"
disabled={busyLib === e.lib || !!batchProgress}
onClick={() => remove(e)}
>
<Trash2 className="w-3 h-3" />
</Button>
</div>
) : (
<Button
size="sm"
variant="outline"
className="w-full text-xs h-7"
disabled={busyLib === e.lib || !!batchProgress}
onClick={() => importOne(e)}
>
{busyLib === e.lib ? (
<Loader2 className="w-3 h-3 animate-spin mr-1" />
) : (
<Download className="w-3 h-3 mr-1" />
)}
Import
</Button>
)}
</div>
</div>
</div>
);
})}
</div>
)}
</div>
);
}
@@ -0,0 +1,38 @@
// @vitest-environment node
import { describe, expect, it } from "vitest";
import * as hotelHelpers from "@/lib/habbo-gamedata-hotel";
describe("furni import source metadata", () => {
it("describes the configured hotel without changing the global asset CDN", () => {
const helpers = hotelHelpers as unknown as {
buildFurniImportSource?: (hotel: string) => {
hotel: string;
label: string;
host: string;
furnidataUrl: string;
assetCdnHost: string;
};
officialHabboEnrichmentWarning?: (hotel: string, name: string) => string;
};
expect(helpers.buildFurniImportSource).toBeTypeOf("function");
expect(helpers.officialHabboEnrichmentWarning).toBeTypeOf("function");
if (
!helpers.buildFurniImportSource ||
!helpers.officialHabboEnrichmentWarning
) {
return;
}
expect(helpers.buildFurniImportSource("nl")).toEqual({
hotel: "nl",
label: "Netherlands (habbo.nl)",
host: "habbo.nl",
furnidataUrl: "https://www.habbo.nl/gamedata/furnidata_json/1",
assetCdnHost: "images.habbo.com",
});
expect(helpers.officialHabboEnrichmentWarning("nl", "Stoel")).toBe(
'Enriched from habbo.nl: "Stoel"',
);
});
});
@@ -0,0 +1,921 @@
"use client";
import {
Box,
Check,
Compass,
Copy,
Eye,
Layers,
Loader2,
MousePointerClick,
Palette,
RotateCcw,
Ruler,
Save,
Sparkles,
} from "lucide-react";
import { useCallback, useEffect, useState } from "react";
import { toast } from "sonner";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Textarea } from "@/components/ui/textarea";
import { adminFetch } from "@/lib/admin-fetch";
import {
autoDetectInteraction,
getInteractionTypeLabel,
nitroAnimationStatesCount,
} from "@/lib/furni/auto-interaction";
interface NitroEditorDialogProps {
classname: string;
name?: string;
open: boolean;
onOpenChange: (open: boolean) => void;
}
interface NitroVisualization {
size?: number;
layers?: Record<string, { z?: number; alpha?: number; ink?: string }>;
colors?: Record<string, Record<string, { color: number }>>;
animations?: Record<string, unknown>;
}
// The .nitro metadata is untyped JSON from disk; typed only for the fields the
// editor reads/writes. Unknown keys are traversed via the path helpers below.
interface NitroMetadata {
visualizationType?: string;
logicType?: string;
logic?: {
model?: {
dimensions?: { x: number; y: number; z: number };
directions?: number[];
};
};
visualizations?: NitroVisualization[];
}
type JsonObject = Record<string, unknown>;
function updateNested<T extends object>(
obj: T,
path: string,
value: unknown,
): T {
const clone: T = JSON.parse(JSON.stringify(obj));
const keys = path.split(".");
let current: JsonObject = clone as JsonObject;
for (let i = 0; i < keys.length - 1; i++) {
const next = current[keys[i]];
if (!next || typeof next !== "object" || Array.isArray(next)) {
current[keys[i]] = {};
}
current = current[keys[i]] as JsonObject;
}
current[keys[keys.length - 1]] = value;
return clone;
}
function getNested<T>(obj: unknown, path: string, fallback: T): T {
const keys = path.split(".");
let current: unknown = obj;
for (const key of keys) {
if (current == null || typeof current !== "object") return fallback;
current = (current as JsonObject)[key];
}
return current === undefined ? fallback : (current as T);
}
const ALL_DIRECTIONS = [0, 1, 2, 3, 4, 5, 6, 7];
const DIR_LABELS = ["N", "NE", "E", "SE", "S", "SW", "W", "NW"];
function hexToColor(hex: string): string {
return `#${hex.padStart(6, "0")}`;
}
function CopyClassname({ value }: { value: string }) {
const [copied, setCopied] = useState(false);
return (
<button
type="button"
className="flex items-center gap-1.5 font-mono text-sm text-muted-foreground mt-0.5 hover:text-foreground transition-colors"
onClick={() => {
navigator.clipboard.writeText(value);
setCopied(true);
setTimeout(() => setCopied(false), 1500);
}}
>
<span className="truncate">{value}</span>
{copied ? (
<Check className="w-3 h-3 text-[var(--admin-success)] shrink-0" />
) : (
<Copy className="w-3 h-3 shrink-0" />
)}
</button>
);
}
export function NitroEditorDialog({
classname,
name = "",
open,
onOpenChange,
}: NitroEditorDialogProps) {
const [metadata, setMetadata] = useState<NitroMetadata | null>(null);
const [originalMetadata, setOriginalMetadata] =
useState<NitroMetadata | null>(null);
const [jsonText, setJsonText] = useState("");
const [loading, setLoading] = useState(false);
const [saving, setSaving] = useState(false);
const [activeTab, setActiveTab] = useState<string>("visual");
const [flags, setFlags] = useState({
canstandon: false,
cansiton: false,
canlayon: false,
});
const [originalFlags, setOriginalFlags] = useState({
canstandon: false,
cansiton: false,
canlayon: false,
});
const [autoDetectResult, setAutoDetectResult] = useState<ReturnType<
typeof autoDetectInteraction
> | null>(null);
const [jsonError, setJsonError] = useState<string | null>(null);
const handleAutoDetect = () => {
// Use real data when available: the DB flags for this item plus the
// .nitro animation states already loaded in the editor. Keyword
// detection only kicks in when neither is present — no false positives.
const nitroJson =
metadata && typeof metadata === "object"
? (metadata as Record<string, unknown>)
: null;
const result = autoDetectInteraction(classname, name, {
cansiton: flags.cansiton,
canlayon: flags.canlayon,
canstandon: flags.canstandon,
logicType: nitroJson?.logicType as string | undefined,
animationStates: nitroAnimationStatesCount(nitroJson),
});
setAutoDetectResult(result);
setFlags({
canstandon: result.canStand,
cansiton: result.canSit,
canlayon: result.canLay,
});
toast.success(`Auto-detected: ${result.reason}`);
};
const patchMeta = (path: string, value: unknown) => {
setMetadata((prev) =>
prev == null ? prev : updateNested(prev, path, value),
);
};
const fetchMetadata = useCallback(async () => {
if (!classname) return;
setLoading(true);
setJsonError(null);
try {
const res = await adminFetch(
`/api/admin/import/furni/nitro-editor?classname=${encodeURIComponent(classname)}`,
);
if (!res.ok) {
const err = await res.json().catch(() => ({}));
toast.error(err.error || "Failed to load .nitro metadata");
onOpenChange(false);
return;
}
const data = await res.json();
setMetadata(data.metadata);
setOriginalMetadata(JSON.parse(JSON.stringify(data.metadata)));
setJsonText(JSON.stringify(data.metadata, null, 2));
setFlags(data.flags);
setOriginalFlags({ ...data.flags });
} catch {
toast.error("Network error loading .nitro metadata");
onOpenChange(false);
} finally {
setLoading(false);
}
}, [classname, onOpenChange]);
useEffect(() => {
if (open && classname) {
setActiveTab("visual");
fetchMetadata();
}
}, [open, classname, fetchMetadata]);
const handleTabChange = (newTab: string) => {
if (activeTab === "json" && newTab === "visual") {
try {
const parsed = JSON.parse(jsonText);
setMetadata(parsed);
setJsonError(null);
} catch {
toast.error("Invalid JSON — fix it before switching to Visual tab");
return;
}
} else if (activeTab === "visual" && newTab === "json") {
setJsonText(JSON.stringify(metadata, null, 2));
}
setActiveTab(newTab);
};
const handleReset = () => {
if (!originalMetadata) return;
setMetadata(JSON.parse(JSON.stringify(originalMetadata)));
setFlags({ ...originalFlags });
setJsonText(JSON.stringify(originalMetadata, null, 2));
toast.info("Reset to original values");
};
const handleSave = async () => {
let metadataToSave = metadata;
if (activeTab === "json") {
try {
metadataToSave = JSON.parse(jsonText);
} catch {
toast.error("Invalid JSON");
return;
}
}
setSaving(true);
try {
const res = await adminFetch("/api/admin/import/furni/nitro-editor", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
classname,
metadata: metadataToSave,
...flags,
}),
});
if (!res.ok) {
const err = await res.json().catch(() => ({}));
toast.error(err.error || "Save failed");
return;
}
toast.success("Nitro metadata saved");
setMetadata(metadataToSave);
setOriginalMetadata(JSON.parse(JSON.stringify(metadataToSave)));
setOriginalFlags({ ...flags });
setJsonText(JSON.stringify(metadataToSave, null, 2));
} catch {
toast.error("Network error");
} finally {
setSaving(false);
}
};
// ── Visual tab helpers ──
const dims = metadata
? getNested(metadata, "logic.model.dimensions", { x: 1, y: 1, z: 0 })
: { x: 1, y: 1, z: 0 };
const directions = metadata
? getNested(metadata, "logic.model.directions", [0])
: [0];
const setDim = (axis: "x" | "y" | "z", value: string) => {
const num =
axis === "z" ? parseFloat(value) || 0 : parseInt(value, 10) || 0;
patchMeta(`logic.model.dimensions.${axis}`, num);
};
const toggleDirection = (dir: number) => {
const current = getNested(metadata, "logic.model.directions", [0]);
const next = current.includes(dir)
? current.filter((d: number) => d !== dir)
: [...current, dir].sort((a: number, b: number) => a - b);
if (next.length === 0) return;
patchMeta("logic.model.directions", next);
};
// Get visualization with size=64 for layer/color editing
const viz64 = metadata?.visualizations?.find((v) => v.size === 64);
const viz64Index =
metadata?.visualizations?.findIndex((v) => v.size === 64) ?? -1;
const layerCount = viz64?.layers ? Object.keys(viz64.layers).length : 0;
const colorCount = viz64?.colors ? Object.keys(viz64.colors).length : 0;
const animCount = viz64?.animations
? Object.keys(viz64.animations).length
: 0;
const handleJsonTextChange = (value: string) => {
setJsonText(value);
try {
JSON.parse(value);
setJsonError(null);
} catch (e) {
setJsonError((e as Error).message);
}
};
// Check if there are unsaved changes
const hasChanges =
metadata &&
originalMetadata &&
(JSON.stringify(metadata) !== JSON.stringify(originalMetadata) ||
JSON.stringify(flags) !== JSON.stringify(originalFlags));
// Furni preview URL
const starIdx = classname.indexOf("*");
const baseClassname =
starIdx !== -1 ? classname.substring(0, starIdx) : classname;
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-4xl max-h-[90vh] flex flex-col p-0 gap-0">
{/* Header with preview */}
<DialogHeader className="px-6 pt-5 pb-4 border-b bg-muted/30">
<div className="flex items-start gap-4">
{/* Furni preview */}
<div
className="w-20 h-20 rounded-lg border bg-card flex items-center justify-center shrink-0 overflow-hidden"
style={{
backgroundImage: "url(/images/furni_floor.png)",
backgroundRepeat: "repeat",
backgroundPosition: "center",
}}
>
<img
src={`/swf/dcr/hof_furni/icons/${baseClassname}_icon.png`}
alt={classname}
className="max-w-[60px] max-h-[60px] drop-shadow-md"
style={{ imageRendering: "pixelated" }}
onError={(e) => {
(e.target as HTMLImageElement).style.opacity = "0";
}}
/>
</div>
<div className="flex-1 min-w-0">
<DialogTitle className="text-lg">Edit Nitro</DialogTitle>
<CopyClassname value={classname} />
{/* Quick stats */}
{metadata && (
<div className="flex gap-2 mt-2 flex-wrap">
<Badge variant="outline" className="text-[10px] gap-1">
<Ruler className="w-2.5 h-2.5" />
{dims.x}×{dims.y}×{dims.z}
</Badge>
<Badge variant="outline" className="text-[10px] gap-1">
<Compass className="w-2.5 h-2.5" />
{directions.length} dir
</Badge>
<Badge variant="outline" className="text-[10px] gap-1">
<Layers className="w-2.5 h-2.5" />
{layerCount} layers
</Badge>
{colorCount > 0 && (
<Badge variant="outline" className="text-[10px] gap-1">
<Palette className="w-2.5 h-2.5" />
{colorCount} colors
</Badge>
)}
{animCount > 0 && (
<Badge variant="outline" className="text-[10px] gap-1">
<Sparkles className="w-2.5 h-2.5" />
{animCount} anim
</Badge>
)}
<Badge variant="outline" className="text-[10px] gap-1">
<Eye className="w-2.5 h-2.5" />
{metadata.visualizationType || "?"}
</Badge>
</div>
)}
</div>
</div>
</DialogHeader>
{loading ? (
<div className="flex items-center justify-center py-16">
<Loader2 className="w-6 h-6 animate-spin text-muted-foreground" />
</div>
) : metadata ? (
<Tabs
value={activeTab}
onValueChange={handleTabChange}
className="flex-1 flex flex-col min-h-0"
>
<div className="px-6 pt-3 pb-0">
<TabsList className="w-fit">
<TabsTrigger value="visual" className="gap-1.5">
<Box className="w-3.5 h-3.5" />
Visual
</TabsTrigger>
<TabsTrigger value="json" className="gap-1.5">
{"{ }"}
JSON
</TabsTrigger>
</TabsList>
</div>
{/* ── Visual Tab ── */}
<TabsContent
value="visual"
className="overflow-y-auto flex-1 px-6 py-4 space-y-5"
>
{/* Dimensions + Interaction in 2-column layout */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-5">
{/* Dimensions card */}
<section className="rounded-lg border bg-card p-4">
<div className="flex items-center gap-2 mb-3">
<Ruler className="w-4 h-4 text-muted-foreground" />
<h3 className="text-sm font-medium">Dimensions</h3>
</div>
<div className="grid grid-cols-3 gap-3">
<div>
<Label
htmlFor="dim-x"
className="text-xs text-muted-foreground"
>
X (width)
</Label>
<Input
id="dim-x"
type="number"
min={1}
value={dims.x}
onChange={(e) => setDim("x", e.target.value)}
className="mt-1"
/>
</div>
<div>
<Label
htmlFor="dim-y"
className="text-xs text-muted-foreground"
>
Y (depth)
</Label>
<Input
id="dim-y"
type="number"
min={1}
value={dims.y}
onChange={(e) => setDim("y", e.target.value)}
className="mt-1"
/>
</div>
<div>
<Label
htmlFor="dim-z"
className="text-xs text-muted-foreground"
>
Z (height)
</Label>
<Input
id="dim-z"
type="number"
step={0.01}
min={0}
value={dims.z}
onChange={(e) => setDim("z", e.target.value)}
className="mt-1"
/>
</div>
</div>
</section>
{/* Interaction card */}
<section className="rounded-lg border bg-card p-4">
<div className="flex items-center gap-2 mb-3">
<MousePointerClick className="w-4 h-4 text-muted-foreground" />
<h3 className="text-sm font-medium">Interaction</h3>
</div>
<div className="flex flex-col gap-3">
<div className="flex items-center justify-between rounded-md border px-3 py-2 hover:bg-muted/50 transition-colors">
<span className="text-sm">Can Stand On</span>
<Switch
checked={flags.canstandon}
onCheckedChange={(v) =>
setFlags((f) => ({ ...f, canstandon: !!v }))
}
/>
</div>
<div className="flex items-center justify-between rounded-md border px-3 py-2 hover:bg-muted/50 transition-colors">
<span className="text-sm">Can Sit On</span>
<Switch
checked={flags.cansiton}
onCheckedChange={(v) =>
setFlags((f) => ({ ...f, cansiton: !!v }))
}
/>
</div>
<div className="flex items-center justify-between rounded-md border px-3 py-2 hover:bg-muted/50 transition-colors">
<span className="text-sm">Can Lay On</span>
<Switch
checked={flags.canlayon}
onCheckedChange={(v) =>
setFlags((f) => ({ ...f, canlayon: !!v }))
}
/>
</div>
</div>
<div className="mt-4 flex flex-col gap-2">
<Button
variant="outline"
size="sm"
onClick={handleAutoDetect}
className="gap-1.5 w-full"
>
<Sparkles className="w-3.5 h-3.5" />
Auto-detect from name
</Button>
{autoDetectResult && (
<div className="rounded-md bg-muted/50 p-3 text-xs space-y-1">
<div className="flex items-center gap-1.5 flex-wrap">
<Badge
variant={
autoDetectResult.canLay ? "default" : "secondary"
}
className="gap-1"
>
{autoDetectResult.canLay ? (
<Layers className="w-2.5 h-2.5" />
) : autoDetectResult.canSit ? (
<MousePointerClick className="w-2.5 h-2.5" />
) : (
<MousePointerClick className="w-2.5 h-2.5" />
)}
{getInteractionTypeLabel(
autoDetectResult.interactionType,
)}
</Badge>
{autoDetectResult.clickLimit > 0 && (
<Badge variant="outline" className="gap-1">
Click limit: {autoDetectResult.clickLimit}
</Badge>
)}
{autoDetectResult.interactionModesCount > 0 && (
<Badge variant="outline" className="gap-1">
Modes: {autoDetectResult.interactionModesCount}
</Badge>
)}
</div>
<p className="text-muted-foreground">
{autoDetectResult.reason}
</p>
</div>
)}
</div>
</section>
</div>
{/* Directions - visual compass */}
<section className="rounded-lg border bg-card p-4">
<div className="flex items-center gap-2 mb-3">
<Compass className="w-4 h-4 text-muted-foreground" />
<h3 className="text-sm font-medium">Directions</h3>
<span className="text-xs text-muted-foreground ml-auto">
{directions.length} active
</span>
</div>
<div className="flex items-center justify-center">
<div className="relative w-48 h-48">
{/* Compass circle */}
<div className="absolute inset-0 rounded-full border-2 border-muted" />
<div className="absolute inset-4 rounded-full border border-muted/50" />
{/* Direction buttons arranged in circle */}
{ALL_DIRECTIONS.map((dir) => {
const angle = (dir * 45 - 90) * (Math.PI / 180);
const r = 80;
const x = 96 + r * Math.cos(angle) - 18;
const y = 96 + r * Math.sin(angle) - 18;
const isActive = directions.includes(dir);
return (
<button
type="button"
key={dir}
className={`absolute w-9 h-9 rounded-full border-2 flex flex-col items-center justify-center text-xs font-medium transition-all ${
isActive
? "bg-primary text-primary-foreground border-primary shadow-md scale-110"
: "bg-card text-muted-foreground border-muted hover:border-foreground/30 hover:scale-105"
}`}
style={{ left: x, top: y }}
onClick={() => toggleDirection(dir)}
>
<span className="text-[9px] leading-none">
{DIR_LABELS[dir]}
</span>
<span className="text-[8px] leading-none opacity-60">
{dir}
</span>
</button>
);
})}
{/* Center label */}
<div className="absolute inset-0 flex items-center justify-center">
<span className="text-xs text-muted-foreground font-medium">
{directions.length}/8
</span>
</div>
</div>
</div>
</section>
{/* Types */}
<section className="rounded-lg border bg-card p-4">
<div className="flex items-center gap-2 mb-3">
<Eye className="w-4 h-4 text-muted-foreground" />
<h3 className="text-sm font-medium">Types</h3>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<Label
htmlFor="viz-type"
className="text-xs text-muted-foreground"
>
Visualization Type
</Label>
<Input
id="viz-type"
value={metadata.visualizationType || ""}
onChange={(e) =>
patchMeta("visualizationType", e.target.value)
}
className="mt-1 font-mono text-sm"
/>
</div>
<div>
<Label
htmlFor="logic-type"
className="text-xs text-muted-foreground"
>
Logic Type
</Label>
<Input
id="logic-type"
value={metadata.logicType || ""}
onChange={(e) => patchMeta("logicType", e.target.value)}
className="mt-1 font-mono text-sm"
/>
</div>
</div>
</section>
{/* Layers */}
{viz64?.layers && layerCount > 0 && (
<section className="rounded-lg border bg-card p-4">
<div className="flex items-center gap-2 mb-3">
<Layers className="w-4 h-4 text-muted-foreground" />
<h3 className="text-sm font-medium">Layer</h3>
<Badge variant="secondary" className="text-[10px] ml-auto">
{layerCount}
</Badge>
</div>
<div className="rounded-md border overflow-hidden">
{/* Header */}
<div className="grid grid-cols-4 gap-2 items-center text-[10px] font-medium text-muted-foreground uppercase tracking-wider bg-muted/50 px-3 py-1.5">
<span>Layer</span>
<span>Z-Index</span>
<span>Alpha</span>
<span>Ink</span>
</div>
{Object.entries(viz64.layers).map(
([layerId, layerData], idx) => {
const layer = layerData as Record<string, unknown>;
return (
<div
key={layerId}
className={`grid grid-cols-4 gap-2 items-center text-sm px-3 py-1.5 ${
idx % 2 === 0 ? "bg-card" : "bg-muted/20"
}`}
>
<span className="font-mono text-xs">
<Badge
variant="outline"
className="text-[10px] px-1.5"
>
#{layerId}
</Badge>
</span>
<Input
type="number"
className="h-7 text-xs"
value={(layer.z as number) ?? 0}
onChange={(e) => {
patchMeta(
`visualizations.${viz64Index}.layers.${layerId}.z`,
parseInt(e.target.value, 10) || 0,
);
}}
/>
<Input
type="number"
className="h-7 text-xs"
min={0}
max={255}
value={(layer.alpha as number) ?? 255}
onChange={(e) => {
patchMeta(
`visualizations.${viz64Index}.layers.${layerId}.alpha`,
parseInt(e.target.value, 10) || 0,
);
}}
/>
<Input
className="h-7 text-xs font-mono"
value={(layer.ink as string) ?? ""}
placeholder="—"
onChange={(e) => {
patchMeta(
`visualizations.${viz64Index}.layers.${layerId}.ink`,
e.target.value || undefined,
);
}}
/>
</div>
);
},
)}
</div>
</section>
)}
{/* Colors */}
{viz64?.colors && colorCount > 0 && (
<section className="rounded-lg border bg-card p-4">
<div className="flex items-center gap-2 mb-3">
<Palette className="w-4 h-4 text-muted-foreground" />
<h3 className="text-sm font-medium">Colori</h3>
<Badge variant="secondary" className="text-[10px] ml-auto">
{colorCount} colors
</Badge>
</div>
<div className="space-y-3">
{Object.entries(viz64.colors).map(
([colorId, colorLayers]) => (
<div key={colorId} className="rounded-md border p-3">
<div className="flex items-center gap-2 mb-2">
<span className="text-xs font-medium">
Color Variant #{colorId}
</span>
{/* Color preview dots */}
<div className="flex gap-1 ml-auto">
{Object.values(
colorLayers as Record<
string,
{ color: number }
>,
).map((lc, i) => (
<div
// biome-ignore lint/suspicious/noArrayIndexKey: color preview dots, position is the identity
key={i}
className="w-4 h-4 rounded-full border border-border shadow-sm"
style={{
backgroundColor: hexToColor(
typeof lc.color === "number"
? lc.color.toString(16).padStart(6, "0")
: "000000",
),
}}
/>
))}
</div>
</div>
<div className="grid grid-cols-2 md:grid-cols-4 gap-2">
{Object.entries(
colorLayers as Record<string, { color: number }>,
).map(([layerId, layerColor]) => (
<div
key={layerId}
className="flex items-center gap-2"
>
<Label className="text-xs text-muted-foreground shrink-0 w-6">
L{layerId}
</Label>
<div className="relative flex-1">
<Input
className="h-7 text-xs font-mono pl-8"
value={
typeof layerColor.color === "number"
? layerColor.color
.toString(16)
.padStart(6, "0")
.toUpperCase()
: String(layerColor.color)
}
onChange={(e) => {
const hex = e.target.value
.replace(/[^0-9a-fA-F]/g, "")
.slice(0, 6);
const num = parseInt(hex, 16) || 0;
patchMeta(
`visualizations.${viz64Index}.colors.${colorId}.${layerId}.color`,
num,
);
}}
/>
{/* Color swatch inside input */}
<div
className="absolute left-1.5 top-1/2 -translate-y-1/2 w-4 h-4 rounded border border-border"
style={{
backgroundColor: hexToColor(
typeof layerColor.color === "number"
? layerColor.color
.toString(16)
.padStart(6, "0")
: "000000",
),
}}
/>
</div>
</div>
))}
</div>
</div>
),
)}
</div>
</section>
)}
</TabsContent>
{/* ── JSON Tab ── */}
<TabsContent
value="json"
className="flex-1 flex flex-col min-h-0 px-6 py-4"
>
<div className="relative flex-1 min-h-0">
<Textarea
className="h-full min-h-[400px] font-mono text-xs resize-none"
value={jsonText}
onChange={(e) => handleJsonTextChange(e.target.value)}
spellCheck={false}
/>
{jsonError && (
<div className="absolute bottom-2 left-2 right-2 rounded-md bg-destructive/10 border border-destructive/20 px-3 py-1.5 text-xs text-destructive">
{jsonError}
</div>
)}
</div>
</TabsContent>
</Tabs>
) : null}
<DialogFooter className="px-6 py-4 border-t bg-muted/30">
<div className="flex items-center gap-2 w-full">
{hasChanges && (
<Badge
variant="secondary"
className="text-[10px] gap-1 animate-[pulse_1s_ease-in-out_1]"
>
Unsaved changes
</Badge>
)}
<div className="flex-1" />
<Button
variant="ghost"
size="sm"
onClick={handleReset}
disabled={saving || loading || !metadata || !hasChanges}
className="gap-1.5"
>
<RotateCcw className="w-3.5 h-3.5" />
Reset
</Button>
<Button
variant="outline"
onClick={() => onOpenChange(false)}
disabled={saving}
>
Cancel
</Button>
<Button
onClick={handleSave}
disabled={saving || loading || !metadata}
className="gap-1.5"
>
{saving ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
<Save className="w-4 h-4" />
)}
Save
</Button>
</div>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,375 @@
"use client";
import { AlertCircle, Check, Loader2, Upload } from "lucide-react";
import { useRef, useState } from "react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { adminFetch } from "@/lib/admin-fetch";
const CLASSNAME_RE = /^[a-zA-Z0-9_]+$/;
const NITRO_MAX_BYTES = 50 * 1024 * 1024;
interface UploadResult {
itemId: number;
catalogItemId: number | null;
classname: string;
warnings: string[];
sqlFile?: string | null;
}
export function UploadFurniClient() {
const nitroRef = useRef<HTMLInputElement>(null);
const iconRef = useRef<HTMLInputElement>(null);
const [classname, setClassname] = useState("");
const [name, setName] = useState("");
const [description, setDescription] = useState("");
const [itemType, setItemType] = useState<"s" | "i">("s");
const [xdim, setXdim] = useState(1);
const [ydim, setYdim] = useState(1);
const [canstandon, setCanstandon] = useState(false);
const [cansiton, setCansiton] = useState(false);
const [canlayon, setCanlayon] = useState(false);
const [interactionType, setInteractionType] = useState("default");
const [customparams, setCustomparams] = useState("");
const [generateSql, setGenerateSql] = useState(false);
const [nitroFile, setNitroFile] = useState<File | null>(null);
const [iconFile, setIconFile] = useState<File | null>(null);
const [uploading, setUploading] = useState(false);
const [result, setResult] = useState<UploadResult | null>(null);
const [error, setError] = useState("");
function resetForm() {
setClassname("");
setName("");
setDescription("");
setItemType("s");
setXdim(1);
setYdim(1);
setCanstandon(false);
setCansiton(false);
setCanlayon(false);
setInteractionType("default");
setCustomparams("");
setGenerateSql(false);
setNitroFile(null);
setIconFile(null);
setResult(null);
setError("");
if (nitroRef.current) nitroRef.current.value = "";
if (iconRef.current) iconRef.current.value = "";
}
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setError("");
setResult(null);
const trimmedClass = classname.trim();
if (!CLASSNAME_RE.test(trimmedClass)) {
const msg =
"Classname must contain only letters, numbers, and underscores";
setError(msg);
toast.error(msg);
return;
}
if (!nitroFile) {
const msg = "Nitro file is required";
setError(msg);
toast.error(msg);
return;
}
if (nitroFile.size > NITRO_MAX_BYTES) {
const msg = "Nitro file must be 50MB or smaller";
setError(msg);
toast.error(msg);
return;
}
setUploading(true);
try {
const fd = new FormData();
fd.set("classname", trimmedClass);
fd.set("name", name.trim());
fd.set("description", description.trim());
fd.set("itemType", itemType);
fd.set("xdim", String(xdim));
fd.set("ydim", String(ydim));
fd.set("canstandon", String(canstandon));
fd.set("cansiton", String(cansiton));
fd.set("canlayon", String(canlayon));
fd.set("interactionType", interactionType.trim());
fd.set("customparams", customparams.trim());
if (generateSql) fd.set("generateSql", "true");
fd.set("nitro", nitroFile);
if (iconFile) fd.set("icon", iconFile);
const res = await adminFetch("/api/admin/import/furni/upload", {
method: "POST",
body: fd,
});
const data = await res.json();
if (!res.ok) {
setError(data.error || "Upload failed");
toast.error(data.error || "Upload failed");
return;
}
setResult(data);
toast.success(`${data.classname} imported successfully`);
} catch {
const msg = "Network error";
setError(msg);
toast.error(msg);
} finally {
setUploading(false);
}
}
return (
<div className="space-y-6">
{!result ? (
<form onSubmit={handleSubmit} className="space-y-6">
<div className="rounded-xl border bg-card p-4 sm:p-5 space-y-5">
<div className="text-sm font-medium">Furniture Details</div>
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-1.5">
<Label htmlFor="classname">Classname *</Label>
<Input
id="classname"
placeholder="e.g. rare_dragon"
value={classname}
onChange={(e) => setClassname(e.target.value)}
required
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="name">Name *</Label>
<Input
id="name"
placeholder="e.g. Dragon Egg"
value={name}
onChange={(e) => setName(e.target.value)}
required
/>
</div>
</div>
<div className="space-y-1.5">
<Label htmlFor="description">Description</Label>
<Textarea
id="description"
placeholder="Optional description"
value={description}
onChange={(e) => setDescription(e.target.value)}
rows={2}
/>
</div>
<div className="grid gap-4 sm:grid-cols-4">
<div className="space-y-1.5">
<Label htmlFor="itemType">Type *</Label>
<select
id="itemType"
value={itemType}
onChange={(e) => setItemType(e.target.value as "s" | "i")}
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
>
<option value="s">Floor (s)</option>
<option value="i">Wall (i)</option>
</select>
</div>
<div className="space-y-1.5">
<Label htmlFor="xdim">Width (x)</Label>
<Input
id="xdim"
type="number"
min={1}
max={64}
value={xdim}
onChange={(e) => setXdim(Number(e.target.value))}
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="ydim">Length (y)</Label>
<Input
id="ydim"
type="number"
min={1}
max={64}
value={ydim}
onChange={(e) => setYdim(Number(e.target.value))}
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="interactionType">Interaction</Label>
<Input
id="interactionType"
placeholder="default"
value={interactionType}
onChange={(e) => setInteractionType(e.target.value)}
/>
</div>
</div>
<div className="flex items-center gap-4 flex-wrap">
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={canstandon}
onChange={(e) => setCanstandon(e.target.checked)}
className="h-4 w-4 rounded border-input"
/>
<span className="text-sm">Can stand on</span>
</label>
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={cansiton}
onChange={(e) => setCansiton(e.target.checked)}
className="h-4 w-4 rounded border-input"
/>
<span className="text-sm">Can sit on</span>
</label>
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={canlayon}
onChange={(e) => setCanlayon(e.target.checked)}
className="h-4 w-4 rounded border-input"
/>
<span className="text-sm">Can lay on</span>
</label>
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={generateSql}
onChange={(e) => setGenerateSql(e.target.checked)}
className="h-4 w-4 rounded border-input"
/>
<span className="text-sm">Generate SQL</span>
</label>
</div>
<div className="space-y-1.5">
<Label htmlFor="customparams">Custom params</Label>
<Input
id="customparams"
placeholder="Optional custom parameters"
value={customparams}
onChange={(e) => setCustomparams(e.target.value)}
/>
</div>
</div>
<div className="rounded-xl border bg-card p-5 space-y-4">
<div className="text-sm font-medium">Files</div>
<div className="space-y-1.5">
<Label htmlFor="nitro">.nitro file *</Label>
<Input
ref={nitroRef}
id="nitro"
type="file"
accept=".nitro"
onChange={(e) => setNitroFile(e.target.files?.[0] ?? null)}
required
/>
<p className="text-xs text-muted-foreground">
The .nitro bundle file (max 50MB)
</p>
</div>
<div className="space-y-1.5">
<Label htmlFor="icon">Icon (PNG)</Label>
<Input
ref={iconRef}
id="icon"
type="file"
accept=".png,image/png"
onChange={(e) => setIconFile(e.target.files?.[0] ?? null)}
/>
<p className="text-xs text-muted-foreground">
Optional 64x64 PNG icon (max 2MB). Leave empty to skip.
</p>
</div>
</div>
{error && (
<div className="rounded-lg border px-4 py-3 text-sm bg-destructive/10 border-destructive/20 text-destructive flex items-center gap-2">
<AlertCircle className="w-4 h-4 shrink-0" />
{error}
</div>
)}
<div className="flex gap-3">
<Button type="submit" disabled={uploading}>
{uploading ? (
<Loader2 className="w-4 h-4 animate-spin mr-2" />
) : (
<Upload className="w-4 h-4 mr-2" />
)}
{uploading ? "Importing…" : "Import Furniture"}
</Button>
</div>
</form>
) : (
<div className="rounded-xl border bg-card p-6 space-y-4">
<div className="flex items-center gap-2">
<Check className="w-5 h-5 text-[var(--admin-success)]" />
<span className="text-lg font-semibold">Import Successful</span>
</div>
<div className="space-y-2 text-sm">
<div className="flex gap-2">
<span className="text-muted-foreground w-32">Classname:</span>
<code className="font-mono">{result.classname}</code>
</div>
<div className="flex gap-2">
<span className="text-muted-foreground w-32">Item ID:</span>
<code className="font-mono">{result.itemId}</code>
</div>
{result.catalogItemId && (
<div className="flex gap-2">
<span className="text-muted-foreground w-32">Catalog ID:</span>
<code className="font-mono">{result.catalogItemId}</code>
</div>
)}
{result.sqlFile ? (
<div className="flex gap-2">
<span className="text-muted-foreground w-32">SQL file:</span>
<code className="font-mono break-all">{result.sqlFile}</code>
</div>
) : null}
{result.warnings.length > 0 && (
<div className="space-y-1 pt-2">
<span className="text-muted-foreground font-medium">
Warnings:
</span>
{result.warnings.map((w) => (
<div
key={w}
className="flex items-center gap-1.5 text-[var(--admin-warning)] dark:text-[var(--admin-warning)]"
>
<AlertCircle className="w-3 h-3 shrink-0" />
<span>{w}</span>
</div>
))}
</div>
)}
</div>
<Button variant="outline" onClick={resetForm}>
Import Another
</Button>
</div>
)}
</div>
);
}
@@ -0,0 +1,390 @@
"use client";
import {
AlertCircle,
Check,
Download,
Loader2,
Package,
PawPrint,
Search,
Trash2,
X,
} from "lucide-react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { toast } from "sonner";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { adminFetch } from "@/lib/admin-fetch";
import { runSseImport } from "@/lib/sse-client";
import { cn } from "@/lib/utils";
interface PetItem {
lib: string;
nitroExists: boolean;
}
/** Real pet preview cropped from its .nitro; falls back to the paw glyph. */
function PetIcon({ lib }: { lib: string }) {
const [failed, setFailed] = useState(false);
if (failed) return <PawPrint className="w-8 h-8 text-muted-foreground/60" />;
return (
<img
src={`/api/admin/import/pets/icon?lib=${encodeURIComponent(lib)}&v=6`}
alt={lib}
className="image-pixelated"
style={{ maxHeight: 56, maxWidth: 56 }}
onError={() => setFailed(true)}
/>
);
}
export function ImportPetsClient() {
const [searchTerm, setSearchTerm] = useState("");
const [pets, setPets] = useState<PetItem[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
const [selected, setSelected] = useState<Set<string>>(new Set());
const [batchProgress, setBatchProgress] = useState<{
done: number;
total: number;
} | null>(null);
const [busyLib, setBusyLib] = useState<string | null>(null);
const batchAbortRef = useRef<AbortController | null>(null);
const fetchPets = useCallback(async (search: string) => {
setLoading(true);
setError("");
try {
const params = new URLSearchParams();
if (search) params.set("search", search);
const res = await adminFetch(`/api/admin/import/pets?${params}`);
const data = await res.json();
if (!res.ok) {
setError(data.error || "Failed to load");
return;
}
setPets(data.pets || []);
setSelected(new Set());
} catch {
setError("Network error");
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
fetchPets("");
}, [fetchPets]);
const importable = useMemo(() => pets.filter((p) => !p.nitroExists), [pets]);
const importedCount = useMemo(
() => pets.filter((p) => p.nitroExists).length,
[pets],
);
function toggle(lib: string) {
setSelected((prev) => {
const n = new Set(prev);
if (n.has(lib)) n.delete(lib);
else n.add(lib);
return n;
});
}
function markDone(lib: string) {
setPets((prev) =>
prev.map((p) => (p.lib === lib ? { ...p, nitroExists: true } : p)),
);
}
async function importViaSse(items: PetItem[]) {
batchAbortRef.current?.abort();
const abort = new AbortController();
batchAbortRef.current = abort;
setBatchProgress({ done: 0, total: items.length });
let done = 0;
try {
await runSseImport(
"/api/admin/import/pets/batch",
{ items, concurrency: 3 },
(lib) => {
done++;
markDone(lib);
setBatchProgress({ done, total: items.length });
},
(succeeded, failed) => {
setSelected(new Set());
if (succeeded > 0) toast.success(`${succeeded} pet(s) imported`);
if (failed > 0) toast.error(`${failed} failed`);
},
abort.signal,
() => {
done++;
setBatchProgress({ done, total: items.length });
},
);
} catch (err) {
if ((err as Error)?.name === "AbortError") {
toast.info("Import cancelled");
} else {
throw err;
}
} finally {
if (batchAbortRef.current === abort) batchAbortRef.current = null;
setBatchProgress(null);
}
}
async function importOne(p: PetItem) {
setBusyLib(p.lib);
try {
await importViaSse([p]);
} finally {
setBusyLib(null);
}
}
async function importSelected() {
const items = pets.filter((p) => selected.has(p.lib) && !p.nitroExists);
if (items.length === 0) return;
await importViaSse(items);
}
async function remove(p: PetItem) {
setBusyLib(p.lib);
try {
const res = await adminFetch(
`/api/admin/import/pets?lib=${encodeURIComponent(p.lib)}`,
{
method: "DELETE",
},
);
const data = await res.json();
if (res.ok) {
toast.success(`${p.lib} removed`);
setPets((prev) =>
prev.map((x) => (x.lib === p.lib ? { ...x, nitroExists: false } : x)),
);
} else {
toast.error(data.error || "Delete failed");
}
} finally {
setBusyLib(null);
}
}
return (
<div className="space-y-4">
{pets.length > 0 && (
<div className="flex items-center gap-3 flex-wrap">
<div className="flex items-center gap-1.5 text-sm">
<Package className="h-4 w-4 text-muted-foreground" />
<span className="font-medium">{pets.length}</span>
<span className="text-muted-foreground">pets</span>
</div>
<div className="flex items-center gap-1.5 text-sm">
<Check className="h-4 w-4 text-[var(--admin-success)]" />
<span className="font-medium text-[var(--admin-success)]">
{importedCount}
</span>
<span className="text-muted-foreground">imported</span>
</div>
<div className="flex items-center gap-1.5 text-sm">
<Download className="h-4 w-4 text-muted-foreground" />
<span className="font-medium">{importable.length}</span>
<span className="text-muted-foreground">missing</span>
</div>
</div>
)}
<div className="flex gap-2 flex-wrap">
<div className="relative flex-1 max-w-md">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Search pets..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") fetchPets(searchTerm);
if (e.key === "Escape") {
setSearchTerm("");
fetchPets("");
}
}}
className="pl-9 pr-8"
/>
{searchTerm && (
<button
type="button"
onClick={() => {
setSearchTerm("");
fetchPets("");
}}
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
>
<X className="h-3.5 w-3.5" />
</button>
)}
</div>
<Button
onClick={() => fetchPets(searchTerm)}
size="sm"
disabled={loading}
>
{loading ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
<Search className="w-4 h-4" />
)}
</Button>
</div>
{importable.length > 0 && (
<div className="flex items-center gap-2 flex-wrap">
<Button
variant="outline"
size="sm"
className="text-xs h-8"
disabled={!!batchProgress}
onClick={() =>
setSelected((prev) =>
prev.size === importable.length
? new Set()
: new Set(importable.map((p) => p.lib)),
)
}
>
{selected.size === importable.length
? "Deselect All"
: `Select All (${importable.length})`}
</Button>
{selected.size > 0 && (
<Button
size="sm"
className="h-8"
onClick={importSelected}
disabled={!!batchProgress}
>
{batchProgress ? (
<Loader2 className="w-4 h-4 animate-spin mr-1" />
) : (
<Download className="w-4 h-4 mr-1" />
)}
Import {selected.size} selected
</Button>
)}
{batchProgress && (
<>
<span className="text-xs text-muted-foreground">
{batchProgress.done}/{batchProgress.total}
</span>
<Button
size="sm"
variant="outline"
className="h-8"
onClick={() => batchAbortRef.current?.abort()}
>
Cancel
</Button>
</>
)}
</div>
)}
{error && (
<div className="rounded-lg border px-4 py-3 text-sm bg-destructive/10 border-destructive/20 text-destructive flex items-center gap-2">
<AlertCircle className="w-4 h-4 shrink-0" />
{error}
</div>
)}
{!loading && pets.length === 0 && !error && (
<div className="text-center py-12 text-muted-foreground">
<PawPrint className="w-12 h-12 mx-auto mb-3 opacity-50" />
<p className="text-sm">No pets found</p>
</div>
)}
{pets.length > 0 && (
<div
className={cn(
"grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 gap-3",
loading && "opacity-50",
)}
>
{pets.map((p) => {
const isSelected = selected.has(p.lib);
return (
<div
key={p.lib}
className={cn(
"rounded-lg border overflow-hidden bg-card relative hover:border-border transition-all",
p.nitroExists && "ring-1 ring-[var(--admin-success)]/30",
isSelected && "ring-2 ring-primary border-primary",
)}
>
{!p.nitroExists && (
<button
type="button"
onClick={() => toggle(p.lib)}
className={cn(
"absolute top-1.5 left-1.5 z-10 w-5 h-5 rounded border-2 flex items-center justify-center transition-colors",
isSelected
? "bg-primary border-primary text-primary-foreground"
: "bg-background/80 border-muted-foreground/30 hover:border-muted-foreground",
)}
>
{isSelected && <Check className="w-3 h-3" />}
</button>
)}
<div className="flex items-center justify-center h-20 bg-accent/20">
<PetIcon lib={p.lib} />
</div>
<div className="px-2.5 py-2 border-t space-y-1">
<div className="text-xs font-bold truncate">{p.lib}</div>
<div className="pt-1">
{p.nitroExists ? (
<div className="flex items-center gap-1">
<Badge className="text-[10px] px-1.5 py-0 bg-[var(--admin-success-subtle)] text-[var(--admin-success)] border-0 gap-0.5 flex-1 justify-center">
<Check className="w-2.5 h-2.5" />
Imported
</Badge>
<Button
variant="ghost"
size="sm"
className="h-5 w-5 p-0 shrink-0"
title="Remove pet"
disabled={busyLib === p.lib || !!batchProgress}
onClick={() => remove(p)}
>
<Trash2 className="w-3 h-3" />
</Button>
</div>
) : (
<Button
size="sm"
variant="outline"
className="w-full text-xs h-7"
disabled={busyLib === p.lib || !!batchProgress}
onClick={() => importOne(p)}
>
{busyLib === p.lib ? (
<Loader2 className="w-3 h-3 animate-spin mr-1" />
) : (
<Download className="w-3 h-3 mr-1" />
)}
Import
</Button>
)}
</div>
</div>
</div>
);
})}
</div>
)}
</div>
);
}
@@ -0,0 +1,242 @@
"use client";
import { Check, ImageIcon, Loader2, Wrench } from "lucide-react";
import { useCallback, useRef, useState } from "react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { adminFetch } from "@/lib/admin-fetch";
interface RepairEvent {
type: string;
classname?: string;
status?: string;
index?: number;
total?: number;
succeeded?: number;
failed?: number;
skipped?: number;
message?: string;
warnings?: string[];
}
export function RepairIconsClient() {
const [running, setRunning] = useState(false);
const [progress, setProgress] = useState<{
done: number;
total: number;
} | null>(null);
const [log, setLog] = useState<string[]>([]);
const [result, setResult] = useState<{
succeeded: number;
failed: number;
skipped: number;
} | null>(null);
const abortRef = useRef<AbortController | null>(null);
const startRepair = useCallback(async () => {
setRunning(true);
setProgress(null);
setLog([]);
setResult(null);
const abort = new AbortController();
abortRef.current = abort;
try {
const res = await adminFetch("/api/admin/import/furni/repair-icons", {
method: "POST",
signal: abort.signal,
});
if (!res.body) {
toast.error("No response stream");
setRunning(false);
return;
}
if (!res.headers.get("content-type")?.includes("text/event-stream")) {
let errMsg = "Repair failed";
try {
const json = await res.json();
errMsg = json.error || errMsg;
} catch {
// ignore
}
toast.error(errMsg);
setRunning(false);
return;
}
const reader = res.body.getReader();
const decoder = new TextDecoder();
let buf = "";
let total = 0;
let succeeded = 0;
let failed = 0;
let skipped = 0;
while (true) {
const { value, done: streamDone } = await reader.read();
if (streamDone) break;
buf += decoder.decode(value, { stream: true });
const parts = buf.split("\n\n");
buf = parts.pop() ?? "";
for (const part of parts) {
if (!part.startsWith("data: ")) continue;
try {
const evt = JSON.parse(part.slice(6)) as RepairEvent;
if (evt.type === "started") {
total = evt.total ?? 0;
setProgress({ done: 0, total });
setLog((prev) => [...prev, `Scanning ${total} furniture items…`]);
}
if (evt.type === "progress") {
const processed = (evt.index ?? 0) + 1;
setProgress({ done: processed, total });
if (evt.status === "skipped") {
skipped++;
} else if (evt.status === "done") {
succeeded++;
} else if (evt.status === "failed") {
failed++;
}
const icon =
evt.status === "done"
? "✓"
: evt.status === "failed"
? "✗"
: "–";
setLog((prev) => [
...prev,
`${icon} ${evt.classname ?? ""} (${evt.status})`,
]);
}
if (evt.type === "batch_complete") {
succeeded = evt.succeeded ?? succeeded;
failed = evt.failed ?? failed;
skipped = evt.skipped ?? skipped;
}
if (evt.type === "error") {
setLog((prev) => [
...prev,
`ERROR: ${evt.message ?? "Unknown error"}`,
]);
toast.error(evt.message ?? "Repair failed");
}
} catch {
// skip parse errors
}
}
}
setResult({ succeeded, failed, skipped });
if (succeeded > 0) toast.success(`${succeeded} icons downloaded`);
if (failed > 0) toast.error(`${failed} icons could not be obtained`);
} catch (err) {
if ((err as Error).name === "AbortError") {
toast.info("Repair cancelled");
} else {
toast.error("Repair request failed");
}
} finally {
setRunning(false);
abortRef.current = null;
}
}, []);
return (
<div className="space-y-4">
{!result && (
<div className="flex items-center gap-2 flex-wrap">
<Button onClick={startRepair} disabled={running} size="lg">
{running ? (
<Loader2 className="w-4 h-4 animate-spin mr-2" />
) : (
<Wrench className="w-4 h-4 mr-2" />
)}
{running
? `Repairing… ${progress ? `${progress.done}/${progress.total}` : ""}`
: "Start Icon Repair"}
</Button>
{running && (
<Button
variant="outline"
size="lg"
onClick={() => abortRef.current?.abort()}
>
Cancel
</Button>
)}
</div>
)}
{progress && (
<div className="text-sm text-muted-foreground">
Processed {progress.done} / {progress.total} items
</div>
)}
{log.length > 0 && (
<div className="rounded-xl border bg-card p-4 max-h-80 overflow-x-auto overflow-y-auto space-y-0.5">
{log.map((line) => (
<div
key={line}
className="text-xs font-mono text-muted-foreground whitespace-nowrap"
>
{line}
</div>
))}
</div>
)}
{result && (
<div className="rounded-xl border bg-card p-5 space-y-3">
<div className="flex items-center gap-2">
<Check className="w-5 h-5 text-[var(--admin-success)]" />
<span className="text-lg font-semibold">Repair Complete</span>
</div>
<div className="text-sm space-y-1">
<p>
<span className="text-muted-foreground">Downloads:</span>{" "}
{result.succeeded}
</p>
<p>
<span className="text-muted-foreground">
Skipped (already had icon):
</span>{" "}
{result.skipped}
</p>
<p>
<span className="text-muted-foreground">Failed:</span>{" "}
{result.failed}
</p>
</div>
<Button
variant="outline"
onClick={() => {
setResult(null);
setLog([]);
}}
>
Run Again
</Button>
</div>
)}
{!running && !result && (
<div className="rounded-xl border bg-card p-6 text-center text-muted-foreground text-sm">
<ImageIcon className="w-10 h-10 mx-auto mb-3 opacity-40" />
<p>
Scans all furniture in the database and downloads any missing icon
files. Icons are extracted from local .nitro bundles or downloaded
from configured clone sources.
</p>
</div>
)}
</div>
);
}
@@ -0,0 +1,213 @@
"use client";
import { Check, CloudDownload, Loader2 } from "lucide-react";
import { useCallback, useRef, useState } from "react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { adminFetch } from "@/lib/admin-fetch";
interface SyncEvent {
type: string;
classname?: string;
status?: string;
succeeded?: number;
failed?: number;
}
export function SyncAllClient() {
const [syncing, setSyncing] = useState(false);
const [progress, setProgress] = useState<{
done: number;
total: number;
} | null>(null);
const [log, setLog] = useState<string[]>([]);
const [result, setResult] = useState<{
succeeded: number;
failed: number;
sources: number;
} | null>(null);
const abortRef = useRef<AbortController | null>(null);
const startSync = useCallback(async () => {
setSyncing(true);
setProgress(null);
setLog([]);
setResult(null);
const abort = new AbortController();
abortRef.current = abort;
try {
const res = await adminFetch("/api/admin/import/clone/sync-all", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({}),
signal: abort.signal,
});
if (!res.body) {
toast.error("No response stream");
setSyncing(false);
return;
}
if (!res.headers.get("content-type")?.includes("text/event-stream")) {
let errMsg = "Sync failed";
try {
const json = await res.json();
errMsg = json.error || errMsg;
} catch {
// ignore
}
toast.error(errMsg);
setSyncing(false);
return;
}
const reader = res.body.getReader();
const decoder = new TextDecoder();
let buf = "";
let succeeded = 0;
let failed = 0;
let totalItems = 0;
while (true) {
const { value, done: streamDone } = await reader.read();
if (streamDone) break;
buf += decoder.decode(value, { stream: true });
const parts = buf.split("\n\n");
buf = parts.pop() ?? "";
for (const part of parts) {
if (!part.startsWith("data: ")) continue;
try {
const evt = JSON.parse(part.slice(6)) as SyncEvent;
if (
evt.type === "item_progress" &&
(evt.status === "done" || evt.status === "failed")
) {
totalItems++;
if (evt.status === "done") succeeded++;
else failed++;
setProgress({ done: succeeded + failed, total: totalItems });
setLog((prev) => [
...prev,
`${evt.status === "done" ? "✓" : "✗"} ${evt.classname ?? ""}`,
]);
}
if (evt.type === "batch_complete") {
succeeded = evt.succeeded ?? succeeded;
failed = evt.failed ?? failed;
}
} catch {
// skip parse errors
}
}
}
// Check for skipped state (all up to date)
try {
const parsed = JSON.parse(
buf.includes("data:") ? (buf.split("data:").pop() ?? "{}") : "{}",
);
if (parsed.skipped) {
toast.info(parsed.message || "All sources are up to date");
setResult({ succeeded: 0, failed: 0, sources: 0 });
setSyncing(false);
return;
}
} catch {
// not a skipped response
}
setResult({ succeeded, failed, sources: 1 });
if (succeeded > 0) toast.success(`${succeeded} items synced`);
if (failed > 0) toast.error(`${failed} items failed`);
} catch (err) {
if ((err as Error).name === "AbortError") {
toast.info("Sync cancelled");
} else {
toast.error("Sync request failed");
}
} finally {
setSyncing(false);
abortRef.current = null;
}
}, []);
return (
<div className="space-y-4">
{!result && (
<div className="flex items-center gap-2 flex-wrap">
<Button onClick={startSync} disabled={syncing} size="lg">
{syncing ? (
<Loader2 className="w-4 h-4 animate-spin mr-2" />
) : (
<CloudDownload className="w-4 h-4 mr-2" />
)}
{syncing
? `Syncing… ${progress ? `${progress.done}/${progress.total}` : ""}`
: "Start Sync All Sources"}
</Button>
{syncing && (
<Button
variant="outline"
size="lg"
onClick={() => abortRef.current?.abort()}
>
Cancel
</Button>
)}
</div>
)}
{progress && (
<div className="text-sm text-muted-foreground">
Processed {progress.done} items
{progress.total > 0 ? ` out of ${progress.total}` : ""}
</div>
)}
{log.length > 0 && (
<div className="rounded-xl border bg-card p-4 max-h-80 overflow-x-auto overflow-y-auto space-y-0.5">
{log.map((line) => (
<div
key={line}
className="text-xs font-mono text-muted-foreground whitespace-nowrap"
>
{line}
</div>
))}
</div>
)}
{result && result.sources > 0 && (
<div className="rounded-xl border bg-card p-5 space-y-3">
<div className="flex items-center gap-2">
<Check className="w-5 h-5 text-[var(--admin-success)]" />
<span className="text-lg font-semibold">Sync Complete</span>
</div>
<div className="text-sm space-y-1">
<p>
<span className="text-muted-foreground">Succeeded:</span>{" "}
{result.succeeded}
</p>
<p>
<span className="text-muted-foreground">Failed:</span>{" "}
{result.failed}
</p>
</div>
</div>
)}
{!syncing && !result && (
<div className="rounded-xl border bg-card p-6 text-center text-muted-foreground text-sm">
<CloudDownload className="w-10 h-10 mx-auto mb-3 opacity-40" />
<p>
Click the button above to scan all clone sources for missing
furniture and import it automatically.
</p>
</div>
)}
</div>
);
}
+197
View File
@@ -0,0 +1,197 @@
import { desc } from "drizzle-orm";
import { redirect } from "next/navigation";
import { getTranslations } from "next-intl/server";
import {
addBlacklist,
addWhitelist,
deleteBlacklist,
deleteWhitelist,
} from "@/actions/admin-ip";
import { StatusCard } from "@/components/admin/dashboard";
import { Button } from "@/components/ui/button";
import { db, WebsiteIpBlacklist, WebsiteIpWhitelist } from "@/lib/db";
import { formatDate } from "@/lib/format-date";
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
type IpRow = {
id: bigint;
ipAddress: string;
asn: string | null;
createdAt: Date | null;
};
export default async function AdminIp() {
const { session, permissions } = await getAdminContext();
if (!canAccess(permissions, PERMS.SETTINGS_VIEW, session.user.rank)) {
redirect("/admin");
}
const t = await getTranslations("pages.admin.ip");
let whitelist: IpRow[] = [];
let blacklist: IpRow[] = [];
try {
whitelist = await db
.select()
.from(WebsiteIpWhitelist)
.orderBy(desc(WebsiteIpWhitelist.id))
.limit(200);
} catch {
whitelist = [];
}
try {
blacklist = await db
.select()
.from(WebsiteIpBlacklist)
.orderBy(desc(WebsiteIpBlacklist.id))
.limit(200);
} catch {
blacklist = [];
}
return (
<main>
<div className="grid grid-cols-[repeat(auto-fit,minmax(180px,1fr))] gap-3.5 mb-6">
<StatusCard
label={t("whitelisted")}
value={whitelist.length}
state={whitelist.length > 0 ? "ok" : "neutral"}
icon="✅"
/>
<StatusCard
label={t("blacklisted")}
value={blacklist.length}
state={blacklist.length > 0 ? "danger" : "neutral"}
icon="🚫"
/>
</div>
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
{/* ── Whitelist ──────────────────── */}
<section className="mt-6">
<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={t("ipAddressPlaceholder")}
required
className="flex-1 min-w-[140px]"
/>
<input
name="asn"
placeholder={t("asnPlaceholder")}
className="w-[140px]"
/>
<Button type="submit" variant="secondary">
{t("add")}
</Button>
</div>
</form>
{whitelist.length === 0 ? (
<div className="admin-empty">{t("noWhitelistedIps")}</div>
) : (
<div className="overflow-x-auto">
<table>
<thead>
<tr>
<th>{t("colIp")}</th>
<th>{t("colAsn")}</th>
<th>{t("colAdded")}</th>
<th />
</tr>
</thead>
<tbody>
{whitelist.map((w) => (
<tr key={String(w.id)}>
<td>{w.ipAddress}</td>
<td className="text-sm theme-text-muted dark:theme-text-muted whitespace-nowrap">
{w.asn ?? "—"}
</td>
<td className="text-sm theme-text-muted dark:theme-text-muted whitespace-nowrap">
{formatDate(w.createdAt, "datetime", "—")}
</td>
<td>
<form action={deleteWhitelist}>
<input type="hidden" name="id" value={String(w.id)} />
<Button type="submit" variant="destructive">
{t("remove")}
</Button>
</form>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</section>
{/* ── Blacklist ──────────────────── */}
<section className="mt-6">
<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={t("ipAddressPlaceholder")}
required
className="flex-1 min-w-[140px]"
/>
<input
name="asn"
placeholder={t("asnPlaceholder")}
className="w-[140px]"
/>
<Button type="submit" variant="destructive">
{t("add")}
</Button>
</div>
</form>
{blacklist.length === 0 ? (
<div className="admin-empty">{t("noBlacklistedIps")}</div>
) : (
<div className="overflow-x-auto">
<table>
<thead>
<tr>
<th>{t("colIp")}</th>
<th>{t("colAsn")}</th>
<th>{t("colAdded")}</th>
<th />
</tr>
</thead>
<tbody>
{blacklist.map((b) => (
<tr key={String(b.id)}>
<td>{b.ipAddress}</td>
<td className="text-sm theme-text-muted dark:theme-text-muted whitespace-nowrap">
{b.asn ?? "—"}
</td>
<td className="text-sm theme-text-muted dark:theme-text-muted whitespace-nowrap">
{formatDate(b.createdAt, "datetime", "—")}
</td>
<td>
<form action={deleteBlacklist}>
<input type="hidden" name="id" value={String(b.id)} />
<Button type="submit" variant="destructive">
{t("remove")}
</Button>
</form>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</section>
</div>
</main>
);
}
@@ -0,0 +1,273 @@
"use client";
import { useRouter } from "next/navigation";
import { useTranslations } from "next-intl";
import { useState } from "react";
import { updateItemsBase } from "@/actions/items-base";
import Link from "@/components/link";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { useServerAction } from "@/hooks/use-server-action";
export type ItemsBaseEditValues = {
id: number;
spriteId: number;
publicName: string;
itemName: string;
type: string;
width: number;
length: number;
stackHeight: string;
allowStack: number;
allowSit: number;
allowLay: number;
allowWalk: number;
allowGift: number;
allowTrade: number;
allowRecycle: number;
allowMarketplaceSell: number;
allowInventoryStack: number;
interactionType: string;
interactionModesCount: number;
vendingIds: string;
multiheight: string;
customparams: string;
};
const ALLOW_FLAGS: Array<{ key: keyof ItemsBaseEditValues; label: string }> = [
{ key: "allowStack", label: "Stack" },
{ key: "allowSit", label: "Sit" },
{ key: "allowLay", label: "Lay" },
{ key: "allowWalk", label: "Walk" },
{ key: "allowGift", label: "Gift" },
{ key: "allowTrade", label: "Trade" },
{ key: "allowRecycle", label: "Recycle" },
{ key: "allowMarketplaceSell", label: "Marketplace" },
{ key: "allowInventoryStack", label: "Inv. stack" },
];
export function ItemsBaseEditForm({
initial,
canEdit,
}: {
initial: ItemsBaseEditValues;
canEdit: boolean;
}) {
const t = useTranslations("pages.admin.items");
const router = useRouter();
const { run, isPending } = useServerAction();
const [form, setForm] = useState(initial);
function setField<K extends keyof ItemsBaseEditValues>(
key: K,
value: ItemsBaseEditValues[K],
) {
setForm((prev) => ({ ...prev, [key]: value }));
}
function handleSave() {
if (!canEdit || isPending) return;
const { id, ...fields } = form;
run(() => updateItemsBase({ id, fields }), {
successMessage: t("saved"),
onSuccess: () => router.refresh(),
});
}
return (
<div className="space-y-6">
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<p className="text-sm text-muted-foreground m-0 font-mono">
#{form.id}
</p>
<h2 className="text-xl font-bold m-0">
{form.publicName || form.itemName || t("untitled")}
</h2>
</div>
<div className="flex gap-2">
<Button variant="outline" asChild>
<Link href="/admin/items">{t("back")}</Link>
</Button>
{canEdit ? (
<Button onClick={handleSave} disabled={isPending}>
{t("save")}
</Button>
) : null}
</div>
</div>
<div className="grid gap-4 md:grid-cols-2">
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm">{t("sectionIdentity")}</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<div>
<Label htmlFor="publicName">{t("publicName")}</Label>
<Input
id="publicName"
value={form.publicName}
disabled={!canEdit}
onChange={(e) => setField("publicName", e.target.value)}
/>
</div>
<div>
<Label htmlFor="itemName">{t("itemName")}</Label>
<Input
id="itemName"
value={form.itemName}
disabled={!canEdit}
className="font-mono"
onChange={(e) => setField("itemName", e.target.value)}
/>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<Label htmlFor="type">{t("type")}</Label>
<Input
id="type"
value={form.type}
disabled={!canEdit}
maxLength={3}
onChange={(e) => setField("type", e.target.value)}
/>
</div>
<div>
<Label htmlFor="spriteId">{t("spriteId")}</Label>
<Input
id="spriteId"
type="number"
value={form.spriteId}
disabled={!canEdit}
onChange={(e) => setField("spriteId", Number(e.target.value))}
/>
</div>
</div>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm">{t("sectionDims")}</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<div className="grid grid-cols-3 gap-3">
<div>
<Label htmlFor="width">{t("width")}</Label>
<Input
id="width"
type="number"
value={form.width}
disabled={!canEdit}
onChange={(e) => setField("width", Number(e.target.value))}
/>
</div>
<div>
<Label htmlFor="length">{t("length")}</Label>
<Input
id="length"
type="number"
value={form.length}
disabled={!canEdit}
onChange={(e) => setField("length", Number(e.target.value))}
/>
</div>
<div>
<Label htmlFor="stackHeight">{t("stackHeight")}</Label>
<Input
id="stackHeight"
value={form.stackHeight}
disabled={!canEdit}
onChange={(e) => setField("stackHeight", e.target.value)}
/>
</div>
</div>
<div>
<Label htmlFor="interactionType">{t("interactionType")}</Label>
<Input
id="interactionType"
value={form.interactionType}
disabled={!canEdit}
onChange={(e) => setField("interactionType", e.target.value)}
/>
</div>
<div>
<Label htmlFor="interactionModesCount">
{t("interactionModes")}
</Label>
<Input
id="interactionModesCount"
type="number"
value={form.interactionModesCount}
disabled={!canEdit}
onChange={(e) =>
setField("interactionModesCount", Number(e.target.value))
}
/>
</div>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm">{t("sectionFlags")}</CardTitle>
</CardHeader>
<CardContent className="grid grid-cols-2 gap-2 sm:grid-cols-3">
{ALLOW_FLAGS.map(({ key, label }) => (
<label
key={key}
className="flex items-center gap-2 text-sm rounded-md border px-2 py-1.5"
>
<input
type="checkbox"
checked={Number(form[key]) === 1}
disabled={!canEdit}
onChange={(e) => setField(key, e.target.checked ? 1 : 0)}
/>
{label}
</label>
))}
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm">{t("sectionExtra")}</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<div>
<Label htmlFor="vendingIds">{t("vendingIds")}</Label>
<Input
id="vendingIds"
value={form.vendingIds}
disabled={!canEdit}
onChange={(e) => setField("vendingIds", e.target.value)}
/>
</div>
<div>
<Label htmlFor="multiheight">{t("multiheight")}</Label>
<Input
id="multiheight"
value={form.multiheight}
disabled={!canEdit}
onChange={(e) => setField("multiheight", e.target.value)}
/>
</div>
<div>
<Label htmlFor="customparams">{t("customparams")}</Label>
<Input
id="customparams"
value={form.customparams}
disabled={!canEdit}
onChange={(e) => setField("customparams", e.target.value)}
/>
</div>
</CardContent>
</Card>
</div>
</div>
);
}
+69
View File
@@ -0,0 +1,69 @@
import { eq } from "drizzle-orm";
import { Package } from "lucide-react";
import { notFound, redirect } from "next/navigation";
import { getTranslations } from "next-intl/server";
import { AdminPageShell } from "@/components/admin/admin-page-shell";
import { db, ItemsBase } from "@/lib/db";
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
import { ItemsBaseEditForm } from "./items-base-edit-form";
export default async function ItemsBaseDetailPage({
params,
}: {
params: Promise<{ id: string }>;
}) {
const { session, permissions } = await getAdminContext();
if (!canAccess(permissions, PERMS.CATALOG_VIEW, session.user.rank)) {
redirect("/admin");
}
const t = await getTranslations("pages.admin.items");
const { id: rawId } = await params;
const id = Number(rawId);
if (!Number.isFinite(id) || id <= 0) notFound();
const [item] = await db
.select()
.from(ItemsBase)
.where(eq(ItemsBase.id, id))
.limit(1);
if (!item) notFound();
const canEdit = canAccess(permissions, PERMS.CATALOG_EDIT, session.user.rank);
return (
<AdminPageShell
icon={Package}
title={t("editTitle", { name: item.publicName || item.itemName })}
subtitle={t("editSubtitle", { id: item.id })}
>
<ItemsBaseEditForm
canEdit={canEdit}
initial={{
id: item.id,
spriteId: item.spriteId,
publicName: item.publicName,
itemName: item.itemName,
type: item.type,
width: item.width,
length: item.length,
stackHeight: String(item.stackHeight),
allowStack: item.allowStack,
allowSit: item.allowSit,
allowLay: item.allowLay,
allowWalk: item.allowWalk,
allowGift: item.allowGift,
allowTrade: item.allowTrade,
allowRecycle: item.allowRecycle,
allowMarketplaceSell: item.allowMarketplaceSell,
allowInventoryStack: item.allowInventoryStack,
interactionType: item.interactionType,
interactionModesCount: item.interactionModesCount,
vendingIds: item.vendingIds,
multiheight: item.multiheight,
customparams: item.customparams,
}}
/>
</AdminPageShell>
);
}
+118
View File
@@ -0,0 +1,118 @@
"use client";
import { Eye, Package, Pencil } from "lucide-react";
import { DataTable } from "@/components/admin/data-table";
import Link from "@/components/link";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import type { DataTableColumn, PaginatedResult } from "@/types/common";
export interface ItemsBaseRow {
id: number;
spriteId: number;
publicName: string;
itemName: string;
type: string;
interactionType: string;
width: number;
length: number;
}
function getColumns(canEdit: boolean): DataTableColumn<ItemsBaseRow>[] {
return [
{ key: "id", label: "ID", sortable: true },
{
key: "publicName",
label: "Name",
sortable: true,
render: (_, row) => (
<Link
href={`/admin/items/${row.id}`}
className="hover:opacity-80 flex items-center gap-2 min-w-0"
>
<Package className="h-4 w-4 shrink-0 text-muted-foreground" />
<div className="min-w-0">
<span className="font-medium block truncate">
{row.publicName || "—"}
</span>
<span className="text-xs text-muted-foreground font-mono truncate block">
{row.itemName}
</span>
</div>
</Link>
),
},
{
key: "spriteId",
label: "Sprite",
sortable: true,
render: (v) => <span className="tabular-nums text-sm">{String(v)}</span>,
},
{
key: "type",
label: "Type",
sortable: true,
filterKey: "filter_type",
filterOptions: [
{ label: "Floor (s)", value: "s" },
{ label: "Wall (i)", value: "i" },
{ label: "Effect (e)", value: "e" },
{ label: "Badge (b)", value: "b" },
{ label: "Robot (r)", value: "r" },
],
render: (v) => <Badge variant="outline">{String(v)}</Badge>,
},
{
key: "interactionType",
label: "Interaction",
sortable: true,
render: (v) => (
<span className="text-xs font-mono text-muted-foreground">
{String(v || "default")}
</span>
),
},
{
key: "width",
label: "Size",
render: (_, row) => (
<span className="tabular-nums text-sm">
{row.width}×{row.length}
</span>
),
},
{
key: "actions",
label: "",
render: (_, row) => (
<div className="flex gap-1 justify-end">
<Button variant="ghost" size="icon" asChild>
<Link href={`/admin/items/${row.id}`} aria-label="View">
{canEdit ? (
<Pencil className="h-4 w-4" />
) : (
<Eye className="h-4 w-4" />
)}
</Link>
</Button>
</div>
),
},
];
}
export function ItemsTable({
data,
canEdit,
}: {
data: PaginatedResult<ItemsBaseRow>;
canEdit: boolean;
}) {
return (
<DataTable
columns={getColumns(canEdit)}
data={data}
searchPlaceholder="Search name, classname or ID…"
/>
);
}
+99
View File
@@ -0,0 +1,99 @@
import { and, count, desc, eq, like, or } from "drizzle-orm";
import { Package } from "lucide-react";
import { getTranslations } from "next-intl/server";
import { AdminPageShell } from "@/components/admin/admin-page-shell";
import { fetchAdminList } from "@/lib/admin-list";
import { db, ItemsBase } from "@/lib/db";
import { canAccess, PERMS } from "@/lib/permissions";
import { type ItemsBaseRow, ItemsTable } from "./items-table";
export default async function ItemsBasePage({
params: paramsPromise,
searchParams,
}: {
params: Promise<{ locale: string }>;
searchParams: Promise<Record<string, string>>;
}) {
const result = await fetchAdminList<ItemsBaseRow>(
{
permission: PERMS.CATALOG_VIEW,
fetch: async ({ search, page, perPage, rawParams }) => {
const conditions = [];
if (search) {
const ors = [
like(ItemsBase.itemName, `%${search}%`),
like(ItemsBase.publicName, `%${search}%`),
];
const asId = Number(search);
if (Number.isFinite(asId) && asId > 0) {
ors.push(eq(ItemsBase.id, asId));
}
conditions.push(or(...ors));
}
if (rawParams.filter_type) {
conditions.push(eq(ItemsBase.type, rawParams.filter_type));
}
const where = conditions.length > 0 ? and(...conditions) : undefined;
const [rows, totals] = await Promise.all([
db
.select({
id: ItemsBase.id,
spriteId: ItemsBase.spriteId,
publicName: ItemsBase.publicName,
itemName: ItemsBase.itemName,
type: ItemsBase.type,
interactionType: ItemsBase.interactionType,
width: ItemsBase.width,
length: ItemsBase.length,
})
.from(ItemsBase)
.where(where)
.orderBy(desc(ItemsBase.id))
.limit(perPage)
.offset((page - 1) * perPage),
db.select({ total: count() }).from(ItemsBase).where(where),
]);
return {
rows: rows.map((r) => ({
id: r.id,
spriteId: r.spriteId,
publicName: r.publicName || "",
itemName: r.itemName || "",
type: r.type || "s",
interactionType: r.interactionType || "default",
width: r.width,
length: r.length,
})),
total: totals[0]?.total ?? 0,
};
},
},
paramsPromise,
searchParams,
);
const t = await getTranslations("pages.admin.items");
const canEdit = canAccess(
result.permissions,
PERMS.CATALOG_EDIT,
result.rank,
);
return (
<AdminPageShell icon={Package} title={t("title")} subtitle={t("subtitle")}>
<p className="text-muted-foreground m-0">
{t("total", { count: result.total })}
</p>
<ItemsTable
data={{
rows: result.rows,
total: result.total,
page: result.page,
perPage: result.perPage,
lastPage: result.lastPage,
}}
canEdit={canEdit}
/>
</AdminPageShell>
);
}
+137
View File
@@ -0,0 +1,137 @@
import { eq } from "drizzle-orm";
import { LogOut } from "lucide-react";
import { redirect } from "next/navigation";
import { getTranslations } from "next-intl/server";
import type { ReactNode } from "react";
import { AdminHubChrome } from "@/components/admin/admin-hub-chrome";
import { AdminMobileWrapper } from "@/components/admin/admin-mobile-wrapper";
import { AdminSidebarNav } from "@/components/admin/admin-sidebar-nav";
import { AdminTopbar } from "@/components/admin/admin-topbar";
import { LanguageSwitcher } from "@/components/language-switcher";
import Link from "@/components/link";
import { ThemeSwitcher } from "@/components/theme-switcher";
import { requireStaff } from "@/lib/admin/guard";
import { collectNavPermissionSlugs } from "@/lib/admin-nav";
import {
ADMIN_NAV_CONFIG_KEY,
parseAdminNavConfig,
} from "@/lib/admin-nav-config";
import { db, User } from "@/lib/db";
import { readCsrfCookieToken } from "@/lib/foundation/security";
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
import { siteSettings } from "@/lib/services/site-settings";
// Request-time auth, existing CSRF cookie, and optional 2FA gate cannot be
// statically rendered. The root layout's Cache Components opt-out
// (instant = false) covers this route tree — leaf opt-outs are redundant.
export default async function AdminLayout({
children,
}: {
children: ReactNode;
}) {
const staff = await requireStaff();
const csrfToken = await readCsrfCookieToken();
if (await siteSettings.getBool("force_staff_2fa", false)) {
const u = await db
.select({ twoFactorConfirmedAt: User.twoFactorConfirmedAt })
.from(User)
.where(eq(User.id, staff.id))
.limit(1)
.then((rows) => rows[0] ?? null)
.catch(() => null);
if (!u?.twoFactorConfirmedAt) redirect("/settings/2fa?error=staffrequired");
}
return (
<>
{csrfToken ? <meta name="csrf-token" content={csrfToken} /> : null}
<AdminMobileWrapper sidebar={<Sidebar staff={staff} />}>
<div
data-admin
className="flex flex-col min-w-0 p-5 lg:p-6 min-h-screen"
style={{ backgroundColor: "var(--admin-canvas)" }}
>
<AdminTopbar staff={staff} />
<section className="admin-page flex-1">
<AdminHubChrome>{children}</AdminHubChrome>
</section>
</div>
</AdminMobileWrapper>
</>
);
}
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 { permissions } = await getAdminContext();
const isSuperAdmin = permissions.isSuperAdmin;
const hasDashboard = canAccess(
permissions,
PERMS.ADMIN_DASHBOARD,
staff.rank,
);
// Super-admins see everything. Everyone else sees items their ACL grants.
// If they can open the admin panel but ACL is incomplete (common after HK→ACL
// migration gaps), still show *.view nav entries so categories do not vanish.
const allowedPermissions = isSuperAdmin
? []
: collectNavPermissionSlugs().filter((slug) => {
if (canAccess(permissions, slug, staff.rank)) return true;
if (!hasDashboard) return false;
return slug.endsWith(".view") || slug === PERMS.ADMIN_DASHBOARD;
});
const navConfig = parseAdminNavConfig(
await siteSettings.get(ADMIN_NAV_CONFIG_KEY, ""),
);
return (
<aside
data-admin
className="max-lg:static sticky top-0 flex h-full max-h-screen min-h-0 w-full flex-col self-start overflow-hidden bg-[var(--admin-sidebar-background)] text-[var(--admin-sidebar-text-readable)] shadow-xl lg:h-screen"
>
<div className="flex shrink-0 items-center gap-3 border-b border-[var(--admin-border)] px-4 py-5">
<span
className="flex-none w-10 h-10 rounded-xl grid place-items-center font-extrabold text-base text-[var(--admin-accent-foreground)] bg-[var(--admin-accent)] shadow-lg shadow-[var(--admin-accent)]/20"
aria-hidden
>
{initial}
</span>
<div className="min-w-0 flex-1">
<div className="font-semibold text-sm text-[var(--admin-sidebar-text-readable)] truncate">
{staff.username}
</div>
<span className="inline-block mt-0.5 text-[0.6rem] font-bold uppercase tracking-widest text-[var(--admin-sidebar-muted-readable)]">
{t("rankLabel", { rank: staff.rank })}
</span>
</div>
<div className="flex items-center gap-1.5 shrink-0">
<LanguageSwitcher variant="admin" />
<ThemeSwitcher variant="admin" />
</div>
</div>
<AdminSidebarNav
allowedPermissions={allowedPermissions}
isSuperAdmin={isSuperAdmin}
navConfig={navConfig}
/>
<div className="shrink-0 border-t border-[var(--admin-border)] px-3 py-3">
<Link
href="/"
className="flex items-center gap-2.5 px-2.5 py-2 rounded-lg text-[var(--admin-sidebar-muted-readable)] text-xs font-medium hover:text-[var(--admin-sidebar-text-readable)] hover:bg-[var(--admin-accent)]/10 transition-all duration-150 no-underline"
>
<LogOut size={14} />
<span>{t("backToSite")}</span>
</Link>
</div>
</aside>
);
}
+15
View File
@@ -0,0 +1,15 @@
export default function AdminLoading() {
return (
<div
style={{ display: "flex", justifyContent: "center", padding: "4rem 0" }}
>
<div
className="animate-spin w-8 h-8 border-4 rounded-full"
style={{
borderColor: "var(--admin-accent)",
borderTopColor: "transparent",
}}
/>
</div>
);
}
Loaded 100 of 802 files, more files were not shown because too many files have changed in this diff. Show more