Wrap next/link in a shared Link component that ships prefetch=false by default, so no route is ever prefetched (viewport or hover) anymore, and drop the DNS prefetch hint. Removes hidden background requests that were the source of intermittent issues.
108 lines
3.0 KiB
TypeScript
108 lines
3.0 KiB
TypeScript
import { desc, inArray } from "drizzle-orm";
|
|
import { Newspaper } from "lucide-react";
|
|
import { redirect } from "next/navigation";
|
|
import { getTranslations } from "next-intl/server";
|
|
import { AdminPageShell } from "@/components/admin/admin-page-shell";
|
|
import { ArticleCard } from "@/components/admin/article-card";
|
|
import { StatusCard } from "@/components/admin/dashboard";
|
|
import Link from "@/components/link";
|
|
import { Button } from "@/components/ui/button";
|
|
import { db, User, WebsiteArticles } from "@/lib/db";
|
|
import { formatDate } from "@/lib/format-date";
|
|
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
|
|
|
export default async function AdminArticles({
|
|
searchParams,
|
|
}: {
|
|
searchParams: Promise<{ error?: string }>;
|
|
}) {
|
|
const { session, permissions } = await getAdminContext();
|
|
if (!canAccess(permissions, PERMS.NEWS_VIEW, session.user.rank)) {
|
|
redirect("/admin");
|
|
}
|
|
|
|
const { error } = await searchParams;
|
|
const t = await getTranslations("pages.admin.articles");
|
|
const articles = await db
|
|
.select({
|
|
id: WebsiteArticles.id,
|
|
title: WebsiteArticles.title,
|
|
slug: WebsiteArticles.slug,
|
|
image: WebsiteArticles.image,
|
|
createdAt: WebsiteArticles.createdAt,
|
|
userId: WebsiteArticles.userId,
|
|
})
|
|
.from(WebsiteArticles)
|
|
.orderBy(desc(WebsiteArticles.createdAt))
|
|
.limit(50)
|
|
.catch(() => []);
|
|
|
|
const authorMap = new Map<number, string>();
|
|
const authorIds = articles
|
|
.map((a) => a.userId)
|
|
.filter((id): id is number => Boolean(id));
|
|
if (authorIds.length > 0) {
|
|
const authors = await db
|
|
.select({ id: User.id, username: User.username })
|
|
.from(User)
|
|
.where(inArray(User.id, authorIds))
|
|
.catch(() => []);
|
|
for (const u of authors) authorMap.set(u.id, u.username);
|
|
}
|
|
|
|
const latest = articles[0]?.createdAt
|
|
? formatDate(articles[0].createdAt, "date")
|
|
: "—";
|
|
|
|
return (
|
|
<AdminPageShell
|
|
icon={Newspaper}
|
|
title={t("title")}
|
|
subtitle={t("subtitle")}
|
|
actions={
|
|
<Button variant="default" asChild>
|
|
<Link href="/admin/articles/new">{t("newArticle")}</Link>
|
|
</Button>
|
|
}
|
|
>
|
|
{error ? (
|
|
<p className="text-xs text-[var(--admin-error)] mb-4">Error: {error}</p>
|
|
) : null}
|
|
|
|
<div className="grid grid-cols-[repeat(auto-fit,minmax(180px,1fr))] gap-3.5 mb-6">
|
|
<StatusCard
|
|
label={t("articlesShown")}
|
|
value={articles.length}
|
|
icon="📰"
|
|
/>
|
|
<StatusCard
|
|
label={t("latest")}
|
|
value={latest}
|
|
hint={t("mostRecentPost")}
|
|
icon="🕒"
|
|
/>
|
|
</div>
|
|
|
|
<section className="mt-2">
|
|
<h2 className="admin-section-title">{t("recentArticles")}</h2>
|
|
{articles.length === 0 ? (
|
|
<div className="admin-empty">{t("noArticles")}</div>
|
|
) : (
|
|
<div className="grid grid-cols-[repeat(auto-fill,minmax(260px,1fr))] gap-4">
|
|
{articles.map((a) => (
|
|
<ArticleCard
|
|
key={String(a.id)}
|
|
id={String(a.id)}
|
|
title={a.title}
|
|
image={a.image}
|
|
createdAt={a.createdAt ? a.createdAt.toISOString() : null}
|
|
author={authorMap.get(a.userId ?? -1)}
|
|
/>
|
|
))}
|
|
</div>
|
|
)}
|
|
</section>
|
|
</AdminPageShell>
|
|
);
|
|
}
|