diff --git a/app/admin/[character]/[page]/page.tsx b/app/admin/[character]/[page]/page.tsx index d9ff124..66860df 100644 --- a/app/admin/[character]/[page]/page.tsx +++ b/app/admin/[character]/[page]/page.tsx @@ -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") { diff --git a/app/admin/[character]/page.tsx b/app/admin/[character]/page.tsx index acc6690..9615043 100644 --- a/app/admin/[character]/page.tsx +++ b/app/admin/[character]/page.tsx @@ -55,7 +55,7 @@ export default async function GuideOverviewPage({ params }: { params: Promise<{
{sections.map(({ slug, title, description }) => ( - + {navigationIconPath(slug) ? : null} diff --git a/app/admin/page.tsx b/app/admin/page.tsx index ab92431..4dc2fb5 100644 --- a/app/admin/page.tsx +++ b/app/admin/page.tsx @@ -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 (
@@ -25,7 +28,7 @@ export default async function AdminPage() {

Guide Dashboard

จัดการ Guide แบบภาพ-first และบันทึกขึ้นเว็บไซต์ทันที

- +
); diff --git a/components/admin/guide-section-nav.tsx b/components/admin/guide-section-nav.tsx index 2b59c99..35e40df 100644 --- a/components/admin/guide-section-nav.tsx +++ b/components/admin/guide-section-nav.tsx @@ -40,7 +40,7 @@ export function GuideSectionNav({ > แก้ไข Guide {guide.name} @@ -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" />
diff --git a/components/public/structured-directory.tsx b/components/public/structured-directory.tsx index 700cf4a..987b1dc 100644 --- a/components/public/structured-directory.tsx +++ b/components/public/structured-directory.tsx @@ -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" /> diff --git a/components/public/structured-guide.tsx b/components/public/structured-guide.tsx index 0d4a0e8..9d53678 100644 --- a/components/public/structured-guide.tsx +++ b/components/public/structured-guide.tsx @@ -146,7 +146,7 @@ export function StructuredGuideShell({ ? "bg-muted text-foreground" : "text-muted-foreground hover:bg-muted hover:text-foreground", )} - prefetch + prefetch={false} > {navigationIconPath(item.slug) ? ( diff --git a/lib/cache/public.ts b/lib/cache/public.ts index a561bdf..0d28f23 100644 --- a/lib/cache/public.ts +++ b/lib/cache/public.ts @@ -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; diff --git a/lib/catalog/sync.ts b/lib/catalog/sync.ts index 5c66c97..2762970 100644 --- a/lib/catalog/sync.ts +++ b/lib/catalog/sync.ts @@ -157,8 +157,10 @@ function rarity(value: string | undefined): number | null { ); } -export async function getLatestLunarisVersion(): Promise { - return versionSchema.parse(await getJson(VERSION_URL)).version; +export async function getLatestLunarisVersion( + signal?: AbortSignal, +): Promise { + return versionSchema.parse(await getJson(VERSION_URL, signal)).version; } async function mapConcurrent( diff --git a/lib/guides/mutations.ts b/lib/guides/mutations.ts index da8ff8f..714d298 100644 --- a/lib/guides/mutations.ts +++ b/lib/guides/mutations.ts @@ -55,9 +55,11 @@ export class GuideVersionConflictError extends Error { } } -async function emitGuideEvents(tx: Parameters["transaction"]>[0]>[0], guideId: string, version: number) { +async function emitGuideEvents(tx: Parameters["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["transaction"]>[0]>[0], guideId: string, expectedVersion: number) { +async function advanceGuideVersion(tx: Parameters["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 }; }); } diff --git a/lib/guides/queries.ts b/lib/guides/queries.ts index d10b419..c11d8ed 100644 --- a/lib/guides/queries.ts +++ b/lib/guides/queries.ts @@ -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) {