From 9580407a90f0b50a75426b94a3e7943228c7cf4f Mon Sep 17 00:00:00 2001 From: gunshiz Date: Thu, 8 Oct 2026 23:21:34 +0700 Subject: [PATCH] perf : cache --- app/admin/[character]/[page]/page.tsx | 4 +- app/admin/[character]/comment/page.tsx | 57 + app/admin/actions.test.ts | 57 + app/admin/actions.ts | 35 +- app/admin/comments/page.tsx | 4 +- app/admin/create/page.tsx | 5 +- app/admin/glossary/page.tsx | 4 +- app/admin/image/constellation/page.tsx | 4 +- app/admin/image/overview/page.tsx | 4 +- app/admin/image/team/page.tsx | 4 +- app/admin/image/weapon/page.tsx | 4 +- app/admin/layout.tsx | 3 +- app/admin/loading.tsx | 10 + app/admin/stygian/page.tsx | 4 +- app/stygian/page.tsx | 4 +- components/admin/admin-header.tsx | 5 +- components/admin/editors/artifact-editor.tsx | 1010 ++++ .../admin/editors/constellation-editor.tsx | 331 ++ components/admin/editors/extra-editor.tsx | 915 +++ components/admin/editors/shared.tsx | 530 ++ components/admin/editors/team-editor.tsx | 1872 ++++++ components/admin/editors/weapon-editor.tsx | 506 ++ components/admin/guide-section-editor.tsx | 5145 +---------------- components/admin/guide-section-nav.tsx | 32 +- components/comments/admin-comment-inbox.tsx | 39 +- components/comments/admin-unread-comments.tsx | 14 +- components/comments/client.test.ts | 26 + components/comments/client.ts | 32 +- components/comments/comment-board.tsx | 23 +- components/comments/comment-card.tsx | 98 +- components/comments/comment-composer.tsx | 59 +- components/comments/comment-image.tsx | 13 +- components/comments/comment-locale.test.tsx | 47 + components/comments/comment-locale.tsx | 17 + components/comments/comment-skeleton.tsx | 13 +- components/comments/use-comment-feed.ts | 12 +- components/commission/mobile-slip-handoff.tsx | 5 +- components/commission/payment-form.tsx | 5 +- components/commission/slip-dialog.tsx | 5 +- components/commission/ticket-chat.tsx | 23 +- components/public/guide-image-export.tsx | 5 +- components/public/structured-guide.tsx | 5 +- components/public/stygian-guide.tsx | 4 +- components/ui/resilient-image.tsx | 4 +- lib/cache/catalog.ts | 20 + lib/cache/public.test.ts | 29 + lib/cache/public.ts | 8 +- lib/cache/tags.test.ts | 4 + lib/cache/tags.ts | 3 + lib/catalog/sync.ts | 12 + lib/events/invalidation.test.ts | 7 + lib/events/invalidation.ts | 4 + lib/glossary-repository.ts | 9 + lib/guides/queries.test.ts | 20 +- lib/guides/queries.ts | 2 + next.config.ts | 1 + package.json | 2 +- tests/redis-replication.integration.test.ts | 14 +- 58 files changed, 5797 insertions(+), 5336 deletions(-) create mode 100644 app/admin/[character]/comment/page.tsx create mode 100644 app/admin/actions.test.ts create mode 100644 app/admin/loading.tsx create mode 100644 components/admin/editors/artifact-editor.tsx create mode 100644 components/admin/editors/constellation-editor.tsx create mode 100644 components/admin/editors/extra-editor.tsx create mode 100644 components/admin/editors/shared.tsx create mode 100644 components/admin/editors/team-editor.tsx create mode 100644 components/admin/editors/weapon-editor.tsx create mode 100644 components/comments/client.test.ts create mode 100644 components/comments/comment-locale.test.tsx create mode 100644 components/comments/comment-locale.tsx create mode 100644 lib/cache/catalog.ts create mode 100644 lib/cache/public.test.ts diff --git a/app/admin/[character]/[page]/page.tsx b/app/admin/[character]/[page]/page.tsx index 6713c9f..1e0b385 100644 --- a/app/admin/[character]/[page]/page.tsx +++ b/app/admin/[character]/[page]/page.tsx @@ -10,9 +10,9 @@ import { AdminHeader } from "@/components/admin/admin-header"; import { EditorPresence } from "@/components/admin/editor-presence"; import { Button } from "@/components/ui/button"; import { getAdminSession } from "@/lib/auth/server"; +import { getCachedCatalogOptions } from "@/lib/cache/catalog"; import { toTeamDraft } from "@/lib/guides/team-draft"; import { - getCatalogOptions, getAdminGuideContent, getGuideEditorHeader, } from "@/lib/guides/queries"; @@ -54,7 +54,7 @@ export default async function StructuredEditorPage({ ?? guide.sections.find((section) => section.slug === page)?.title ?? page; const [catalog, content] = await Promise.all([ - getCatalogOptions(), + getCachedCatalogOptions(), getAdminGuideContent(guide.slug, page), ]); if (!catalog || !content) notFound(); diff --git a/app/admin/[character]/comment/page.tsx b/app/admin/[character]/comment/page.tsx new file mode 100644 index 0000000..7b95143 --- /dev/null +++ b/app/admin/[character]/comment/page.tsx @@ -0,0 +1,57 @@ +import { Suspense } from "react"; +import Link from "next/link"; +import { ArrowLeftIcon } from "lucide-react"; +import { notFound, redirect } from "next/navigation"; +import { connection } from "next/server"; + +import { AdminHeader } from "@/components/admin/admin-header"; +import { GuideSectionNav } from "@/components/admin/guide-section-nav"; +import { CommentBoardContent } from "@/components/comments/comment-board-content"; +import { CommentBoardSkeleton } from "@/components/comments/comment-skeleton"; +import { Button } from "@/components/ui/button"; +import Image from "@/components/ui/resilient-image"; +import { getAdminSession } from "@/lib/auth/server"; +import { getGuideEditorHeader } from "@/lib/guides/queries"; + +export const instant = false; + +export default async function GuideCommentPage({ + params, +}: { + params: Promise<{ character: string }>; +}) { + await connection(); + if (!(await getAdminSession())) redirect("/auth/login?next=%2Fadmin"); + const { character } = await params; + const guide = await getGuideEditorHeader(character); + if (!guide) notFound(); + const extras = guide.sections + .filter((section) => section.kind === "extra") + .map(({ id, slug, title }) => ({ id, slug, title })); + + return ( +
+ +
+
+
+ +
+ {guide.name} +
+
+

{guide.name}

+

Comment

+
+
+ +
+ }> + + +
+
+ ); +} diff --git a/app/admin/actions.test.ts b/app/admin/actions.test.ts new file mode 100644 index 0000000..e66e90f --- /dev/null +++ b/app/admin/actions.test.ts @@ -0,0 +1,57 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ admin: vi.fn(), updateTag: vi.fn(), save: vi.fn() })); +vi.mock("next/cache", () => ({ updateTag: mocks.updateTag })); +vi.mock("@/lib/auth/server", () => ({ requireAdmin: mocks.admin })); +vi.mock("@/lib/audit-log", () => ({ auditActor: () => ({ id: "admin", label: "Admin" }) })); +vi.mock("@/lib/glossary-repository", () => ({ createGlossaryAlias: vi.fn(), deleteGlossaryAlias: vi.fn() })); +vi.mock("@/lib/catalog/sync", () => ({ cancelCatalogSyncJob: vi.fn(), getCatalogSyncJob: vi.fn(), startCatalogSyncJob: vi.fn() })); +vi.mock("@/lib/guides/queries", () => ({ + getAdminGuideContentById: vi.fn(), getAdminTeamImportGuide: vi.fn(), + listAdminTeamImportsForCharacter: vi.fn(), listAdminTeamImportGuides: vi.fn(), +})); +vi.mock("@/lib/guides/mutations", () => ({ + createExtraSection: mocks.save, createGuide: mocks.save, deleteExtraSection: mocks.save, + permanentlyDeleteGuide: mocks.save, reorderExtraSections: mocks.save, restoreGuide: mocks.save, + saveArtifacts: mocks.save, saveConstellations: mocks.save, saveExtraSection: mocks.save, + saveTeams: mocks.save, setGuideVisibility: mocks.save, saveWeapons: mocks.save, + trashGuide: mocks.save, updateGuideBasics: mocks.save, + GuideVersionConflictError: class extends Error { currentVersion = 9; }, +})); + +import { moveGuideToTrash, restoreStructuredGuide, saveArtifactSection, saveGuideExtra, saveGuideOverview, saveWeaponSection } from "./actions"; +import { GuideVersionConflictError } from "@/lib/guides/mutations"; + +beforeEach(() => { + vi.resetAllMocks(); + mocks.admin.mockResolvedValue({ user: { id: "admin" } }); + mocks.save.mockResolvedValue({ version: 10 }); +}); + +describe("guide save cache freshness", () => { + const guideId = "550e8400-e29b-41d4-a716-446655440000"; + it.each([saveArtifactSection, saveGuideOverview, saveWeaponSection, moveGuideToTrash, restoreStructuredGuide])( + "expires the public page immediately after a successful action", async (action) => { + expect(await action(guideId, {})).toMatchObject({ status: "saved", version: 10 }); + expect(mocks.updateTag).toHaveBeenCalledWith(`buzz:page:${guideId}`); + }, + ); + it("invalidates custom extras without changing their section id or version input", async () => { + const sectionId = "550e8400-e29b-41d4-a716-446655440001"; + const input = { expectedVersion: 9 }; + await saveGuideExtra(guideId, sectionId, input); + expect(mocks.save).toHaveBeenCalledWith(guideId, sectionId, input, expect.any(Object)); + expect(mocks.updateTag).toHaveBeenCalledWith(`buzz:page:${guideId}`); + }); + it("preserves conflicts without invalidating or reporting a save", async () => { + mocks.save.mockRejectedValue(new GuideVersionConflictError(9)); + expect(await saveArtifactSection(guideId, {})).toMatchObject({ status: "conflict", currentVersion: 9 }); + expect(mocks.updateTag).not.toHaveBeenCalled(); + }); + it("authorizes before writes or cache invalidation", async () => { + mocks.admin.mockRejectedValue(new Error("unauthorized")); + await expect(saveArtifactSection(guideId, {})).rejects.toThrow("unauthorized"); + expect(mocks.save).not.toHaveBeenCalled(); + expect(mocks.updateTag).not.toHaveBeenCalled(); + }); +}); diff --git a/app/admin/actions.ts b/app/admin/actions.ts index f0b6ee1..1769ac9 100644 --- a/app/admin/actions.ts +++ b/app/admin/actions.ts @@ -3,7 +3,7 @@ import { updateTag } from "next/cache"; import * as z from "zod"; -import { GLOSSARY_CACHE_TAG, pageCacheTag } from "@/lib/cache/tags"; +import { DIRECTORY_CACHE_TAG, GLOSSARY_CACHE_TAG, pageCacheTag } from "@/lib/cache/tags"; import { createGlossaryAlias, deleteGlossaryAlias, @@ -56,9 +56,11 @@ function mutationContext( return { actor: auditActor(admin.user), overwrite: intent?.overwrite }; } -async function runGuideAction(action: () => Promise<{ version?: number }>): Promise { +async function runGuideAction(action: () => Promise<{ version?: number }>, guideId: string): Promise { try { const result = await action(); + updateTag(pageCacheTag(guideId)); + updateTag(DIRECTORY_CACHE_TAG); return { status: "saved", version: result.version }; } catch (cause) { if (cause instanceof GuideVersionConflictError) { @@ -75,6 +77,8 @@ export async function createStructuredGuide(input: unknown): Promise saveWeapons(guideId, input, mutationContext(admin, intent))); + return runGuideAction(() => saveWeapons(guideId, input, mutationContext(admin, intent)), guideId); } export async function saveGuideOverview(guideId: string, input: unknown, intent?: SaveIntent) { const admin = await requireAdmin(); - return runGuideAction(() => updateGuideBasics(guideId, input, mutationContext(admin, intent))); + return runGuideAction(() => updateGuideBasics(guideId, input, mutationContext(admin, intent)), guideId); } export async function saveArtifactSection(guideId: string, input: unknown, intent?: SaveIntent) { const admin = await requireAdmin(); - return runGuideAction(() => saveArtifacts(guideId, input, mutationContext(admin, intent))); + return runGuideAction(() => saveArtifacts(guideId, input, mutationContext(admin, intent)), guideId); } export async function saveConstellationSection(guideId: string, input: unknown, intent?: SaveIntent) { const admin = await requireAdmin(); - return runGuideAction(() => saveConstellations(guideId, input, mutationContext(admin, intent))); + return runGuideAction(() => saveConstellations(guideId, input, mutationContext(admin, intent)), guideId); } export async function saveTeamSection(guideId: string, input: unknown, intent?: SaveIntent) { const admin = await requireAdmin(); - return runGuideAction(() => saveTeams(guideId, input, mutationContext(admin, intent))); + return runGuideAction(() => saveTeams(guideId, input, mutationContext(admin, intent)), guideId); } export async function listTeamImportGuides(currentGuideId: string) { @@ -157,6 +161,7 @@ export async function createGuideExtra(guideId: string, input: unknown) { mutationContext(admin), ); updateTag(pageCacheTag(guideId)); + updateTag(DIRECTORY_CACHE_TAG); return { status: "saved" as const, version: section.version, @@ -185,8 +190,8 @@ export async function saveGuideExtra( const parsedSectionId = z.string().uuid().parse(sectionId); const result = await runGuideAction(() => saveExtraSection(parsedGuideId, parsedSectionId, input, mutationContext(admin, intent)), + guideId, ); - if (result.status === "saved") updateTag(pageCacheTag(guideId)); return result; } @@ -198,8 +203,8 @@ export async function deleteGuideExtra(guideId: string, sectionId: string) { z.string().uuid().parse(sectionId), mutationContext(admin), ), + guideId, ); - if (result.status === "saved") updateTag(pageCacheTag(guideId)); return result; } @@ -211,19 +216,19 @@ export async function reorderGuideExtras(guideId: string, input: unknown) { input, mutationContext(admin), ), + guideId, ); - if (result.status === "saved") updateTag(pageCacheTag(guideId)); return result; } export async function moveGuideToTrash(guideId: string) { const admin = await requireAdmin(); - return runGuideAction(() => trashGuide(guideId, mutationContext(admin))); + return runGuideAction(() => trashGuide(guideId, mutationContext(admin)), guideId); } export async function restoreStructuredGuide(guideId: string) { const admin = await requireAdmin(); - return runGuideAction(() => restoreGuide(guideId, mutationContext(admin))); + return runGuideAction(() => restoreGuide(guideId, mutationContext(admin)), guideId); } export async function setStructuredGuideVisibility( @@ -237,7 +242,7 @@ export async function setStructuredGuideVisibility( }).safeParse(input); if (!parsed.success) return { status: "error" as const, message: "ข้อมูลการเผยแพร่ไม่ถูกต้อง" }; if (!parsed.data.isPublic) { - return runGuideAction(() => setGuideVisibility(guideId, parsed.data, mutationContext(admin))); + return runGuideAction(() => setGuideVisibility(guideId, parsed.data, mutationContext(admin)), guideId); } const guide = await getAdminGuideContentById(guideId); @@ -252,12 +257,12 @@ export async function setStructuredGuideVisibility( if (issues.length) { return { status: "error" as const, message: issues.join(" • "), issues }; } - return runGuideAction(() => setGuideVisibility(guideId, parsed.data, mutationContext(admin))); + return runGuideAction(() => setGuideVisibility(guideId, parsed.data, mutationContext(admin)), guideId); } export async function deleteStructuredGuide(guideId: string) { const admin = await requireAdmin(); - return runGuideAction(() => permanentlyDeleteGuide(guideId, mutationContext(admin))); + return runGuideAction(() => permanentlyDeleteGuide(guideId, mutationContext(admin)), guideId); } export async function startCatalogSync() { diff --git a/app/admin/comments/page.tsx b/app/admin/comments/page.tsx index 61495dd..83669e2 100644 --- a/app/admin/comments/page.tsx +++ b/app/admin/comments/page.tsx @@ -12,8 +12,8 @@ export default async function AdminCommentsPage({ searchParams }: { searchParams const initialTarget = query.target; const initialComment = initialTarget && query.comment && /^[0-9a-f-]{36}$/i.test(query.comment) ? query.comment : undefined; const [initial, targets] = await Promise.all([listComments({ viewer, inbox: true, grouped: true, target: initialTarget }), listCommentTargets()]); - return <>
-

ความคิดเห็น

ตอบกลับและจัดการความคิดเห็นจากทุกไกด์

+ return <>
+

Comments

Reply to and manage comments from every guide

; } diff --git a/app/admin/create/page.tsx b/app/admin/create/page.tsx index d78f871..38ef623 100644 --- a/app/admin/create/page.tsx +++ b/app/admin/create/page.tsx @@ -7,13 +7,14 @@ import { CreateGuideFlow } from "@/components/admin/create-guide-flow"; import { AdminHeader } from "@/components/admin/admin-header"; import { Button } from "@/components/ui/button"; import { getAdminSession } from "@/lib/auth/server"; -import { getActiveCatalog, listGuideCards } from "@/lib/guides/queries"; +import { getCachedCatalogOptions } from "@/lib/cache/catalog"; +import { listGuideCards } from "@/lib/guides/queries"; export default async function CreateGuidePage() { await connection(); if (!(await getAdminSession())) redirect("/auth/login?next=%2Fadmin"); const [catalog, guides] = await Promise.all([ - getActiveCatalog(), + getCachedCatalogOptions(), listGuideCards(), ]); if (!catalog) redirect("/admin?sync=required"); diff --git a/app/admin/glossary/page.tsx b/app/admin/glossary/page.tsx index 34620be..75d4229 100644 --- a/app/admin/glossary/page.tsx +++ b/app/admin/glossary/page.tsx @@ -5,13 +5,13 @@ import { AdminHeader } from "@/components/admin/admin-header"; import { GlossaryManager } from "@/components/admin/glossary-manager"; import { getAdminSession } from "@/lib/auth/server"; import { listGlossaryAliases } from "@/lib/glossary-repository"; -import { getCatalogOptions } from "@/lib/guides/queries"; +import { getCachedCatalogOptions } from "@/lib/cache/catalog"; export default async function GlossaryPage() { await connection(); if (!(await getAdminSession())) redirect("/auth/login?next=%2Fadmin"); const [catalog, aliases] = await Promise.all([ - getCatalogOptions(), + getCachedCatalogOptions(), listGlossaryAliases(), ]); if (!catalog) redirect("/admin"); diff --git a/app/admin/image/constellation/page.tsx b/app/admin/image/constellation/page.tsx index 95a11d3..5227b1c 100644 --- a/app/admin/image/constellation/page.tsx +++ b/app/admin/image/constellation/page.tsx @@ -2,12 +2,12 @@ import { redirect } from "next/navigation"; import { connection } from "next/server"; import { ConstellationImageGenerator } from "@/components/admin/constellation-image-generator"; -import { getActiveCatalog } from "@/lib/guides/queries"; +import { getCachedCatalogOptions } from "@/lib/cache/catalog"; export default async function ConstellationImagePage() { await connection(); - const catalog = await getActiveCatalog(); + const catalog = await getCachedCatalogOptions(); if (!catalog) redirect("/admin?sync=required"); return ( diff --git a/app/admin/image/overview/page.tsx b/app/admin/image/overview/page.tsx index 912fc35..283f1f8 100644 --- a/app/admin/image/overview/page.tsx +++ b/app/admin/image/overview/page.tsx @@ -2,13 +2,13 @@ import { redirect } from "next/navigation"; import { connection } from "next/server"; import { OverviewImageGenerator } from "@/components/admin/overview-image-generator"; -import { getActiveCatalog } from "@/lib/guides/queries"; +import { getCachedActiveCatalog } from "@/lib/cache/catalog"; import { elementOrder } from "@/lib/overview-image"; export default async function OverviewImagePage() { await connection(); - const catalog = await getActiveCatalog(); + const catalog = await getCachedActiveCatalog(); if (!catalog) redirect("/admin?sync=required"); const elementIconUrls = Object.fromEntries( diff --git a/app/admin/image/team/page.tsx b/app/admin/image/team/page.tsx index f7c246c..9bcccd2 100644 --- a/app/admin/image/team/page.tsx +++ b/app/admin/image/team/page.tsx @@ -2,12 +2,12 @@ import { redirect } from "next/navigation"; import { connection } from "next/server"; import { TeamImageGenerator } from "@/components/admin/team-image-generator"; -import { getActiveCatalog } from "@/lib/guides/queries"; +import { getCachedCatalogOptions } from "@/lib/cache/catalog"; export default async function TeamImagePage() { await connection(); - const catalog = await getActiveCatalog(); + const catalog = await getCachedCatalogOptions(); if (!catalog) redirect("/admin?sync=required"); return ( diff --git a/app/admin/image/weapon/page.tsx b/app/admin/image/weapon/page.tsx index 778b382..3d86dc8 100644 --- a/app/admin/image/weapon/page.tsx +++ b/app/admin/image/weapon/page.tsx @@ -2,12 +2,12 @@ import { redirect } from "next/navigation"; import { connection } from "next/server"; import { WeaponImageGenerator } from "@/components/admin/weapon-image-generator"; -import { getActiveCatalog } from "@/lib/guides/queries"; +import { getCachedCatalogOptions } from "@/lib/cache/catalog"; export default async function WeaponImagePage() { await connection(); - const catalog = await getActiveCatalog(); + const catalog = await getCachedCatalogOptions(); if (!catalog) redirect("/admin?sync=required"); return ( diff --git a/app/admin/layout.tsx b/app/admin/layout.tsx index d8304f5..3fefbb1 100644 --- a/app/admin/layout.tsx +++ b/app/admin/layout.tsx @@ -1,5 +1,6 @@ import { Suspense } from "react"; +import { CommentLocaleProvider } from "@/components/comments/comment-locale"; import { AdminUnreadCommentsProvider } from "@/components/comments/admin-unread-comments"; import { TooltipProvider } from "@/components/ui/tooltip"; @@ -13,7 +14,7 @@ export default function AdminLayout({ children }: LayoutProps<"/admin">) {
} > - {children} + {children} ); diff --git a/app/admin/loading.tsx b/app/admin/loading.tsx new file mode 100644 index 0000000..72246f3 --- /dev/null +++ b/app/admin/loading.tsx @@ -0,0 +1,10 @@ +import { Skeleton } from "@/components/ui/skeleton"; + +export default function AdminLoading() { + return
+ Loading admin page… + + + +
; +} diff --git a/app/admin/stygian/page.tsx b/app/admin/stygian/page.tsx index 46f6708..f140e80 100644 --- a/app/admin/stygian/page.tsx +++ b/app/admin/stygian/page.tsx @@ -6,7 +6,7 @@ import { StygianEditor } from "@/components/admin/stygian-editor"; import { StygianSyncButton } from "@/components/admin/stygian-sync-button"; import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; import { getAdminSession } from "@/lib/auth/server"; -import { getActiveCatalog } from "@/lib/guides/queries"; +import { getCachedCatalogOptions } from "@/lib/cache/catalog"; import { getStygianSchedule, listStygianSchedules } from "@/lib/stygian/repository"; export default async function AdminStygianPage({ searchParams }: { searchParams: Promise> }) { @@ -16,7 +16,7 @@ export default async function AdminStygianPage({ searchParams }: { searchParams: const schedules = await listStygianSchedules(); const raw = Array.isArray(query.schedule) ? query.schedule[0] : query.schedule; const selectedId = Number(raw) || schedules[0]?.scheduleId; - const [schedule, catalog] = await Promise.all([selectedId ? getStygianSchedule(selectedId) : null, getActiveCatalog()]); + const [schedule, catalog] = await Promise.all([selectedId ? getStygianSchedule(selectedId) : null, getCachedCatalogOptions()]); return (

Stygian Onslaught

Stygian Guide

diff --git a/app/stygian/page.tsx b/app/stygian/page.tsx index 6751e58..0634bb2 100644 --- a/app/stygian/page.tsx +++ b/app/stygian/page.tsx @@ -11,7 +11,7 @@ import { CommentBoardSkeleton } from "@/components/comments/comment-skeleton"; import { Empty, EmptyDescription, EmptyHeader, EmptyTitle } from "@/components/ui/empty"; import { buttonVariants } from "@/components/ui/button"; import { getCachedStygianSchedule, getCachedStygianSchedules } from "@/lib/cache/stygian"; -import { getActiveCatalog } from "@/lib/guides/queries"; +import { getCachedCatalogOptions } from "@/lib/cache/catalog"; export const instant = false; @@ -22,7 +22,7 @@ export default async function StygianPage({ searchParams }: { searchParams: Prom const rawSchedule = Array.isArray(query.schedule) ? query.schedule[0] : query.schedule; const requestedId = Number(rawSchedule); const selectedId = schedules.some((entry) => entry.scheduleId === requestedId) ? requestedId : schedules[0]?.scheduleId; - const [schedule, catalog] = await Promise.all([selectedId ? getCachedStygianSchedule(selectedId) : null, getActiveCatalog()]); + const [schedule, catalog] = await Promise.all([selectedId ? getCachedStygianSchedule(selectedId) : null, getCachedCatalogOptions()]); return (
diff --git a/components/admin/admin-header.tsx b/components/admin/admin-header.tsx index 133455e..e79b77c 100644 --- a/components/admin/admin-header.tsx +++ b/components/admin/admin-header.tsx @@ -10,6 +10,7 @@ import { BookOpenIcon, ClipboardListIcon, MessagesSquareIcon, + MessageSquareIcon, SwordsIcon, } from "lucide-react"; @@ -33,8 +34,8 @@ export function AdminHeader() {
+ ); +} + + +export function SortableArtifactRow({ + id, + children, +}: { + id: string; + children: (dragHandle: ReactNode) => ReactNode; +}) { + const { + attributes, + listeners, + setNodeRef, + transform, + transition, + isDragging, + } = useSortable({ id }); + return ( +
+ {children( + , + )} +
+ ); +} + + +export function ArtifactEditor({ + guide, + artifacts, + initial, + catalogTalents, + characters, +}: EditorBase) { + const profile = initial.artifactProfile; + const elementIconUrls = Object.fromEntries( + characters.flatMap((character) => + character.element && character.elementIconUrl + ? [[character.element, character.elementIconUrl] as const] + : [], + ), + ); + const artifactSensors = useSensors( + useSensor(PointerSensor), + useSensor(KeyboardSensor, { + coordinateGetter: sortableKeyboardCoordinates, + }), + ); + const talentSensors = useSensors( + useSensor(PointerSensor), + useSensor(KeyboardSensor, { + coordinateGetter: sortableKeyboardCoordinates, + }), + ); + const [groups, setGroups] = useState(() => { + const source = initial.artifactGroups.length ? initial.artifactGroups : [{ id: "artifact-condition-default", name: null, note: null, iconMediaId: null, iconUrl: null, tiers: [] }]; + return source.map((condition, conditionIndex) => ({ + clientId: condition.id, + name: condition.name ?? "", + note: condition.note ?? "", + iconMediaId: condition.iconMediaId ?? null, + iconUrl: condition.iconUrl ?? null, + tiers: (["Late Game", "Early Game"] as const).map((name, tierIndex) => ({ + name, + rows: condition.tiers[tierIndex]?.rows.map((row, rowIndex) => ({ ...row, clientId: `${conditionIndex}-${name}-${rowIndex}` })) ?? [], + })), + })); + }); + const [activeArtifactGroupIndex, setActiveArtifactGroupIndex] = useState(0); + const [renamingArtifactGroupIndex, setRenamingArtifactGroupIndex] = useState(null); + const artifactTiers = groups[activeArtifactGroupIndex]?.tiers ?? []; + const setArtifactTierRows = ( + tierIndex: number, + update: (rows: (typeof artifactTiers)[number]["rows"]) => (typeof artifactTiers)[number]["rows"], + ) => setGroups((current) => current.map((condition, conditionIndex) => conditionIndex === activeArtifactGroupIndex + ? { ...condition, tiers: condition.tiers.map((tier, index) => index === tierIndex ? { ...tier, rows: update(tier.rows) } : tier) } + : condition)); + const [recommendedGroups, setRecommendedGroups] = useState(() => { + const source = profile?.recommendedConditions.length + ? profile.recommendedConditions + : [{ name: null, note: null, iconMediaId: null, iconUrl: null, sands: profile?.sands ?? [], goblet: profile?.goblet ?? [], circlet: profile?.circlet ?? [], substats: profile?.substats ?? [] }]; + return source.map((group, index) => ({ ...group, clientId: `recommended-condition-${index}`, name: group.name ?? "", note: group.note ?? "", iconMediaId: group.iconMediaId ?? null, iconUrl: group.iconUrl ?? null })); + }); + const [activeRecommendedGroupIndex, setActiveRecommendedGroupIndex] = useState(0); + const [renamingRecommendedGroupIndex, setRenamingRecommendedGroupIndex] = useState(null); + const recommended = recommendedGroups[activeRecommendedGroupIndex]; + const { sands, goblet, circlet, substats } = recommended; + const setRecommendedStat = (slot: "sands" | "goblet" | "circlet" | "substats", values: string[]) => + setRecommendedGroups((current) => current.map((group, index) => index === activeRecommendedGroupIndex ? { ...group, [slot]: values } : group)); + const [targetGroups, setTargetGroups] = useState(() => { + const source = profile?.targetConditions.length + ? profile.targetConditions + : [{ name: null, note: null, iconMediaId: null, iconUrl: null, rows: profile?.targets ?? [] }]; + return source.map((group, index) => { + const existing = new Map(group.rows.flatMap((target) => { + const stat = normalizeTargetStat(target.stat); + return stat ? [[stat, target.value] as const] : []; + })); + return { + clientId: `target-condition-${index}`, + name: group.name ?? "", + note: group.note ?? "", + iconMediaId: group.iconMediaId ?? null, + iconUrl: group.iconUrl ?? null, + rows: targetStatDefaults.map((target) => ({ stat: target.stat, value: existing.get(target.stat) ?? target.value })), + }; + }); + }); + const [activeTargetGroupIndex, setActiveTargetGroupIndex] = useState(0); + const [renamingTargetGroupIndex, setRenamingTargetGroupIndex] = useState(null); + const targets = targetGroups[activeTargetGroupIndex]?.rows ?? []; + const setTargets = (rows: typeof targets) => setTargetGroups((current) => current.map((group, index) => index === activeTargetGroupIndex ? { ...group, rows } : group)); + const [talents, setTalents] = useState(() => { + const source: TalentPriority[] = profile?.talents.length + ? profile.talents + : (["NA", "E", "Q"] as const).map((type, priority) => ({ + type, + priority: priority + 1, + })); + return normalizeTalentPriorities( + source.map((talent) => ({ + ...talent, + level: + talent.level && + talentLevelOptions.includes( + talent.level as (typeof talentLevelOptions)[number], + ) + ? talent.level + : undefined, + })), + ); + }); + const [note, setNote] = useState(profile?.note ?? ""); + const value = { + groups: groups.map((group) => ({ + name: group.name.trim() || null, + note: group.note.trim() || null, + iconMediaId: group.iconMediaId, + tiers: group.tiers.map((tier) => ({ + name: tier.name, + rows: tier.rows.map((row) => ({ + firstArtifactKey: row.firstArtifactKey, + secondArtifactKey: row.secondArtifactKey, + percent: Number(row.percent), + note: row.note?.trim() || null, + })), + })), + })), + sands: recommendedGroups[0].sands, + goblet: recommendedGroups[0].goblet, + circlet: recommendedGroups[0].circlet, + substats: recommendedGroups[0].substats, + recommendedGroups: recommendedGroups.map(({ name, note, iconMediaId, sands, goblet, circlet, substats }) => ({ name: name.trim() || null, note: note.trim() || null, iconMediaId, sands, goblet, circlet, substats })), + targetGroups: targetGroups.map((group) => ({ + name: group.name.trim() || null, + note: group.note.trim() || null, + iconMediaId: group.iconMediaId, + rows: group.rows.filter((target) => target.value.trim()).map((target) => ({ stat: target.stat, value: target.value.trim() })), + })), + talents, + note: note || null, + }; + const validation = artifactSectionSchema.safeParse({ + ...value, + expectedVersion: guide.version, + }); + const artifactIssue = (...path: (string | number)[]) => + !validation.success && hasIssuePrefix(validation.error.issues, ...path); + const localDraft = { + groups, + activeArtifactGroupIndex, + sands, + goblet, + circlet, + substats, + recommendedGroups, + activeRecommendedGroupIndex, + targetGroups, + activeTargetGroupIndex, + talents, + note, + }; + const autosave = useAutosave({ + guideId: guide.id, + scope: "artifact", + value, + valid: validation.success, + initialVersion: guide.version, + persist: (next, expectedVersion, intent) => + saveArtifactSection(guide.id, { ...next, expectedVersion }, intent), + storageKey: adminDraftKey(guide.id, "artifact"), + localDraft, + parseLocalDraft: (candidate) => + parseEditorDraft( + candidate, + [ + "groups", + "sands", + "goblet", + "circlet", + "substats", + "targetGroups", + "talents", + ], + ["note"], + ), + restoreLocalDraft: (draft) => { + setGroups(draft.groups); + setActiveArtifactGroupIndex(draft.activeArtifactGroupIndex ?? 0); + setRecommendedGroups(draft.recommendedGroups?.length ? draft.recommendedGroups : [{ clientId: createClientId("recommended-condition"), name: "", note: "", iconMediaId: null, iconUrl: null, sands: draft.sands, goblet: draft.goblet, circlet: draft.circlet, substats: draft.substats }]); + setActiveRecommendedGroupIndex(Math.min(draft.activeRecommendedGroupIndex ?? 0, Math.max(0, (draft.recommendedGroups?.length ?? 1) - 1))); + setTargetGroups(draft.targetGroups.map((group) => ({ ...group, name: group.name ?? "" }))); + setActiveTargetGroupIndex(draft.activeTargetGroupIndex ?? 0); + setTalents(draft.talents); + setNote(draft.note); + }, + }); + const orderedTalents = normalizeTalentPriorities(talents); + const handleArtifactDragEnd = ({ active, over }: DragEndEvent) => { + if (!over || active.id === over.id) return; + const activeTierIndex = artifactTiers.findIndex((group) => + group.rows.some((row) => row.clientId === active.id), + ); + const overTierIndex = artifactTiers.findIndex((group) => + group.rows.some((row) => row.clientId === over.id), + ); + if (activeTierIndex === -1 || activeTierIndex !== overTierIndex) return; + setGroups((current) => + current.map((condition, conditionIndex) => { + if (conditionIndex !== activeArtifactGroupIndex) return condition; + return { ...condition, tiers: condition.tiers.map((group, groupIndex) => { + if (groupIndex !== activeTierIndex) return group; + const from = group.rows.findIndex((row) => row.clientId === active.id); + const to = group.rows.findIndex((row) => row.clientId === over.id); + return from === -1 || to === -1 ? group : { ...group, rows: arrayMove(group.rows, from, to) }; + }) }; + }), + ); + }; + const handleTalentDragEnd = ({ active, over }: DragEndEvent) => { + if (!over) return; + setTalents( + reorderTalentPriorities( + orderedTalents, + active.id as TalentPriority["type"], + over.id as TalentPriority["type"], + ), + ); + }; + const setTalentRelation = ( + type: TalentPriority["type"], + relationToNext: TalentRelation, + ) => { + setTalents( + orderedTalents.map((talent) => + talent.type === type ? { ...talent, relationToNext } : talent, + ), + ); + }; + return ( +
+
+ + + Artifact tier list + + จัดชุด Artifact ในกลุ่ม Late Game และ Early Game + ระบบจะบันทึกอัตโนมัติ + + + + { + const reordered = reorderConditions(groups, activeArtifactGroupIndex, from, to); + setGroups(reordered.items); + setActiveArtifactGroupIndex(reordered.activeIndex); + }} + onAdd={(details) => { + setGroups([...groups, { clientId: createClientId("artifact-condition"), ...details, note: "", tiers: [{ name: "Late Game", rows: [] }, { name: "Early Game", rows: [] }] }]); + setActiveArtifactGroupIndex(groups.length); + }} + onDuplicate={() => { + const source = groups[activeArtifactGroupIndex]; + if (!source) return; + setGroups([...groups, { ...source, clientId: createClientId("artifact-condition"), tiers: source.tiers.map((tier) => ({ ...tier, rows: tier.rows.map((row) => ({ ...row, clientId: createClientId("artifact") })) })) }]); + setActiveArtifactGroupIndex(groups.length); + }} + onDelete={() => { + const next = groups.filter((_, index) => index !== activeArtifactGroupIndex); + setGroups(next); + setActiveArtifactGroupIndex(Math.min(activeArtifactGroupIndex, next.length - 1)); + }} + /> + { if (!open) setRenamingArtifactGroupIndex(null); }} + onChange={(name) => setGroups(groups.map((group, index) => index === renamingArtifactGroupIndex ? { ...group, name } : group))} + onIconChange={(icon) => setGroups(groups.map((group, index) => index === renamingArtifactGroupIndex ? { ...group, ...icon } : group))} + /> + + Condition note (optional) +