feat : update
CI / Verify (push) Successful in 59s
CI / Build immutable images and deploy (push) Successful in 2m11s

This commit is contained in:
2026-08-29 19:42:28 +00:00 Unverified
parent 1ec145a22e
commit 3f480ac143
9 changed files with 94 additions and 23 deletions
+9 -12
View File
@@ -24,9 +24,6 @@ import {
Field, FieldDescription, FieldGroup, FieldLabel, FieldLegend, FieldSet,
} from "@/components/ui/field";
import { Input } from "@/components/ui/input";
import {
Pagination, PaginationContent, PaginationItem, PaginationLink, PaginationNext, PaginationPrevious,
} from "@/components/ui/pagination";
import {
Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue,
} from "@/components/ui/select";
@@ -42,8 +39,10 @@ import {
teamSectionSchema,
weaponSectionSchema,
} from "@/lib/guides/schemas";
import { supportsWeaponType } from "@/lib/catalog/lunaris";
import { cn } from "@/lib/utils";
type CatalogItem = { key: string; name: string; imageUrl: string };
type CatalogItem = { key: string; name: string; imageUrl: string; rarity?: number | null; weaponType?: string | null };
type Guide = { id: string; characterKey: string; version: number; name: string };
type WeaponValue = { weaponKey: string; refinement: number; overallPercent: string | null; personalPercent: string | null; weapon: CatalogItem };
type ArtifactValue = { id: string; name: string; rows: Array<{ firstArtifactKey: string; secondArtifactKey: string | null; percent: string }> };
@@ -69,11 +68,9 @@ export interface GuideSectionEditorProps {
};
}
const SECTION_ORDER = ["weapon", "artifact", "constellations", "team", "extra"] as const;
const SECTION_LABELS = { weapon: "Weapons", artifact: "Artifacts & Stats", constellations: "Constellations", team: "Team Calc", extra: "Extra" };
const DEFAULT_TARGET_STATS = ["ATK", "EM", "ER", "CRIT Rate", "CRIT DMG"] as const;
function CatalogPicker({ items, value, onChange, placeholder }: { items: CatalogItem[]; value: string; onChange: (value: string) => void; placeholder: string }) {
function CatalogPicker({ items, value, onChange, placeholder, weaponBackground = false }: { items: CatalogItem[]; value: string; onChange: (value: string) => void; placeholder: string; weaponBackground?: boolean }) {
const selected = items.find((item) => item.key === value) ?? null;
return (
<Combobox items={items} value={selected} onValueChange={(next) => onChange(next?.key ?? "")} itemToStringLabel={(item) => item.name} itemToStringValue={(item) => item.key}>
@@ -81,7 +78,7 @@ function CatalogPicker({ items, value, onChange, placeholder }: { items: Catalog
<ComboboxContent>
<ComboboxEmpty>ไม่พบรายการ</ComboboxEmpty>
<ComboboxList>
{items.map((item) => <ComboboxItem key={item.key} value={item}><Image src={item.imageUrl} alt="" width={32} height={32} className="rounded-md" />{item.name}</ComboboxItem>)}
{items.map((item) => <ComboboxItem key={item.key} value={item}><div className={cn("relative size-8 shrink-0 overflow-hidden rounded-md", weaponBackground && item.rarity === 5 && "bg-[linear-gradient(90deg,rgba(138,90,0,1)_0%,rgba(15,8,0,1)_100%)]", weaponBackground && item.rarity === 4 && "bg-gradient-to-r from-[#a300b8] to-black")}><Image src={item.imageUrl} alt="" fill className="object-contain" /></div>{item.name}</ComboboxItem>)}
</ComboboxList>
</ComboboxContent>
</Combobox>
@@ -113,7 +110,6 @@ function moveItem<T>(items: T[], from: number, to: number): T[] {
}
export function GuideSectionEditor(props: GuideSectionEditorProps) {
const index = SECTION_ORDER.indexOf(props.section);
return (
<div className="flex flex-col gap-6">
{props.section === "weapon" ? <WeaponEditor {...props} /> : null}
@@ -121,14 +117,15 @@ export function GuideSectionEditor(props: GuideSectionEditorProps) {
{props.section === "constellations" ? <ConstellationEditor {...props} /> : null}
{props.section === "team" ? <TeamEditor {...props} /> : null}
{props.section === "extra" ? <ExtraEditor {...props} /> : null}
<Pagination><PaginationContent className="flex-wrap"><PaginationItem>{index > 0 ? <PaginationPrevious href={`/admin/${props.guide.characterKey}/${SECTION_ORDER[index - 1]}`} text={SECTION_LABELS[SECTION_ORDER[index - 1]]} /> : <PaginationLink href={`/admin/${props.guide.characterKey}`} size="default">Overview</PaginationLink>}</PaginationItem><PaginationItem><PaginationLink href={`/admin/${props.guide.characterKey}/${props.section}`} size="default" isActive>{SECTION_LABELS[props.section]}</PaginationLink></PaginationItem>{index < SECTION_ORDER.length - 1 ? <PaginationItem><PaginationNext href={`/admin/${props.guide.characterKey}/${SECTION_ORDER[index + 1]}`} text={SECTION_LABELS[SECTION_ORDER[index + 1]]} /></PaginationItem> : null}</PaginationContent></Pagination>
</div>
);
}
type EditorBase = GuideSectionEditorProps;
function WeaponEditor({ guide, weapons, initial }: EditorBase) {
function WeaponEditor({ guide, weapons, characters, initial }: EditorBase) {
const characterWeaponType = characters.find((character) => character.key === guide.characterKey)?.weaponType;
const compatibleWeapons = weapons.filter((weapon) => supportsWeaponType(characterWeaponType, weapon.weaponType));
const [rows, setRows] = useState(initial.weapons.map((row) => ({ weaponKey: row.weaponKey, refinement: row.refinement, overallPercent: row.overallPercent ?? "", personalPercent: row.personalPercent ?? "" })));
const value = { rows: rows.map((row) => ({ ...row, overallPercent: row.overallPercent === "" ? null : Number(row.overallPercent), personalPercent: row.personalPercent === "" ? null : Number(row.personalPercent) })) };
const autosave = useAutosave({ value, valid: weaponSectionSchema.safeParse({ ...value, expectedVersion: guide.version }).success, initialVersion: guide.version, persist: (next, expectedVersion) => saveWeaponSection(guide.id, { ...next, expectedVersion }) });
@@ -136,7 +133,7 @@ function WeaponEditor({ guide, weapons, initial }: EditorBase) {
<Card>
<CardHeader><CardTitle>Weapon tier list</CardTitle><CardDescription>เรียงอาวุธจากดีที่สุดลงมา และกรอกเปอร์เซ็นต์อย่างน้อยหนึ่งช่อง ระบบจะบันทึกอัตโนมัติ</CardDescription></CardHeader>
<CardContent className="flex flex-col gap-4">
{rows.map((row, rowIndex) => <Card key={rowIndex} size="sm"><CardHeader><CardTitle>อันดับ {rowIndex + 1}</CardTitle><CardAction><RowActions index={rowIndex} length={rows.length} move={(from, to) => setRows(moveItem(rows, from, to))} remove={() => setRows(rows.filter((_, index) => index !== rowIndex))} /></CardAction></CardHeader><CardContent><FieldGroup><Field><FieldLabel>อาวุธ</FieldLabel><CatalogPicker items={weapons} value={row.weaponKey} placeholder="ค้นหาชื่ออาวุธ…" onChange={(weaponKey) => setRows(rows.map((item, index) => index === rowIndex ? { ...item, weaponKey } : item))} /></Field><Field><FieldLabel>Refinement</FieldLabel><ToggleGroup value={[`R${row.refinement}`]} onValueChange={(selected) => { const next = selected[0]; if (next) setRows(rows.map((item, index) => index === rowIndex ? { ...item, refinement: Number(next.slice(1)) } : item)); }}>{[1, 2, 3, 4, 5].map((refinement) => <ToggleGroupItem key={refinement} value={`R${refinement}`}>R{refinement}</ToggleGroupItem>)}</ToggleGroup></Field><div className="grid gap-4 sm:grid-cols-2"><Field><FieldLabel>Overall %</FieldLabel><Input type="number" min="0" step="0.01" value={row.overallPercent} onChange={(event) => setRows(rows.map((item, index) => index === rowIndex ? { ...item, overallPercent: event.target.value } : item))} /></Field><Field><FieldLabel>Personal %</FieldLabel><Input type="number" min="0" step="0.01" value={row.personalPercent} onChange={(event) => setRows(rows.map((item, index) => index === rowIndex ? { ...item, personalPercent: event.target.value } : item))} /></Field></div></FieldGroup></CardContent></Card>)}
{rows.map((row, rowIndex) => { const selectedWeapon = compatibleWeapons.find((weapon) => weapon.key === row.weaponKey); return <Card key={rowIndex} size="sm"><CardHeader><CardTitle className="flex items-center gap-3">{selectedWeapon ? <div className={cn("relative size-10 shrink-0 overflow-hidden rounded-lg", selectedWeapon.rarity === 5 && "bg-[linear-gradient(90deg,rgba(138,90,0,1)_0%,rgba(15,8,0,1)_100%)]", selectedWeapon.rarity === 4 && "bg-gradient-to-r from-[#a300b8] to-black")}><Image src={selectedWeapon.imageUrl} alt="" fill className="object-contain" /></div> : null}<span>อันดับ {rowIndex + 1}</span></CardTitle><CardAction><RowActions index={rowIndex} length={rows.length} move={(from, to) => setRows(moveItem(rows, from, to))} remove={() => setRows(rows.filter((_, index) => index !== rowIndex))} /></CardAction></CardHeader><CardContent><FieldGroup><Field><FieldLabel>อาวุธ</FieldLabel><CatalogPicker items={compatibleWeapons} value={row.weaponKey} placeholder="ค้นหาชื่ออาวุธ…" weaponBackground onChange={(weaponKey) => setRows(rows.map((item, index) => index === rowIndex ? { ...item, weaponKey } : item))} /></Field><Field><FieldLabel>Refinement</FieldLabel><ToggleGroup value={[`R${row.refinement}`]} onValueChange={(selected) => { const next = selected[0]; if (next) setRows(rows.map((item, index) => index === rowIndex ? { ...item, refinement: Number(next.slice(1)) } : item)); }}>{[1, 2, 3, 4, 5].map((refinement) => <ToggleGroupItem key={refinement} value={`R${refinement}`}>R{refinement}</ToggleGroupItem>)}</ToggleGroup></Field><div className="grid gap-4 sm:grid-cols-2"><Field><FieldLabel>Overall %</FieldLabel><Input type="number" min="0" step="0.01" value={row.overallPercent} onChange={(event) => setRows(rows.map((item, index) => index === rowIndex ? { ...item, overallPercent: event.target.value } : item))} /></Field><Field><FieldLabel>Personal %</FieldLabel><Input type="number" min="0" step="0.01" value={row.personalPercent} onChange={(event) => setRows(rows.map((item, index) => index === rowIndex ? { ...item, personalPercent: event.target.value } : item))} /></Field></div></FieldGroup></CardContent></Card>; })}
</CardContent>
<CardFooter className="flex-wrap justify-between gap-3"><Button type="button" variant="outline" onClick={() => setRows([...rows, { weaponKey: "", refinement: 1, overallPercent: "", personalPercent: "" }])}><PlusIcon data-icon="inline-start" />เพิ่มอาวุธ</Button><AutosaveStatus state={autosave} /></CardFooter>
</Card>
+49
View File
@@ -0,0 +1,49 @@
import {
Pagination,
PaginationContent,
PaginationItem,
PaginationLink,
} from "@/components/ui/pagination";
const sections = [
{ slug: "weapon", label: "Weapons" },
{ slug: "artifact", label: "Artifacts & Stats" },
{ slug: "constellations", label: "Constellations" },
{ slug: "team", label: "Team Calc" },
{ slug: "extra", label: "Extra" },
] as const;
export function GuideSectionNav({
characterKey,
current,
}: {
characterKey: string;
current: "overview" | (typeof sections)[number]["slug"];
}) {
return (
<Pagination className="justify-start overflow-x-auto rounded-xl border bg-card p-2">
<PaginationContent>
<PaginationItem>
<PaginationLink
href={`/admin/${characterKey}`}
size="default"
isActive={current === "overview"}
>
Overview
</PaginationLink>
</PaginationItem>
{sections.map((section) => (
<PaginationItem key={section.slug}>
<PaginationLink
href={`/admin/${characterKey}/${section.slug}`}
size="default"
isActive={current === section.slug}
>
{section.label}
</PaginationLink>
</PaginationItem>
))}
</PaginationContent>
</Pagination>
);
}