perf : improve via lighthouse
CI / Verify (push) Successful in 1m14s
CI / Build immutable images and deploy (push) Failing after 57s

This commit is contained in:
2026-09-08 18:11:15 +07:00 Unverified
parent 63b1ec6629
commit 9a3672dab3
10 changed files with 11999 additions and 40 deletions
-3
View File
@@ -1,6 +1,5 @@
import type { Metadata } from "next";
import { notFound } from "next/navigation";
import { connection } from "next/server";
import { publicSections, StructuredGuideShell } from "@/components/public/structured-guide";
import { getCachedPublicGuide } from "@/lib/cache/public";
@@ -10,14 +9,12 @@ type Params = Promise<{ character: string; page: string }>;
export const instant = false;
export async function generateMetadata({ params }: { params: Params }): Promise<Metadata> {
await connection();
const { character } = await params;
const guide = await getCachedPublicGuide(character);
return guide ? { title: `${guide.name}`, description: guide.overview } : { title: "ไม่พบไกด์" };
}
export default async function GuideSectionPage({ params }: { params: Params }) {
await connection();
const { character, page } = await params;
const guide = await getCachedPublicGuide(character);
if (!guide || !publicSections(guide).some((section) => section.slug === page)) notFound();
-3
View File
@@ -1,6 +1,5 @@
import type { Metadata } from "next";
import { notFound } from "next/navigation";
import { connection } from "next/server";
import { StructuredGuideShell } from "@/components/public/structured-guide";
import { getCachedPublicGuide } from "@/lib/cache/public";
@@ -14,7 +13,6 @@ export async function generateMetadata({
}: {
params: Params;
}): Promise<Metadata> {
await connection();
const guide = await getCachedPublicGuide((await params).character);
return guide
? { title: `${guide.name}`, description: guide.overview }
@@ -26,7 +24,6 @@ export default async function GuideOverviewPage({
}: {
params: Params;
}) {
await connection();
const guide = await getCachedPublicGuide((await params).character);
if (!guide) notFound();
return <StructuredGuideShell guide={guide} />;
+7 -1
View File
@@ -1,5 +1,11 @@
import { Suspense } from "react";
import { TooltipProvider } from "@/components/ui/tooltip";
export default function AdminLayout({ children }: LayoutProps<"/admin">) {
return <Suspense fallback={null}>{children}</Suspense>;
return (
<TooltipProvider>
<Suspense fallback={null}>{children}</Suspense>
</TooltipProvider>
);
}
+9 -13
View File
@@ -4,7 +4,6 @@ import { Anuphan, Geist_Mono } from "next/font/google";
import { DeploymentUpdateNotifier } from "@/components/deployment-update-notifier";
import { PageProgress } from "@/components/page-progress";
import { Toaster } from "@/components/ui/sonner";
import { TooltipProvider } from "@/components/ui/tooltip";
import "./globals.css";
@@ -28,8 +27,6 @@ export const metadata: Metadata = {
description: "ไกด์ตัวละครจากช่องเกนชินไม่ใช่เกมมือถือ",
};
export const instant = false;
export default function RootLayout({ children }: LayoutProps<"/">) {
const deploymentId = process.env.NEXT_DEPLOYMENT_ID?.trim() || "development";
@@ -39,16 +36,15 @@ export default function RootLayout({ children }: LayoutProps<"/">) {
className={`dark ${anuphan.variable} ${geistMono.variable} h-full antialiased`}
>
<body className="flex min-h-full flex-col">
<TooltipProvider>
<PageProgress />
{children}
<DeploymentUpdateNotifier deploymentId={deploymentId} />
<Toaster
richColors
closeButton
position="bottom-right"
toastOptions={{ className: anuphan.className }}/>
</TooltipProvider>
<PageProgress />
{children}
<DeploymentUpdateNotifier deploymentId={deploymentId} />
<Toaster
richColors
closeButton
position="bottom-right"
toastOptions={{ className: anuphan.className }}
/>
</body>
</html>
);
-5
View File
@@ -1,15 +1,10 @@
import { connection } from "next/server";
import { LiveRefresh } from "@/components/live-refresh";
import { SiteFooter } from "@/components/public/site-footer";
import { StructuredDirectory } from "@/components/public/structured-directory";
import { SiteHeader } from "@/components/public/site-header";
import { getCachedPublicGuideCards } from "@/lib/cache/public";
export const instant = false;
export default async function HomePage() {
await connection();
const guides = await getCachedPublicGuideCards();
return (
<div className="flex min-h-svh flex-col bg-background">
+18 -2
View File
@@ -1,13 +1,27 @@
import Image from "next/image";
import Link from "next/link";
import { Suspense } from "react";
import { Button } from "@/components/ui/button";
import { VisitorPresence } from "@/components/public/visitor-presence";
import { getAdminSession } from "@/lib/auth/server";
export async function SiteHeader({ editHref = "/admin", guideId }: { editHref?: string; guideId?: string } = {}) {
async function AdminEditLink({ editHref }: { editHref: string }) {
const isAdmin = Boolean(await getAdminSession());
return isAdmin ? (
<Button
variant="ghost"
size="sm"
nativeButton={false}
render={<Link href={editHref} />}
>
ไปหน้าแก้ไข
</Button>
) : null;
}
export function SiteHeader({ editHref = "/admin", guideId }: { editHref?: string; guideId?: string } = {}) {
return (
<header className="border-b bg-background/95 supports-backdrop-filter:backdrop-blur">
<div className="mx-auto flex min-h-16 w-full max-w-7xl flex-wrap items-center gap-2 px-4 py-2 sm:px-6">
@@ -20,7 +34,9 @@ export async function SiteHeader({ editHref = "/admin", guideId }: { editHref?:
</Link>
<nav aria-label="Public navigation" className="flex items-center gap-1">
<VisitorPresence guideId={guideId} />
{isAdmin ? <Button variant="ghost" size="sm" nativeButton={false} render={<Link href={editHref} />}>ไปหน้าแก้ไข</Button> : null}
<Suspense fallback={null}>
<AdminEditLink editHref={editHref} />
</Suspense>
</nav>
</div>
</header>
+10 -9
View File
@@ -58,7 +58,7 @@ export function StructuredDirectory({ guides }: { guides: GuideCard[] }) {
</InputGroup>
{filtered.length ? (
<div className="grid gap-5 sm:grid-cols-2 lg:grid-cols-3">
{filtered.map((guide) => (
{filtered.map((guide, index) => (
<Link
key={guide.id}
href={`/${guide.slug}`}
@@ -70,20 +70,21 @@ export function StructuredDirectory({ guides }: { guides: GuideCard[] }) {
src={guide.coverUrl}
alt={`ไกด์ ${guide.name}`}
fill
preload={index === 0}
sizes="(min-width: 1024px) 33vw, (min-width: 640px) 50vw, 100vw"
className="object-cover"
/>
</div>
<CardHeader className="pt-5">
<CardTitle>{guide.name}</CardTitle>
{(presence?.byGuide[guide.id] ?? 0) > 0 ? (
<CardAction>
<Badge variant="secondary">
<UsersIcon data-icon="inline-start" />
{presence?.byGuide[guide.id]} คน
</Badge>
</CardAction>
) : null}
<CardAction
className={(presence?.byGuide[guide.id] ?? 0) > 0 ? undefined : "invisible"}
>
<Badge variant="secondary" className="min-w-14 tabular-nums">
<UsersIcon data-icon="inline-start" />
{presence?.byGuide[guide.id] ?? 0} คน
</Badge>
</CardAction>
<CardDescription className="line-clamp-3">
{guide.overview}
</CardDescription>
+12 -4
View File
@@ -9,13 +9,21 @@ export function VisitorPresence({ guideId }: { guideId?: string }) {
const counts = useVisitorPresence(guideId ?? null);
return (
<div className="flex items-center gap-1" aria-live="polite">
<Badge variant="secondary" title="ผู้เข้าชมทั้งหมดที่กำลังออนไลน์">
<Badge
variant="secondary"
className="min-w-24 tabular-nums"
title="ผู้เข้าชมทั้งหมดที่กำลังออนไลน์"
>
<UsersIcon data-icon="inline-start" />
ทั้งหมด {counts?.global ?? ""}
ทั้งหมด {counts?.global ?? "–"}
</Badge>
{guideId ? (
<Badge variant="outline" title="ผู้เข้าชม Guide นี้ที่กำลังออนไลน์">
Guide นี้ {counts?.guide ?? ""}
<Badge
variant="outline"
className="min-w-24 tabular-nums"
title="ผู้เข้าชม Guide นี้ที่กำลังออนไลน์"
>
Guide นี้ {counts?.guide ?? "–"}
</Badge>
) : null}
</div>
File diff suppressed because one or more lines are too long
+35
View File
@@ -0,0 +1,35 @@
import { readFile } from "node:fs/promises";
import { describe, expect, it } from "vitest";
async function repositoryFile(path: string): Promise<string> {
return readFile(new URL(`../${path}`, import.meta.url), "utf8");
}
describe("public rendering performance", () => {
it("keeps the directory eligible for a populated static shell", async () => {
const [layout, page] = await Promise.all([
repositoryFile("app/layout.tsx"),
repositoryFile("app/page.tsx"),
]);
expect(layout).not.toContain("export const instant = false");
expect(page).not.toContain("connection()");
expect(page).not.toContain("export const instant = false");
});
it("isolates request-time header authentication", async () => {
const header = await repositoryFile("components/public/site-header.tsx");
expect(header).toContain("<Suspense fallback={null}>");
expect(header).toContain("<AdminEditLink");
});
it("preloads only the first directory cover", async () => {
const directory = await repositoryFile(
"components/public/structured-directory.tsx",
);
expect(directory).toContain("preload={index === 0}");
});
});