This commit is contained in:
1 parent
8efd032cc6
commit
df38dccbf1
735 files changed
+128321
-120870
No files matched your search
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 "{inputValue.trim()}"
|
||||
</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 "{inputValue.trim()}"
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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} · {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} · {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,5 +1,5 @@
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
export default function BuilderClubPage() {
|
||||
redirect("/admin/catalog?catalog=bc");
|
||||
redirect("/admin/catalog?catalog=bc");
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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) · {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) ·{" "}
|
||||
{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) · {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) · {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
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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} · {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} · {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>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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..."
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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")}
|
||||
>
|
||||
← Prev
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={!hasMore || loading}
|
||||
onClick={() => goPage("next")}
|
||||
>
|
||||
Next →
|
||||
</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")}
|
||||
>
|
||||
← Prev
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={!hasMore || loading}
|
||||
onClick={() => goPage("next")}
|
||||
>
|
||||
Next →
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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 />;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 />;
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
Reference in new issue
Block a user