From 9896e8a4ad1114d7c4be566765d361b9046847e4 Mon Sep 17 00:00:00 2001 From: gunshiz Date: Sat, 19 Sep 2026 13:32:57 +0700 Subject: [PATCH] feat(guides) : add safer publishing and discovery --- .gitea/workflows/ci.yml | 25 +- app/admin/[character]/[page]/page.tsx | 3 + app/admin/[character]/page.tsx | 5 +- .../[character]/preview/[[...page]]/page.tsx | 27 + app/admin/actions.ts | 46 +- components/admin/guide-visibility-control.tsx | 37 +- components/admin/structured-dashboard.tsx | 1 + components/public/structured-directory.tsx | 96 +- components/public/structured-guide.tsx | 51 +- db/schema.ts | 3 + drizzle/0047_green_riptide.sql | 3 + drizzle/meta/0047_snapshot.json | 2807 +++++++++++++++++ drizzle/meta/_journal.json | 9 +- lib/catalog/lunaris.ts | 2 +- lib/catalog/sync-lock.ts | 2 +- lib/glossary-repository.ts | 10 +- lib/guides/mutations.ts | 64 +- lib/guides/public-sections.ts | 31 + lib/guides/publication.test.ts | 40 + lib/guides/publication.ts | 20 + lib/guides/queries.ts | 18 + lib/guides/stats.ts | 4 - lib/guides/team-search.ts | 2 +- lib/guides/team-tools.ts | 2 +- lib/media/repository.ts | 9 +- lib/rich-text.ts | 2 +- tests/deployment-contract.test.ts | 2 + 27 files changed, 3213 insertions(+), 108 deletions(-) create mode 100644 app/admin/[character]/preview/[[...page]]/page.tsx create mode 100644 drizzle/0047_green_riptide.sql create mode 100644 drizzle/meta/0047_snapshot.json create mode 100644 lib/guides/public-sections.ts create mode 100644 lib/guides/publication.test.ts create mode 100644 lib/guides/publication.ts diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index 8255476..b4195b3 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -4,9 +4,6 @@ on: push: branches: - main - pull_request: - branches: - - main env: BASE_URL: https://guide.sudloh.com @@ -16,8 +13,30 @@ env: KUBE_API_SERVER: https://100.112.189.33:6443/ jobs: + verify: + name: Verify + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - name: Check out repository + uses: actions/checkout@v4 + - name: Set up Bun + uses: oven-sh/setup-bun@v2 + with: + bun-version: 1.3.14 + - name: Set up kubectl + uses: azure/setup-kubectl@v4 + - name: Verify application and manifests + run: | + bun install --frozen-lockfile + bun run test + bun run typecheck + bun run lint + kubectl kustomize k8s/ >/dev/null + build-and-deploy: name: Build immutable images and deploy + needs: verify if: >- gitea.event_name == 'push' && gitea.ref == 'refs/heads/main' && diff --git a/app/admin/[character]/[page]/page.tsx b/app/admin/[character]/[page]/page.tsx index 1f3ed5e..2a6ba78 100644 --- a/app/admin/[character]/[page]/page.tsx +++ b/app/admin/[character]/[page]/page.tsx @@ -84,6 +84,9 @@ export default async function StructuredEditorPage({ + {guide.name} - + + diff --git a/app/admin/[character]/preview/[[...page]]/page.tsx b/app/admin/[character]/preview/[[...page]]/page.tsx new file mode 100644 index 0000000..b5a8c04 --- /dev/null +++ b/app/admin/[character]/preview/[[...page]]/page.tsx @@ -0,0 +1,27 @@ +import { notFound, redirect } from "next/navigation"; +import { connection } from "next/server"; + +import { StructuredGuideShell } from "@/components/public/structured-guide"; +import { getAdminSession } from "@/lib/auth/server"; +import { publicGuideSections } from "@/lib/guides/public-sections"; +import { getAdminGuideContent, getGuideEditorHeader } from "@/lib/guides/queries"; + +export const instant = false; + +export default async function GuidePreviewPage({ + params, +}: { + params: Promise<{ character: string; page?: string[] }>; +}) { + await connection(); + if (!(await getAdminSession())) redirect("/admin/login"); + const { character, page } = await params; + if (page && page.length > 1) notFound(); + const header = await getGuideEditorHeader(character); + if (!header) notFound(); + const guide = await getAdminGuideContent(header.slug); + if (!guide) notFound(); + const section = page?.[0]; + if (section && !publicGuideSections(guide).some((item) => item.slug === section)) notFound(); + return ; +} diff --git a/app/admin/actions.ts b/app/admin/actions.ts index 7c63265..69f26ae 100644 --- a/app/admin/actions.ts +++ b/app/admin/actions.ts @@ -9,6 +9,10 @@ import { deleteGlossaryAlias, } from "@/lib/glossary-repository"; import { requireAdmin } from "@/lib/auth/server"; +import { getCurrentCatalogVersion } from "@/lib/catalog/sync"; +import { publicGuideSections } from "@/lib/guides/public-sections"; +import { publicationIssues } from "@/lib/guides/publication"; +import { getAdminGuideContentById } from "@/lib/guides/queries"; import { createExtraSection, createGuide, @@ -20,7 +24,6 @@ import { saveArtifacts, saveConstellations, saveExtraSection, - saveExtraSections, saveTeams, setGuideVisibility, saveWeapons, @@ -126,13 +129,6 @@ export async function loadTeamImportGuide(guideId: string) { } } -export async function saveExtraSectionList(guideId: string, input: unknown) { - await requireAdmin(); - const result = await runGuideAction(() => saveExtraSections(guideId, input)); - if (result.status === "saved") updateTag(pageCacheTag(guideId)); - return result; -} - export async function createGuideExtra(guideId: string, input: unknown) { await requireAdmin(); try { @@ -204,10 +200,40 @@ export async function restoreStructuredGuide(guideId: string) { export async function setStructuredGuideVisibility( guideId: string, - isPublic: boolean, + input: unknown, ) { await requireAdmin(); - return runGuideAction(() => setGuideVisibility(guideId, isPublic)); + const parsed = z.strictObject({ + isPublic: z.boolean(), + expectedVersion: z.number().int().positive(), + publicationNote: z.string().trim().max(500).optional(), + }).safeParse(input); + if (!parsed.success) return { status: "error" as const, message: "ข้อมูลการเผยแพร่ไม่ถูกต้อง" }; + if (!parsed.data.isPublic) { + return runGuideAction(() => setGuideVisibility(guideId, parsed.data)); + } + + const [guide, catalogVersion] = await Promise.all([ + getAdminGuideContentById(guideId), + getCurrentCatalogVersion(), + ]); + const issues = publicationIssues({ + exists: Boolean(guide), + overview: guide?.overview ?? "", + coverReady: guide?.coverStatus === "ready", + catalogVersion, + renderableSectionCount: guide ? publicGuideSections(guide).length : 0, + version: guide?.version ?? 0, + expectedVersion: parsed.data.expectedVersion, + }); + if (issues.length || !catalogVersion) { + return { status: "error" as const, message: issues.join(" • "), issues }; + } + return runGuideAction(() => setGuideVisibility(guideId, { + ...parsed.data, + publicationNote: parsed.data.publicationNote || null, + catalogVersion, + })); } export async function deleteStructuredGuide(guideId: string) { diff --git a/components/admin/guide-visibility-control.tsx b/components/admin/guide-visibility-control.tsx index 25fded2..2355f36 100644 --- a/components/admin/guide-visibility-control.tsx +++ b/components/admin/guide-visibility-control.tsx @@ -19,28 +19,41 @@ import { import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { Spinner } from "@/components/ui/spinner"; +import { Textarea } from "@/components/ui/textarea"; +import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; import { cn } from "@/lib/utils"; export function GuideVisibilityControl({ guideId, guideName, isPublic, + version, className, }: { guideId: string; guideName: string; isPublic: boolean; + version: number; className?: string; }) { const router = useRouter(); const [pending, startTransition] = useTransition(); const [open, setOpen] = useState(false); + const [publicationNote, setPublicationNote] = useState(""); + const [error, setError] = useState(null); const updateVisibility = (nextPublic: boolean) => { startTransition(async () => { - const result = await setStructuredGuideVisibility(guideId, nextPublic); + setError(null); + const result = await setStructuredGuideVisibility(guideId, { + isPublic: nextPublic, + expectedVersion: version, + publicationNote, + }); if (result.status !== "saved") { - toast.error(result.message ?? "เปลี่ยนสถานะ Guide ไม่สำเร็จ"); + const message = result.message ?? "เปลี่ยนสถานะ Guide ไม่สำเร็จ"; + setError(message); + toast.error(message); return; } toast.success(nextPublic ? "เผยแพร่ Guide แล้ว" : "เปลี่ยน Guide เป็น Private แล้ว"); @@ -78,12 +91,30 @@ export function GuideVisibilityControl({ : "Guide จะแสดงบนหน้าเว็บไซต์และทุกคนจะเปิดดูผ่านลิงก์สาธารณะได้"} + {!isPublic ? ( +