Files
gunshiz 9580407a90
CI / Verify (push) Successful in 1m56s
CI / Build immutable images and deploy (push) Successful in 3m12s
perf : cache
2026-10-08 23:21:34 +07:00

332 lines
14 KiB
TypeScript

"use client";
import Image from "@/components/ui/resilient-image";
import { SparklesIcon } from "lucide-react";
import { useState } from "react";
import { saveConstellationSection } from "@/app/admin/actions";
import { AutosaveStatus } from "@/components/admin/autosave-status";
import { LunarisText } from "@/components/public/lunaris-text";
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card";
import { Field, FieldLabel } from "@/components/ui/field";
import { ClearableInput as Input } from "@/components/admin/ui/clearable-input";
import { Textarea } from "@/components/ui/textarea";
import { Toggle } from "@/components/ui/toggle";
import { useAutosave } from "@/hooks/use-autosave";
import { adminDraftKey } from "@/lib/editor/local-draft";
import { constellationSectionSchema } from "@/lib/guides/schemas";
import { finalizePercentInput, normalizePercentInput } from "@/lib/guides/percent-input";
import { formatGuideValidationIssues } from "@/lib/guides/validation-messages";
import { cn } from "@/lib/utils";
import { type EditorBase, type ConstellationValue, hasIssuePrefix, parseEditorDraft, ConditionControls, reorderConditions, createClientId, ConditionRenameDialog, SELECTED_TOGGLE_CLASS } from "./shared";
export const DEFAULT_CONSTELLATION_CONDITION_NOTE = "C0 = 100%";
export function ConstellationEditor({
guide,
initial,
catalogConstellations,
}: EditorBase) {
const [note, setNote] = useState(initial.sectionNote ?? "");
const makeRows = (savedRows: ConstellationValue[] = []) => {
const source = catalogConstellations.length
? catalogConstellations
: Array.from({ length: 6 }, (_, index) => ({
level: index + 1,
name: `C${index + 1}`,
description:
savedRows.find((row) => row.level === index + 1)?.detail ?? "",
descriptionNodes: undefined,
iconUrl: null,
}));
return source.map((constellation) => {
const saved = savedRows.find((row) => row.level === constellation.level);
return {
...constellation,
cumulativePercent: saved?.cumulativePercent ?? "",
isGreat: saved?.isGreat ?? false,
};
});
};
const [groups, setGroups] = useState(() => {
const source = initial.constellationGroups.length
? initial.constellationGroups
: [{ id: "constellation-condition-default", name: null, note: DEFAULT_CONSTELLATION_CONDITION_NOTE, iconMediaId: null, iconUrl: null, rows: [] }];
return source.map((group) => ({
clientId: group.id,
name: group.name ?? "",
note: group.note ?? "",
iconMediaId: group.iconMediaId ?? null,
iconUrl: group.iconUrl ?? null,
rows: makeRows(group.rows),
}));
});
const [activeConstellationGroupIndex, setActiveConstellationGroupIndex] = useState(0);
const [renamingConstellationGroupIndex, setRenamingConstellationGroupIndex] = useState<number | null>(null);
const value = {
note: note.trim() || null,
groups: groups.map((group) => ({
name: group.name.trim() || null,
note: group.note.trim() || null,
iconMediaId: group.iconMediaId,
rows: group.rows
.filter((row) => row.description.trim() && row.cumulativePercent !== "")
.map((row) => ({
level: row.level,
detail: row.description,
cumulativePercent: Number(row.cumulativePercent),
isGreat: row.isGreat,
})),
})),
};
const validation = constellationSectionSchema.safeParse({
...value,
expectedVersion: guide.version,
});
const constellationIssue = (...path: (string | number)[]) =>
!validation.success && hasIssuePrefix(validation.error.issues, ...path);
const localDraft = { note, groups };
const autosave = useAutosave({
guideId: guide.id,
scope: "constellations",
value,
valid: validation.success,
initialVersion: guide.version,
persist: (next, expectedVersion, intent) =>
saveConstellationSection(guide.id, { ...next, expectedVersion }, intent),
storageKey: adminDraftKey(guide.id, "constellations"),
localDraft,
parseLocalDraft: (candidate) =>
parseEditorDraft<typeof localDraft>(candidate, ["groups"], ["note"]),
restoreLocalDraft: (draft) => {
setNote(draft.note);
setGroups(draft.groups.map((group) => ({ ...group, name: group.name ?? "" })));
},
});
return (
<Card>
<CardHeader>
<CardTitle>Constellations</CardTitle>
<CardDescription>
ชื่อ รายละเอียด และไอคอนมาจาก Lunaris อัตโนมัติ
กรอกเฉพาะเปอร์เซ็นต์เทียบกับ C0 = 100%
</CardDescription>
</CardHeader>
<CardContent className="flex flex-col gap-4">
<Field>
<FieldLabel htmlFor="constellation-section-note">
Header note (optional)
</FieldLabel>
<Textarea
id="constellation-section-note"
value={note}
onChange={(event) => setNote(event.target.value)}
/>
</Field>
<ConditionControls
ariaLabel="เลือกเงื่อนไขกลุ่มดาว"
items={groups}
activeIndex={activeConstellationGroupIndex}
onActiveIndexChange={setActiveConstellationGroupIndex}
onRename={setRenamingConstellationGroupIndex}
onReorder={(from, to) => {
const reordered = reorderConditions(groups, activeConstellationGroupIndex, from, to);
setGroups(reordered.items);
setActiveConstellationGroupIndex(reordered.activeIndex);
}}
onAdd={(details) => {
setGroups([
...groups,
{
clientId: createClientId("constellation-condition"),
...details,
note: DEFAULT_CONSTELLATION_CONDITION_NOTE,
rows: makeRows(),
},
]);
setActiveConstellationGroupIndex(groups.length);
}}
onDelete={() => {
setGroups(groups.filter((_, index) => index !== activeConstellationGroupIndex));
setActiveConstellationGroupIndex(Math.max(0, activeConstellationGroupIndex - 1));
}}
/>
<ConditionRenameDialog
open={renamingConstellationGroupIndex !== null}
fallback={`Condition ${(renamingConstellationGroupIndex ?? 0) + 1}`}
value={renamingConstellationGroupIndex === null ? "" : groups[renamingConstellationGroupIndex]?.name ?? ""}
iconUrl={renamingConstellationGroupIndex === null ? null : groups[renamingConstellationGroupIndex]?.iconUrl ?? null}
onOpenChange={(open) => { if (!open) setRenamingConstellationGroupIndex(null); }}
onChange={(name) => setGroups(groups.map((group, index) => index === renamingConstellationGroupIndex ? { ...group, name } : group))}
onIconChange={(icon) => setGroups(groups.map((group, index) => index === renamingConstellationGroupIndex ? { ...group, ...icon } : group))}
/>
{groups[activeConstellationGroupIndex] ? [groups[activeConstellationGroupIndex]].map((group) => {
const groupIndex = activeConstellationGroupIndex;
return (
<Card key={group.clientId} size="sm">
<CardHeader>
<CardTitle>Condition {groupIndex + 1}</CardTitle>
<CardDescription>
ใช้ note เพื่ออธิบายอาวุธ ทีม หรือเงื่อนไขที่ใช้เปรียบเทียบ
</CardDescription>
</CardHeader>
<CardContent className="flex flex-col gap-4">
<Field>
<FieldLabel
htmlFor={`constellation-condition-note-${group.clientId}`}
>
Condition note (optional)
</FieldLabel>
<Textarea
id={`constellation-condition-note-${group.clientId}`}
value={group.note}
onChange={(event) =>
setGroups(
groups.map((item) =>
item.clientId === group.clientId
? { ...item, note: event.target.value }
: item,
),
)
}
/>
</Field>
<div className="grid gap-4 lg:grid-cols-2">
{group.rows.map((row, rowIndex) => (
<Card
key={row.level}
size="sm"
className={cn(
constellationIssue("groups", groupIndex, "rows", rowIndex) && "border-destructive",
)}
>
<CardHeader>
{row.iconUrl ? (
<Image
src={row.iconUrl}
alt=""
width={48}
height={48}
className="rounded-lg bg-muted"
/>
) : null}
<CardTitle>
C{row.level} - {row.name}
</CardTitle>
<CardDescription className="whitespace-pre-line">
<LunarisText nodes={row.descriptionNodes} fallback={row.description} />
</CardDescription>
</CardHeader>
<CardContent className="flex items-end gap-3">
<Field className="min-w-0 flex-1">
<FieldLabel>Cumulative %</FieldLabel>
<Input
type="text"
inputMode="decimal"
value={row.cumulativePercent}
aria-invalid={constellationIssue("groups", groupIndex, "rows", rowIndex, "cumulativePercent")}
onChange={(event) =>
setGroups(
groups.map((item) =>
item.clientId === group.clientId
? {
...item,
rows: item.rows.map((candidate, index) =>
index === rowIndex
? {
...candidate,
cumulativePercent:
normalizePercentInput(
event.target.value,
candidate.cumulativePercent,
),
}
: candidate,
),
}
: item,
),
)
}
onBlur={() =>
setGroups(
groups.map((item) =>
item.clientId === group.clientId
? {
...item,
rows: item.rows.map((candidate, index) =>
index === rowIndex
? {
...candidate,
cumulativePercent:
finalizePercentInput(
candidate.cumulativePercent,
),
}
: candidate,
),
}
: item,
),
)
}
/>
</Field>
<Toggle
variant="outline"
pressed={row.isGreat}
onPressedChange={(isGreat) =>
setGroups(
groups.map((item) =>
item.clientId === group.clientId
? {
...item,
rows: item.rows.map((candidate, index) =>
index === rowIndex
? { ...candidate, isGreat }
: candidate,
),
}
: item,
),
)
}
aria-label={`Mark Condition ${groupIndex + 1} C${row.level} percentage as great`}
className={cn(
SELECTED_TOGGLE_CLASS,
"data-[state=on]:text-warning",
)}
>
<SparklesIcon data-icon="inline-start" />
Great %
</Toggle>
</CardContent>
</Card>
))}
</div>
</CardContent>
</Card>
);}) : null}
</CardContent>
<CardFooter className="justify-end">
<AutosaveStatus
state={autosave.state}
conflictVersion={autosave.conflictVersion}
canOverwrite={autosave.canOverwrite}
onReloadLatest={autosave.reloadLatest}
onOverwrite={autosave.overwrite}
issues={
validation.success
? []
: formatGuideValidationIssues(
"constellations",
validation.error.issues,
)
}
onSave={autosave.saveNow}
/>
</CardFooter>
</Card>
);
}