CI / check (push) Has been cancelled
1. Performance: 25 pages switched from force-dynamic to revalidate=300 (ISR); 2 pages (community, developers) now fully static (SSG) 2. DB indexes: Added @@index on foreign keys for WebsiteArticles, WebsiteArticleReactions, WebsiteArticleComments, WebsiteHelpCenterTickets, WebsiteShopArticles, RadioSongRequests, StaffActivities 3. SEO: Added robots.ts, sitemap.ts, canonical URLs, Open Graph + Twitter Card metadata on root layout and news articles 4. A11Y: Replaced <details>/<summary> dropdowns with accessible button-based NavDropdown (aria-expanded, aria-haspopup, role=menu). MobileNav now uses translated aria-label, aria-expanded, aria-controls, role=menu 5. Code quality: Added try/catch to updateArticle/deleteArticle; deleteArticle now uses prisma. for atomicity 6. CI/CD: Added GitHub Actions workflow (typecheck + test) 7. i18n: Added openMenu/closeMenu keys to all 6 locales 8. Observability: Health endpoint now checks SMTP reachability when configured 9. Loading states: Added loading.tsx for root, admin, and news sections 10. Word filter cache: Added 60s TTL auto-refresh instead of manual cache bust
177 lines
5.2 KiB
TypeScript
177 lines
5.2 KiB
TypeScript
import { getTranslations } from "next-intl/server";
|
|
import Link from "next/link";
|
|
import { notFound } from "next/navigation";
|
|
import { ContentCard, EmptyState } from "@/components/public/ui";
|
|
import { prisma } from "@/lib/prisma";
|
|
import { siteSettings } from "@/lib/services/site-settings";
|
|
|
|
export const revalidate = 300;
|
|
|
|
type RareRow = {
|
|
id: bigint;
|
|
name: string;
|
|
creditValue: string | null;
|
|
currencyValue: string | null;
|
|
currencyType: string;
|
|
furnitureIcon: string;
|
|
itemId: number | null;
|
|
};
|
|
|
|
type CurrencyTranslator = (key: string) => string;
|
|
|
|
function prettyCurrency(type: string, t: CurrencyTranslator): string {
|
|
switch (type) {
|
|
case "0":
|
|
case "duckets":
|
|
return t("duckets");
|
|
case "5":
|
|
case "diamonds":
|
|
return t("diamonds");
|
|
case "-1":
|
|
case "credits":
|
|
return t("credits");
|
|
default:
|
|
return type.charAt(0).toUpperCase() + type.slice(1);
|
|
}
|
|
}
|
|
|
|
export default async function RareCategoryPage({
|
|
params,
|
|
}: {
|
|
params: Promise<{ category: string }>;
|
|
}) {
|
|
const t = await getTranslations("pages.rareCategory");
|
|
const { category } = await params;
|
|
|
|
let categoryId: bigint;
|
|
try {
|
|
categoryId = BigInt(category);
|
|
} catch {
|
|
notFound();
|
|
}
|
|
|
|
let cat: { id: bigint; name: string; badge: string } | null = null;
|
|
try {
|
|
cat = await prisma.websiteRareValueCategories.findUnique({
|
|
where: { id: categoryId! },
|
|
select: { id: true, name: true, badge: true },
|
|
});
|
|
} catch {
|
|
cat = null;
|
|
}
|
|
|
|
if (!cat) notFound();
|
|
|
|
let rares: RareRow[] = [];
|
|
try {
|
|
rares = await prisma.websiteRareValues.findMany({
|
|
where: { categoryId: categoryId! },
|
|
orderBy: { name: "asc" },
|
|
select: {
|
|
id: true,
|
|
name: true,
|
|
creditValue: true,
|
|
currencyValue: true,
|
|
currencyType: true,
|
|
furnitureIcon: true,
|
|
itemId: true,
|
|
},
|
|
});
|
|
} catch {
|
|
rares = [];
|
|
}
|
|
|
|
const [iconsBase, badgesBase] = await Promise.all([
|
|
siteSettings.get("furniture_icons_path", ""),
|
|
siteSettings.get("badges_path", ""),
|
|
]);
|
|
const iconBase = iconsBase ?? "";
|
|
const badgeBase = badgesBase ?? "";
|
|
const badgeSrc = badgeBase && cat.badge ? `${badgeBase}/${cat.badge}.gif` : "";
|
|
|
|
return (
|
|
<main style={{ display: "grid", gap: "1.5rem" }}>
|
|
<ContentCard
|
|
icon={badgeSrc ? undefined : "💎"}
|
|
title={
|
|
<span style={{ display: "inline-flex", alignItems: "center", gap: "0.6rem" }}>
|
|
{badgeSrc ? (
|
|
// eslint-disable-next-line @next/next/no-img-element
|
|
<img src={badgeSrc} alt={`${cat.name} badge`} width={32} height={32} />
|
|
) : null}
|
|
{cat.name}
|
|
</span>
|
|
}
|
|
subtitle={t("rareCount", { count: rares.length })}
|
|
action={
|
|
<Link className="btn btn-outline" href="/rares">
|
|
{t("allRares")}
|
|
</Link>
|
|
}
|
|
padded={rares.length === 0}
|
|
>
|
|
{rares.length === 0 ? (
|
|
<EmptyState icon="💎">{t("empty")}</EmptyState>
|
|
) : (
|
|
<div style={{ padding: "0.25rem 1rem 0.75rem", overflowX: "auto" }}>
|
|
<table>
|
|
<thead>
|
|
<tr>
|
|
<th style={{ width: 56 }}></th>
|
|
<th>{t("colRare")}</th>
|
|
<th>{t("colCredits")}</th>
|
|
<th>{t("colCurrency")}</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{rares.map((r) => {
|
|
const iconSrc =
|
|
iconBase && r.furnitureIcon ? `${iconBase}/${r.furnitureIcon}` : "";
|
|
return (
|
|
<tr key={String(r.id)}>
|
|
<td>
|
|
{iconSrc ? (
|
|
// eslint-disable-next-line @next/next/no-img-element
|
|
<img
|
|
src={iconSrc}
|
|
alt=""
|
|
width={40}
|
|
height={40}
|
|
style={{ objectFit: "contain" }}
|
|
/>
|
|
) : (
|
|
<span
|
|
className="avatar"
|
|
style={{ display: "inline-block", width: 40, height: 40 }}
|
|
/>
|
|
)}
|
|
</td>
|
|
<td>
|
|
<strong>{r.name}</strong>
|
|
</td>
|
|
<td>
|
|
{r.creditValue && r.creditValue !== "" ? (
|
|
t("creditsValue", { value: r.creditValue })
|
|
) : (
|
|
<span className="muted">—</span>
|
|
)}
|
|
</td>
|
|
<td>
|
|
{r.currencyValue && r.currencyValue !== "" ? (
|
|
`${r.currencyValue} ${prettyCurrency(r.currencyType, t)}`
|
|
) : (
|
|
<span className="muted">—</span>
|
|
)}
|
|
</td>
|
|
</tr>
|
|
);
|
|
})}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
)}
|
|
</ContentCard>
|
|
</main>
|
|
);
|
|
}
|