feat : add stygian guide
This commit is contained in:
@@ -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) };
|
||||
}
|
||||
}
|
||||
@@ -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<Record<string, string | string[] | undefined>> }) {
|
||||
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 (
|
||||
<div className="min-h-svh"><AdminHeader /><main className="mx-auto flex w-full max-w-7xl flex-col gap-8 p-4 sm:p-6 lg:p-8">
|
||||
<header><p className="text-sm font-medium text-primary">Stygian Onslaught</p><h1 className="font-heading text-3xl font-semibold">Stygian Guide</h1><p className="mt-2 text-muted-foreground">Sync Lunaris schedules, prepare recommendations, and choose the public version.</p></header>
|
||||
{!schedule || !catalog ? <Alert><AlertTitle>No Stygian schedules yet</AlertTitle><AlertDescription>Use the sync action below after the catalog has been synchronized.</AlertDescription></Alert> : null}
|
||||
{schedule && catalog ? <StygianEditor
|
||||
key={`${schedule.scheduleId}-${schedule.version}`}
|
||||
schedules={schedules.map(({ scheduleId, challengeName, version, isActive }) => ({ 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 ? <StygianSyncButton /> : null}
|
||||
</main></div>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,7 @@ export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
|
||||
|
||||
return [
|
||||
{ url: siteUrl },
|
||||
{ url: `${siteUrl}/stygian` },
|
||||
...guides.map((guide) => ({
|
||||
url: `${siteUrl}/${encodeURIComponent(guide.slug)}`,
|
||||
})),
|
||||
|
||||
@@ -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 (
|
||||
<div className="flex min-h-svh flex-col bg-background">
|
||||
<LiveRefresh endpoint="/api/events/stygian" />
|
||||
<SiteHeader editHref="/admin/stygian" />
|
||||
<main className="mx-auto flex w-full max-w-7xl flex-1 flex-col gap-8 px-4 py-10 sm:px-6 sm:py-14">
|
||||
<header>
|
||||
<p className="text-sm font-medium text-primary">Stygian Onslaught</p>
|
||||
<h1 className="font-heading text-4xl font-semibold">Stygian Guide</h1>
|
||||
{schedule ? <p className="mt-2 text-muted-foreground">Version {schedule.challengeName} · Schedule {schedule.scheduleId}</p> : null}
|
||||
</header>
|
||||
{schedule && catalog ? <StygianGuide schedule={schedule} catalog={catalog} /> : (
|
||||
<Empty className="border"><EmptyHeader><EmptyTitle>Stygian Guide is not published yet</EmptyTitle><EmptyDescription>An administrator must sync and activate a schedule first.</EmptyDescription></EmptyHeader></Empty>
|
||||
)}
|
||||
</main>
|
||||
<SiteFooter />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user