4076 lines
169 KiB
TypeScript
4076 lines
169 KiB
TypeScript
"use client";
|
||
|
||
import dynamic from "next/dynamic";
|
||
import Image from "next/image";
|
||
import {
|
||
ChartNoAxesCombinedIcon,
|
||
ChevronDownIcon,
|
||
ChevronUpIcon,
|
||
ClipboardCopyIcon,
|
||
ClipboardPasteIcon,
|
||
CopyIcon,
|
||
GemIcon,
|
||
GripVerticalIcon,
|
||
HeartPulseIcon,
|
||
ListOrderedIcon,
|
||
PlusIcon,
|
||
RefreshCcwIcon,
|
||
SearchIcon,
|
||
SparklesIcon,
|
||
SwordIcon,
|
||
TimerIcon,
|
||
Trash2Icon,
|
||
UserRoundIcon,
|
||
XIcon,
|
||
} from "lucide-react";
|
||
import { useEffect, useMemo, useState, type ReactNode } from "react";
|
||
import { toast } from "sonner";
|
||
import {
|
||
closestCenter,
|
||
DndContext,
|
||
KeyboardSensor,
|
||
PointerSensor,
|
||
useSensor,
|
||
useSensors,
|
||
type DragEndEvent,
|
||
} from "@dnd-kit/core";
|
||
import {
|
||
arrayMove,
|
||
rectSortingStrategy,
|
||
SortableContext,
|
||
sortableKeyboardCoordinates,
|
||
useSortable,
|
||
verticalListSortingStrategy,
|
||
} from "@dnd-kit/sortable";
|
||
import { CSS } from "@dnd-kit/utilities";
|
||
|
||
import {
|
||
saveArtifactSection,
|
||
saveConstellationSection,
|
||
saveExtraSectionList,
|
||
saveTeamSection,
|
||
saveWeaponSection,
|
||
} from "@/app/admin/actions";
|
||
import { AutosaveStatus } from "@/components/admin/autosave-status";
|
||
import { DeleteConfirmationDialog } from "@/components/admin/delete-confirmation-dialog";
|
||
import { MediaUploader } from "@/components/admin/media-uploader";
|
||
import { SimpleTooltip } from "@/components/tooltip";
|
||
import {
|
||
Accordion,
|
||
AccordionContent,
|
||
AccordionItem,
|
||
AccordionTrigger,
|
||
} from "@/components/ui/accordion";
|
||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||
import { Button } from "@/components/ui/button";
|
||
import { Checkbox } from "@/components/ui/checkbox";
|
||
import {
|
||
Card,
|
||
CardAction,
|
||
CardContent,
|
||
CardDescription,
|
||
CardFooter,
|
||
CardHeader,
|
||
CardTitle,
|
||
} from "@/components/ui/card";
|
||
import {
|
||
Combobox,
|
||
ComboboxContent,
|
||
ComboboxEmpty,
|
||
ComboboxInput,
|
||
ComboboxItem,
|
||
ComboboxList,
|
||
} from "@/components/ui/combobox";
|
||
import {
|
||
Field,
|
||
FieldDescription,
|
||
FieldGroup,
|
||
FieldLabel,
|
||
FieldLegend,
|
||
FieldSet,
|
||
} from "@/components/ui/field";
|
||
import { Input } from "@/components/ui/input";
|
||
import {
|
||
Select,
|
||
SelectContent,
|
||
SelectGroup,
|
||
SelectItem,
|
||
SelectTrigger,
|
||
SelectValue,
|
||
} from "@/components/ui/select";
|
||
import { Textarea } from "@/components/ui/textarea";
|
||
import { Skeleton } from "@/components/ui/skeleton";
|
||
import { Toggle } from "@/components/ui/toggle";
|
||
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
|
||
import {
|
||
Dialog,
|
||
DialogContent,
|
||
DialogDescription,
|
||
DialogFooter,
|
||
DialogHeader,
|
||
DialogTitle,
|
||
} from "@/components/ui/dialog";
|
||
import { useAutosave } from "@/hooks/use-autosave";
|
||
import { adminDraftKey } from "@/lib/editor/local-draft";
|
||
import {
|
||
artifactSectionSchema,
|
||
constellationSectionSchema,
|
||
extraSectionSchema,
|
||
mainStatOptions,
|
||
substatOptions,
|
||
talentLevelOptions,
|
||
teamSectionSchema,
|
||
weaponSectionSchema,
|
||
} from "@/lib/guides/schemas";
|
||
import {
|
||
normalizeTargetStat,
|
||
recommendedStatElement,
|
||
recommendedStatIconName,
|
||
targetStatDefaults,
|
||
targetStatIconName,
|
||
targetStatOptions,
|
||
} from "@/lib/guides/stats";
|
||
import { elementIconFilter } from "@/lib/catalog/element";
|
||
import { supportsWeaponType } from "@/lib/catalog/lunaris";
|
||
import { rarityGradientClass } from "@/lib/catalog/rarity";
|
||
import {
|
||
applyPresetFields,
|
||
applyTeamFields,
|
||
findInvalidTeamCatalogReference,
|
||
findUnsupportedTeamWeapon,
|
||
limitPresets,
|
||
parseTeamPresets,
|
||
parseTeamTransfer,
|
||
serializeTeamTransfer,
|
||
teamTransferFileName,
|
||
teamPresetCookieKey,
|
||
teamPresetStorageKey,
|
||
type TeamApplySelection,
|
||
type TeamMemberApplyField,
|
||
type TeamPreset,
|
||
type TeamToolValue,
|
||
} from "@/lib/guides/team-tools";
|
||
import {
|
||
matchesTeamSearch,
|
||
type TeamSearchTarget,
|
||
} from "@/lib/guides/team-search";
|
||
import {
|
||
normalizeTalentPriorities,
|
||
reorderTalentPriorities,
|
||
type TalentPriority,
|
||
type TalentRelation,
|
||
} from "@/lib/guides/talents";
|
||
import {
|
||
finalizePercentInput,
|
||
normalizePercentInput,
|
||
} from "@/lib/guides/percent-input";
|
||
import { formatGuideValidationIssues } from "@/lib/guides/validation-messages";
|
||
import { normalizeExtraRichText } from "@/lib/rich-text";
|
||
import { cn } from "@/lib/utils";
|
||
|
||
type CatalogItem = {
|
||
key: string;
|
||
name: string;
|
||
imageUrl: string;
|
||
rarity?: number | null;
|
||
weaponType?: string | null;
|
||
element?: string | null;
|
||
elementIconUrl?: string | null;
|
||
};
|
||
type ExtraCatalogItem = {
|
||
kind: "character" | "weapon" | "artifact";
|
||
key: string;
|
||
};
|
||
const teamMemberApplyOptions = [
|
||
["characterKey", "Character"],
|
||
["weaponKey", "Weapon"],
|
||
["artifactKey", "Artifact 1"],
|
||
["secondArtifactKey", "Artifact 2"],
|
||
["constellation", "C"],
|
||
["refinement", "R"],
|
||
["damage", "Damage"],
|
||
] as const satisfies readonly (readonly [TeamMemberApplyField, string])[];
|
||
type Guide = {
|
||
id: string;
|
||
characterKey: string;
|
||
version: number;
|
||
name: string;
|
||
};
|
||
type WeaponValue = {
|
||
id: string;
|
||
weaponKey: string;
|
||
refinement: number;
|
||
overallPercent: string | null;
|
||
personalPercent: string | null;
|
||
note: string | null;
|
||
weapon: CatalogItem;
|
||
};
|
||
type WeaponGroupValue = {
|
||
id: string;
|
||
note: string | null;
|
||
rows: WeaponValue[];
|
||
};
|
||
type ArtifactValue = {
|
||
id: string;
|
||
name: string;
|
||
rows: Array<{
|
||
firstArtifactKey: string;
|
||
secondArtifactKey: string | null;
|
||
percent: string;
|
||
note: string | null;
|
||
}>;
|
||
};
|
||
type ArtifactProfile = {
|
||
sands: string[];
|
||
goblet: string[];
|
||
circlet: string[];
|
||
substats: string[];
|
||
targets: Array<{ stat: string; value: string }>;
|
||
talents: TalentPriority[];
|
||
note: string | null;
|
||
} | null;
|
||
type ConstellationValue = {
|
||
level: number;
|
||
detail: string;
|
||
cumulativePercent: string;
|
||
isGreat: boolean;
|
||
};
|
||
type ConstellationGroupValue = {
|
||
id: string;
|
||
note: string | null;
|
||
rows: ConstellationValue[];
|
||
};
|
||
type CatalogConstellation = {
|
||
level: number;
|
||
name: string;
|
||
description: string;
|
||
iconUrl: string | null;
|
||
};
|
||
type CatalogTalent = {
|
||
type: "NA" | "E" | "Q";
|
||
name: string;
|
||
iconUrl: string | null;
|
||
};
|
||
type TeamValue = {
|
||
id: string;
|
||
title: string;
|
||
rotationSeconds: string;
|
||
combo: string;
|
||
members: Array<{
|
||
characterKey: string;
|
||
weaponKey: string | null;
|
||
artifactKey: string | null;
|
||
secondArtifactKey: string | null;
|
||
constellation: number;
|
||
refinement: number;
|
||
damage: string;
|
||
}>;
|
||
};
|
||
|
||
let clientIdCounter = 0;
|
||
const DEFAULT_EXTRA_BACKGROUND_COLOR = "#24232C";
|
||
const DEFAULT_EXTRA_TEXT_COLOR = "#FAFAFA";
|
||
const RichTextEditor = dynamic(
|
||
() => import("@/components/rich-text-editor").then((module) => module.RichTextEditor),
|
||
{
|
||
ssr: false,
|
||
loading: () => <Skeleton className="h-36 w-full" />,
|
||
},
|
||
);
|
||
|
||
function createClientId(prefix: string): string {
|
||
const browserCrypto = globalThis.crypto;
|
||
if (typeof browserCrypto?.randomUUID === "function")
|
||
return `${prefix}-${browserCrypto.randomUUID()}`;
|
||
clientIdCounter += 1;
|
||
return `${prefix}-${Date.now()}-${clientIdCounter}`;
|
||
}
|
||
|
||
function parseEditorDraft<Draft>(
|
||
value: unknown,
|
||
arrayKeys: string[],
|
||
stringKeys: string[] = [],
|
||
): Draft | null {
|
||
if (!value || typeof value !== "object") return null;
|
||
const record = value as Record<string, unknown>;
|
||
if (arrayKeys.some((key) => !Array.isArray(record[key]))) return null;
|
||
if (stringKeys.some((key) => typeof record[key] !== "string")) return null;
|
||
return value as Draft;
|
||
}
|
||
|
||
function hasIssuePrefix(
|
||
issues: readonly { path: readonly PropertyKey[] }[],
|
||
...path: (string | number)[]
|
||
): boolean {
|
||
return issues.some((issue) => path.every((part, index) => issue.path[index] === part));
|
||
}
|
||
|
||
export interface GuideSectionEditorProps {
|
||
section: "weapon" | "artifact" | "constellations" | "team" | "extra";
|
||
guide: Guide;
|
||
weapons: CatalogItem[];
|
||
artifacts: CatalogItem[];
|
||
characters: CatalogItem[];
|
||
catalogConstellations: CatalogConstellation[];
|
||
catalogTalents: CatalogTalent[];
|
||
initial: {
|
||
sectionNote: string | null;
|
||
weaponGroups: WeaponGroupValue[];
|
||
artifactGroups: ArtifactValue[];
|
||
artifactProfile: ArtifactProfile;
|
||
constellationGroups: ConstellationGroupValue[];
|
||
teams: TeamValue[];
|
||
extras: Array<{
|
||
id: string;
|
||
title: string;
|
||
rows: Array<{
|
||
id: string;
|
||
mediaId: string | null;
|
||
heading: string;
|
||
body: string;
|
||
percent: string | null;
|
||
backgroundColor: string | null;
|
||
textColor: string | null;
|
||
catalogItems: ExtraCatalogItem[];
|
||
highlights: Array<{ text: string; color: string }>;
|
||
imageUrl: string | null;
|
||
imageWidth: number | null;
|
||
imageHeight: number | null;
|
||
}>;
|
||
}>;
|
||
};
|
||
}
|
||
|
||
const SELECTED_TOGGLE_CLASS =
|
||
"aria-pressed:border-primary aria-pressed:ring-2 aria-pressed:ring-primary/30";
|
||
|
||
function CatalogPicker({
|
||
items,
|
||
value,
|
||
onChange,
|
||
placeholder,
|
||
rarityBackground = false,
|
||
showSelectedImage = false,
|
||
invalid = false,
|
||
}: {
|
||
items: CatalogItem[];
|
||
value: string;
|
||
onChange: (value: string) => void;
|
||
placeholder: string;
|
||
rarityBackground?: boolean;
|
||
showSelectedImage?: boolean;
|
||
invalid?: boolean;
|
||
}) {
|
||
const selected = items.find((item) => item.key === value) ?? null;
|
||
return (
|
||
<div className="flex min-w-0 items-center gap-2">
|
||
{showSelectedImage && selected ? (
|
||
<div
|
||
className={cn(
|
||
"relative size-10 shrink-0 overflow-hidden rounded-md",
|
||
rarityBackground && rarityGradientClass(selected.rarity),
|
||
)}
|
||
>
|
||
<Image
|
||
src={selected.imageUrl}
|
||
alt=""
|
||
fill
|
||
className="object-contain"
|
||
/>
|
||
</div>
|
||
) : null}
|
||
<div className="min-w-0 flex-1">
|
||
<Combobox
|
||
items={items}
|
||
value={selected}
|
||
onValueChange={(next) => onChange(next?.key ?? "")}
|
||
itemToStringLabel={(item) => item.name}
|
||
itemToStringValue={(item) => item.key}
|
||
autoHighlight
|
||
>
|
||
<ComboboxInput
|
||
className="w-full"
|
||
placeholder={placeholder}
|
||
showClear
|
||
aria-invalid={invalid}
|
||
/>
|
||
<ComboboxContent className="min-w-[24rem]">
|
||
<ComboboxEmpty>ไม่พบรายการ</ComboboxEmpty>
|
||
<ComboboxList>
|
||
{(item) => (
|
||
<ComboboxItem key={item.key} value={item}>
|
||
<div
|
||
className={cn(
|
||
"relative size-8 shrink-0 overflow-hidden rounded-md",
|
||
rarityBackground && rarityGradientClass(item.rarity),
|
||
)}
|
||
>
|
||
<Image
|
||
src={item.imageUrl}
|
||
alt=""
|
||
fill
|
||
className="object-contain"
|
||
/>
|
||
</div>
|
||
<span className="whitespace-nowrap">{item.name}</span>
|
||
</ComboboxItem>
|
||
)}
|
||
</ComboboxList>
|
||
</ComboboxContent>
|
||
</Combobox>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function StatSelect({
|
||
label,
|
||
options,
|
||
value,
|
||
max,
|
||
onChange,
|
||
elementIconUrls,
|
||
}: {
|
||
label: string;
|
||
options: readonly string[];
|
||
value: string[];
|
||
max: number;
|
||
onChange: (value: string[]) => void;
|
||
elementIconUrls: Record<string, string>;
|
||
}) {
|
||
return (
|
||
<Field>
|
||
<FieldLabel>{label}</FieldLabel>
|
||
<Select
|
||
multiple
|
||
value={value}
|
||
onValueChange={(next) => {
|
||
if (next.length <= max) onChange(next);
|
||
}}
|
||
>
|
||
<SelectTrigger className="w-full">
|
||
<StatIcons values={value} elementIconUrls={elementIconUrls} />
|
||
<SelectValue placeholder="Select recommended stats…" />
|
||
</SelectTrigger>
|
||
<SelectContent>
|
||
<SelectGroup>
|
||
{options.map((option) => (
|
||
<SelectItem
|
||
key={option}
|
||
value={option}
|
||
disabled={value.length >= max && !value.includes(option)}
|
||
>
|
||
<StatIcons values={[option]} elementIconUrls={elementIconUrls} />
|
||
{option}
|
||
</SelectItem>
|
||
))}
|
||
</SelectGroup>
|
||
</SelectContent>
|
||
</Select>
|
||
<FieldDescription>เลือกได้สูงสุด {max} ค่า</FieldDescription>
|
||
</Field>
|
||
);
|
||
}
|
||
|
||
function StatIcons({
|
||
values,
|
||
elementIconUrls = {},
|
||
}: {
|
||
values: readonly string[];
|
||
elementIconUrls?: Record<string, string>;
|
||
}) {
|
||
const icons = values.flatMap((value) => {
|
||
const element = recommendedStatElement(value);
|
||
if (element && elementIconUrls[element]) {
|
||
return [{ name: element, src: elementIconUrls[element], element }];
|
||
}
|
||
const targetIcon = targetStatIconName(value);
|
||
if (targetIcon) {
|
||
return [{ name: targetIcon, src: `/icon/stats/${targetIcon}.webp` }];
|
||
}
|
||
const recommendedIcon = recommendedStatIconName(value);
|
||
return recommendedIcon
|
||
? [{
|
||
name: recommendedIcon,
|
||
src: `/icon/stats/${recommendedIcon}.${recommendedIcon === "elemental-bonus" ? "svg" : "webp"}`,
|
||
}]
|
||
: [];
|
||
});
|
||
if (!icons.length) return null;
|
||
return (
|
||
<span className="flex shrink-0 items-center gap-0.5" aria-hidden="true">
|
||
{icons.map((icon) => (
|
||
<StatIcon key={icon.name} {...icon} />
|
||
))}
|
||
</span>
|
||
);
|
||
}
|
||
|
||
function StatIcon({
|
||
name,
|
||
src,
|
||
element,
|
||
}: {
|
||
name: string;
|
||
src: string;
|
||
element?: string;
|
||
}) {
|
||
const [hasError, setHasError] = useState(false);
|
||
if (hasError) {
|
||
const PlaceholderIcon =
|
||
name === "healing-bonus" ? HeartPulseIcon : SparklesIcon;
|
||
return (
|
||
<PlaceholderIcon
|
||
className="size-5 text-muted-foreground"
|
||
aria-hidden="true"
|
||
/>
|
||
);
|
||
}
|
||
return (
|
||
<Image
|
||
src={src}
|
||
alt=""
|
||
width={20}
|
||
height={20}
|
||
className={cn("size-5 object-contain", !element && "brightness-0 invert")}
|
||
style={element ? { filter: elementIconFilter(element) } : undefined}
|
||
onError={() => setHasError(true)}
|
||
/>
|
||
);
|
||
}
|
||
|
||
function TalentLevelPicker({
|
||
value,
|
||
onChange,
|
||
}: {
|
||
value?: string;
|
||
onChange: (value?: string) => void;
|
||
}) {
|
||
const selected = talentLevelOptions.find((level) => level === value) ?? null;
|
||
return (
|
||
<Combobox
|
||
items={[...talentLevelOptions]}
|
||
value={selected}
|
||
onValueChange={(next) => onChange(next ?? undefined)}
|
||
>
|
||
<ComboboxInput
|
||
className="w-full"
|
||
placeholder="เลือกระดับ 1–10"
|
||
showClear
|
||
/>
|
||
<ComboboxContent>
|
||
<ComboboxEmpty>ไม่พบระดับ</ComboboxEmpty>
|
||
<ComboboxList>
|
||
{(level) => (
|
||
<ComboboxItem key={level} value={level}>
|
||
ระดับ {level}
|
||
</ComboboxItem>
|
||
)}
|
||
</ComboboxList>
|
||
</ComboboxContent>
|
||
</Combobox>
|
||
);
|
||
}
|
||
|
||
function SortableTalentCard({
|
||
talent,
|
||
catalogTalent,
|
||
onLevelChange,
|
||
}: {
|
||
talent: TalentPriority;
|
||
catalogTalent?: CatalogTalent;
|
||
onLevelChange: (level?: string) => void;
|
||
}) {
|
||
const {
|
||
attributes,
|
||
listeners,
|
||
setNodeRef,
|
||
transform,
|
||
transition,
|
||
isDragging,
|
||
} = useSortable({ id: talent.type });
|
||
return (
|
||
<div
|
||
ref={setNodeRef}
|
||
style={{ transform: CSS.Transform.toString(transform), transition }}
|
||
className={cn("w-full shrink-0 sm:w-56", isDragging && "opacity-60")}
|
||
>
|
||
<Card size="sm" className="h-full">
|
||
<CardHeader className="flex items-center gap-2">
|
||
{catalogTalent?.iconUrl ? (
|
||
<Image
|
||
src={catalogTalent.iconUrl}
|
||
alt=""
|
||
width={40}
|
||
height={40}
|
||
className="size-10 shrink-0 rounded-lg bg-muted"
|
||
/>
|
||
) : null}
|
||
<CardTitle className="min-w-0 flex-1 truncate">
|
||
{talent.type}. {catalogTalent?.name ?? talent.type}
|
||
</CardTitle>
|
||
<CardAction>
|
||
<Button
|
||
type="button"
|
||
variant="ghost"
|
||
size="icon-sm"
|
||
className="touch-none"
|
||
{...attributes}
|
||
{...listeners}
|
||
>
|
||
<GripVerticalIcon />
|
||
<span className="sr-only">
|
||
ลากเพื่อจัดลำดับ {catalogTalent?.name ?? talent.type}
|
||
</span>
|
||
</Button>
|
||
</CardAction>
|
||
</CardHeader>
|
||
<CardContent>
|
||
<Field>
|
||
<FieldLabel>ระดับเป้าหมาย</FieldLabel>
|
||
<TalentLevelPicker value={talent.level} onChange={onLevelChange} />
|
||
</Field>
|
||
</CardContent>
|
||
</Card>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function SortableWeaponCard({
|
||
id,
|
||
children,
|
||
}: {
|
||
id: string;
|
||
children: (dragHandle: ReactNode) => ReactNode;
|
||
}) {
|
||
const {
|
||
attributes,
|
||
listeners,
|
||
setNodeRef,
|
||
transform,
|
||
transition,
|
||
isDragging,
|
||
} = useSortable({ id });
|
||
return (
|
||
<div
|
||
ref={setNodeRef}
|
||
style={{ transform: CSS.Transform.toString(transform), transition }}
|
||
className={cn(isDragging && "opacity-60")}
|
||
>
|
||
{children(
|
||
<Button
|
||
type="button"
|
||
variant="ghost"
|
||
size="icon-sm"
|
||
className="touch-none"
|
||
{...attributes}
|
||
{...listeners}
|
||
>
|
||
<GripVerticalIcon />
|
||
<span className="sr-only">ลากเพื่อจัดลำดับอาวุธ</span>
|
||
</Button>,
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function SortableArtifactRow({
|
||
id,
|
||
children,
|
||
}: {
|
||
id: string;
|
||
children: (dragHandle: ReactNode) => ReactNode;
|
||
}) {
|
||
const {
|
||
attributes,
|
||
listeners,
|
||
setNodeRef,
|
||
transform,
|
||
transition,
|
||
isDragging,
|
||
} = useSortable({ id });
|
||
return (
|
||
<div
|
||
ref={setNodeRef}
|
||
style={{ transform: CSS.Transform.toString(transform), transition }}
|
||
className={cn(isDragging && "opacity-60")}
|
||
>
|
||
{children(
|
||
<Button
|
||
type="button"
|
||
variant="ghost"
|
||
size="icon-sm"
|
||
className="touch-none"
|
||
{...attributes}
|
||
{...listeners}
|
||
>
|
||
<GripVerticalIcon />
|
||
<span className="sr-only">ลากเพื่อจัดลำดับ Artifact</span>
|
||
</Button>,
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function SortableTeamItem({
|
||
id,
|
||
children,
|
||
}: {
|
||
id: string;
|
||
children: (dragHandle: ReactNode) => ReactNode;
|
||
}) {
|
||
const {
|
||
attributes,
|
||
listeners,
|
||
setNodeRef,
|
||
transform,
|
||
transition,
|
||
isDragging,
|
||
} = useSortable({ id });
|
||
return (
|
||
<AccordionItem
|
||
ref={setNodeRef}
|
||
style={{ transform: CSS.Transform.toString(transform), transition }}
|
||
value={id}
|
||
className={cn("rounded-xl border px-4", isDragging && "opacity-60")}
|
||
>
|
||
{children(
|
||
<SimpleTooltip text="ลากเพื่อจัดลำดับ Team">
|
||
<Button
|
||
type="button"
|
||
variant="ghost"
|
||
size="icon-sm"
|
||
className="touch-none"
|
||
{...attributes}
|
||
{...listeners}
|
||
>
|
||
<GripVerticalIcon />
|
||
<span className="sr-only">ลากเพื่อจัดลำดับ Team</span>
|
||
</Button>
|
||
</SimpleTooltip>,
|
||
)}
|
||
</AccordionItem>
|
||
);
|
||
}
|
||
|
||
function SortableTeamMember({
|
||
id,
|
||
children,
|
||
}: {
|
||
id: string;
|
||
children: (dragHandle: ReactNode) => ReactNode;
|
||
}) {
|
||
const {
|
||
attributes,
|
||
listeners,
|
||
setNodeRef,
|
||
transform,
|
||
transition,
|
||
isDragging,
|
||
} = useSortable({ id });
|
||
return (
|
||
<div
|
||
ref={setNodeRef}
|
||
style={{ transform: CSS.Transform.toString(transform), transition }}
|
||
className={cn(isDragging && "opacity-60")}
|
||
>
|
||
{children(
|
||
<SimpleTooltip text="ลากเพื่อย้ายตำแหน่งตัวละคร">
|
||
<Button
|
||
type="button"
|
||
variant="ghost"
|
||
size="icon-sm"
|
||
className="touch-none"
|
||
{...attributes}
|
||
{...listeners}
|
||
>
|
||
<GripVerticalIcon />
|
||
<span className="sr-only">ลากเพื่อย้ายตำแหน่งตัวละคร</span>
|
||
</Button>
|
||
</SimpleTooltip>,
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function SortableExtraItem({
|
||
id,
|
||
label,
|
||
children,
|
||
}: {
|
||
id: string;
|
||
label: string;
|
||
children: (dragHandle: ReactNode) => ReactNode;
|
||
}) {
|
||
const {
|
||
attributes,
|
||
listeners,
|
||
setNodeRef,
|
||
transform,
|
||
transition,
|
||
isDragging,
|
||
} = useSortable({ id });
|
||
return (
|
||
<div
|
||
ref={setNodeRef}
|
||
style={{ transform: CSS.Transform.toString(transform), transition }}
|
||
className={cn(isDragging && "opacity-60")}
|
||
>
|
||
{children(
|
||
<Button
|
||
type="button"
|
||
variant="ghost"
|
||
size="icon-sm"
|
||
className="touch-none"
|
||
{...attributes}
|
||
{...listeners}
|
||
>
|
||
<GripVerticalIcon />
|
||
<span className="sr-only">{label}</span>
|
||
</Button>,
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
export function GuideSectionEditor(props: GuideSectionEditorProps) {
|
||
return (
|
||
<div className="flex flex-col gap-6">
|
||
{props.section === "weapon" ? <WeaponEditor {...props} /> : null}
|
||
{props.section === "artifact" ? <ArtifactEditor {...props} /> : null}
|
||
{props.section === "constellations" ? (
|
||
<ConstellationEditor {...props} />
|
||
) : null}
|
||
{props.section === "team" ? <TeamEditor {...props} /> : null}
|
||
{props.section === "extra" ? <ExtraEditor {...props} /> : null}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
type EditorBase = GuideSectionEditorProps;
|
||
|
||
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 weaponSensors = useSensors(
|
||
useSensor(PointerSensor),
|
||
useSensor(KeyboardSensor, {
|
||
coordinateGetter: sortableKeyboardCoordinates,
|
||
}),
|
||
);
|
||
const [note, setNote] = useState(initial.sectionNote ?? "");
|
||
const [groups, setGroups] = useState(() => {
|
||
const source = initial.weaponGroups.length
|
||
? initial.weaponGroups
|
||
: [{ id: "weapon-condition-default", note: null, rows: [] }];
|
||
return source.map((group) => ({
|
||
clientId: group.id,
|
||
note: group.note ?? "",
|
||
rows: group.rows.map((row) => ({
|
||
clientId: row.id,
|
||
weaponKey: row.weaponKey,
|
||
refinement: row.refinement,
|
||
overallPercent: row.overallPercent ?? "",
|
||
personalPercent: row.personalPercent ?? "",
|
||
note: row.note ?? "",
|
||
})),
|
||
}));
|
||
});
|
||
const [activeGroupIndex, setActiveGroupIndex] = useState(0);
|
||
const rows = groups[activeGroupIndex]?.rows ?? [];
|
||
const setRows = (update: typeof rows | ((current: typeof rows) => typeof rows)) =>
|
||
setGroups((current) => current.map((group, index) => index === activeGroupIndex
|
||
? { ...group, rows: typeof update === "function" ? update(group.rows) : update }
|
||
: group));
|
||
const value = {
|
||
note: note.trim() || null,
|
||
groups: groups.map((group) => ({
|
||
note: group.note.trim() || null,
|
||
rows: group.rows.map((row) => ({
|
||
weaponKey: row.weaponKey,
|
||
refinement: row.refinement,
|
||
overallPercent: row.overallPercent === "" ? null : Number(row.overallPercent),
|
||
personalPercent: row.personalPercent === "" ? null : Number(row.personalPercent),
|
||
note: row.note.trim() || null,
|
||
})),
|
||
})),
|
||
};
|
||
const validation = weaponSectionSchema.safeParse({
|
||
...value,
|
||
expectedVersion: guide.version,
|
||
});
|
||
const weaponIssue = (...path: (string | number)[]) =>
|
||
!validation.success && hasIssuePrefix(validation.error.issues, ...path);
|
||
const localDraft = { note, groups, activeGroupIndex };
|
||
const autosave = useAutosave({
|
||
value,
|
||
valid: validation.success,
|
||
initialVersion: guide.version,
|
||
persist: (next, expectedVersion) =>
|
||
saveWeaponSection(guide.id, { ...next, expectedVersion }),
|
||
storageKey: adminDraftKey(guide.id, "weapon"),
|
||
localDraft,
|
||
parseLocalDraft: (candidate) =>
|
||
parseEditorDraft<typeof localDraft>(candidate, ["groups"], ["note"]),
|
||
restoreLocalDraft: (draft) => {
|
||
setNote(draft.note);
|
||
setGroups(draft.groups);
|
||
setActiveGroupIndex(draft.activeGroupIndex ?? 0);
|
||
},
|
||
});
|
||
const handleWeaponDragEnd = ({ active, over }: DragEndEvent) => {
|
||
if (!over || active.id === over.id) return;
|
||
setRows((current) => {
|
||
const from = current.findIndex((row) => row.clientId === active.id);
|
||
const to = current.findIndex((row) => row.clientId === over.id);
|
||
return from === -1 || to === -1 ? current : arrayMove(current, from, to);
|
||
});
|
||
};
|
||
return (
|
||
<Card>
|
||
<CardHeader>
|
||
<CardTitle>Weapon tier list</CardTitle>
|
||
<CardDescription>
|
||
ลากเพื่อเรียงอาวุธจากดีที่สุดลงมา และกรอกเปอร์เซ็นต์อย่างน้อยหนึ่งช่อง
|
||
ระบบจะบันทึกอัตโนมัติ
|
||
</CardDescription>
|
||
</CardHeader>
|
||
<CardContent className="flex flex-col gap-4">
|
||
<Field>
|
||
<FieldLabel htmlFor="weapon-section-note">
|
||
Header note (optional)
|
||
</FieldLabel>
|
||
<Textarea
|
||
id="weapon-section-note"
|
||
value={note}
|
||
onChange={(event) => setNote(event.target.value)}
|
||
/>
|
||
</Field>
|
||
<div className="flex flex-wrap gap-2">
|
||
{groups.map((group, index) => (
|
||
<Button key={group.clientId} type="button" variant={index === activeGroupIndex ? "default" : "outline"} onClick={() => setActiveGroupIndex(index)}>
|
||
Condition {index + 1}
|
||
</Button>
|
||
))}
|
||
<Button type="button" variant="outline" disabled={groups.length >= 20} onClick={() => {
|
||
setGroups([...groups, { clientId: createClientId("weapon-condition"), note: "", rows: [] }]);
|
||
setActiveGroupIndex(groups.length);
|
||
}}>
|
||
<PlusIcon data-icon="inline-start" />เพิ่ม Condition
|
||
</Button>
|
||
<Button
|
||
type="button"
|
||
variant="outline"
|
||
disabled={groups.length >= 20 || !groups[activeGroupIndex]}
|
||
onClick={() => {
|
||
const source = groups[activeGroupIndex];
|
||
if (!source) return;
|
||
setGroups([
|
||
...groups,
|
||
{
|
||
clientId: createClientId("weapon-condition"),
|
||
note: source.note,
|
||
rows: source.rows.map((row) => ({
|
||
...row,
|
||
clientId: createClientId("weapon"),
|
||
})),
|
||
},
|
||
]);
|
||
setActiveGroupIndex(groups.length);
|
||
}}
|
||
>
|
||
<CopyIcon data-icon="inline-start" />Duplicate Condition
|
||
</Button>
|
||
</div>
|
||
<Field>
|
||
<FieldLabel htmlFor={`weapon-condition-note-${groups[activeGroupIndex]?.clientId}`}>
|
||
Condition note (optional)
|
||
</FieldLabel>
|
||
<Textarea
|
||
id={`weapon-condition-note-${groups[activeGroupIndex]?.clientId}`}
|
||
value={groups[activeGroupIndex]?.note ?? ""}
|
||
onChange={(event) => setGroups(groups.map((group, index) => index === activeGroupIndex ? { ...group, note: event.target.value } : group))}
|
||
/>
|
||
</Field>
|
||
<DndContext
|
||
sensors={weaponSensors}
|
||
collisionDetection={closestCenter}
|
||
onDragEnd={handleWeaponDragEnd}
|
||
>
|
||
<SortableContext
|
||
items={rows.map((row) => row.clientId)}
|
||
strategy={verticalListSortingStrategy}
|
||
>
|
||
<div className="flex flex-col gap-4">
|
||
{rows.map((row, rowIndex) => {
|
||
const selectedWeapon = compatibleWeapons.find(
|
||
(weapon) => weapon.key === row.weaponKey,
|
||
);
|
||
return (
|
||
<SortableWeaponCard key={row.clientId} id={row.clientId}>
|
||
{(dragHandle) => (
|
||
<Card size="sm">
|
||
<CardHeader>
|
||
<CardTitle className="flex items-center gap-3">
|
||
{selectedWeapon ? (
|
||
<div
|
||
className={cn(
|
||
"relative size-10 shrink-0 overflow-hidden rounded-lg",
|
||
rarityGradientClass(selectedWeapon.rarity),
|
||
)}
|
||
>
|
||
<Image
|
||
src={selectedWeapon.imageUrl}
|
||
alt=""
|
||
fill
|
||
className="object-contain"
|
||
/>
|
||
</div>
|
||
) : null}
|
||
<span>อันดับ {rowIndex + 1}</span>
|
||
</CardTitle>
|
||
<CardAction>
|
||
<div className="flex gap-1">
|
||
{dragHandle}
|
||
<DeleteConfirmationDialog
|
||
trigger={
|
||
<Button
|
||
type="button"
|
||
variant="ghost"
|
||
size="icon-sm"
|
||
/>
|
||
}
|
||
title={`ลบ ${selectedWeapon?.name ?? `อาวุธอันดับ ${rowIndex + 1}`}?`}
|
||
description="รายการอาวุธและเปอร์เซ็นต์เปรียบเทียบจะถูกลบออกจาก Guide"
|
||
onConfirm={() =>
|
||
setRows(
|
||
rows.filter(
|
||
(item) => item.clientId !== row.clientId,
|
||
),
|
||
)
|
||
}
|
||
>
|
||
<Trash2Icon />
|
||
<span className="sr-only">ลบอาวุธ</span>
|
||
</DeleteConfirmationDialog>
|
||
</div>
|
||
</CardAction>
|
||
</CardHeader>
|
||
<CardContent>
|
||
<FieldGroup>
|
||
<div className="grid grid-cols-[minmax(0,1fr)_auto] items-end gap-4">
|
||
<Field className="min-w-0" data-invalid={weaponIssue("groups", activeGroupIndex, "rows", rowIndex, "weaponKey")}>
|
||
<FieldLabel>อาวุธ</FieldLabel>
|
||
<CatalogPicker
|
||
items={compatibleWeapons}
|
||
value={row.weaponKey}
|
||
invalid={weaponIssue("groups", activeGroupIndex, "rows", rowIndex, "weaponKey")}
|
||
placeholder="ค้นหาชื่ออาวุธ…"
|
||
rarityBackground
|
||
onChange={(weaponKey) =>
|
||
setRows(
|
||
rows.map((item) =>
|
||
item.clientId === row.clientId
|
||
? { ...item, weaponKey }
|
||
: item,
|
||
),
|
||
)
|
||
}
|
||
/>
|
||
</Field>
|
||
<Field className="shrink-0" data-invalid={weaponIssue("groups", activeGroupIndex, "rows", rowIndex)}>
|
||
<FieldLabel>Refinement</FieldLabel>
|
||
<ToggleGroup
|
||
variant="outline"
|
||
value={[`R${row.refinement}`]}
|
||
onValueChange={(selected) => {
|
||
const next = selected[0];
|
||
if (next)
|
||
setRows(
|
||
rows.map((item) =>
|
||
item.clientId === row.clientId
|
||
? {
|
||
...item,
|
||
refinement: Number(
|
||
next.slice(1),
|
||
),
|
||
}
|
||
: item,
|
||
),
|
||
);
|
||
}}
|
||
>
|
||
{[1, 2, 3, 4, 5].map((refinement) => (
|
||
<ToggleGroupItem
|
||
className={SELECTED_TOGGLE_CLASS}
|
||
key={refinement}
|
||
value={`R${refinement}`}
|
||
>
|
||
R{refinement}
|
||
</ToggleGroupItem>
|
||
))}
|
||
</ToggleGroup>
|
||
</Field>
|
||
</div>
|
||
<div className="grid gap-4 sm:grid-cols-2">
|
||
<Field data-invalid={weaponIssue("groups", activeGroupIndex, "rows", rowIndex)}>
|
||
<FieldLabel>Overall %</FieldLabel>
|
||
<Input
|
||
type="text"
|
||
inputMode="decimal"
|
||
value={row.overallPercent}
|
||
aria-invalid={weaponIssue("groups", activeGroupIndex, "rows", rowIndex)}
|
||
onChange={(event) =>
|
||
setRows(
|
||
rows.map((item) =>
|
||
item.clientId === row.clientId
|
||
? {
|
||
...item,
|
||
overallPercent:
|
||
normalizePercentInput(
|
||
event.target.value,
|
||
item.overallPercent,
|
||
),
|
||
}
|
||
: item,
|
||
),
|
||
)
|
||
}
|
||
onBlur={() =>
|
||
setRows(
|
||
rows.map((item) =>
|
||
item.clientId === row.clientId
|
||
? {
|
||
...item,
|
||
overallPercent:
|
||
finalizePercentInput(
|
||
item.overallPercent,
|
||
),
|
||
}
|
||
: item,
|
||
),
|
||
)
|
||
}
|
||
/>
|
||
</Field>
|
||
<Field data-invalid={weaponIssue("groups", activeGroupIndex, "rows", rowIndex)}>
|
||
<FieldLabel>Personal %</FieldLabel>
|
||
<Input
|
||
type="text"
|
||
inputMode="decimal"
|
||
value={row.personalPercent}
|
||
aria-invalid={weaponIssue("groups", activeGroupIndex, "rows", rowIndex)}
|
||
onChange={(event) =>
|
||
setRows(
|
||
rows.map((item) =>
|
||
item.clientId === row.clientId
|
||
? {
|
||
...item,
|
||
personalPercent:
|
||
normalizePercentInput(
|
||
event.target.value,
|
||
item.personalPercent,
|
||
),
|
||
}
|
||
: item,
|
||
),
|
||
)
|
||
}
|
||
onBlur={() =>
|
||
setRows(
|
||
rows.map((item) =>
|
||
item.clientId === row.clientId
|
||
? {
|
||
...item,
|
||
personalPercent:
|
||
finalizePercentInput(
|
||
item.personalPercent,
|
||
),
|
||
}
|
||
: item,
|
||
),
|
||
)
|
||
}
|
||
/>
|
||
</Field>
|
||
</div>
|
||
<Field>
|
||
<FieldLabel
|
||
htmlFor={`weapon-note-${row.clientId}`}
|
||
>
|
||
Note (optional)
|
||
</FieldLabel>
|
||
<Textarea
|
||
id={`weapon-note-${row.clientId}`}
|
||
value={row.note}
|
||
onChange={(event) =>
|
||
setRows(
|
||
rows.map((item) =>
|
||
item.clientId === row.clientId
|
||
? { ...item, note: event.target.value }
|
||
: item,
|
||
),
|
||
)
|
||
}
|
||
/>
|
||
</Field>
|
||
</FieldGroup>
|
||
</CardContent>
|
||
</Card>
|
||
)}
|
||
</SortableWeaponCard>
|
||
);
|
||
})}
|
||
</div>
|
||
</SortableContext>
|
||
</DndContext>
|
||
</CardContent>
|
||
<CardFooter className="flex-wrap justify-between gap-3">
|
||
<Button
|
||
type="button"
|
||
variant="outline"
|
||
onClick={() =>
|
||
setRows([
|
||
...rows,
|
||
{
|
||
clientId: createClientId("weapon"),
|
||
weaponKey: "",
|
||
refinement: 1,
|
||
overallPercent: "",
|
||
personalPercent: "",
|
||
note: "",
|
||
},
|
||
])
|
||
}
|
||
>
|
||
<PlusIcon data-icon="inline-start" />
|
||
เพิ่มอาวุธ
|
||
</Button>
|
||
{groups.length > 1 ? (
|
||
<Button type="button" variant="ghost" onClick={() => {
|
||
const next = groups.filter((_, index) => index !== activeGroupIndex);
|
||
setGroups(next);
|
||
setActiveGroupIndex(Math.min(activeGroupIndex, next.length - 1));
|
||
}}>
|
||
<Trash2Icon data-icon="inline-start" />ลบ Condition นี้
|
||
</Button>
|
||
) : null}
|
||
<AutosaveStatus
|
||
state={autosave.state}
|
||
issues={
|
||
validation.success
|
||
? []
|
||
: formatGuideValidationIssues("weapon", validation.error.issues)
|
||
}
|
||
onSave={autosave.saveNow}
|
||
/>
|
||
</CardFooter>
|
||
</Card>
|
||
);
|
||
}
|
||
|
||
function ArtifactEditor({
|
||
guide,
|
||
artifacts,
|
||
initial,
|
||
catalogTalents,
|
||
characters,
|
||
}: EditorBase) {
|
||
const profile = initial.artifactProfile;
|
||
const elementIconUrls = Object.fromEntries(
|
||
characters.flatMap((character) =>
|
||
character.element && character.elementIconUrl
|
||
? [[character.element, character.elementIconUrl] as const]
|
||
: [],
|
||
),
|
||
);
|
||
const artifactSensors = useSensors(
|
||
useSensor(PointerSensor),
|
||
useSensor(KeyboardSensor, {
|
||
coordinateGetter: sortableKeyboardCoordinates,
|
||
}),
|
||
);
|
||
const talentSensors = useSensors(
|
||
useSensor(PointerSensor),
|
||
useSensor(KeyboardSensor, {
|
||
coordinateGetter: sortableKeyboardCoordinates,
|
||
}),
|
||
);
|
||
const [groups, setGroups] = useState(() =>
|
||
(["Late Game", "Early Game"] as const).map((name, index) => ({
|
||
name,
|
||
rows:
|
||
initial.artifactGroups[index]?.rows.map((row, rowIndex) => ({
|
||
...row,
|
||
clientId: `${name}-${rowIndex}`,
|
||
})) ?? [],
|
||
})),
|
||
);
|
||
const [sands, setSands] = useState(profile?.sands ?? []);
|
||
const [goblet, setGoblet] = useState(profile?.goblet ?? []);
|
||
const [circlet, setCirclet] = useState(profile?.circlet ?? []);
|
||
const [substats, setSubstats] = useState(profile?.substats ?? []);
|
||
const [targets, setTargets] = useState(() => {
|
||
const existing = new Map(
|
||
(profile?.targets ?? []).flatMap((target) => {
|
||
const stat = normalizeTargetStat(target.stat);
|
||
return stat ? [[stat, target.value] as const] : [];
|
||
}),
|
||
);
|
||
return targetStatDefaults.map((target) => ({
|
||
stat: target.stat,
|
||
value: existing.get(target.stat) ?? target.value,
|
||
}));
|
||
});
|
||
const [talents, setTalents] = useState<TalentPriority[]>(() => {
|
||
const source: TalentPriority[] = profile?.talents.length
|
||
? profile.talents
|
||
: (["NA", "E", "Q"] as const).map((type, priority) => ({
|
||
type,
|
||
priority: priority + 1,
|
||
}));
|
||
return normalizeTalentPriorities(
|
||
source.map((talent) => ({
|
||
...talent,
|
||
level:
|
||
talent.level &&
|
||
talentLevelOptions.includes(
|
||
talent.level as (typeof talentLevelOptions)[number],
|
||
)
|
||
? talent.level
|
||
: undefined,
|
||
})),
|
||
);
|
||
});
|
||
const [note, setNote] = useState(profile?.note ?? "");
|
||
const value = {
|
||
groups: groups.map((group) => ({
|
||
name: group.name,
|
||
rows: group.rows.map((row) => ({
|
||
firstArtifactKey: row.firstArtifactKey,
|
||
secondArtifactKey: row.secondArtifactKey,
|
||
percent: Number(row.percent),
|
||
note: row.note?.trim() || null,
|
||
})),
|
||
})),
|
||
sands,
|
||
goblet,
|
||
circlet,
|
||
substats,
|
||
targets: targets
|
||
.filter((target) => target.value.trim())
|
||
.map((target) => ({ stat: target.stat, value: target.value.trim() })),
|
||
talents,
|
||
note: note || null,
|
||
};
|
||
const validation = artifactSectionSchema.safeParse({
|
||
...value,
|
||
expectedVersion: guide.version,
|
||
});
|
||
const artifactIssue = (...path: (string | number)[]) =>
|
||
!validation.success && hasIssuePrefix(validation.error.issues, ...path);
|
||
const localDraft = {
|
||
groups,
|
||
sands,
|
||
goblet,
|
||
circlet,
|
||
substats,
|
||
targets,
|
||
talents,
|
||
note,
|
||
};
|
||
const autosave = useAutosave({
|
||
value,
|
||
valid: validation.success,
|
||
initialVersion: guide.version,
|
||
persist: (next, expectedVersion) =>
|
||
saveArtifactSection(guide.id, { ...next, expectedVersion }),
|
||
storageKey: adminDraftKey(guide.id, "artifact"),
|
||
localDraft,
|
||
parseLocalDraft: (candidate) =>
|
||
parseEditorDraft<typeof localDraft>(
|
||
candidate,
|
||
[
|
||
"groups",
|
||
"sands",
|
||
"goblet",
|
||
"circlet",
|
||
"substats",
|
||
"targets",
|
||
"talents",
|
||
],
|
||
["note"],
|
||
),
|
||
restoreLocalDraft: (draft) => {
|
||
setGroups(draft.groups);
|
||
setSands(draft.sands);
|
||
setGoblet(draft.goblet);
|
||
setCirclet(draft.circlet);
|
||
setSubstats(draft.substats);
|
||
setTargets(draft.targets);
|
||
setTalents(draft.talents);
|
||
setNote(draft.note);
|
||
},
|
||
});
|
||
const orderedTalents = normalizeTalentPriorities(talents);
|
||
const handleArtifactDragEnd = ({ active, over }: DragEndEvent) => {
|
||
if (!over || active.id === over.id) return;
|
||
const activeGroupIndex = groups.findIndex((group) =>
|
||
group.rows.some((row) => row.clientId === active.id),
|
||
);
|
||
const overGroupIndex = groups.findIndex((group) =>
|
||
group.rows.some((row) => row.clientId === over.id),
|
||
);
|
||
if (activeGroupIndex === -1 || activeGroupIndex !== overGroupIndex) return;
|
||
setGroups((current) =>
|
||
current.map((group, groupIndex) => {
|
||
if (groupIndex !== activeGroupIndex) return group;
|
||
const from = group.rows.findIndex((row) => row.clientId === active.id);
|
||
const to = group.rows.findIndex((row) => row.clientId === over.id);
|
||
return from === -1 || to === -1
|
||
? group
|
||
: { ...group, rows: arrayMove(group.rows, from, to) };
|
||
}),
|
||
);
|
||
};
|
||
const handleTalentDragEnd = ({ active, over }: DragEndEvent) => {
|
||
if (!over) return;
|
||
setTalents(
|
||
reorderTalentPriorities(
|
||
orderedTalents,
|
||
active.id as TalentPriority["type"],
|
||
over.id as TalentPriority["type"],
|
||
),
|
||
);
|
||
};
|
||
const setTalentRelation = (
|
||
type: TalentPriority["type"],
|
||
relationToNext: TalentRelation,
|
||
) => {
|
||
setTalents(
|
||
orderedTalents.map((talent) =>
|
||
talent.type === type ? { ...talent, relationToNext } : talent,
|
||
),
|
||
);
|
||
};
|
||
return (
|
||
<div className="flex flex-col gap-6">
|
||
<Card>
|
||
<CardHeader>
|
||
<CardTitle>Artifact tier list</CardTitle>
|
||
<CardDescription>
|
||
จัดชุด Artifact ในกลุ่ม Late Game และ Early Game
|
||
ระบบจะบันทึกอัตโนมัติ
|
||
</CardDescription>
|
||
</CardHeader>
|
||
<CardContent className="grid gap-5 md:grid-cols-2">
|
||
{groups.map((group, groupIndex) => (
|
||
<Card key={group.name} size="sm">
|
||
<CardHeader>
|
||
<CardTitle>{group.name}</CardTitle>
|
||
</CardHeader>
|
||
<CardContent className="flex flex-col gap-3">
|
||
<DndContext
|
||
sensors={artifactSensors}
|
||
collisionDetection={closestCenter}
|
||
onDragEnd={handleArtifactDragEnd}
|
||
>
|
||
<SortableContext
|
||
items={group.rows.map((row) => row.clientId)}
|
||
strategy={verticalListSortingStrategy}
|
||
>
|
||
<div className="flex flex-col gap-3">
|
||
{group.rows.map((row, rowIndex) => (
|
||
<SortableArtifactRow
|
||
key={row.clientId}
|
||
id={row.clientId}
|
||
>
|
||
{(dragHandle) => (
|
||
<div
|
||
className={cn(
|
||
"flex items-start gap-2 rounded-lg border p-3",
|
||
rowIndex % 2 === 0
|
||
? "bg-muted/60"
|
||
: "bg-muted/20",
|
||
artifactIssue("groups", groupIndex, "rows", rowIndex) && "border-destructive",
|
||
)}
|
||
>
|
||
{dragHandle}
|
||
<div className="flex min-w-0 flex-1 flex-col gap-3">
|
||
<div className="grid gap-3 lg:grid-cols-[1fr_1fr_8rem_auto]">
|
||
<CatalogPicker
|
||
items={artifacts}
|
||
value={row.firstArtifactKey}
|
||
invalid={artifactIssue("groups", groupIndex, "rows", rowIndex, "firstArtifactKey")}
|
||
placeholder="ค้นหาชุด Artifact แรก…"
|
||
rarityBackground
|
||
showSelectedImage
|
||
onChange={(firstArtifactKey) =>
|
||
setGroups(
|
||
groups.map((item, index) =>
|
||
index === groupIndex
|
||
? {
|
||
...item,
|
||
rows: item.rows.map(
|
||
(candidate, inner) =>
|
||
inner === rowIndex
|
||
? {
|
||
...candidate,
|
||
firstArtifactKey,
|
||
}
|
||
: candidate,
|
||
),
|
||
}
|
||
: item,
|
||
),
|
||
)
|
||
}
|
||
/>
|
||
<CatalogPicker
|
||
items={artifacts}
|
||
value={row.secondArtifactKey ?? ""}
|
||
placeholder="ค้นหาชุดที่สอง (ถ้ามี)…"
|
||
rarityBackground
|
||
showSelectedImage
|
||
onChange={(secondArtifactKey) =>
|
||
setGroups(
|
||
groups.map((item, index) =>
|
||
index === groupIndex
|
||
? {
|
||
...item,
|
||
rows: item.rows.map(
|
||
(candidate, inner) =>
|
||
inner === rowIndex
|
||
? {
|
||
...candidate,
|
||
secondArtifactKey:
|
||
secondArtifactKey ||
|
||
null,
|
||
}
|
||
: candidate,
|
||
),
|
||
}
|
||
: item,
|
||
),
|
||
)
|
||
}
|
||
/>
|
||
<Input
|
||
type="text"
|
||
inputMode="decimal"
|
||
aria-label="Artifact percent"
|
||
value={row.percent}
|
||
aria-invalid={artifactIssue("groups", groupIndex, "rows", rowIndex)}
|
||
onChange={(event) =>
|
||
setGroups(
|
||
groups.map((item, index) =>
|
||
index === groupIndex
|
||
? {
|
||
...item,
|
||
rows: item.rows.map(
|
||
(candidate, inner) =>
|
||
inner === rowIndex
|
||
? {
|
||
...candidate,
|
||
percent:
|
||
normalizePercentInput(
|
||
event.target
|
||
.value,
|
||
candidate.percent,
|
||
),
|
||
}
|
||
: candidate,
|
||
),
|
||
}
|
||
: item,
|
||
),
|
||
)
|
||
}
|
||
onBlur={() =>
|
||
setGroups(
|
||
groups.map((item, index) =>
|
||
index === groupIndex
|
||
? {
|
||
...item,
|
||
rows: item.rows.map(
|
||
(candidate, inner) =>
|
||
inner === rowIndex
|
||
? {
|
||
...candidate,
|
||
percent:
|
||
finalizePercentInput(
|
||
candidate.percent,
|
||
),
|
||
}
|
||
: candidate,
|
||
),
|
||
}
|
||
: item,
|
||
),
|
||
)
|
||
}
|
||
/>
|
||
<DeleteConfirmationDialog
|
||
trigger={
|
||
<Button
|
||
type="button"
|
||
variant="ghost"
|
||
size="icon"
|
||
/>
|
||
}
|
||
title={`ลบ Artifact อันดับ ${rowIndex + 1}?`}
|
||
description={`รายการ Artifact ในกลุ่ม ${group.name} และเปอร์เซ็นต์เปรียบเทียบจะถูกลบออกจาก Guide`}
|
||
onConfirm={() =>
|
||
setGroups(
|
||
groups.map((item, index) =>
|
||
index === groupIndex
|
||
? {
|
||
...item,
|
||
rows: item.rows.filter(
|
||
(_, inner) =>
|
||
inner !== rowIndex,
|
||
),
|
||
}
|
||
: item,
|
||
),
|
||
)
|
||
}
|
||
>
|
||
<Trash2Icon />
|
||
<span className="sr-only">ลบ Artifact</span>
|
||
</DeleteConfirmationDialog>
|
||
</div>
|
||
<Field>
|
||
<FieldLabel
|
||
htmlFor={`artifact-note-${groupIndex}-${rowIndex}`}
|
||
>
|
||
Note (optional)
|
||
</FieldLabel>
|
||
<Textarea
|
||
id={`artifact-note-${groupIndex}-${rowIndex}`}
|
||
value={row.note ?? ""}
|
||
onChange={(event) =>
|
||
setGroups(
|
||
groups.map((item, index) =>
|
||
index === groupIndex
|
||
? {
|
||
...item,
|
||
rows: item.rows.map(
|
||
(candidate, inner) =>
|
||
inner === rowIndex
|
||
? {
|
||
...candidate,
|
||
note: event.target
|
||
.value,
|
||
}
|
||
: candidate,
|
||
),
|
||
}
|
||
: item,
|
||
),
|
||
)
|
||
}
|
||
/>
|
||
</Field>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</SortableArtifactRow>
|
||
))}
|
||
</div>
|
||
</SortableContext>
|
||
</DndContext>
|
||
<Button
|
||
type="button"
|
||
variant="outline"
|
||
onClick={() =>
|
||
setGroups(
|
||
groups.map((item, index) =>
|
||
index === groupIndex
|
||
? {
|
||
...item,
|
||
rows: [
|
||
...item.rows,
|
||
{
|
||
clientId: createClientId("artifact"),
|
||
firstArtifactKey: "",
|
||
secondArtifactKey: null,
|
||
percent: "100",
|
||
note: "",
|
||
},
|
||
],
|
||
}
|
||
: item,
|
||
),
|
||
)
|
||
}
|
||
>
|
||
<PlusIcon data-icon="inline-start" />
|
||
เพิ่มชุด Artifact
|
||
</Button>
|
||
</CardContent>
|
||
</Card>
|
||
))}
|
||
</CardContent>
|
||
</Card>
|
||
<Card>
|
||
<CardHeader>
|
||
<CardTitle>Recommended Stats</CardTitle>
|
||
<CardDescription>
|
||
เลือกค่าจากรายการแทนปุ่ม และเลือก Main Stat ได้สูงสุด 3 ค่าต่อชิ้น
|
||
</CardDescription>
|
||
</CardHeader>
|
||
<CardContent>
|
||
<FieldGroup>
|
||
<div className="grid gap-4 sm:grid-cols-3">
|
||
<StatSelect
|
||
label="Sands / นาฬิกา"
|
||
options={mainStatOptions.sands}
|
||
value={sands}
|
||
max={3}
|
||
onChange={setSands}
|
||
elementIconUrls={elementIconUrls}
|
||
/>
|
||
<StatSelect
|
||
label="Goblet / ถ้วย"
|
||
options={mainStatOptions.goblet}
|
||
value={goblet}
|
||
max={3}
|
||
onChange={setGoblet}
|
||
elementIconUrls={elementIconUrls}
|
||
/>
|
||
<StatSelect
|
||
label="Circlet / หมวก"
|
||
options={mainStatOptions.circlet}
|
||
value={circlet}
|
||
max={3}
|
||
onChange={setCirclet}
|
||
elementIconUrls={elementIconUrls}
|
||
/>
|
||
</div>
|
||
<StatSelect
|
||
label="Substats"
|
||
options={substatOptions}
|
||
value={substats}
|
||
max={substatOptions.length}
|
||
onChange={setSubstats}
|
||
elementIconUrls={elementIconUrls}
|
||
/>
|
||
<FieldSet>
|
||
<FieldLegend>Target Stats</FieldLegend>
|
||
<div className="grid gap-5 sm:grid-cols-2">
|
||
{[
|
||
targetStatOptions.slice(0, 4),
|
||
targetStatOptions.slice(4),
|
||
].map((column) => (
|
||
<div key={column[0]} className="flex flex-col gap-3">
|
||
{column.map((stat) => {
|
||
const target = targets.find((item) => item.stat === stat);
|
||
return (
|
||
<Field
|
||
key={stat}
|
||
orientation="horizontal"
|
||
className="items-center"
|
||
>
|
||
<FieldLabel className="flex w-32 shrink-0 items-center gap-2">
|
||
<StatIcons values={[stat]} />
|
||
{stat}
|
||
</FieldLabel>
|
||
<Input
|
||
value={target?.value ?? ""}
|
||
placeholder="Value or text"
|
||
aria-label={`${stat} value`}
|
||
onChange={(event) =>
|
||
setTargets(
|
||
targets.map((item) =>
|
||
item.stat === stat
|
||
? { ...item, value: event.target.value }
|
||
: item,
|
||
),
|
||
)
|
||
}
|
||
/>
|
||
</Field>
|
||
);
|
||
})}
|
||
</div>
|
||
))}
|
||
</div>
|
||
<FieldDescription>
|
||
กรอกเฉพาะค่าเป็นตัวเลข เปอร์เซ็นต์ หรือข้อความ เช่น -%
|
||
หากไม่กรอกค่า ระบบจะไม่แสดงส่วนค่าสถานะเป้าหมาย
|
||
</FieldDescription>
|
||
</FieldSet>
|
||
<FieldSet>
|
||
<FieldLegend>Talent Priority</FieldLegend>
|
||
<DndContext
|
||
sensors={talentSensors}
|
||
collisionDetection={closestCenter}
|
||
onDragEnd={handleTalentDragEnd}
|
||
>
|
||
<SortableContext
|
||
items={orderedTalents.map((talent) => talent.type)}
|
||
strategy={rectSortingStrategy}
|
||
>
|
||
<div className="flex min-w-0 flex-col items-stretch gap-3 sm:flex-row sm:items-start sm:overflow-x-auto sm:pb-1">
|
||
{orderedTalents.map((talent, index) => {
|
||
const catalogTalent = catalogTalents.find(
|
||
(item) => item.type === talent.type,
|
||
);
|
||
return (
|
||
<div
|
||
key={talent.type}
|
||
className="flex w-full shrink-0 flex-col items-stretch gap-3 sm:w-auto sm:flex-row sm:items-center"
|
||
>
|
||
<SortableTalentCard
|
||
talent={talent}
|
||
catalogTalent={catalogTalent}
|
||
onLevelChange={(level) =>
|
||
setTalents(
|
||
orderedTalents.map((item) =>
|
||
item.type === talent.type
|
||
? { ...item, level }
|
||
: item,
|
||
),
|
||
)
|
||
}
|
||
/>
|
||
{index < orderedTalents.length - 1 ? (
|
||
<ToggleGroup
|
||
variant="outline"
|
||
spacing={0}
|
||
value={[talent.relationToNext ?? "gt"]}
|
||
onValueChange={(selected) => {
|
||
const relation = selected[0] as
|
||
| TalentRelation
|
||
| undefined;
|
||
if (relation)
|
||
setTalentRelation(talent.type, relation);
|
||
}}
|
||
aria-label={`ตัวเปรียบเทียบหลัง ${catalogTalent?.name ?? talent.type}`}
|
||
className="shrink-0"
|
||
>
|
||
<ToggleGroupItem
|
||
className={SELECTED_TOGGLE_CLASS}
|
||
value="gt"
|
||
aria-label="มากกว่า"
|
||
>
|
||
>
|
||
</ToggleGroupItem>
|
||
<ToggleGroupItem
|
||
className={SELECTED_TOGGLE_CLASS}
|
||
value="gte"
|
||
aria-label="มากกว่าหรือเท่ากับ"
|
||
>
|
||
≥
|
||
</ToggleGroupItem>
|
||
<ToggleGroupItem
|
||
className={SELECTED_TOGGLE_CLASS}
|
||
value="eq"
|
||
aria-label="เท่ากับ"
|
||
>
|
||
=
|
||
</ToggleGroupItem>
|
||
</ToggleGroup>
|
||
) : null}
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
</SortableContext>
|
||
</DndContext>
|
||
</FieldSet>
|
||
<Field>
|
||
<FieldLabel htmlFor="artifact-note">Note (optional)</FieldLabel>
|
||
<Textarea
|
||
id="artifact-note"
|
||
value={note}
|
||
onChange={(event) => setNote(event.target.value)}
|
||
/>
|
||
</Field>
|
||
</FieldGroup>
|
||
</CardContent>
|
||
<CardFooter className="justify-end">
|
||
<AutosaveStatus
|
||
state={autosave.state}
|
||
issues={
|
||
validation.success
|
||
? []
|
||
: formatGuideValidationIssues(
|
||
"artifact",
|
||
validation.error.issues,
|
||
)
|
||
}
|
||
onSave={autosave.saveNow}
|
||
/>
|
||
</CardFooter>
|
||
</Card>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
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 ?? "",
|
||
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", note: null, rows: [] }];
|
||
return source.map((group) => ({
|
||
clientId: group.id,
|
||
note: group.note ?? "",
|
||
rows: makeRows(group.rows),
|
||
}));
|
||
});
|
||
const value = {
|
||
note: note.trim() || null,
|
||
groups: groups.map((group) => ({
|
||
note: group.note.trim() || null,
|
||
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({
|
||
value,
|
||
valid: validation.success,
|
||
initialVersion: guide.version,
|
||
persist: (next, expectedVersion) =>
|
||
saveConstellationSection(guide.id, { ...next, expectedVersion }),
|
||
storageKey: adminDraftKey(guide.id, "constellations"),
|
||
localDraft,
|
||
parseLocalDraft: (candidate) =>
|
||
parseEditorDraft<typeof localDraft>(candidate, ["groups"], ["note"]),
|
||
restoreLocalDraft: (draft) => {
|
||
setNote(draft.note);
|
||
setGroups(draft.groups);
|
||
},
|
||
});
|
||
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>
|
||
{groups.map((group, groupIndex) => (
|
||
<Card key={group.clientId} size="sm">
|
||
<CardHeader>
|
||
<CardTitle>Condition {groupIndex + 1}</CardTitle>
|
||
<CardDescription>
|
||
ใช้ note เพื่ออธิบายอาวุธ ทีม หรือเงื่อนไขที่ใช้เปรียบเทียบ
|
||
</CardDescription>
|
||
{groups.length > 1 ? (
|
||
<CardAction>
|
||
<DeleteConfirmationDialog
|
||
trigger={
|
||
<Button type="button" variant="ghost" size="icon-sm" />
|
||
}
|
||
title={`ลบ Condition ${groupIndex + 1}?`}
|
||
description="เปอร์เซ็นต์กลุ่มดาวทั้งหมดในเงื่อนไขนี้จะถูกลบออก"
|
||
onConfirm={() =>
|
||
setGroups(
|
||
groups.filter(
|
||
(item) => item.clientId !== group.clientId,
|
||
),
|
||
)
|
||
}
|
||
>
|
||
<Trash2Icon />
|
||
<span className="sr-only">
|
||
ลบ Condition {groupIndex + 1}
|
||
</span>
|
||
</DeleteConfirmationDialog>
|
||
</CardAction>
|
||
) : null}
|
||
</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>{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>
|
||
))}
|
||
</CardContent>
|
||
<CardFooter className="flex-wrap justify-between gap-3">
|
||
<Button
|
||
type="button"
|
||
variant="outline"
|
||
disabled={groups.length >= 20}
|
||
onClick={() =>
|
||
setGroups([
|
||
...groups,
|
||
{
|
||
clientId: createClientId("constellation-condition"),
|
||
note: "",
|
||
rows: makeRows(),
|
||
},
|
||
])
|
||
}
|
||
>
|
||
<PlusIcon data-icon="inline-start" />
|
||
เพิ่ม Condition
|
||
</Button>
|
||
<AutosaveStatus
|
||
state={autosave.state}
|
||
issues={
|
||
validation.success
|
||
? []
|
||
: formatGuideValidationIssues(
|
||
"constellations",
|
||
validation.error.issues,
|
||
)
|
||
}
|
||
onSave={autosave.saveNow}
|
||
/>
|
||
</CardFooter>
|
||
</Card>
|
||
);
|
||
}
|
||
|
||
function TeamEditor({
|
||
guide,
|
||
characters,
|
||
weapons,
|
||
artifacts,
|
||
initial,
|
||
}: EditorBase) {
|
||
const fallbackMembers = Array.from({ length: 4 }, () => ({
|
||
characterKey: "",
|
||
weaponKey: null,
|
||
artifactKey: null,
|
||
secondArtifactKey: null,
|
||
constellation: 0,
|
||
refinement: 1,
|
||
damage: "0",
|
||
}));
|
||
const teamSensors = useSensors(
|
||
useSensor(PointerSensor),
|
||
useSensor(KeyboardSensor, {
|
||
coordinateGetter: sortableKeyboardCoordinates,
|
||
}),
|
||
);
|
||
const [teams, setTeams] = useState<TeamToolValue[]>(
|
||
initial.teams.map(({ id, ...team }) => ({ clientId: id, ...team })),
|
||
);
|
||
const [selectedTeamIds, setSelectedTeamIds] = useState<Set<string>>(
|
||
() => new Set(),
|
||
);
|
||
const [sourceTeamId, setSourceTeamId] = useState(teams[0]?.clientId ?? "");
|
||
const [applyOpen, setApplyOpen] = useState(false);
|
||
const [presetOpen, setPresetOpen] = useState(false);
|
||
const [importOpen, setImportOpen] = useState(false);
|
||
const [importValue, setImportValue] = useState("");
|
||
const [importFiles, setImportFiles] = useState<File[]>([]);
|
||
const [presetName, setPresetName] = useState("");
|
||
const [presets, setPresets] = useState<TeamPreset[]>([]);
|
||
const [presetsHydrated, setPresetsHydrated] = useState(false);
|
||
const [presetsGuideId, setPresetsGuideId] = useState<string | null>(null);
|
||
const [applyFields, setApplyFields] = useState<TeamApplySelection[]>([]);
|
||
const [searchQuery, setSearchQuery] = useState("");
|
||
const [searchTarget, setSearchTarget] = useState<TeamSearchTarget>("all");
|
||
const filteredTeams = useMemo(() => {
|
||
const characterNames = new Map(characters.map((item) => [item.key, item.name]));
|
||
const weaponNames = new Map(weapons.map((item) => [item.key, item.name]));
|
||
const artifactNames = new Map(artifacts.map((item) => [item.key, item.name]));
|
||
|
||
return teams
|
||
.map((team, teamIndex) => ({ team, teamIndex }))
|
||
.filter(({ team }) => matchesTeamSearch({
|
||
team: [team.title],
|
||
character: team.members.flatMap((member) => {
|
||
const name = characterNames.get(member.characterKey);
|
||
return name ? [name] : [];
|
||
}),
|
||
weapon: team.members.flatMap((member) => {
|
||
const name = member.weaponKey
|
||
? weaponNames.get(member.weaponKey)
|
||
: undefined;
|
||
return name ? [name] : [];
|
||
}),
|
||
artifact: team.members.flatMap((member) =>
|
||
[member.artifactKey, member.secondArtifactKey].flatMap((key) => {
|
||
const name = key ? artifactNames.get(key) : undefined;
|
||
return name ? [name] : [];
|
||
}),
|
||
),
|
||
}, searchQuery, searchTarget));
|
||
}, [artifacts, characters, searchQuery, searchTarget, teams, weapons]);
|
||
const value = {
|
||
teams: teams.map((team) => ({
|
||
title: team.title,
|
||
rotationSeconds: Number(team.rotationSeconds),
|
||
combo: team.combo,
|
||
members: team.members.map((member) => ({
|
||
...member,
|
||
damage: Number(member.damage),
|
||
})),
|
||
})),
|
||
};
|
||
const validation = teamSectionSchema.safeParse({
|
||
...value,
|
||
expectedVersion: guide.version,
|
||
});
|
||
const teamFieldInvalid = (teamIndex: number, field: string) =>
|
||
!validation.success && validation.error.issues.some((issue) =>
|
||
issue.path[0] === "teams" && issue.path[1] === teamIndex && issue.path[2] === field,
|
||
);
|
||
const memberFieldInvalid = (teamIndex: number, memberIndex: number, field: string) =>
|
||
!validation.success && validation.error.issues.some((issue) =>
|
||
issue.path[0] === "teams" && issue.path[1] === teamIndex && issue.path[2] === "members" &&
|
||
issue.path[3] === memberIndex && issue.path[4] === field,
|
||
);
|
||
const localDraft = { teams };
|
||
useEffect(() => {
|
||
const timer = window.setTimeout(() => {
|
||
try {
|
||
setPresets(parseTeamPresets(window.localStorage.getItem(teamPresetStorageKey(guide.id))));
|
||
} catch {
|
||
setPresets([]);
|
||
}
|
||
setPresetsHydrated(true);
|
||
setPresetsGuideId(guide.id);
|
||
}, 0);
|
||
return () => window.clearTimeout(timer);
|
||
}, [guide.id]);
|
||
useEffect(() => {
|
||
if (!presetsHydrated || presetsGuideId !== guide.id) return;
|
||
try {
|
||
window.localStorage.setItem(teamPresetStorageKey(guide.id), JSON.stringify(presets));
|
||
document.cookie = `${teamPresetCookieKey(guide.id)}=${encodeURIComponent(JSON.stringify(presets.map(({ id, name, createdAt }) => ({ id, name, createdAt })) ))}; path=/; max-age=${60 * 60 * 24 * 365}; SameSite=Lax`;
|
||
} catch {
|
||
// Browser storage is optional; the editor remains usable without it.
|
||
}
|
||
}, [guide.id, presets, presetsGuideId, presetsHydrated]);
|
||
const autosave = useAutosave({
|
||
value,
|
||
valid: validation.success,
|
||
initialVersion: guide.version,
|
||
persist: (next, expectedVersion) =>
|
||
saveTeamSection(guide.id, { ...next, expectedVersion }),
|
||
storageKey: adminDraftKey(guide.id, "team"),
|
||
localDraft,
|
||
parseLocalDraft: (candidate) =>
|
||
parseEditorDraft<typeof localDraft>(candidate, ["teams"]),
|
||
restoreLocalDraft: (draft) => setTeams(draft.teams),
|
||
});
|
||
const handleTeamDragEnd = ({ active, over }: DragEndEvent) => {
|
||
if (!over || active.id === over.id) return;
|
||
setTeams((current) => {
|
||
const from = current.findIndex((team) => team.clientId === active.id);
|
||
const to = current.findIndex((team) => team.clientId === over.id);
|
||
return from === -1 || to === -1 ? current : arrayMove(current, from, to);
|
||
});
|
||
};
|
||
const handleTeamMemberDragEnd = (
|
||
teamId: string,
|
||
{ active, over }: DragEndEvent,
|
||
) => {
|
||
if (!over || active.id === over.id) return;
|
||
setTeams((current) =>
|
||
current.map((team) => {
|
||
if (team.clientId !== teamId) return team;
|
||
const memberIds = team.members.map(
|
||
(_, index) => `${team.clientId}-member-${index}`,
|
||
);
|
||
const from = memberIds.indexOf(String(active.id));
|
||
const to = memberIds.indexOf(String(over.id));
|
||
return from === -1 || to === -1
|
||
? team
|
||
: { ...team, members: arrayMove(team.members, from, to) };
|
||
}),
|
||
);
|
||
};
|
||
const toggleTeamSelection = (teamId: string, checked: boolean) => {
|
||
setSelectedTeamIds((current) => {
|
||
const next = new Set(current);
|
||
if (checked) next.add(teamId);
|
||
else next.delete(teamId);
|
||
return next;
|
||
});
|
||
};
|
||
const toggleApplyField = (field: TeamApplySelection, checked: boolean) => {
|
||
setApplyFields((current) => {
|
||
const next = new Set(current);
|
||
if (checked) next.add(field);
|
||
else next.delete(field);
|
||
return [...next];
|
||
});
|
||
};
|
||
const applyChanges = () => {
|
||
if (!sourceTeamId || !selectedTeamIds.size || !applyFields.length) return;
|
||
const nextTeams = applyTeamFields(
|
||
teams,
|
||
sourceTeamId,
|
||
selectedTeamIds,
|
||
applyFields,
|
||
);
|
||
const unsupported = findUnsupportedTeamWeapon(
|
||
nextTeams,
|
||
selectedTeamIds,
|
||
characters,
|
||
weapons,
|
||
);
|
||
if (unsupported) {
|
||
toast.error(
|
||
`${unsupported.characterName} ไม่รองรับอาวุธ ${unsupported.weaponName}`,
|
||
);
|
||
return;
|
||
}
|
||
setTeams(nextTeams);
|
||
setSelectedTeamIds(new Set());
|
||
setApplyOpen(false);
|
||
};
|
||
const savePreset = () => {
|
||
const source = teams.find((team) => team.clientId === sourceTeamId);
|
||
const name = presetName.trim();
|
||
if (!source || !name) return;
|
||
const preset: TeamPreset = {
|
||
id: createClientId("preset"),
|
||
name,
|
||
createdAt: new Date().toISOString(),
|
||
team: {
|
||
title: source.title,
|
||
rotationSeconds: source.rotationSeconds,
|
||
combo: source.combo,
|
||
members: source.members.map((member) => ({ ...member })),
|
||
},
|
||
};
|
||
setPresets((current) => limitPresets([preset, ...current]));
|
||
setPresetName("");
|
||
setPresetOpen(false);
|
||
};
|
||
const applyPreset = (preset: TeamPreset) => {
|
||
if (!selectedTeamIds.size || !applyFields.length) return;
|
||
const nextTeams = applyPresetFields(
|
||
teams,
|
||
preset,
|
||
selectedTeamIds,
|
||
applyFields,
|
||
);
|
||
const unsupported = findUnsupportedTeamWeapon(
|
||
nextTeams,
|
||
selectedTeamIds,
|
||
characters,
|
||
weapons,
|
||
);
|
||
if (unsupported) {
|
||
toast.error(
|
||
`${unsupported.characterName} ไม่รองรับอาวุธ ${unsupported.weaponName}`,
|
||
);
|
||
return;
|
||
}
|
||
setTeams(nextTeams);
|
||
setSelectedTeamIds(new Set());
|
||
};
|
||
const exportTeam = async (team: TeamToolValue) => {
|
||
let serialized: string;
|
||
try {
|
||
serialized = serializeTeamTransfer(team);
|
||
} catch {
|
||
toast.error("Export Team ไม่สำเร็จ กรุณาตรวจสอบข้อมูล Team");
|
||
return;
|
||
}
|
||
const url = URL.createObjectURL(new Blob([serialized], { type: "application/json" }));
|
||
const link = document.createElement("a");
|
||
link.href = url;
|
||
link.download = teamTransferFileName(team.title);
|
||
link.click();
|
||
URL.revokeObjectURL(url);
|
||
try {
|
||
await navigator.clipboard.writeText(serialized);
|
||
toast.success(`คัดลอกและดาวน์โหลด ${team.title} แล้ว`);
|
||
} catch {
|
||
toast.success(`ดาวน์โหลด ${teamTransferFileName(team.title)} แล้ว`);
|
||
}
|
||
};
|
||
const importTeam = async () => {
|
||
try {
|
||
const serializedTeams = [
|
||
...(importValue.trim() ? [importValue] : []),
|
||
...(await Promise.all(importFiles.map((file) => file.text()))),
|
||
];
|
||
if (!serializedTeams.length) return;
|
||
if (teams.length + serializedTeams.length > 30) {
|
||
toast.error("Import ไม่ได้ เพราะ Guide จะมี Team เกิน 30 Team");
|
||
return;
|
||
}
|
||
const importedTeams = serializedTeams.map(parseTeamTransfer);
|
||
for (const imported of importedTeams) {
|
||
const catalogIssue = findInvalidTeamCatalogReference(
|
||
imported,
|
||
characters,
|
||
weapons,
|
||
artifacts,
|
||
);
|
||
if (catalogIssue) {
|
||
toast.error(catalogIssue);
|
||
return;
|
||
}
|
||
}
|
||
setTeams((current) => [
|
||
...current,
|
||
...importedTeams.map((imported) => ({
|
||
clientId: createClientId("team"),
|
||
...imported,
|
||
members: imported.members.map((member) => ({ ...member })),
|
||
})),
|
||
]);
|
||
setImportValue("");
|
||
setImportFiles([]);
|
||
setImportOpen(false);
|
||
toast.success(`Import ${importedTeams.length} Team แล้ว`);
|
||
} catch {
|
||
toast.error("มีไฟล์หรือ JSON ของ Team ที่ไม่ถูกต้องหรือเป็นเวอร์ชันที่ไม่รองรับ");
|
||
}
|
||
};
|
||
return (
|
||
<Card>
|
||
<CardHeader>
|
||
<CardTitle>Team Calc</CardTitle>
|
||
<CardDescription>
|
||
ลากเพื่อเรียง Team แล้วใส่ความเสียหายของตัวละคร 4 ตัว
|
||
ระบบจะแสดงสัดส่วนและ Total DPS และบันทึกอัตโนมัติ
|
||
</CardDescription>
|
||
</CardHeader>
|
||
<CardContent>
|
||
<div className="flex min-w-0 flex-wrap items-center gap-2 rounded-lg border bg-muted/30 p-3">
|
||
<Checkbox
|
||
checked={selectedTeamIds.size === teams.length && teams.length > 0}
|
||
onCheckedChange={(checked) => setSelectedTeamIds(checked ? new Set(teams.map((team) => team.clientId)) : new Set())}
|
||
aria-label="เลือกทุก Team"
|
||
/>
|
||
<span className="text-sm font-medium">เลือก Team สำหรับ Multiple Apply</span>
|
||
<Select value={sourceTeamId} onValueChange={(value) => { if (value) setSourceTeamId(value); }}>
|
||
<SelectTrigger className="max-w-full"><span>{teams.find((team) => team.clientId === sourceTeamId)?.title || "Source Team"}</span></SelectTrigger>
|
||
<SelectContent>
|
||
<SelectGroup>{teams.map((team) => <SelectItem key={team.clientId} value={team.clientId}>{team.title || "Untitled Team"}</SelectItem>)}</SelectGroup>
|
||
</SelectContent>
|
||
</Select>
|
||
<Button type="button" size="sm" disabled={!selectedTeamIds.size} onClick={() => setApplyOpen(true)}>Multiple Apply</Button>
|
||
<Button type="button" variant="outline" size="sm" disabled={!sourceTeamId} onClick={() => setPresetOpen(true)}>Save preset</Button>
|
||
{presets.map((preset) => (
|
||
<div key={preset.id} className="flex min-w-0 max-w-full items-center gap-1">
|
||
<Button className="min-w-0 max-w-full truncate" type="button" variant="ghost" size="sm" disabled={!selectedTeamIds.size} onClick={() => applyPreset(preset)}>{preset.name}</Button>
|
||
<SimpleTooltip text={`ลบ preset ${preset.name}`}>
|
||
<Button type="button" variant="ghost" size="icon-sm" onClick={() => setPresets((current) => current.filter((item) => item.id !== preset.id))} aria-label={`ลบ preset ${preset.name}`}><XIcon /></Button>
|
||
</SimpleTooltip>
|
||
</div>
|
||
))}
|
||
</div>
|
||
<FieldGroup className="mt-4">
|
||
<Field>
|
||
<FieldLabel htmlFor="team-search">ค้นหา Team</FieldLabel>
|
||
<div className="relative">
|
||
<SearchIcon
|
||
aria-hidden="true"
|
||
className="pointer-events-none absolute top-1/2 left-3 size-4 -translate-y-1/2 text-muted-foreground"
|
||
/>
|
||
<Input
|
||
id="team-search"
|
||
className="pl-9"
|
||
placeholder="ค้นหาชื่อทีม ตัวละคร อาวุธ หรืออาร์ติแฟกต์"
|
||
value={searchQuery}
|
||
onValueChange={setSearchQuery}
|
||
/>
|
||
</div>
|
||
</Field>
|
||
<Field>
|
||
<FieldLabel>ค้นหาใน</FieldLabel>
|
||
<ToggleGroup
|
||
aria-label="เลือกประเภทข้อมูลที่ต้องการค้นหา"
|
||
className="max-w-full flex-wrap justify-start"
|
||
variant="outline"
|
||
size="sm"
|
||
value={[searchTarget]}
|
||
onValueChange={(value) => {
|
||
const target = value[0] as TeamSearchTarget | undefined;
|
||
if (target) setSearchTarget(target);
|
||
}}
|
||
>
|
||
<ToggleGroupItem value="all">ทั้งหมด</ToggleGroupItem>
|
||
<ToggleGroupItem value="team">ชื่อทีม</ToggleGroupItem>
|
||
<ToggleGroupItem value="character">ตัวละคร</ToggleGroupItem>
|
||
<ToggleGroupItem value="weapon">อาวุธ</ToggleGroupItem>
|
||
<ToggleGroupItem value="artifact">อาร์ติแฟกต์</ToggleGroupItem>
|
||
</ToggleGroup>
|
||
</Field>
|
||
</FieldGroup>
|
||
<DndContext
|
||
sensors={teamSensors}
|
||
collisionDetection={closestCenter}
|
||
onDragEnd={handleTeamDragEnd}
|
||
>
|
||
<SortableContext
|
||
items={filteredTeams.map(({ team }) => team.clientId)}
|
||
strategy={verticalListSortingStrategy}
|
||
>
|
||
<Accordion
|
||
multiple
|
||
defaultValue={teams.length ? [teams[0].clientId] : []}
|
||
className="gap-3"
|
||
>
|
||
{filteredTeams.map(({ team, teamIndex }) => {
|
||
const total = team.members.reduce(
|
||
(sum, member) => sum + (Number(member.damage) || 0),
|
||
0,
|
||
);
|
||
const dps = total / (Number(team.rotationSeconds) || 1);
|
||
return (
|
||
<SortableTeamItem key={team.clientId} id={team.clientId}>
|
||
{(dragHandle) => (
|
||
<>
|
||
<div className="flex w-full items-center gap-2">
|
||
<Checkbox
|
||
checked={selectedTeamIds.has(team.clientId)}
|
||
onCheckedChange={(checked) => toggleTeamSelection(team.clientId, checked === true)}
|
||
onClick={(event) => event.stopPropagation()}
|
||
aria-label={`เลือก ${team.title || `Team ${teamIndex + 1}`}`}
|
||
/>
|
||
<AccordionTrigger
|
||
headerClassName="min-w-0 flex-1"
|
||
className="min-w-0 flex-1 py-4 hover:no-underline"
|
||
>
|
||
<span className="flex min-w-0 flex-col gap-1">
|
||
<span className="font-semibold">
|
||
{team.title || `Team ${teamIndex + 1}`}
|
||
</span>
|
||
<span className="text-xs font-normal text-muted-foreground">
|
||
Damage {total.toLocaleString()} • DPS{" "}
|
||
{dps.toFixed(0)}
|
||
</span>
|
||
</span>
|
||
</AccordionTrigger>
|
||
<div className="ml-auto flex shrink-0 items-center gap-1">
|
||
{dragHandle}
|
||
<SimpleTooltip text="Export Team">
|
||
<Button
|
||
type="button"
|
||
variant="ghost"
|
||
size="icon-sm"
|
||
onClick={() => void exportTeam(team)}
|
||
>
|
||
<ClipboardCopyIcon />
|
||
<span className="sr-only">Export {team.title || `Team ${teamIndex + 1}`}</span>
|
||
</Button>
|
||
</SimpleTooltip>
|
||
<SimpleTooltip text="ทำสำเนา Team">
|
||
<Button
|
||
type="button"
|
||
variant="ghost"
|
||
size="icon-sm"
|
||
disabled={teams.length >= 30}
|
||
onClick={() =>
|
||
setTeams((current) => {
|
||
const sourceIndex = current.findIndex(
|
||
(item) => item.clientId === team.clientId,
|
||
);
|
||
if (
|
||
sourceIndex === -1 ||
|
||
current.length >= 30
|
||
)
|
||
return current;
|
||
const source = current[sourceIndex];
|
||
const title = source.title.trim()
|
||
? `${source.title.slice(0, 153)} (Copy)`
|
||
: `Team ${sourceIndex + 1} (Copy)`;
|
||
const copy = {
|
||
...source,
|
||
clientId: createClientId("team"),
|
||
title,
|
||
members: source.members.map((member) => ({
|
||
...member,
|
||
})),
|
||
};
|
||
return current.toSpliced(
|
||
sourceIndex + 1,
|
||
0,
|
||
copy,
|
||
);
|
||
})
|
||
}
|
||
>
|
||
<CopyIcon />
|
||
<span className="sr-only">ทำสำเนา Team</span>
|
||
</Button>
|
||
</SimpleTooltip>
|
||
<DeleteConfirmationDialog
|
||
triggerTooltip="ลบ Team"
|
||
trigger={
|
||
<Button
|
||
type="button"
|
||
variant="ghost"
|
||
size="icon-sm"
|
||
/>
|
||
}
|
||
title={`ลบ ${team.title || `Team ${teamIndex + 1}`}?`}
|
||
description="ข้อมูล Team และตัวละครทั้ง 4 คนจะถูกลบออกจาก Guide"
|
||
confirmLabel="ยืนยันลบ Team"
|
||
onConfirm={() =>
|
||
setTeams(
|
||
teams.filter(
|
||
(item) => item.clientId !== team.clientId,
|
||
),
|
||
)
|
||
}
|
||
>
|
||
<Trash2Icon />
|
||
<span className="sr-only">ลบ Team</span>
|
||
</DeleteConfirmationDialog>
|
||
</div>
|
||
</div>
|
||
<AccordionContent className="pb-4" data-invalid={teamFieldInvalid(teamIndex, "members")}>
|
||
<FieldGroup>
|
||
<Field data-invalid={teamFieldInvalid(teamIndex, "title")}>
|
||
<FieldLabel className="flex items-center gap-2">
|
||
<UserRoundIcon />
|
||
ชื่อ Team
|
||
</FieldLabel>
|
||
<Input
|
||
value={team.title}
|
||
aria-invalid={teamFieldInvalid(teamIndex, "title")}
|
||
onChange={(event) =>
|
||
setTeams(
|
||
teams.map((item, index) =>
|
||
index === teamIndex
|
||
? { ...item, title: event.target.value }
|
||
: item,
|
||
),
|
||
)
|
||
}
|
||
/>
|
||
</Field>
|
||
<DndContext
|
||
sensors={teamSensors}
|
||
collisionDetection={closestCenter}
|
||
onDragEnd={(event) =>
|
||
handleTeamMemberDragEnd(team.clientId, event)
|
||
}
|
||
>
|
||
<SortableContext
|
||
items={team.members.map(
|
||
(_, index) =>
|
||
`${team.clientId}-member-${index}`,
|
||
)}
|
||
strategy={rectSortingStrategy}
|
||
>
|
||
<div className="grid gap-4 md:grid-cols-2">
|
||
{team.members.map((member, memberIndex) => {
|
||
const character = characters.find(
|
||
(item) => item.key === member.characterKey,
|
||
);
|
||
const compatibleWeapons = weapons.filter(
|
||
(weapon) =>
|
||
supportsWeaponType(
|
||
character?.weaponType,
|
||
weapon.weaponType,
|
||
),
|
||
);
|
||
return (
|
||
<SortableTeamMember
|
||
key={`${team.clientId}-member-${memberIndex}`}
|
||
id={`${team.clientId}-member-${memberIndex}`}
|
||
>
|
||
{(memberDragHandle) => (
|
||
<Card size="sm" data-invalid={memberFieldInvalid(teamIndex, memberIndex, "members")}>
|
||
<CardHeader>
|
||
<CardTitle>
|
||
ตัวละคร {memberIndex + 1}
|
||
</CardTitle>
|
||
<CardDescription>
|
||
{total
|
||
? `${(((Number(member.damage) || 0) / total) * 100).toFixed(2)}% ของ Team`
|
||
: "0% ของ Team"}
|
||
</CardDescription>
|
||
<CardAction className="flex items-center gap-1">
|
||
{character ? (
|
||
<Avatar>
|
||
<AvatarImage
|
||
src={character.imageUrl}
|
||
alt={character.name}
|
||
/>
|
||
<AvatarFallback>
|
||
{character.name
|
||
.slice(0, 2)
|
||
.toUpperCase()}
|
||
</AvatarFallback>
|
||
</Avatar>
|
||
) : null}
|
||
{memberDragHandle}
|
||
</CardAction>
|
||
</CardHeader>
|
||
<CardContent>
|
||
<FieldGroup>
|
||
<Field data-invalid={memberFieldInvalid(teamIndex, memberIndex, "characterKey")}>
|
||
<FieldLabel className="flex items-center gap-2">
|
||
<UserRoundIcon />
|
||
ตัวละคร
|
||
</FieldLabel>
|
||
<CatalogPicker
|
||
items={characters}
|
||
value={member.characterKey}
|
||
invalid={memberFieldInvalid(teamIndex, memberIndex, "characterKey")}
|
||
placeholder="ค้นหาชื่อตัวละคร…"
|
||
onChange={(characterKey) =>
|
||
setTeams(
|
||
teams.map((item, index) =>
|
||
index === teamIndex
|
||
? {
|
||
...item,
|
||
members:
|
||
item.members.map(
|
||
(
|
||
candidate,
|
||
inner,
|
||
) =>
|
||
inner ===
|
||
memberIndex
|
||
? {
|
||
...candidate,
|
||
characterKey,
|
||
weaponKey:
|
||
null,
|
||
artifactKey:
|
||
null,
|
||
secondArtifactKey:
|
||
null,
|
||
constellation: 0,
|
||
refinement: 1,
|
||
}
|
||
: candidate,
|
||
),
|
||
}
|
||
: item,
|
||
),
|
||
)
|
||
}
|
||
/>
|
||
</Field>
|
||
<Field data-invalid={memberFieldInvalid(teamIndex, memberIndex, "damage")}>
|
||
<FieldLabel className="flex items-center gap-2">
|
||
<ChartNoAxesCombinedIcon />
|
||
Damage
|
||
</FieldLabel>
|
||
<Input
|
||
type="number"
|
||
min="0"
|
||
step="0.01"
|
||
value={member.damage}
|
||
aria-invalid={memberFieldInvalid(teamIndex, memberIndex, "damage")}
|
||
onChange={(event) =>
|
||
setTeams(
|
||
teams.map((item, index) =>
|
||
index === teamIndex
|
||
? {
|
||
...item,
|
||
members:
|
||
item.members.map(
|
||
(
|
||
candidate,
|
||
inner,
|
||
) =>
|
||
inner ===
|
||
memberIndex
|
||
? {
|
||
...candidate,
|
||
damage:
|
||
event
|
||
.target
|
||
.value,
|
||
}
|
||
: candidate,
|
||
),
|
||
}
|
||
: item,
|
||
),
|
||
)
|
||
}
|
||
/>
|
||
</Field>
|
||
<Field>
|
||
<FieldLabel className="flex items-center gap-2">
|
||
<SparklesIcon />
|
||
Constellation
|
||
</FieldLabel>
|
||
<ToggleGroup
|
||
className="flex-wrap justify-start"
|
||
variant="outline"
|
||
value={[`C${member.constellation}`]}
|
||
onValueChange={(selected) => {
|
||
const constellation = selected[0];
|
||
if (constellation)
|
||
setTeams(
|
||
teams.map((item, index) =>
|
||
index === teamIndex
|
||
? {
|
||
...item,
|
||
members:
|
||
item.members.map(
|
||
(
|
||
candidate,
|
||
inner,
|
||
) =>
|
||
inner ===
|
||
memberIndex
|
||
? {
|
||
...candidate,
|
||
constellation:
|
||
Number(
|
||
constellation.slice(
|
||
1,
|
||
),
|
||
),
|
||
}
|
||
: candidate,
|
||
),
|
||
}
|
||
: item,
|
||
),
|
||
);
|
||
}}
|
||
>
|
||
{[0, 1, 2, 3, 4, 5, 6].map(
|
||
(constellation) => (
|
||
<ToggleGroupItem
|
||
className={
|
||
SELECTED_TOGGLE_CLASS
|
||
}
|
||
key={constellation}
|
||
value={`C${constellation}`}
|
||
>
|
||
C{constellation}
|
||
</ToggleGroupItem>
|
||
),
|
||
)}
|
||
</ToggleGroup>
|
||
</Field>
|
||
<div className="grid grid-cols-[minmax(0,1fr)_auto] items-end gap-4">
|
||
<Field className="min-w-0">
|
||
<FieldLabel className="flex items-center gap-2">
|
||
<SwordIcon />
|
||
Weapon
|
||
</FieldLabel>
|
||
<CatalogPicker
|
||
items={compatibleWeapons}
|
||
value={member.weaponKey ?? ""}
|
||
placeholder="ค้นหาอาวุธที่รองรับ…"
|
||
rarityBackground
|
||
onChange={(weaponKey) =>
|
||
setTeams(
|
||
teams.map((item, index) =>
|
||
index === teamIndex
|
||
? {
|
||
...item,
|
||
members:
|
||
item.members.map(
|
||
(
|
||
candidate,
|
||
inner,
|
||
) =>
|
||
inner ===
|
||
memberIndex
|
||
? {
|
||
...candidate,
|
||
weaponKey:
|
||
weaponKey ||
|
||
null,
|
||
}
|
||
: candidate,
|
||
),
|
||
}
|
||
: item,
|
||
),
|
||
)
|
||
}
|
||
/>
|
||
</Field>
|
||
<Field className="shrink-0">
|
||
<FieldLabel className="flex items-center gap-2">
|
||
<RefreshCcwIcon />
|
||
Refinement
|
||
</FieldLabel>
|
||
<ToggleGroup
|
||
className="flex-nowrap"
|
||
variant="outline"
|
||
value={[`R${member.refinement}`]}
|
||
onValueChange={(selected) => {
|
||
const refinement = selected[0];
|
||
if (refinement)
|
||
setTeams(
|
||
teams.map((item, index) =>
|
||
index === teamIndex
|
||
? {
|
||
...item,
|
||
members:
|
||
item.members.map(
|
||
(
|
||
candidate,
|
||
inner,
|
||
) =>
|
||
inner ===
|
||
memberIndex
|
||
? {
|
||
...candidate,
|
||
refinement:
|
||
Number(
|
||
refinement.slice(
|
||
1,
|
||
),
|
||
),
|
||
}
|
||
: candidate,
|
||
),
|
||
}
|
||
: item,
|
||
),
|
||
);
|
||
}}
|
||
>
|
||
{[1, 2, 3, 4, 5].map(
|
||
(refinement) => (
|
||
<ToggleGroupItem
|
||
className={
|
||
SELECTED_TOGGLE_CLASS
|
||
}
|
||
key={refinement}
|
||
value={`R${refinement}`}
|
||
>
|
||
R{refinement}
|
||
</ToggleGroupItem>
|
||
),
|
||
)}
|
||
</ToggleGroup>
|
||
</Field>
|
||
</div>
|
||
<div className="grid grid-cols-2 gap-4">
|
||
<Field>
|
||
<FieldLabel className="flex items-center gap-2">
|
||
<GemIcon />
|
||
Artifact set 1
|
||
</FieldLabel>
|
||
<CatalogPicker
|
||
items={artifacts}
|
||
value={member.artifactKey ?? ""}
|
||
placeholder="ค้นหาชุด Artifact…"
|
||
rarityBackground
|
||
onChange={(artifactKey) =>
|
||
setTeams(
|
||
teams.map((item, index) =>
|
||
index === teamIndex
|
||
? {
|
||
...item,
|
||
members:
|
||
item.members.map(
|
||
(
|
||
candidate,
|
||
inner,
|
||
) =>
|
||
inner ===
|
||
memberIndex
|
||
? {
|
||
...candidate,
|
||
artifactKey:
|
||
artifactKey ||
|
||
null,
|
||
}
|
||
: candidate,
|
||
),
|
||
}
|
||
: item,
|
||
),
|
||
)
|
||
}
|
||
/>
|
||
</Field>
|
||
<Field>
|
||
<FieldLabel className="flex items-center gap-2">
|
||
<GemIcon />
|
||
Artifact set 2
|
||
</FieldLabel>
|
||
<CatalogPicker
|
||
items={artifacts}
|
||
value={
|
||
member.secondArtifactKey ?? ""
|
||
}
|
||
placeholder="ค้นหาชุด Artifact…"
|
||
rarityBackground
|
||
onChange={(secondArtifactKey) =>
|
||
setTeams(
|
||
teams.map((item, index) =>
|
||
index === teamIndex
|
||
? {
|
||
...item,
|
||
members:
|
||
item.members.map(
|
||
(
|
||
candidate,
|
||
inner,
|
||
) =>
|
||
inner ===
|
||
memberIndex
|
||
? {
|
||
...candidate,
|
||
secondArtifactKey:
|
||
secondArtifactKey ||
|
||
null,
|
||
}
|
||
: candidate,
|
||
),
|
||
}
|
||
: item,
|
||
),
|
||
)
|
||
}
|
||
/>
|
||
</Field>
|
||
</div>
|
||
</FieldGroup>
|
||
</CardContent>
|
||
</Card>
|
||
)}
|
||
</SortableTeamMember>
|
||
);
|
||
})}
|
||
</div>
|
||
</SortableContext>
|
||
</DndContext>
|
||
<Field data-invalid={teamFieldInvalid(teamIndex, "rotationSeconds")}>
|
||
<FieldLabel className="flex items-center gap-2">
|
||
<TimerIcon />
|
||
Rotation (seconds)
|
||
</FieldLabel>
|
||
<Input
|
||
type="number"
|
||
min="0.01"
|
||
step="0.01"
|
||
value={team.rotationSeconds}
|
||
aria-invalid={teamFieldInvalid(teamIndex, "rotationSeconds")}
|
||
onChange={(event) =>
|
||
setTeams(
|
||
teams.map((item, index) =>
|
||
index === teamIndex
|
||
? {
|
||
...item,
|
||
rotationSeconds: event.target.value,
|
||
}
|
||
: item,
|
||
),
|
||
)
|
||
}
|
||
/>
|
||
</Field>
|
||
<Field data-invalid={teamFieldInvalid(teamIndex, "combo")}>
|
||
<FieldLabel className="flex items-center gap-2">
|
||
<ListOrderedIcon />
|
||
Combo
|
||
</FieldLabel>
|
||
<Textarea
|
||
value={team.combo}
|
||
aria-invalid={teamFieldInvalid(teamIndex, "combo")}
|
||
onChange={(event) =>
|
||
setTeams(
|
||
teams.map((item, index) =>
|
||
index === teamIndex
|
||
? { ...item, combo: event.target.value }
|
||
: item,
|
||
),
|
||
)
|
||
}
|
||
/>
|
||
</Field>
|
||
</FieldGroup>
|
||
</AccordionContent>
|
||
</>
|
||
)}
|
||
</SortableTeamItem>
|
||
);
|
||
})}
|
||
{filteredTeams.length === 0 ? (
|
||
<p className="py-8 text-center text-sm text-muted-foreground">
|
||
ไม่พบ Team ที่ตรงกับคำค้นหา
|
||
</p>
|
||
) : null}
|
||
</Accordion>
|
||
</SortableContext>
|
||
</DndContext>
|
||
</CardContent>
|
||
<CardFooter className="flex-wrap justify-between gap-3">
|
||
<Button
|
||
variant="outline"
|
||
disabled={characters.length < 4}
|
||
onClick={() =>
|
||
setTeams([
|
||
...teams,
|
||
{
|
||
clientId: createClientId("team"),
|
||
title: "",
|
||
rotationSeconds: "20",
|
||
combo: "",
|
||
members: fallbackMembers,
|
||
},
|
||
])
|
||
}
|
||
>
|
||
<PlusIcon data-icon="inline-start" />
|
||
เพิ่ม Team
|
||
</Button>
|
||
<Button
|
||
type="button"
|
||
variant="outline"
|
||
disabled={teams.length >= 30}
|
||
onClick={() => setImportOpen(true)}
|
||
>
|
||
<ClipboardPasteIcon data-icon="inline-start" />
|
||
Import Team
|
||
</Button>
|
||
<AutosaveStatus
|
||
state={autosave.state}
|
||
issues={
|
||
validation.success
|
||
? []
|
||
: formatGuideValidationIssues("team", validation.error.issues, {
|
||
teamNames: teams.map((team) => team.title),
|
||
})
|
||
}
|
||
onSave={autosave.saveNow}
|
||
/>
|
||
</CardFooter>
|
||
<Dialog open={applyOpen} onOpenChange={setApplyOpen}>
|
||
<DialogContent className="max-h-[90svh] grid-rows-[auto_minmax(0,1fr)_auto] overflow-hidden sm:max-w-3xl">
|
||
<DialogHeader>
|
||
<DialogTitle>Multiple Apply</DialogTitle>
|
||
<DialogDescription className="break-words">คัดลอกค่าจาก {teams.find((team) => team.clientId === sourceTeamId)?.title || "Source Team"} ไปยัง {selectedTeamIds.size} Team ที่เลือก: {[...selectedTeamIds].map((id) => teams.find((team) => team.clientId === id)?.title || "Untitled Team").join(", ")}</DialogDescription>
|
||
</DialogHeader>
|
||
<div className="flex min-h-0 flex-col gap-5 overflow-y-auto pr-1">
|
||
{teams.find((team) => team.clientId === sourceTeamId)?.members.map((member, memberIndex) => {
|
||
const characterName = characters.find((character) => character.key === member.characterKey)?.name || "No character";
|
||
return (
|
||
<FieldSet key={memberIndex}>
|
||
<FieldLegend>{characterName} (char{memberIndex + 1})</FieldLegend>
|
||
<div className="grid gap-3 sm:grid-cols-4">
|
||
{teamMemberApplyOptions.map(([field, label]) => {
|
||
const selection = `member:${memberIndex}:${field}` as TeamApplySelection;
|
||
const id = `apply-${memberIndex}-${field}`;
|
||
return (
|
||
<Field key={field} orientation="horizontal">
|
||
<Checkbox id={id} checked={applyFields.includes(selection)} onCheckedChange={(checked) => toggleApplyField(selection, checked === true)} />
|
||
<FieldLabel htmlFor={id}>{label}{field === "refinement" ? ` (R${member.refinement})` : ""}</FieldLabel>
|
||
</Field>
|
||
);
|
||
})}
|
||
</div>
|
||
</FieldSet>
|
||
);
|
||
})}
|
||
<FieldSet>
|
||
<FieldLegend>Team fields</FieldLegend>
|
||
<div className="grid gap-3 sm:grid-cols-2">
|
||
{(["rotation", "combo"] as const).map((field) => {
|
||
const id = `apply-${field}`;
|
||
return (
|
||
<Field key={field} orientation="horizontal">
|
||
<Checkbox id={id} checked={applyFields.includes(field)} onCheckedChange={(checked) => toggleApplyField(field, checked === true)} />
|
||
<FieldLabel htmlFor={id}>{field === "rotation" ? "Rotation" : "Combo"}</FieldLabel>
|
||
</Field>
|
||
);
|
||
})}
|
||
</div>
|
||
</FieldSet>
|
||
</div>
|
||
<DialogFooter><Button type="button" variant="outline" onClick={() => setApplyOpen(false)}>ยกเลิก</Button><Button type="button" disabled={!applyFields.length} onClick={applyChanges}>ยืนยันการคัดลอก</Button></DialogFooter>
|
||
</DialogContent>
|
||
</Dialog>
|
||
<Dialog open={importOpen} onOpenChange={setImportOpen}>
|
||
<DialogContent className="max-h-[90svh] grid-rows-[auto_minmax(0,1fr)_auto] overflow-hidden">
|
||
<DialogHeader>
|
||
<DialogTitle>Import Team</DialogTitle>
|
||
<DialogDescription>
|
||
วาง JSON ที่ Export จาก Team ใน Guide อื่น ระบบจะเพิ่ม Team ใหม่ต่อท้ายรายการ
|
||
</DialogDescription>
|
||
</DialogHeader>
|
||
<FieldGroup className="min-h-0 overflow-y-auto pr-1">
|
||
<Field>
|
||
<FieldLabel htmlFor="team-import-json">Team JSON</FieldLabel>
|
||
<Textarea
|
||
id="team-import-json"
|
||
value={importValue}
|
||
rows={12}
|
||
placeholder={'{\n "type": "buzz-sheet.team",\n ...\n}'}
|
||
onChange={(event) => setImportValue(event.target.value)}
|
||
/>
|
||
</Field>
|
||
<Field>
|
||
<FieldLabel htmlFor="team-import-files">เลือกไฟล์ JSON</FieldLabel>
|
||
<Input
|
||
id="team-import-files"
|
||
type="file"
|
||
accept=".json,application/json"
|
||
multiple
|
||
onChange={(event) => setImportFiles(Array.from(event.target.files ?? []))}
|
||
/>
|
||
<FieldDescription>
|
||
เลือกหลายไฟล์เพื่อ Import หลาย Team พร้อมกันได้
|
||
</FieldDescription>
|
||
</Field>
|
||
</FieldGroup>
|
||
<DialogFooter>
|
||
<Button type="button" variant="outline" onClick={() => setImportOpen(false)}>
|
||
ยกเลิก
|
||
</Button>
|
||
<Button type="button" disabled={!importValue.trim() && !importFiles.length} onClick={() => void importTeam()}>
|
||
Import Team
|
||
</Button>
|
||
</DialogFooter>
|
||
</DialogContent>
|
||
</Dialog>
|
||
<Dialog open={presetOpen} onOpenChange={setPresetOpen}>
|
||
<DialogContent>
|
||
<DialogHeader><DialogTitle>Save team preset</DialogTitle><DialogDescription>บันทึก Source Team นี้ไว้ใช้กับ Guide นี้ในเบราว์เซอร์นี้</DialogDescription></DialogHeader>
|
||
<Field><FieldLabel htmlFor="team-preset-name">ชื่อ Preset</FieldLabel><Input id="team-preset-name" value={presetName} onChange={(event) => setPresetName(event.target.value)} /></Field>
|
||
<DialogFooter><Button type="button" variant="outline" onClick={() => setPresetOpen(false)}>ยกเลิก</Button><Button type="button" disabled={!presetName.trim()} onClick={savePreset}>บันทึก</Button></DialogFooter>
|
||
</DialogContent>
|
||
</Dialog>
|
||
</Card>
|
||
);
|
||
}
|
||
|
||
function ExtraCatalogItemsEditor({
|
||
value,
|
||
characters,
|
||
weapons,
|
||
artifacts,
|
||
onChange,
|
||
}: {
|
||
value: ExtraCatalogItem[];
|
||
characters: CatalogItem[];
|
||
weapons: CatalogItem[];
|
||
artifacts: CatalogItem[];
|
||
onChange: (value: ExtraCatalogItem[]) => void;
|
||
}) {
|
||
const [kind, setKind] = useState<ExtraCatalogItem["kind"]>("character");
|
||
const [key, setKey] = useState("");
|
||
const catalogs = { character: characters, weapon: weapons, artifact: artifacts };
|
||
const labels = { character: "ตัวละคร", weapon: "อาวุธ", artifact: "อาร์ติแฟกต์" };
|
||
const items = catalogs[kind];
|
||
|
||
return (
|
||
<Field>
|
||
<FieldLabel>ตัวละคร อาวุธ และอาร์ติแฟกต์</FieldLabel>
|
||
<div className="flex flex-col gap-3">
|
||
{value.length ? (
|
||
<div className="flex flex-col gap-2">
|
||
{value.map((reference, index) => {
|
||
const item = catalogs[reference.kind].find(
|
||
(candidate) => candidate.key === reference.key,
|
||
);
|
||
return (
|
||
<Card key={`${reference.kind}:${reference.key}`} size="sm">
|
||
<CardContent className="flex items-center gap-3">
|
||
{item ? (
|
||
<div className={cn("relative size-12 shrink-0 overflow-hidden rounded-md", rarityGradientClass(item.rarity))}>
|
||
<Image src={item.imageUrl} alt="" fill className="object-contain" />
|
||
</div>
|
||
) : null}
|
||
<div className="min-w-0 flex-1">
|
||
<p className="truncate font-medium">{item?.name ?? reference.key}</p>
|
||
<p className="text-sm text-muted-foreground">{labels[reference.kind]}</p>
|
||
</div>
|
||
<Button type="button" variant="ghost" size="icon-sm" disabled={index === 0} onClick={() => onChange(arrayMove(value, index, index - 1))} aria-label="เลื่อนขึ้น">
|
||
<ChevronUpIcon />
|
||
</Button>
|
||
<Button type="button" variant="ghost" size="icon-sm" disabled={index === value.length - 1} onClick={() => onChange(arrayMove(value, index, index + 1))} aria-label="เลื่อนลง">
|
||
<ChevronDownIcon />
|
||
</Button>
|
||
<Button type="button" variant="ghost" size="icon-sm" onClick={() => onChange(value.filter((_, itemIndex) => itemIndex !== index))} aria-label={`ลบ ${item?.name ?? reference.key}`}>
|
||
<XIcon />
|
||
</Button>
|
||
</CardContent>
|
||
</Card>
|
||
);
|
||
})}
|
||
</div>
|
||
) : null}
|
||
<div className="flex flex-wrap items-center gap-2">
|
||
<Select value={kind} onValueChange={(next) => { setKind(next as ExtraCatalogItem["kind"]); setKey(""); }}>
|
||
<SelectTrigger className="w-40"><SelectValue /></SelectTrigger>
|
||
<SelectContent><SelectGroup>
|
||
<SelectItem value="character">ตัวละคร</SelectItem>
|
||
<SelectItem value="weapon">อาวุธ</SelectItem>
|
||
<SelectItem value="artifact">อาร์ติแฟกต์</SelectItem>
|
||
</SelectGroup></SelectContent>
|
||
</Select>
|
||
<div className="min-w-64 flex-1">
|
||
<CatalogPicker items={items} value={key} onChange={setKey} placeholder={`เลือก${labels[kind]}`} showSelectedImage rarityBackground />
|
||
</div>
|
||
<Button type="button" variant="outline" disabled={!key || value.some((item) => item.kind === kind && item.key === key)} onClick={() => { onChange([...value, { kind, key }]); setKey(""); }}>
|
||
<PlusIcon data-icon="inline-start" />
|
||
เพิ่มรายการ
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
</Field>
|
||
);
|
||
}
|
||
|
||
function ExtraEditor({ guide, initial, characters, weapons, artifacts }: EditorBase) {
|
||
const extraSensors = useSensors(
|
||
useSensor(PointerSensor),
|
||
useSensor(KeyboardSensor, {
|
||
coordinateGetter: sortableKeyboardCoordinates,
|
||
}),
|
||
);
|
||
const [sections, setSections] = useState(() =>
|
||
initial.extras.map((section) => ({
|
||
...section,
|
||
clientId: section.id,
|
||
rows: section.rows.map((row) => ({
|
||
...row,
|
||
body: normalizeExtraRichText(row.body, row.highlights),
|
||
highlights: [],
|
||
clientId: row.id,
|
||
})),
|
||
})),
|
||
);
|
||
const [openSectionIds, setOpenSectionIds] = useState(() =>
|
||
initial.extras.map((section) => section.id),
|
||
);
|
||
const value = {
|
||
sections: sections.map((section) => ({
|
||
title: section.title,
|
||
rows: section.rows.map((row) => ({
|
||
mediaId: row.mediaId,
|
||
heading: row.heading,
|
||
body: row.body,
|
||
percent:
|
||
row.percent === null || row.percent === ""
|
||
? null
|
||
: Number(row.percent),
|
||
backgroundColor: row.backgroundColor,
|
||
textColor: row.textColor,
|
||
catalogItems: row.catalogItems,
|
||
highlights: [],
|
||
})),
|
||
})),
|
||
};
|
||
const validation = extraSectionSchema.safeParse({
|
||
...value,
|
||
expectedVersion: guide.version,
|
||
});
|
||
const extraIssue = (...path: (string | number)[]) =>
|
||
!validation.success && hasIssuePrefix(validation.error.issues, ...path);
|
||
const localDraft = { sections };
|
||
const autosave = useAutosave({
|
||
value,
|
||
valid: validation.success,
|
||
initialVersion: guide.version,
|
||
persist: (next, expectedVersion) =>
|
||
saveExtraSectionList(guide.id, { ...next, expectedVersion }),
|
||
storageKey: adminDraftKey(guide.id, "extra"),
|
||
localDraft,
|
||
parseLocalDraft: (candidate) =>
|
||
parseEditorDraft<typeof localDraft>(candidate, ["sections"]),
|
||
restoreLocalDraft: (draft) => {
|
||
const restoredSections = draft.sections.map((section) => ({
|
||
...section,
|
||
id: section.id || createClientId("extra-section-record"),
|
||
clientId: section.clientId || createClientId("extra-section"),
|
||
rows: section.rows.map((row) => ({
|
||
...row,
|
||
id: row.id || createClientId("extra-row-record"),
|
||
body: normalizeExtraRichText(row.body, row.highlights ?? []),
|
||
backgroundColor: row.backgroundColor ?? null,
|
||
textColor: row.textColor ?? null,
|
||
catalogItems: row.catalogItems ?? [],
|
||
highlights: [],
|
||
clientId: row.clientId || createClientId("extra-row"),
|
||
})),
|
||
}));
|
||
setSections(restoredSections);
|
||
setOpenSectionIds(restoredSections.map((section) => section.clientId));
|
||
},
|
||
});
|
||
const handleSectionDragEnd = ({ active, over }: DragEndEvent) => {
|
||
if (!over || active.id === over.id) return;
|
||
setSections((current) => {
|
||
const from = current.findIndex((section) => section.clientId === active.id);
|
||
const to = current.findIndex((section) => section.clientId === over.id);
|
||
return from === -1 || to === -1 ? current : arrayMove(current, from, to);
|
||
});
|
||
};
|
||
const handleRowDragEnd = (sectionId: string, { active, over }: DragEndEvent) => {
|
||
if (!over || active.id === over.id) return;
|
||
setSections((current) => current.map((section) => {
|
||
if (section.clientId !== sectionId) return section;
|
||
const from = section.rows.findIndex((row) => row.clientId === active.id);
|
||
const to = section.rows.findIndex((row) => row.clientId === over.id);
|
||
return from === -1 || to === -1
|
||
? section
|
||
: { ...section, rows: arrayMove(section.rows, from, to) };
|
||
}));
|
||
};
|
||
return (
|
||
<Card>
|
||
<CardHeader>
|
||
<CardTitle>Extra sections</CardTitle>
|
||
<CardDescription>
|
||
เพิ่มส่วนเสริมแบบภาพ หัวข้อ ข้อความที่จัดรูปแบบได้ และเปอร์เซ็นต์
|
||
ระบบจะบันทึกอัตโนมัติ
|
||
</CardDescription>
|
||
</CardHeader>
|
||
<CardContent className="flex flex-col gap-5">
|
||
<DndContext
|
||
sensors={extraSensors}
|
||
collisionDetection={closestCenter}
|
||
onDragEnd={handleSectionDragEnd}
|
||
>
|
||
<Accordion
|
||
multiple
|
||
value={openSectionIds}
|
||
onValueChange={setOpenSectionIds}
|
||
className="gap-5"
|
||
>
|
||
<SortableContext
|
||
items={sections.map((section) => section.clientId)}
|
||
strategy={verticalListSortingStrategy}
|
||
>
|
||
{sections.map((section, sectionIndex) => (
|
||
<SortableExtraItem
|
||
key={section.clientId}
|
||
id={section.clientId}
|
||
label={`ลากเพื่อจัดลำดับ ${section.title || `Extra section ${sectionIndex + 1}`}`}
|
||
>
|
||
{(sectionDragHandle) => (
|
||
<AccordionItem value={section.clientId} className="border-0">
|
||
<Card
|
||
size="sm"
|
||
className={cn(
|
||
extraIssue("sections", sectionIndex, "title") && "border-destructive",
|
||
)}
|
||
>
|
||
<CardHeader>
|
||
<CardTitle>
|
||
<Input
|
||
value={section.title}
|
||
aria-label="ชื่อ Extra section"
|
||
aria-invalid={extraIssue("sections", sectionIndex, "title")}
|
||
onChange={(event) =>
|
||
setSections(
|
||
sections.map((item, index) =>
|
||
index === sectionIndex
|
||
? { ...item, title: event.target.value }
|
||
: item,
|
||
),
|
||
)
|
||
}
|
||
/>
|
||
</CardTitle>
|
||
<CardAction className="flex items-center gap-1">
|
||
{sectionDragHandle}
|
||
<DeleteConfirmationDialog
|
||
trigger={
|
||
<Button type="button" variant="ghost" size="icon-sm" />
|
||
}
|
||
title={`ลบ ${section.title || `Extra section ${sectionIndex + 1}`}?`}
|
||
description="แถว ข้อความ รูปภาพ และเปอร์เซ็นต์ทั้งหมดในส่วนนี้จะถูกลบออกจาก Guide"
|
||
onConfirm={() =>
|
||
setSections(
|
||
sections.filter((_, index) => index !== sectionIndex),
|
||
)
|
||
}
|
||
>
|
||
<Trash2Icon />
|
||
<span className="sr-only">ลบส่วน</span>
|
||
</DeleteConfirmationDialog>
|
||
<AccordionTrigger
|
||
className="size-8 flex-none items-center justify-center p-0 hover:no-underline"
|
||
headerClassName="flex-none"
|
||
aria-label={`${openSectionIds.includes(section.clientId) ? "ซ่อน" : "แสดง"} ${section.title || `Extra section ${sectionIndex + 1}`}`}
|
||
title={openSectionIds.includes(section.clientId) ? "ซ่อนส่วนนี้" : "แสดงส่วนนี้"}
|
||
/>
|
||
</CardAction>
|
||
</CardHeader>
|
||
<AccordionContent className="pb-0">
|
||
<CardContent className="flex flex-col gap-4">
|
||
<DndContext
|
||
sensors={extraSensors}
|
||
collisionDetection={closestCenter}
|
||
onDragEnd={(event) => handleRowDragEnd(section.clientId, event)}
|
||
>
|
||
<SortableContext
|
||
items={section.rows.map((row) => row.clientId)}
|
||
strategy={verticalListSortingStrategy}
|
||
>
|
||
{section.rows.map((row, rowIndex) => (
|
||
<SortableExtraItem
|
||
key={row.clientId}
|
||
id={row.clientId}
|
||
label={`ลากเพื่อจัดลำดับ ${row.heading || `แถว ${rowIndex + 1}`}`}
|
||
>
|
||
{(rowDragHandle) => (
|
||
<Card
|
||
size="sm"
|
||
className={cn(
|
||
extraIssue("sections", sectionIndex, "rows", rowIndex) &&
|
||
"border-destructive",
|
||
)}
|
||
>
|
||
<CardHeader>
|
||
<CardTitle>
|
||
{row.heading || `แถว ${rowIndex + 1}`}
|
||
</CardTitle>
|
||
<CardAction className="flex items-center gap-1">
|
||
{rowDragHandle}
|
||
<DeleteConfirmationDialog
|
||
trigger={
|
||
<Button
|
||
type="button"
|
||
variant="ghost"
|
||
size="icon-sm"
|
||
/>
|
||
}
|
||
title={`ลบ ${row.heading || `แถว ${rowIndex + 1}`}?`}
|
||
description="ข้อความ รูปภาพ และเปอร์เซ็นต์ในแถวนี้จะถูกลบออกจาก Guide"
|
||
onConfirm={() =>
|
||
setSections(
|
||
sections.map((item, index) =>
|
||
index === sectionIndex
|
||
? {
|
||
...item,
|
||
rows: item.rows.filter(
|
||
(_, inner) => inner !== rowIndex,
|
||
),
|
||
}
|
||
: item,
|
||
),
|
||
)
|
||
}
|
||
>
|
||
<Trash2Icon />
|
||
<span className="sr-only">ลบแถว</span>
|
||
</DeleteConfirmationDialog>
|
||
</CardAction>
|
||
</CardHeader>
|
||
<CardContent>
|
||
<FieldGroup>
|
||
{row.imageUrl ? (
|
||
<div
|
||
className="relative overflow-hidden rounded-lg border"
|
||
style={{
|
||
aspectRatio:
|
||
row.imageWidth && row.imageHeight
|
||
? `${row.imageWidth} / ${row.imageHeight}`
|
||
: "16 / 9",
|
||
}}
|
||
>
|
||
<Image
|
||
src={row.imageUrl}
|
||
alt={row.heading}
|
||
fill
|
||
className="object-cover"
|
||
/>
|
||
<DeleteConfirmationDialog
|
||
trigger={
|
||
<Button
|
||
type="button"
|
||
variant="destructive"
|
||
size="icon-sm"
|
||
className="absolute top-2 right-2"
|
||
aria-label="ลบภาพ"
|
||
/>
|
||
}
|
||
title="ลบภาพนี้?"
|
||
description="ภาพจะถูกนำออกจากแถวนี้"
|
||
confirmLabel="ยืนยันลบภาพ"
|
||
onConfirm={() =>
|
||
setSections(
|
||
sections.map((item, index) =>
|
||
index === sectionIndex
|
||
? {
|
||
...item,
|
||
rows: item.rows.map(
|
||
(candidate, inner) =>
|
||
inner === rowIndex
|
||
? {
|
||
...candidate,
|
||
mediaId: null,
|
||
imageUrl: null,
|
||
imageWidth: null,
|
||
imageHeight: null,
|
||
}
|
||
: candidate,
|
||
),
|
||
}
|
||
: item,
|
||
),
|
||
)
|
||
}
|
||
>
|
||
<XIcon />
|
||
</DeleteConfirmationDialog>
|
||
</div>
|
||
) : null}
|
||
<MediaUploader
|
||
onReady={(asset) =>
|
||
setSections(
|
||
sections.map((item, index) =>
|
||
index === sectionIndex
|
||
? {
|
||
...item,
|
||
rows: item.rows.map((candidate, inner) =>
|
||
inner === rowIndex
|
||
? {
|
||
...candidate,
|
||
mediaId: asset.id,
|
||
imageUrl: asset.url,
|
||
imageWidth: asset.width,
|
||
imageHeight: asset.height,
|
||
}
|
||
: candidate,
|
||
),
|
||
}
|
||
: item,
|
||
),
|
||
)
|
||
}
|
||
/>
|
||
<ExtraCatalogItemsEditor
|
||
value={row.catalogItems}
|
||
characters={characters}
|
||
weapons={weapons}
|
||
artifacts={artifacts}
|
||
onChange={(catalogItems) =>
|
||
setSections(
|
||
sections.map((item, index) =>
|
||
index === sectionIndex
|
||
? {
|
||
...item,
|
||
rows: item.rows.map((candidate, inner) =>
|
||
inner === rowIndex
|
||
? { ...candidate, catalogItems }
|
||
: candidate,
|
||
),
|
||
}
|
||
: item,
|
||
),
|
||
)
|
||
}
|
||
/>
|
||
<Field>
|
||
<FieldLabel>หัวข้อ</FieldLabel>
|
||
<Input
|
||
value={row.heading}
|
||
aria-invalid={extraIssue(
|
||
"sections",
|
||
sectionIndex,
|
||
"rows",
|
||
rowIndex,
|
||
"heading",
|
||
)}
|
||
onChange={(event) =>
|
||
setSections(
|
||
sections.map((item, index) =>
|
||
index === sectionIndex
|
||
? {
|
||
...item,
|
||
rows: item.rows.map((candidate, inner) =>
|
||
inner === rowIndex
|
||
? {
|
||
...candidate,
|
||
heading: event.target.value,
|
||
}
|
||
: candidate,
|
||
),
|
||
}
|
||
: item,
|
||
),
|
||
)
|
||
}
|
||
/>
|
||
</Field>
|
||
<Field>
|
||
<FieldLabel>ข้อความ</FieldLabel>
|
||
<RichTextEditor
|
||
value={row.body}
|
||
showBubbleMenu={false}
|
||
invalid={extraIssue(
|
||
"sections",
|
||
sectionIndex,
|
||
"rows",
|
||
rowIndex,
|
||
"body",
|
||
)}
|
||
onChange={(body) =>
|
||
setSections(
|
||
sections.map((item, index) =>
|
||
index === sectionIndex
|
||
? {
|
||
...item,
|
||
rows: item.rows.map((candidate, inner) =>
|
||
inner === rowIndex
|
||
? {
|
||
...candidate,
|
||
body,
|
||
highlights: [],
|
||
}
|
||
: candidate,
|
||
),
|
||
}
|
||
: item,
|
||
),
|
||
)
|
||
}
|
||
/>
|
||
</Field>
|
||
<Field>
|
||
<FieldLabel>Percent (optional)</FieldLabel>
|
||
<Input
|
||
type="text"
|
||
inputMode="decimal"
|
||
value={row.percent ?? ""}
|
||
onChange={(event) =>
|
||
setSections(
|
||
sections.map((item, index) =>
|
||
index === sectionIndex
|
||
? {
|
||
...item,
|
||
rows: item.rows.map((candidate, inner) =>
|
||
inner === rowIndex
|
||
? {
|
||
...candidate,
|
||
percent:
|
||
normalizePercentInput(
|
||
event.target.value,
|
||
candidate.percent ?? "",
|
||
) || null,
|
||
}
|
||
: candidate,
|
||
),
|
||
}
|
||
: item,
|
||
),
|
||
)
|
||
}
|
||
onBlur={() =>
|
||
setSections(
|
||
sections.map((item, index) =>
|
||
index === sectionIndex
|
||
? {
|
||
...item,
|
||
rows: item.rows.map((candidate, inner) =>
|
||
inner === rowIndex
|
||
? {
|
||
...candidate,
|
||
percent:
|
||
finalizePercentInput(
|
||
candidate.percent ?? "",
|
||
) || null,
|
||
}
|
||
: candidate,
|
||
),
|
||
}
|
||
: item,
|
||
),
|
||
)
|
||
}
|
||
/>
|
||
</Field>
|
||
<FieldSet>
|
||
<FieldLegend>สีการ์ด (optional)</FieldLegend>
|
||
<FieldDescription>
|
||
สีจะใช้เฉพาะการ์ดนี้บนหน้า Guide
|
||
</FieldDescription>
|
||
<Field
|
||
data-invalid={extraIssue(
|
||
"sections",
|
||
sectionIndex,
|
||
"rows",
|
||
rowIndex,
|
||
"backgroundColor",
|
||
) || undefined}
|
||
>
|
||
<FieldLabel>สีพื้นหลัง</FieldLabel>
|
||
<Input
|
||
type="color"
|
||
value={row.backgroundColor ?? DEFAULT_EXTRA_BACKGROUND_COLOR}
|
||
aria-invalid={extraIssue(
|
||
"sections",
|
||
sectionIndex,
|
||
"rows",
|
||
rowIndex,
|
||
"backgroundColor",
|
||
)}
|
||
onChange={(event) =>
|
||
setSections(
|
||
sections.map((item, index) =>
|
||
index === sectionIndex
|
||
? {
|
||
...item,
|
||
rows: item.rows.map((candidate, inner) =>
|
||
inner === rowIndex
|
||
? { ...candidate, backgroundColor: event.target.value }
|
||
: candidate,
|
||
),
|
||
}
|
||
: item,
|
||
),
|
||
)
|
||
}
|
||
/>
|
||
</Field>
|
||
<Button
|
||
type="button"
|
||
variant="outline"
|
||
size="sm"
|
||
disabled={row.backgroundColor === null}
|
||
onClick={() =>
|
||
setSections(
|
||
sections.map((item, index) =>
|
||
index === sectionIndex
|
||
? {
|
||
...item,
|
||
rows: item.rows.map((candidate, inner) =>
|
||
inner === rowIndex
|
||
? { ...candidate, backgroundColor: null }
|
||
: candidate,
|
||
),
|
||
}
|
||
: item,
|
||
),
|
||
)
|
||
}
|
||
>
|
||
<RefreshCcwIcon data-icon="inline-start" />
|
||
ใช้สีตามธีม
|
||
</Button>
|
||
<Field
|
||
data-invalid={extraIssue(
|
||
"sections",
|
||
sectionIndex,
|
||
"rows",
|
||
rowIndex,
|
||
"textColor",
|
||
) || undefined}
|
||
>
|
||
<FieldLabel>สีข้อความ</FieldLabel>
|
||
<Input
|
||
type="color"
|
||
value={row.textColor ?? DEFAULT_EXTRA_TEXT_COLOR}
|
||
aria-invalid={extraIssue(
|
||
"sections",
|
||
sectionIndex,
|
||
"rows",
|
||
rowIndex,
|
||
"textColor",
|
||
)}
|
||
onChange={(event) =>
|
||
setSections(
|
||
sections.map((item, index) =>
|
||
index === sectionIndex
|
||
? {
|
||
...item,
|
||
rows: item.rows.map((candidate, inner) =>
|
||
inner === rowIndex
|
||
? { ...candidate, textColor: event.target.value }
|
||
: candidate,
|
||
),
|
||
}
|
||
: item,
|
||
),
|
||
)
|
||
}
|
||
/>
|
||
</Field>
|
||
<Button
|
||
type="button"
|
||
variant="outline"
|
||
size="sm"
|
||
disabled={row.textColor === null}
|
||
onClick={() =>
|
||
setSections(
|
||
sections.map((item, index) =>
|
||
index === sectionIndex
|
||
? {
|
||
...item,
|
||
rows: item.rows.map((candidate, inner) =>
|
||
inner === rowIndex
|
||
? { ...candidate, textColor: null }
|
||
: candidate,
|
||
),
|
||
}
|
||
: item,
|
||
),
|
||
)
|
||
}
|
||
>
|
||
<RefreshCcwIcon data-icon="inline-start" />
|
||
ใช้สีข้อความตามธีม
|
||
</Button>
|
||
</FieldSet>
|
||
</FieldGroup>
|
||
</CardContent>
|
||
</Card>
|
||
)}
|
||
</SortableExtraItem>
|
||
))}
|
||
</SortableContext>
|
||
</DndContext>
|
||
<Button
|
||
type="button"
|
||
variant="outline"
|
||
onClick={() =>
|
||
setSections(
|
||
sections.map((item, index) =>
|
||
index === sectionIndex
|
||
? {
|
||
...item,
|
||
rows: [
|
||
...item.rows,
|
||
{
|
||
id: createClientId("extra-row-record"),
|
||
mediaId: null,
|
||
imageUrl: null,
|
||
imageWidth: null,
|
||
imageHeight: null,
|
||
heading: "",
|
||
body: "",
|
||
percent: null,
|
||
backgroundColor: null,
|
||
textColor: null,
|
||
catalogItems: [],
|
||
highlights: [],
|
||
clientId: createClientId("extra-row"),
|
||
},
|
||
],
|
||
}
|
||
: item,
|
||
),
|
||
)
|
||
}
|
||
>
|
||
<PlusIcon data-icon="inline-start" />
|
||
เพิ่มแถว
|
||
</Button>
|
||
</CardContent>
|
||
</AccordionContent>
|
||
</Card>
|
||
</AccordionItem>
|
||
)}
|
||
</SortableExtraItem>
|
||
))}
|
||
</SortableContext>
|
||
</Accordion>
|
||
</DndContext>
|
||
</CardContent>
|
||
<CardFooter className="flex-wrap justify-between gap-3">
|
||
<Button
|
||
variant="outline"
|
||
onClick={() => {
|
||
const clientId = createClientId("extra-section");
|
||
setSections([
|
||
...sections,
|
||
{
|
||
id: createClientId("extra-section-record"),
|
||
title: "Extra",
|
||
rows: [],
|
||
clientId,
|
||
},
|
||
]);
|
||
setOpenSectionIds([...openSectionIds, clientId]);
|
||
}}
|
||
>
|
||
<PlusIcon data-icon="inline-start" />
|
||
เพิ่ม Extra section
|
||
</Button>
|
||
<AutosaveStatus
|
||
state={autosave.state}
|
||
issues={
|
||
validation.success
|
||
? []
|
||
: formatGuideValidationIssues("extra", validation.error.issues)
|
||
}
|
||
onSave={autosave.saveNow}
|
||
/>
|
||
</CardFooter>
|
||
</Card>
|
||
);
|
||
}
|