136 lines
3.8 KiB
TypeScript
136 lines
3.8 KiB
TypeScript
import Link from "next/link";
|
|
import { redirect } from "next/navigation";
|
|
import { getTranslations } from "next-intl/server";
|
|
import { StatusCard } from "@/components/admin/dashboard";
|
|
import { formatDate } from "@/lib/format-date";
|
|
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
|
import { prisma } from "@/lib/prisma";
|
|
|
|
type Campaign = {
|
|
id: number;
|
|
name: string;
|
|
image: string;
|
|
startTimestamp: number;
|
|
totalDays: number;
|
|
lockExpired: string;
|
|
enabled: string;
|
|
};
|
|
|
|
export default async function AdminCalendar() {
|
|
const { session, permissions } = await getAdminContext();
|
|
if (!canAccess(permissions, PERMS.SHOP_VIEW, session.user.rank)) {
|
|
redirect("/admin");
|
|
}
|
|
|
|
const t = await getTranslations("pages.admin.calendar");
|
|
|
|
let campaigns: Campaign[] = [];
|
|
let loadError = false;
|
|
try {
|
|
campaigns = await prisma.calendarCampaigns.findMany({
|
|
select: {
|
|
id: true,
|
|
name: true,
|
|
image: true,
|
|
startTimestamp: true,
|
|
totalDays: true,
|
|
lockExpired: true,
|
|
enabled: true,
|
|
},
|
|
orderBy: { id: "desc" },
|
|
take: 200,
|
|
});
|
|
} catch {
|
|
campaigns = [];
|
|
loadError = true;
|
|
}
|
|
|
|
const rewardCounts = new Map<number, number>();
|
|
let totalRewards = 0;
|
|
try {
|
|
const grouped = await prisma.calendarRewards.groupBy({
|
|
by: ["campaignId"],
|
|
_count: { _all: true },
|
|
});
|
|
for (const g of grouped) {
|
|
rewardCounts.set(g.campaignId, g._count._all);
|
|
totalRewards += g._count._all;
|
|
}
|
|
} catch {
|
|
loadError = true;
|
|
}
|
|
|
|
const enabledCount = campaigns.filter((c) => c.enabled === "1").length;
|
|
|
|
return (
|
|
<main>
|
|
{loadError ? (
|
|
<div className="mb-4 rounded-lg bg-[var(--admin-error-subtle)] p-3 text-xs text-[var(--admin-error)]">
|
|
Could not load some calendar data (database unavailable).
|
|
</div>
|
|
) : null}
|
|
<div className="grid grid-cols-[repeat(auto-fit,minmax(180px,1fr))] gap-3.5 mb-6">
|
|
<StatusCard label={t("campaigns")} value={campaigns.length} icon="📅" />
|
|
<StatusCard
|
|
label={t("enabled")}
|
|
value={enabledCount}
|
|
state={enabledCount > 0 ? "ok" : "neutral"}
|
|
icon="✅"
|
|
/>
|
|
<StatusCard label={t("totalRewards")} value={totalRewards} icon="🎁" />
|
|
</div>
|
|
|
|
{campaigns.length === 0 ? (
|
|
<div className="admin-empty">{t("noCampaigns")}</div>
|
|
) : (
|
|
<div className="grid grid-cols-1 gap-4 md:grid-cols-3">
|
|
{campaigns.map((c) => {
|
|
const enabled = c.enabled === "1";
|
|
const count = rewardCounts.get(c.id) ?? 0;
|
|
return (
|
|
<Link
|
|
key={c.id}
|
|
href={`/admin/calendar/${c.id}`}
|
|
className="admin-card hover article"
|
|
>
|
|
{c.image ? (
|
|
// eslint-disable-next-line @next/next/no-img-element
|
|
<img className="article-img" src={c.image} alt={c.name} />
|
|
) : (
|
|
<div className="article-img" />
|
|
)}
|
|
<div className="body">
|
|
<div className="flex items-center justify-between gap-2 mb-1">
|
|
<h3 className="m-0">{c.name || `#${c.id}`}</h3>
|
|
<span
|
|
className={`inline-block text-[0.72rem] font-bold px-2 py-0.5 rounded-full ${enabled ? "bg-[var(--admin-success-subtle)] text-[var(--admin-text)]" : "bg-[var(--admin-error-subtle)] text-[var(--admin-text)]"}`}
|
|
>
|
|
{enabled ? t("statusEnabled") : t("statusDisabled")}
|
|
</span>
|
|
</div>
|
|
<p className="text-xs theme-text-muted dark:theme-text-muted m-0">
|
|
{t("campaignMeta", {
|
|
id: c.id,
|
|
days: c.totalDays,
|
|
rewards: count,
|
|
})}
|
|
</p>
|
|
<p className="text-xs theme-text-muted dark:theme-text-muted mt-1">
|
|
{t("startsAt", {
|
|
date: formatDate(
|
|
new Date(c.startTimestamp * 1000),
|
|
"date",
|
|
),
|
|
})}
|
|
{c.lockExpired === "1" ? ` · ${t("locksExpired")}` : ""}
|
|
</p>
|
|
</div>
|
|
</Link>
|
|
);
|
|
})}
|
|
</div>
|
|
)}
|
|
</main>
|
|
);
|
|
}
|