style: format code biome
Local Build and Deploy / deploy (push) Failing after 46s

This commit is contained in:
openhands committed 2026-07-13 21:57:41 +02:00
1 parent 8efd032cc6
commit df38dccbf1
735 files changed
+128321 -120870

No files matched your search

+107 -94
View File
@@ -6,107 +6,120 @@ import { prisma } from "@/lib/prisma";
export const dynamic = "force-dynamic";
type Achievement = {
name: string;
category: string;
level: number;
rewardAmount: number;
rewardType: number;
points: number | null;
progressNeeded: number;
name: string;
category: string;
level: number;
rewardAmount: number;
rewardType: number;
points: number | null;
progressNeeded: number;
};
export default async function AdminAchievements() {
const t = await getTranslations("pages.admin.achievements");
const t = await getTranslations("pages.admin.achievements");
let achievements: Achievement[];
try {
achievements = await prisma.achievements.findMany({
select: {
name: true,
category: true,
level: true,
rewardAmount: true,
rewardType: true,
points: true,
progressNeeded: true,
},
orderBy: [{ category: "asc" }, { name: "asc" }, { level: "asc" }],
});
} catch {
achievements = [];
}
let achievements: Achievement[];
try {
achievements = await prisma.achievements.findMany({
select: {
name: true,
category: true,
level: true,
rewardAmount: true,
rewardType: true,
points: true,
progressNeeded: true,
},
orderBy: [{ category: "asc" }, { name: "asc" }, { level: "asc" }],
});
} 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 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;
const distinctNames = new Set(achievements.map((a) => a.name)).size;
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">
<Award size={20} className="text-[var(--admin-accent)]" />
</div>
<div>
<h1 className="m-0 text-xl font-extrabold text-[var(--admin-text)]">{t("title")}</h1>
<p className="m-0 text-xs text-[var(--admin-text-muted)] mt-0.5">
{t("subtitle", { rows: achievements.length, categories: groups.size })}
</p>
</div>
</div>
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">
<Award size={20} className="text-[var(--admin-accent)]" />
</div>
<div>
<h1 className="m-0 text-xl font-extrabold text-[var(--admin-text)]">
{t("title")}
</h1>
<p className="m-0 text-xs text-[var(--admin-text-muted)] mt-0.5">
{t("subtitle", {
rows: achievements.length,
categories: groups.size,
})}
</p>
</div>
</div>
<div className="grid grid-cols-[repeat(auto-fit,minmax(180px,1fr))] gap-3.5 mb-6">
<StatusCard label={t("achievementRows")} value={achievements.length} icon="🏆" />
<StatusCard label={t("distinctAchievements")} value={distinctNames} icon="🎖️" />
<StatusCard label={t("categories")} value={groups.size} icon="🗂️" />
</div>
<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>
);
{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>
);
}
+50 -38
View File
@@ -1,47 +1,59 @@
import Link from "next/link";
import { Megaphone } from "lucide-react";
import Link from "next/link";
import { notFound } from "next/navigation";
import { deleteAd, updateAd } from "@/actions/admin-ads";
import { prisma } from "@/lib/prisma";
export const dynamic = "force-dynamic";
export default async function EditAd({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
let ad: Awaited<ReturnType<typeof prisma.websiteAds.findUnique>> = null;
try {
ad = await prisma.websiteAds.findUnique({ where: { id: BigInt(id) } });
} catch {
notFound();
}
if (!ad) notFound();
export default async function EditAd({
params,
}: {
params: Promise<{ id: string }>;
}) {
const { id } = await params;
let ad: Awaited<ReturnType<typeof prisma.websiteAds.findUnique>> = null;
try {
ad = await prisma.websiteAds.findUnique({ where: { id: BigInt(id) } });
} catch {
notFound();
}
if (!ad) 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">
<Megaphone size={20} className="text-[var(--admin-accent)]" />
</div>
<div>
<h1 className="m-0 text-xl font-extrabold text-[var(--admin-text)]">Edit 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={updateAd} className="admin-card">
<input type="hidden" name="id" value={String(ad.id)} />
<input name="image" defaultValue={ad.image} placeholder="Image URL" required maxLength={255} />
<button type="submit" className="btn btn-primary">
Save
</button>
</form>
<form action={deleteAd} style={{ marginTop: "1rem" }}>
<input type="hidden" name="id" value={String(ad.id)} />
<button type="submit" className="btn btn-danger">
Delete advertisement
</button>
</form>
</main>
);
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)]">
Edit 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={updateAd} className="admin-card">
<input type="hidden" name="id" value={String(ad.id)} />
<input
name="image"
defaultValue={ad.image}
placeholder="Image URL"
required
maxLength={255}
/>
<button type="submit" className="btn btn-primary">
Save
</button>
</form>
<form action={deleteAd} style={{ marginTop: "1rem" }}>
<input type="hidden" name="id" value={String(ad.id)} />
<button type="submit" className="btn btn-danger">
Delete advertisement
</button>
</form>
</main>
);
}
+24 -22
View File
@@ -1,27 +1,29 @@
import Link from "next/link";
import { Megaphone } from "lucide-react";
import Link from "next/link";
import { createAd } from "@/actions/admin-ads";
export default function NewAd() {
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" className="btn btn-primary">
Create
</button>
</form>
</main>
);
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" className="btn btn-primary">
Create
</button>
</form>
</main>
);
}
+85 -68
View File
@@ -1,81 +1,98 @@
import { Megaphone } from "lucide-react";
import Link from "next/link";
import { getTranslations } from "next-intl/server";
import { Megaphone } from "lucide-react";
import { StatusCard } from "@/components/admin/dashboard";
import { prisma } from "@/lib/prisma";
export const dynamic = "force-dynamic";
export default async function AdminAds() {
const t = await getTranslations("pages.admin.ads");
let ads: Awaited<ReturnType<typeof prisma.websiteAds.findMany>>;
try {
ads = await prisma.websiteAds.findMany({
orderBy: { id: "desc" },
take: 500,
});
} catch {
ads = [];
}
const t = await getTranslations("pages.admin.ads");
let ads: Awaited<ReturnType<typeof prisma.websiteAds.findMany>>;
try {
ads = await prisma.websiteAds.findMany({
orderBy: { id: "desc" },
take: 500,
});
} catch {
ads = [];
}
const latest = ads[0]?.createdAt ? ads[0].createdAt.toISOString().slice(0, 10) : "—";
const latest = ads[0]?.createdAt
? ads[0].createdAt.toISOString().slice(0, 10)
: "—";
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 className="flex-1">
<h1 className="m-0 text-xl font-extrabold text-[var(--admin-text)]">{t("title")}</h1>
<p className="m-0 text-xs text-[var(--admin-text-muted)] mt-0.5">{t("subtitle")}</p>
</div>
<Link href="/admin/ads/new" className="btn btn-primary">
+ {t("newAd")}
</Link>
</div>
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 className="flex-1">
<h1 className="m-0 text-xl font-extrabold text-[var(--admin-text)]">
{t("title")}
</h1>
<p className="m-0 text-xs text-[var(--admin-text-muted)] mt-0.5">
{t("subtitle")}
</p>
</div>
<Link href="/admin/ads/new" className="btn btn-primary">
+ {t("newAd")}
</Link>
</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>
<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>
) : (
<table>
<thead>
<tr>
<th>{t("colPreview")}</th>
<th>{t("colImage")}</th>
<th>{t("colCreated")}</th>
<th />
</tr>
</thead>
<tbody>
{ads.map((ad) => (
<tr key={String(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 ? ad.createdAt.toISOString().slice(0, 10) : "—"}
</td>
<td>
<Link href={`/admin/ads/${ad.id}`}>{t("edit")}</Link>
</td>
</tr>
))}
</tbody>
</table>
)}
</section>
</main>
);
<section className="mt-6">
<h2 className="admin-section-title">{t("allAds")}</h2>
{ads.length === 0 ? (
<div className="admin-empty">{t("noAds")}</div>
) : (
<table>
<thead>
<tr>
<th>{t("colPreview")}</th>
<th>{t("colImage")}</th>
<th>{t("colCreated")}</th>
<th />
</tr>
</thead>
<tbody>
{ads.map((ad) => (
<tr key={String(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
? ad.createdAt.toISOString().slice(0, 10)
: "—"}
</td>
<td>
<Link href={`/admin/ads/${ad.id}`}>{t("edit")}</Link>
</td>
</tr>
))}
</tbody>
</table>
)}
</section>
</main>
);
}
+153 -129
View File
@@ -1,5 +1,5 @@
import { getTranslations } from "next-intl/server";
import { Bell } from "lucide-react";
import { getTranslations } from "next-intl/server";
import { sendHotelAlert } from "@/actions/admin-alerts";
import { StatusCard } from "@/components/admin/dashboard";
import { prisma } from "@/lib/prisma";
@@ -10,147 +10,171 @@ const CRITICAL = new Set(["critical", "error", "danger"]);
const WARNING = new Set(["warning", "warn"]);
function fromDate(d: Date | null | undefined): string {
return d ? d.toISOString().slice(0, 16).replace("T", " ") : "—";
return d ? d.toISOString().slice(0, 16).replace("T", " ") : "—";
}
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)";
}
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>
);
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 t = await getTranslations("pages.admin.alerts");
const t = await getTranslations("pages.admin.alerts");
let alerts: Awaited<ReturnType<typeof prisma.alertLogs.findMany>>;
try {
alerts = await prisma.alertLogs.findMany({
orderBy: { id: "desc" },
take: 100,
});
} catch {
alerts = [];
}
let alerts: Awaited<ReturnType<typeof prisma.alertLogs.findMany>>;
try {
alerts = await prisma.alertLogs.findMany({
orderBy: { id: "desc" },
take: 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;
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="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">
<Bell size={20} className="text-[var(--admin-accent)]" />
</div>
<div>
<h1 className="m-0 text-xl font-extrabold text-[var(--admin-text)]">{t("title")}</h1>
<p className="m-0 text-xs text-[var(--admin-text-muted)] mt-0.5">{t("subtitle")}</p>
</div>
</div>
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">
<Bell size={20} className="text-[var(--admin-accent)]" />
</div>
<div>
<h1 className="m-0 text-xl font-extrabold text-[var(--admin-text)]">
{t("title")}
</h1>
<p className="m-0 text-xs text-[var(--admin-text-muted)] mt-0.5">
{t("subtitle")}
</p>
</div>
</div>
<div className="grid grid-cols-[repeat(auto-fit,minmax(180px,1fr))] gap-3.5 mb-6">
<StatusCard label={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>
<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" className="btn btn-primary">
{t("form.send")}
</button>
</div>
</form>
<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" className="btn btn-primary">
{t("form.send")}
</button>
</div>
</form>
<section className="admin-card">
<h3 className="mt-0">{t("recentAlerts", { count: alerts.length })}</h3>
{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">
{fromDate(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>
);
<section className="admin-card">
<h3 className="mt-0">{t("recentAlerts", { count: alerts.length })}</h3>
{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">
{fromDate(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>
);
}
+182 -162
View File
@@ -6,193 +6,213 @@ import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
import { prisma } from "@/lib/prisma";
async function getActivityData() {
const now = Math.floor(Date.now() / 1000);
const todayStart = now - (now % 86400);
const weekAgo = now - 7 * 86400;
const now = Math.floor(Date.now() / 1000);
const todayStart = now - (now % 86400);
const weekAgo = now - 7 * 86400;
// Daily activity breakdown — 4 GROUP BY queries instead of 28 sequential queries
const rangeStart = todayStart - 6 * 86400;
const [chatRows, cmdRows, banRows, regRows] = await Promise.all([
prisma.$queryRaw<{ day_bucket: bigint; count: bigint }[]>`
// Daily activity breakdown — 4 GROUP BY queries instead of 28 sequential queries
const rangeStart = todayStart - 6 * 86400;
const [chatRows, cmdRows, banRows, regRows] = await Promise.all([
prisma.$queryRaw<{ day_bucket: bigint; count: bigint }[]>`
SELECT FLOOR(timestamp / 86400) AS day_bucket, COUNT(*) AS count
FROM chatlogs_room WHERE timestamp >= ${rangeStart} GROUP BY day_bucket`,
prisma.$queryRaw<{ day_bucket: bigint; count: bigint }[]>`
prisma.$queryRaw<{ day_bucket: bigint; count: bigint }[]>`
SELECT FLOOR(timestamp / 86400) AS day_bucket, COUNT(*) AS count
FROM commandlogs WHERE timestamp >= ${rangeStart} GROUP BY day_bucket`,
prisma.$queryRaw<{ day_bucket: bigint; count: bigint }[]>`
prisma.$queryRaw<{ day_bucket: bigint; count: bigint }[]>`
SELECT FLOOR(timestamp / 86400) AS day_bucket, COUNT(*) AS count
FROM bans WHERE timestamp >= ${rangeStart} GROUP BY day_bucket`,
prisma.$queryRaw<{ day_bucket: bigint; count: bigint }[]>`
prisma.$queryRaw<{ day_bucket: bigint; count: bigint }[]>`
SELECT FLOOR(account_created / 86400) AS day_bucket, COUNT(*) AS count
FROM users WHERE account_created >= ${rangeStart} GROUP BY day_bucket`,
]);
const toMap = (rows: { day_bucket: bigint; count: bigint }[]) =>
new Map(rows.map((r) => [Number(r.day_bucket), Number(r.count)]));
const chatMap = toMap(chatRows);
const cmdMap = toMap(cmdRows);
const banMap = toMap(banRows);
const regMap = toMap(regRows);
]);
const toMap = (rows: { day_bucket: bigint; count: bigint }[]) =>
new Map(rows.map((r) => [Number(r.day_bucket), Number(r.count)]));
const chatMap = toMap(chatRows);
const cmdMap = toMap(cmdRows);
const banMap = toMap(banRows);
const regMap = toMap(regRows);
const dailyData = [];
for (let i = 6; i >= 0; i--) {
const dayStart = todayStart - i * 86400;
const bucket = Math.floor(dayStart / 86400);
const date = new Date(dayStart * 1000);
dailyData.push({
label: date.toLocaleDateString("en", { weekday: "short", month: "short", day: "numeric" }),
chats: chatMap.get(bucket) ?? 0,
commands: cmdMap.get(bucket) ?? 0,
bans: banMap.get(bucket) ?? 0,
registrations: regMap.get(bucket) ?? 0,
});
}
const dailyData = [];
for (let i = 6; i >= 0; i--) {
const dayStart = todayStart - i * 86400;
const bucket = Math.floor(dayStart / 86400);
const date = new Date(dayStart * 1000);
dailyData.push({
label: date.toLocaleDateString("en", {
weekday: "short",
month: "short",
day: "numeric",
}),
chats: chatMap.get(bucket) ?? 0,
commands: cmdMap.get(bucket) ?? 0,
bans: banMap.get(bucket) ?? 0,
registrations: regMap.get(bucket) ?? 0,
});
}
// Totals
const [weekChatRows, weekCommandRows, weekBans, weekRegistrations] = await Promise.all([
prisma.$queryRaw<
{ count: bigint }[]
>`SELECT COUNT(*) AS count FROM chatlogs_room WHERE timestamp >= ${weekAgo}`,
prisma.$queryRaw<
{ count: bigint }[]
>`SELECT COUNT(*) AS count FROM commandlogs WHERE timestamp >= ${weekAgo}`,
prisma.ban.count({ where: { timestamp: { gte: weekAgo } } }),
prisma.user.count({ where: { accountCreated: { gte: weekAgo } } }),
]);
const weekChats = Number(weekChatRows[0]?.count ?? 0);
const weekCommands = Number(weekCommandRows[0]?.count ?? 0);
// Totals
const [weekChatRows, weekCommandRows, weekBans, weekRegistrations] =
await Promise.all([
prisma.$queryRaw<
{ count: bigint }[]
>`SELECT COUNT(*) AS count FROM chatlogs_room WHERE timestamp >= ${weekAgo}`,
prisma.$queryRaw<
{ count: bigint }[]
>`SELECT COUNT(*) AS count FROM commandlogs WHERE timestamp >= ${weekAgo}`,
prisma.ban.count({ where: { timestamp: { gte: weekAgo } } }),
prisma.user.count({ where: { accountCreated: { gte: weekAgo } } }),
]);
const weekChats = Number(weekChatRows[0]?.count ?? 0);
const weekCommands = Number(weekCommandRows[0]?.count ?? 0);
// Most active users (by chat messages)
const topChatters = await prisma.$queryRaw<{ userId: number; messages: bigint }[]>`
// Most active users (by chat messages)
const topChatters = await prisma.$queryRaw<
{ userId: number; messages: bigint }[]
>`
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 = topChatters.map((c) => c.userId);
const users = await prisma.user.findMany({
where: { id: { in: userIds } },
select: { id: true, username: true },
});
const userMap = new Map(users.map((u) => [u.id, u.username]));
const userIds = topChatters.map((c) => c.userId);
const users = await prisma.user.findMany({
where: { id: { in: userIds } },
select: { id: true, username: true },
});
const userMap = new Map(users.map((u) => [u.id, u.username]));
const topChattersWithNames = topChatters.map((c) => ({
userId: c.userId,
username: userMap.get(c.userId) ?? `User #${c.userId}`,
messages: Number(c.messages),
}));
const topChattersWithNames = topChatters.map((c) => ({
userId: c.userId,
username: userMap.get(c.userId) ?? `User #${c.userId}`,
messages: Number(c.messages),
}));
return {
dailyData,
weekChats,
weekCommands,
weekBans,
weekRegistrations,
topChatters: topChattersWithNames,
};
return {
dailyData,
weekChats,
weekCommands,
weekBans,
weekRegistrations,
topChatters: topChattersWithNames,
};
}
export default async function ActivityPage() {
const { session, permissions } = await getAdminContext();
if (!canAccess(permissions, PERMS.ANALYTICS_VIEW, session.user.rank)) {
redirect("/admin");
}
const { session, permissions } = await getAdminContext();
if (!canAccess(permissions, PERMS.ANALYTICS_VIEW, session.user.rank)) {
redirect("/admin");
}
const data = await getActivityData();
const data = await getActivityData();
return (
<div className="space-y-6">
<div>
<h1 className="text-3xl font-bold tracking-tight">Activity</h1>
<p className="text-muted-foreground">User activity breakdown and engagement metrics</p>
</div>
return (
<div className="space-y-6">
<div>
<h1 className="text-3xl font-bold tracking-tight">Activity</h1>
<p className="text-muted-foreground">
User activity breakdown and engagement metrics
</p>
</div>
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
<StatsCard
title="Chat Messages"
value={data.weekChats.toLocaleString()}
description="Last 7 days"
icon={<MessageSquare className="h-4 w-4" />}
/>
<StatsCard
title="Commands"
value={data.weekCommands.toLocaleString()}
description="Last 7 days"
icon={<Terminal className="h-4 w-4" />}
/>
<StatsCard
title="Bans"
value={data.weekBans.toLocaleString()}
description="Last 7 days"
icon={<Shield className="h-4 w-4" />}
/>
<StatsCard
title="Registrations"
value={data.weekRegistrations.toLocaleString()}
description="Last 7 days"
icon={<Users className="h-4 w-4" />}
/>
</div>
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
<StatsCard
title="Chat Messages"
value={data.weekChats.toLocaleString()}
description="Last 7 days"
icon={<MessageSquare className="h-4 w-4" />}
/>
<StatsCard
title="Commands"
value={data.weekCommands.toLocaleString()}
description="Last 7 days"
icon={<Terminal className="h-4 w-4" />}
/>
<StatsCard
title="Bans"
value={data.weekBans.toLocaleString()}
description="Last 7 days"
icon={<Shield className="h-4 w-4" />}
/>
<StatsCard
title="Registrations"
value={data.weekRegistrations.toLocaleString()}
description="Last 7 days"
icon={<Users className="h-4 w-4" />}
/>
</div>
<div className="grid gap-6 md:grid-cols-2">
{/* Daily breakdown table */}
<Card>
<CardHeader>
<CardTitle>Daily Breakdown (7 days)</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">Day</th>
<th className="text-right py-2 font-medium">Chats</th>
<th className="text-right py-2 font-medium">Cmds</th>
<th className="text-right py-2 font-medium">Bans</th>
<th className="text-right py-2 font-medium">Regs</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()}</td>
<td className="text-right tabular-nums">{day.commands.toLocaleString()}</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>
<div className="grid gap-6 md:grid-cols-2">
{/* Daily breakdown table */}
<Card>
<CardHeader>
<CardTitle>Daily Breakdown (7 days)</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">Day</th>
<th className="text-right py-2 font-medium">Chats</th>
<th className="text-right py-2 font-medium">Cmds</th>
<th className="text-right py-2 font-medium">Bans</th>
<th className="text-right py-2 font-medium">Regs</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()}
</td>
<td className="text-right tabular-nums">
{day.commands.toLocaleString()}
</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>
{/* Top chatters */}
<Card>
<CardHeader>
<CardTitle>Most Active Users (7 days)</CardTitle>
</CardHeader>
<CardContent>
{data.topChatters.length === 0 ? (
<p className="text-sm text-muted-foreground">No chat activity</p>
) : (
<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}</span>
<span className="text-sm tabular-nums text-muted-foreground">
{user.messages.toLocaleString()} msgs
</span>
</div>
))}
</div>
)}
</CardContent>
</Card>
</div>
</div>
);
{/* Top chatters */}
<Card>
<CardHeader>
<CardTitle>Most Active Users (7 days)</CardTitle>
</CardHeader>
<CardContent>
{data.topChatters.length === 0 ? (
<p className="text-sm text-muted-foreground">No chat activity</p>
) : (
<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}
</span>
<span className="text-sm tabular-nums text-muted-foreground">
{user.messages.toLocaleString()} msgs
</span>
</div>
))}
</div>
)}
</CardContent>
</Card>
</div>
</div>
);
}
+183 -160
View File
@@ -8,41 +8,48 @@ import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
import { prisma } from "@/lib/prisma";
const RevenueChart = dynamic(
() => import("@/components/admin/revenue-chart").then((m) => ({ default: m.RevenueChart })),
{ loading: () => <Skeleton className="h-[300px] w-full rounded-xl" /> },
() =>
import("@/components/admin/revenue-chart").then((m) => ({
default: m.RevenueChart,
})),
{ loading: () => <Skeleton className="h-[300px] w-full rounded-xl" /> },
);
async function getEconomyData() {
const now = Math.floor(Date.now() / 1000);
const todayStart = now - (now % 86400);
const weekAgo = now - 7 * 86400;
const now = Math.floor(Date.now() / 1000);
const todayStart = now - (now % 86400);
const weekAgo = now - 7 * 86400;
// Total currency in circulation — parallel instead of sequential
const [creditsAgg, pixelsAgg, pointsAgg] = await Promise.all([
prisma.user.aggregate({ _sum: { credits: true } }),
prisma.user.aggregate({ _sum: { pixels: true } }),
prisma.user.aggregate({ _sum: { points: true } }),
]);
const totalCredits = creditsAgg._sum.credits ?? 0;
const totalPixels = pixelsAgg._sum.pixels ?? 0;
const totalPoints = pointsAgg._sum.points ?? 0;
// Total currency in circulation — parallel instead of sequential
const [creditsAgg, pixelsAgg, pointsAgg] = await Promise.all([
prisma.user.aggregate({ _sum: { credits: true } }),
prisma.user.aggregate({ _sum: { pixels: true } }),
prisma.user.aggregate({ _sum: { points: true } }),
]);
const totalCredits = creditsAgg._sum.credits ?? 0;
const totalPixels = pixelsAgg._sum.pixels ?? 0;
const totalPoints = pointsAgg._sum.points ?? 0;
// Shop revenue (7 days)
const shopRevenue = await prisma.logsShopPurchases.aggregate({
where: { timestamp: { gte: weekAgo } },
_sum: { costCredits: true, costPoints: true },
_count: true,
});
// Shop revenue (7 days)
const shopRevenue = await prisma.logsShopPurchases.aggregate({
where: { timestamp: { gte: weekAgo } },
_sum: { costCredits: true, costPoints: true },
_count: true,
});
// Marketplace stats
const marketplaceActive = await prisma.marketplaceItems.count({ where: { state: 1 } });
const marketplaceSold = await prisma.marketplaceItems.count({
where: { state: 2, soldTimestamp: { gte: weekAgo } },
});
// Marketplace stats
const marketplaceActive = await prisma.marketplaceItems.count({
where: { state: 1 },
});
const marketplaceSold = await prisma.marketplaceItems.count({
where: { state: 2, soldTimestamp: { gte: weekAgo } },
});
// Revenue chart (7 days) — single GROUP BY query instead of 7 sequential queries
const rangeStart = todayStart - 6 * 86400;
const revRows = await prisma.$queryRaw<{ day_bucket: bigint; credits: number; points: number }[]>`
// Revenue chart (7 days) — single GROUP BY query instead of 7 sequential queries
const rangeStart = todayStart - 6 * 86400;
const revRows = await prisma.$queryRaw<
{ day_bucket: bigint; credits: number; points: number }[]
>`
SELECT FLOOR(timestamp / 86400) AS day_bucket,
COALESCE(SUM(cost_credits), 0) AS credits,
COALESCE(SUM(cost_points), 0) AS points
@@ -50,146 +57,162 @@ async function getEconomyData() {
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) }]),
);
const revenueData = [];
for (let i = 6; i >= 0; i--) {
const dayStart = todayStart - i * 86400;
const bucket = Math.floor(dayStart / 86400);
const date = new Date(dayStart * 1000);
const entry = revMap.get(bucket);
revenueData.push({
label: date.toLocaleDateString("en", { weekday: "short" }),
credits: entry?.credits ?? 0,
points: entry?.points ?? 0,
});
}
const revMap = new Map(
revRows.map((r) => [
Number(r.day_bucket),
{ credits: Number(r.credits), points: Number(r.points) },
]),
);
const revenueData = [];
for (let i = 6; i >= 0; i--) {
const dayStart = todayStart - i * 86400;
const bucket = Math.floor(dayStart / 86400);
const date = new Date(dayStart * 1000);
const entry = revMap.get(bucket);
revenueData.push({
label: date.toLocaleDateString("en", { weekday: "short" }),
credits: entry?.credits ?? 0,
points: entry?.points ?? 0,
});
}
// Trades (7 days)
const weekTrades = await prisma.roomTradeLog.count({
where: { timestamp: { gte: weekAgo } },
});
// Trades (7 days)
const weekTrades = await prisma.roomTradeLog.count({
where: { timestamp: { gte: weekAgo } },
});
return {
totalCredits,
totalPixels,
totalPoints,
shopPurchases: typeof shopRevenue._count === "number" ? shopRevenue._count : 0,
shopCreditsSpent: shopRevenue._sum?.costCredits ?? 0,
shopPointsSpent: shopRevenue._sum?.costPoints ?? 0,
marketplaceActive,
marketplaceSold,
weekTrades,
revenueData,
};
return {
totalCredits,
totalPixels,
totalPoints,
shopPurchases:
typeof shopRevenue._count === "number" ? shopRevenue._count : 0,
shopCreditsSpent: shopRevenue._sum?.costCredits ?? 0,
shopPointsSpent: shopRevenue._sum?.costPoints ?? 0,
marketplaceActive,
marketplaceSold,
weekTrades,
revenueData,
};
}
export default async function EconomyPage() {
const { session, permissions } = await getAdminContext();
if (!canAccess(permissions, PERMS.ANALYTICS_VIEW, session.user.rank)) {
redirect("/admin");
}
const { session, permissions } = await getAdminContext();
if (!canAccess(permissions, PERMS.ANALYTICS_VIEW, session.user.rank)) {
redirect("/admin");
}
const data = await getEconomyData();
const data = await getEconomyData();
return (
<div className="space-y-6">
<div>
<h1 className="text-3xl font-bold tracking-tight">Economy</h1>
<p className="text-muted-foreground">Currency circulation, shop revenue and marketplace activity</p>
</div>
return (
<div className="space-y-6">
<div>
<h1 className="text-3xl font-bold tracking-tight">Economy</h1>
<p className="text-muted-foreground">
Currency circulation, shop revenue and marketplace activity
</p>
</div>
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
<StatsCard
title="Credits in Circulation"
value={data.totalCredits.toLocaleString()}
description="Total credits across all users"
icon={<Coins className="h-4 w-4" />}
/>
<StatsCard
title="Pixels in Circulation"
value={data.totalPixels.toLocaleString()}
description="Total pixels across all users"
icon={<TrendingUp className="h-4 w-4" />}
/>
<StatsCard
title="Points in Circulation"
value={data.totalPoints.toLocaleString()}
description="Total points across all users"
icon={<TrendingDown className="h-4 w-4" />}
/>
<StatsCard
title="Weekly Trades"
value={data.weekTrades.toLocaleString()}
description={`${data.marketplaceActive} marketplace listings`}
icon={<ArrowLeftRight className="h-4 w-4" />}
/>
</div>
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
<StatsCard
title="Credits in Circulation"
value={data.totalCredits.toLocaleString()}
description="Total credits across all users"
icon={<Coins className="h-4 w-4" />}
/>
<StatsCard
title="Pixels in Circulation"
value={data.totalPixels.toLocaleString()}
description="Total pixels across all users"
icon={<TrendingUp className="h-4 w-4" />}
/>
<StatsCard
title="Points in Circulation"
value={data.totalPoints.toLocaleString()}
description="Total points across all users"
icon={<TrendingDown className="h-4 w-4" />}
/>
<StatsCard
title="Weekly Trades"
value={data.weekTrades.toLocaleString()}
description={`${data.marketplaceActive} marketplace listings`}
icon={<ArrowLeftRight className="h-4 w-4" />}
/>
</div>
<RevenueChart data={data.revenueData} />
<RevenueChart data={data.revenueData} />
<div className="grid gap-6 md:grid-cols-3">
<Card>
<CardHeader>
<CardTitle>Shop Revenue (7d)</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-2">
<div className="flex justify-between">
<span className="text-muted-foreground">Purchases</span>
<span className="font-medium">{data.shopPurchases}</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">Credits spent</span>
<span className="font-medium">{data.shopCreditsSpent.toLocaleString()}</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">Points spent</span>
<span className="font-medium">{data.shopPointsSpent.toLocaleString()}</span>
</div>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Marketplace</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-2">
<div className="flex justify-between">
<span className="text-muted-foreground">Active listings</span>
<span className="font-medium">{data.marketplaceActive}</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">Sold (7d)</span>
<span className="font-medium">{data.marketplaceSold}</span>
</div>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Currency Distribution</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-2">
<div className="flex justify-between">
<span className="text-muted-foreground">Credits</span>
<span className="font-medium">{data.totalCredits.toLocaleString()}</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">Pixels</span>
<span className="font-medium">{data.totalPixels.toLocaleString()}</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">Points</span>
<span className="font-medium">{data.totalPoints.toLocaleString()}</span>
</div>
</div>
</CardContent>
</Card>
</div>
</div>
);
<div className="grid gap-6 md:grid-cols-3">
<Card>
<CardHeader>
<CardTitle>Shop Revenue (7d)</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-2">
<div className="flex justify-between">
<span className="text-muted-foreground">Purchases</span>
<span className="font-medium">{data.shopPurchases}</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">Credits spent</span>
<span className="font-medium">
{data.shopCreditsSpent.toLocaleString()}
</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">Points spent</span>
<span className="font-medium">
{data.shopPointsSpent.toLocaleString()}
</span>
</div>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Marketplace</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-2">
<div className="flex justify-between">
<span className="text-muted-foreground">Active listings</span>
<span className="font-medium">{data.marketplaceActive}</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">Sold (7d)</span>
<span className="font-medium">{data.marketplaceSold}</span>
</div>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Currency Distribution</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-2">
<div className="flex justify-between">
<span className="text-muted-foreground">Credits</span>
<span className="font-medium">
{data.totalCredits.toLocaleString()}
</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">Pixels</span>
<span className="font-medium">
{data.totalPixels.toLocaleString()}
</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">Points</span>
<span className="font-medium">
{data.totalPoints.toLocaleString()}
</span>
</div>
</div>
</CardContent>
</Card>
</div>
</div>
);
}
+188 -152
View File
@@ -1,4 +1,11 @@
import { ArrowLeftRight, Clock, DoorOpen, MessageSquare, TrendingUp, Users } from "lucide-react";
import {
ArrowLeftRight,
Clock,
DoorOpen,
MessageSquare,
TrendingUp,
Users,
} from "lucide-react";
import dynamic from "next/dynamic";
import { redirect } from "next/navigation";
import { StatsCard } from "@/components/admin/stats-card";
@@ -8,66 +15,78 @@ import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
import { prisma } from "@/lib/prisma";
const DashboardChart = dynamic(
() => import("@/components/admin/dashboard-chart").then((m) => ({ default: m.DashboardChart })),
{ loading: () => <Skeleton className="h-[300px] w-full rounded-xl" /> },
() =>
import("@/components/admin/dashboard-chart").then((m) => ({
default: m.DashboardChart,
})),
{ loading: () => <Skeleton className="h-[300px] w-full rounded-xl" /> },
);
const PeakHoursHeatmap = dynamic(
() => import("@/components/admin/peak-hours-heatmap").then((m) => ({ default: m.PeakHoursHeatmap })),
{ loading: () => <Skeleton className="h-[250px] w-full rounded-xl" /> },
() =>
import("@/components/admin/peak-hours-heatmap").then((m) => ({
default: m.PeakHoursHeatmap,
})),
{ loading: () => <Skeleton className="h-[250px] w-full rounded-xl" /> },
);
async function getAnalyticsData() {
const now = Math.floor(Date.now() / 1000);
const todayStart = now - (now % 86400);
const weekAgo = now - 7 * 86400;
const monthAgo = now - 30 * 86400;
const now = Math.floor(Date.now() / 1000);
const todayStart = now - (now % 86400);
const weekAgo = now - 7 * 86400;
const monthAgo = now - 30 * 86400;
const [
totalUsers,
onlineUsers,
weekRegistrations,
monthRegistrations,
totalRooms,
activeRooms,
weekChatMessages,
weekTrades,
weekBans,
] = await Promise.all([
prisma.user.count(),
prisma.user.count({ where: { online: "1" } }),
prisma.user.count({ where: { accountCreated: { gte: weekAgo } } }),
prisma.user.count({ where: { accountCreated: { gte: monthAgo } } }),
prisma.rooms.count(),
prisma.rooms.count({ where: { users: { gt: 0 } } }),
prisma.$queryRaw<
{ count: bigint }[]
>`SELECT COUNT(*) AS count FROM chatlogs_room WHERE timestamp >= ${weekAgo}`,
prisma.roomTradeLog.count({ where: { timestamp: { gte: weekAgo } } }),
prisma.ban.count({ where: { timestamp: { gte: weekAgo } } }),
]);
const [
totalUsers,
onlineUsers,
weekRegistrations,
monthRegistrations,
totalRooms,
activeRooms,
weekChatMessages,
weekTrades,
weekBans,
] = await Promise.all([
prisma.user.count(),
prisma.user.count({ where: { online: "1" } }),
prisma.user.count({ where: { accountCreated: { gte: weekAgo } } }),
prisma.user.count({ where: { accountCreated: { gte: monthAgo } } }),
prisma.rooms.count(),
prisma.rooms.count({ where: { users: { gt: 0 } } }),
prisma.$queryRaw<
{ count: bigint }[]
>`SELECT COUNT(*) AS count FROM chatlogs_room WHERE timestamp >= ${weekAgo}`,
prisma.roomTradeLog.count({ where: { timestamp: { gte: weekAgo } } }),
prisma.ban.count({ where: { timestamp: { gte: weekAgo } } }),
]);
// Registration chart (30 days) — single GROUP BY query instead of 30 sequential queries
const regRows = await prisma.$queryRaw<{ day_bucket: bigint; count: bigint }[]>`
// Registration chart (30 days) — single GROUP BY query instead of 30 sequential queries
const regRows = await prisma.$queryRaw<
{ day_bucket: bigint; count: bigint }[]
>`
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)]));
const registrationChart = [];
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("en", { month: "short", day: "numeric" }),
value: regCountMap.get(bucket) ?? 0,
});
}
const regCountMap = new Map(
regRows.map((r) => [Number(r.day_bucket), Number(r.count)]),
);
const registrationChart = [];
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("en", { month: "short", day: "numeric" }),
value: regCountMap.get(bucket) ?? 0,
});
}
// Peak hours — single GROUP BY query instead of fetching all user rows
const peakRows = await prisma.$queryRaw<{ d: number; h: number; c: bigint }[]>`
// Peak hours — single GROUP BY query instead of fetching all user rows
const peakRows = await prisma.$queryRaw<
{ d: number; h: number; c: bigint }[]
>`
SELECT ((DAYOFWEEK(FROM_UNIXTIME(last_online)) + 5) % 7) AS d,
HOUR(FROM_UNIXTIME(last_online)) AS h,
COUNT(*) AS c
@@ -75,118 +94,135 @@ async function getAnalyticsData() {
WHERE last_online >= ${weekAgo}
GROUP BY d, h
`;
const grid: number[][] = Array.from({ length: 7 }, () => Array(24).fill(0));
let max = 0;
for (const r of peakRows) {
const count = Number(r.c);
grid[r.d][r.h] = count;
if (count > max) max = count;
}
const grid: number[][] = Array.from({ length: 7 }, () => Array(24).fill(0));
let max = 0;
for (const r of peakRows) {
const count = Number(r.c);
grid[r.d][r.h] = count;
if (count > max) max = count;
}
return {
totalUsers,
onlineUsers,
weekRegistrations,
monthRegistrations,
totalRooms,
activeRooms,
weekChatMessages: Array.isArray(weekChatMessages) ? Number(weekChatMessages[0]?.count ?? 0) : 0,
weekTrades,
weekBans,
registrationChart,
peakHours: { grid, max },
};
return {
totalUsers,
onlineUsers,
weekRegistrations,
monthRegistrations,
totalRooms,
activeRooms,
weekChatMessages: Array.isArray(weekChatMessages)
? Number(weekChatMessages[0]?.count ?? 0)
: 0,
weekTrades,
weekBans,
registrationChart,
peakHours: { grid, max },
};
}
export default async function AnalyticsPage() {
const { session, permissions } = await getAdminContext();
if (!canAccess(permissions, PERMS.ANALYTICS_VIEW, session.user.rank)) {
redirect("/admin");
}
const { session, permissions } = await getAdminContext();
if (!canAccess(permissions, PERMS.ANALYTICS_VIEW, session.user.rank)) {
redirect("/admin");
}
const data = await getAnalyticsData();
const data = await getAnalyticsData();
return (
<div className="space-y-6">
<div>
<h1 className="text-3xl font-bold tracking-tight">Analytics</h1>
<p className="text-muted-foreground">Hotel performance overview and trends</p>
</div>
return (
<div className="space-y-6">
<div>
<h1 className="text-3xl font-bold tracking-tight">Analytics</h1>
<p className="text-muted-foreground">
Hotel performance overview and trends
</p>
</div>
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
<StatsCard
title="Total Users"
value={data.totalUsers.toLocaleString()}
description={`${data.onlineUsers} online now`}
icon={<Users className="h-4 w-4" />}
/>
<StatsCard
title="Week Registrations"
value={data.weekRegistrations.toLocaleString()}
description={`${data.monthRegistrations} this month`}
icon={<TrendingUp className="h-4 w-4" />}
/>
<StatsCard
title="Active Rooms"
value={data.activeRooms.toLocaleString()}
description={`${data.totalRooms} total rooms`}
icon={<DoorOpen className="h-4 w-4" />}
/>
<StatsCard
title="Weekly Activity"
value={data.weekChatMessages.toLocaleString()}
description={`${data.weekTrades} trades, ${data.weekBans} bans`}
icon={<MessageSquare className="h-4 w-4" />}
/>
</div>
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
<StatsCard
title="Total Users"
value={data.totalUsers.toLocaleString()}
description={`${data.onlineUsers} online now`}
icon={<Users className="h-4 w-4" />}
/>
<StatsCard
title="Week Registrations"
value={data.weekRegistrations.toLocaleString()}
description={`${data.monthRegistrations} this month`}
icon={<TrendingUp className="h-4 w-4" />}
/>
<StatsCard
title="Active Rooms"
value={data.activeRooms.toLocaleString()}
description={`${data.totalRooms} total rooms`}
icon={<DoorOpen className="h-4 w-4" />}
/>
<StatsCard
title="Weekly Activity"
value={data.weekChatMessages.toLocaleString()}
description={`${data.weekTrades} trades, ${data.weekBans} bans`}
icon={<MessageSquare className="h-4 w-4" />}
/>
</div>
<div className="grid gap-6 md:grid-cols-2">
<DashboardChart
title="Registrations (30 days)"
data={data.registrationChart}
color="hsl(262, 83%, 58%)"
description="New user registrations over the last 30 days"
/>
<PeakHoursHeatmap data={data.peakHours} />
</div>
<div className="grid gap-6 md:grid-cols-2">
<DashboardChart
title="Registrations (30 days)"
data={data.registrationChart}
color="hsl(262, 83%, 58%)"
description="New user registrations over the last 30 days"
/>
<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" /> Chat Activity
</CardTitle>
</CardHeader>
<CardContent>
<div className="text-3xl font-bold">{data.weekChatMessages.toLocaleString()}</div>
<p className="text-sm text-muted-foreground">Messages in the last 7 days</p>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<ArrowLeftRight className="h-4 w-4" /> Trades
</CardTitle>
</CardHeader>
<CardContent>
<div className="text-3xl font-bold">{data.weekTrades.toLocaleString()}</div>
<p className="text-sm text-muted-foreground">Trades in the last 7 days</p>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Clock className="h-4 w-4" /> 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">Users online / total</p>
</CardContent>
</Card>
</div>
</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" /> Chat Activity
</CardTitle>
</CardHeader>
<CardContent>
<div className="text-3xl font-bold">
{data.weekChatMessages.toLocaleString()}
</div>
<p className="text-sm text-muted-foreground">
Messages in the last 7 days
</p>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<ArrowLeftRight className="h-4 w-4" /> Trades
</CardTitle>
</CardHeader>
<CardContent>
<div className="text-3xl font-bold">
{data.weekTrades.toLocaleString()}
</div>
<p className="text-sm text-muted-foreground">
Trades in the last 7 days
</p>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Clock className="h-4 w-4" /> 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">
Users online / total
</p>
</CardContent>
</Card>
</div>
</div>
);
}
+101 -91
View File
@@ -1,110 +1,120 @@
import Link from "next/link";
import { ClipboardList } from "lucide-react";
import Link from "next/link";
import { getTranslations } from "next-intl/server";
import { dismissApplication } from "@/actions/admin-applications";
import { StatusCard } from "@/components/admin/dashboard";
import { prisma } from "@/lib/prisma";
import { getTranslations } from "next-intl/server";
export const dynamic = "force-dynamic";
type ApplicationRow = {
id: bigint;
userId: number;
rankId: number;
content: string;
createdAt: Date | null;
id: bigint;
userId: number;
rankId: number;
content: string;
createdAt: Date | null;
};
function formatDate(d: Date | null): string {
return d ? d.toISOString().slice(0, 16).replace("T", " ") : "—";
return d ? d.toISOString().slice(0, 16).replace("T", " ") : "—";
}
export default async function AdminApplications() {
const t = await getTranslations("pages.admin.applications");
let applications: ApplicationRow[];
try {
applications = await prisma.websiteStaffApplications.findMany({
orderBy: { createdAt: "desc" },
take: 100,
});
} catch {
applications = [];
}
const t = await getTranslations("pages.admin.applications");
let applications: ApplicationRow[];
try {
applications = await prisma.websiteStaffApplications.findMany({
orderBy: { createdAt: "desc" },
take: 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 prisma.user.findMany({
where: { id: { in: ids } },
select: { id: true, username: true },
});
for (const u of users) userMap.set(u.id, u.username);
} catch {
// no DB — fall back to raw ids
}
}
// 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 prisma.user.findMany({
where: { id: { in: ids } },
select: { id: true, username: true },
});
for (const u of users) userMap.set(u.id, u.username);
} catch {
// no DB — fall back to raw ids
}
}
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">
<ClipboardList size={20} className="text-[var(--admin-accent)]" />
</div>
<div>
<h1 className="m-0 text-xl font-extrabold text-[var(--admin-text)]">{t("title")}</h1>
<p className="m-0 text-xs text-[var(--admin-text-muted)] mt-0.5">{t("subtitle")}</p>
</div>
</div>
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">
<ClipboardList size={20} className="text-[var(--admin-accent)]" />
</div>
<div>
<h1 className="m-0 text-xl font-extrabold text-[var(--admin-text)]">
{t("title")}
</h1>
<p className="m-0 text-xs text-[var(--admin-text-muted)] mt-0.5">
{t("subtitle")}
</p>
</div>
</div>
<div className="grid grid-cols-[repeat(auto-fit,minmax(180px,1fr))] gap-3.5 mb-6">
<StatusCard
label={t("pending")}
value={applications.length}
state={applications.length > 0 ? "warn" : "ok"}
icon="📨"
/>
</div>
<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)}
</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" className="btn btn-danger">
{t("dismiss")}
</button>
</form>
</article>
);
})}
</div>
)}
</section>
</main>
);
<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)}
</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" className="btn btn-danger">
{t("dismiss")}
</button>
</form>
</article>
);
})}
</div>
)}
</section>
</main>
);
}
+64 -55
View File
@@ -1,7 +1,7 @@
import Link from "next/link";
import { getTranslations } from "next-intl/server";
import { Newspaper } from "lucide-react";
import Link from "next/link";
import { notFound } from "next/navigation";
import { getTranslations } from "next-intl/server";
import { deleteArticle, updateArticle } from "@/actions/admin-articles";
import { ArticleForm } from "@/components/admin/article-form";
import { prisma } from "@/lib/prisma";
@@ -9,60 +9,69 @@ import { prisma } from "@/lib/prisma";
export const dynamic = "force-dynamic";
export default async function EditArticle({
params,
searchParams,
params,
searchParams,
}: {
params: Promise<{ id: string }>;
searchParams: Promise<{ error?: string }>;
params: Promise<{ id: string }>;
searchParams: Promise<{ error?: string }>;
}) {
const { id } = await params;
const { error } = await searchParams;
const t = await getTranslations("pages.admin.articles");
const article = await prisma.websiteArticles
.findUnique({
where: { id: BigInt(id) },
select: { id: true, slug: true, title: true, shortStory: true, fullStory: true, image: true },
})
.catch(() => null);
if (!article) notFound();
const { id } = await params;
const { error } = await searchParams;
const t = await getTranslations("pages.admin.articles");
const article = await prisma.websiteArticles
.findUnique({
where: { id: BigInt(id) },
select: {
id: true,
slug: true,
title: true,
shortStory: true,
fullStory: true,
image: true,
},
})
.catch(() => null);
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,
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" className="btn btn-primary">
{t("form.deleteArticle")}
</button>
</form>
</main>
);
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,
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" className="btn btn-primary">
{t("form.deleteArticle")}
</button>
</form>
</main>
);
}
+31 -25
View File
@@ -1,32 +1,38 @@
import { Newspaper } from "lucide-react";
import Link from "next/link";
import { getTranslations } from "next-intl/server";
import { Newspaper } from "lucide-react";
import { createArticle } from "@/actions/admin-articles";
import { ArticleForm } from "@/components/admin/article-form";
export default async function NewArticle({ searchParams }: { searchParams: Promise<{ error?: string }> }) {
const { error } = await searchParams;
const t = await getTranslations("pages.admin.articles");
export default async function NewArticle({
searchParams,
}: {
searchParams: Promise<{ error?: string }>;
}) {
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>
);
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>
);
}
+105 -83
View File
@@ -1,96 +1,118 @@
import { Newspaper } from "lucide-react";
import Link from "next/link";
import { getTranslations } from "next-intl/server";
import { Newspaper } from "lucide-react";
import { StatusCard } from "@/components/admin/dashboard";
import { deleteArticle } from "@/actions/admin-articles";
import { StatusCard } from "@/components/admin/dashboard";
import { prisma } from "@/lib/prisma";
export const dynamic = "force-dynamic";
export default async function AdminArticles({ searchParams }: { searchParams: Promise<{ error?: string }> }) {
const { error } = await searchParams;
const t = await getTranslations("pages.admin.articles");
const articles = await prisma.websiteArticles
.findMany({
orderBy: { createdAt: "desc" },
select: { id: true, title: true, slug: true, createdAt: true },
take: 50,
})
.catch(() => []);
export default async function AdminArticles({
searchParams,
}: {
searchParams: Promise<{ error?: string }>;
}) {
const { error } = await searchParams;
const t = await getTranslations("pages.admin.articles");
const articles = await prisma.websiteArticles
.findMany({
orderBy: { createdAt: "desc" },
select: { id: true, title: true, slug: true, createdAt: true },
take: 50,
})
.catch(() => []);
const latest = articles[0]?.createdAt ? articles[0].createdAt.toISOString().slice(0, 10) : "—";
const latest = articles[0]?.createdAt
? articles[0].createdAt.toISOString().slice(0, 10)
: "—";
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 className="flex-1">
<h1 className="m-0 text-xl font-extrabold text-[var(--admin-text)]">{t("title")}</h1>
<p className="m-0 text-xs text-[var(--admin-text-muted)] mt-0.5">{t("subtitle")}</p>
</div>
<Link href="/admin/articles/new" className="btn btn-primary">
+ {t("newArticle")}
</Link>
</div>
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 className="flex-1">
<h1 className="m-0 text-xl font-extrabold text-[var(--admin-text)]">
{t("title")}
</h1>
<p className="m-0 text-xs text-[var(--admin-text-muted)] mt-0.5">
{t("subtitle")}
</p>
</div>
<Link href="/admin/articles/new" className="btn btn-primary">
+ {t("newArticle")}
</Link>
</div>
{error ? (
<p className="text-xs text-[var(--admin-error)] dark:text-[var(--admin-error)] mb-4">
Error: {error}
</p>
) : null}
{error ? (
<p className="text-xs text-[var(--admin-error)] dark: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>
<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-6">
<h2 className="admin-section-title">{t("recentArticles")}</h2>
{articles.length === 0 ? (
<div className="admin-empty">{t("noArticles")}</div>
) : (
<table>
<thead>
<tr>
<th>{t("colTitle")}</th>
<th>{t("colSlug")}</th>
<th>{t("colCreated")}</th>
<th>{t("colActions")}</th>
</tr>
</thead>
<tbody>
{articles.map((a) => (
<tr key={String(a.id)}>
<td>
<Link href={`/admin/articles/${a.id}`}>{a.title}</Link>
</td>
<td className="text-sm theme-text-muted dark:theme-text-muted whitespace-nowrap">
<code>{a.slug}</code>
</td>
<td className="text-sm theme-text-muted dark:theme-text-muted whitespace-nowrap">
{a.createdAt ? a.createdAt.toISOString().slice(0, 10) : "—"}
</td>
<td>
<div className="flex gap-2">
<Link href={`/admin/articles/${a.id}`} className="btn btn-primary">
{t("edit")}
</Link>
<form action={deleteArticle} className="m-0">
<input type="hidden" name="id" value={String(a.id)} />
<button type="submit" className="btn btn-danger">
{t("delete")}
</button>
</form>
</div>
</td>
</tr>
))}
</tbody>
</table>
)}
</section>
</main>
);
<section className="mt-6">
<h2 className="admin-section-title">{t("recentArticles")}</h2>
{articles.length === 0 ? (
<div className="admin-empty">{t("noArticles")}</div>
) : (
<table>
<thead>
<tr>
<th>{t("colTitle")}</th>
<th>{t("colSlug")}</th>
<th>{t("colCreated")}</th>
<th>{t("colActions")}</th>
</tr>
</thead>
<tbody>
{articles.map((a) => (
<tr key={String(a.id)}>
<td>
<Link href={`/admin/articles/${a.id}`}>{a.title}</Link>
</td>
<td className="text-sm theme-text-muted dark:theme-text-muted whitespace-nowrap">
<code>{a.slug}</code>
</td>
<td className="text-sm theme-text-muted dark:theme-text-muted whitespace-nowrap">
{a.createdAt ? a.createdAt.toISOString().slice(0, 10) : "—"}
</td>
<td>
<div className="flex gap-2">
<Link
href={`/admin/articles/${a.id}`}
className="btn btn-primary"
>
{t("edit")}
</Link>
<form action={deleteArticle} className="m-0">
<input type="hidden" name="id" value={String(a.id)} />
<button type="submit" className="btn btn-danger">
{t("delete")}
</button>
</form>
</div>
</td>
</tr>
))}
</tbody>
</table>
)}
</section>
</main>
);
}
+151 -134
View File
@@ -1,150 +1,167 @@
import { getTranslations } from "next-intl/server";
import { giveBadge } from "@/actions/admin-badges";
import { uploadBadge } from "@/actions/admin-badge-upload";
import { prisma } from "@/lib/prisma";
import { Award } from "lucide-react";
import { getTranslations } from "next-intl/server";
import { uploadBadge } from "@/actions/admin-badge-upload";
import { giveBadge } from "@/actions/admin-badges";
import { prisma } from "@/lib/prisma";
export const dynamic = "force-dynamic";
export const metadata = { title: "Badges" };
export default async function AdminBadges({
searchParams,
searchParams,
}: {
searchParams: Promise<{ uploaded?: string; error?: string }>;
searchParams: Promise<{ uploaded?: string; error?: string }>;
}) {
const t = await getTranslations("pages.admin.badges");
const { uploaded, error } = await searchParams;
const t = await getTranslations("pages.admin.badges");
const { uploaded, error } = await searchParams;
let badges: Awaited<ReturnType<typeof prisma.websiteBadges.findMany>>;
try {
badges = await prisma.websiteBadges.findMany({
orderBy: { badgeName: "asc" },
take: 500,
});
} catch {
badges = [];
}
let badges: Awaited<ReturnType<typeof prisma.websiteBadges.findMany>>;
try {
badges = await prisma.websiteBadges.findMany({
orderBy: { badgeName: "asc" },
take: 500,
});
} catch {
badges = [];
}
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">
<Award size={20} className="text-[var(--admin-accent)]" />
</div>
<div>
<h1 className="m-0 text-xl font-extrabold text-[var(--admin-text)]">{t("title")}</h1>
<p className="m-0 text-xs text-[var(--admin-text-muted)] mt-0.5">{t("subtitle")}</p>
</div>
</div>
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">
<Award size={20} className="text-[var(--admin-accent)]" />
</div>
<div>
<h1 className="m-0 text-xl font-extrabold text-[var(--admin-text)]">
{t("title")}
</h1>
<p className="m-0 text-xs text-[var(--admin-text-muted)] mt-0.5">
{t("subtitle")}
</p>
</div>
</div>
{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>
)}
{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" className="btn btn-primary">
{t("uploadButton")}
</button>
</div>
</form>
</section>
<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" className="btn btn-primary">
{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" className="btn btn-primary">
{t("giveButton")}
</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" className="btn btn-primary">
{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" className="btn btn-outline">
{t("giveButton")}
</button>
</form>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</section>
</main>
);
<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" className="btn btn-outline">
{t("giveButton")}
</button>
</form>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</section>
</main>
);
}
+260 -214
View File
@@ -13,230 +13,276 @@ 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;
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[];
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,
});
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 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 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();
}
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>
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>
)}
{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>
);
<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>
);
}
+18 -16
View File
@@ -4,22 +4,24 @@ import { prisma } from "@/lib/prisma";
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 { session, permissions } = await getAdminContext();
if (!canAccess(permissions, PERMS.BANNERS_VIEW, session.user.rank)) {
redirect("/admin");
}
const banners = await prisma.websiteBanner.findMany({
orderBy: { sortOrder: "asc" },
});
const banners = await prisma.websiteBanner.findMany({
orderBy: { sortOrder: "asc" },
});
return (
<div className="space-y-6">
<div>
<h1 className="text-3xl font-bold tracking-tight">Banners</h1>
<p className="text-muted-foreground">Manage homepage banners and promotional slides</p>
</div>
<BannersManager banners={banners} />
</div>
);
return (
<div className="space-y-6">
<div>
<h1 className="text-3xl font-bold tracking-tight">Banners</h1>
<p className="text-muted-foreground">
Manage homepage banners and promotional slides
</p>
</div>
<BannersManager banners={banners} />
</div>
);
}
+138 -117
View File
@@ -1,133 +1,154 @@
import { Ban } from "lucide-react";
import { getTranslations } from "next-intl/server";
import { createBan, liftBan } from "@/actions/admin-bans";
import { StatusCard } from "@/components/admin/dashboard";
import { prisma } from "@/lib/prisma";
import { Ban } from "lucide-react";
import { getTranslations } from "next-intl/server";
export const dynamic = "force-dynamic";
function fromUnix(ts: number): string {
return new Date(ts * 1000).toISOString().slice(0, 16).replace("T", " ");
return new Date(ts * 1000).toISOString().slice(0, 16).replace("T", " ");
}
export default async function AdminBans() {
const t = await getTranslations("pages.admin.bans");
const now = Math.floor(Date.now() / 1000);
let bans: Awaited<ReturnType<typeof prisma.ban.findMany>> = [];
try {
bans = await prisma.ban.findMany({
where: { banExpire: { gt: now } },
orderBy: { timestamp: "desc" },
take: 100,
});
} catch {
bans = [];
}
const t = await getTranslations("pages.admin.bans");
const now = Math.floor(Date.now() / 1000);
let bans: Awaited<ReturnType<typeof prisma.ban.findMany>> = [];
try {
bans = await prisma.ban.findMany({
where: { banExpire: { gt: now } },
orderBy: { timestamp: "desc" },
take: 100,
});
} catch {
bans = [];
}
// banExpire of 0 means permanent in the emulator schema.
const permanent = bans.filter((b) => b.banExpire === 0).length;
const accountBans = bans.filter((b) => b.type === "account").length;
// banExpire of 0 means permanent in the emulator schema.
const permanent = bans.filter((b) => b.banExpire === 0).length;
const accountBans = bans.filter((b) => b.type === "account").length;
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">
<Ban size={20} className="text-[var(--admin-accent)]" />
</div>
<div>
<h1 className="m-0 text-xl font-extrabold text-[var(--admin-text)]">{t("title")}</h1>
<p className="m-0 text-xs text-[var(--admin-text-muted)] mt-0.5">{t("subtitle")}</p>
</div>
</div>
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">
<Ban size={20} className="text-[var(--admin-accent)]" />
</div>
<div>
<h1 className="m-0 text-xl font-extrabold text-[var(--admin-text)]">
{t("title")}
</h1>
<p className="m-0 text-xs text-[var(--admin-text-muted)] mt-0.5">
{t("subtitle")}
</p>
</div>
</div>
<div className="grid grid-cols-[repeat(auto-fit,minmax(180px,1fr))] gap-3.5 mb-6">
<StatusCard
label={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>
<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" className="btn btn-danger">
{t("ban")}
</button>
</div>
</form>
<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" className="btn btn-danger">
{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">
{fromUnix(b.banExpire)}
</span>
)}
</td>
<td>
<form action={liftBan}>
<input type="hidden" name="id" value={b.id} />
<button type="submit" className="btn btn-outline">
{t("lift")}
</button>
</form>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</section>
</main>
);
<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">
{fromUnix(b.banExpire)}
</span>
)}
</td>
<td>
<form action={liftBan}>
<input type="hidden" name="id" value={b.id} />
<button type="submit" className="btn btn-outline">
{t("lift")}
</button>
</form>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</section>
</main>
);
}
+181 -157
View File
@@ -1,181 +1,205 @@
import Link from "next/link";
import { getTranslations } from "next-intl/server";
import { notFound } from "next/navigation";
import { getTranslations } from "next-intl/server";
import { prisma } from "@/lib/prisma";
export const dynamic = "force-dynamic";
type Campaign = {
id: number;
name: string;
image: string;
startTimestamp: number;
totalDays: number;
lockExpired: string;
enabled: string;
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;
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;
};
function fmtDate(ts: number): string {
if (!ts) return "—";
return new Date(ts * 1000).toISOString().slice(0, 10);
if (!ts) return "—";
return new Date(ts * 1000).toISOString().slice(0, 10);
}
export default async function AdminCalendarCampaign({ params }: { params: Promise<{ id: string }> }) {
const t = await getTranslations("pages.admin.calendar");
export default async function AdminCalendarCampaign({
params,
}: {
params: Promise<{ id: string }>;
}) {
const t = await getTranslations("pages.admin.calendar");
const { id } = await params;
const campaignId = Number(id);
if (!Number.isInteger(campaignId)) notFound();
const { id } = await params;
const campaignId = Number(id);
if (!Number.isInteger(campaignId)) notFound();
let campaign: Campaign | null = null;
try {
campaign = await prisma.calendarCampaigns.findUnique({
where: { id: campaignId },
select: {
id: true,
name: true,
image: true,
startTimestamp: true,
totalDays: true,
lockExpired: true,
enabled: true,
},
});
} catch {
campaign = null;
}
if (!campaign) notFound();
let campaign: Campaign | null = null;
try {
campaign = await prisma.calendarCampaigns.findUnique({
where: { id: campaignId },
select: {
id: true,
name: true,
image: true,
startTimestamp: true,
totalDays: true,
lockExpired: true,
enabled: true,
},
});
} catch {
campaign = null;
}
if (!campaign) notFound();
let rewards: Reward[] = [];
try {
rewards = await prisma.calendarRewards.findMany({
where: { campaignId },
select: {
id: true,
campaignId: true,
productName: true,
customImage: true,
credits: true,
pixels: true,
points: true,
pointsType: true,
badge: true,
itemId: true,
subscriptionType: true,
subscriptionDays: true,
},
orderBy: { id: "asc" },
take: 500,
});
} catch {
rewards = [];
}
let rewards: Reward[] = [];
try {
rewards = await prisma.calendarRewards.findMany({
where: { campaignId },
select: {
id: true,
campaignId: true,
productName: true,
customImage: true,
credits: true,
pixels: true,
points: true,
pointsType: true,
badge: true,
itemId: true,
subscriptionType: true,
subscriptionDays: true,
},
orderBy: { id: "asc" },
take: 500,
});
} catch {
rewards = [];
}
const disabled = campaign.enabled !== "1";
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>
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: fmtDate(campaign.startTimestamp),
})}
{disabled ? ` · ${t("detail.disabled")}` : ` · ${t("detail.enabled")}`}
{campaign.lockExpired === "1" ? ` · ${t("detail.locksExpired")}` : ""}
</p>
</div>
</div>
<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: fmtDate(campaign.startTimestamp),
})}
{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>
)}
<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>
);
<p className="text-xs theme-text-muted dark:theme-text-muted mt-4">
{t("detail.rewardCount", { count: rewards.length })}
</p>
</main>
);
}
+112 -102
View File
@@ -1,123 +1,133 @@
import { Calendar } from "lucide-react";
import Link from "next/link";
import { getTranslations } from "next-intl/server";
import { StatusCard } from "@/components/admin/dashboard";
import { prisma } from "@/lib/prisma";
import { Calendar } from "lucide-react";
export const dynamic = "force-dynamic";
type Campaign = {
id: number;
name: string;
image: string;
startTimestamp: number;
totalDays: number;
lockExpired: string;
enabled: string;
id: number;
name: string;
image: string;
startTimestamp: number;
totalDays: number;
lockExpired: string;
enabled: string;
};
function fmtDate(ts: number): string {
if (!ts) return "—";
return new Date(ts * 1000).toISOString().slice(0, 10);
if (!ts) return "—";
return new Date(ts * 1000).toISOString().slice(0, 10);
}
export default async function AdminCalendar() {
const t = await getTranslations("pages.admin.calendar");
const t = await getTranslations("pages.admin.calendar");
let campaigns: Campaign[] = [];
try {
campaigns = await prisma.calendarCampaigns.findMany({
select: {
id: true,
name: true,
image: true,
startTimestamp: true,
totalDays: true,
lockExpired: true,
enabled: true,
},
orderBy: { id: "desc" },
});
} catch {
campaigns = [];
}
let campaigns: Campaign[] = [];
try {
campaigns = await prisma.calendarCampaigns.findMany({
select: {
id: true,
name: true,
image: true,
startTimestamp: true,
totalDays: true,
lockExpired: true,
enabled: true,
},
orderBy: { id: "desc" },
});
} catch {
campaigns = [];
}
const rewardCounts = new Map<number, number>();
let totalRewards = 0;
try {
const rewards = await prisma.calendarRewards.findMany({
select: { campaignId: true },
});
totalRewards = rewards.length;
for (const r of rewards) {
rewardCounts.set(r.campaignId, (rewardCounts.get(r.campaignId) ?? 0) + 1);
}
} catch {}
const rewardCounts = new Map<number, number>();
let totalRewards = 0;
try {
const rewards = await prisma.calendarRewards.findMany({
select: { campaignId: true },
});
totalRewards = rewards.length;
for (const r of rewards) {
rewardCounts.set(r.campaignId, (rewardCounts.get(r.campaignId) ?? 0) + 1);
}
} catch {}
const enabledCount = campaigns.filter((c) => c.enabled === "1").length;
const enabledCount = campaigns.filter((c) => c.enabled === "1").length;
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">
<Calendar size={20} className="text-[var(--admin-accent)]" />
</div>
<div>
<h1 className="m-0 text-xl font-extrabold text-[var(--admin-text)]">{t("title")}</h1>
<p className="m-0 text-xs text-[var(--admin-text-muted)] mt-0.5">
{t("subtitle", { count: campaigns.length })}
</p>
</div>
</div>
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">
<Calendar size={20} className="text-[var(--admin-accent)]" />
</div>
<div>
<h1 className="m-0 text-xl font-extrabold text-[var(--admin-text)]">
{t("title")}
</h1>
<p className="m-0 text-xs text-[var(--admin-text-muted)] mt-0.5">
{t("subtitle", { count: campaigns.length })}
</p>
</div>
</div>
<div className="grid grid-cols-[repeat(auto-fit,minmax(180px,1fr))] gap-3.5 mb-6">
<StatusCard label={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>
<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: fmtDate(c.startTimestamp) })}
{c.lockExpired === "1" ? ` · ${t("locksExpired")}` : ""}
</p>
</div>
</Link>
);
})}
</div>
)}
</main>
);
{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: fmtDate(c.startTimestamp) })}
{c.lockExpired === "1" ? ` · ${t("locksExpired")}` : ""}
</p>
</div>
</Link>
);
})}
</div>
)}
</main>
);
}
+102 -103
View File
@@ -8,120 +8,119 @@ 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;
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;
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[];
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[];
}
export function CatalogDetailTabs({
catalogPage,
items,
baseItems,
catalogNameMap,
childPages,
pageId,
canEdit,
furniDataIdList,
furniDescriptionMap,
furniRevisionMap,
allPages,
interactionTypes,
catalogPage,
items,
baseItems,
catalogNameMap,
childPages,
pageId,
canEdit,
furniDataIdList,
furniDescriptionMap,
furniRevisionMap,
allPages,
interactionTypes,
}: CatalogDetailTabsProps) {
return (
<Tabs defaultValue="settings">
<TabsList>
<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>
return (
<Tabs defaultValue="settings">
<TabsList>
<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="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="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}
/>
</TabsContent>
</Tabs>
);
<TabsContent value="translate" className="mt-4">
<CatalogTranslateTab
baseItems={baseItems}
catalogNameMap={catalogNameMap}
canEdit={canEdit}
furniDataIdList={furniDataIdList}
furniDescriptionMap={furniDescriptionMap}
furniRevisionMap={furniRevisionMap}
/>
</TabsContent>
</Tabs>
);
}
@@ -4,4 +4,7 @@
*/
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";
export type {
BaseItemData,
CatalogItemData,
} from "./catalog-items-table/types";
File diff suppressed because it is too large. Load diff
@@ -4,231 +4,278 @@ 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,
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 {
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,
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;
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>
);
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,
onChange,
types,
disabled,
}: {
value: string;
onChange: (v: string) => void;
types: string[];
disabled?: boolean;
value: string;
onChange: (v: string) => void;
types: string[];
disabled?: boolean;
}) {
const [open, setOpen] = useState(false);
const [inputValue, setInputValue] = useState("");
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]);
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>
);
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,
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[];
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 [open, setOpen] = useState(false);
const options = useMemo(() => pages.filter((p) => p.id !== currentPageId), [pages, currentPageId]);
const options = useMemo(
() => pages.filter((p) => p.id !== currentPageId),
[pages, currentPageId],
);
const selectedPage = value ? pages.find((p) => p.id === value) : null;
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]);
// 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]);
// 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>
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>
)}
{/* 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>
);
{/* 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>
);
}
@@ -3,40 +3,46 @@
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>
);
}
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);
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";
}
}
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>
);
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>
);
}
@@ -2,11 +2,17 @@
import { Loader2, Music } from "lucide-react";
import { useEffect, useState } from "react";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import {
getCachedSongPickerOptions,
loadSongPickerOptions,
type SoundtrackOption,
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
getCachedSongPickerOptions,
loadSongPickerOptions,
type SoundtrackOption,
} from "@/lib/client-cache/song-picker-cache";
// Re-export so existing `import { invalidateSongPickerCache } from '.../song-picker'`
@@ -14,73 +20,79 @@ import {
export { invalidateSongPickerCache } from "@/lib/client-cache/song-picker-cache";
interface Props {
value: number;
onChange: (id: number) => void;
disabled?: boolean;
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")}`;
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);
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;
};
}, []);
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>
);
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>
);
}
@@ -1,85 +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;
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;
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;
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)]" },
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: "",
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" };
return (
POINTS_TYPES[type] || { label: `Type ${type}`, color: "theme-text-muted" }
);
}
File diff suppressed because it is too large. Load diff
@@ -1,16 +1,16 @@
"use client";
import {
AlertTriangle,
CheckCircle2,
Database,
FileJson,
Filter,
Loader2,
RotateCcw,
Save,
ShoppingCart,
Sparkles,
AlertTriangle,
CheckCircle2,
Database,
FileJson,
Filter,
Loader2,
RotateCcw,
Save,
ShoppingCart,
Sparkles,
} from "lucide-react";
import { useRouter } from "next/navigation";
import { useCallback, useMemo, useState } from "react";
@@ -21,467 +21,541 @@ 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 {
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 { FurniIcon } from "./catalog-items-table";
interface BaseItemForTranslation {
id: number;
publicName: string;
itemName: string;
spriteId: number;
type: string;
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 }>;
baseItems: BaseItemForTranslation[];
catalogNameMap: Record<number, string>;
canEdit: boolean;
furniDataIdList: number[];
furniDescriptionMap: Record<number, string>;
furniRevisionMap?: Record<number, { classname: string; revision: number }>;
}
type FilterMode = "all" | "missing" | "modified";
export function CatalogTranslateTab({
baseItems,
catalogNameMap,
canEdit,
furniDataIdList,
furniDescriptionMap,
furniRevisionMap = {},
baseItems,
catalogNameMap,
canEdit,
furniDataIdList,
furniDescriptionMap,
furniRevisionMap = {},
}: CatalogTranslateTabProps) {
const router = useRouter();
const { run, isPending } = useServerAction();
const [isSuggesting, setIsSuggesting] = useState(false);
const [filterMode, setFilterMode] = useState<FilterMode>("all");
const router = useRouter();
const { run, isPending } = useServerAction();
const [isSuggesting, setIsSuggesting] = useState(false);
const [filterMode, setFilterMode] = useState<FilterMode>("all");
const furniDataIdSet = useMemo(() => new Set(furniDataIdList), [furniDataIdList]);
const [selected, setSelected] = useState<Set<number>>(new Set());
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])),
);
// ── 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] ?? ""])),
);
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],
);
// 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 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 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 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 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 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]);
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]);
// 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 handleNameChange(id: number, value: string) {
setEditedNames((prev) => ({ ...prev, [id]: value }));
}
function handleDescChange(id: number, value: string) {
setEditedDescs((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] ?? ""])),
);
}
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);
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;
}
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 }> = {};
// Batch in chunks of 50 to avoid too-long URLs
const CHUNK_SIZE = 50;
const allSuggestions: Record<
string,
{ name: string; description: string }
> = {};
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();
Object.assign(allSuggestions, data.suggestions || {});
}
}
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();
Object.assign(allSuggestions, data.suggestions || {});
}
}
// Apply suggestions to items that have a match (names + descriptions)
let nameCount = 0;
let descCount = 0;
const newNames = { ...editedNames };
const newDescs = { ...editedDescs };
// 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;
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++;
}
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++;
}
}
const currentDesc = furniDescriptionMap[item.id] ?? "";
if (suggestion.description && suggestion.description !== currentDesc) {
newDescs[item.id] = suggestion.description;
descCount++;
}
}
setEditedNames(newNames);
setEditedDescs(newDescs);
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(`HabboFurni: ${parts.join(" + ")} suggested from habbo.it. Review and save.`);
} else {
toast.info(
`HabboFurni: No new suggestions found (${Object.keys(allSuggestions).length} looked up, all already match).`,
);
}
} catch (err) {
toast.error(`HabboFurni error: ${(err as Error).message}`);
} finally {
setIsSuggesting(false);
}
}
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(
`HabboFurni: ${parts.join(" + ")} suggested from habbo.it. Review and save.`,
);
} else {
toast.info(
`HabboFurni: No new suggestions found (${Object.keys(allSuggestions).length} looked up, all already match).`,
);
}
} catch (err) {
toast.error(`HabboFurni error: ${(err as Error).message}`);
} finally {
setIsSuggesting(false);
}
}
async function handleSaveAll() {
if (changedItems.length === 0) return;
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();
},
},
);
}
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 Italian names + descriptions from HabboFurni API (habbo.it)"
>
{isSuggesting ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Sparkles className="mr-2 h-4 w-4" />
)}
Suggest IT{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>
)}
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 Italian names + descriptions from HabboFurni API (habbo.it)"
>
{isSuggesting ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Sparkles className="mr-2 h-4 w-4" />
)}
Suggest IT{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>
);
{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>
);
}
+34 -34
View File
@@ -1,41 +1,41 @@
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>
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>
{/* 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 }).map((_, i) => (
<Skeleton key={i} className="h-9 w-24 rounded-md" />
))}
</div>
{/* Tabs */}
<div className="space-y-4">
<div className="flex gap-2 border-b pb-2">
{Array.from({ length: 4 }).map((_, i) => (
<Skeleton key={i} 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 }).map((_, i) => (
<div key={i} className="space-y-2">
<Skeleton className="h-4 w-24" />
<Skeleton className="h-10 w-full" />
</div>
))}
</div>
</div>
</div>
);
{/* Tab content */}
<div className="rounded-lg border bg-card p-6 space-y-4">
{Array.from({ length: 6 }).map((_, i) => (
<div key={i} className="space-y-2">
<Skeleton className="h-4 w-24" />
<Skeleton className="h-10 w-full" />
</div>
))}
</div>
</div>
</div>
);
}
+107 -97
View File
@@ -9,112 +9,122 @@ import { getAncestors } from "@/lib/services/catalog-tree";
import { CatalogDetailTabs } from "./catalog-detail-tabs";
export default async function CatalogEditPage({
params,
params,
}: {
params: Promise<{ locale: string; id: string }>;
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 { 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 pageId = parseInt(id, 10);
if (Number.isNaN(pageId)) notFound();
const catalogPage = await prisma.catalogPages.findUnique({
where: { id: pageId },
});
const catalogPage = await prisma.catalogPages.findUnique({
where: { id: pageId },
});
if (!catalogPage) notFound();
if (!catalogPage) notFound();
const [children, itemsData, ancestors] = await Promise.all([
prisma.catalogPages.findMany({
where: { parentId: pageId },
orderBy: { orderNum: "asc" },
select: { id: true, caption: true, enabled: true },
}),
loadCatalogItemsData(pageId),
getAncestors(catalogPage.parentId),
]);
const [children, itemsData, ancestors] = await Promise.all([
prisma.catalogPages.findMany({
where: { parentId: pageId },
orderBy: { orderNum: "asc" },
select: { id: true, caption: true, enabled: true },
}),
loadCatalogItemsData(pageId),
getAncestors(catalogPage.parentId),
]);
const {
items: catalogItems,
baseItems,
catalogNameMap,
furniDataIdList,
furniDescriptionMap,
furniRevisionMap,
interactionTypes,
allPages,
} = itemsData;
const {
items: catalogItems,
baseItems,
catalogNameMap,
furniDataIdList,
furniDescriptionMap,
furniRevisionMap,
interactionTypes,
allPages,
} = itemsData;
const canEdit = canAccess(permissions, PERMS.CATALOG_EDIT, session.user.rank);
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>
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>
{/* 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}
/>
</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}
/>
</div>
);
}
@@ -4,58 +4,58 @@ import { prisma } from "@/lib/prisma";
import { BcPageDetail } from "../bc-manager";
export default async function BcPageDetailPage({
params: paramsPromise,
params: paramsPromise,
}: {
params: Promise<{ locale: string; id: string }>;
params: Promise<{ locale: string; id: string }>;
}) {
const { id: idStr } = await paramsPromise;
const id = parseInt(idStr, 10);
if (Number.isNaN(id)) notFound();
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 { 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 canEdit = canAccess(permissions, PERMS.CATALOG_EDIT, session.user.rank);
const bcPage = await prisma.catalogPagesBc.findUnique({ where: { id } });
if (!bcPage) notFound();
const bcPage = await prisma.catalogPagesBc.findUnique({ where: { id } });
if (!bcPage) notFound();
const bcItems = await prisma.catalogItemsBc.findMany({
where: { pageId: id },
orderBy: { orderNumber: "asc" },
});
const bcItems = await prisma.catalogItemsBc.findMany({
where: { pageId: id },
orderBy: { orderNumber: "asc" },
});
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}
/>
);
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}
/>
);
}
File diff suppressed because it is too large. Load diff
+1 -1
View File
@@ -1,5 +1,5 @@
import { redirect } from "next/navigation";
export default function BuilderClubPage() {
redirect("/admin/catalog?catalog=bc");
redirect("/admin/catalog?catalog=bc");
}
+115 -93
View File
@@ -6,111 +6,133 @@ 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, DialogTrigger } from "@/components/ui/dialog";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogTrigger,
} 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 {
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";
catalogType?: "normal" | "bc";
}
export function CatalogActions({ catalogType = "normal" }: CatalogActionsProps) {
const { run, isPending } = useServerAction();
const [open, setOpen] = useState(false);
const [caption, setCaption] = useState("");
const [parentId, setParentId] = useState("-1");
const [pageLayout, setPageLayout] = useState("default_3x3");
const isBc = catalogType === "bc";
export function CatalogActions({
catalogType = "normal",
}: CatalogActionsProps) {
const { run, isPending } = useServerAction();
const [open, setOpen] = useState(false);
const [caption, setCaption] = useState("");
const [parentId, setParentId] = useState("-1");
const [pageLayout, setPageLayout] = useState("default_3x3");
const isBc = catalogType === "bc";
function handleCreate() {
if (!caption.trim()) {
toast.error("Caption is required");
return;
}
function handleCreate() {
if (!caption.trim()) {
toast.error("Caption is required");
return;
}
const input = {
caption: caption.trim(),
parentId: parseInt(parentId, 10),
pageLayout,
};
const input = {
caption: caption.trim(),
parentId: parseInt(parentId, 10),
pageLayout,
};
const fn = isBc ? () => createBcPage(input) : () => createCatalogPage(input);
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}`;
}
},
});
}
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}>
<DialogTrigger asChild>
<Button>
<Plus className="mr-2 h-4 w-4" />
New Page
</Button>
</DialogTrigger>
<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>
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button>
<Plus className="mr-2 h-4 w-4" />
New Page
</Button>
</DialogTrigger>
<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>
);
<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>
);
}
+173 -144
View File
@@ -11,157 +11,186 @@ import { useServerAction } from "@/hooks/use-server-action";
import type { DataTableColumn, PaginatedResult } from "@/types";
interface CatalogPageRow {
id: number;
caption: string;
parentId: number;
parentCaption: string;
pageLayout: string;
enabled: string;
visible: string;
minRank: number;
orderNum: number;
itemCount: number;
childCount: number;
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";
data: PaginatedResult<CatalogPageRow>;
canEdit: boolean;
catalogType?: "normal" | "bc";
}
export function CatalogTable({ data, canEdit, catalogType = "normal" }: CatalogTableProps) {
const { run, isPending } = useServerAction();
const isBc = catalogType === "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}`);
// 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." });
}
}
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>
),
},
];
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>
),
},
];
return (
<DataTable
data={data}
columns={columns}
searchPlaceholder="Search by caption, layout or item..."
bulkActions={
canEdit ? [{ label: "Delete Selected", value: "delete", variant: "destructive" }] : undefined
}
/>
);
return (
<DataTable
data={data}
columns={columns}
searchPlaceholder="Search by caption, layout or item..."
bulkActions={
canEdit
? [
{
label: "Delete Selected",
value: "delete",
variant: "destructive",
},
]
: undefined
}
/>
);
}
+85 -70
View File
@@ -6,80 +6,95 @@ import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
interface CatalogViewToggleProps {
currentView: string;
catalogType?: string;
currentView: string;
catalogType?: string;
}
export function CatalogViewToggle({ currentView, catalogType = "normal" }: CatalogViewToggleProps) {
const router = useRouter();
const searchParams = useSearchParams();
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 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()}`);
}
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>
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>
);
{/* 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>
);
}
+39 -39
View File
@@ -1,44 +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>
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 }).map((_, i) => (
<div
key={i}
className="flex items-center gap-2 px-2 py-1.5"
style={{ paddingLeft: `${(i % 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>
);
{/* 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 }).map((_, i) => (
<div
key={i}
className="flex items-center gap-2 px-2 py-1.5"
style={{ paddingLeft: `${(i % 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>
);
}
+373 -331
View File
@@ -10,283 +10,313 @@ import { CatalogTable } from "./catalog-table";
import { CatalogViewToggle } from "./catalog-view-toggle";
const CatalogManagerDialog = dynamic(() =>
import("@/components/admin/catalog-manager/catalog-manager-dialog").then((m) => ({
default: m.CatalogManagerDialog,
})),
import("@/components/admin/catalog-manager/catalog-manager-dialog").then(
(m) => ({
default: m.CatalogManagerDialog,
}),
),
);
export default async function CatalogPagesPage({
params: paramsPromise,
searchParams,
params: paramsPromise,
searchParams,
}: {
params: Promise<{ locale: string }>;
searchParams: Promise<Record<string, string>>;
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");
}
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 canEdit = canAccess(permissions, PERMS.CATALOG_EDIT, session.user.rank);
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";
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";
// ── Builder Club mode ──────────────────────────────────────────
type TreeNodeShape = {
id: number;
caption: string;
parentId: number;
enabled: string;
visible: string;
iconImage: number;
iconColor: number;
pageLayout: string;
childCount: number;
itemCount: number;
};
// ── Builder Club mode ──────────────────────────────────────────
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 [totalBcPages, totalBcItems, enabledBcPages] = await Promise.all([
prisma.catalogPagesBc.count(),
prisma.catalogItemsBc.count(),
prisma.catalogPagesBc.count({ where: { enabled: "1" } }),
]);
if (catalogType === "bc") {
const [totalBcPages, totalBcItems, enabledBcPages] = await Promise.all([
prisma.catalogPagesBc.count(),
prisma.catalogItemsBc.count(),
prisma.catalogPagesBc.count({ where: { enabled: "1" } }),
]);
// ── BC tree view ──────────────────────────────────────────────
let bcRootPages: TreeNodeShape[] = [];
if (viewMode === "tree") {
const bcRootRaw = await prisma.catalogPagesBc.findMany({
where: { OR: [{ parentId: -1 }, { parentId: 0 }] },
orderBy: { orderNum: "asc" },
});
const bcRootIds = bcRootRaw.map((p) => p.id);
const [bcChildCounts, bcItemCounts] = await Promise.all([
bcRootIds.length > 0
? prisma.catalogPagesBc.groupBy({
by: ["parentId"],
where: { parentId: { in: bcRootIds } },
_count: true,
})
: [],
bcRootIds.length > 0
? prisma.catalogItemsBc.groupBy({
by: ["pageId"],
where: { pageId: { in: bcRootIds } },
_count: true,
})
: [],
]);
const bcChildMap = Object.fromEntries(bcChildCounts.map((c) => [c.parentId, c._count]));
const bcItemMap = Object.fromEntries(bcItemCounts.map((c) => [c.pageId, c._count]));
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,
}));
}
// ── BC tree view ──────────────────────────────────────────────
let bcRootPages: TreeNodeShape[] = [];
if (viewMode === "tree") {
const bcRootRaw = await prisma.catalogPagesBc.findMany({
where: { OR: [{ parentId: -1 }, { parentId: 0 }] },
orderBy: { orderNum: "asc" },
});
const bcRootIds = bcRootRaw.map((p) => p.id);
const [bcChildCounts, bcItemCounts] = await Promise.all([
bcRootIds.length > 0
? prisma.catalogPagesBc.groupBy({
by: ["parentId"],
where: { parentId: { in: bcRootIds } },
_count: true,
})
: [],
bcRootIds.length > 0
? prisma.catalogItemsBc.groupBy({
by: ["pageId"],
where: { pageId: { in: bcRootIds } },
_count: true,
})
: [],
]);
const bcChildMap = Object.fromEntries(
bcChildCounts.map((c) => [c.parentId, c._count]),
);
const bcItemMap = Object.fromEntries(
bcItemCounts.map((c) => [c.pageId, c._count]),
);
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,
}));
}
// ── BC table view ─────────────────────────────────────────────
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 };
// ── BC table view ─────────────────────────────────────────────
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;
if (viewMode === "table") {
const parentFilter = sp.get("parent");
const parentId =
parentFilter !== null ? parseInt(parentFilter, 10) : undefined;
const where: Record<string, unknown> = {};
const hasParent = parentId !== undefined && !Number.isNaN(parentId);
if (search) {
const pattern = { contains: search };
where.OR = [{ caption: pattern }];
}
if (hasParent) where.parentId = parentId;
const where: Record<string, unknown> = {};
const hasParent = parentId !== undefined && !Number.isNaN(parentId);
if (search) {
const pattern = { contains: search };
where.OR = [{ caption: pattern }];
}
if (hasParent) where.parentId = parentId;
// Prisma sort key is snake_case for BC model. Translate known keys.
const sortKeyMap: Record<string, string> = {
parentId: "parentId",
pageLayout: "pageLayout",
orderNum: "orderNum",
};
const sortKey = sort ? (sortKeyMap[sort] ?? sort) : "orderNum";
const orderBy = { [sortKey]: sort ? order : "asc" };
// Prisma sort key is snake_case for BC model. Translate known keys.
const sortKeyMap: Record<string, string> = {
parentId: "parentId",
pageLayout: "pageLayout",
orderNum: "orderNum",
};
const sortKey = sort ? (sortKeyMap[sort] ?? sort) : "orderNum";
const orderBy = { [sortKey]: sort ? order : "asc" };
const [pages, total] = await Promise.all([
prisma.catalogPagesBc.findMany({
where,
orderBy,
skip: (page - 1) * perPage,
take: perPage,
}),
prisma.catalogPagesBc.count({ where }),
]);
const [pages, total] = await Promise.all([
prisma.catalogPagesBc.findMany({
where,
orderBy,
skip: (page - 1) * perPage,
take: perPage,
}),
prisma.catalogPagesBc.count({ where }),
]);
bcPagination = calcPagination(total, page, perPage);
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
? prisma.catalogPagesBc.groupBy({
by: ["parentId"],
where: { parentId: { in: pageIds } },
_count: true,
})
: [],
pageIds.length > 0
? prisma.catalogItemsBc.groupBy({
by: ["pageId"],
where: { pageId: { in: pageIds } },
_count: true,
})
: [],
parentIds.length > 0
? prisma.catalogPagesBc.findMany({
where: { id: { in: parentIds } },
select: { id: true, caption: true },
})
: [],
]);
const childCountMap = Object.fromEntries(childCounts.map((c) => [c.parentId, c._count]));
const itemCountMap = Object.fromEntries(itemCounts.map((c) => [c.pageId, c._count]));
const parentNameMap = Object.fromEntries(parentPages.map((p) => [p.id, p.caption]));
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
? prisma.catalogPagesBc.groupBy({
by: ["parentId"],
where: { parentId: { in: pageIds } },
_count: true,
})
: [],
pageIds.length > 0
? prisma.catalogItemsBc.groupBy({
by: ["pageId"],
where: { pageId: { in: pageIds } },
_count: true,
})
: [],
parentIds.length > 0
? prisma.catalogPagesBc.findMany({
where: { id: { in: parentIds } },
select: { id: true, caption: true },
})
: [],
]);
const childCountMap = Object.fromEntries(
childCounts.map((c) => [c.parentId, c._count]),
);
const itemCountMap = Object.fromEntries(
itemCounts.map((c) => [c.pageId, c._count]),
);
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, // BC pages have no min rank column
orderNum: p.orderNum,
itemCount: itemCountMap[p.id] ?? 0,
childCount: childCountMap[p.id] ?? 0,
}));
}
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, // BC pages have no min rank column
orderNum: p.orderNum,
itemCount: itemCountMap[p.id] ?? 0,
childCount: childCountMap[p.id] ?? 0,
}));
}
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold tracking-tight">Catalog Pages</h1>
<p className="text-muted-foreground">
{totalBcPages} BC pages ({enabledBcPages} enabled) &middot; {totalBcItems} items
</p>
</div>
<div className="flex items-center gap-2">
<CatalogViewToggle currentView={viewMode} catalogType="bc" />
{canEdit && <CatalogActions catalogType="bc" />}
</div>
</div>
{viewMode === "tree" ? (
<CatalogTree rootPages={bcRootPages} catalogType="bc" />
) : (
<CatalogTable data={{ rows: bcRows, ...bcPagination }} canEdit={canEdit} catalogType="bc" />
)}
</div>
);
}
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold tracking-tight">Catalog Pages</h1>
<p className="text-muted-foreground">
{totalBcPages} BC pages ({enabledBcPages} enabled) &middot;{" "}
{totalBcItems} items
</p>
</div>
<div className="flex items-center gap-2">
<CatalogViewToggle currentView={viewMode} catalogType="bc" />
{canEdit && <CatalogActions catalogType="bc" />}
</div>
</div>
{viewMode === "tree" ? (
<CatalogTree rootPages={bcRootPages} catalogType="bc" />
) : (
<CatalogTable
data={{ rows: bcRows, ...bcPagination }}
canEdit={canEdit}
catalogType="bc"
/>
)}
</div>
);
}
// ── Normal catalog mode ────────────────────────────────────────
// ── Normal catalog mode ────────────────────────────────────────
// Global stats (lightweight counts)
const [totalPages, totalItems, enabledPages] = await Promise.all([
prisma.catalogPages.count(),
prisma.catalogItems.count(),
prisma.catalogPages.count({ where: { enabled: "1" } }),
]);
// Global stats (lightweight counts)
const [totalPages, totalItems, enabledPages] = await Promise.all([
prisma.catalogPages.count(),
prisma.catalogItems.count(),
prisma.catalogPages.count({ where: { enabled: "1" } }),
]);
// ── Tree view data ──────────────────────────────────────────────
let rootPages: TreeNodeShape[] = [];
if (viewMode === "tree") {
const rootPagesRaw = await prisma.catalogPages.findMany({
where: { parentId: -1 },
orderBy: { orderNum: "asc" },
});
const rootIds = rootPagesRaw.map((p) => p.id);
const [rootChildCounts, rootItemCounts] = await Promise.all([
rootIds.length > 0
? prisma.catalogPages.groupBy({
by: ["parentId"],
where: { parentId: { in: rootIds } },
_count: true,
})
: [],
rootIds.length > 0
? prisma.catalogItems.groupBy({
by: ["pageId"],
where: { pageId: { in: rootIds } },
_count: true,
})
: [],
]);
const rootChildMap = Object.fromEntries(rootChildCounts.map((c) => [c.parentId, c._count]));
const rootItemMap = Object.fromEntries(rootItemCounts.map((c) => [c.pageId, c._count]));
rootPages = 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,
}));
}
// ── Tree view data ──────────────────────────────────────────────
let rootPages: TreeNodeShape[] = [];
if (viewMode === "tree") {
const rootPagesRaw = await prisma.catalogPages.findMany({
where: { parentId: -1 },
orderBy: { orderNum: "asc" },
});
const rootIds = rootPagesRaw.map((p) => p.id);
const [rootChildCounts, rootItemCounts] = await Promise.all([
rootIds.length > 0
? prisma.catalogPages.groupBy({
by: ["parentId"],
where: { parentId: { in: rootIds } },
_count: true,
})
: [],
rootIds.length > 0
? prisma.catalogItems.groupBy({
by: ["pageId"],
where: { pageId: { in: rootIds } },
_count: true,
})
: [],
]);
const rootChildMap = Object.fromEntries(
rootChildCounts.map((c) => [c.parentId, c._count]),
);
const rootItemMap = Object.fromEntries(
rootItemCounts.map((c) => [c.pageId, c._count]),
);
rootPages = 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,
}));
}
// ── Table view data (skip if tree) ────────────────────────────
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 };
// ── Table view data (skip if tree) ────────────────────────────
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;
if (viewMode === "table") {
const parentFilter = sp.get("parent");
const parentId =
parentFilter !== null ? parseInt(parentFilter, 10) : undefined;
const where: Record<string, unknown> = {};
const hasParent = parentId !== undefined && !Number.isNaN(parentId);
if (search) {
const searchPattern = `%${search}%`;
const parentClause = hasParent ? Prisma.sql`AND cp.parentId = ${parentId}` : Prisma.empty;
const where: Record<string, unknown> = {};
const hasParent = parentId !== undefined && !Number.isNaN(parentId);
if (search) {
const searchPattern = `%${search}%`;
const parentClause = hasParent
? Prisma.sql`AND cp.parentId = ${parentId}`
: Prisma.empty;
const matchedIds = await prisma.$queryRaw<{ id: number }[]>(Prisma.sql`
const matchedIds = await prisma.$queryRaw<{ id: number }[]>(Prisma.sql`
SELECT DISTINCT cp.id FROM catalog_pages cp
LEFT JOIN catalog_items ci ON ci.pageId = cp.id
LEFT JOIN items_base ib ON FIND_IN_SET(ib.id, REPLACE(ci.itemIds, ';', ','))
@@ -297,90 +327,102 @@ export default async function CatalogPagesPage({
OR ib.item_name LIKE ${searchPattern})
${parentClause}
`);
where.id = { in: matchedIds.map((r) => r.id) };
} else if (hasParent) {
where.parentId = parentId;
}
where.id = { in: matchedIds.map((r) => r.id) };
} else if (hasParent) {
where.parentId = parentId;
}
const orderBy = sort ? { [sort]: order } : { orderNum: "asc" as const };
const orderBy = sort ? { [sort]: order } : { orderNum: "asc" as const };
const [pages, total] = await Promise.all([
prisma.catalogPages.findMany({
where,
orderBy,
skip: (page - 1) * perPage,
take: perPage,
}),
prisma.catalogPages.count({ where }),
]);
const [pages, total] = await Promise.all([
prisma.catalogPages.findMany({
where,
orderBy,
skip: (page - 1) * perPage,
take: perPage,
}),
prisma.catalogPages.count({ where }),
]);
pagination = calcPagination(total, page, perPage);
pagination = calcPagination(total, page, perPage);
// Count children and items per page, and resolve parent names in parallel
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
? prisma.catalogPages.groupBy({
by: ["parentId"],
where: { parentId: { in: pageIds } },
_count: true,
})
: [],
pageIds.length > 0
? prisma.catalogItems.groupBy({
by: ["pageId"],
where: { pageId: { in: pageIds } },
_count: true,
})
: [],
parentIds.length > 0
? prisma.catalogPages.findMany({
where: { id: { in: parentIds } },
select: { id: true, caption: true },
})
: [],
]);
const childCountMap = Object.fromEntries(childCounts.map((c) => [c.parentId, c._count]));
const itemCountMap = Object.fromEntries(itemCounts.map((c) => [c.pageId, c._count]));
const parentNameMap = Object.fromEntries(parentPages.map((p) => [p.id, p.caption]));
// Count children and items per page, and resolve parent names in parallel
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
? prisma.catalogPages.groupBy({
by: ["parentId"],
where: { parentId: { in: pageIds } },
_count: true,
})
: [],
pageIds.length > 0
? prisma.catalogItems.groupBy({
by: ["pageId"],
where: { pageId: { in: pageIds } },
_count: true,
})
: [],
parentIds.length > 0
? prisma.catalogPages.findMany({
where: { id: { in: parentIds } },
select: { id: true, caption: true },
})
: [],
]);
const childCountMap = Object.fromEntries(
childCounts.map((c) => [c.parentId, c._count]),
);
const itemCountMap = Object.fromEntries(
itemCounts.map((c) => [c.pageId, c._count]),
);
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,
}));
}
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 (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold tracking-tight">Catalog Pages</h1>
<p className="text-muted-foreground">
{totalPages} pages ({enabledPages} enabled) &middot; {totalItems} items
</p>
</div>
<div className="flex items-center gap-2">
<CatalogViewToggle currentView={viewMode} catalogType="normal" />
{canEdit && <CatalogManagerDialog />}
{canEdit && <CatalogActions />}
</div>
</div>
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold tracking-tight">Catalog Pages</h1>
<p className="text-muted-foreground">
{totalPages} pages ({enabledPages} enabled) &middot; {totalItems}{" "}
items
</p>
</div>
<div className="flex items-center gap-2">
<CatalogViewToggle currentView={viewMode} catalogType="normal" />
{canEdit && <CatalogManagerDialog />}
{canEdit && <CatalogActions />}
</div>
</div>
{viewMode === "tree" ? (
<CatalogTree rootPages={rootPages} catalogType="normal" />
) : (
<CatalogTable data={{ rows, ...pagination }} canEdit={canEdit} />
)}
</div>
);
{viewMode === "tree" ? (
<CatalogTree rootPages={rootPages} catalogType="normal" />
) : (
<CatalogTable data={{ rows, ...pagination }} canEdit={canEdit} />
)}
</div>
);
}
File diff suppressed because it is too large. Load diff
+78 -68
View File
@@ -4,80 +4,90 @@ import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
import { prisma } from "@/lib/prisma";
export default async function EmulatorErrorsPage({
searchParams,
searchParams,
}: {
searchParams: Promise<Record<string, string>>;
searchParams: Promise<Record<string, string>>;
}) {
const { session, permissions } = await getAdminContext();
if (!canAccess(permissions, PERMS.DEVOPS_VIEW, session.user.rank)) {
redirect("/admin");
}
const { session, permissions } = await getAdminContext();
if (!canAccess(permissions, PERMS.DEVOPS_VIEW, session.user.rank)) {
redirect("/admin");
}
const params = await searchParams;
const page = Math.max(1, Number(params.page) || 1);
const perPage = 50;
const params = await searchParams;
const page = Math.max(1, Number(params.page) || 1);
const perPage = 50;
const [errors, total] = await Promise.all([
prisma.emulatorErrors.findMany({
orderBy: { id: "desc" },
skip: (page - 1) * perPage,
take: perPage,
}),
prisma.emulatorErrors.count(),
]);
const [errors, total] = await Promise.all([
prisma.emulatorErrors.findMany({
orderBy: { id: "desc" },
skip: (page - 1) * perPage,
take: perPage,
}),
prisma.emulatorErrors.count(),
]);
const lastPage = Math.ceil(total / perPage);
const lastPage = Math.ceil(total / perPage);
return (
<div className="space-y-6">
<div>
<h1 className="text-3xl font-bold tracking-tight">Emulator Errors</h1>
<p className="text-muted-foreground">
{total} total errors — showing page {page} of {lastPage}
</p>
</div>
return (
<div className="space-y-6">
<div>
<h1 className="text-3xl font-bold tracking-tight">Emulator Errors</h1>
<p className="text-muted-foreground">
{total} total errors — showing page {page} of {lastPage}
</p>
</div>
<Card>
<CardContent className="p-0">
{errors.length === 0 ? (
<div className="p-6 text-center text-muted-foreground">No emulator errors found</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>
<Card>
<CardContent className="p-0">
{errors.length === 0 ? (
<div className="p-6 text-center text-muted-foreground">
No emulator errors found
</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>
);
{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>
);
}
+53 -33
View File
@@ -6,40 +6,60 @@ 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);
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);
}
}
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();
}, []);
// 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>
);
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>
);
}
+135 -121
View File
@@ -7,141 +7,155 @@ import { prisma } from "@/lib/prisma";
import { HealthCheckClient } from "./health-check-client";
async function getDevOpsData() {
const now = Math.floor(Date.now() / 1000);
const dayAgo = now - 86400;
const now = Math.floor(Date.now() / 1000);
const dayAgo = now - 86400;
// DB health — simple count query
let dbStatus: "healthy" | "error" = "healthy";
let dbLatency = 0;
try {
const start = Date.now();
await prisma.user.count({ take: 1 });
dbLatency = Date.now() - start;
} catch {
dbStatus = "error";
}
// DB health — simple count query
let dbStatus: "healthy" | "error" = "healthy";
let dbLatency = 0;
try {
const start = Date.now();
await prisma.user.count({ take: 1 });
dbLatency = Date.now() - start;
} catch {
dbStatus = "error";
}
// Recent emulator errors
const recentErrors = await prisma.emulatorErrors.findMany({
orderBy: { id: "desc" },
take: 20,
});
// Recent emulator errors
const recentErrors = await prisma.emulatorErrors.findMany({
orderBy: { id: "desc" },
take: 20,
});
const errorsToday = await prisma.emulatorErrors.count({
where: { timestamp: { gte: dayAgo } },
});
const errorsToday = await prisma.emulatorErrors.count({
where: { timestamp: { gte: dayAgo } },
});
// Emulator settings (version info)
const emuVersion = await prisma.emulatorSettings.findFirst({
where: { key: "version" },
});
// Emulator settings (version info)
const emuVersion = await prisma.emulatorSettings.findFirst({
where: { key: "version" },
});
// Online user count as proxy for emulator health
const onlineUsers = await prisma.user.count({ where: { online: "1" } });
// Online user count as proxy for emulator health
const onlineUsers = await prisma.user.count({ where: { online: "1" } });
return {
dbStatus,
dbLatency,
recentErrors,
errorsToday,
emuVersion: emuVersion?.value ?? "Unknown",
onlineUsers,
};
return {
dbStatus,
dbLatency,
recentErrors,
errorsToday,
emuVersion: emuVersion?.value ?? "Unknown",
onlineUsers,
};
}
export default async function DevOpsPage() {
const { session, permissions } = await getAdminContext();
if (!canAccess(permissions, PERMS.DEVOPS_VIEW, session.user.rank)) {
redirect("/admin");
}
const { session, permissions } = await getAdminContext();
if (!canAccess(permissions, PERMS.DEVOPS_VIEW, session.user.rank)) {
redirect("/admin");
}
const data = await getDevOpsData();
const data = await getDevOpsData();
return (
<div className="space-y-6">
<div>
<h1 className="text-3xl font-bold tracking-tight">Health Status</h1>
<p className="text-muted-foreground">System health, emulator status and error monitoring</p>
</div>
return (
<div className="space-y-6">
<div>
<h1 className="text-3xl font-bold tracking-tight">Health Status</h1>
<p className="text-muted-foreground">
System health, emulator status and error monitoring
</p>
</div>
{/* Status cards */}
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
<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>
{/* Status cards */}
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
<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">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">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">Current active connections</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">
Current active connections
</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>
<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 ? (
<p className="text-sm text-muted-foreground">No recent errors</p>
) : (
<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>
);
{/* 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 ? (
<p className="text-sm text-muted-foreground">No recent errors</p>
) : (
<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>
);
}
+145 -120
View File
@@ -1,133 +1,158 @@
import { getTranslations } from "next-intl/server";
import {
createEmailTemplate,
deleteEmailTemplate,
updateEmailTemplate,
createEmailTemplate,
deleteEmailTemplate,
updateEmailTemplate,
} from "@/actions/admin-email-templates";
import { prisma } from "@/lib/prisma";
export const dynamic = "force-dynamic";
export default async function AdminEmailTemplates() {
const t = await getTranslations("pages.admin.emailTemplates");
const t = await getTranslations("pages.admin.emailTemplates");
let templates: Awaited<ReturnType<typeof prisma.emailTemplates.findMany>> = [];
try {
templates = await prisma.emailTemplates.findMany({ orderBy: { name: "asc" } });
} catch {
templates = [];
}
let templates: Awaited<ReturnType<typeof prisma.emailTemplates.findMany>> =
[];
try {
templates = await prisma.emailTemplates.findMany({
orderBy: { name: "asc" },
});
} catch {
templates = [];
}
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">
<span className="text-lg">✉️</span>
</div>
<div>
<h1 className="m-0 text-xl font-extrabold text-[var(--admin-text)]">{t("title")}</h1>
<p className="m-0 text-xs text-[var(--admin-text-muted)] mt-0.5">
{t.rich("subtitle", { code: (chunks) => <code>{chunks}</code> })}
</p>
</div>
</div>
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">
<span className="text-lg">✉️</span>
</div>
<div>
<h1 className="m-0 text-xl font-extrabold text-[var(--admin-text)]">
{t("title")}
</h1>
<p className="m-0 text-xs text-[var(--admin-text-muted)] mt-0.5">
{t.rich("subtitle", { code: (chunks) => <code>{chunks}</code> })}
</p>
</div>
</div>
<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" className="btn btn-primary">
{t("create")}
</button>
</form>
<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" className="btn btn-primary">
{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" className="btn btn-primary">
{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" className="btn btn-danger">
{t("delete")}
</button>
</form>
</div>
);
})}
</div>
{templates.length === 0 ? <div className="admin-empty">{t("noTemplates")}</div> : null}
</main>
);
<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" className="btn btn-primary">
{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" className="btn btn-danger">
{t("delete")}
</button>
</form>
</div>
);
})}
</div>
{templates.length === 0 ? (
<div className="admin-empty">{t("noTemplates")}</div>
) : null}
</main>
);
}
+172 -130
View File
@@ -1,5 +1,8 @@
import { getTranslations } from "next-intl/server";
import { updateEmulatorSetting, updateEmulatorText } from "@/actions/admin-emulator";
import {
updateEmulatorSetting,
updateEmulatorText,
} from "@/actions/admin-emulator";
import { StatusCard } from "@/components/admin/dashboard";
import { prisma } from "@/lib/prisma";
@@ -8,144 +11,183 @@ export const dynamic = "force-dynamic";
const TEXTS_LIMIT = 300;
export default async function AdminEmulator() {
const t = await getTranslations("pages.admin.emulator");
const t = await getTranslations("pages.admin.emulator");
let settings: { key: string; value: string }[] = [];
try {
settings = await prisma.emulatorSettings.findMany({ orderBy: { key: "asc" } });
} catch {
settings = [];
}
let settings: { key: string; value: string }[] = [];
try {
settings = await prisma.emulatorSettings.findMany({
orderBy: { key: "asc" },
});
} catch {
settings = [];
}
let texts: { key: string; value: string }[] = [];
let textsCount = 0;
try {
textsCount = await prisma.emulatorTexts.count();
} catch {
textsCount = 0;
}
try {
texts = await prisma.emulatorTexts.findMany({
orderBy: { key: "asc" },
take: TEXTS_LIMIT,
});
} catch {
texts = [];
}
let texts: { key: string; value: string }[] = [];
let textsCount = 0;
try {
textsCount = await prisma.emulatorTexts.count();
} catch {
textsCount = 0;
}
try {
texts = await prisma.emulatorTexts.findMany({
orderBy: { key: "asc" },
take: TEXTS_LIMIT,
});
} catch {
texts = [];
}
const textsTruncated = textsCount > texts.length;
const textsTruncated = textsCount > texts.length;
return (
<main>
<h1>{t("title")}</h1>
<p className="text-xs theme-text-muted dark:theme-text-muted mb-3">
{t.rich("subtitle", {
code: (chunks) => <code>{chunks}</code>,
})}
</p>
return (
<main>
<h1>{t("title")}</h1>
<p className="text-xs theme-text-muted dark:theme-text-muted mb-3">
{t.rich("subtitle", {
code: (chunks) => <code>{chunks}</code>,
})}
</p>
<div className="grid grid-cols-[repeat(auto-fit,minmax(180px,1fr))] gap-3.5 mb-6">
<StatusCard label={t("settingsKeys")} value={settings.length} icon="⚙️" />
<StatusCard label={t("textKeys")} value={textsCount} icon="🗒️" />
</div>
<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>
<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" className="btn btn-primary">
{t("save")}
</button>
</div>
</form>
<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" className="btn btn-primary">
{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" className="btn btn-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>
<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" className="btn btn-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>
<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" className="btn btn-primary">
{t("save")}
</button>
</div>
</form>
<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" className="btn btn-primary">
{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" className="btn btn-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>
);
<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" className="btn btn-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>
);
}
+186 -137
View File
@@ -8,154 +8,203 @@ 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 {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { useServerAction } from "@/hooks/use-server-action";
interface Prize {
id: number;
position: number;
prizeType: string;
badgeCode: string | null;
credits: number;
pixels: number;
points: number;
itemId: number | null;
description: string;
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[];
eventId: number;
prizes: Prize[];
}
export function EventPrizesManager({ eventId, prizes }: Props) {
const { run, isPending } = useServerAction();
const [showForm, setShowForm] = useState(false);
const [form, setForm] = useState({
position: prizes.length + 1,
prizeType: "badge" as string,
badgeCode: "",
credits: 0,
pixels: 0,
points: 0,
description: "",
});
const { run, isPending } = useServerAction();
const [showForm, setShowForm] = useState(false);
const [form, setForm] = useState({
position: prizes.length + 1,
prizeType: "badge" as string,
badgeCode: "",
credits: 0,
pixels: 0,
points: 0,
description: "",
});
function handleAdd(e: React.FormEvent) {
e.preventDefault();
// biome-ignore lint/suspicious/noExplicitAny: explicitly chosen here, see surrounding code
run(() => addEventPrize({ eventId, ...form } as any), {
successMessage: "Prize added!",
onSuccess: () => {
setShowForm(false);
setForm({ ...form, position: form.position + 1, badgeCode: "", description: "" });
},
});
}
function handleAdd(e: React.FormEvent) {
e.preventDefault();
// biome-ignore lint/suspicious/noExplicitAny: explicitly chosen here, see surrounding code
run(() => addEventPrize({ eventId, ...form } as any), {
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>Prizes</CardTitle>
<Button size="sm" onClick={() => setShowForm(!showForm)}>
<Plus className="h-4 w-4 mr-1" /> Add Prize
</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 })}>
<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>
)}
return (
<Card>
<CardHeader className="flex flex-row items-center justify-between">
<CardTitle>Prizes</CardTitle>
<Button size="sm" onClick={() => setShowForm(!showForm)}>
<Plus className="h-4 w-4 mr-1" /> Add Prize
</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 })}
>
<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 && (
<p className="text-sm text-muted-foreground text-center py-4">No prizes configured yet.</p>
)}
{prizes.length === 0 && !showForm && (
<p className="text-sm text-muted-foreground text-center py-4">
No prizes configured yet.
</p>
)}
{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>
);
{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>
);
}
@@ -3,38 +3,43 @@
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
interface Registration {
id: number;
userId: number;
username: string;
createdAt: Date;
id: number;
userId: number;
username: string;
createdAt: Date;
}
interface Props {
registrations: Registration[];
registrations: Registration[];
}
export function EventRegistrations({ registrations }: Props) {
return (
<Card>
<CardHeader>
<CardTitle>Registrations ({registrations.length})</CardTitle>
</CardHeader>
<CardContent>
{registrations.length === 0 ? (
<p className="text-sm text-muted-foreground text-center py-4">No registrations yet.</p>
) : (
<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>
);
return (
<Card>
<CardHeader>
<CardTitle>Registrations ({registrations.length})</CardTitle>
</CardHeader>
<CardContent>
{registrations.length === 0 ? (
<p className="text-sm text-muted-foreground text-center py-4">
No registrations yet.
</p>
) : (
<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>
);
}
+81 -76
View File
@@ -10,90 +10,95 @@ 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;
id: number;
userId: number;
position: number;
username: string;
createdAt: Date;
}
interface Props {
eventId: number;
winners: Winner[];
eventId: number;
winners: Winner[];
}
export function EventWinnersManager({ eventId, winners }: Props) {
const { run, isPending } = useServerAction();
const [showForm, setShowForm] = useState(false);
const [userId, setUserId] = useState("");
const [position, setPosition] = useState(winners.length + 1);
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);
},
});
}
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>Winners</CardTitle>
<Button size="sm" onClick={() => setShowForm(!showForm)}>
<Plus className="h-4 w-4 mr-1" /> Add Winner
</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>
)}
return (
<Card>
<CardHeader className="flex flex-row items-center justify-between">
<CardTitle>Winners</CardTitle>
<Button size="sm" onClick={() => setShowForm(!showForm)}>
<Plus className="h-4 w-4 mr-1" /> Add Winner
</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 && (
<p className="text-sm text-muted-foreground text-center py-4">No winners yet.</p>
)}
{winners.length === 0 && !showForm && (
<p className="text-sm text-muted-foreground text-center py-4">
No winners yet.
</p>
)}
<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>
);
<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>
);
}
+157 -137
View File
@@ -11,154 +11,174 @@ import { EventRegistrations } from "./event-registrations";
import { EventWinnersManager } from "./event-winners";
export default async function EventDetailPage({
params,
params,
}: {
params: Promise<{ locale: string; id: string }>;
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 { 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 eventId = parseInt(id, 10);
if (Number.isNaN(eventId)) notFound();
const event = await prisma.websiteEvent.findUnique({
where: { id: eventId },
include: {
type: true,
prizes: { orderBy: { position: "asc" } },
winners: { orderBy: { position: "asc" } },
registrations: { orderBy: { createdAt: "desc" } },
},
});
const event = await prisma.websiteEvent.findUnique({
where: { id: eventId },
include: {
type: true,
prizes: { orderBy: { position: "asc" } },
winners: { orderBy: { position: "asc" } },
registrations: { orderBy: { createdAt: "desc" } },
},
});
if (!event) notFound();
if (!event) notFound();
const eventTypes = await prisma.websiteEventType.findMany({
where: { isActive: 1 },
orderBy: { name: "asc" },
});
const eventTypes = await prisma.websiteEventType.findMany({
where: { isActive: 1 },
orderBy: { name: "asc" },
});
// 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 prisma.user.findMany({
where: { id: { in: [...new Set(userIds)] } },
select: { id: true, username: true },
})
: [];
const userMap = new Map(users.map((u) => [u.id, u.username]));
// 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 prisma.user.findMany({
where: { id: { in: [...new Set(userIds)] } },
select: { id: true, username: true },
})
: [];
const userMap = new Map(users.map((u) => [u.id, u.username]));
return (
<div className="space-y-6">
<div>
<AdminBreadcrumb items={[{ label: "Events", href: "/admin/events" }, { label: event.title }]} />
<div className="flex items-center gap-3">
<h1 className="text-3xl font-bold tracking-tight">{event.title}</h1>
<Badge
variant={
event.status === "published"
? "default"
: event.status === "cancelled"
? "destructive"
: "secondary"
}
>
{event.status}
</Badge>
</div>
<p className="text-muted-foreground">
{event.type.name} &middot; {new Date(event.startsAt).toLocaleString()}
</p>
</div>
return (
<div className="space-y-6">
<div>
<AdminBreadcrumb
items={[
{ label: "Events", href: "/admin/events" },
{ label: event.title },
]}
/>
<div className="flex items-center gap-3">
<h1 className="text-3xl font-bold tracking-tight">{event.title}</h1>
<Badge
variant={
event.status === "published"
? "default"
: event.status === "cancelled"
? "destructive"
: "secondary"
}
>
{event.status}
</Badge>
</div>
<p className="text-muted-foreground">
{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">Registrations</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">Max: {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">Prizes</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">Winners</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">Time</CardTitle>
<CalendarDays className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-sm font-medium">
{new Date(event.startsAt) > new Date() ? "Upcoming" : "Past"}
</div>
{event.endsAt && (
<p className="text-xs text-muted-foreground">Ends: {new Date(event.endsAt).toLocaleString()}</p>
)}
</CardContent>
</Card>
</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">Registrations</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">
Max: {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">Prizes</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">Winners</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">Time</CardTitle>
<CalendarDays className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-sm font-medium">
{new Date(event.startsAt) > new Date() ? "Upcoming" : "Past"}
</div>
{event.endsAt && (
<p className="text-xs text-muted-foreground">
Ends: {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,
}}
/>
{/* 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} />
{/* Prizes */}
<EventPrizesManager eventId={event.id} prizes={event.prizes} />
{/* Winners */}
<EventWinnersManager
eventId={event.id}
winners={event.winners.map((w) => ({
...w,
username: userMap.get(w.userId) ?? `User #${w.userId}`,
}))}
/>
{/* Winners */}
<EventWinnersManager
eventId={event.id}
winners={event.winners.map((w) => ({
...w,
username: userMap.get(w.userId) ?? `User #${w.userId}`,
}))}
/>
{/* Registrations */}
<EventRegistrations
registrations={event.registrations.map((r) => ({
...r,
username: userMap.get(r.userId) ?? `User #${r.userId}`,
}))}
/>
</div>
);
{/* Registrations */}
<EventRegistrations
registrations={event.registrations.map((r) => ({
...r,
username: userMap.get(r.userId) ?? `User #${r.userId}`,
}))}
/>
</div>
);
}
+17 -17
View File
@@ -4,24 +4,24 @@ import { prisma } from "@/lib/prisma";
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 { session, permissions } = await getAdminContext();
if (!canAccess(permissions, PERMS.EVENTS_EDIT, session.user.rank)) {
redirect("/admin/events");
}
const eventTypes = await prisma.websiteEventType.findMany({
where: { isActive: 1 },
orderBy: { name: "asc" },
});
const eventTypes = await prisma.websiteEventType.findMany({
where: { isActive: 1 },
orderBy: { name: "asc" },
});
return (
<div className="space-y-6">
<div>
<h1 className="text-3xl font-bold tracking-tight">Create Event</h1>
<p className="text-muted-foreground">Schedule a new hotel event</p>
</div>
return (
<div className="space-y-6">
<div>
<h1 className="text-3xl font-bold tracking-tight">Create Event</h1>
<p className="text-muted-foreground">Schedule a new hotel event</p>
</div>
<EventForm eventTypes={eventTypes} />
</div>
);
<EventForm eventTypes={eventTypes} />
</div>
);
}
+171 -135
View File
@@ -7,159 +7,195 @@ 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 {
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 } from "@/lib/validators/event";
interface EventType {
id: number;
name: string;
slug: string;
color: string;
id: number;
name: string;
slug: string;
color: string;
}
interface EventFormProps {
eventTypes: EventType[];
defaultValues?: Partial<CreateEventInput> & { id?: number };
eventTypes: EventType[];
defaultValues?: Partial<CreateEventInput> & { id?: number };
}
export function EventForm({ eventTypes, defaultValues }: EventFormProps) {
const { run, isPending } = useServerAction();
const isEditing = !!defaultValues?.id;
const { run, isPending } = useServerAction();
const isEditing = !!defaultValues?.id;
const form = useForm({
defaultValues: {
title: "",
description: "",
typeId: eventTypes[0]?.id,
status: "draft",
isRecurring: 0,
...defaultValues,
},
});
const form = useForm({
defaultValues: {
title: "",
description: "",
typeId: eventTypes[0]?.id,
status: "draft",
isRecurring: 0,
...defaultValues,
},
});
// biome-ignore lint/suspicious/noExplicitAny: form values typed by useForm defaults; createEvent does its own zod validation
function onSubmit(data: any) {
if (isEditing && defaultValues?.id) {
run(() => updateEvent({ id: defaultValues.id!, ...data }), {
successMessage: "Event updated!",
});
} else {
run(() => createEvent(data), {
successMessage: "Event created!",
onSuccess: (result) => {
const data = result.data as Record<string, unknown> | undefined;
if (data?.id) {
window.location.href = `/admin/events/${data.id}`;
}
},
});
}
}
// biome-ignore lint/suspicious/noExplicitAny: form values typed by useForm defaults; createEvent does its own zod validation
function onSubmit(data: any) {
if (isEditing && defaultValues?.id) {
run(() => updateEvent({ id: defaultValues.id!, ...data }), {
successMessage: "Event updated!",
});
} else {
run(() => createEvent(data), {
successMessage: "Event created!",
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 ? "Edit Event" : "Event Details"}</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">Title</Label>
<Input id="title" {...form.register("title")} />
{form.formState.errors.title && (
<p className="text-sm text-destructive">{form.formState.errors.title.message}</p>
)}
</div>
return (
<form onSubmit={form.handleSubmit(onSubmit)}>
<Card>
<CardHeader>
<CardTitle>{isEditing ? "Edit Event" : "Event Details"}</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">Title</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">Event Type</Label>
<Select
value={String(form.watch("typeId"))}
onValueChange={(v) => form.setValue("typeId", Number(v))}
>
<SelectTrigger>
<SelectValue placeholder="Select type" />
</SelectTrigger>
<SelectContent>
{eventTypes.map((t) => (
<SelectItem key={t.id} value={String(t.id)}>
{t.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="typeId">Event Type</Label>
<Select
value={String(form.watch("typeId"))}
onValueChange={(v) => form.setValue("typeId", Number(v))}
>
<SelectTrigger>
<SelectValue placeholder="Select type" />
</SelectTrigger>
<SelectContent>
{eventTypes.map((t) => (
<SelectItem key={t.id} value={String(t.id)}>
{t.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="description">Description</Label>
<Textarea id="description" rows={4} {...form.register("description")} />
</div>
<div className="space-y-2">
<Label htmlFor="description">Description</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>Starts At</Label>
<DateTimePicker
value={form.watch("startsAt")}
// biome-ignore lint/suspicious/noExplicitAny: form value type inferred loose; runtime validated by zod on submit
onChange={(d) => form.setValue("startsAt", d as any, { shouldDirty: true })}
placeholder="Select start date"
/>
</div>
<div className="space-y-2">
<Label>Ends At</Label>
<DateTimePicker
value={form.watch("endsAt")}
// biome-ignore lint/suspicious/noExplicitAny: form value type inferred loose; runtime validated by zod on submit
onChange={(d) => form.setValue("endsAt", d as any, { shouldDirty: true })}
placeholder="Select end date"
/>
</div>
<div className="space-y-2">
<Label htmlFor="maxPlayers">Max Players</Label>
<Input id="maxPlayers" type="number" {...form.register("maxPlayers")} placeholder="Unlimited" />
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div className="space-y-2">
<Label>Starts At</Label>
<DateTimePicker
value={form.watch("startsAt")}
// biome-ignore lint/suspicious/noExplicitAny: form value type inferred loose; runtime validated by zod on submit
onChange={(d) =>
form.setValue("startsAt", d as any, { shouldDirty: true })
}
placeholder="Select start date"
/>
</div>
<div className="space-y-2">
<Label>Ends At</Label>
<DateTimePicker
value={form.watch("endsAt")}
// biome-ignore lint/suspicious/noExplicitAny: form value type inferred loose; runtime validated by zod on submit
onChange={(d) =>
form.setValue("endsAt", d as any, { shouldDirty: true })
}
placeholder="Select end date"
/>
</div>
<div className="space-y-2">
<Label htmlFor="maxPlayers">Max Players</Label>
<Input
id="maxPlayers"
type="number"
{...form.register("maxPlayers")}
placeholder="Unlimited"
/>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="roomId">Room ID</Label>
<Input id="roomId" type="number" {...form.register("roomId")} placeholder="Optional" />
</div>
<div className="space-y-2">
<Label htmlFor="status">Status</Label>
<Select
value={form.watch("status")}
onValueChange={(v) => form.setValue("status", v as CreateEventInput["status"])}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="draft">Draft</SelectItem>
<SelectItem value="published">Published</SelectItem>
<SelectItem value="cancelled">Cancelled</SelectItem>
<SelectItem value="completed">Completed</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="roomId">Room ID</Label>
<Input
id="roomId"
type="number"
{...form.register("roomId")}
placeholder="Optional"
/>
</div>
<div className="space-y-2">
<Label htmlFor="status">Status</Label>
<Select
value={form.watch("status")}
onValueChange={(v) =>
form.setValue("status", v as CreateEventInput["status"])
}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="draft">Draft</SelectItem>
<SelectItem value="published">Published</SelectItem>
<SelectItem value="cancelled">Cancelled</SelectItem>
<SelectItem value="completed">Completed</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="image">Image URL</Label>
<Input id="image" {...form.register("image")} placeholder="https://..." />
</div>
<div className="space-y-2">
<Label htmlFor="image">Image URL</Label>
<Input
id="image"
{...form.register("image")}
placeholder="https://..."
/>
</div>
<div className="flex justify-end">
<Button type="submit" disabled={isPending}>
{isPending ? "Saving..." : isEditing ? "Update Event" : "Create Event"}
</Button>
</div>
</CardContent>
</Card>
</form>
);
<div className="flex justify-end">
<Button type="submit" disabled={isPending}>
{isPending
? "Saving..."
: isEditing
? "Update Event"
: "Create Event"}
</Button>
</div>
</CardContent>
</Card>
</form>
);
}
+74 -59
View File
@@ -8,74 +8,89 @@ import { Button } from "@/components/ui/button";
import type { DataTableColumn, PaginatedResult } from "@/types";
interface EventRow {
id: number;
title: string;
typeName: string;
typeColor: string;
startsAt: Date;
endsAt: Date | null;
status: string;
hostUserId: number;
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",
const statusVariants: Record<
string,
"default" | "secondary" | "outline" | "destructive"
> = {
published: "default",
draft: "secondary",
cancelled: "destructive",
completed: "outline",
};
function getColumns(): DataTableColumn<EventRow>[] {
return [
{ key: "id", label: "ID", sortable: true },
{
key: "title",
label: "Title",
sortable: true,
render: (value) => <span className="font-medium">{String(value)}</span>,
},
{
key: "typeName",
label: "Type",
sortable: false,
render: (_, row) => (
<Badge style={{ backgroundColor: row.typeColor, color: "#fff" }}>{row.typeName}</Badge>
),
},
{
key: "startsAt",
label: "Starts",
sortable: true,
render: (value) => (
<span className="text-sm">{new Date(value as unknown as string).toLocaleString()}</span>
),
},
{
key: "status",
label: "Status",
sortable: true,
render: (value) => (
<Badge variant={statusVariants[String(value)] ?? "secondary"}>{String(value)}</Badge>
),
},
{
key: "actions",
label: "Actions",
render: (_, row) => (
<Button variant="ghost" size="icon" asChild>
<Link href={`/admin/events/${row.id}`}>
<Pencil className="h-4 w-4" />
</Link>
</Button>
),
},
];
return [
{ key: "id", label: "ID", sortable: true },
{
key: "title",
label: "Title",
sortable: true,
render: (value) => <span className="font-medium">{String(value)}</span>,
},
{
key: "typeName",
label: "Type",
sortable: false,
render: (_, row) => (
<Badge style={{ backgroundColor: row.typeColor, color: "#fff" }}>
{row.typeName}
</Badge>
),
},
{
key: "startsAt",
label: "Starts",
sortable: true,
render: (value) => (
<span className="text-sm">
{new Date(value as unknown as string).toLocaleString()}
</span>
),
},
{
key: "status",
label: "Status",
sortable: true,
render: (value) => (
<Badge variant={statusVariants[String(value)] ?? "secondary"}>
{String(value)}
</Badge>
),
},
{
key: "actions",
label: "Actions",
render: (_, row) => (
<Button variant="ghost" size="icon" asChild>
<Link href={`/admin/events/${row.id}`}>
<Pencil className="h-4 w-4" />
</Link>
</Button>
),
},
];
}
interface EventsTableProps {
data: PaginatedResult<EventRow>;
data: PaginatedResult<EventRow>;
}
export function EventsTable({ data }: EventsTableProps) {
return <DataTable data={data} columns={getColumns()} searchPlaceholder="Search events..." />;
return (
<DataTable
data={data}
columns={getColumns()}
searchPlaceholder="Search events..."
/>
);
}
+58 -56
View File
@@ -5,64 +5,66 @@ import { PERMS } from "@/lib/permissions";
import { EventsTable } from "./events-table";
export default async function EventsPage({
params: paramsPromise,
searchParams,
params: paramsPromise,
searchParams,
}: {
params: Promise<{ locale: string }>;
searchParams: Promise<Record<string, string>>;
params: Promise<{ locale: string }>;
searchParams: Promise<Record<string, string>>;
}) {
const result = await fetchAdminList(
{
permission: PERMS.EVENTS_VIEW,
model: "websiteEvent",
searchFields: ["title"],
defaultSort: { startsAt: "desc" },
include: { type: { select: { name: true, color: true } } },
mapRow: (e: {
id: number;
title: string;
type: { name: string; color: string };
startsAt: Date;
endsAt: Date | null;
status: string;
hostUserId: number;
_count?: { registrations: number };
}) => ({
id: e.id,
title: e.title,
typeName: e.type.name,
typeColor: e.type.color,
startsAt: e.startsAt,
endsAt: e.endsAt,
status: e.status,
hostUserId: e.hostUserId,
}),
},
paramsPromise,
searchParams,
);
const result = await fetchAdminList(
{
permission: PERMS.EVENTS_VIEW,
model: "websiteEvent",
searchFields: ["title"],
defaultSort: { startsAt: "desc" },
include: { type: { select: { name: true, color: true } } },
mapRow: (e: {
id: number;
title: string;
type: { name: string; color: string };
startsAt: Date;
endsAt: Date | null;
status: string;
hostUserId: number;
_count?: { registrations: number };
}) => ({
id: e.id,
title: e.title,
typeName: e.type.name,
typeColor: e.type.color,
startsAt: e.startsAt,
endsAt: e.endsAt,
status: e.status,
hostUserId: e.hostUserId,
}),
},
paramsPromise,
searchParams,
);
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold tracking-tight">Events</h1>
<p className="text-muted-foreground">Manage hotel events, calendar and prizes</p>
</div>
<Button asChild>
<Link href="/admin/events/create">New Event</Link>
</Button>
</div>
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold tracking-tight">Events</h1>
<p className="text-muted-foreground">
Manage hotel events, calendar and prizes
</p>
</div>
<Button asChild>
<Link href="/admin/events/create">New Event</Link>
</Button>
</div>
<EventsTable
data={{
rows: result.rows,
total: result.total,
page: result.page,
perPage: result.perPage,
lastPage: result.lastPage,
}}
/>
</div>
);
<EventsTable
data={{
rows: result.rows,
total: result.total,
page: result.page,
perPage: result.perPage,
lastPage: result.lastPage,
}}
/>
</div>
);
}
+211 -171
View File
@@ -2,7 +2,11 @@
import { Pencil, Plus, Trash2 } from "lucide-react";
import { useState } from "react";
import { createEventType, deleteEventType, updateEventType } from "@/actions/events";
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";
@@ -11,187 +15,223 @@ 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;
id: number;
name: string;
slug: string;
description: string;
color: string;
icon: string;
isActive: number;
minRank: number;
}
interface Props {
eventTypes: EventType[];
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,
});
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 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 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 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!",
});
}
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>
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>
)}
{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>
);
<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>
);
}
+18 -16
View File
@@ -4,23 +4,25 @@ import { prisma } from "@/lib/prisma";
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 { session, permissions } = await getAdminContext();
if (!canAccess(permissions, PERMS.EVENTS_EDIT, session.user.rank)) {
redirect("/admin/events");
}
const eventTypes = await prisma.websiteEventType.findMany({
orderBy: { name: "asc" },
});
const eventTypes = await prisma.websiteEventType.findMany({
orderBy: { name: "asc" },
});
return (
<div className="space-y-6">
<div>
<h1 className="text-3xl font-bold tracking-tight">Event Types</h1>
<p className="text-muted-foreground">Manage event categories and types</p>
</div>
return (
<div className="space-y-6">
<div>
<h1 className="text-3xl font-bold tracking-tight">Event Types</h1>
<p className="text-muted-foreground">
Manage event categories and types
</p>
</div>
<EventTypesManager eventTypes={eventTypes} />
</div>
);
<EventTypesManager eventTypes={eventTypes} />
</div>
);
}
+127 -109
View File
@@ -1,127 +1,145 @@
"use client";
import { useRef, useState } from "react";
import { useTranslations } from "next-intl";
import Image from "next/image";
import { saveFavicon, deleteFavicon } from "@/actions/save-favicon";
import { useTranslations } from "next-intl";
import { useRef, useState } from "react";
import { deleteFavicon, saveFavicon } from "@/actions/save-favicon";
import { FaviconGenerator } from "./favicon-generator";
export function FaviconForm({ currentUrl }: { currentUrl: string | null }) {
const t = useTranslations("pages.admin.favicon");
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);
const t = useTranslations("pages.admin.favicon");
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);
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") });
}
}
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() {
if (!confirm(t("confirmDelete"))) return;
setDeleting(true);
setMessage(null);
const result = await deleteFavicon();
setDeleting(false);
async function handleDelete() {
if (!confirm(t("confirmDelete"))) 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") });
}
}
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);
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") });
}
}
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">
<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 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
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>
)}
return (
<div className="grid gap-6 lg:grid-cols-2">
<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 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
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>
<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} className="btn btn-primary">
{uploading ? t("uploading") : t("upload")}
</button>
{preview && (
<button type="button" onClick={handleDelete} disabled={deleting} className="btn btn-danger">
{deleting ? t("deleting") : t("delete")}
</button>
)}
</div>
</form>
<div className="flex gap-2">
<button
type="submit"
disabled={uploading}
className="btn btn-primary"
>
{uploading ? t("uploading") : t("upload")}
</button>
{preview && (
<button
type="button"
onClick={handleDelete}
disabled={deleting}
className="btn btn-danger"
>
{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>
{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>
);
<FaviconGenerator onSave={handleGenerated} />
</div>
);
}
+169 -153
View File
@@ -1,179 +1,195 @@
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import { useTranslations } from "next-intl";
import { useCallback, useEffect, useRef, useState } from "react";
const SIZE = 64;
const HALF = SIZE / 2;
interface Props {
onSave: (blob: Blob) => void;
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 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;
const render = useCallback(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const ctx = canvas.getContext("2d");
if (!ctx) return;
canvas.width = SIZE;
canvas.height = SIZE;
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);
}
// 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]);
// 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]);
useEffect(() => {
render();
}, [render]);
function handleSave() {
const canvas = canvasRef.current;
if (!canvas) return;
canvas.toBlob((blob) => {
if (blob) onSave(blob);
}, "image/png");
}
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",
];
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>
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="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 className="block text-xs font-bold text-[var(--admin-text-muted)] uppercase tracking-wider mb-2">
{t("text")}
</label>
<input
type="text"
value={text}
onChange={(e) => setText(e.target.value.slice(0, 4))}
maxLength={4}
className="w-full"
placeholder="A"
/>
</div>
<div className="space-y-4">
<div>
<label className="block text-xs font-bold text-[var(--admin-text-muted)] uppercase tracking-wider mb-2">
{t("text")}
</label>
<input
type="text"
value={text}
onChange={(e) => setText(e.target.value.slice(0, 4))}
maxLength={4}
className="w-full"
placeholder="A"
/>
</div>
<div>
<label 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
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 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
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 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
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>
<label 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
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>
<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} className="btn btn-primary w-full">
{t("save")}
</button>
</div>
</div>
);
<button
type="button"
onClick={handleSave}
className="btn btn-primary w-full"
>
{t("save")}
</button>
</div>
</div>
);
}
+30 -24
View File
@@ -1,34 +1,40 @@
import { getTranslations } from "next-intl/server";
import { Image } from "lucide-react";
import { FaviconForm } from "./favicon-form";
import { getTranslations } from "next-intl/server";
import { prisma } from "@/lib/prisma";
import { FaviconForm } from "./favicon-form";
export const dynamic = "force-dynamic";
export default async function AdminFaviconPage() {
const t = await getTranslations("pages.admin.favicon");
const t = await getTranslations("pages.admin.favicon");
let faviconUrl: string | null = null;
try {
const setting = await prisma.websiteSetting.findUnique({ where: { key: "cms_favicon" } });
faviconUrl = setting?.value ?? null;
} catch {
faviconUrl = null;
}
let faviconUrl: string | null = null;
try {
const setting = await prisma.websiteSetting.findUnique({
where: { key: "cms_favicon" },
});
faviconUrl = setting?.value ?? null;
} catch {
faviconUrl = null;
}
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">
<Image size={20} className="text-[var(--admin-accent)]" />
</div>
<div>
<h1 className="m-0 text-xl font-extrabold text-[var(--admin-text)]">{t("title")}</h1>
<p className="m-0 text-xs text-[var(--admin-text-muted)] mt-0.5">{t("subtitle")}</p>
</div>
</div>
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">
<Image size={20} className="text-[var(--admin-accent)]" />
</div>
<div>
<h1 className="m-0 text-xl font-extrabold text-[var(--admin-text)]">
{t("title")}
</h1>
<p className="m-0 text-xs text-[var(--admin-text-muted)] mt-0.5">
{t("subtitle")}
</p>
</div>
</div>
<FaviconForm currentUrl={faviconUrl} />
</main>
);
<FaviconForm currentUrl={faviconUrl} />
</main>
);
}
+123 -81
View File
@@ -1,5 +1,5 @@
import Link from "next/link";
import { HelpCircle } from "lucide-react";
import Link from "next/link";
import { notFound } from "next/navigation";
import { deleteHelpQuestion, updateHelpQuestion } from "@/actions/admin-help";
import { prisma } from "@/lib/prisma";
@@ -7,87 +7,129 @@ import { prisma } from "@/lib/prisma";
export const dynamic = "force-dynamic";
export default async function EditHelpQuestion({
params,
searchParams,
params,
searchParams,
}: {
params: Promise<{ id: string }>;
searchParams: Promise<{ error?: string }>;
params: Promise<{ id: string }>;
searchParams: Promise<{ error?: string }>;
}) {
const { id } = await params;
const { error } = await searchParams;
let entry: Awaited<ReturnType<typeof prisma.websiteHelpCenterCategories.findUnique>> = null;
try {
entry = await prisma.websiteHelpCenterCategories.findUnique({
where: { id: BigInt(id) },
});
} catch {
notFound();
}
if (!entry) notFound();
const { id } = await params;
const { error } = await searchParams;
let entry: Awaited<
ReturnType<typeof prisma.websiteHelpCenterCategories.findUnique>
> = null;
try {
entry = await prisma.websiteHelpCenterCategories.findUnique({
where: { id: BigInt(id) },
});
} 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" className="btn btn-primary">
Save
</button>
</form>
<form action={deleteHelpQuestion} style={{ marginTop: "1rem" }}>
<input type="hidden" name="id" value={String(entry.id)} />
<button type="submit" className="btn btn-danger">
Delete entry
</button>
</form>
</main>
);
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" className="btn btn-primary">
Save
</button>
</form>
<form action={deleteHelpQuestion} style={{ marginTop: "1rem" }}>
<input type="hidden" name="id" value={String(entry.id)} />
<button type="submit" className="btn btn-danger">
Delete entry
</button>
</form>
</main>
);
}
+79 -47
View File
@@ -1,55 +1,87 @@
import Link from "next/link";
import { HelpCircle } from "lucide-react";
import Link from "next/link";
import { createHelpQuestion } from "@/actions/admin-help";
export default async function NewHelpQuestion({
searchParams,
searchParams,
}: {
searchParams: Promise<{ error?: string }>;
searchParams: Promise<{ error?: string }>;
}) {
const { error } = await searchParams;
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" className="btn btn-primary">
Create
</button>
</form>
</main>
);
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" className="btn btn-primary">
Create
</button>
</form>
</main>
);
}
+85 -77
View File
@@ -1,90 +1,98 @@
import { HelpCircle } from "lucide-react";
import Link from "next/link";
import { getTranslations } from "next-intl/server";
import { HelpCircle } from "lucide-react";
import { StatusCard } from "@/components/admin/dashboard";
import { prisma } from "@/lib/prisma";
export const dynamic = "force-dynamic";
export default async function AdminHelpQuestions() {
const t = await getTranslations("pages.admin.helpCenter");
let entries: Awaited<ReturnType<typeof prisma.websiteHelpCenterCategories.findMany>> = [];
try {
entries = await prisma.websiteHelpCenterCategories.findMany({
orderBy: [{ position: "asc" }, { id: "asc" }],
take: 500,
});
} catch {
entries = [];
}
const t = await getTranslations("pages.admin.helpCenter");
let entries: Awaited<
ReturnType<typeof prisma.websiteHelpCenterCategories.findMany>
> = [];
try {
entries = await prisma.websiteHelpCenterCategories.findMany({
orderBy: [{ position: "asc" }, { id: "asc" }],
take: 500,
});
} catch {
entries = [];
}
const smallBoxes = entries.filter((e) => e.smallBox).length;
const withButton = entries.filter((e) => e.buttonText).length;
const smallBoxes = entries.filter((e) => e.smallBox).length;
const withButton = entries.filter((e) => e.buttonText).length;
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)]">{t("title")}</h1>
<p className="m-0 text-xs text-[var(--admin-text-muted)] mt-0.5">{t("subtitle")}</p>
</div>
<Link href="/admin/help-questions/new" className="btn btn-primary">
+ {t("newEntry")}
</Link>
</div>
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)]">
{t("title")}
</h1>
<p className="m-0 text-xs text-[var(--admin-text-muted)] mt-0.5">
{t("subtitle")}
</p>
</div>
<Link href="/admin/help-questions/new" className="btn btn-primary">
+ {t("newEntry")}
</Link>
</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>
<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>
);
<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>
);
}
+226 -178
View File
@@ -1,198 +1,246 @@
import Link from "next/link";
import { getTranslations } from "next-intl/server";
import { deletePermission, upsertPermission } from "@/actions/admin-housekeeping";
import {
deletePermission,
upsertPermission,
} from "@/actions/admin-housekeeping";
import { prisma } from "@/lib/prisma";
export const dynamic = "force-dynamic";
export default async function AdminHousekeeping({
searchParams,
searchParams,
}: {
searchParams: Promise<{ q?: string; mode?: "manage" | "import" }>;
searchParams: Promise<{ q?: string; mode?: "manage" | "import" }>;
}) {
const t = await getTranslations("pages.admin.housekeeping");
const t = await getTranslations("pages.admin.housekeeping");
const sp = await searchParams;
const q = (sp.q ?? "").trim();
const mode = sp.mode === "import" ? "import" : "manage";
const sp = await searchParams;
const q = (sp.q ?? "").trim();
const mode = sp.mode === "import" ? "import" : "manage";
let permissions: Awaited<ReturnType<typeof prisma.websiteHousekeepingPermissions.findMany>> = [];
try {
permissions = await prisma.websiteHousekeepingPermissions.findMany({
orderBy: [{ permission: "asc" }],
});
} catch {
permissions = [];
}
let permissions: Awaited<
ReturnType<typeof prisma.websiteHousekeepingPermissions.findMany>
> = [];
try {
permissions = await prisma.websiteHousekeepingPermissions.findMany({
orderBy: [{ permission: "asc" }],
});
} catch {
permissions = [];
}
const filtered = q
? permissions.filter((p) => p.permission.toLowerCase().includes(q.toLowerCase()))
: permissions;
const filtered = q
? permissions.filter((p) =>
p.permission.toLowerCase().includes(q.toLowerCase()),
)
: permissions;
const ranks = permissions.map((p) => p.minRank);
const maxRank = ranks.length ? Math.max(...ranks) : 0;
const totalMin = ranks.reduce((sum, r) => sum + r, 0);
const ranks = permissions.map((p) => p.minRank);
const maxRank = ranks.length ? Math.max(...ranks) : 0;
const totalMin = ranks.reduce((sum, r) => sum + r, 0);
const selectedIds = new Set<number>();
if (mode === "import") {
const importRaw = String(sp.q ?? "");
if (importRaw) {
const importIds = importRaw
.split(",")
.map((i) => Number(i.trim()))
.filter((i) => !isNaN(i));
importIds.forEach((id) => selectedIds.add(id));
}
}
const selectedIds = new Set<number>();
if (mode === "import") {
const importRaw = String(sp.q ?? "");
if (importRaw) {
const importIds = importRaw
.split(",")
.map((i) => Number(i.trim()))
.filter((i) => !Number.isNaN(i));
importIds.forEach((id) => selectedIds.add(id));
}
}
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">
<span className="text-lg">🔑</span>
</div>
<div>
<h1 className="m-0 text-xl font-extrabold text-[var(--admin-text)]">{t("title")}</h1>
<p className="m-0 text-xs text-[var(--admin-text-muted)] mt-0.5">{t("subtitle")}</p>
</div>
</div>
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">
<span className="text-lg">🔑</span>
</div>
<div>
<h1 className="m-0 text-xl font-extrabold text-[var(--admin-text)]">
{t("title")}
</h1>
<p className="m-0 text-xs text-[var(--admin-text-muted)] mt-0.5">
{t("subtitle")}
</p>
</div>
</div>
<div className="admin-stat-row">
<span className="admin-stat-chip">{t("permissionsCount", { count: permissions.length })}</span>
<span className="admin-stat-chip">{t("highestMinRank", { rank: maxRank })}</span>
<span className="admin-stat-chip">{t("totalMinRank", { total: totalMin })}</span>
</div>
<div className="admin-stat-row">
<span className="admin-stat-chip">
{t("permissionsCount", { count: permissions.length })}
</span>
<span className="admin-stat-chip">
{t("highestMinRank", { rank: maxRank })}
</span>
<span className="admin-stat-chip">
{t("totalMinRank", { total: totalMin })}
</span>
</div>
<div className="admin-filter-bar">
<form className="flex gap-2 flex-1">
<input
name="q"
defaultValue={q}
placeholder={t("searchPermission")}
className="flex-1 min-w-[200px]"
/>
<button type="submit" className="btn btn-primary">
{t("search")}
</button>
</form>
<Link href="/admin/housekeeping?mode=manage" className="btn btn-outline">
{t("manage")}
</Link>
<Link href="/admin/housekeeping?mode=import&form=selected_ids" className="btn btn-outline">
{t("importSelection")}
</Link>
</div>
<div className="admin-filter-bar">
<form className="flex gap-2 flex-1">
<input
name="q"
defaultValue={q}
placeholder={t("searchPermission")}
className="flex-1 min-w-[200px]"
/>
<button type="submit" className="btn btn-primary">
{t("search")}
</button>
</form>
<Link
href="/admin/housekeeping?mode=manage"
className="btn btn-outline"
>
{t("manage")}
</Link>
<Link
href="/admin/housekeeping?mode=import&form=selected_ids"
className="btn btn-outline"
>
{t("importSelection")}
</Link>
</div>
{mode === "manage" ? (
<section>
<h2 className="admin-section-title">{t("addPermission")}</h2>
<form action={upsertPermission} className="admin-card">
<div className="flex gap-2 flex-wrap">
<input name="permission" placeholder={t("permissionKey")} required className="min-w-[200px]" />
<input
name="minRank"
type="number"
min={0}
placeholder={t("minRank")}
required
className="w-[110px]"
/>
<input name="description" placeholder={t("description")} className="flex-1 min-w-[160px]" />
<button type="submit" className="btn btn-primary">
{t("save")}
</button>
</div>
</form>
</section>
) : (
<section>
<h2 className="admin-section-title">{t("importPermissions")}</h2>
<p className="text-xs text-[var(--admin-text-muted)] -mt-2 mb-3">{t("importHint")}</p>
<form action={upsertPermission} className="admin-card">
<div className="flex gap-2 flex-wrap">
<input
name="q"
placeholder={t("importPlaceholder")}
required
className="flex-1 min-w-[200px]"
/>
<button type="submit" className="btn btn-primary">
{t("import")}
</button>
</div>
</form>
</section>
)}
{mode === "manage" ? (
<section>
<h2 className="admin-section-title">{t("addPermission")}</h2>
<form action={upsertPermission} className="admin-card">
<div className="flex gap-2 flex-wrap">
<input
name="permission"
placeholder={t("permissionKey")}
required
className="min-w-[200px]"
/>
<input
name="minRank"
type="number"
min={0}
placeholder={t("minRank")}
required
className="w-[110px]"
/>
<input
name="description"
placeholder={t("description")}
className="flex-1 min-w-[160px]"
/>
<button type="submit" className="btn btn-primary">
{t("save")}
</button>
</div>
</form>
</section>
) : (
<section>
<h2 className="admin-section-title">{t("importPermissions")}</h2>
<p className="text-xs text-[var(--admin-text-muted)] -mt-2 mb-3">
{t("importHint")}
</p>
<form action={upsertPermission} className="admin-card">
<div className="flex gap-2 flex-wrap">
<input
name="q"
placeholder={t("importPlaceholder")}
required
className="flex-1 min-w-[200px]"
/>
<button type="submit" className="btn btn-primary">
{t("import")}
</button>
</div>
</form>
</section>
)}
<section>
<h2 className="admin-section-title">
{t("permissions")}{" "}
<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)]">
{filtered.length}
</span>
</h2>
{filtered.length === 0 ? (
<div className="admin-empty">{t("noPermissions")}</div>
) : (
<div className="overflow-x-auto">
<table>
<thead>
<tr>
<th className="w-[40px]">#</th>
<th>{t("colPermission")}</th>
<th>{t("colDescription")}</th>
<th className="w-[200px]">{t("colMinRank")}</th>
<th className="w-[80px]" />
</tr>
</thead>
<tbody>
{filtered.map((p, i) => (
<tr
key={String(p.id)}
style={
mode === "import" && selectedIds.has(i)
? { backgroundColor: "color-mix(in srgb, var(--admin-accent) 5%, transparent)" }
: {}
}
>
<td className="muted" style={{ width: "40px" }}>
{i + 1}
</td>
<td>
<strong>{p.permission}</strong>
</td>
<td className="muted">{p.description ?? "—"}</td>
<td>
<form action={upsertPermission} style={{ display: "flex", gap: "0.5rem" }}>
<input type="hidden" name="permission" value={p.permission} />
<input type="hidden" name="description" value={p.description ?? ""} />
<input
name="minRank"
type="number"
min={0}
defaultValue={p.minRank}
style={{ width: 90 }}
/>
<button type="submit" className="btn btn-outline">
{t("save")}
</button>
</form>
</td>
<td>
<form action={deletePermission}>
<input type="hidden" name="id" value={String(p.id)} />
<button type="submit" className="btn btn-danger">
✕
</button>
</form>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</section>
</main>
);
<section>
<h2 className="admin-section-title">
{t("permissions")}{" "}
<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)]">
{filtered.length}
</span>
</h2>
{filtered.length === 0 ? (
<div className="admin-empty">{t("noPermissions")}</div>
) : (
<div className="overflow-x-auto">
<table>
<thead>
<tr>
<th className="w-[40px]">#</th>
<th>{t("colPermission")}</th>
<th>{t("colDescription")}</th>
<th className="w-[200px]">{t("colMinRank")}</th>
<th className="w-[80px]" />
</tr>
</thead>
<tbody>
{filtered.map((p, i) => (
<tr
key={String(p.id)}
style={
mode === "import" && selectedIds.has(i)
? {
backgroundColor:
"color-mix(in srgb, var(--admin-accent) 5%, transparent)",
}
: {}
}
>
<td className="muted" style={{ width: "40px" }}>
{i + 1}
</td>
<td>
<strong>{p.permission}</strong>
</td>
<td className="muted">{p.description ?? "—"}</td>
<td>
<form
action={upsertPermission}
style={{ display: "flex", gap: "0.5rem" }}
>
<input
type="hidden"
name="permission"
value={p.permission}
/>
<input
type="hidden"
name="description"
value={p.description ?? ""}
/>
<input
name="minRank"
type="number"
min={0}
defaultValue={p.minRank}
style={{ width: 90 }}
/>
<button type="submit" className="btn btn-outline">
{t("save")}
</button>
</form>
</td>
<td>
<form action={deletePermission}>
<input type="hidden" name="id" value={String(p.id)} />
<button type="submit" className="btn btn-danger">
✕
</button>
</form>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</section>
</main>
);
}
@@ -5,98 +5,115 @@ 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 {
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;
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();
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]);
// 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);
},
});
}
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>
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>
)}
{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>
);
<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>
);
}
@@ -1,17 +1,17 @@
"use client";
import {
AlertCircle,
BadgeCheck,
Check,
CheckCircle2,
Download,
Globe,
Loader2,
Package,
Pencil,
Search,
X,
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";
@@ -19,465 +19,524 @@ import { importBadgeFromRemote } from "@/actions/import-badges";
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 {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
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;
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 [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);
}
}, []);
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]);
// 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);
}, []);
// "/" 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 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);
}
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 doImport(badge: BadgeItem) {
setImportingCode(badge.code);
try {
const result = await importBadgeFromRemote({
code: badge.code,
name: badge.name,
description: badge.description,
});
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 || `Failed to import ${badge.code}`);
}
} catch {
toast.error(`Network error importing ${badge.code}`);
} finally {
setImportingCode(null);
}
}
async function doImport(badge: BadgeItem) {
setImportingCode(badge.code);
try {
const result = await importBadgeFromRemote({
code: badge.code,
name: badge.name,
description: badge.description,
});
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 || `Failed to import ${badge.code}`);
}
} 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 importBadgeFromRemote({
code: badge.code,
name: badge.name,
description: badge.description,
});
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`);
}
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 importBadgeFromRemote({
code: badge.code,
name: badge.name,
description: badge.description,
});
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;
});
}
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]);
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)));
}
}
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;
// 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>
)}
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>
{/* 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>
)}
{/* 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>
)}
{/* 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 }).map((_, i) => (
// biome-ignore lint/suspicious/noArrayIndexKey: static skeleton placeholders
<div key={i} 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>
)}
{/* 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 }).map((_, i) => (
// biome-ignore lint/suspicious/noArrayIndexKey: static skeleton placeholders
<div
key={i}
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>
)}
{/* 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 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>
{/* 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>
)}
{/* 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>
)}
{/* 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>
)}
{/* 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>
);
{/* Badge Editor Dialog */}
<BadgeEditorDialog
code={editingCode ?? ""}
open={!!editingCode}
onOpenChange={(open) => {
if (!open) setEditingCode(null);
}}
/>
</div>
</TooltipProvider>
);
}
+17 -17
View File
@@ -1,21 +1,21 @@
import { Skeleton } from "@/components/ui/skeleton";
export default function ImportBadgesLoading() {
return (
<div className="space-y-6">
<div>
<Skeleton className="h-9 w-48" />
<Skeleton className="h-5 w-72 mt-2" />
</div>
<div className="rounded-lg border bg-card p-6 space-y-4">
{Array.from({ length: 6 }).map((_, i) => (
<div key={i} className="space-y-2">
<Skeleton className="h-4 w-24" />
<Skeleton className="h-10 w-full" />
</div>
))}
<Skeleton className="h-10 w-24 mt-4" />
</div>
</div>
);
return (
<div className="space-y-6">
<div>
<Skeleton className="h-9 w-48" />
<Skeleton className="h-5 w-72 mt-2" />
</div>
<div className="rounded-lg border bg-card p-6 space-y-4">
{Array.from({ length: 6 }).map((_, i) => (
<div key={i} className="space-y-2">
<Skeleton className="h-4 w-24" />
<Skeleton className="h-10 w-full" />
</div>
))}
<Skeleton className="h-10 w-24 mt-4" />
</div>
</div>
);
}
+8 -6
View File
@@ -2,11 +2,13 @@ import { redirect } from "next/navigation";
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
import { ImportBadgesClient } from "./import-badges-client";
export default async function ImportBadgesPage(_props: { params: Promise<{ locale: string }> }) {
const { session, permissions } = await getAdminContext();
if (!canAccess(permissions, PERMS.ASSETS_IMPORT, session.user.rank)) {
redirect("/admin");
}
export default async function ImportBadgesPage(_props: {
params: Promise<{ locale: string }>;
}) {
const { session, permissions } = await getAdminContext();
if (!canAccess(permissions, PERMS.ASSETS_IMPORT, session.user.rank)) {
redirect("/admin");
}
return <ImportBadgesClient />;
return <ImportBadgesClient />;
}
File diff suppressed because it is too large. Load diff
+8 -6
View File
@@ -2,10 +2,12 @@ import { redirect } from "next/navigation";
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
import { ImportCloneClient } from "./import-clone-client";
export default async function ImportClonePage(_props: { params: Promise<{ locale: string }> }) {
const { session, permissions } = await getAdminContext();
if (!canAccess(permissions, PERMS.ASSETS_IMPORT, session.user.rank)) {
redirect("/admin");
}
return <ImportCloneClient />;
export default async function ImportClonePage(_props: {
params: Promise<{ locale: string }>;
}) {
const { session, permissions } = await getAdminContext();
if (!canAccess(permissions, PERMS.ASSETS_IMPORT, session.user.rank)) {
redirect("/admin");
}
return <ImportCloneClient />;
}
File diff suppressed because it is too large. Load diff
+14 -11
View File
@@ -1,13 +1,16 @@
export default function Loading() {
return (
<div className="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 gap-3">
{Array.from({ length: 18 }).map((_, i) => (
<div key={`figure-skeleton-${i}`} 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-7 bg-muted rounded" />
</div>
))}
</div>
);
return (
<div className="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 gap-3">
{Array.from({ length: 18 }).map((_, i) => (
<div
key={`figure-skeleton-${i}`}
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-7 bg-muted rounded" />
</div>
))}
</div>
);
}
+8 -6
View File
@@ -2,10 +2,12 @@ import { redirect } from "next/navigation";
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
import { ImportClothingClient } from "./import-clothing-client";
export default async function ImportClothingPage(_props: { params: Promise<{ locale: string }> }) {
const { session, permissions } = await getAdminContext();
if (!canAccess(permissions, PERMS.ASSETS_IMPORT, session.user.rank)) {
redirect("/admin");
}
return <ImportClothingClient />;
export default async function ImportClothingPage(_props: {
params: Promise<{ locale: string }>;
}) {
const { session, permissions } = await getAdminContext();
if (!canAccess(permissions, PERMS.ASSETS_IMPORT, session.user.rank)) {
redirect("/admin");
}
return <ImportClothingClient />;
}
@@ -2,16 +2,16 @@
"use client";
import {
AlertCircle,
Check,
Download,
Loader2,
Music,
Package,
Search,
Sparkles,
Trash2,
X,
AlertCircle,
Check,
Download,
Loader2,
Music,
Package,
Search,
Sparkles,
Trash2,
X,
} from "lucide-react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { toast } from "sonner";
@@ -21,362 +21,408 @@ import { Input } from "@/components/ui/input";
import { cn } from "@/lib/utils";
interface EffectItem {
id: string;
lib: string;
type: string;
revision: number;
present: boolean;
nitroExists: boolean;
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 [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 fetchEffects = useCallback(async (search: string) => {
setLoading(true);
setError("");
try {
const params = new URLSearchParams();
if (search) params.set("search", search);
const res = await fetch(`/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);
}
}, []);
const fetchEffects = useCallback(async (search: string) => {
setLoading(true);
setError("");
try {
const params = new URLSearchParams();
if (search) params.set("search", search);
const res = await fetch(`/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]);
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]);
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 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)));
}
function markDone(lib: string) {
setEffects((prev) =>
prev.map((e) =>
e.lib === lib ? { ...e, present: true, nitroExists: true } : e,
),
);
}
async function importViaSse(items: EffectItem[]) {
setBatchProgress({ done: 0, total: items.length });
const res = await fetch("/api/admin/import/effects/batch", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ items, concurrency: 3 }),
});
if (!res.body) {
toast.error("No response stream");
setBatchProgress(null);
return;
}
const reader = res.body.getReader();
const decoder = new TextDecoder();
let buf = "";
let done = 0;
let succeeded = 0;
let failed = 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;
const evt = JSON.parse(part.slice(6));
if (evt.type === "item_progress" && evt.status === "done") {
done++;
succeeded++;
markDone(evt.classname);
setBatchProgress({ done, total: items.length });
} else if (evt.type === "item_progress" && evt.status === "failed") {
done++;
failed++;
setBatchProgress({ done, total: items.length });
} else if (evt.type === "batch_complete") {
succeeded = evt.succeeded;
failed = evt.failed;
}
}
}
setBatchProgress(null);
setSelected(new Set());
if (succeeded > 0) toast.success(`${succeeded} effect(s) imported`);
if (failed > 0) toast.error(`${failed} failed`);
}
async function importViaSse(items: EffectItem[]) {
setBatchProgress({ done: 0, total: items.length });
const res = await fetch("/api/admin/import/effects/batch", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ items, concurrency: 3 }),
});
if (!res.body) {
toast.error("No response stream");
setBatchProgress(null);
return;
}
const reader = res.body.getReader();
const decoder = new TextDecoder();
let buf = "";
let done = 0;
let succeeded = 0;
let failed = 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;
const evt = JSON.parse(part.slice(6));
if (evt.type === "item_progress" && evt.status === "done") {
done++;
succeeded++;
markDone(evt.classname);
setBatchProgress({ done, total: items.length });
} else if (evt.type === "item_progress" && evt.status === "failed") {
done++;
failed++;
setBatchProgress({ done, total: items.length });
} else if (evt.type === "batch_complete") {
succeeded = evt.succeeded;
failed = evt.failed;
}
}
}
setBatchProgress(null);
setSelected(new Set());
if (succeeded > 0) toast.success(`${succeeded} effect(s) imported`);
if (failed > 0) toast.error(`${failed} failed`);
}
async function importOne(e: EffectItem) {
setBusyLib(e.lib);
try {
await importViaSse([e]);
} finally {
setBusyLib(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 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 fetch(`/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);
}
}
async function remove(e: EffectItem) {
setBusyLib(e.lib);
try {
const res = await fetch(
`/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>
)}
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>
{/* 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>
)}
</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>
)}
</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>
)}
{/* 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>
)}
{/* 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>
);
{/* 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>
);
}
+14 -11
View File
@@ -1,14 +1,17 @@
// src/app/[locale]/admin/import/effects/loading.tsx
export default function Loading() {
return (
<div className="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 gap-3">
{Array.from({ length: 18 }).map((_, i) => (
<div key={`effect-skeleton-${i}`} 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-7 bg-muted rounded" />
</div>
))}
</div>
);
return (
<div className="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 gap-3">
{Array.from({ length: 18 }).map((_, i) => (
<div
key={`effect-skeleton-${i}`}
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-7 bg-muted rounded" />
</div>
))}
</div>
);
}
+8 -6
View File
@@ -3,10 +3,12 @@ import { redirect } from "next/navigation";
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
import { ImportEffectsClient } from "./import-effects-client";
export default async function ImportEffectsPage(_props: { params: Promise<{ locale: string }> }) {
const { session, permissions } = await getAdminContext();
if (!canAccess(permissions, PERMS.ASSETS_IMPORT, session.user.rank)) {
redirect("/admin");
}
return <ImportEffectsClient />;
export default async function ImportEffectsPage(_props: {
params: Promise<{ locale: string }>;
}) {
const { session, permissions } = await getAdminContext();
if (!canAccess(permissions, PERMS.ASSETS_IMPORT, session.user.rank)) {
redirect("/admin");
}
return <ImportEffectsClient />;
}
File diff suppressed because it is too large. Load diff
+17 -17
View File
@@ -1,21 +1,21 @@
import { Skeleton } from "@/components/ui/skeleton";
export default function ImportFurniLoading() {
return (
<div className="space-y-6">
<div>
<Skeleton className="h-9 w-48" />
<Skeleton className="h-5 w-72 mt-2" />
</div>
<div className="rounded-lg border bg-card p-6 space-y-4">
{Array.from({ length: 6 }).map((_, i) => (
<div key={i} className="space-y-2">
<Skeleton className="h-4 w-24" />
<Skeleton className="h-10 w-full" />
</div>
))}
<Skeleton className="h-10 w-24 mt-4" />
</div>
</div>
);
return (
<div className="space-y-6">
<div>
<Skeleton className="h-9 w-48" />
<Skeleton className="h-5 w-72 mt-2" />
</div>
<div className="rounded-lg border bg-card p-6 space-y-4">
{Array.from({ length: 6 }).map((_, i) => (
<div key={i} className="space-y-2">
<Skeleton className="h-4 w-24" />
<Skeleton className="h-10 w-full" />
</div>
))}
<Skeleton className="h-10 w-24 mt-4" />
</div>
</div>
);
}
File diff suppressed because it is too large. Load diff
+8 -6
View File
@@ -2,11 +2,13 @@ import { redirect } from "next/navigation";
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
import { ImportFurniClient } from "./import-furni-client";
export default async function ImportFurniPage(_props: { params: Promise<{ locale: string }> }) {
const { session, permissions } = await getAdminContext();
if (!canAccess(permissions, PERMS.ASSETS_IMPORT, session.user.rank)) {
redirect("/admin");
}
export default async function ImportFurniPage(_props: {
params: Promise<{ locale: string }>;
}) {
const { session, permissions } = await getAdminContext();
if (!canAccess(permissions, PERMS.ASSETS_IMPORT, session.user.rank)) {
redirect("/admin");
}
return <ImportFurniClient />;
return <ImportFurniClient />;
}
+88 -58
View File
@@ -1,67 +1,97 @@
import { getTranslations } from "next-intl/server";
import { BadgeCheck, Cat, Copy, Dumbbell, Puzzle, Shirt, Wrench } from "lucide-react";
import {
BadgeCheck,
Cat,
Copy,
Dumbbell,
Puzzle,
Shirt,
Wrench,
} from "lucide-react";
import Link from "next/link";
import { redirect } from "next/navigation";
import { getTranslations } from "next-intl/server";
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
export default async function ImportPage() {
const t = await getTranslations("pages.admin.import");
const { session, permissions } = await getAdminContext();
if (!canAccess(permissions, PERMS.ASSETS_IMPORT, session.user.rank)) {
redirect("/admin");
}
const t = await getTranslations("pages.admin.import");
const { session, permissions } = await getAdminContext();
if (!canAccess(permissions, PERMS.ASSETS_IMPORT, session.user.rank)) {
redirect("/admin");
}
const SECTIONS = [
{
href: "/admin/import/badges",
label: t("badges"),
icon: BadgeCheck,
description: t("badgesDescription"),
},
{ href: "/admin/import/furni", label: t("furni"), icon: Puzzle, description: t("furniDescription") },
{
href: "/admin/import/clothing",
label: t("clothing"),
icon: Shirt,
description: t("clothingDescription"),
},
{
href: "/admin/import/effects",
label: t("effects"),
icon: Dumbbell,
description: t("effectsDescription"),
},
{ href: "/admin/import/pets", label: t("pets"), icon: Cat, description: t("petsDescription") },
{ href: "/admin/import/clone", label: t("clone"), icon: Copy, description: t("cloneDescription") },
{ href: "/admin/import/repair", label: t("repair"), icon: Wrench, description: t("repairDescription") },
] as const;
const SECTIONS = [
{
href: "/admin/import/badges",
label: t("badges"),
icon: BadgeCheck,
description: t("badgesDescription"),
},
{
href: "/admin/import/furni",
label: t("furni"),
icon: Puzzle,
description: t("furniDescription"),
},
{
href: "/admin/import/clothing",
label: t("clothing"),
icon: Shirt,
description: t("clothingDescription"),
},
{
href: "/admin/import/effects",
label: t("effects"),
icon: Dumbbell,
description: t("effectsDescription"),
},
{
href: "/admin/import/pets",
label: t("pets"),
icon: Cat,
description: t("petsDescription"),
},
{
href: "/admin/import/clone",
label: t("clone"),
icon: Copy,
description: t("cloneDescription"),
},
{
href: "/admin/import/repair",
label: t("repair"),
icon: Wrench,
description: t("repairDescription"),
},
] as const;
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-bold">{t("title")}</h1>
<p className="text-muted-foreground">{t("subtitle")}</p>
</div>
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-bold">{t("title")}</h1>
<p className="text-muted-foreground">{t("subtitle")}</p>
</div>
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{SECTIONS.map(({ href, label, icon: Icon, description }) => (
<Link
key={href}
href={href}
className="group rounded-lg border bg-card p-4 transition-colors hover:border-primary hover:bg-accent"
>
<div className="flex items-center gap-3">
<div className="rounded-md bg-primary/10 p-2 text-primary">
<Icon className="h-5 w-5" />
</div>
<div>
<h2 className="font-semibold group-hover:text-primary">{label}</h2>
<p className="text-xs text-muted-foreground">{description}</p>
</div>
</div>
</Link>
))}
</div>
</div>
);
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{SECTIONS.map(({ href, label, icon: Icon, description }) => (
<Link
key={href}
href={href}
className="group rounded-lg border bg-card p-4 transition-colors hover:border-primary hover:bg-accent"
>
<div className="flex items-center gap-3">
<div className="rounded-md bg-primary/10 p-2 text-primary">
<Icon className="h-5 w-5" />
</div>
<div>
<h2 className="font-semibold group-hover:text-primary">
{label}
</h2>
<p className="text-xs text-muted-foreground">{description}</p>
</div>
</div>
</Link>
))}
</div>
</div>
);
}
+353 -306
View File
@@ -1,6 +1,16 @@
"use client";
import { AlertCircle, Check, Download, Loader2, Package, PawPrint, Search, Trash2, X } from "lucide-react";
import {
AlertCircle,
Check,
Download,
Loader2,
Package,
PawPrint,
Search,
Trash2,
X,
} from "lucide-react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { toast } from "sonner";
import { Badge } from "@/components/ui/badge";
@@ -9,331 +19,368 @@ import { Input } from "@/components/ui/input";
import { cn } from "@/lib/utils";
interface PetItem {
lib: string;
nitroExists: boolean;
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 (
// biome-ignore lint/performance/noImgElement: tiny dynamic sprite, not a Next asset
<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)}
/>
);
const [failed, setFailed] = useState(false);
if (failed) return <PawPrint className="w-8 h-8 text-muted-foreground/60" />;
return (
// biome-ignore lint/performance/noImgElement: tiny dynamic sprite, not a Next asset
<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 [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 fetchPets = useCallback(async (search: string) => {
setLoading(true);
setError("");
try {
const params = new URLSearchParams();
if (search) params.set("search", search);
const res = await fetch(`/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);
}
}, []);
const fetchPets = useCallback(async (search: string) => {
setLoading(true);
setError("");
try {
const params = new URLSearchParams();
if (search) params.set("search", search);
const res = await fetch(`/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]);
useEffect(() => {
fetchPets("");
}, [fetchPets]);
const importable = useMemo(() => pets.filter((p) => !p.nitroExists), [pets]);
const importedCount = useMemo(() => pets.filter((p) => p.nitroExists).length, [pets]);
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 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)));
}
function markDone(lib: string) {
setPets((prev) =>
prev.map((p) => (p.lib === lib ? { ...p, nitroExists: true } : p)),
);
}
async function importViaSse(items: PetItem[]) {
setBatchProgress({ done: 0, total: items.length });
const res = await fetch("/api/admin/import/pets/batch", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ items, concurrency: 3 }),
});
if (!res.body) {
toast.error("No response stream");
setBatchProgress(null);
return;
}
const reader = res.body.getReader();
const decoder = new TextDecoder();
let buf = "";
let done = 0;
let succeeded = 0;
let failed = 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;
const evt = JSON.parse(part.slice(6));
if (evt.type === "item_progress" && evt.status === "done") {
done++;
markDone(evt.classname);
setBatchProgress({ done, total: items.length });
} else if (evt.type === "item_progress" && evt.status === "failed") {
done++;
setBatchProgress({ done, total: items.length });
} else if (evt.type === "batch_complete") {
succeeded = evt.succeeded;
failed = evt.failed;
}
}
}
setBatchProgress(null);
setSelected(new Set());
if (succeeded > 0) toast.success(`${succeeded} pet(s) imported`);
if (failed > 0) toast.error(`${failed} failed`);
}
async function importViaSse(items: PetItem[]) {
setBatchProgress({ done: 0, total: items.length });
const res = await fetch("/api/admin/import/pets/batch", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ items, concurrency: 3 }),
});
if (!res.body) {
toast.error("No response stream");
setBatchProgress(null);
return;
}
const reader = res.body.getReader();
const decoder = new TextDecoder();
let buf = "";
let done = 0;
let succeeded = 0;
let failed = 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;
const evt = JSON.parse(part.slice(6));
if (evt.type === "item_progress" && evt.status === "done") {
done++;
markDone(evt.classname);
setBatchProgress({ done, total: items.length });
} else if (evt.type === "item_progress" && evt.status === "failed") {
done++;
setBatchProgress({ done, total: items.length });
} else if (evt.type === "batch_complete") {
succeeded = evt.succeeded;
failed = evt.failed;
}
}
}
setBatchProgress(null);
setSelected(new Set());
if (succeeded > 0) toast.success(`${succeeded} pet(s) imported`);
if (failed > 0) toast.error(`${failed} failed`);
}
async function importOne(p: PetItem) {
setBusyLib(p.lib);
try {
await importViaSse([p]);
} finally {
setBusyLib(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 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 fetch(`/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);
}
}
async function remove(p: PetItem) {
setBusyLib(p.lib);
try {
const res = await fetch(
`/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>
)}
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>
<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>
)}
</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>
)}
</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>
)}
{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>
)}
{!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>
);
{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>
);
}
+14 -11
View File
@@ -1,13 +1,16 @@
export default function Loading() {
return (
<div className="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 gap-3">
{Array.from({ length: 18 }).map((_, i) => (
<div key={`pet-skeleton-${i}`} 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-7 bg-muted rounded" />
</div>
))}
</div>
);
return (
<div className="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 gap-3">
{Array.from({ length: 18 }).map((_, i) => (
<div
key={`pet-skeleton-${i}`}
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-7 bg-muted rounded" />
</div>
))}
</div>
);
}
+8 -6
View File
@@ -2,10 +2,12 @@ import { redirect } from "next/navigation";
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
import { ImportPetsClient } from "./import-pets-client";
export default async function ImportPetsPage(_props: { params: Promise<{ locale: string }> }) {
const { session, permissions } = await getAdminContext();
if (!canAccess(permissions, PERMS.ASSETS_IMPORT, session.user.rank)) {
redirect("/admin");
}
return <ImportPetsClient />;
export default async function ImportPetsPage(_props: {
params: Promise<{ locale: string }>;
}) {
const { session, permissions } = await getAdminContext();
if (!canAccess(permissions, PERMS.ASSETS_IMPORT, session.user.rank)) {
redirect("/admin");
}
return <ImportPetsClient />;
}
+5 -5
View File
@@ -3,10 +3,10 @@ import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
import { RepairClient } from "./repair-client";
export default async function RepairImportedPage() {
const { session, permissions } = await getAdminContext();
if (!canAccess(permissions, PERMS.ASSETS_IMPORT, session.user.rank)) {
redirect("/admin");
}
const { session, permissions } = await getAdminContext();
if (!canAccess(permissions, PERMS.ASSETS_IMPORT, session.user.rank)) {
redirect("/admin");
}
return <RepairClient />;
return <RepairClient />;
}
File diff suppressed because it is too large. Load diff
+182 -165
View File
@@ -1,189 +1,206 @@
import { Shield } from "lucide-react";
import { addBlacklist, addWhitelist, deleteBlacklist, deleteWhitelist } from "@/actions/admin-ip";
import { getTranslations } from "next-intl/server";
import {
addBlacklist,
addWhitelist,
deleteBlacklist,
deleteWhitelist,
} from "@/actions/admin-ip";
import { StatusCard } from "@/components/admin/dashboard";
import { prisma } from "@/lib/prisma";
import { getTranslations } from "next-intl/server";
export const dynamic = "force-dynamic";
type IpRow = {
id: bigint;
ipAddress: string;
asn: string | null;
createdAt: Date | null;
id: bigint;
ipAddress: string;
asn: string | null;
createdAt: Date | null;
};
function formatDate(d: Date | null): string {
return d ? d.toISOString().slice(0, 16).replace("T", " ") : "—";
return d ? d.toISOString().slice(0, 16).replace("T", " ") : "—";
}
export default async function AdminIp() {
const t = await getTranslations("pages.admin.ip");
let whitelist: IpRow[] = [];
let blacklist: IpRow[] = [];
const t = await getTranslations("pages.admin.ip");
let whitelist: IpRow[] = [];
let blacklist: IpRow[] = [];
try {
whitelist = await prisma.websiteIpWhitelist.findMany({
orderBy: { id: "desc" },
take: 200,
});
} catch {
whitelist = [];
}
try {
whitelist = await prisma.websiteIpWhitelist.findMany({
orderBy: { id: "desc" },
take: 200,
});
} catch {
whitelist = [];
}
try {
blacklist = await prisma.websiteIpBlacklist.findMany({
orderBy: { id: "desc" },
take: 200,
});
} catch {
blacklist = [];
}
try {
blacklist = await prisma.websiteIpBlacklist.findMany({
orderBy: { id: "desc" },
take: 200,
});
} catch {
blacklist = [];
}
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">
<Shield size={20} className="text-[var(--admin-accent)]" />
</div>
<div>
<h1 className="m-0 text-xl font-extrabold text-[var(--admin-text)]">{t("title")}</h1>
<p className="m-0 text-xs text-[var(--admin-text-muted)] mt-0.5">{t("subtitle")}</p>
</div>
</div>
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">
<Shield size={20} className="text-[var(--admin-accent)]" />
</div>
<div>
<h1 className="m-0 text-xl font-extrabold text-[var(--admin-text)]">
{t("title")}
</h1>
<p className="m-0 text-xs text-[var(--admin-text-muted)] mt-0.5">
{t("subtitle")}
</p>
</div>
</div>
<div className="grid grid-cols-[repeat(auto-fit,minmax(180px,1fr))] gap-3.5 mb-6">
<StatusCard
label={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-[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>
<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" className="btn btn-secondary">
{t("add")}
</button>
</div>
</form>
<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" className="btn btn-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)}
</td>
<td>
<form action={deleteWhitelist}>
<input type="hidden" name="id" value={String(w.id)} />
<button type="submit" className="btn btn-danger">
{t("remove")}
</button>
</form>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</section>
{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)}
</td>
<td>
<form action={deleteWhitelist}>
<input type="hidden" name="id" value={String(w.id)} />
<button type="submit" className="btn btn-danger">
{t("remove")}
</button>
</form>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</section>
{/* ── Blacklist ──────────────────── */}
<section className="mt-6">
<h2 className="admin-section-title">{t("blacklist")}</h2>
{/* ── 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" className="btn btn-danger">
{t("add")}
</button>
</div>
</form>
<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" className="btn btn-danger">
{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)}
</td>
<td>
<form action={deleteBlacklist}>
<input type="hidden" name="id" value={String(b.id)} />
<button type="submit" className="btn btn-danger">
{t("remove")}
</button>
</form>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</section>
</div>
</main>
);
{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)}
</td>
<td>
<form action={deleteBlacklist}>
<input type="hidden" name="id" value={String(b.id)} />
<button type="submit" className="btn btn-danger">
{t("remove")}
</button>
</form>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</section>
</div>
</main>
);
}
+461 -237
View File
@@ -1,260 +1,484 @@
import {
Award,
Ban,
Bell,
Calendar,
ClipboardList,
Cog,
Compass,
CreditCard,
DoorOpen,
FileText,
Filter,
Gem,
Globe,
Group,
HelpCircle,
History,
Image,
Key,
Languages,
LayoutDashboard,
LogOut,
Mail,
Megaphone,
MessageSquare,
Monitor,
Music,
Newspaper,
Palette,
Radio,
Receipt,
Settings,
Shield,
ShoppingCart,
SquarePen,
Store,
Tags,
Terminal,
Ticket,
Trophy,
Upload,
Users,
Wrench,
} from "lucide-react";
import Link from "next/link";
import { redirect } from "next/navigation";
import type { ReactNode } from "react";
import { getTranslations } from "next-intl/server";
import { AdminNavLink } from "@/components/admin/admin-nav-link";
import type { ReactNode } from "react";
import { AdminMobileWrapper } from "@/components/admin/admin-mobile-wrapper";
import { AdminNavLink } from "@/components/admin/admin-nav-link";
import { requireStaff } from "@/lib/admin/guard";
import { prisma } from "@/lib/prisma";
import { siteSettings } from "@/lib/services/site-settings";
import {
LayoutDashboard,
Newspaper,
Tags,
Compass,
HelpCircle,
Megaphone,
SquarePen,
Image,
Users,
Ban,
Globe,
Shield,
ClipboardList,
Group,
Key,
Store,
ShoppingCart,
Receipt,
Ticket,
CreditCard,
Gem,
Award,
Trophy,
Calendar,
Radio,
History,
Terminal,
Monitor,
Bell,
FileText,
Filter,
Wrench,
Settings,
DoorOpen,
Mail,
Palette,
Cog,
LogOut,
Music,
Languages,
Upload,
MessageSquare,
} from "lucide-react";
export const dynamic = "force-dynamic";
function getNavGroups(t: (key: string) => string) {
return [
{
label: t("overview"),
icon: <LayoutDashboard size={14} />,
items: [{ href: "/admin", label: t("dashboard"), icon: <LayoutDashboard size={16} /> }],
},
{
label: t("content"),
icon: <Newspaper size={14} />,
items: [
{ href: "/admin/articles", label: t("articles"), icon: <Newspaper size={16} /> },
{ href: "/admin/tags", label: t("tags"), icon: <Tags size={16} /> },
{ href: "/admin/navigation", label: t("navigator"), icon: <Compass size={16} /> },
{ href: "/admin/help-questions", label: t("helpCenter"), icon: <HelpCircle size={16} /> },
{ href: "/admin/ads", label: t("advertisements"), icon: <Megaphone size={16} /> },
{ href: "/admin/writeable-boxes", label: t("writeableBoxes"), icon: <SquarePen size={16} /> },
{ href: "/admin/photos", label: t("photos"), icon: <Image size={16} /> },
{ href: "/admin/tickets", label: t("tickets"), icon: <MessageSquare size={16} /> },
{ href: "/admin/events", label: "Events", icon: <Calendar size={16} /> },
{ href: "/admin/polls", label: "Polls", icon: <ClipboardList size={16} /> },
{ href: "/admin/banners", label: "Banners", icon: <Image size={16} /> },
{ href: "/admin/prefixes", label: "Prefixes", icon: <Languages size={16} /> },
],
},
{
label: t("usersAccess"),
icon: <Shield size={14} />,
items: [
{ href: "/admin/users", label: t("users"), icon: <Users size={16} /> },
{ href: "/admin/bans", label: t("bans"), icon: <Ban size={16} /> },
{ href: "/admin/ip", label: t("ipManagement"), icon: <Globe size={16} /> },
{ href: "/admin/vpn", label: t("vpn"), icon: <Shield size={16} /> },
{ href: "/admin/applications", label: t("applications"), icon: <ClipboardList size={16} /> },
{ href: "/admin/teams", label: t("teams"), icon: <Group size={16} /> },
{ href: "/admin/permissions", label: t("permissions"), icon: <Key size={16} /> },
{ href: "/admin/online", label: "Online Users", icon: <Users size={16} /> },
{ href: "/admin/moderation", label: "Moderation", icon: <Shield size={16} /> },
],
},
{
label: t("economy"),
icon: <ShoppingCart size={14} />,
items: [
{ href: "/admin/catalog", label: t("catalog"), icon: <Store size={16} /> },
{ href: "/admin/shop", label: t("shop"), icon: <ShoppingCart size={16} /> },
{ href: "/admin/transactions", label: t("transactions"), icon: <Receipt size={16} /> },
{ href: "/admin/vouchers", label: t("vouchers"), icon: <Ticket size={16} /> },
{ href: "/admin/subscriptions", label: t("subscriptions"), icon: <CreditCard size={16} /> },
{ href: "/admin/rare-values", label: t("rareValues"), icon: <Gem size={16} /> },
{ href: "/admin/badges", label: t("badges"), icon: <Award size={16} /> },
{ href: "/admin/achievements", label: t("achievements"), icon: <Trophy size={16} /> },
{ href: "/admin/calendar", label: t("calendar"), icon: <Calendar size={16} /> },
],
},
{
label: t("radio"),
icon: <Radio size={14} />,
items: [
{ href: "/admin/radio", label: t("radio"), icon: <Radio size={16} /> },
{ href: "/admin/radio/history", label: t("radioHistory"), icon: <History size={16} /> },
{ href: "/admin/radio/banners", label: t("radioBanners"), icon: <Image size={16} /> },
{ href: "/admin/radio/embed", label: t("radioEmbed"), icon: <Radio size={16} /> },
{ href: "/admin/radio/api-keys", label: t("radioApiKeys"), icon: <Key size={16} /> },
{ href: "/admin/radio/moderation", label: t("radioModeration"), icon: <Shield size={16} /> },
{ href: "/admin/radio/settings", label: t("radioSettings"), icon: <Settings size={16} /> },
{ href: "/admin/radio/monitoring", label: t("radioMonitoring"), icon: <Monitor size={16} /> },
{ href: "/admin/radio/points", label: t("radioPoints"), icon: <Gem size={16} /> },
{ href: "/admin/radio/ranks", label: t("radioRanks"), icon: <Group size={16} /> },
{ href: "/admin/radio/autodj", label: t("radioAutodj"), icon: <Radio size={16} /> },
],
},
{
label: t("system"),
icon: <Settings size={14} />,
items: [
{ href: "/admin/commandocentrum", label: t("commandocentrum"), icon: <Terminal size={16} /> },
{ href: "/admin/emulator", label: t("emulator"), icon: <Monitor size={16} /> },
{ href: "/admin/alerts", label: t("alerts"), icon: <Bell size={16} /> },
{ href: "/admin/logs", label: t("logs"), icon: <FileText size={16} /> },
{ href: "/admin/logs/audit", label: "Audit Log", icon: <FileText size={16} /> },
{ href: "/admin/analytics", label: "Analytics", icon: <LayoutDashboard size={16} /> },
{ href: "/admin/devops", label: "DevOps", icon: <Monitor size={16} /> },
{ href: "/admin/wordfilter", label: t("wordFilter"), icon: <Filter size={16} /> },
{ href: "/admin/maintenance", label: t("maintenance"), icon: <Wrench size={16} /> },
{ href: "/admin/housekeeping", label: t("housekeeping"), icon: <Settings size={16} /> },
{ href: "/admin/rooms", label: t("rooms"), icon: <DoorOpen size={16} /> },
{ href: "/admin/email-templates", label: t("emailTemplates"), icon: <Mail size={16} /> },
{ href: "/admin/theme", label: t("theme"), icon: <Palette size={16} /> },
{ href: "/admin/media", label: t("media"), icon: <Image size={16} /> },
{ href: "/admin/favicon", label: t("favicon"), icon: <Image size={16} /> },
{ href: "/admin/sounds", label: t("sounds"), icon: <Music size={16} /> },
{ href: "/admin/translations", label: t("translations"), icon: <Languages size={16} /> },
{ href: "/admin/import", label: t("import"), icon: <Upload size={16} /> },
{ href: "/admin/settings", label: t("settings"), icon: <Cog size={16} /> },
],
},
];
return [
{
label: t("overview"),
icon: <LayoutDashboard size={14} />,
items: [
{
href: "/admin",
label: t("dashboard"),
icon: <LayoutDashboard size={16} />,
},
],
},
{
label: t("content"),
icon: <Newspaper size={14} />,
items: [
{
href: "/admin/articles",
label: t("articles"),
icon: <Newspaper size={16} />,
},
{ href: "/admin/tags", label: t("tags"), icon: <Tags size={16} /> },
{
href: "/admin/navigation",
label: t("navigator"),
icon: <Compass size={16} />,
},
{
href: "/admin/help-questions",
label: t("helpCenter"),
icon: <HelpCircle size={16} />,
},
{
href: "/admin/ads",
label: t("advertisements"),
icon: <Megaphone size={16} />,
},
{
href: "/admin/writeable-boxes",
label: t("writeableBoxes"),
icon: <SquarePen size={16} />,
},
{
href: "/admin/photos",
label: t("photos"),
icon: <Image size={16} />,
},
{
href: "/admin/tickets",
label: t("tickets"),
icon: <MessageSquare size={16} />,
},
{
href: "/admin/events",
label: "Events",
icon: <Calendar size={16} />,
},
{
href: "/admin/polls",
label: "Polls",
icon: <ClipboardList size={16} />,
},
{ href: "/admin/banners", label: "Banners", icon: <Image size={16} /> },
{
href: "/admin/prefixes",
label: "Prefixes",
icon: <Languages size={16} />,
},
],
},
{
label: t("usersAccess"),
icon: <Shield size={14} />,
items: [
{ href: "/admin/users", label: t("users"), icon: <Users size={16} /> },
{ href: "/admin/bans", label: t("bans"), icon: <Ban size={16} /> },
{
href: "/admin/ip",
label: t("ipManagement"),
icon: <Globe size={16} />,
},
{ href: "/admin/vpn", label: t("vpn"), icon: <Shield size={16} /> },
{
href: "/admin/applications",
label: t("applications"),
icon: <ClipboardList size={16} />,
},
{ href: "/admin/teams", label: t("teams"), icon: <Group size={16} /> },
{
href: "/admin/permissions",
label: t("permissions"),
icon: <Key size={16} />,
},
{
href: "/admin/online",
label: "Online Users",
icon: <Users size={16} />,
},
{
href: "/admin/moderation",
label: "Moderation",
icon: <Shield size={16} />,
},
],
},
{
label: t("economy"),
icon: <ShoppingCart size={14} />,
items: [
{
href: "/admin/catalog",
label: t("catalog"),
icon: <Store size={16} />,
},
{
href: "/admin/shop",
label: t("shop"),
icon: <ShoppingCart size={16} />,
},
{
href: "/admin/transactions",
label: t("transactions"),
icon: <Receipt size={16} />,
},
{
href: "/admin/vouchers",
label: t("vouchers"),
icon: <Ticket size={16} />,
},
{
href: "/admin/subscriptions",
label: t("subscriptions"),
icon: <CreditCard size={16} />,
},
{
href: "/admin/rare-values",
label: t("rareValues"),
icon: <Gem size={16} />,
},
{
href: "/admin/badges",
label: t("badges"),
icon: <Award size={16} />,
},
{
href: "/admin/achievements",
label: t("achievements"),
icon: <Trophy size={16} />,
},
{
href: "/admin/calendar",
label: t("calendar"),
icon: <Calendar size={16} />,
},
],
},
{
label: t("radio"),
icon: <Radio size={14} />,
items: [
{ href: "/admin/radio", label: t("radio"), icon: <Radio size={16} /> },
{
href: "/admin/radio/history",
label: t("radioHistory"),
icon: <History size={16} />,
},
{
href: "/admin/radio/banners",
label: t("radioBanners"),
icon: <Image size={16} />,
},
{
href: "/admin/radio/embed",
label: t("radioEmbed"),
icon: <Radio size={16} />,
},
{
href: "/admin/radio/api-keys",
label: t("radioApiKeys"),
icon: <Key size={16} />,
},
{
href: "/admin/radio/moderation",
label: t("radioModeration"),
icon: <Shield size={16} />,
},
{
href: "/admin/radio/settings",
label: t("radioSettings"),
icon: <Settings size={16} />,
},
{
href: "/admin/radio/monitoring",
label: t("radioMonitoring"),
icon: <Monitor size={16} />,
},
{
href: "/admin/radio/points",
label: t("radioPoints"),
icon: <Gem size={16} />,
},
{
href: "/admin/radio/ranks",
label: t("radioRanks"),
icon: <Group size={16} />,
},
{
href: "/admin/radio/autodj",
label: t("radioAutodj"),
icon: <Radio size={16} />,
},
],
},
{
label: t("system"),
icon: <Settings size={14} />,
items: [
{
href: "/admin/commandocentrum",
label: t("commandocentrum"),
icon: <Terminal size={16} />,
},
{
href: "/admin/emulator",
label: t("emulator"),
icon: <Monitor size={16} />,
},
{ href: "/admin/alerts", label: t("alerts"), icon: <Bell size={16} /> },
{ href: "/admin/logs", label: t("logs"), icon: <FileText size={16} /> },
{
href: "/admin/logs/audit",
label: "Audit Log",
icon: <FileText size={16} />,
},
{
href: "/admin/analytics",
label: "Analytics",
icon: <LayoutDashboard size={16} />,
},
{ href: "/admin/devops", label: "DevOps", icon: <Monitor size={16} /> },
{
href: "/admin/wordfilter",
label: t("wordFilter"),
icon: <Filter size={16} />,
},
{
href: "/admin/maintenance",
label: t("maintenance"),
icon: <Wrench size={16} />,
},
{
href: "/admin/housekeeping",
label: t("housekeeping"),
icon: <Settings size={16} />,
},
{
href: "/admin/rooms",
label: t("rooms"),
icon: <DoorOpen size={16} />,
},
{
href: "/admin/email-templates",
label: t("emailTemplates"),
icon: <Mail size={16} />,
},
{
href: "/admin/theme",
label: t("theme"),
icon: <Palette size={16} />,
},
{ href: "/admin/media", label: t("media"), icon: <Image size={16} /> },
{
href: "/admin/favicon",
label: t("favicon"),
icon: <Image size={16} />,
},
{
href: "/admin/sounds",
label: t("sounds"),
icon: <Music size={16} />,
},
{
href: "/admin/translations",
label: t("translations"),
icon: <Languages size={16} />,
},
{
href: "/admin/import",
label: t("import"),
icon: <Upload size={16} />,
},
{
href: "/admin/settings",
label: t("settings"),
icon: <Cog size={16} />,
},
],
},
];
}
export default async function AdminLayout({ children }: { children: ReactNode }) {
const staff = await requireStaff();
if (await siteSettings.getBool("force_staff_2fa", false)) {
const u = await prisma.user
.findUnique({ where: { id: staff.id }, select: { twoFactorConfirmedAt: true } })
.catch(() => null);
if (!u?.twoFactorConfirmedAt) redirect("/settings/2fa?error=staffrequired");
}
export default async function AdminLayout({
children,
}: {
children: ReactNode;
}) {
const staff = await requireStaff();
if (await siteSettings.getBool("force_staff_2fa", false)) {
const u = await prisma.user
.findUnique({
where: { id: staff.id },
select: { twoFactorConfirmedAt: true },
})
.catch(() => null);
if (!u?.twoFactorConfirmedAt) redirect("/settings/2fa?error=staffrequired");
}
return (
<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)" }}
>
<Topbar staff={staff} />
<section className="admin-page flex-1">{children}</section>
</div>
</AdminMobileWrapper>
);
return (
<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)" }}
>
<Topbar staff={staff} />
<section className="admin-page flex-1">{children}</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 navGroups = getNavGroups(t);
async function Sidebar({
staff,
}: {
staff: { id: number; username: string; rank: number };
}) {
const t = await getTranslations("pages.admin.nav");
const initial = staff.username.charAt(0).toUpperCase();
const navGroups = getNavGroups(t);
return (
<aside
data-admin
className="sticky top-0 self-start h-auto lg:h-screen overflow-y-auto bg-[var(--admin-sidebar-background)] text-[var(--admin-sidebar-text-readable)] shadow-xl flex flex-col"
>
<div className="flex items-center gap-3 px-4 py-5 border-b border-[var(--admin-border)]">
<span
className="flex-none w-10 h-10 rounded-xl grid place-items-center font-extrabold text-base text-[var(--color-primary-foreground-readable)] 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>
return (
<aside
data-admin
className="sticky top-0 self-start h-auto lg:h-screen overflow-y-auto bg-[var(--admin-sidebar-background)] text-[var(--admin-sidebar-text-readable)] shadow-xl flex flex-col"
>
<div className="flex items-center gap-3 px-4 py-5 border-b border-[var(--admin-border)]">
<span
className="flex-none w-10 h-10 rounded-xl grid place-items-center font-extrabold text-base text-[var(--color-primary-foreground-readable)] 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>
<nav className="flex-1 overflow-y-auto px-3 py-4">
{navGroups.map((group, gi) => (
<div key={group.label} className={gi > 0 ? "mt-4 pt-4 border-t border-[var(--admin-border)]" : ""}>
<div className="flex items-center gap-1.5 px-2 pb-2 text-[0.65rem] font-extrabold uppercase tracking-[0.14em] text-[var(--admin-sidebar-text-readable)] opacity-70">
{group.icon}
<span>{group.label}</span>
</div>
<div className="space-y-0.5">
{group.items.map((item) => (
<AdminNavLink key={item.href} href={item.href} icon={item.icon}>
{item.label}
</AdminNavLink>
))}
</div>
</div>
))}
</nav>
<nav className="flex-1 overflow-y-auto px-3 py-4">
{navGroups.map((group, gi) => (
<div
key={group.label}
className={
gi > 0 ? "mt-4 pt-4 border-t border-[var(--admin-border)]" : ""
}
>
<div className="flex items-center gap-1.5 px-2 pb-2 text-[0.65rem] font-extrabold uppercase tracking-[0.14em] text-[var(--admin-sidebar-text-readable)] opacity-70">
{group.icon}
<span>{group.label}</span>
</div>
<div className="space-y-0.5">
{group.items.map((item) => (
<AdminNavLink key={item.href} href={item.href} icon={item.icon}>
{item.label}
</AdminNavLink>
))}
</div>
</div>
))}
</nav>
<div className="px-3 py-3 border-t border-[var(--admin-border)]">
<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>
);
<div className="px-3 py-3 border-t border-[var(--admin-border)]">
<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>
);
}
async function Topbar({ staff }: { staff: { id: number; username: string; rank: number } }) {
const t = await getTranslations("pages.admin.nav");
async function Topbar({
staff,
}: {
staff: { id: number; username: string; rank: number };
}) {
const t = await getTranslations("pages.admin.nav");
return (
<header className="flex items-center justify-between gap-4 bg-[var(--admin-surface)] border border-[var(--admin-border)] rounded-2xl px-5 py-3 mb-5 shadow-card">
<div className="flex items-center gap-3">
<h2 className="m-0 text-base font-extrabold text-[var(--admin-text)]">{t("housekeeping")}</h2>
<span className="hidden sm:inline-block w-px h-5 bg-[var(--admin-border)]" />
<span className="hidden sm:inline text-xs text-[var(--admin-text-muted)]">
{t("hotelManagementPanel")}
</span>
</div>
<div className="flex items-center gap-3">
<span className="text-xs text-[var(--admin-text-muted)]">
<strong className="text-[var(--admin-text)]">{staff.username}</strong>
</span>
<span className="inline-flex items-center gap-1.5 text-[0.6rem] font-bold uppercase tracking-wider text-[var(--color-primary-foreground-readable)] bg-[var(--admin-accent)] rounded-full px-2.5 py-1">
{t("rankLabel", { rank: staff.rank })}
</span>
</div>
</header>
);
return (
<header className="flex items-center justify-between gap-4 bg-[var(--admin-surface)] border border-[var(--admin-border)] rounded-2xl px-5 py-3 mb-5 shadow-card">
<div className="flex items-center gap-3">
<h2 className="m-0 text-base font-extrabold text-[var(--admin-text)]">
{t("housekeeping")}
</h2>
<span className="hidden sm:inline-block w-px h-5 bg-[var(--admin-border)]" />
<span className="hidden sm:inline text-xs text-[var(--admin-text-muted)]">
{t("hotelManagementPanel")}
</span>
</div>
<div className="flex items-center gap-3">
<span className="text-xs text-[var(--admin-text-muted)]">
<strong className="text-[var(--admin-text)]">{staff.username}</strong>
</span>
<span className="inline-flex items-center gap-1.5 text-[0.6rem] font-bold uppercase tracking-wider text-[var(--color-primary-foreground-readable)] bg-[var(--admin-accent)] rounded-full px-2.5 py-1">
{t("rankLabel", { rank: staff.rank })}
</span>
</div>
</header>
);
}
+13 -8
View File
@@ -1,10 +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>
);
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>
);
}
+91 -85
View File
@@ -7,105 +7,111 @@ import { Button } from "@/components/ui/button";
import type { DataTableColumn, PaginatedResult } from "@/types";
interface AuditRow {
id: number;
username: string;
action: string;
target: string;
targetId: number | null;
diff: string | null;
createdAt: string;
id: number;
username: string;
action: string;
target: string;
targetId: number | null;
diff: string | null;
createdAt: string;
}
function DiffCell({ diff }: { diff: string | null }) {
const [open, setOpen] = useState(false);
if (!diff) return <span className="text-muted-foreground">-</span>;
const [open, setOpen] = useState(false);
if (!diff) return <span className="text-muted-foreground">-</span>;
let parsed: Record<string, { from: unknown; to: unknown }>;
try {
parsed = JSON.parse(diff);
} catch {
return <span className="text-muted-foreground">-</span>;
}
let parsed: Record<string, { from: unknown; to: unknown }>;
try {
parsed = JSON.parse(diff);
} catch {
return <span className="text-muted-foreground">-</span>;
}
const keys = Object.keys(parsed);
if (keys.length === 0) return <span className="text-muted-foreground">-</span>;
const keys = Object.keys(parsed);
if (keys.length === 0)
return <span className="text-muted-foreground">-</span>;
const summary =
keys.length === 1
? `${keys[0]}: ${String(parsed[keys[0]].from ?? "∅")} → ${String(parsed[keys[0]].to ?? "∅")}`
: `${keys.length} changes`;
const summary =
keys.length === 1
? `${keys[0]}: ${String(parsed[keys[0]].from ?? "∅")} → ${String(parsed[keys[0]].to ?? "∅")}`
: `${keys.length} changes`;
return (
<div>
<Button
variant="ghost"
size="sm"
className="h-auto p-0 text-xs font-normal"
onClick={() => setOpen(!open)}
>
{summary}
</Button>
{open && (
<div className="mt-1 space-y-0.5 text-xs">
{keys.map((k) => (
<div key={k} className="flex gap-1">
<span className="font-medium">{k}:</span>
<span className="text-[var(--admin-error)] line-through">{String(parsed[k].from ?? "∅")}</span>
<span>→</span>
<span className="text-[var(--admin-success)]">{String(parsed[k].to ?? "∅")}</span>
</div>
))}
</div>
)}
</div>
);
return (
<div>
<Button
variant="ghost"
size="sm"
className="h-auto p-0 text-xs font-normal"
onClick={() => setOpen(!open)}
>
{summary}
</Button>
{open && (
<div className="mt-1 space-y-0.5 text-xs">
{keys.map((k) => (
<div key={k} className="flex gap-1">
<span className="font-medium">{k}:</span>
<span className="text-[var(--admin-error)] line-through">
{String(parsed[k].from ?? "∅")}
</span>
<span>→</span>
<span className="text-[var(--admin-success)]">
{String(parsed[k].to ?? "∅")}
</span>
</div>
))}
</div>
)}
</div>
);
}
const columns: DataTableColumn<AuditRow>[] = [
{ key: "id", label: "ID", sortable: true },
{ key: "username", label: "User", sortable: true },
{
key: "action",
label: "Action",
sortable: true,
render: (value) => {
const v = String(value);
let variant: "default" | "secondary" | "destructive" = "secondary";
if (v.includes("create") || v.includes("add")) variant = "default";
if (v.includes("delete") || v.includes("remove") || v.includes("ban")) variant = "destructive";
return <Badge variant={variant}>{v}</Badge>;
},
},
{ key: "target", label: "Target", sortable: true },
{
key: "targetId",
label: "Target ID",
render: (value) => <span>{value != null ? String(value) : "-"}</span>,
},
{
key: "diff",
label: "Details",
render: (_value, row) => <DiffCell diff={row.diff} />,
},
{
key: "createdAt",
label: "Date",
sortable: true,
render: (value) => <span>{String(value) || "N/A"}</span>,
},
{ key: "id", label: "ID", sortable: true },
{ key: "username", label: "User", sortable: true },
{
key: "action",
label: "Action",
sortable: true,
render: (value) => {
const v = String(value);
let variant: "default" | "secondary" | "destructive" = "secondary";
if (v.includes("create") || v.includes("add")) variant = "default";
if (v.includes("delete") || v.includes("remove") || v.includes("ban"))
variant = "destructive";
return <Badge variant={variant}>{v}</Badge>;
},
},
{ key: "target", label: "Target", sortable: true },
{
key: "targetId",
label: "Target ID",
render: (value) => <span>{value != null ? String(value) : "-"}</span>,
},
{
key: "diff",
label: "Details",
render: (_value, row) => <DiffCell diff={row.diff} />,
},
{
key: "createdAt",
label: "Date",
sortable: true,
render: (value) => <span>{String(value) || "N/A"}</span>,
},
];
interface AuditTableProps {
data: PaginatedResult<AuditRow>;
data: PaginatedResult<AuditRow>;
}
export function AuditTable({ data }: AuditTableProps) {
return (
<DataTable
data={data}
columns={columns}
searchPlaceholder="Search by action or target..."
exportUrl="/api/admin/export?type=audit"
/>
);
return (
<DataTable
data={data}
columns={columns}
searchPlaceholder="Search by action or target..."
exportUrl="/api/admin/export?type=audit"
/>
);
}
+27 -24
View File
@@ -1,28 +1,31 @@
import { Skeleton } from "@/components/ui/skeleton";
export default function AuditLogsLoading() {
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<Skeleton className="h-9 w-40" />
<Skeleton className="h-5 w-64 mt-2" />
</div>
</div>
<div className="flex items-center gap-3">
<Skeleton className="h-10 w-64" />
<Skeleton className="h-10 w-24" />
</div>
<div className="rounded-md border">
{Array.from({ length: 8 }).map((_, i) => (
<div key={i} className="flex items-center gap-6 px-4 py-3 border-b last:border-0">
<Skeleton className="h-4 w-12" />
<Skeleton className="h-4 w-32" />
<Skeleton className="h-4 w-24" />
<Skeleton className="h-5 w-16 rounded-full" />
</div>
))}
</div>
</div>
);
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<Skeleton className="h-9 w-40" />
<Skeleton className="h-5 w-64 mt-2" />
</div>
</div>
<div className="flex items-center gap-3">
<Skeleton className="h-10 w-64" />
<Skeleton className="h-10 w-24" />
</div>
<div className="rounded-md border">
{Array.from({ length: 8 }).map((_, i) => (
<div
key={i}
className="flex items-center gap-6 px-4 py-3 border-b last:border-0"
>
<Skeleton className="h-4 w-12" />
<Skeleton className="h-4 w-32" />
<Skeleton className="h-4 w-24" />
<Skeleton className="h-5 w-16 rounded-full" />
</div>
))}
</div>
</div>
);
}
+32 -32
View File
@@ -5,44 +5,44 @@ import { getAuditLogs } from "@/lib/services/audit";
import { AuditTable } from "./audit-table";
export default async function AuditLogsPage({
params: paramsPromise,
searchParams,
params: paramsPromise,
searchParams,
}: {
params: Promise<{ locale: string }>;
searchParams: Promise<Record<string, string>>;
params: Promise<{ locale: string }>;
searchParams: Promise<Record<string, string>>;
}) {
await paramsPromise;
const { session, permissions } = await getAdminContext();
if (!canAccess(permissions, PERMS.LOGS_VIEW, session.user.rank)) {
redirect("/admin");
}
await paramsPromise;
const { session, permissions } = await getAdminContext();
if (!canAccess(permissions, PERMS.LOGS_VIEW, session.user.rank)) {
redirect("/admin");
}
const params = await searchParams;
const sp = new URLSearchParams(params);
const { search, perPage, page } = parseListParams(sp);
const params = await searchParams;
const sp = new URLSearchParams(params);
const { search, perPage, page } = parseListParams(sp);
const result = await getAuditLogs({ search, page, perPage });
const result = await getAuditLogs({ search, page, perPage });
const pagination = calcPagination(result.total, result.page, result.perPage);
const pagination = calcPagination(result.total, result.page, result.perPage);
const rows = result.rows.map((r) => ({
id: r.id,
username: r.username,
action: r.action,
target: r.target,
targetId: r.targetId,
diff: r.diff,
createdAt: r.createdAt,
}));
const rows = result.rows.map((r) => ({
id: r.id,
username: r.username,
action: r.action,
target: r.target,
targetId: r.targetId,
diff: r.diff,
createdAt: r.createdAt,
}));
return (
<div className="space-y-6">
<div>
<h1 className="text-3xl font-bold tracking-tight">Audit Logs</h1>
<p className="text-muted-foreground">Track admin actions and changes</p>
</div>
return (
<div className="space-y-6">
<div>
<h1 className="text-3xl font-bold tracking-tight">Audit Logs</h1>
<p className="text-muted-foreground">Track admin actions and changes</p>
</div>
<AuditTable data={{ rows, ...pagination }} />
</div>
);
<AuditTable data={{ rows, ...pagination }} />
</div>
);
}
+30 -28
View File
@@ -5,41 +5,43 @@ import { formatTimestamp } from "@/lib/admin-helpers";
import type { DataTableColumn, PaginatedResult } from "@/types";
interface ChatLogRow {
id: number;
username: string;
message: string;
timestamp: number;
roomId: number;
id: number;
username: string;
message: string;
timestamp: number;
roomId: number;
}
const columns: DataTableColumn<ChatLogRow>[] = [
{ key: "id", label: "ID", sortable: true },
{ key: "username", label: "User" },
{
key: "message",
label: "Message",
render: (value) => <span className="max-w-[300px] truncate block">{String(value)}</span>,
},
{
key: "timestamp",
label: "Time",
sortable: true,
render: (value) => <span>{formatTimestamp(Number(value))}</span>,
},
{ key: "roomId", label: "Room ID", sortable: true },
{ key: "id", label: "ID", sortable: true },
{ key: "username", label: "User" },
{
key: "message",
label: "Message",
render: (value) => (
<span className="max-w-[300px] truncate block">{String(value)}</span>
),
},
{
key: "timestamp",
label: "Time",
sortable: true,
render: (value) => <span>{formatTimestamp(Number(value))}</span>,
},
{ key: "roomId", label: "Room ID", sortable: true },
];
interface ChatTableProps {
data: PaginatedResult<ChatLogRow>;
data: PaginatedResult<ChatLogRow>;
}
export function ChatTable({ data }: ChatTableProps) {
return (
<DataTable
data={data}
columns={columns}
searchPlaceholder="Search by username or message..."
exportUrl="/api/admin/export?type=chatlogs"
/>
);
return (
<DataTable
data={data}
columns={columns}
searchPlaceholder="Search by username or message..."
exportUrl="/api/admin/export?type=chatlogs"
/>
);
}
+27 -24
View File
@@ -1,28 +1,31 @@
import { Skeleton } from "@/components/ui/skeleton";
export default function ChatLogsLoading() {
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<Skeleton className="h-9 w-40" />
<Skeleton className="h-5 w-64 mt-2" />
</div>
</div>
<div className="flex items-center gap-3">
<Skeleton className="h-10 w-64" />
<Skeleton className="h-10 w-24" />
</div>
<div className="rounded-md border">
{Array.from({ length: 8 }).map((_, i) => (
<div key={i} className="flex items-center gap-6 px-4 py-3 border-b last:border-0">
<Skeleton className="h-4 w-12" />
<Skeleton className="h-4 w-32" />
<Skeleton className="h-4 w-24" />
<Skeleton className="h-5 w-16 rounded-full" />
</div>
))}
</div>
</div>
);
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<Skeleton className="h-9 w-40" />
<Skeleton className="h-5 w-64 mt-2" />
</div>
</div>
<div className="flex items-center gap-3">
<Skeleton className="h-10 w-64" />
<Skeleton className="h-10 w-24" />
</div>
<div className="rounded-md border">
{Array.from({ length: 8 }).map((_, i) => (
<div
key={i}
className="flex items-center gap-6 px-4 py-3 border-b last:border-0"
>
<Skeleton className="h-4 w-12" />
<Skeleton className="h-4 w-32" />
<Skeleton className="h-4 w-24" />
<Skeleton className="h-5 w-16 rounded-full" />
</div>
))}
</div>
</div>
);
}
+39 -39
View File
@@ -3,47 +3,47 @@ import { PERMS } from "@/lib/permissions";
import { ChatTable } from "./chat-table";
export default async function ChatLogsPage({
params: paramsPromise,
searchParams,
params: paramsPromise,
searchParams,
}: {
params: Promise<{ locale: string }>;
searchParams: Promise<Record<string, string>>;
params: Promise<{ locale: string }>;
searchParams: Promise<Record<string, string>>;
}) {
const result = await fetchAdminList(
{
permission: PERMS.LOGS_VIEW,
model: "chatLog",
searchFields: ["message", "user.username"],
defaultPerPage: 50,
include: { user: { select: { username: true } } },
mapRow: (l) => ({
id: l.id,
username: l.user.username,
message: l.message,
timestamp: l.timestamp,
roomId: l.roomId,
}),
},
paramsPromise,
searchParams,
);
const result = await fetchAdminList(
{
permission: PERMS.LOGS_VIEW,
model: "chatLog",
searchFields: ["message", "user.username"],
defaultPerPage: 50,
include: { user: { select: { username: true } } },
mapRow: (l) => ({
id: l.id,
username: l.user.username,
message: l.message,
timestamp: l.timestamp,
roomId: l.roomId,
}),
},
paramsPromise,
searchParams,
);
return (
<div className="space-y-6">
<div>
<h1 className="text-3xl font-bold tracking-tight">Chat Logs</h1>
<p className="text-muted-foreground">View in-game chat messages</p>
</div>
return (
<div className="space-y-6">
<div>
<h1 className="text-3xl font-bold tracking-tight">Chat Logs</h1>
<p className="text-muted-foreground">View in-game chat messages</p>
</div>
<ChatTable
data={{
rows: result.rows,
total: result.total,
page: result.page,
perPage: result.perPage,
lastPage: result.lastPage,
}}
/>
</div>
);
<ChatTable
data={{
rows: result.rows,
total: result.total,
page: result.page,
perPage: result.perPage,
lastPage: result.lastPage,
}}
/>
</div>
);
}
+32 -30
View File
@@ -5,43 +5,45 @@ import { formatTimestamp } from "@/lib/admin-helpers";
import type { DataTableColumn, PaginatedResult } from "@/types";
interface CommandLogRow {
id: number;
username: string;
command: string;
params: string;
timestamp: number;
id: number;
username: string;
command: string;
params: string;
timestamp: number;
}
const columns: DataTableColumn<CommandLogRow>[] = [
{ key: "id", label: "ID", sortable: true },
{ key: "username", label: "User" },
{ key: "command", label: "Command", sortable: true },
{
key: "params",
label: "Params",
render: (value) => (
<span className="max-w-[200px] truncate block font-mono text-sm">{String(value) || "-"}</span>
),
},
{
key: "timestamp",
label: "Time",
sortable: true,
render: (value) => <span>{formatTimestamp(Number(value))}</span>,
},
{ key: "id", label: "ID", sortable: true },
{ key: "username", label: "User" },
{ key: "command", label: "Command", sortable: true },
{
key: "params",
label: "Params",
render: (value) => (
<span className="max-w-[200px] truncate block font-mono text-sm">
{String(value) || "-"}
</span>
),
},
{
key: "timestamp",
label: "Time",
sortable: true,
render: (value) => <span>{formatTimestamp(Number(value))}</span>,
},
];
interface CommandsTableProps {
data: PaginatedResult<CommandLogRow>;
data: PaginatedResult<CommandLogRow>;
}
export function CommandsTable({ data }: CommandsTableProps) {
return (
<DataTable
data={data}
columns={columns}
searchPlaceholder="Search by username, command, or params..."
exportUrl="/api/admin/export?type=commands"
/>
);
return (
<DataTable
data={data}
columns={columns}
searchPlaceholder="Search by username, command, or params..."
exportUrl="/api/admin/export?type=commands"
/>
);
}
+27 -24
View File
@@ -1,28 +1,31 @@
import { Skeleton } from "@/components/ui/skeleton";
export default function CommandLogsLoading() {
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<Skeleton className="h-9 w-40" />
<Skeleton className="h-5 w-64 mt-2" />
</div>
</div>
<div className="flex items-center gap-3">
<Skeleton className="h-10 w-64" />
<Skeleton className="h-10 w-24" />
</div>
<div className="rounded-md border">
{Array.from({ length: 8 }).map((_, i) => (
<div key={i} className="flex items-center gap-6 px-4 py-3 border-b last:border-0">
<Skeleton className="h-4 w-12" />
<Skeleton className="h-4 w-32" />
<Skeleton className="h-4 w-24" />
<Skeleton className="h-5 w-16 rounded-full" />
</div>
))}
</div>
</div>
);
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<Skeleton className="h-9 w-40" />
<Skeleton className="h-5 w-64 mt-2" />
</div>
</div>
<div className="flex items-center gap-3">
<Skeleton className="h-10 w-64" />
<Skeleton className="h-10 w-24" />
</div>
<div className="rounded-md border">
{Array.from({ length: 8 }).map((_, i) => (
<div
key={i}
className="flex items-center gap-6 px-4 py-3 border-b last:border-0"
>
<Skeleton className="h-4 w-12" />
<Skeleton className="h-4 w-32" />
<Skeleton className="h-4 w-24" />
<Skeleton className="h-5 w-16 rounded-full" />
</div>
))}
</div>
</div>
);
}
+39 -39
View File
@@ -3,47 +3,47 @@ import { PERMS } from "@/lib/permissions";
import { CommandsTable } from "./commands-table";
export default async function CommandLogsPage({
params: paramsPromise,
searchParams,
params: paramsPromise,
searchParams,
}: {
params: Promise<{ locale: string }>;
searchParams: Promise<Record<string, string>>;
params: Promise<{ locale: string }>;
searchParams: Promise<Record<string, string>>;
}) {
const result = await fetchAdminList(
{
permission: PERMS.LOGS_VIEW,
model: "commandLog",
searchFields: ["command", "params", "user.username"],
defaultPerPage: 50,
include: { user: { select: { username: true } } },
mapRow: (l) => ({
id: l.id,
username: l.user.username,
command: l.command,
params: l.params,
timestamp: l.timestamp,
}),
},
paramsPromise,
searchParams,
);
const result = await fetchAdminList(
{
permission: PERMS.LOGS_VIEW,
model: "commandLog",
searchFields: ["command", "params", "user.username"],
defaultPerPage: 50,
include: { user: { select: { username: true } } },
mapRow: (l) => ({
id: l.id,
username: l.user.username,
command: l.command,
params: l.params,
timestamp: l.timestamp,
}),
},
paramsPromise,
searchParams,
);
return (
<div className="space-y-6">
<div>
<h1 className="text-3xl font-bold tracking-tight">Command Logs</h1>
<p className="text-muted-foreground">View in-game command usage</p>
</div>
return (
<div className="space-y-6">
<div>
<h1 className="text-3xl font-bold tracking-tight">Command Logs</h1>
<p className="text-muted-foreground">View in-game command usage</p>
</div>
<CommandsTable
data={{
rows: result.rows,
total: result.total,
page: result.page,
perPage: result.perPage,
lastPage: result.lastPage,
}}
/>
</div>
);
<CommandsTable
data={{
rows: result.rows,
total: result.total,
page: result.page,
perPage: result.perPage,
lastPage: result.lastPage,
}}
/>
</div>
);
}
+202 -172
View File
@@ -1,199 +1,229 @@
import { getTranslations } from "next-intl/server";
import { ScrollText } from "lucide-react";
import { getTranslations } from "next-intl/server";
import { StatusCard } from "@/components/admin/dashboard";
import { prisma } from "@/lib/prisma";
import { buildStaffActivityWhere } from "@/lib/admin/log-filters";
import { prisma } from "@/lib/prisma";
export const dynamic = "force-dynamic";
const PER_PAGE = 25;
function fromDate(d: Date | null | undefined): string {
return d ? d.toISOString().slice(0, 16).replace("T", " ") : "—";
return d ? d.toISOString().slice(0, 16).replace("T", " ") : "—";
}
export default async function AdminStaffActivities({
searchParams,
searchParams,
}: {
searchParams: Promise<{
q?: string;
page?: string;
staffId?: string;
action?: string;
authorization?: string;
}>;
searchParams: Promise<{
q?: string;
page?: string;
staffId?: string;
action?: string;
authorization?: string;
}>;
}) {
const t = await getTranslations("pages.admin.logs");
const t = await getTranslations("pages.admin.logs");
const sp = await searchParams;
const q = (sp.q ?? "").trim();
const page = Math.max(1, Number(sp.page ?? "1") || 1);
const staffId = sp.staffId ? Number(sp.staffId) : null;
const action = sp.action ?? null;
const authorizationOnly = sp.authorization === "1";
const whereClause = buildStaffActivityWhere({ q, staffId, action, authorizationOnly });
const sp = await searchParams;
const q = (sp.q ?? "").trim();
const page = Math.max(1, Number(sp.page ?? "1") || 1);
const staffId = sp.staffId ? Number(sp.staffId) : null;
const action = sp.action ?? null;
const authorizationOnly = sp.authorization === "1";
const whereClause = buildStaffActivityWhere({
q,
staffId,
action,
authorizationOnly,
});
const activities = await prisma.staffActivities
.findMany({
where: whereClause,
select: {
id: true,
userId: true,
action: true,
description: true,
targetType: true,
targetId: true,
ipAddress: true,
createdAt: true,
},
orderBy: { createdAt: "desc" },
skip: (page - 1) * PER_PAGE,
take: PER_PAGE,
})
.catch(() => []);
const activities = await prisma.staffActivities
.findMany({
where: whereClause,
select: {
id: true,
userId: true,
action: true,
description: true,
targetType: true,
targetId: true,
ipAddress: true,
createdAt: true,
},
orderBy: { createdAt: "desc" },
skip: (page - 1) * PER_PAGE,
take: PER_PAGE,
})
.catch(() => []);
const staffIds = Array.from(new Set(activities.map((a) => Number(a.userId)))).filter((n) => n > 0);
let nameById = new Map<number, string>();
if (staffIds.length) {
const users = await prisma.user
.findMany({ where: { id: { in: staffIds } }, select: { id: true, username: true } })
.catch(() => []);
nameById = new Map(users.map((x) => [x.id, x.username]));
}
const staffIds = Array.from(
new Set(activities.map((a) => Number(a.userId))),
).filter((n) => n > 0);
let nameById = new Map<number, string>();
if (staffIds.length) {
const users = await prisma.user
.findMany({
where: { id: { in: staffIds } },
select: { id: true, username: true },
})
.catch(() => []);
nameById = new Map(users.map((x) => [x.id, x.username]));
}
const total = await prisma.staffActivities.count({ where: whereClause }).catch(() => 0);
const total = await prisma.staffActivities
.count({ where: whereClause })
.catch(() => 0);
const pages = Math.max(1, Math.ceil(total / PER_PAGE));
const pages = Math.max(1, Math.ceil(total / PER_PAGE));
const onlineCount = activities.filter(
(a) => a.createdAt && new Date(a.createdAt).getTime() > Date.now() - 60000,
).length;
const onlineCount = activities.filter(
(a) => a.createdAt && new Date(a.createdAt).getTime() > Date.now() - 60000,
).length;
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">
<ScrollText size={20} className="text-[var(--admin-accent)]" />
</div>
<div>
<h1 className="m-0 text-xl font-extrabold text-[var(--admin-text)]">{t("title")}</h1>
<p className="m-0 text-xs text-[var(--admin-text-muted)] mt-0.5">{t("subtitle")}</p>
</div>
</div>
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">
<ScrollText size={20} className="text-[var(--admin-accent)]" />
</div>
<div>
<h1 className="m-0 text-xl font-extrabold text-[var(--admin-text)]">
{t("title")}
</h1>
<p className="m-0 text-xs text-[var(--admin-text-muted)] mt-0.5">
{t("subtitle")}
</p>
</div>
</div>
<div className="grid grid-cols-[repeat(auto-fit,minmax(180px,1fr))] gap-3.5 mb-6">
<StatusCard label={t("totalActivities")} value={total} icon="📋" />
<StatusCard label={t("onThisPage")} value={activities.length} icon="📄" />
<StatusCard
label={t("recent")}
value={onlineCount}
state={onlineCount > 0 ? "ok" : "neutral"}
icon="🟢"
/>
</div>
<div className="grid grid-cols-[repeat(auto-fit,minmax(180px,1fr))] gap-3.5 mb-6">
<StatusCard label={t("totalActivities")} value={total} icon="📋" />
<StatusCard
label={t("onThisPage")}
value={activities.length}
icon="📄"
/>
<StatusCard
label={t("recent")}
value={onlineCount}
state={onlineCount > 0 ? "ok" : "neutral"}
icon="🟢"
/>
</div>
<form className="admin-card flex gap-2 mb-6 flex-wrap">
<input
name="q"
defaultValue={q}
placeholder={t("searchPlaceholder")}
className="flex-1 min-w-[200px]"
/>
<label className="flex items-center gap-2 text-sm">
<input type="checkbox" name="authorization" value="1" defaultChecked={authorizationOnly} />
Authorization errors
</label>
<input
name="staffId"
defaultValue={staffId?.toString()}
placeholder={t("staffId")}
className="w-[120px]"
/>
<input
name="action"
defaultValue={action ?? ""}
placeholder={t("actionType")}
className="w-[160px]"
/>
<form className="admin-card flex gap-2 mb-6 flex-wrap">
<input
name="q"
defaultValue={q}
placeholder={t("searchPlaceholder")}
className="flex-1 min-w-[200px]"
/>
<label className="flex items-center gap-2 text-sm">
<input
type="checkbox"
name="authorization"
value="1"
defaultChecked={authorizationOnly}
/>
Authorization errors
</label>
<input
name="staffId"
defaultValue={staffId?.toString()}
placeholder={t("staffId")}
className="w-[120px]"
/>
<input
name="action"
defaultValue={action ?? ""}
placeholder={t("actionType")}
className="w-[160px]"
/>
<button type="submit" className="btn btn-primary">
{t("filter")}
</button>
{(q || staffId || action || authorizationOnly) && (
<a href="/admin/logs" className="btn btn-outline">
{t("clearFilters")}
</a>
)}
</form>
<button type="submit" className="btn btn-primary">
{t("filter")}
</button>
{(q || staffId || action || authorizationOnly) && (
<a href="/admin/logs" className="btn btn-outline">
{t("clearFilters")}
</a>
)}
</form>
<section className="mt-6">
<h2 className="admin-section-title">{t("activities", { count: total })}</h2>
{activities.length === 0 ? (
<div className="admin-empty">{t("noResults")}</div>
) : (
<div className="overflow-x-auto">
<table>
<thead>
<tr>
<th>{t("colWhen")}</th>
<th>{t("colStaff")}</th>
<th>{t("colAction")}</th>
<th>{t("colDescription")}</th>
<th>{t("colTarget")}</th>
<th>{t("colIp")}</th>
</tr>
</thead>
<tbody>
{activities.map((a) => (
<tr key={String(a.id)}>
<td className="text-sm theme-text-muted dark:theme-text-muted whitespace-nowrap">
{fromDate(a.createdAt)}
</td>
<td className="text-sm theme-text-muted dark:theme-text-muted whitespace-nowrap">
{t("staffWithId", {
id: Number(a.userId),
name: nameById.get(Number(a.userId)) ?? "unknown",
})}
</td>
<td>
<span className="inline-block text-[0.72rem] font-bold px-2 py-0.5 rounded-full bg-[var(--admin-accent)]/18 text-[var(--admin-text)]">
{a.action}
</span>
</td>
<td>{a.description}</td>
<td>
{a.targetType && a.targetId ? (
<span className="text-sm theme-text-muted dark:theme-text-muted">
{a.targetType === "user" ? t("userPrefix") : ""}#{String(a.targetId)}
</span>
) : (
<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">
{a.ipAddress || "—"}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</section>
<section className="mt-6">
<h2 className="admin-section-title">
{t("activities", { count: total })}
</h2>
{activities.length === 0 ? (
<div className="admin-empty">{t("noResults")}</div>
) : (
<div className="overflow-x-auto">
<table>
<thead>
<tr>
<th>{t("colWhen")}</th>
<th>{t("colStaff")}</th>
<th>{t("colAction")}</th>
<th>{t("colDescription")}</th>
<th>{t("colTarget")}</th>
<th>{t("colIp")}</th>
</tr>
</thead>
<tbody>
{activities.map((a) => (
<tr key={String(a.id)}>
<td className="text-sm theme-text-muted dark:theme-text-muted whitespace-nowrap">
{fromDate(a.createdAt)}
</td>
<td className="text-sm theme-text-muted dark:theme-text-muted whitespace-nowrap">
{t("staffWithId", {
id: Number(a.userId),
name: nameById.get(Number(a.userId)) ?? "unknown",
})}
</td>
<td>
<span className="inline-block text-[0.72rem] font-bold px-2 py-0.5 rounded-full bg-[var(--admin-accent)]/18 text-[var(--admin-text)]">
{a.action}
</span>
</td>
<td>{a.description}</td>
<td>
{a.targetType && a.targetId ? (
<span className="text-sm theme-text-muted dark:theme-text-muted">
{a.targetType === "user" ? t("userPrefix") : ""}#
{String(a.targetId)}
</span>
) : (
<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">
{a.ipAddress || "—"}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</section>
<p className="text-xs theme-text-muted dark:theme-text-muted">
{t("pagination", { page, pages, total })}
{page < pages ? (
<>
{" · "}
<a
href={`/admin/logs?q=${encodeURIComponent(q)}&staffId=${staffId || ""}&action=${action || ""}&authorization=${authorizationOnly ? "1" : ""}&page=${page + 1}`}
>
{t("next")} →
</a>
</>
) : null}
</p>
</main>
);
<p className="text-xs theme-text-muted dark:theme-text-muted">
{t("pagination", { page, pages, total })}
{page < pages ? (
<>
{" · "}
<a
href={`/admin/logs?q=${encodeURIComponent(q)}&staffId=${staffId || ""}&action=${action || ""}&authorization=${authorizationOnly ? "1" : ""}&page=${page + 1}`}
>
{t("next")} →
</a>
</>
) : null}
</p>
</main>
);
}
+27 -24
View File
@@ -1,28 +1,31 @@
import { Skeleton } from "@/components/ui/skeleton";
export default function TradeLogsLoading() {
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<Skeleton className="h-9 w-40" />
<Skeleton className="h-5 w-64 mt-2" />
</div>
</div>
<div className="flex items-center gap-3">
<Skeleton className="h-10 w-64" />
<Skeleton className="h-10 w-24" />
</div>
<div className="rounded-md border">
{Array.from({ length: 8 }).map((_, i) => (
<div key={i} className="flex items-center gap-6 px-4 py-3 border-b last:border-0">
<Skeleton className="h-4 w-12" />
<Skeleton className="h-4 w-32" />
<Skeleton className="h-4 w-24" />
<Skeleton className="h-5 w-16 rounded-full" />
</div>
))}
</div>
</div>
);
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<Skeleton className="h-9 w-40" />
<Skeleton className="h-5 w-64 mt-2" />
</div>
</div>
<div className="flex items-center gap-3">
<Skeleton className="h-10 w-64" />
<Skeleton className="h-10 w-24" />
</div>
<div className="rounded-md border">
{Array.from({ length: 8 }).map((_, i) => (
<div
key={i}
className="flex items-center gap-6 px-4 py-3 border-b last:border-0"
>
<Skeleton className="h-4 w-12" />
<Skeleton className="h-4 w-32" />
<Skeleton className="h-4 w-24" />
<Skeleton className="h-5 w-16 rounded-full" />
</div>
))}
</div>
</div>
);
}
+42 -42
View File
@@ -3,50 +3,50 @@ import { PERMS } from "@/lib/permissions";
import { TradesTable } from "./trades-table";
export default async function TradeLogsPage({
params: paramsPromise,
searchParams,
params: paramsPromise,
searchParams,
}: {
params: Promise<{ locale: string }>;
searchParams: Promise<Record<string, string>>;
params: Promise<{ locale: string }>;
searchParams: Promise<Record<string, string>>;
}) {
const result = await fetchAdminList(
{
permission: PERMS.LOGS_VIEW,
model: "trade",
searchFields: ["userOne.username", "userTwo.username"],
defaultPerPage: 50,
include: {
userOne: { select: { username: true } },
userTwo: { select: { username: true } },
},
mapRow: (t) => ({
id: t.id,
userOneName: t.userOne.username,
userTwoName: t.userTwo.username,
status: t.status,
timestamp: t.timestamp,
}),
},
paramsPromise,
searchParams,
);
const result = await fetchAdminList(
{
permission: PERMS.LOGS_VIEW,
model: "trade",
searchFields: ["userOne.username", "userTwo.username"],
defaultPerPage: 50,
include: {
userOne: { select: { username: true } },
userTwo: { select: { username: true } },
},
mapRow: (t) => ({
id: t.id,
userOneName: t.userOne.username,
userTwoName: t.userTwo.username,
status: t.status,
timestamp: t.timestamp,
}),
},
paramsPromise,
searchParams,
);
return (
<div className="space-y-6">
<div>
<h1 className="text-3xl font-bold tracking-tight">Trade Logs</h1>
<p className="text-muted-foreground">View in-game trade history</p>
</div>
return (
<div className="space-y-6">
<div>
<h1 className="text-3xl font-bold tracking-tight">Trade Logs</h1>
<p className="text-muted-foreground">View in-game trade history</p>
</div>
<TradesTable
data={{
rows: result.rows,
total: result.total,
page: result.page,
perPage: result.perPage,
lastPage: result.lastPage,
}}
/>
</div>
);
<TradesTable
data={{
rows: result.rows,
total: result.total,
page: result.page,
perPage: result.perPage,
lastPage: result.lastPage,
}}
/>
</div>
);
}
+37 -26
View File
@@ -6,39 +6,50 @@ import { formatTimestamp } from "@/lib/admin-helpers";
import type { DataTableColumn, PaginatedResult } from "@/types";
interface TradeLogRow {
id: number;
userOneName: string;
userTwoName: string;
status: string;
timestamp: number;
id: number;
userOneName: string;
userTwoName: string;
status: string;
timestamp: number;
}
const columns: DataTableColumn<TradeLogRow>[] = [
{ key: "id", label: "ID", sortable: true },
{ key: "userOneName", label: "User One" },
{ key: "userTwoName", label: "User Two" },
{
key: "status",
label: "Status",
sortable: true,
render: (value) => {
const v = String(value);
const variant = v === "completed" ? "default" : v === "cancelled" ? "destructive" : "secondary";
return <Badge variant={variant}>{v || "unknown"}</Badge>;
},
},
{
key: "timestamp",
label: "Time",
sortable: true,
render: (value) => <span>{formatTimestamp(Number(value))}</span>,
},
{ key: "id", label: "ID", sortable: true },
{ key: "userOneName", label: "User One" },
{ key: "userTwoName", label: "User Two" },
{
key: "status",
label: "Status",
sortable: true,
render: (value) => {
const v = String(value);
const variant =
v === "completed"
? "default"
: v === "cancelled"
? "destructive"
: "secondary";
return <Badge variant={variant}>{v || "unknown"}</Badge>;
},
},
{
key: "timestamp",
label: "Time",
sortable: true,
render: (value) => <span>{formatTimestamp(Number(value))}</span>,
},
];
interface TradesTableProps {
data: PaginatedResult<TradeLogRow>;
data: PaginatedResult<TradeLogRow>;
}
export function TradesTable({ data }: TradesTableProps) {
return <DataTable data={data} columns={columns} searchPlaceholder="Search by username..." />;
return (
<DataTable
data={data}
columns={columns}
searchPlaceholder="Search by username..."
/>
);
}
+135 -115
View File
@@ -1,135 +1,155 @@
import { getTranslations } from "next-intl/server";
import { Wrench } from "lucide-react";
import { getTranslations } from "next-intl/server";
import { saveMaintenance } from "@/actions/admin-maintenance";
import { StatusCard } from "@/components/admin/dashboard";
import { prisma } from "@/lib/prisma";
export const dynamic = "force-dynamic";
const KEYS = ["maintenance_enabled", "maintenance_message", "min_maintenance_login_rank"] as const;
const KEYS = [
"maintenance_enabled",
"maintenance_message",
"min_maintenance_login_rank",
] as const;
export default async function AdminMaintenance() {
const t = await getTranslations("pages.admin.maintenance");
const t = await getTranslations("pages.admin.maintenance");
let rows: { key: string; value: string }[] = [];
try {
rows = await prisma.websiteSetting.findMany({
where: { key: { in: [...KEYS] } },
select: { key: true, value: true },
});
} catch {
rows = [];
}
let rows: { key: string; value: string }[] = [];
try {
rows = await prisma.websiteSetting.findMany({
where: { key: { in: [...KEYS] } },
select: { key: true, value: true },
});
} catch {
rows = [];
}
const byKey = new Map(rows.map((r) => [r.key, r.value]));
const byKey = new Map(rows.map((r) => [r.key, r.value]));
const enabledRaw = byKey.get("maintenance_enabled") ?? "0";
const enabled = enabledRaw === "1" || enabledRaw.toLowerCase() === "true";
const message = byKey.get("maintenance_message") ?? "";
const minRankRaw = byKey.get("min_maintenance_login_rank") ?? "5";
const minRank = Number.parseInt(minRankRaw, 10);
const minRankValue = Number.isFinite(minRank) ? minRank : 5;
const enabledRaw = byKey.get("maintenance_enabled") ?? "0";
const enabled = enabledRaw === "1" || enabledRaw.toLowerCase() === "true";
const message = byKey.get("maintenance_message") ?? "";
const minRankRaw = byKey.get("min_maintenance_login_rank") ?? "5";
const minRank = Number.parseInt(minRankRaw, 10);
const minRankValue = Number.isFinite(minRank) ? minRank : 5;
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">
<Wrench size={20} className="text-[var(--admin-accent)]" />
</div>
<div>
<h1 className="m-0 text-xl font-extrabold text-[var(--admin-text)]">{t("title")}</h1>
<p className="m-0 text-xs text-[var(--admin-text-muted)] mt-0.5">
{t.rich("subtitle", { code: (chunks) => <code>{chunks}</code> })}
</p>
</div>
</div>
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">
<Wrench size={20} className="text-[var(--admin-accent)]" />
</div>
<div>
<h1 className="m-0 text-xl font-extrabold text-[var(--admin-text)]">
{t("title")}
</h1>
<p className="m-0 text-xs text-[var(--admin-text-muted)] mt-0.5">
{t.rich("subtitle", { code: (chunks) => <code>{chunks}</code> })}
</p>
</div>
</div>
<div className="grid grid-cols-[repeat(auto-fit,minmax(180px,1fr))] gap-3.5 mb-6">
<StatusCard
label={t("maintenance")}
value={enabled ? t("enabled") : t("disabled")}
state={enabled ? "danger" : "ok"}
hint={enabled ? t("hotelLockedDown") : t("hotelOpen")}
icon={enabled ? "🚧" : "🟢"}
/>
<StatusCard label={t("minLoginRank")} value={minRankValue} hint={t("duringMaintenance")} icon="🛡️" />
<StatusCard
label={t("splashMessage")}
value={message ? t("set") : t("empty")}
state={message ? "ok" : "neutral"}
icon="💬"
/>
</div>
<div className="grid grid-cols-[repeat(auto-fit,minmax(180px,1fr))] gap-3.5 mb-6">
<StatusCard
label={t("maintenance")}
value={enabled ? t("enabled") : t("disabled")}
state={enabled ? "danger" : "ok"}
hint={enabled ? t("hotelLockedDown") : t("hotelOpen")}
icon={enabled ? "🚧" : "🟢"}
/>
<StatusCard
label={t("minLoginRank")}
value={minRankValue}
hint={t("duringMaintenance")}
icon="🛡️"
/>
<StatusCard
label={t("splashMessage")}
value={message ? t("set") : t("empty")}
state={message ? "ok" : "neutral"}
icon="💬"
/>
</div>
<section className="mt-6">
<h2 className="admin-section-title">{t("currentState")}</h2>
<div className="admin-card flex items-center gap-3">
<span
className={`inline-block text-[0.72rem] font-bold px-2 py-0.5 rounded-full ${enabled ? "bg-[var(--admin-error-subtle)] text-[var(--admin-text)]" : "bg-[var(--admin-success-subtle)] text-[var(--admin-text)]"}`}
>
{enabled ? t("enabled") : t("disabled")}
</span>
<div>
<strong>{t("maintenanceCurrently", { status: enabled ? t("enabled") : t("disabled") })}</strong>
<p className="text-xs theme-text-muted dark:theme-text-muted mt-1 m-0">
{enabled ? t("onlyRankCanLogin", { rank: minRankValue }) : t("hotelOpenToEveryone")}
</p>
</div>
</div>
</section>
<section className="mt-6">
<h2 className="admin-section-title">{t("currentState")}</h2>
<div className="admin-card flex items-center gap-3">
<span
className={`inline-block text-[0.72rem] font-bold px-2 py-0.5 rounded-full ${enabled ? "bg-[var(--admin-error-subtle)] text-[var(--admin-text)]" : "bg-[var(--admin-success-subtle)] text-[var(--admin-text)]"}`}
>
{enabled ? t("enabled") : t("disabled")}
</span>
<div>
<strong>
{t("maintenanceCurrently", {
status: enabled ? t("enabled") : t("disabled"),
})}
</strong>
<p className="text-xs theme-text-muted dark:theme-text-muted mt-1 m-0">
{enabled
? t("onlyRankCanLogin", { rank: minRankValue })
: t("hotelOpenToEveryone")}
</p>
</div>
</div>
</section>
<section className="mt-6">
<h2 className="admin-section-title">{t("settings")}</h2>
<form action={saveMaintenance} className="admin-card">
<label htmlFor="mt-enabled" className="flex items-center gap-2 mb-4">
<input
id="mt-enabled"
type="checkbox"
name="enabled"
value="1"
defaultChecked={enabled}
className="w-auto"
/>
<span>{t("enableMaintenance")}</span>
</label>
<section className="mt-6">
<h2 className="admin-section-title">{t("settings")}</h2>
<form action={saveMaintenance} className="admin-card">
<label htmlFor="mt-enabled" className="flex items-center gap-2 mb-4">
<input
id="mt-enabled"
type="checkbox"
name="enabled"
value="1"
defaultChecked={enabled}
className="w-auto"
/>
<span>{t("enableMaintenance")}</span>
</label>
<div className="mb-4 max-w-[220px]">
<label
htmlFor="mt-min-rank"
className="text-sm theme-text-muted dark:theme-text-muted block mb-1"
>
{t("minLoginRankLabel")}
</label>
<input
id="mt-min-rank"
type="number"
name="min_rank"
min={0}
step={1}
defaultValue={minRankValue}
className="w-full"
/>
</div>
<div className="mb-4 max-w-[220px]">
<label
htmlFor="mt-min-rank"
className="text-sm theme-text-muted dark:theme-text-muted block mb-1"
>
{t("minLoginRankLabel")}
</label>
<input
id="mt-min-rank"
type="number"
name="min_rank"
min={0}
step={1}
defaultValue={minRankValue}
className="w-full"
/>
</div>
<div className="mb-4">
<label htmlFor="mt-message" className="text-sm theme-text-muted dark:theme-text-muted block mb-1">
{t("maintenanceMessageLabel")}
</label>
<textarea
id="mt-message"
name="message"
rows={8}
defaultValue={message}
placeholder={t("maintenanceMessagePlaceholder")}
className="w-full resize-y"
/>
</div>
<div className="mb-4">
<label
htmlFor="mt-message"
className="text-sm theme-text-muted dark:theme-text-muted block mb-1"
>
{t("maintenanceMessageLabel")}
</label>
<textarea
id="mt-message"
name="message"
rows={8}
defaultValue={message}
placeholder={t("maintenanceMessagePlaceholder")}
className="w-full resize-y"
/>
</div>
<button type="submit" className="btn btn-primary">
{t("saveChanges")}
</button>
</form>
</section>
</main>
);
<button type="submit" className="btn btn-primary">
{t("saveChanges")}
</button>
</form>
</section>
</main>
);
}
+27 -23
View File
@@ -1,30 +1,34 @@
import { Image } from "lucide-react";
import Link from "next/link";
import { getTranslations } from "next-intl/server";
import { Image } from "lucide-react";
import { AdminMediaGrid } from "@/components/admin/media-grid";
export default async function AdminMediaPage() {
const t = await getTranslations("pages.admin.media");
const t = await getTranslations("pages.admin.media");
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">
<Image size={20} className="text-[var(--admin-accent)]" />
</div>
<div className="flex-1">
<h1 className="m-0 text-xl font-extrabold text-[var(--admin-text)]">{t("title")}</h1>
<p className="m-0 text-xs text-[var(--admin-text-muted)] mt-0.5">{t("subtitle")}</p>
</div>
<Link
href="/admin/articles"
className="text-sm theme-text-muted dark:theme-text-muted"
style={{ fontSize: "0.88rem" }}
>
← {t("articles")}
</Link>
</div>
<AdminMediaGrid />
</main>
);
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">
<Image size={20} className="text-[var(--admin-accent)]" />
</div>
<div className="flex-1">
<h1 className="m-0 text-xl font-extrabold text-[var(--admin-text)]">
{t("title")}
</h1>
<p className="m-0 text-xs text-[var(--admin-text-muted)] mt-0.5">
{t("subtitle")}
</p>
</div>
<Link
href="/admin/articles"
className="text-sm theme-text-muted dark:theme-text-muted"
style={{ fontSize: "0.88rem" }}
>
← {t("articles")}
</Link>
</div>
<AdminMediaGrid />
</main>
);
}
Loaded 100 of 735 files, more files were not shown because too many files have changed in this diff. Show more