From 69a4d34b97c127f4f4b7307ba0a1c69ab80d6a7a Mon Sep 17 00:00:00 2001 From: gunshiz Date: Tue, 29 Sep 2026 21:23:18 +0700 Subject: [PATCH] feat : add stygian guide --- app/admin/stygian/actions.ts | 73 + app/admin/stygian/page.tsx | 36 + app/api/events/stygian/route.ts | 15 + app/sitemap.ts | 1 + app/stygian/page.tsx | 33 + components/admin/admin-header.tsx | 10 + components/admin/stygian-editor.tsx | 323 +++ components/admin/stygian-sync-button.tsx | 28 + components/public/site-header.tsx | 8 + components/public/stygian-guide.tsx | 152 + db/schema.ts | 72 + drizzle/0055_sour_micromax.sql | 51 + drizzle/0056_burly_trish_tilby.sql | 2 + drizzle/0057_dashing_zaran.sql | 1 + drizzle/meta/0055_snapshot.json | 3288 +++++++++++++++++++++ drizzle/meta/0056_snapshot.json | 3300 +++++++++++++++++++++ drizzle/meta/0057_snapshot.json | 3316 ++++++++++++++++++++++ drizzle/meta/_journal.json | 21 + lib/audit-log.ts | 8 + lib/cache/stygian.ts | 15 + lib/cache/tags.test.ts | 2 + lib/cache/tags.ts | 2 + lib/events/invalidation.test.ts | 9 + lib/events/invalidation.ts | 3 + lib/stygian/repository.ts | 229 ++ lib/stygian/schema.test.ts | 96 + lib/stygian/schema.ts | 102 + 27 files changed, 11196 insertions(+) create mode 100644 app/admin/stygian/actions.ts create mode 100644 app/admin/stygian/page.tsx create mode 100644 app/api/events/stygian/route.ts create mode 100644 app/stygian/page.tsx create mode 100644 components/admin/stygian-editor.tsx create mode 100644 components/admin/stygian-sync-button.tsx create mode 100644 components/public/stygian-guide.tsx create mode 100644 drizzle/0055_sour_micromax.sql create mode 100644 drizzle/0056_burly_trish_tilby.sql create mode 100644 drizzle/0057_dashing_zaran.sql create mode 100644 drizzle/meta/0055_snapshot.json create mode 100644 drizzle/meta/0056_snapshot.json create mode 100644 drizzle/meta/0057_snapshot.json create mode 100644 lib/cache/stygian.ts create mode 100644 lib/stygian/repository.ts create mode 100644 lib/stygian/schema.test.ts create mode 100644 lib/stygian/schema.ts diff --git a/app/admin/stygian/actions.ts b/app/admin/stygian/actions.ts new file mode 100644 index 0000000..81ee44e --- /dev/null +++ b/app/admin/stygian/actions.ts @@ -0,0 +1,73 @@ +"use server"; + +import { updateTag } from "next/cache"; +import * as z from "zod"; + +import { requireAdmin } from "@/lib/auth/server"; +import { auditActor, writeAuditLogBestEffort } from "@/lib/audit-log"; +import { STYGIAN_CACHE_TAG } from "@/lib/cache/stygian"; +import { + activateStygianSchedule, + saveStygianRecommendations, + syncStygianArchive, +} from "@/lib/stygian/repository"; + +function message(cause: unknown) { + return cause instanceof z.ZodError + ? cause.issues.map((issue) => issue.message).join(" • ") + : cause instanceof Error ? cause.message : "ดำเนินการไม่สำเร็จ"; +} + +export async function syncStygianArchiveAction() { + const admin = await requireAdmin(); + try { + const result = await syncStygianArchive(); + await writeAuditLogBestEffort(auditActor(admin.user), { + action: "stygian.synced", + targetType: "stygian_schedule", + targetId: String(result.imported.at(-1) ?? result.stoppedAt), + metadata: { imported: result.imported, stoppedAt: result.stoppedAt }, + }); + updateTag(STYGIAN_CACHE_TAG); + return { status: "saved" as const, ...result }; + } catch (cause) { + return { status: "error" as const, message: message(cause) }; + } +} + +export async function saveStygianRecommendationsAction(scheduleId: number, input: unknown) { + const admin = await requireAdmin(); + try { + const result = await saveStygianRecommendations(scheduleId, input); + await writeAuditLogBestEffort(auditActor(admin.user), { + action: "stygian.recommendations.saved", + targetType: "stygian_schedule", + targetId: String(scheduleId), + resultingVersion: result.version, + }); + updateTag(STYGIAN_CACHE_TAG); + return { status: "saved" as const, version: result.version }; + } catch (cause) { + return { status: "error" as const, message: message(cause) }; + } +} + +export async function activateStygianScheduleAction(scheduleId: number, expectedVersion: number) { + const admin = await requireAdmin(); + try { + const result = await activateStygianSchedule( + z.number().int().positive().parse(scheduleId), + z.number().int().positive().parse(expectedVersion), + ); + await writeAuditLogBestEffort(auditActor(admin.user), { + action: "stygian.activated", + targetType: "stygian_schedule", + targetId: String(scheduleId), + resultingVersion: result.version, + }); + updateTag(STYGIAN_CACHE_TAG); + return { status: "saved" as const, version: result.version }; + } catch (cause) { + return { status: "error" as const, message: message(cause) }; + } +} diff --git a/app/admin/stygian/page.tsx b/app/admin/stygian/page.tsx new file mode 100644 index 0000000..4e680b0 --- /dev/null +++ b/app/admin/stygian/page.tsx @@ -0,0 +1,36 @@ +import { redirect } from "next/navigation"; +import { connection } from "next/server"; + +import { AdminHeader } from "@/components/admin/admin-header"; +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 { getStygianSchedule, listStygianSchedules } from "@/lib/stygian/repository"; + +export default async function AdminStygianPage({ searchParams }: { searchParams: Promise> }) { + await connection(); + if (!(await getAdminSession())) redirect("/admin/login"); + const query = await searchParams; + const schedules = await listStygianSchedules(); + const raw = Array.isArray(query.schedule) ? query.schedule[0] : query.schedule; + const selectedId = Number(raw) || schedules.find((entry) => entry.isActive)?.scheduleId || schedules[0]?.scheduleId; + const [schedule, catalog] = await Promise.all([selectedId ? getStygianSchedule(selectedId) : null, getActiveCatalog()]); + return ( +
+

Stygian Onslaught

Stygian Guide

Sync Lunaris schedules, prepare recommendations, and choose the public version.

+ {!schedule || !catalog ? No Stygian schedules yetUse the sync action below after the catalog has been synchronized. : null} + {schedule && catalog ? ({ scheduleId, challengeName, version, isActive }))} + schedule={{ scheduleId: schedule.scheduleId, challengeName: schedule.challengeName, version: schedule.version, isActive: schedule.isActive }} + bosses={schedule.bosses.map(({ level, position, name, imageUrl }) => ({ level, position, name, imageUrl }))} + recommendations={schedule.recommendations} + characters={catalog.characters.map(({ key, name, aliases, imageUrl, rarity, constellations }) => ({ key, name, aliases, imageUrl, rarity, constellations: constellations.map(({ level, name: constellationName, iconUrl }) => ({ level, name: constellationName, iconUrl })) }))} + weapons={catalog.weapons.map(({ key, name, aliases, imageUrl, rarity }) => ({ key, name, aliases, imageUrl, rarity }))} + artifacts={catalog.artifacts.map(({ key, name, aliases, imageUrl, rarity }) => ({ key, name, aliases, imageUrl, rarity }))} + /> : schedules.length === 0 && catalog ? : null} +
+ ); +} diff --git a/app/api/events/stygian/route.ts b/app/api/events/stygian/route.ts new file mode 100644 index 0000000..25753d4 --- /dev/null +++ b/app/api/events/stygian/route.ts @@ -0,0 +1,15 @@ +import { connection } from "next/server"; + +import { createRedisEventResponse } from "@/lib/events/redis-stream"; +import { errorResponse } from "@/lib/security/http"; +import { limitRequest, trustedClientAddress } from "@/lib/security/rate-limit"; + +export async function GET(request: Request) { + await connection(); + try { + await limitRequest("stream-open", trustedClientAddress(request.headers), 60); + return await createRedisEventResponse("stygian", request.signal); + } catch (cause) { + return errorResponse(cause); + } +} diff --git a/app/sitemap.ts b/app/sitemap.ts index 9f0bcb1..fb6bec7 100644 --- a/app/sitemap.ts +++ b/app/sitemap.ts @@ -10,6 +10,7 @@ export default async function sitemap(): Promise { return [ { url: siteUrl }, + { url: `${siteUrl}/stygian` }, ...guides.map((guide) => ({ url: `${siteUrl}/${encodeURIComponent(guide.slug)}`, })), diff --git a/app/stygian/page.tsx b/app/stygian/page.tsx new file mode 100644 index 0000000..c3762fa --- /dev/null +++ b/app/stygian/page.tsx @@ -0,0 +1,33 @@ +import { connection } from "next/server"; + +import { SiteFooter } from "@/components/public/site-footer"; +import { SiteHeader } from "@/components/public/site-header"; +import { StygianGuide } from "@/components/public/stygian-guide"; +import { LiveRefresh } from "@/components/live-refresh"; +import { Empty, EmptyDescription, EmptyHeader, EmptyTitle } from "@/components/ui/empty"; +import { getCachedActiveStygianSchedule } from "@/lib/cache/stygian"; +import { getActiveCatalog } from "@/lib/guides/queries"; + +export const instant = false; + +export default async function StygianPage() { + await connection(); + const [schedule, catalog] = await Promise.all([getCachedActiveStygianSchedule(), getActiveCatalog()]); + return ( +
+ + +
+
+

Stygian Onslaught

+

Stygian Guide

+ {schedule ?

Version {schedule.challengeName} · Schedule {schedule.scheduleId}

: null} +
+ {schedule && catalog ? : ( + Stygian Guide is not published yetAn administrator must sync and activate a schedule first. + )} +
+ +
+ ); +} diff --git a/components/admin/admin-header.tsx b/components/admin/admin-header.tsx index 138fde7..0270f01 100644 --- a/components/admin/admin-header.tsx +++ b/components/admin/admin-header.tsx @@ -12,6 +12,7 @@ import { LogOutIcon, PlusIcon, UserPlusIcon, + SwordsIcon, } from "lucide-react"; import { Button } from "@/components/ui/button"; @@ -32,6 +33,15 @@ export function AdminHeader() { ไกด์เกนชินไม่ใช่เกมมือถือ