feat : add stygian guide

This commit is contained in:
2026-09-29 21:23:18 +07:00 Unverified
parent 1654d3df3a
commit 69a4d34b97
27 changed files with 11196 additions and 0 deletions
+10
View File
@@ -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() {
<span className="hidden sm:inline">ไกด์เกนชินไม่ใช่เกมมือถือ</span>
</Link>
<nav aria-label="Admin navigation" className="flex items-center gap-1">
<Button
variant={pathname === "/admin/stygian" ? "secondary" : "ghost"}
size="sm"
nativeButton={false}
render={<Link href="/admin/stygian" />}
>
<SwordsIcon data-icon="inline-start" />
<span className="hidden sm:inline">Stygian</span>
</Button>
<Button
variant={pathname === "/admin/activity" ? "secondary" : "ghost"}
size="sm"
+323
View File
@@ -0,0 +1,323 @@
"use client";
import Image from "next/image";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useEffect, useState, useTransition } from "react";
import { CheckIcon, PlusIcon, RefreshCwIcon, Trash2Icon } from "lucide-react";
import { toast } from "sonner";
import {
activateStygianScheduleAction,
saveStygianRecommendationsAction,
syncStygianArchiveAction,
} from "@/app/admin/stygian/actions";
import { AutosaveStatus } from "@/components/admin/autosave-status";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import {
ComboboxContent,
ComboboxEmpty,
ComboboxInput,
ComboboxItem,
ComboboxList,
ComboboxTrigger,
FuzzyCombobox,
} from "@/components/ui/combobox";
import { Field, FieldGroup, FieldLabel, FieldSet, FieldLegend } from "@/components/ui/field";
import { Popover, PopoverContent, PopoverDescription, PopoverHeader, PopoverTitle, PopoverTrigger } from "@/components/ui/popover";
import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Spinner } from "@/components/ui/spinner";
import { rarityGradientClass } from "@/lib/catalog/rarity";
import { stygianElements, type StygianRecommendationItemInput } from "@/lib/stygian/schema";
import { cn } from "@/lib/utils";
type CatalogItem = { key: string; name: string; aliases: string[]; imageUrl: string; rarity: number | null };
type Character = CatalogItem & { constellations: Array<{ level: number; name: string; iconUrl: string | null }> };
type ScheduleSummary = { scheduleId: number; challengeName: string; version: number; isActive: boolean };
type Boss = { level: number; position: number; name: string; imageUrl: string };
type StoredItem = {
kind: string;
catalogKey: string | null;
weaponKey: string | null;
artifactKey: string | null;
characterKey: string | null;
constellationLevel: number | null;
element: string | null;
};
type Recommendation = { id: string; level: number; bossPosition: number; kind: string; items: StoredItem[] };
function toInput(item: StoredItem): StygianRecommendationItemInput | null {
if ((item.kind === "character" || item.kind === "weapon" || item.kind === "artifact") && item.catalogKey) {
return item.kind === "character"
? { kind: "character", catalogKey: item.catalogKey, weaponKey: item.weaponKey, artifactKey: item.artifactKey, constellationLevel: item.constellationLevel }
: { kind: item.kind, catalogKey: item.catalogKey };
}
if (item.kind === "element" && item.element && stygianElements.includes(item.element as typeof stygianElements[number])) {
return { kind: "element", element: item.element as typeof stygianElements[number] };
}
if (item.kind === "constellation" && item.characterKey && item.constellationLevel) {
return { kind: "constellation", characterKey: item.characterKey, constellationLevel: item.constellationLevel };
}
return null;
}
function label(item: StygianRecommendationItemInput, characters: Character[], weapons: CatalogItem[], artifacts: CatalogItem[]) {
if (item.kind === "element") return item.element;
if (item.kind === "constellation") return `${characters.find((entry) => entry.key === item.characterKey)?.name ?? item.characterKey} C${item.constellationLevel}`;
const values = item.kind === "character" ? characters : item.kind === "weapon" ? weapons : artifacts;
return values.find((entry) => entry.key === item.catalogKey)?.name ?? item.catalogKey;
}
function CatalogPicker({ items, value, onChange, placeholder }: { items: CatalogItem[]; value: string; onChange: (value: string) => void; placeholder: string }) {
const selected = items.find((item) => item.key === value) ?? null;
return (
<FuzzyCombobox
items={items}
value={selected}
onValueChange={(next) => onChange(next?.key ?? "")}
itemToStringLabel={(item) => item.name}
itemToStringValue={(item) => item.key}
searchNames={(item) => [item.name, ...item.aliases]}
autoHighlight
>
<ComboboxInput className="w-full" placeholder={placeholder} showClear />
<ComboboxContent className="min-w-80">
<ComboboxEmpty>No matches</ComboboxEmpty>
<ComboboxList>{(item) => <ComboboxItem key={item.key} value={item}><Image src={item.imageUrl} alt="" width={32} height={32} className="size-8 object-contain" /><span>{item.name}</span></ComboboxItem>}</ComboboxList>
</ComboboxContent>
</FuzzyCombobox>
);
}
function CatalogSlotPicker({ items, value, onChange, label, placeholder, className }: { items: CatalogItem[]; value: string; onChange: (value: string) => void; label: string; placeholder: string; className?: string }) {
const selected = items.find((item) => item.key === value) ?? null;
return (
<FuzzyCombobox items={items} value={selected} onValueChange={(next) => onChange(next?.key ?? "")} itemToStringLabel={(item) => item.name} itemToStringValue={(item) => item.key} searchNames={(item) => [item.name, ...item.aliases]} autoHighlight>
<ComboboxTrigger aria-label={selected ? `${label}: ${selected.name}` : label} className={cn("relative flex aspect-square w-full items-center justify-center overflow-hidden rounded-md border bg-muted p-0 outline-none focus-visible:ring-2 focus-visible:ring-ring [&>svg]:hidden", selected && rarityGradientClass(selected.rarity), className)}>
{selected ? <Image src={selected.imageUrl} alt="" fill sizes="96px" className="object-contain p-0.5" /> : <PlusIcon aria-hidden="true" />}
</ComboboxTrigger>
<ComboboxContent className="min-w-72"><ComboboxInput placeholder={placeholder} showClear showTrigger={false} /><ComboboxEmpty>No matches</ComboboxEmpty><ComboboxList>{(item) => <ComboboxItem key={item.key} value={item}><div className={cn("relative size-8 shrink-0 overflow-hidden rounded-md", rarityGradientClass(item.rarity))}><Image src={item.imageUrl} alt="" fill sizes="32px" className="object-contain" /></div><span>{item.name}</span></ComboboxItem>}</ComboboxList></ComboboxContent>
</FuzzyCombobox>
);
}
function ItemPicker({ characters, weapons, artifacts, onAdd }: { characters: Character[]; weapons: CatalogItem[]; artifacts: CatalogItem[]; onAdd: (item: StygianRecommendationItemInput) => void }) {
const [kind, setKind] = useState<StygianRecommendationItemInput["kind"]>("character");
const [key, setKey] = useState("");
const [weaponKey, setWeaponKey] = useState("");
const [artifactKey, setArtifactKey] = useState("");
const [element, setElement] = useState<typeof stygianElements[number]>("Cryo");
const [constellationLevel, setConstellationLevel] = useState(0);
const catalog = kind === "character" || kind === "constellation" ? characters : kind === "weapon" ? weapons : artifacts;
const selected = catalog.find((item) => item.key === key) ?? null;
const selectedWeapon = weapons.find((item) => item.key === weaponKey) ?? null;
const selectedArtifact = artifacts.find((item) => item.key === artifactKey) ?? null;
const value: StygianRecommendationItemInput | null = kind === "element"
? { kind, element }
: kind === "constellation"
? key ? { kind, characterKey: key, constellationLevel } : null
: key ? kind === "character"
? { kind, catalogKey: key, weaponKey: weaponKey || null, artifactKey: artifactKey || null, constellationLevel: constellationLevel || null }
: { kind, catalogKey: key }
: null;
return (
<div className="grid gap-4 sm:grid-cols-[7rem_minmax(0,1fr)]">
<div className="self-start rounded-xl border bg-muted/30 p-1.5">
<div className={`relative aspect-square overflow-hidden rounded-lg ${selected ? rarityGradientClass(selected.rarity) : "bg-muted"}`}>
{selected ? <Image src={selected.imageUrl} alt={selected.name} fill sizes="112px" className="object-contain" /> : <span className="absolute inset-0 grid place-items-center px-2 text-center text-xs text-muted-foreground">{kind === "element" ? element : "Choose an item"}</span>}
</div>
{kind === "character" ? <div className="mt-1.5 grid grid-cols-2 gap-1.5">
<div className={`relative aspect-square overflow-hidden rounded-md border ${selectedArtifact ? rarityGradientClass(selectedArtifact.rarity) : "bg-muted"}`}>{selectedArtifact ? <Image src={selectedArtifact.imageUrl} alt={selectedArtifact.name} fill sizes="48px" className="object-contain p-1" /> : null}</div>
<div className={`relative aspect-square overflow-hidden rounded-md border ${selectedWeapon ? rarityGradientClass(selectedWeapon.rarity) : "bg-muted"}`}>{selectedWeapon ? <Image src={selectedWeapon.imageUrl} alt={selectedWeapon.name} fill sizes="48px" className="object-contain p-1" /> : null}</div>
</div> : null}
</div>
<FieldGroup className="gap-3">
<div className="grid gap-3 sm:grid-cols-[9rem_minmax(0,1fr)]">
<Field><FieldLabel>Type</FieldLabel><Select value={kind} onValueChange={(next) => { if (next) { setKind(next as typeof kind); setKey(""); setConstellationLevel(next === "constellation" ? 1 : 0); } }}><SelectTrigger className="w-full"><SelectValue /></SelectTrigger><SelectContent><SelectGroup><SelectItem value="character">Character</SelectItem><SelectItem value="element">Element</SelectItem><SelectItem value="weapon">Weapon</SelectItem><SelectItem value="artifact">Artifact</SelectItem><SelectItem value="constellation">Constellation</SelectItem></SelectGroup></SelectContent></Select></Field>
<Field><FieldLabel>Recommendation</FieldLabel>{kind === "element" ? <Select value={element} onValueChange={(next) => { if (next) setElement(next as typeof element); }}><SelectTrigger className="w-full"><SelectValue /></SelectTrigger><SelectContent><SelectGroup>{stygianElements.map((entry) => <SelectItem key={entry} value={entry}>{entry}</SelectItem>)}</SelectGroup></SelectContent></Select> : <CatalogPicker items={catalog} value={key} onChange={setKey} placeholder={`Search ${kind}…`} />}</Field>
</div>
{kind === "character" ? <div className="grid gap-3 sm:grid-cols-2"><Field><FieldLabel>Weapon (optional)</FieldLabel><CatalogPicker items={weapons} value={weaponKey} onChange={setWeaponKey} placeholder="Search weapon…" /></Field><Field><FieldLabel>Artifact (optional)</FieldLabel><CatalogPicker items={artifacts} value={artifactKey} onChange={setArtifactKey} placeholder="Search artifact…" /></Field></div> : null}
<div className="flex items-end justify-end gap-3">
{kind === "character" || kind === "constellation" ? <Field className="max-w-32"><FieldLabel>{kind === "character" ? "Constellation" : "Level"}</FieldLabel><Select value={String(constellationLevel)} onValueChange={(next) => { if (next) setConstellationLevel(Number(next)); }}><SelectTrigger className="w-full"><SelectValue /></SelectTrigger><SelectContent><SelectGroup>{(kind === "character" ? [0, 1, 2, 3, 4, 5, 6] : [1, 2, 3, 4, 5, 6]).map((entry) => <SelectItem key={entry} value={String(entry)}>C{entry}</SelectItem>)}</SelectGroup></SelectContent></Select></Field> : null}
<Button type="button" disabled={!value} onClick={() => { if (value) { onAdd(value); setKey(""); } }}><PlusIcon data-icon="inline-start" />Add</Button>
</div>
</FieldGroup>
</div>
);
}
function AddRecommendationSlot({ characters, weapons, artifacts, onAdd }: { characters: Character[]; weapons: CatalogItem[]; artifacts: CatalogItem[]; onAdd: (item: StygianRecommendationItemInput) => void }) {
const [open, setOpen] = useState(false);
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger render={<Button variant="outline" className="aspect-[3/4] h-auto min-h-28 w-full border-dashed" />}><PlusIcon /><span className="sr-only">Add recommendation</span></PopoverTrigger>
<PopoverContent align="start" className="w-[min(44rem,calc(100vw-2rem))] p-4">
<PopoverHeader><PopoverTitle>Add recommendation</PopoverTitle><PopoverDescription>Choose a character or flexible recommendation. Weapon and artifact are optional.</PopoverDescription></PopoverHeader>
<ItemPicker characters={characters} weapons={weapons} artifacts={artifacts} onAdd={(item) => { onAdd(item); setOpen(false); }} />
</PopoverContent>
</Popover>
);
}
function AddCharacterSlot({ characters, onAdd }: { characters: Character[]; onAdd: (item: StygianRecommendationItemInput) => void }) {
return <CatalogSlotPicker items={characters} value="" label="Choose character" placeholder="Search character…" className="rounded-lg border-dashed" onChange={(catalogKey) => { if (catalogKey) onAdd({ kind: "character", catalogKey, weaponKey: null, artifactKey: null, constellationLevel: null }); }} />;
}
function VisualRecommendation({ item, onChange, onRemove, characters, weapons, artifacts, teamLayout = false }: { item: StygianRecommendationItemInput; onChange: (item: StygianRecommendationItemInput) => void; onRemove: () => void; characters: Character[]; weapons: CatalogItem[]; artifacts: CatalogItem[]; teamLayout?: boolean }) {
const character = item.kind === "character" ? characters.find((entry) => entry.key === item.catalogKey) : null;
const weapon = item.kind === "character" ? weapons.find((entry) => entry.key === item.weaponKey) : null;
const artifact = item.kind === "character" ? artifacts.find((entry) => entry.key === item.artifactKey) : null;
const standalone = item.kind === "weapon"
? weapons.find((entry) => entry.key === item.catalogKey)
: item.kind === "artifact"
? artifacts.find((entry) => entry.key === item.catalogKey)
: item.kind === "constellation"
? characters.find((entry) => entry.key === item.characterKey)
: null;
const updateCharacter = (patch: Partial<Extract<StygianRecommendationItemInput, { kind: "character" }>>) => {
if (item.kind === "character") onChange({ ...item, ...patch });
};
return (
<div className="group relative min-w-0 overflow-hidden rounded-xl border bg-card p-1.5">
{character ? <>
<div className={cn("grid gap-1", teamLayout && "grid-cols-3")}>
<CatalogSlotPicker items={characters} value={character.key} label="Choose character" placeholder="Search character…" className={cn("rounded-lg border-0 [&>img]:p-0", teamLayout && "col-span-2 row-span-2")} onChange={(catalogKey) => { if (catalogKey) updateCharacter({ catalogKey, weaponKey: null, artifactKey: null, constellationLevel: null }); }} />
{teamLayout ? <div className="grid min-w-0 grid-cols-1 gap-1">
<CatalogSlotPicker items={weapons} value={weapon?.key ?? ""} label={`Choose weapon for ${character.name}`} placeholder="Search weapon…" onChange={(weaponKey) => updateCharacter({ weaponKey: weaponKey || null })} />
<CatalogSlotPicker items={artifacts} value={artifact?.key ?? ""} label={`Choose artifact for ${character.name}`} placeholder="Search artifact…" onChange={(artifactKey) => updateCharacter({ artifactKey: artifactKey || null })} />
</div> : <div className="col-span-full grid grid-cols-2 gap-1"><CatalogSlotPicker items={artifacts} value={artifact?.key ?? ""} label={`Choose artifact for ${character.name}`} placeholder="Search artifact…" onChange={(artifactKey) => updateCharacter({ artifactKey: artifactKey || null })} /><CatalogSlotPicker items={weapons} value={weapon?.key ?? ""} label={`Choose weapon for ${character.name}`} placeholder="Search weapon…" onChange={(weaponKey) => updateCharacter({ weaponKey: weaponKey || null })} /></div>}
</div>
<div className="mt-1.5 flex min-w-0 items-center justify-between gap-1">
<span className="truncate text-xs font-medium">{character.name}</span>
{teamLayout && item.kind === "character" ? <Select value={item.constellationLevel === null ? "none" : String(item.constellationLevel)} onValueChange={(next) => { if (next) updateCharacter({ constellationLevel: next === "none" ? null : Number(next) }); }}><SelectTrigger size="sm" className="h-6 w-auto min-w-12 px-1 text-xs"><SelectValue /></SelectTrigger><SelectContent><SelectGroup><SelectItem value="none">—</SelectItem>{[0, 1, 2, 3, 4, 5, 6].map((entry) => <SelectItem key={entry} value={String(entry)}>C{entry}</SelectItem>)}</SelectGroup></SelectContent></Select> : item.kind === "character" && item.constellationLevel !== null ? <Badge variant="secondary">C{item.constellationLevel}</Badge> : null}
</div>
</> : <div className="flex aspect-[3/4] flex-col items-center justify-center gap-2 rounded-lg bg-muted p-2 text-center">
{standalone ? <div className={`relative aspect-square w-full overflow-hidden rounded-lg ${rarityGradientClass(standalone.rarity)}`}><Image src={standalone.imageUrl} alt={standalone.name} fill sizes="128px" className="object-contain p-1" /></div> : null}
<span className="text-xs font-medium">{label(item, characters, weapons, artifacts)}</span>
</div>}
<Button variant="destructive" size="icon-xs" className="absolute top-2 left-2 opacity-0 group-hover:opacity-100 focus-visible:opacity-100" aria-label="Remove recommendation" onClick={onRemove}><Trash2Icon /></Button>
</div>
);
}
function VisualRecommendationGrid({ items, limit, onChange, characters, weapons, artifacts, teamLayout = false, characterOnly = false }: { items: StygianRecommendationItemInput[]; limit?: number; onChange: (items: StygianRecommendationItemInput[]) => void; characters: Character[]; weapons: CatalogItem[]; artifacts: CatalogItem[]; teamLayout?: boolean; characterOnly?: boolean }) {
const emptyCount = limit ? Math.max(0, limit - items.length) : 1;
return <div className={cn("grid gap-2", teamLayout ? "grid-cols-[repeat(4,minmax(0,7.5rem))]" : "grid-cols-[repeat(auto-fill,minmax(6rem,7.5rem))]")}>{items.map((item, index) => <VisualRecommendation key={`${label(item, characters, weapons, artifacts)}-${index}`} item={item} teamLayout={teamLayout || characterOnly} characters={characters} weapons={weapons} artifacts={artifacts} onChange={(next) => onChange(items.map((entry, candidate) => candidate === index ? next : entry))} onRemove={() => onChange(items.filter((_, candidate) => candidate !== index))} />)}{Array.from({ length: emptyCount }, (_, index) => teamLayout || characterOnly ? <AddCharacterSlot key={`empty-${index}`} characters={characters} onAdd={(item) => onChange([...items, item])} /> : <AddRecommendationSlot key={`empty-${index}`} characters={characters} weapons={weapons} artifacts={artifacts} onAdd={(item) => onChange([...items, item])} />)}</div>;
}
function BossRecommendationEditor({ scheduleId, version, level, boss, recommendations, characters, weapons, artifacts, onVersionChange }: {
scheduleId: number;
version: number;
level: 105 | 110;
boss: Boss;
recommendations: Recommendation[];
characters: Character[];
weapons: CatalogItem[];
artifacts: CatalogItem[];
onVersionChange: (version: number) => void;
}) {
const groups = recommendations.filter((group) => group.level === level && group.bossPosition === boss.position);
const [teams, setTeams] = useState<StygianRecommendationItemInput[][]>(() => groups.filter((group) => group.kind === "team").map((group) => group.items.flatMap((item) => toInput(item) ?? [])));
const [units, setUnits] = useState<StygianRecommendationItemInput[]>(() => groups.filter((group) => group.kind === "unit").flatMap((group) => group.items.flatMap((item) => toInput(item) ?? [])));
const fingerprint = JSON.stringify({ teams, units });
const valid = teams.every((team) => team.length >= 1 && team.length <= 4 && team.every((item) => item.kind === "character"));
const [savedFingerprint, setSavedFingerprint] = useState(fingerprint);
const [failedFingerprint, setFailedFingerprint] = useState<string | null>(null);
const [savingFingerprint, setSavingFingerprint] = useState<string | null>(null);
const saveState = !valid
? "incomplete"
: failedFingerprint === fingerprint
? "error"
: savingFingerprint !== null
? "saving"
: fingerprint === savedFingerprint
? "saved"
: "unsaved";
useEffect(() => {
if (fingerprint === savedFingerprint || !valid || fingerprint === failedFingerprint || savingFingerprint !== null) return;
const timer = window.setTimeout(async () => {
setSavingFingerprint(fingerprint);
const snapshot = JSON.parse(fingerprint) as { teams: StygianRecommendationItemInput[][]; units: StygianRecommendationItemInput[] };
const result = await saveStygianRecommendationsAction(scheduleId, { expectedVersion: version, level, bossPosition: boss.position, ...snapshot });
if (result.status === "error") {
setFailedFingerprint(fingerprint);
setSavingFingerprint(null);
toast.error(result.message);
return;
}
setSavedFingerprint(fingerprint);
setFailedFingerprint(null);
setSavingFingerprint(null);
onVersionChange(result.version);
}, 700);
return () => window.clearTimeout(timer);
}, [boss.position, failedFingerprint, fingerprint, level, onVersionChange, savedFingerprint, savingFingerprint, scheduleId, valid, version]);
return (
<Card>
<CardContent className="grid gap-6 lg:grid-cols-[12rem_minmax(0,1fr)]">
<aside className="self-start">
<div className="relative aspect-square overflow-hidden rounded-xl bg-muted/30"><Image src={boss.imageUrl} alt={boss.name} fill sizes="192px" className="object-contain p-3" /></div>
<h3 className="mt-3 font-heading font-medium leading-snug">{boss.name}</h3>
<p className="mt-1 text-sm text-muted-foreground">Level {level} recommendations</p>
</aside>
<div className="flex min-w-0 flex-col gap-6">
<FieldSet className="gap-3"><FieldLegend>REC TEAM</FieldLegend>
{teams.map((team, teamIndex) => <Card key={teamIndex} size="sm"><CardContent className="flex flex-col gap-2">
<VisualRecommendationGrid items={team} limit={4} teamLayout characters={characters} weapons={weapons} artifacts={artifacts} onChange={(items) => setTeams(teams.map((entry, index) => index === teamIndex ? items : entry).filter((entry) => entry.length))} />
<Button variant="outline" size="sm" className="self-start" onClick={() => setTeams(teams.filter((_, index) => index !== teamIndex))}><Trash2Icon data-icon="inline-start" />Remove team</Button>
</CardContent></Card>)}
<Button variant="outline" size="sm" className="self-start" onClick={() => setTeams([...teams, []])}><PlusIcon data-icon="inline-start" />Add team</Button>
</FieldSet>
<FieldSet className="gap-3"><FieldLegend>REC UNIT</FieldLegend><VisualRecommendationGrid items={units} characters={characters} weapons={weapons} artifacts={artifacts} onChange={setUnits} /></FieldSet>
<div className="flex justify-end"><AutosaveStatus state={saveState} issues={valid ? [] : ["Add at least 1 character to each team before autosave."]} onSave={() => setFailedFingerprint(null)} /></div>
</div>
</CardContent>
</Card>
);
}
export function StygianEditor({ schedules, schedule, bosses, recommendations, characters, weapons, artifacts }: { schedules: ScheduleSummary[]; schedule: ScheduleSummary; bosses: Boss[]; recommendations: Recommendation[]; characters: Character[]; weapons: CatalogItem[]; artifacts: CatalogItem[] }) {
const router = useRouter();
const [version, setVersion] = useState(schedule.version);
const [level, setLevel] = useState<105 | 110>(110);
const [bossPosition, setBossPosition] = useState(0);
const [pending, startTransition] = useTransition();
return (
<div className="flex flex-col gap-6">
<Card><CardHeader><CardTitle>Schedules</CardTitle><CardDescription>Sync imports every valid schedule from 5269001. Publishing remains manual.</CardDescription></CardHeader><CardContent className="flex flex-wrap gap-2">
{schedules.map((entry) => <Button key={entry.scheduleId} variant={entry.scheduleId === schedule.scheduleId ? "secondary" : "outline"} nativeButton={false} render={<Link href={`/admin/stygian?schedule=${entry.scheduleId}`} />}>{entry.challengeName}{entry.isActive ? " · Active" : ""}</Button>)}
<Button disabled={pending} onClick={() => startTransition(async () => { const result = await syncStygianArchiveAction(); if (result.status === "error") toast.error(result.message); else { toast.success(`Imported through ${result.imported.at(-1) ?? "none"}; stopped at ${result.stoppedAt}`); router.refresh(); } })}>{pending ? <Spinner data-icon="inline-start" /> : <RefreshCwIcon data-icon="inline-start" />}Sync archive</Button>
<Button disabled={pending || schedule.isActive} onClick={() => startTransition(async () => { const result = await activateStygianScheduleAction(schedule.scheduleId, version); if (result.status === "error") toast.error(result.message); else { setVersion(result.version); toast.success("Schedule activated"); router.refresh(); } })}><CheckIcon data-icon="inline-start" />{schedule.isActive ? "Active" : "Activate"}</Button>
</CardContent></Card>
<Tabs value={String(level)} onValueChange={(next) => setLevel(Number(next) as 105 | 110)}>
<TabsList><TabsTrigger value="105">Level 105</TabsTrigger><TabsTrigger value="110">Level 110</TabsTrigger></TabsList>
{([105, 110] as const).map((entry) => <TabsContent key={entry} value={String(entry)}>
<Tabs value={String(bossPosition)} onValueChange={(next) => setBossPosition(Number(next))}>
<TabsList className="grid h-auto w-full grid-cols-3">
{bosses.filter((boss) => boss.level === entry).map((boss) => <TabsTrigger key={boss.position} value={String(boss.position)} className="min-w-0 py-2"><span className="truncate">{boss.name}</span></TabsTrigger>)}
</TabsList>
{bosses.filter((boss) => boss.level === entry).map((boss) => <TabsContent key={boss.position} value={String(boss.position)} className="pt-4">
<BossRecommendationEditor
scheduleId={schedule.scheduleId}
version={version}
level={entry}
boss={boss}
recommendations={recommendations}
characters={characters}
weapons={weapons}
artifacts={artifacts}
onVersionChange={setVersion}
/>
</TabsContent>)}
</Tabs>
</TabsContent>)}
</Tabs>
</div>
);
}
+28
View File
@@ -0,0 +1,28 @@
"use client";
import { useRouter } from "next/navigation";
import { useTransition } from "react";
import { RefreshCwIcon } from "lucide-react";
import { toast } from "sonner";
import { syncStygianArchiveAction } from "@/app/admin/stygian/actions";
import { Button } from "@/components/ui/button";
import { Spinner } from "@/components/ui/spinner";
export function StygianSyncButton() {
const router = useRouter();
const [pending, startTransition] = useTransition();
return (
<Button disabled={pending} onClick={() => startTransition(async () => {
const result = await syncStygianArchiveAction();
if (result.status === "error") toast.error(result.message);
else {
toast.success(`Imported through ${result.imported.at(-1) ?? "none"}; stopped at ${result.stoppedAt}`);
router.refresh();
}
})}>
{pending ? <Spinner data-icon="inline-start" /> : <RefreshCwIcon data-icon="inline-start" />}
Sync archive
</Button>
);
}
+8
View File
@@ -33,6 +33,14 @@ export function SiteHeader({ editHref = "/admin", guideId }: { editHref?: string
<span className="hidden sm:inline">ไกด์เกนชินไม่ใช่เกมมือถือ</span>
</Link>
<nav aria-label="Public navigation" className="flex items-center gap-1">
<Button
variant="ghost"
size="sm"
nativeButton={false}
render={<Link href="/stygian" />}
>
Stygian Guide
</Button>
<VisitorPresence guideId={guideId} />
<Suspense fallback={null}>
<AdminEditLink editHref={editHref} />
+152
View File
@@ -0,0 +1,152 @@
import Image from "next/image";
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion";
import { Badge } from "@/components/ui/badge";
import { Card, CardContent } from "@/components/ui/card";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { LunarisText } from "@/components/public/lunaris-text";
import { parseLunarisText } from "@/lib/catalog/lunaris";
import { rarityGradientClass } from "@/lib/catalog/rarity";
import type { getActiveCatalog } from "@/lib/guides/queries";
import type { getActiveStygianSchedule } from "@/lib/stygian/repository";
type Schedule = NonNullable<Awaited<ReturnType<typeof getActiveStygianSchedule>>>;
type Catalog = NonNullable<Awaited<ReturnType<typeof getActiveCatalog>>>;
type Item = Schedule["recommendations"][number]["items"][number];
function recommendation(item: Item, catalog: Catalog) {
if (item.kind === "element" && item.element) {
const source = catalog.characters.find((character) => character.element === item.element && character.elementIconUrl);
return { name: item.element, imageUrl: source?.elementIconUrl ?? null, badge: "Element" };
}
if (item.kind === "constellation" && item.characterKey && item.constellationLevel) {
const character = catalog.characters.find((candidate) => candidate.key === item.characterKey);
const constellation = character?.constellations.find((candidate) => candidate.level === item.constellationLevel);
return { name: `${character?.name ?? item.characterKey} C${item.constellationLevel}`, imageUrl: constellation?.iconUrl ?? character?.imageUrl ?? null, badge: "Constellation" };
}
if (item.kind === "character") {
const character = catalog.characters.find((candidate) => candidate.key === item.catalogKey);
const weapon = catalog.weapons.find((candidate) => candidate.key === item.weaponKey);
const artifact = catalog.artifacts.find((candidate) => candidate.key === item.artifactKey);
return {
name: character?.name ?? item.catalogKey ?? "Unknown",
imageUrl: character?.imageUrl ?? null,
badge: item.constellationLevel === null ? "Character" : `C${item.constellationLevel}`,
rarity: character?.rarity ?? null,
weapon,
artifact,
};
}
const collection = item.kind === "weapon" ? catalog.weapons : catalog.artifacts;
const value = collection.find((candidate) => candidate.key === item.catalogKey);
return { name: value?.name ?? item.catalogKey ?? "Unknown", imageUrl: value?.imageUrl ?? null, badge: item.kind, rarity: value?.rarity ?? null };
}
function RecommendationIcon({ item, catalog, teamLayout = false }: { item: Item; catalog: Catalog; teamLayout?: boolean }) {
const value = recommendation(item, catalog);
if (item.kind === "character") {
return (
<div className="flex min-w-0 flex-col gap-1 rounded-lg border bg-card p-1">
<div className={teamLayout ? "grid grid-cols-3 gap-1" : "flex flex-col gap-1"}>
<div className={`${teamLayout ? "col-span-2 row-span-2" : ""} relative aspect-square overflow-hidden rounded-lg ${rarityGradientClass(value.rarity)}`}>
{value.imageUrl ? <Image src={value.imageUrl} alt={value.name} fill sizes="128px" className="object-contain" /> : null}
</div>
<div className={teamLayout ? "grid grid-cols-1 gap-1" : "grid grid-cols-2 gap-1"}>
<div className="relative aspect-square overflow-hidden rounded-md border bg-muted">
{value.weapon ? <Image src={value.weapon.imageUrl} alt={value.weapon.name} fill sizes="64px" className="object-contain p-1" /> : null}
</div>
<div className="relative aspect-square overflow-hidden rounded-md border bg-muted">
{value.artifact ? <Image src={value.artifact.imageUrl} alt={value.artifact.name} fill sizes="64px" className="object-contain p-1" /> : null}
</div>
</div>
</div>
<div className="flex min-w-0 items-center justify-between gap-1"><span className="truncate text-xs font-medium" title={value.name}>{value.name}</span><Badge variant="secondary">{value.badge}</Badge></div>
</div>
);
}
return (
<div className="flex min-w-0 flex-col items-center gap-1 text-center">
<div className="relative size-16 overflow-hidden rounded-lg border bg-muted">
{value.imageUrl ? <Image src={value.imageUrl} alt={value.name} fill sizes="64px" className="object-contain" /> : null}
</div>
<span className="max-w-20 truncate text-xs font-medium" title={value.name}>{value.name}</span>
<Badge variant="outline" className="capitalize">{value.badge}</Badge>
</div>
);
}
function BossCard({ schedule, level, position, catalog }: { schedule: Schedule; level: 105 | 110; position: number; catalog: Catalog }) {
const boss = schedule.bosses.find((candidate) => candidate.level === level && candidate.position === position);
if (!boss) return null;
const groups = schedule.recommendations.filter((group) => group.level === level && group.bossPosition === position);
const teams = groups.filter((group) => group.kind === "team");
const units = groups.filter((group) => group.kind === "unit").flatMap((group) => group.items);
return (
<Card>
<CardContent className="grid gap-6 md:grid-cols-[14rem_minmax(0,1fr)]">
<aside className="self-start">
<div className="relative aspect-square overflow-hidden rounded-xl bg-muted/30">
<Image src={boss.imageUrl} alt={boss.name} fill sizes="224px" className="object-contain p-3" priority={level === 110} />
</div>
<h2 className="mt-3 font-heading text-lg font-semibold">{boss.name}</h2>
<p className="mt-1 text-sm text-muted-foreground">Level {level} · HP {Math.round(boss.hp).toLocaleString("en-US")}</p>
</aside>
<div className="flex min-w-0 flex-col gap-5">
<Accordion>
<AccordionItem value="overview">
<AccordionTrigger>Boss overview</AccordionTrigger>
<AccordionContent className="whitespace-pre-line text-muted-foreground">
<LunarisText nodes={parseLunarisText(boss.description)} fallback={boss.description} />
</AccordionContent>
</AccordionItem>
{boss.mechanics.map((mechanic, index) => (
<AccordionItem key={`${mechanic.name}-${index}`} value={`mechanic-${index}`}>
<AccordionTrigger>{mechanic.name}</AccordionTrigger>
<AccordionContent className="text-muted-foreground">
<LunarisText nodes={parseLunarisText(mechanic.description)} fallback={mechanic.description} />
</AccordionContent>
</AccordionItem>
))}
</Accordion>
<section className="flex flex-col gap-3" aria-label={`Recommended teams for ${boss.name}`}>
<h3 className="font-heading text-lg font-semibold">REC TEAM</h3>
{teams.length ? teams.map((team) => (
<div key={team.id} className="grid grid-cols-[repeat(4,minmax(0,7.5rem))] gap-2 rounded-lg border bg-muted/20 p-2">
{team.items.slice(0, 4).map((item) => <RecommendationIcon key={`${item.groupId}-${item.position}`} item={item} catalog={catalog} teamLayout />)}
{Array.from({ length: Math.max(0, 4 - team.items.length) }, (_, index) => <div key={`empty-${index}`} className="aspect-[3/4] rounded-lg border border-dashed bg-muted/30" aria-hidden="true" />)}
</div>
)) : <p className="text-sm text-muted-foreground">No team recommendations yet.</p>}
</section>
<section className="flex flex-col gap-3" aria-label={`Recommended units for ${boss.name}`}>
<h3 className="font-heading text-lg font-semibold">REC UNIT</h3>
{units.length ? <div className="grid grid-cols-[repeat(auto-fill,minmax(6rem,7.5rem))] gap-2">{units.map((item) => <RecommendationIcon key={`${item.groupId}-${item.position}`} item={item} catalog={catalog} />)}</div> : <p className="text-sm text-muted-foreground">No unit recommendations yet.</p>}
</section>
</div>
</CardContent>
</Card>
);
}
export function StygianGuide({ schedule, catalog }: { schedule: Schedule; catalog: Catalog }) {
return (
<Tabs defaultValue="110">
<TabsList aria-label="Stygian difficulty">
<TabsTrigger value="105">Level 105</TabsTrigger>
<TabsTrigger value="110">Level 110</TabsTrigger>
</TabsList>
{([105, 110] as const).map((level) => (
<TabsContent key={level} value={String(level)}>
<Tabs defaultValue="0">
<TabsList className="grid h-auto w-full grid-cols-3" aria-label={`Level ${level} bosses`}>
{[0, 1, 2].map((position) => {
const boss = schedule.bosses.find((candidate) => candidate.level === level && candidate.position === position);
return <TabsTrigger key={position} value={String(position)} className="min-w-0 py-2"><span className="truncate">{boss?.name ?? `Boss ${position + 1}`}</span></TabsTrigger>;
})}
</TabsList>
{[0, 1, 2].map((position) => <TabsContent key={position} value={String(position)} className="pt-4"><BossCard schedule={schedule} level={level} position={position} catalog={catalog} /></TabsContent>)}
</Tabs>
</TabsContent>
))}
</Tabs>
);
}