332 lines
14 KiB
TypeScript
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>
|
|
);
|
|
}
|