update
CI / Verify (push) Successful in 1m7s
CI / Build immutable images and deploy (push) Successful in 2m23s

This commit is contained in:
2026-09-07 16:57:10 +07:00 Unverified
parent c3c2aa0862
commit 05826eda82
11 changed files with 90 additions and 33 deletions
+2 -2
View File
@@ -13,7 +13,7 @@ import { getAdminSession } from "@/lib/auth/server";
import {
getActiveCatalog,
getAdminGuideContent,
getGuideByCharacterKey,
getGuideEditorHeader,
} from "@/lib/guides/queries";
const sections = [
@@ -33,7 +33,7 @@ export default async function StructuredEditorPage({
await connection();
if (!(await getAdminSession())) redirect("/admin/login");
const { character, page } = await params;
const guide = await getGuideByCharacterKey(character);
const guide = await getGuideEditorHeader(character);
if (!guide) notFound();
const extraSections = guide.sections.filter((section) => section.kind === "extra");
if (page === "extra") {
+1 -1
View File
@@ -55,7 +55,7 @@ export default async function GuideOverviewPage({ params }: { params: Promise<{
<GuideOverviewForm guide={guide} />
<div className="grid gap-4 md:grid-cols-2">
{sections.map(({ slug, title, description }) => (
<Link key={slug} href={`/admin/${guide.characterKey}/${slug}`} prefetch className="rounded-xl outline-none focus-visible:ring-2 focus-visible:ring-ring">
<Link key={slug} href={`/admin/${guide.characterKey}/${slug}`} prefetch={false} className="rounded-xl outline-none focus-visible:ring-2 focus-visible:ring-ring">
<Card className="h-full transition-colors hover:bg-muted/50">
<CardHeader>
{navigationIconPath(slug) ? <Image src={navigationIconPath(slug)!} alt="" width={40} height={40} className="size-10 object-contain" /> : null}
+9 -6
View File
@@ -4,17 +4,20 @@ import { connection } from "next/server";
import { StructuredDashboard } from "@/components/admin/structured-dashboard";
import { AdminHeader } from "@/components/admin/admin-header";
import { getAdminSession } from "@/lib/auth/server";
import { getLatestLunarisVersion } from "@/lib/catalog/sync";
import { getActiveCatalog, listGuideCards } from "@/lib/guides/queries";
import {
getCurrentCatalogVersion,
getLatestLunarisVersion,
} from "@/lib/catalog/sync";
import { listGuideCards } from "@/lib/guides/queries";
export default async function AdminPage() {
await connection();
if (!(await getAdminSession())) redirect("/admin/login");
const [guides, trash, catalog, latestCatalogVersion] = await Promise.all([
const [guides, trash, catalogVersion, latestCatalogVersion] = await Promise.all([
listGuideCards(),
listGuideCards(true),
getActiveCatalog(),
getLatestLunarisVersion().catch(() => null),
getCurrentCatalogVersion(),
getLatestLunarisVersion(AbortSignal.timeout(3_000)).catch(() => null),
]);
return (
<div className="min-h-svh">
@@ -25,7 +28,7 @@ export default async function AdminPage() {
<h1 className="font-heading text-3xl font-semibold">Guide Dashboard</h1>
<p className="mt-2 text-muted-foreground">จัดการ Guide แบบภาพ-first และบันทึกขึ้นเว็บไซต์ทันที</p>
</header>
<StructuredDashboard guides={guides} trash={trash} catalogVersion={catalog?.version ?? null} latestCatalogVersion={latestCatalogVersion} />
<StructuredDashboard guides={guides} trash={trash} catalogVersion={catalogVersion} latestCatalogVersion={latestCatalogVersion} />
</main>
</div>
);
+3 -3
View File
@@ -40,7 +40,7 @@ export function GuideSectionNav({
>
<Link
href={`/admin/${characterKey}`}
prefetch
prefetch={false}
onClick={confirmNavigation}
className={cn(
"flex min-h-8 shrink-0 items-center gap-2 rounded-md px-3 text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring",
@@ -56,7 +56,7 @@ export function GuideSectionNav({
<Link
key={section.slug}
href={`/admin/${characterKey}/${section.slug}`}
prefetch
prefetch={false}
onClick={confirmNavigation}
className={cn(
"flex min-h-8 shrink-0 items-center gap-2 rounded-md px-3 text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring",
@@ -72,7 +72,7 @@ export function GuideSectionNav({
<Link
key={extra.id}
href={`/admin/${characterKey}/${extra.slug}`}
prefetch
prefetch={false}
onClick={confirmNavigation}
className={cn(
"flex min-h-8 shrink-0 items-center gap-2 rounded-md px-3 text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring",
+2 -1
View File
@@ -195,7 +195,7 @@ export function StructuredDashboard({
>
<Link
href={`/admin/${guide.characterKey}`}
prefetch
prefetch={false}
className="absolute inset-0 z-10 rounded-xl outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
<span className="sr-only">แก้ไข Guide {guide.name}</span>
@@ -205,6 +205,7 @@ export function StructuredDashboard({
src={guide.coverUrl}
alt={`Guide ${guide.name}`}
fill
sizes="(min-width: 1280px) 33vw, (min-width: 768px) 50vw, 100vw"
className="object-cover"
/>
</div>
@@ -70,6 +70,7 @@ export function StructuredDirectory({ guides }: { guides: GuideCard[] }) {
src={guide.coverUrl}
alt={`ไกด์ ${guide.name}`}
fill
sizes="(min-width: 1024px) 33vw, (min-width: 640px) 50vw, 100vw"
className="object-cover"
/>
</div>
+3 -3
View File
@@ -146,7 +146,7 @@ export function StructuredGuideShell({
? "bg-muted text-foreground"
: "text-muted-foreground hover:bg-muted hover:text-foreground",
)}
prefetch
prefetch={false}
>
<Image
src={navigationIconPath("overview")!}
@@ -167,7 +167,7 @@ export function StructuredGuideShell({
? "bg-muted text-foreground"
: "text-muted-foreground hover:bg-muted hover:text-foreground",
)}
prefetch
prefetch={false}
>
{navigationIconPath(item.slug) ? (
<Image
@@ -306,7 +306,7 @@ function GuideNavigation({
key={item.id}
href={`/${guide.slug}/${item.slug}`}
className="rounded-xl outline-none focus-visible:ring-2 focus-visible:ring-ring"
prefetch
prefetch={false}
>
<Card size="sm" className="transition-colors hover:bg-muted/50">
<CardHeader>
-1
View File
@@ -57,7 +57,6 @@ export async function getCachedPublicGuide(slug: string) {
"use cache: remote";
cacheLife({ stale: 15, revalidate: 60, expire: 60 * 60 });
const result = await getPublicGuide(slug);
cacheTag(DIRECTORY_CACHE_TAG);
cacheTag(GLOSSARY_CACHE_TAG);
if (result) cacheTag(pageCacheTag(result.id));
return result;
+4 -2
View File
@@ -157,8 +157,10 @@ function rarity(value: string | undefined): number | null {
);
}
export async function getLatestLunarisVersion(): Promise<string> {
return versionSchema.parse(await getJson(VERSION_URL)).version;
export async function getLatestLunarisVersion(
signal?: AbortSignal,
): Promise<string> {
return versionSchema.parse(await getJson(VERSION_URL, signal)).version;
}
async function mapConcurrent<T, R>(
+12 -10
View File
@@ -55,9 +55,11 @@ export class GuideVersionConflictError extends Error {
}
}
async function emitGuideEvents(tx: Parameters<Parameters<ReturnType<typeof getDb>["transaction"]>[0]>[0], guideId: string, version: number) {
async function emitGuideEvents(tx: Parameters<Parameters<ReturnType<typeof getDb>["transaction"]>[0]>[0], guideId: string, version: number, directoryChanged = false) {
await tx.insert(outboxEvents).values([
{ topic: "directory", aggregateId: guideId, eventType: "directory.updated", payload: { id: guideId, version } },
...(directoryChanged
? [{ topic: "directory", aggregateId: guideId, eventType: "directory.updated", payload: { id: guideId, version } }]
: []),
{ topic: `page:${guideId}`, aggregateId: guideId, eventType: "page.updated", payload: { id: guideId, version } },
{ topic: "admin", aggregateId: guideId, eventType: "admin.updated", payload: { id: guideId, version } },
]);
@@ -89,19 +91,19 @@ export async function createGuide(input: unknown) {
]);
await tx.insert(artifactProfiles).values({ guideId: guide.id });
await tx.update(media).set({ currentReferenceCount: sql`${media.currentReferenceCount} + 1` }).where(eq(media.id, parsed.coverMediaId));
await emitGuideEvents(tx, guide.id, guide.version);
await emitGuideEvents(tx, guide.id, guide.version, true);
return guide;
});
}
async function advanceGuideVersion(tx: Parameters<Parameters<ReturnType<typeof getDb>["transaction"]>[0]>[0], guideId: string, expectedVersion: number) {
async function advanceGuideVersion(tx: Parameters<Parameters<ReturnType<typeof getDb>["transaction"]>[0]>[0], guideId: string, expectedVersion: number, directoryChanged = false) {
const [guide] = await tx.update(guides).set({ version: expectedVersion + 1 }).where(and(eq(guides.id, guideId), eq(guides.version, expectedVersion), sql`${guides.trashedAt} is null`)).returning();
if (!guide) {
const [current] = await tx.select({ version: guides.version }).from(guides).where(eq(guides.id, guideId)).limit(1);
if (current) throw new GuideVersionConflictError(current.version);
throw new Error("ไม่พบ Guide");
}
await emitGuideEvents(tx, guide.id, guide.version);
await emitGuideEvents(tx, guide.id, guide.version, directoryChanged);
return guide;
}
@@ -146,7 +148,7 @@ export async function updateGuideBasics(guideId: string, input: unknown) {
: [];
if (bleedingMedia.length !== bleedingMediaIds.length) throw new Error("ไม่พบภาพ Bleeding ที่อัปโหลดแล้ว");
}
const updated = await advanceGuideVersion(tx, guideId, data.expectedVersion);
const updated = await advanceGuideVersion(tx, guideId, data.expectedVersion, true);
await tx.update(guides).set({
overview: data.overview,
coverMediaId: data.coverMediaId,
@@ -414,7 +416,7 @@ export async function trashGuide(guideId: string) {
return getDb().transaction(async (tx) => {
const [guide] = await tx.update(guides).set({ trashedAt: new Date(), version: sql`${guides.version} + 1` }).where(eq(guides.id, guideId)).returning();
if (!guide) throw new Error("ไม่พบ Guide");
await emitGuideEvents(tx, guide.id, guide.version);
await emitGuideEvents(tx, guide.id, guide.version, true);
return guide;
});
}
@@ -423,7 +425,7 @@ export async function restoreGuide(guideId: string) {
return getDb().transaction(async (tx) => {
const [guide] = await tx.update(guides).set({ trashedAt: null, version: sql`${guides.version} + 1` }).where(eq(guides.id, guideId)).returning();
if (!guide) throw new Error("ไม่พบ Guide");
await emitGuideEvents(tx, guide.id, guide.version);
await emitGuideEvents(tx, guide.id, guide.version, true);
return guide;
});
}
@@ -436,7 +438,7 @@ export async function setGuideVisibility(guideId: string, isPublic: boolean) {
.where(and(eq(guides.id, guideId), sql`${guides.trashedAt} is null`))
.returning();
if (!guide) throw new Error("ไม่พบ Guide");
await emitGuideEvents(tx, guide.id, guide.version);
await emitGuideEvents(tx, guide.id, guide.version, true);
return guide;
});
}
@@ -454,7 +456,7 @@ export async function permanentlyDeleteGuide(guideId: string) {
for (const layer of bleeding) {
await tx.update(media).set({ currentReferenceCount: sql`greatest(${media.currentReferenceCount} - 1, 0)` }).where(eq(media.id, layer.mediaId));
}
await emitGuideEvents(tx, guide.id, guide.version + 1);
await emitGuideEvents(tx, guide.id, guide.version + 1, true);
return { version: guide.version + 1 };
});
}
+53 -4
View File
@@ -113,23 +113,72 @@ export async function getActiveCatalog() {
export async function listGuideCards(includeTrash = false) {
const rows = await getDb()
.select({ guide: guides, cover: media })
.select({
id: guides.id,
characterKey: guides.characterKey,
name: guides.name,
overview: guides.overview,
version: guides.version,
updatedAt: guides.updatedAt,
trashedAt: guides.trashedAt,
isPublic: guides.isPublic,
coverObjectKey: media.objectKey,
})
.from(guides)
.innerJoin(media, eq(media.id, guides.coverMediaId))
.where(includeTrash ? isNotNull(guides.trashedAt) : isNull(guides.trashedAt))
.orderBy(asc(guides.sortOrder), asc(guides.name));
return rows.map(({ guide, cover }) => ({ ...guide, coverUrl: publicMediaUrl(cover.objectKey) }));
return rows.map(({ coverObjectKey, ...guide }) => ({
...guide,
coverUrl: publicMediaUrl(coverObjectKey),
}));
}
export async function listPublicGuideCards() {
const [rows, aliases] = await Promise.all([getDb()
.select({ guide: guides, cover: media })
.select({
id: guides.id,
characterKey: guides.characterKey,
slug: guides.slug,
name: guides.name,
overview: guides.overview,
version: guides.version,
coverObjectKey: media.objectKey,
})
.from(guides)
.innerJoin(media, eq(media.id, guides.coverMediaId))
.where(and(eq(guides.isPublic, true), isNull(guides.trashedAt)))
.orderBy(asc(guides.sortOrder), asc(guides.name)), getDb().select().from(glossaryAliases)]);
const grouped = aliasesByItem(aliases);
return rows.map(({ guide, cover }) => ({ ...guide, aliases: grouped.character.get(guide.characterKey) ?? [], coverUrl: publicMediaUrl(cover.objectKey) }));
return rows.map(({ coverObjectKey, ...guide }) => ({
...guide,
aliases: grouped.character.get(guide.characterKey) ?? [],
coverUrl: publicMediaUrl(coverObjectKey),
}));
}
export async function getGuideEditorHeader(characterKey: string) {
const [row] = await getDb()
.select({
id: guides.id,
characterKey: guides.characterKey,
slug: guides.slug,
name: guides.name,
version: guides.version,
coverObjectKey: media.objectKey,
})
.from(guides)
.innerJoin(media, eq(media.id, guides.coverMediaId))
.where(and(eq(guides.characterKey, characterKey), isNull(guides.trashedAt)))
.limit(1);
if (!row) return null;
const sections = await getDb()
.select()
.from(guideSections)
.where(eq(guideSections.guideId, row.id))
.orderBy(asc(guideSections.sortOrder));
const { coverObjectKey, ...guide } = row;
return { ...guide, coverUrl: publicMediaUrl(coverObjectKey), sections };
}
export async function getGuideByCharacterKey(characterKey: string) {