5036 lines
214 KiB
TypeScript
5036 lines
214 KiB
TypeScript
"use client";
|
||
|
||
import { searchItems } from "@/lib/search";
|
||
|
||
import dynamic from "next/dynamic";
|
||
import Image from "next/image";
|
||
import { useRouter } from "next/navigation";
|
||
import {
|
||
ArrowLeftIcon,
|
||
BookOpenIcon,
|
||
ClipboardCopyIcon,
|
||
ClipboardPasteIcon,
|
||
CopyIcon,
|
||
CircleHelpIcon,
|
||
GripVerticalIcon,
|
||
HeartPulseIcon,
|
||
PlusIcon,
|
||
RefreshCcwIcon,
|
||
SearchIcon,
|
||
SparklesIcon,
|
||
Trash2Icon,
|
||
XIcon,
|
||
} from "lucide-react";
|
||
import { useEffect, useMemo, useState, type ComponentProps, type ReactNode, type SetStateAction } 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 {
|
||
deleteGuideExtra,
|
||
saveArtifactSection,
|
||
saveConstellationSection,
|
||
saveGuideExtra,
|
||
listTeamImportGuides,
|
||
loadTeamImportGuide,
|
||
saveTeamSection,
|
||
saveWeaponSection,
|
||
} from "@/app/admin/actions";
|
||
import { AutosaveStatus } from "@/components/admin/autosave-status";
|
||
import { DeleteConfirmationDialog } from "@/components/admin/delete-confirmation-dialog";
|
||
import { DeleteConditionDialog } from "@/components/admin/delete-condition-dialog";
|
||
import { MediaUploader } from "@/components/admin/media-uploader";
|
||
import { ScrollableNav } from "@/components/public/scrollable-nav";
|
||
import { TeamArtifactIcon } from "@/components/public/team-search";
|
||
import { SimpleTooltip } from "@/components/tooltip";
|
||
import {
|
||
Accordion,
|
||
AccordionContent,
|
||
AccordionItem,
|
||
AccordionTrigger,
|
||
} from "@/components/ui/accordion";
|
||
import { Button } from "@/components/ui/button";
|
||
import { Badge } from "@/components/ui/badge";
|
||
import { Checkbox } from "@/components/ui/checkbox";
|
||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||
import {
|
||
Card,
|
||
CardAction,
|
||
CardContent,
|
||
CardDescription,
|
||
CardFooter,
|
||
CardHeader,
|
||
CardTitle,
|
||
} from "@/components/ui/card";
|
||
import {
|
||
Combobox,
|
||
FuzzyCombobox,
|
||
ComboboxContent,
|
||
ComboboxEmpty,
|
||
ComboboxInput,
|
||
ComboboxItem,
|
||
ComboboxList,
|
||
ComboboxTrigger,
|
||
} from "@/components/ui/combobox";
|
||
import {
|
||
Empty,
|
||
EmptyContent,
|
||
EmptyDescription,
|
||
EmptyHeader,
|
||
EmptyMedia,
|
||
EmptyTitle,
|
||
} from "@/components/ui/empty";
|
||
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 { Separator } from "@/components/ui/separator";
|
||
import { Skeleton } from "@/components/ui/skeleton";
|
||
import { Spinner } from "@/components/ui/spinner";
|
||
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,
|
||
recommendedStatOptionConflicts,
|
||
targetStatDefaults,
|
||
targetStatIconName,
|
||
targetStatOptions,
|
||
statPriorityColorClass,
|
||
} from "@/lib/guides/stats";
|
||
import { elementBackgroundColor, elementIconFilter } from "@/lib/catalog/element";
|
||
import { supportsWeaponType } from "@/lib/catalog/lunaris";
|
||
import { rarityGradientClass } from "@/lib/catalog/rarity";
|
||
import { isCustomTravelerKey } from "@/lib/catalog/travelers";
|
||
import {
|
||
applyPresetFields,
|
||
applyTeamFields,
|
||
cloneTeamImports,
|
||
findInvalidTeamCatalogReference,
|
||
findUnsupportedTeamWeapon,
|
||
limitPresets,
|
||
parseTeamPresets,
|
||
parseTeamTransfer,
|
||
serializeTeamTransfer,
|
||
teamTransferFileName,
|
||
teamPresetCookieKey,
|
||
teamPresetStorageKey,
|
||
type TeamApplySelection,
|
||
type TeamMemberApplyField,
|
||
type TeamPreset,
|
||
type TeamToolValue,
|
||
} from "@/lib/guides/team-tools";
|
||
import {
|
||
searchTeams,
|
||
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 { createExtraSheet, type ExtraSheet } from "@/lib/guides/extra-sheet";
|
||
import { cn } from "@/lib/utils";
|
||
|
||
type CatalogItem = {
|
||
key: string;
|
||
name: string;
|
||
aliases?: string[];
|
||
imageUrl: string;
|
||
rarity?: number | null;
|
||
weaponType?: string | null;
|
||
element?: string | null;
|
||
elementIconUrl?: string | null;
|
||
};
|
||
const catalogItemsEqual = (item: CatalogItem, value: CatalogItem) =>
|
||
item.key === value.key;
|
||
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])[];
|
||
const multipleApplyStorageKey = (guideId: string) =>
|
||
`buzz-sheet:team-multiple-apply:${guideId}`;
|
||
type Guide = {
|
||
id: string;
|
||
characterKey: string;
|
||
version: number;
|
||
name: string;
|
||
};
|
||
type WeaponValue = {
|
||
id: string;
|
||
weaponKey: string;
|
||
refinement: string;
|
||
overallPercent: string | null;
|
||
personalPercent: string | null;
|
||
note: string | null;
|
||
weapon: CatalogItem;
|
||
};
|
||
type WeaponGroupValue = {
|
||
id: string;
|
||
name: string | null;
|
||
note: string | null;
|
||
rows: WeaponValue[];
|
||
};
|
||
type ArtifactTierValue = {
|
||
id: string;
|
||
name: string;
|
||
rows: Array<{
|
||
firstArtifactKey: string;
|
||
secondArtifactKey: string | null;
|
||
percent: string;
|
||
note: string | null;
|
||
}>;
|
||
};
|
||
type ArtifactValue = {
|
||
id: string;
|
||
name: string | null;
|
||
note: string | null;
|
||
tiers: ArtifactTierValue[];
|
||
};
|
||
type ArtifactProfile = {
|
||
sands: string[];
|
||
goblet: string[];
|
||
circlet: string[];
|
||
substats: string[];
|
||
recommendedConditions: Array<{
|
||
name?: string | null;
|
||
note: string | null;
|
||
sands: string[];
|
||
goblet: string[];
|
||
circlet: string[];
|
||
substats: string[];
|
||
}>;
|
||
targets: Array<{ stat: string; value: string }>;
|
||
targetConditions: Array<{
|
||
name?: string | null;
|
||
note: string | null;
|
||
rows: 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;
|
||
name: string | null;
|
||
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;
|
||
}>;
|
||
};
|
||
type TeamGroupValue = {
|
||
id: string;
|
||
name: string | null;
|
||
note: string | null;
|
||
teams: TeamValue[];
|
||
};
|
||
|
||
let clientIdCounter = 0;
|
||
const DEFAULT_EXTRA_BACKGROUND_COLOR = "#24232C";
|
||
const RichTextEditor = dynamic(
|
||
() => import("@/components/rich-text-editor").then((module) => module.RichTextEditor),
|
||
{
|
||
ssr: false,
|
||
loading: () => <Skeleton className="h-36 w-full" />,
|
||
},
|
||
);
|
||
const ExtraSheetEditor = dynamic(
|
||
() => import("@/components/admin/extra-sheet-editor").then((module) => module.ExtraSheetEditor),
|
||
{
|
||
ssr: false,
|
||
loading: () => <Skeleton className="h-48 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 ConditionRenameDialog({
|
||
open,
|
||
fallback,
|
||
value,
|
||
onOpenChange,
|
||
onChange,
|
||
}: {
|
||
open: boolean;
|
||
fallback: string;
|
||
value: string;
|
||
onOpenChange: (open: boolean) => void;
|
||
onChange: (value: string) => void;
|
||
}) {
|
||
return (
|
||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||
<DialogContent>
|
||
<DialogHeader>
|
||
<DialogTitle>Rename condition</DialogTitle>
|
||
<DialogDescription>
|
||
Leave blank to use {fallback}.
|
||
</DialogDescription>
|
||
</DialogHeader>
|
||
<Field>
|
||
<FieldLabel htmlFor="condition-name">Condition name</FieldLabel>
|
||
<Input
|
||
id="condition-name"
|
||
autoFocus
|
||
maxLength={120}
|
||
placeholder={fallback}
|
||
value={value}
|
||
onChange={(event) => onChange(event.target.value)}
|
||
/>
|
||
</Field>
|
||
<DialogFooter>
|
||
<Button type="button" onClick={() => onOpenChange(false)}>Done</Button>
|
||
</DialogFooter>
|
||
</DialogContent>
|
||
</Dialog>
|
||
);
|
||
}
|
||
|
||
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";
|
||
extraSectionId?: string;
|
||
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[];
|
||
teamGroups: TeamGroupValue[];
|
||
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;
|
||
sheet: ExtraSheet | 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
|
||
sizes="40px"
|
||
className="object-contain"
|
||
/>
|
||
</div>
|
||
) : null}
|
||
<div className="min-w-0 flex-1">
|
||
<FuzzyCombobox
|
||
items={items}
|
||
value={selected}
|
||
onValueChange={(next) => onChange(next?.key ?? "")}
|
||
itemToStringLabel={(item) => item.name}
|
||
itemToStringValue={(item) => item.key}
|
||
searchNames={(item) => [item.name, ...(item.aliases ?? [])]}
|
||
autoHighlight
|
||
>
|
||
<ComboboxInput
|
||
className="w-full"
|
||
placeholder={placeholder}
|
||
showClear
|
||
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
|
||
sizes="32px"
|
||
className="object-contain"
|
||
/>
|
||
</div>
|
||
<span className="whitespace-nowrap">{item.name}</span>
|
||
</ComboboxItem>
|
||
)}
|
||
</ComboboxList>
|
||
</ComboboxContent>
|
||
</FuzzyCombobox>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function CatalogSlotPicker({
|
||
items,
|
||
value,
|
||
onChange,
|
||
label,
|
||
placeholder,
|
||
rarityBackground = false,
|
||
invalid = false,
|
||
className,
|
||
}: {
|
||
items: CatalogItem[];
|
||
value: string;
|
||
onChange: (value: string) => void;
|
||
label: string;
|
||
placeholder: string;
|
||
rarityBackground?: boolean;
|
||
invalid?: boolean;
|
||
className?: string;
|
||
}) {
|
||
const selected = items.find((item) => item.key === value) ?? null;
|
||
return (
|
||
<FuzzyCombobox
|
||
items={items}
|
||
value={selected}
|
||
onValueChange={(next) => onChange(next?.key ?? "")}
|
||
itemToStringLabel={(item) => item.name}
|
||
itemToStringValue={(item) => item.key}
|
||
searchNames={(item) => [item.name, ...(item.aliases ?? [])]}
|
||
autoHighlight
|
||
>
|
||
<ComboboxTrigger
|
||
aria-label={selected ? `${label}: ${selected.name}` : label}
|
||
aria-invalid={invalid}
|
||
className={cn(
|
||
"relative flex aspect-square w-full items-center justify-center overflow-hidden rounded-md border bg-muted p-0 outline-none focus-visible:ring-2 focus-visible:ring-ring aria-invalid:border aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&>svg]:hidden",
|
||
rarityBackground && selected && rarityGradientClass(selected.rarity),
|
||
className,
|
||
)}
|
||
>
|
||
{selected ? (
|
||
<Image
|
||
src={selected.imageUrl}
|
||
alt=""
|
||
fill
|
||
sizes="96px"
|
||
className="object-contain p-0.5"
|
||
/>
|
||
) : (
|
||
<CircleHelpIcon className="size-8 text-muted-foreground" aria-hidden="true" />
|
||
)}
|
||
</ComboboxTrigger>
|
||
<ComboboxContent className="min-w-72">
|
||
<ComboboxInput placeholder={placeholder} showClear showTrigger={false} />
|
||
<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
|
||
sizes="32px"
|
||
className="object-contain"
|
||
/>
|
||
</div>
|
||
<span className="whitespace-nowrap">{item.name}</span>
|
||
</ComboboxItem>
|
||
)}
|
||
</ComboboxList>
|
||
</ComboboxContent>
|
||
</FuzzyCombobox>
|
||
);
|
||
}
|
||
|
||
function ArtifactSlotPicker({
|
||
items,
|
||
values,
|
||
onChange,
|
||
label,
|
||
invalid = false,
|
||
}: {
|
||
items: CatalogItem[];
|
||
values: string[];
|
||
onChange: (values: string[]) => void;
|
||
label: string;
|
||
invalid?: boolean;
|
||
}) {
|
||
const firstKey = values[0];
|
||
const secondKey = values[1];
|
||
const selected = useMemo(
|
||
() => [firstKey, secondKey].flatMap((value) => {
|
||
const item = items.find((candidate) => candidate.key === value);
|
||
return item ? [item] : [];
|
||
}),
|
||
[firstKey, items, secondKey],
|
||
);
|
||
const artifactIconValue = (item: CatalogItem | undefined) => item
|
||
? {
|
||
key: item.key,
|
||
name: item.name,
|
||
imageUrl: item.imageUrl,
|
||
rarity: item.rarity ?? null,
|
||
}
|
||
: null;
|
||
|
||
return (
|
||
<FuzzyCombobox
|
||
multiple
|
||
items={items}
|
||
value={selected}
|
||
onValueChange={(next) =>
|
||
onChange(next.slice(0, 2).map((item) => item.key))
|
||
}
|
||
itemToStringLabel={(item) => item.name}
|
||
itemToStringValue={(item) => item.key}
|
||
isItemEqualToValue={catalogItemsEqual}
|
||
searchNames={(item) => [item.name, ...(item.aliases ?? [])]}
|
||
autoHighlight
|
||
>
|
||
<ComboboxTrigger
|
||
aria-label={selected.length
|
||
? `${label}: ${selected.map((item) => item.name).join(", ")}`
|
||
: label}
|
||
aria-invalid={invalid}
|
||
className="relative aspect-square w-full overflow-hidden rounded-md border bg-muted outline-none focus-visible:ring-2 focus-visible:ring-ring aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&>svg]:hidden"
|
||
>
|
||
{selected.length ? (
|
||
<TeamArtifactIcon
|
||
first={artifactIconValue(selected[0])}
|
||
second={artifactIconValue(selected[1])}
|
||
className="border-0"
|
||
sizes="96px"
|
||
/>
|
||
) : (
|
||
<CircleHelpIcon className="size-8 text-muted-foreground" aria-hidden="true" />
|
||
)}
|
||
</ComboboxTrigger>
|
||
<ComboboxContent className="min-w-72">
|
||
<ComboboxInput
|
||
placeholder="ค้นหาชุด Artifact…"
|
||
showClear
|
||
showTrigger={false}
|
||
/>
|
||
<ComboboxEmpty>ไม่พบชุด Artifact</ComboboxEmpty>
|
||
<ComboboxList>
|
||
{(item) => (
|
||
<ComboboxItem
|
||
key={item.key}
|
||
value={item}
|
||
disabled={
|
||
selected.length >= 2 &&
|
||
!selected.some((candidate) => candidate.key === item.key)
|
||
}
|
||
>
|
||
<div
|
||
className={cn(
|
||
"relative size-8 shrink-0 overflow-hidden rounded-md",
|
||
rarityGradientClass(item.rarity),
|
||
)}
|
||
>
|
||
<Image
|
||
src={item.imageUrl}
|
||
alt=""
|
||
fill
|
||
sizes="32px"
|
||
className="object-contain"
|
||
/>
|
||
</div>
|
||
<span className="whitespace-nowrap">{item.name}</span>
|
||
</ComboboxItem>
|
||
)}
|
||
</ComboboxList>
|
||
</ComboboxContent>
|
||
</FuzzyCombobox>
|
||
);
|
||
}
|
||
|
||
function StatSelect({
|
||
label,
|
||
options,
|
||
value,
|
||
onChange,
|
||
elementIconUrls,
|
||
priorityColors = false,
|
||
}: {
|
||
label: string;
|
||
options: readonly string[];
|
||
value: string[];
|
||
onChange: (value: string[]) => void;
|
||
elementIconUrls: Record<string, string>;
|
||
priorityColors?: boolean;
|
||
}) {
|
||
return (
|
||
<Field>
|
||
<FieldLabel>{label}</FieldLabel>
|
||
<Select
|
||
multiple
|
||
value={value}
|
||
onValueChange={onChange}
|
||
>
|
||
<SelectTrigger className="w-full">
|
||
<StatIcons values={value} elementIconUrls={elementIconUrls} />
|
||
<SelectValue placeholder="Select recommended stats…">
|
||
{priorityColors && value.length
|
||
? () => value.map((selected, index) => (
|
||
<span key={selected} className={statPriorityColorClass(index)}>
|
||
{selected}{index < value.length - 1 ? "," : ""}
|
||
</span>
|
||
))
|
||
: undefined}
|
||
</SelectValue>
|
||
</SelectTrigger>
|
||
<SelectContent className="w-max min-w-(--anchor-width) max-w-(--available-width)">
|
||
<SelectGroup>
|
||
{options.map((option) => (
|
||
<SelectItem
|
||
key={option}
|
||
value={option}
|
||
disabled={recommendedStatOptionConflicts(option, value)}
|
||
className={priorityColors && value.includes(option)
|
||
? statPriorityColorClass(value.indexOf(option))
|
||
: undefined}
|
||
>
|
||
<StatIcons values={[option]} elementIconUrls={elementIconUrls} />
|
||
{option}
|
||
</SelectItem>
|
||
))}
|
||
</SelectGroup>
|
||
</SelectContent>
|
||
</Select>
|
||
</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 (
|
||
<div
|
||
ref={setNodeRef}
|
||
style={{ transform: CSS.Transform.toString(transform), transition }}
|
||
className={cn("min-w-0", 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>,
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
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>
|
||
);
|
||
}
|
||
|
||
type ConditionNavItem = {
|
||
clientId: string;
|
||
name: string;
|
||
};
|
||
|
||
function SortableConditionTab({
|
||
item,
|
||
index,
|
||
active,
|
||
onSelect,
|
||
onRename,
|
||
}: {
|
||
item: ConditionNavItem;
|
||
index: number;
|
||
active: boolean;
|
||
onSelect: () => void;
|
||
onRename: () => void;
|
||
}) {
|
||
const {
|
||
attributes,
|
||
listeners,
|
||
setNodeRef,
|
||
transform,
|
||
transition,
|
||
isDragging,
|
||
} = useSortable({ id: item.clientId });
|
||
const label = item.name.trim() || `Condition ${index + 1}`;
|
||
|
||
return (
|
||
<div
|
||
ref={setNodeRef}
|
||
style={{ transform: CSS.Transform.toString(transform), transition }}
|
||
className={cn("flex shrink-0 items-center rounded-md border", isDragging && "z-10 opacity-60")}
|
||
>
|
||
<ToggleGroupItem
|
||
value={item.clientId}
|
||
className="border-0 aria-pressed:bg-primary aria-pressed:text-primary-foreground"
|
||
onClick={onSelect}
|
||
onDoubleClick={onRename}
|
||
>
|
||
{label}
|
||
{index === 0 ? <Badge variant={active ? "secondary" : "outline"}>Default</Badge> : null}
|
||
</ToggleGroupItem>
|
||
<SimpleTooltip text={`ลากเพื่อจัดลำดับ ${label}`}>
|
||
<Button
|
||
type="button"
|
||
variant="ghost"
|
||
size="icon-sm"
|
||
className="touch-none rounded-l-none"
|
||
{...attributes}
|
||
{...listeners}
|
||
>
|
||
<GripVerticalIcon />
|
||
<span className="sr-only">ลากเพื่อจัดลำดับ {label}</span>
|
||
</Button>
|
||
</SimpleTooltip>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function SortableConditionSwitcher({
|
||
ariaLabel,
|
||
items,
|
||
activeIndex,
|
||
onActiveIndexChange,
|
||
onReorder,
|
||
onRename,
|
||
}: {
|
||
ariaLabel: string;
|
||
items: ConditionNavItem[];
|
||
activeIndex: number;
|
||
onActiveIndexChange: (index: number) => void;
|
||
onReorder: (from: number, to: number) => void;
|
||
onRename: (index: number) => void;
|
||
}) {
|
||
const sensors = useSensors(
|
||
useSensor(PointerSensor, { activationConstraint: { distance: 8 } }),
|
||
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }),
|
||
);
|
||
const activeId = items[activeIndex]?.clientId;
|
||
|
||
return (
|
||
<ScrollableNav ariaLabel={ariaLabel} className="min-w-0 flex-1">
|
||
<DndContext
|
||
sensors={sensors}
|
||
collisionDetection={closestCenter}
|
||
onDragEnd={({ active, over }) => {
|
||
if (!over || active.id === over.id) return;
|
||
const from = items.findIndex((item) => item.clientId === active.id);
|
||
const to = items.findIndex((item) => item.clientId === over.id);
|
||
if (from !== -1 && to !== -1) onReorder(from, to);
|
||
}}
|
||
>
|
||
<SortableContext items={items.map((item) => item.clientId)} strategy={rectSortingStrategy}>
|
||
<ToggleGroup
|
||
aria-label={ariaLabel}
|
||
variant="outline"
|
||
value={activeId ? [activeId] : []}
|
||
onValueChange={(values) => {
|
||
const index = items.findIndex((item) => item.clientId === values[0]);
|
||
if (index !== -1) onActiveIndexChange(index);
|
||
}}
|
||
>
|
||
{items.map((item, index) => (
|
||
<SortableConditionTab
|
||
key={item.clientId}
|
||
item={item}
|
||
index={index}
|
||
active={item.clientId === activeId}
|
||
onSelect={() => onActiveIndexChange(index)}
|
||
onRename={() => onRename(index)}
|
||
/>
|
||
))}
|
||
</ToggleGroup>
|
||
</SortableContext>
|
||
</DndContext>
|
||
</ScrollableNav>
|
||
);
|
||
}
|
||
|
||
function ConditionControls({
|
||
onAdd,
|
||
onDuplicate,
|
||
onDelete,
|
||
...switcherProps
|
||
}: ComponentProps<typeof SortableConditionSwitcher> & {
|
||
onAdd: () => void;
|
||
onDuplicate?: () => void;
|
||
onDelete: () => void;
|
||
}) {
|
||
const { items, activeIndex } = switcherProps;
|
||
const atLimit = items.length >= 20;
|
||
|
||
return (
|
||
<div className="flex flex-wrap gap-2">
|
||
<SortableConditionSwitcher {...switcherProps} />
|
||
<SimpleTooltip text="เพิ่ม Condition">
|
||
<Button type="button" size="icon" variant="outline" aria-label="เพิ่ม Condition" disabled={atLimit} onClick={onAdd}>
|
||
<PlusIcon />
|
||
</Button>
|
||
</SimpleTooltip>
|
||
{onDuplicate ? (
|
||
<SimpleTooltip text="Duplicate Condition">
|
||
<Button type="button" size="icon" variant="outline" aria-label="Duplicate Condition" disabled={atLimit || !items[activeIndex]} onClick={onDuplicate}>
|
||
<CopyIcon />
|
||
</Button>
|
||
</SimpleTooltip>
|
||
) : null}
|
||
{items.length > 1 ? (
|
||
<DeleteConditionDialog
|
||
trigger={<Button type="button" size="icon" variant="outline" aria-label="ลบ Condition นี้" />}
|
||
onConfirm={onDelete}
|
||
iconOnly
|
||
/>
|
||
) : null}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function reorderConditions<T extends ConditionNavItem>(
|
||
items: T[],
|
||
activeIndex: number,
|
||
from: number,
|
||
to: number,
|
||
) {
|
||
const activeId = items[activeIndex]?.clientId;
|
||
const reordered = arrayMove(items, from, to);
|
||
return {
|
||
items: reordered,
|
||
activeIndex: Math.max(0, reordered.findIndex((item) => item.clientId === activeId)),
|
||
};
|
||
}
|
||
|
||
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", name: null, note: null, rows: [] }];
|
||
return source.map((group) => ({
|
||
clientId: group.id,
|
||
name: group.name ?? "",
|
||
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 [renamingWeaponGroupIndex, setRenamingWeaponGroupIndex] = useState<number | null>(null);
|
||
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) => ({
|
||
name: group.name.trim() || null,
|
||
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({
|
||
guideId: guide.id,
|
||
scope: "weapon",
|
||
value,
|
||
valid: validation.success,
|
||
initialVersion: guide.version,
|
||
persist: (next, expectedVersion, intent) =>
|
||
saveWeaponSection(guide.id, { ...next, expectedVersion }, intent),
|
||
storageKey: adminDraftKey(guide.id, "weapon"),
|
||
localDraft,
|
||
parseLocalDraft: (candidate) =>
|
||
parseEditorDraft<typeof localDraft>(candidate, ["groups"], ["note"]),
|
||
restoreLocalDraft: (draft) => {
|
||
setNote(draft.note);
|
||
setGroups(draft.groups.map((group) => ({ ...group, name: group.name ?? "" })));
|
||
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>
|
||
<ConditionControls
|
||
ariaLabel="เลือกเงื่อนไขอาวุธ"
|
||
items={groups}
|
||
activeIndex={activeGroupIndex}
|
||
onActiveIndexChange={setActiveGroupIndex}
|
||
onRename={setRenamingWeaponGroupIndex}
|
||
onReorder={(from, to) => {
|
||
const reordered = reorderConditions(groups, activeGroupIndex, from, to);
|
||
setGroups(reordered.items);
|
||
setActiveGroupIndex(reordered.activeIndex);
|
||
}}
|
||
onAdd={() => {
|
||
setGroups([...groups, { clientId: createClientId("weapon-condition"), name: "", note: "", rows: [] }]);
|
||
setActiveGroupIndex(groups.length);
|
||
}}
|
||
onDuplicate={() => {
|
||
const source = groups[activeGroupIndex];
|
||
if (!source) return;
|
||
setGroups([
|
||
...groups,
|
||
{
|
||
clientId: createClientId("weapon-condition"),
|
||
name: source.name,
|
||
note: source.note,
|
||
rows: source.rows.map((row) => ({
|
||
...row,
|
||
clientId: createClientId("weapon"),
|
||
})),
|
||
},
|
||
]);
|
||
setActiveGroupIndex(groups.length);
|
||
}}
|
||
onDelete={() => {
|
||
const next = groups.filter((_, index) => index !== activeGroupIndex);
|
||
setGroups(next);
|
||
setActiveGroupIndex(Math.min(activeGroupIndex, next.length - 1));
|
||
}}
|
||
/>
|
||
<ConditionRenameDialog
|
||
open={renamingWeaponGroupIndex !== null}
|
||
fallback={`Condition ${(renamingWeaponGroupIndex ?? 0) + 1}`}
|
||
value={renamingWeaponGroupIndex === null ? "" : groups[renamingWeaponGroupIndex]?.name ?? ""}
|
||
onOpenChange={(open) => { if (!open) setRenamingWeaponGroupIndex(null); }}
|
||
onChange={(name) => setGroups(groups.map((group, index) => index === renamingWeaponGroupIndex ? { ...group, name } : group))}
|
||
/>
|
||
<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
|
||
sizes="40px"
|
||
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: next.slice(1),
|
||
}
|
||
: item,
|
||
),
|
||
);
|
||
}}
|
||
>
|
||
{["1", "2", "3", "4", "5", "1-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>
|
||
<AutosaveStatus
|
||
state={autosave.state}
|
||
conflictVersion={autosave.conflictVersion}
|
||
canOverwrite={autosave.canOverwrite}
|
||
onReloadLatest={autosave.reloadLatest}
|
||
onOverwrite={autosave.overwrite}
|
||
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(() => {
|
||
const source = initial.artifactGroups.length ? initial.artifactGroups : [{ id: "artifact-condition-default", name: null, note: null, tiers: [] }];
|
||
return source.map((condition, conditionIndex) => ({
|
||
clientId: condition.id,
|
||
name: condition.name ?? "",
|
||
note: condition.note ?? "",
|
||
tiers: (["Late Game", "Early Game"] as const).map((name, tierIndex) => ({
|
||
name,
|
||
rows: condition.tiers[tierIndex]?.rows.map((row, rowIndex) => ({ ...row, clientId: `${conditionIndex}-${name}-${rowIndex}` })) ?? [],
|
||
})),
|
||
}));
|
||
});
|
||
const [activeArtifactGroupIndex, setActiveArtifactGroupIndex] = useState(0);
|
||
const [renamingArtifactGroupIndex, setRenamingArtifactGroupIndex] = useState<number | null>(null);
|
||
const artifactTiers = groups[activeArtifactGroupIndex]?.tiers ?? [];
|
||
const setArtifactTierRows = (
|
||
tierIndex: number,
|
||
update: (rows: (typeof artifactTiers)[number]["rows"]) => (typeof artifactTiers)[number]["rows"],
|
||
) => setGroups((current) => current.map((condition, conditionIndex) => conditionIndex === activeArtifactGroupIndex
|
||
? { ...condition, tiers: condition.tiers.map((tier, index) => index === tierIndex ? { ...tier, rows: update(tier.rows) } : tier) }
|
||
: condition));
|
||
const [recommendedGroups, setRecommendedGroups] = useState(() => {
|
||
const source = profile?.recommendedConditions.length
|
||
? profile.recommendedConditions
|
||
: [{ name: null, note: null, sands: profile?.sands ?? [], goblet: profile?.goblet ?? [], circlet: profile?.circlet ?? [], substats: profile?.substats ?? [] }];
|
||
return source.map((group, index) => ({ ...group, clientId: `recommended-condition-${index}`, name: group.name ?? "", note: group.note ?? "" }));
|
||
});
|
||
const [activeRecommendedGroupIndex, setActiveRecommendedGroupIndex] = useState(0);
|
||
const [renamingRecommendedGroupIndex, setRenamingRecommendedGroupIndex] = useState<number | null>(null);
|
||
const recommended = recommendedGroups[activeRecommendedGroupIndex];
|
||
const { sands, goblet, circlet, substats } = recommended;
|
||
const setRecommendedStat = (slot: "sands" | "goblet" | "circlet" | "substats", values: string[]) =>
|
||
setRecommendedGroups((current) => current.map((group, index) => index === activeRecommendedGroupIndex ? { ...group, [slot]: values } : group));
|
||
const [targetGroups, setTargetGroups] = useState(() => {
|
||
const source = profile?.targetConditions.length
|
||
? profile.targetConditions
|
||
: [{ name: null, note: null, rows: profile?.targets ?? [] }];
|
||
return source.map((group, index) => {
|
||
const existing = new Map(group.rows.flatMap((target) => {
|
||
const stat = normalizeTargetStat(target.stat);
|
||
return stat ? [[stat, target.value] as const] : [];
|
||
}));
|
||
return {
|
||
clientId: `target-condition-${index}`,
|
||
name: group.name ?? "",
|
||
note: group.note ?? "",
|
||
rows: targetStatDefaults.map((target) => ({ stat: target.stat, value: existing.get(target.stat) ?? target.value })),
|
||
};
|
||
});
|
||
});
|
||
const [activeTargetGroupIndex, setActiveTargetGroupIndex] = useState(0);
|
||
const [renamingTargetGroupIndex, setRenamingTargetGroupIndex] = useState<number | null>(null);
|
||
const targets = targetGroups[activeTargetGroupIndex]?.rows ?? [];
|
||
const setTargets = (rows: typeof targets) => setTargetGroups((current) => current.map((group, index) => index === activeTargetGroupIndex ? { ...group, rows } : group));
|
||
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.trim() || null,
|
||
note: group.note.trim() || null,
|
||
tiers: group.tiers.map((tier) => ({
|
||
name: tier.name,
|
||
rows: tier.rows.map((row) => ({
|
||
firstArtifactKey: row.firstArtifactKey,
|
||
secondArtifactKey: row.secondArtifactKey,
|
||
percent: Number(row.percent),
|
||
note: row.note?.trim() || null,
|
||
})),
|
||
})),
|
||
})),
|
||
sands: recommendedGroups[0].sands,
|
||
goblet: recommendedGroups[0].goblet,
|
||
circlet: recommendedGroups[0].circlet,
|
||
substats: recommendedGroups[0].substats,
|
||
recommendedGroups: recommendedGroups.map(({ name, note, sands, goblet, circlet, substats }) => ({ name: name.trim() || null, note: note.trim() || null, sands, goblet, circlet, substats })),
|
||
targetGroups: targetGroups.map((group) => ({
|
||
name: group.name.trim() || null,
|
||
note: group.note.trim() || null,
|
||
rows: group.rows.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,
|
||
activeArtifactGroupIndex,
|
||
sands,
|
||
goblet,
|
||
circlet,
|
||
substats,
|
||
recommendedGroups,
|
||
activeRecommendedGroupIndex,
|
||
targetGroups,
|
||
activeTargetGroupIndex,
|
||
talents,
|
||
note,
|
||
};
|
||
const autosave = useAutosave({
|
||
guideId: guide.id,
|
||
scope: "artifact",
|
||
value,
|
||
valid: validation.success,
|
||
initialVersion: guide.version,
|
||
persist: (next, expectedVersion, intent) =>
|
||
saveArtifactSection(guide.id, { ...next, expectedVersion }, intent),
|
||
storageKey: adminDraftKey(guide.id, "artifact"),
|
||
localDraft,
|
||
parseLocalDraft: (candidate) =>
|
||
parseEditorDraft<typeof localDraft>(
|
||
candidate,
|
||
[
|
||
"groups",
|
||
"sands",
|
||
"goblet",
|
||
"circlet",
|
||
"substats",
|
||
"targetGroups",
|
||
"talents",
|
||
],
|
||
["note"],
|
||
),
|
||
restoreLocalDraft: (draft) => {
|
||
setGroups(draft.groups);
|
||
setActiveArtifactGroupIndex(draft.activeArtifactGroupIndex ?? 0);
|
||
setRecommendedGroups(draft.recommendedGroups?.length ? draft.recommendedGroups : [{ clientId: createClientId("recommended-condition"), name: "", note: "", sands: draft.sands, goblet: draft.goblet, circlet: draft.circlet, substats: draft.substats }]);
|
||
setActiveRecommendedGroupIndex(Math.min(draft.activeRecommendedGroupIndex ?? 0, Math.max(0, (draft.recommendedGroups?.length ?? 1) - 1)));
|
||
setTargetGroups(draft.targetGroups.map((group) => ({ ...group, name: group.name ?? "" })));
|
||
setActiveTargetGroupIndex(draft.activeTargetGroupIndex ?? 0);
|
||
setTalents(draft.talents);
|
||
setNote(draft.note);
|
||
},
|
||
});
|
||
const orderedTalents = normalizeTalentPriorities(talents);
|
||
const handleArtifactDragEnd = ({ active, over }: DragEndEvent) => {
|
||
if (!over || active.id === over.id) return;
|
||
const activeTierIndex = artifactTiers.findIndex((group) =>
|
||
group.rows.some((row) => row.clientId === active.id),
|
||
);
|
||
const overTierIndex = artifactTiers.findIndex((group) =>
|
||
group.rows.some((row) => row.clientId === over.id),
|
||
);
|
||
if (activeTierIndex === -1 || activeTierIndex !== overTierIndex) return;
|
||
setGroups((current) =>
|
||
current.map((condition, conditionIndex) => {
|
||
if (conditionIndex !== activeArtifactGroupIndex) return condition;
|
||
return { ...condition, tiers: condition.tiers.map((group, groupIndex) => {
|
||
if (groupIndex !== activeTierIndex) 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">
|
||
<div className="grid items-start gap-6 xl:grid-cols-2">
|
||
<Card className="order-1">
|
||
<CardHeader>
|
||
<CardTitle>Artifact tier list</CardTitle>
|
||
<CardDescription>
|
||
จัดชุด Artifact ในกลุ่ม Late Game และ Early Game
|
||
ระบบจะบันทึกอัตโนมัติ
|
||
</CardDescription>
|
||
</CardHeader>
|
||
<CardContent className="flex flex-col gap-5">
|
||
<ConditionControls
|
||
ariaLabel="เลือกเงื่อนไขอาร์ติแฟกต์"
|
||
items={groups}
|
||
activeIndex={activeArtifactGroupIndex}
|
||
onActiveIndexChange={setActiveArtifactGroupIndex}
|
||
onRename={setRenamingArtifactGroupIndex}
|
||
onReorder={(from, to) => {
|
||
const reordered = reorderConditions(groups, activeArtifactGroupIndex, from, to);
|
||
setGroups(reordered.items);
|
||
setActiveArtifactGroupIndex(reordered.activeIndex);
|
||
}}
|
||
onAdd={() => {
|
||
setGroups([...groups, { clientId: createClientId("artifact-condition"), name: "", note: "", tiers: [{ name: "Late Game", rows: [] }, { name: "Early Game", rows: [] }] }]);
|
||
setActiveArtifactGroupIndex(groups.length);
|
||
}}
|
||
onDuplicate={() => {
|
||
const source = groups[activeArtifactGroupIndex];
|
||
if (!source) return;
|
||
setGroups([...groups, { ...source, clientId: createClientId("artifact-condition"), tiers: source.tiers.map((tier) => ({ ...tier, rows: tier.rows.map((row) => ({ ...row, clientId: createClientId("artifact") })) })) }]);
|
||
setActiveArtifactGroupIndex(groups.length);
|
||
}}
|
||
onDelete={() => {
|
||
const next = groups.filter((_, index) => index !== activeArtifactGroupIndex);
|
||
setGroups(next);
|
||
setActiveArtifactGroupIndex(Math.min(activeArtifactGroupIndex, next.length - 1));
|
||
}}
|
||
/>
|
||
<ConditionRenameDialog
|
||
open={renamingArtifactGroupIndex !== null}
|
||
fallback={`Condition ${(renamingArtifactGroupIndex ?? 0) + 1}`}
|
||
value={renamingArtifactGroupIndex === null ? "" : groups[renamingArtifactGroupIndex]?.name ?? ""}
|
||
onOpenChange={(open) => { if (!open) setRenamingArtifactGroupIndex(null); }}
|
||
onChange={(name) => setGroups(groups.map((group, index) => index === renamingArtifactGroupIndex ? { ...group, name } : group))}
|
||
/>
|
||
<Field>
|
||
<FieldLabel htmlFor={`artifact-condition-note-${groups[activeArtifactGroupIndex]?.clientId}`}>Condition note (optional)</FieldLabel>
|
||
<Textarea id={`artifact-condition-note-${groups[activeArtifactGroupIndex]?.clientId}`} value={groups[activeArtifactGroupIndex]?.note ?? ""} onChange={(event) => setGroups(groups.map((group, index) => index === activeArtifactGroupIndex ? { ...group, note: event.target.value } : group))} />
|
||
</Field>
|
||
{artifactTiers.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", activeArtifactGroupIndex, "tiers", 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_4rem_auto]">
|
||
<CatalogPicker
|
||
items={artifacts}
|
||
value={row.firstArtifactKey}
|
||
invalid={artifactIssue("groups", activeArtifactGroupIndex, "tiers", groupIndex, "rows", rowIndex, "firstArtifactKey")}
|
||
placeholder="ค้นหาชุด Artifact แรก…"
|
||
rarityBackground
|
||
showSelectedImage
|
||
onChange={(firstArtifactKey) =>
|
||
setArtifactTierRows(groupIndex, (rows) => rows.map((candidate, inner) => inner === rowIndex ? { ...candidate, firstArtifactKey } : candidate))
|
||
}
|
||
/>
|
||
<CatalogPicker
|
||
items={artifacts}
|
||
value={row.secondArtifactKey ?? ""}
|
||
placeholder="ค้นหาชุดที่สอง (ถ้ามี)…"
|
||
rarityBackground
|
||
showSelectedImage
|
||
onChange={(secondArtifactKey) =>
|
||
setArtifactTierRows(groupIndex, (rows) => rows.map((candidate, inner) => inner === rowIndex ? { ...candidate, secondArtifactKey: secondArtifactKey || null } : candidate))
|
||
}
|
||
/>
|
||
<Input
|
||
type="text"
|
||
inputMode="decimal"
|
||
aria-label="Artifact percent"
|
||
value={row.percent}
|
||
aria-invalid={artifactIssue("groups", activeArtifactGroupIndex, "tiers", groupIndex, "rows", rowIndex)}
|
||
onChange={(event) =>
|
||
setArtifactTierRows(groupIndex, (rows) => rows.map((candidate, inner) => inner === rowIndex ? { ...candidate, percent: normalizePercentInput(event.target.value, candidate.percent) } : candidate))
|
||
}
|
||
onBlur={() =>
|
||
setArtifactTierRows(groupIndex, (rows) => rows.map((candidate, inner) => inner === rowIndex ? { ...candidate, percent: finalizePercentInput(candidate.percent) } : candidate))
|
||
}
|
||
/>
|
||
<DeleteConfirmationDialog
|
||
trigger={
|
||
<Button
|
||
type="button"
|
||
variant="ghost"
|
||
size="icon"
|
||
/>
|
||
}
|
||
title={`ลบ Artifact อันดับ ${rowIndex + 1}?`}
|
||
description={`รายการ Artifact ในกลุ่ม ${group.name} และเปอร์เซ็นต์เปรียบเทียบจะถูกลบออกจาก Guide`}
|
||
onConfirm={() =>
|
||
setArtifactTierRows(groupIndex, (rows) => rows.filter((_, inner) => inner !== rowIndex))
|
||
}
|
||
>
|
||
<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) =>
|
||
setArtifactTierRows(groupIndex, (rows) => rows.map((candidate, inner) => inner === rowIndex ? { ...candidate, note: event.target.value } : candidate))
|
||
}
|
||
/>
|
||
</Field>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</SortableArtifactRow>
|
||
))}
|
||
</div>
|
||
</SortableContext>
|
||
</DndContext>
|
||
<Button
|
||
type="button"
|
||
variant="outline"
|
||
onClick={() =>
|
||
setArtifactTierRows(groupIndex, (rows) => [...rows, { clientId: createClientId("artifact"), firstArtifactKey: "", secondArtifactKey: null, percent: "100", note: "" }])
|
||
}
|
||
>
|
||
<PlusIcon data-icon="inline-start" />
|
||
เพิ่มชุด Artifact
|
||
</Button>
|
||
</CardContent>
|
||
</Card>
|
||
))}
|
||
</CardContent>
|
||
</Card>
|
||
<Card className="order-3 overflow-hidden pt-0">
|
||
<CardHeader className="bg-primary py-4 text-center text-primary-foreground">
|
||
<CardTitle className="text-2xl">Recommended Stats</CardTitle>
|
||
</CardHeader>
|
||
<CardContent>
|
||
<FieldGroup>
|
||
<ConditionControls
|
||
ariaLabel="เลือกเงื่อนไขค่าสถานะแนะนำ"
|
||
items={recommendedGroups}
|
||
activeIndex={activeRecommendedGroupIndex}
|
||
onActiveIndexChange={setActiveRecommendedGroupIndex}
|
||
onRename={setRenamingRecommendedGroupIndex}
|
||
onReorder={(from, to) => {
|
||
const reordered = reorderConditions(recommendedGroups, activeRecommendedGroupIndex, from, to);
|
||
setRecommendedGroups(reordered.items);
|
||
setActiveRecommendedGroupIndex(reordered.activeIndex);
|
||
}}
|
||
onAdd={() => {
|
||
setRecommendedGroups([...recommendedGroups, { clientId: createClientId("recommended-condition"), name: "", note: "", sands: [], goblet: [], circlet: [], substats: [] }]);
|
||
setActiveRecommendedGroupIndex(recommendedGroups.length);
|
||
}}
|
||
onDuplicate={() => {
|
||
const source = recommendedGroups[activeRecommendedGroupIndex];
|
||
if (!source) return;
|
||
setRecommendedGroups([...recommendedGroups, { clientId: createClientId("recommended-condition"), name: source.name, note: source.note, sands: [...source.sands], goblet: [...source.goblet], circlet: [...source.circlet], substats: [...source.substats] }]);
|
||
setActiveRecommendedGroupIndex(recommendedGroups.length);
|
||
}}
|
||
onDelete={() => {
|
||
setRecommendedGroups(recommendedGroups.filter((_, index) => index !== activeRecommendedGroupIndex));
|
||
setActiveRecommendedGroupIndex(Math.max(0, activeRecommendedGroupIndex - 1));
|
||
}}
|
||
/>
|
||
<ConditionRenameDialog
|
||
open={renamingRecommendedGroupIndex !== null}
|
||
fallback={`Condition ${(renamingRecommendedGroupIndex ?? 0) + 1}`}
|
||
value={renamingRecommendedGroupIndex === null ? "" : recommendedGroups[renamingRecommendedGroupIndex]?.name ?? ""}
|
||
onOpenChange={(open) => { if (!open) setRenamingRecommendedGroupIndex(null); }}
|
||
onChange={(name) => setRecommendedGroups(recommendedGroups.map((group, index) => index === renamingRecommendedGroupIndex ? { ...group, name } : group))}
|
||
/>
|
||
<Field>
|
||
<FieldLabel htmlFor={`recommended-condition-note-${recommendedGroups[activeRecommendedGroupIndex]?.clientId}`}>Condition note (optional)</FieldLabel>
|
||
<Textarea id={`recommended-condition-note-${recommendedGroups[activeRecommendedGroupIndex]?.clientId}`} value={recommendedGroups[activeRecommendedGroupIndex]?.note ?? ""} onChange={(event) => setRecommendedGroups(recommendedGroups.map((group, index) => index === activeRecommendedGroupIndex ? { ...group, note: event.target.value } : group))} />
|
||
</Field>
|
||
|
||
<div className="grid gap-4 sm:grid-cols-3">
|
||
<StatSelect
|
||
label="Sands / นาฬิกา"
|
||
options={mainStatOptions.sands}
|
||
value={sands}
|
||
onChange={(values) => setRecommendedStat("sands", values)}
|
||
elementIconUrls={elementIconUrls}
|
||
priorityColors
|
||
/>
|
||
<StatSelect
|
||
label="Goblet / ถ้วย"
|
||
options={mainStatOptions.goblet}
|
||
value={goblet}
|
||
onChange={(values) => setRecommendedStat("goblet", values)}
|
||
elementIconUrls={elementIconUrls}
|
||
priorityColors
|
||
/>
|
||
<StatSelect
|
||
label="Circlet / หมวก"
|
||
options={mainStatOptions.circlet}
|
||
value={circlet}
|
||
onChange={(values) => setRecommendedStat("circlet", values)}
|
||
elementIconUrls={elementIconUrls}
|
||
priorityColors
|
||
/>
|
||
</div>
|
||
<StatSelect
|
||
label="Substats"
|
||
options={substatOptions}
|
||
value={substats}
|
||
onChange={(values) => setRecommendedStat("substats", values)}
|
||
elementIconUrls={elementIconUrls}
|
||
priorityColors
|
||
/>
|
||
</FieldGroup>
|
||
</CardContent>
|
||
</Card>
|
||
<Card className="order-2 xl:row-span-2 xl:h-full">
|
||
<CardHeader>
|
||
<CardTitle>Target Stats</CardTitle>
|
||
<CardDescription>
|
||
ตั้งค่าเป้าหมายค่าสถานะสำหรับแต่ละ Condition
|
||
</CardDescription>
|
||
</CardHeader>
|
||
<CardContent>
|
||
<FieldGroup>
|
||
<FieldSet>
|
||
<FieldLegend>Target Stats</FieldLegend>
|
||
<ConditionControls
|
||
ariaLabel="เลือกเงื่อนไขค่าสถานะเป้าหมาย"
|
||
items={targetGroups}
|
||
activeIndex={activeTargetGroupIndex}
|
||
onActiveIndexChange={setActiveTargetGroupIndex}
|
||
onRename={setRenamingTargetGroupIndex}
|
||
onReorder={(from, to) => {
|
||
const reordered = reorderConditions(targetGroups, activeTargetGroupIndex, from, to);
|
||
setTargetGroups(reordered.items);
|
||
setActiveTargetGroupIndex(reordered.activeIndex);
|
||
}}
|
||
onAdd={() => {
|
||
setTargetGroups([...targetGroups, { clientId: createClientId("target-condition"), name: "", note: "", rows: targetStatDefaults.map((target) => ({ ...target })) }]);
|
||
setActiveTargetGroupIndex(targetGroups.length);
|
||
}}
|
||
onDuplicate={() => {
|
||
const source = targetGroups[activeTargetGroupIndex];
|
||
if (!source) return;
|
||
setTargetGroups([...targetGroups, { clientId: createClientId("target-condition"), name: source.name, note: source.note, rows: source.rows.map((row) => ({ ...row })) }]);
|
||
setActiveTargetGroupIndex(targetGroups.length);
|
||
}}
|
||
onDelete={() => {
|
||
setTargetGroups(targetGroups.filter((_, index) => index !== activeTargetGroupIndex));
|
||
setActiveTargetGroupIndex(Math.max(0, activeTargetGroupIndex - 1));
|
||
}}
|
||
/>
|
||
<ConditionRenameDialog
|
||
open={renamingTargetGroupIndex !== null}
|
||
fallback={`Condition ${(renamingTargetGroupIndex ?? 0) + 1}`}
|
||
value={renamingTargetGroupIndex === null ? "" : targetGroups[renamingTargetGroupIndex]?.name ?? ""}
|
||
onOpenChange={(open) => { if (!open) setRenamingTargetGroupIndex(null); }}
|
||
onChange={(name) => setTargetGroups(targetGroups.map((group, index) => index === renamingTargetGroupIndex ? { ...group, name } : group))}
|
||
/>
|
||
<Field>
|
||
<FieldLabel htmlFor={`target-condition-note-${targetGroups[activeTargetGroupIndex]?.clientId}`}>Condition note (optional)</FieldLabel>
|
||
<Textarea id={`target-condition-note-${targetGroups[activeTargetGroupIndex]?.clientId}`} value={targetGroups[activeTargetGroupIndex]?.note ?? ""} onChange={(event) => setTargetGroups(targetGroups.map((group, index) => index === activeTargetGroupIndex ? { ...group, note: event.target.value } : group))} />
|
||
</Field>
|
||
<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 rounded-md bg-muted/40 px-3 py-2"
|
||
>
|
||
<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>
|
||
</FieldGroup>
|
||
</CardContent>
|
||
</Card>
|
||
</div>
|
||
<Card>
|
||
<CardHeader>
|
||
<CardTitle>Talent Upgrade</CardTitle>
|
||
<CardDescription>
|
||
จัดลำดับ Talent เลือกระดับเป้าหมาย และกำหนดความสำคัญ
|
||
</CardDescription>
|
||
</CardHeader>
|
||
<CardContent>
|
||
<FieldGroup>
|
||
<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>
|
||
</FieldGroup>
|
||
</CardContent>
|
||
</Card>
|
||
<Card>
|
||
<CardContent>
|
||
<FieldGroup>
|
||
<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}
|
||
conflictVersion={autosave.conflictVersion}
|
||
canOverwrite={autosave.canOverwrite}
|
||
onReloadLatest={autosave.reloadLatest}
|
||
onOverwrite={autosave.overwrite}
|
||
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", name: null, note: null, rows: [] }];
|
||
return source.map((group) => ({
|
||
clientId: group.id,
|
||
name: group.name ?? "",
|
||
note: group.note ?? "",
|
||
rows: makeRows(group.rows),
|
||
}));
|
||
});
|
||
const [activeConstellationGroupIndex, setActiveConstellationGroupIndex] = useState(0);
|
||
const [renamingConstellationGroupIndex, setRenamingConstellationGroupIndex] = useState<number | null>(null);
|
||
const value = {
|
||
note: note.trim() || null,
|
||
groups: groups.map((group) => ({
|
||
name: group.name.trim() || null,
|
||
note: group.note.trim() || null,
|
||
rows: group.rows
|
||
.filter((row) => row.description.trim() && row.cumulativePercent !== "")
|
||
.map((row) => ({
|
||
level: row.level,
|
||
detail: row.description,
|
||
cumulativePercent: Number(row.cumulativePercent),
|
||
isGreat: row.isGreat,
|
||
})),
|
||
})),
|
||
};
|
||
const validation = constellationSectionSchema.safeParse({
|
||
...value,
|
||
expectedVersion: guide.version,
|
||
});
|
||
const constellationIssue = (...path: (string | number)[]) =>
|
||
!validation.success && hasIssuePrefix(validation.error.issues, ...path);
|
||
const localDraft = { note, groups };
|
||
const autosave = useAutosave({
|
||
guideId: guide.id,
|
||
scope: "constellations",
|
||
value,
|
||
valid: validation.success,
|
||
initialVersion: guide.version,
|
||
persist: (next, expectedVersion, intent) =>
|
||
saveConstellationSection(guide.id, { ...next, expectedVersion }, intent),
|
||
storageKey: adminDraftKey(guide.id, "constellations"),
|
||
localDraft,
|
||
parseLocalDraft: (candidate) =>
|
||
parseEditorDraft<typeof localDraft>(candidate, ["groups"], ["note"]),
|
||
restoreLocalDraft: (draft) => {
|
||
setNote(draft.note);
|
||
setGroups(draft.groups.map((group) => ({ ...group, name: group.name ?? "" })));
|
||
},
|
||
});
|
||
return (
|
||
<Card>
|
||
<CardHeader>
|
||
<CardTitle>Constellations</CardTitle>
|
||
<CardDescription>
|
||
ชื่อ รายละเอียด และไอคอนมาจาก Lunaris อัตโนมัติ
|
||
กรอกเฉพาะเปอร์เซ็นต์เทียบกับ C0 = 100%
|
||
</CardDescription>
|
||
</CardHeader>
|
||
<CardContent className="flex flex-col gap-4">
|
||
<Field>
|
||
<FieldLabel htmlFor="constellation-section-note">
|
||
Header note (optional)
|
||
</FieldLabel>
|
||
<Textarea
|
||
id="constellation-section-note"
|
||
value={note}
|
||
onChange={(event) => setNote(event.target.value)}
|
||
/>
|
||
</Field>
|
||
<ConditionControls
|
||
ariaLabel="เลือกเงื่อนไขกลุ่มดาว"
|
||
items={groups}
|
||
activeIndex={activeConstellationGroupIndex}
|
||
onActiveIndexChange={setActiveConstellationGroupIndex}
|
||
onRename={setRenamingConstellationGroupIndex}
|
||
onReorder={(from, to) => {
|
||
const reordered = reorderConditions(groups, activeConstellationGroupIndex, from, to);
|
||
setGroups(reordered.items);
|
||
setActiveConstellationGroupIndex(reordered.activeIndex);
|
||
}}
|
||
onAdd={() => {
|
||
setGroups([
|
||
...groups,
|
||
{
|
||
clientId: createClientId("constellation-condition"),
|
||
name: "",
|
||
note: "",
|
||
rows: makeRows(),
|
||
},
|
||
]);
|
||
setActiveConstellationGroupIndex(groups.length);
|
||
}}
|
||
onDelete={() => {
|
||
setGroups(groups.filter((_, index) => index !== activeConstellationGroupIndex));
|
||
setActiveConstellationGroupIndex(Math.max(0, activeConstellationGroupIndex - 1));
|
||
}}
|
||
/>
|
||
<ConditionRenameDialog
|
||
open={renamingConstellationGroupIndex !== null}
|
||
fallback={`Condition ${(renamingConstellationGroupIndex ?? 0) + 1}`}
|
||
value={renamingConstellationGroupIndex === null ? "" : groups[renamingConstellationGroupIndex]?.name ?? ""}
|
||
onOpenChange={(open) => { if (!open) setRenamingConstellationGroupIndex(null); }}
|
||
onChange={(name) => setGroups(groups.map((group, index) => index === renamingConstellationGroupIndex ? { ...group, name } : group))}
|
||
/>
|
||
{groups[activeConstellationGroupIndex] ? [groups[activeConstellationGroupIndex]].map((group) => {
|
||
const groupIndex = activeConstellationGroupIndex;
|
||
return (
|
||
<Card key={group.clientId} size="sm">
|
||
<CardHeader>
|
||
<CardTitle>Condition {groupIndex + 1}</CardTitle>
|
||
<CardDescription>
|
||
ใช้ note เพื่ออธิบายอาวุธ ทีม หรือเงื่อนไขที่ใช้เปรียบเทียบ
|
||
</CardDescription>
|
||
</CardHeader>
|
||
<CardContent className="flex flex-col gap-4">
|
||
<Field>
|
||
<FieldLabel
|
||
htmlFor={`constellation-condition-note-${group.clientId}`}
|
||
>
|
||
Condition note (optional)
|
||
</FieldLabel>
|
||
<Textarea
|
||
id={`constellation-condition-note-${group.clientId}`}
|
||
value={group.note}
|
||
onChange={(event) =>
|
||
setGroups(
|
||
groups.map((item) =>
|
||
item.clientId === group.clientId
|
||
? { ...item, note: event.target.value }
|
||
: item,
|
||
),
|
||
)
|
||
}
|
||
/>
|
||
</Field>
|
||
<div className="grid gap-4 lg:grid-cols-2">
|
||
{group.rows.map((row, rowIndex) => (
|
||
<Card
|
||
key={row.level}
|
||
size="sm"
|
||
className={cn(
|
||
constellationIssue("groups", groupIndex, "rows", rowIndex) && "border-destructive",
|
||
)}
|
||
>
|
||
<CardHeader>
|
||
{row.iconUrl ? (
|
||
<Image
|
||
src={row.iconUrl}
|
||
alt=""
|
||
width={48}
|
||
height={48}
|
||
className="rounded-lg bg-muted"
|
||
/>
|
||
) : null}
|
||
<CardTitle>
|
||
C{row.level} · {row.name}
|
||
</CardTitle>
|
||
<CardDescription>{row.description}</CardDescription>
|
||
</CardHeader>
|
||
<CardContent className="flex items-end gap-3">
|
||
<Field className="min-w-0 flex-1">
|
||
<FieldLabel>Cumulative %</FieldLabel>
|
||
<Input
|
||
type="text"
|
||
inputMode="decimal"
|
||
value={row.cumulativePercent}
|
||
aria-invalid={constellationIssue("groups", groupIndex, "rows", rowIndex, "cumulativePercent")}
|
||
onChange={(event) =>
|
||
setGroups(
|
||
groups.map((item) =>
|
||
item.clientId === group.clientId
|
||
? {
|
||
...item,
|
||
rows: item.rows.map((candidate, index) =>
|
||
index === rowIndex
|
||
? {
|
||
...candidate,
|
||
cumulativePercent:
|
||
normalizePercentInput(
|
||
event.target.value,
|
||
candidate.cumulativePercent,
|
||
),
|
||
}
|
||
: candidate,
|
||
),
|
||
}
|
||
: item,
|
||
),
|
||
)
|
||
}
|
||
onBlur={() =>
|
||
setGroups(
|
||
groups.map((item) =>
|
||
item.clientId === group.clientId
|
||
? {
|
||
...item,
|
||
rows: item.rows.map((candidate, index) =>
|
||
index === rowIndex
|
||
? {
|
||
...candidate,
|
||
cumulativePercent:
|
||
finalizePercentInput(
|
||
candidate.cumulativePercent,
|
||
),
|
||
}
|
||
: candidate,
|
||
),
|
||
}
|
||
: item,
|
||
),
|
||
)
|
||
}
|
||
/>
|
||
</Field>
|
||
<Toggle
|
||
variant="outline"
|
||
pressed={row.isGreat}
|
||
onPressedChange={(isGreat) =>
|
||
setGroups(
|
||
groups.map((item) =>
|
||
item.clientId === group.clientId
|
||
? {
|
||
...item,
|
||
rows: item.rows.map((candidate, index) =>
|
||
index === rowIndex
|
||
? { ...candidate, isGreat }
|
||
: candidate,
|
||
),
|
||
}
|
||
: item,
|
||
),
|
||
)
|
||
}
|
||
aria-label={`Mark Condition ${groupIndex + 1} C${row.level} percentage as great`}
|
||
className={cn(
|
||
SELECTED_TOGGLE_CLASS,
|
||
"data-[state=on]:text-warning",
|
||
)}
|
||
>
|
||
<SparklesIcon data-icon="inline-start" />
|
||
Great %
|
||
</Toggle>
|
||
</CardContent>
|
||
</Card>
|
||
))}
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
);}) : null}
|
||
</CardContent>
|
||
<CardFooter className="justify-end">
|
||
<AutosaveStatus
|
||
state={autosave.state}
|
||
conflictVersion={autosave.conflictVersion}
|
||
canOverwrite={autosave.canOverwrite}
|
||
onReloadLatest={autosave.reloadLatest}
|
||
onOverwrite={autosave.overwrite}
|
||
issues={
|
||
validation.success
|
||
? []
|
||
: formatGuideValidationIssues(
|
||
"constellations",
|
||
validation.error.issues,
|
||
)
|
||
}
|
||
onSave={autosave.saveNow}
|
||
/>
|
||
</CardFooter>
|
||
</Card>
|
||
);
|
||
}
|
||
|
||
type TeamImportGuide = {
|
||
id: string;
|
||
name: string;
|
||
groups: Array<{
|
||
id: string;
|
||
name: string | null;
|
||
teams: Array<{
|
||
id: string;
|
||
title: string;
|
||
rotationSeconds: string;
|
||
combo: string;
|
||
members: Array<TeamToolValue["members"][number] & { characterName: string }>;
|
||
}>;
|
||
}>;
|
||
};
|
||
|
||
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 [teamGroups, setTeamGroups] = useState(() => initial.teamGroups.length
|
||
? initial.teamGroups.map((group) => ({ clientId: group.id, name: group.name ?? "", note: group.note ?? "", teams: group.teams.map(({ id, ...team }) => ({ clientId: id, ...team })) }))
|
||
: [{ clientId: "team-condition-default", name: "", note: "", teams: [] as TeamToolValue[] }]);
|
||
const [activeTeamGroupIndex, setActiveTeamGroupIndex] = useState(0);
|
||
const [renamingTeamGroupIndex, setRenamingTeamGroupIndex] = useState<number | null>(null);
|
||
const teams = useMemo(() => teamGroups[activeTeamGroupIndex]?.teams ?? [], [activeTeamGroupIndex, teamGroups]);
|
||
const setTeams = (update: SetStateAction<TeamToolValue[]>) => setTeamGroups((current) => current.map((group, index) => index === activeTeamGroupIndex ? { ...group, teams: typeof update === "function" ? update(group.teams) : update } : group));
|
||
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 [importFiles, setImportFiles] = useState<File[]>([]);
|
||
const [guideImportOpen, setGuideImportOpen] = useState(false);
|
||
const [importGuides, setImportGuides] = useState<Array<{ id: string; name: string; characterKey: string; isPublic: boolean; coverUrl: string }> | null>(null);
|
||
const [activeImportGuide, setActiveImportGuide] = useState<TeamImportGuide | null>(null);
|
||
const [selectedImports, setSelectedImports] = useState<Record<string, Omit<TeamToolValue, "clientId">>>({});
|
||
const [guideImportLoading, setGuideImportLoading] = useState(false);
|
||
const [guideImportError, setGuideImportError] = useState<string | null>(null);
|
||
const [guideImportCondition, setGuideImportCondition] = useState("0");
|
||
const [guideTeamQuery, setGuideTeamQuery] = useState("");
|
||
const [guideTeamSearchTarget, setGuideTeamSearchTarget] = useState<TeamSearchTarget>("all");
|
||
const [guideListQuery, setGuideListQuery] = useState("");
|
||
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 [multipleApplyGuideId, setMultipleApplyGuideId] = useState<string | null>(null);
|
||
const [searchQuery, setSearchQuery] = useState("");
|
||
const [searchTarget, setSearchTarget] = useState<TeamSearchTarget>("all");
|
||
const teamSearch = useMemo(() => {
|
||
const characterNames = new Map(characters.map((item) => [item.key, [item.name, ...(item.aliases ?? [])]]));
|
||
const weaponNames = new Map(weapons.map((item) => [item.key, [item.name, ...(item.aliases ?? [])]]));
|
||
const artifactNames = new Map(artifacts.map((item) => [item.key, [item.name, ...(item.aliases ?? [])]]));
|
||
|
||
return searchTeams(teams.map((team, teamIndex) => ({ team, teamIndex })), ({ team }) => ({
|
||
team: [team.title],
|
||
character: team.members.flatMap((member) => {
|
||
const name = characterNames.get(member.characterKey);
|
||
return name ?? [];
|
||
}),
|
||
weapon: team.members.flatMap((member) => {
|
||
const name = member.weaponKey
|
||
? weaponNames.get(member.weaponKey)
|
||
: undefined;
|
||
return name ?? [];
|
||
}),
|
||
artifact: team.members.flatMap((member) =>
|
||
[member.artifactKey, member.secondArtifactKey].flatMap((key) => {
|
||
const name = key ? artifactNames.get(key) : undefined;
|
||
return name ?? [];
|
||
}),
|
||
),
|
||
}), searchQuery, searchTarget);
|
||
}, [artifacts, characters, searchQuery, searchTarget, teams, weapons]);
|
||
const activeImportGroup = activeImportGuide?.groups[Number(guideImportCondition)]
|
||
?? activeImportGuide?.groups[0];
|
||
const importTeamSearch = useMemo(() => {
|
||
const characterNames = new Map(characters.map((item) => [item.key, [item.name, ...(item.aliases ?? [])]]));
|
||
const weaponNames = new Map(weapons.map((item) => [item.key, [item.name, ...(item.aliases ?? [])]]));
|
||
const artifactNames = new Map(artifacts.map((item) => [item.key, [item.name, ...(item.aliases ?? [])]]));
|
||
return searchTeams(activeImportGroup?.teams ?? [], (team) => ({
|
||
team: [team.title],
|
||
character: team.members.flatMap((member) => characterNames.get(member.characterKey) ?? [member.characterName]),
|
||
weapon: team.members.flatMap((member) => member.weaponKey ? weaponNames.get(member.weaponKey) ?? [] : []),
|
||
artifact: team.members.flatMap((member) => [member.artifactKey, member.secondArtifactKey].flatMap((key) => key ? artifactNames.get(key) ?? [] : [])),
|
||
}), guideTeamQuery, guideTeamSearchTarget);
|
||
}, [activeImportGroup, artifacts, characters, guideTeamQuery, guideTeamSearchTarget, weapons]);
|
||
const importGuideSearch = useMemo(() => {
|
||
return searchItems(importGuides ?? [], guideListQuery,
|
||
(sourceGuide) => `${sourceGuide.name} ${sourceGuide.characterKey}`,
|
||
(sourceGuide) => [sourceGuide.name]);
|
||
}, [guideListQuery, importGuides]);
|
||
const filteredTeams = teamSearch.items;
|
||
const filteredImportTeams = importTeamSearch.items;
|
||
const filteredImportGuides = importGuideSearch.items;
|
||
const value = {
|
||
groups: teamGroups.map((group) => ({ name: group.name.trim() || null, note: group.note.trim() || null, teams: group.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] === "groups" && issue.path[1] === activeTeamGroupIndex && issue.path[2] === "teams" && issue.path[3] === teamIndex && issue.path[4] === field,
|
||
);
|
||
const memberFieldInvalid = (teamIndex: number, memberIndex: number, field: string) =>
|
||
!validation.success && validation.error.issues.some((issue) =>
|
||
issue.path[0] === "groups" && issue.path[1] === activeTeamGroupIndex && issue.path[2] === "teams" && issue.path[3] === teamIndex && issue.path[4] === "members" &&
|
||
issue.path[5] === memberIndex && issue.path[6] === field,
|
||
);
|
||
const localDraft = { teamGroups, activeTeamGroupIndex };
|
||
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(() => {
|
||
const timer = window.setTimeout(() => {
|
||
try {
|
||
const stored = JSON.parse(
|
||
window.localStorage.getItem(multipleApplyStorageKey(guide.id)) ?? "null",
|
||
) as { selectedTeamIds?: unknown; applyFields?: unknown } | null;
|
||
const teamIds = new Set(initial.teamGroups.flatMap((group) => group.teams.map((team) => team.id)));
|
||
setSelectedTeamIds(new Set(
|
||
Array.isArray(stored?.selectedTeamIds)
|
||
? stored.selectedTeamIds.filter(
|
||
(id): id is string => typeof id === "string" && teamIds.has(id),
|
||
)
|
||
: [],
|
||
));
|
||
setApplyFields(
|
||
Array.isArray(stored?.applyFields)
|
||
? stored.applyFields.filter(
|
||
(field): field is TeamApplySelection => typeof field === "string",
|
||
)
|
||
: [],
|
||
);
|
||
} catch {
|
||
setSelectedTeamIds(new Set());
|
||
setApplyFields([]);
|
||
}
|
||
setMultipleApplyGuideId(guide.id);
|
||
}, 0);
|
||
return () => window.clearTimeout(timer);
|
||
}, [guide.id, initial.teamGroups]);
|
||
useEffect(() => {
|
||
if (multipleApplyGuideId !== guide.id) return;
|
||
try {
|
||
window.localStorage.setItem(
|
||
multipleApplyStorageKey(guide.id),
|
||
JSON.stringify({
|
||
selectedTeamIds: [...selectedTeamIds],
|
||
applyFields,
|
||
}),
|
||
);
|
||
} catch {
|
||
// Browser storage is optional; multiple apply remains usable without it.
|
||
}
|
||
}, [applyFields, guide.id, multipleApplyGuideId, selectedTeamIds]);
|
||
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({
|
||
guideId: guide.id,
|
||
scope: "team",
|
||
value,
|
||
valid: validation.success,
|
||
initialVersion: guide.version,
|
||
persist: (next, expectedVersion, intent) =>
|
||
saveTeamSection(guide.id, { ...next, expectedVersion }, intent),
|
||
storageKey: adminDraftKey(guide.id, "team"),
|
||
localDraft,
|
||
parseLocalDraft: (candidate) =>
|
||
parseEditorDraft<typeof localDraft>(candidate, ["teamGroups"]),
|
||
restoreLocalDraft: (draft) => {
|
||
setTeamGroups(draft.teamGroups.map((group) => ({ ...group, name: group.name ?? "" })));
|
||
setActiveTeamGroupIndex(draft.activeTeamGroupIndex ?? 0);
|
||
},
|
||
});
|
||
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 updateTeam = (
|
||
teamIndex: number,
|
||
update: Partial<Omit<TeamToolValue, "clientId" | "members">>,
|
||
) => {
|
||
setTeams((current) =>
|
||
current.map((team, index) =>
|
||
index === teamIndex ? { ...team, ...update } : team,
|
||
),
|
||
);
|
||
};
|
||
const updateTeamMember = (
|
||
teamIndex: number,
|
||
memberIndex: number,
|
||
update: Partial<TeamToolValue["members"][number]>,
|
||
) => {
|
||
setTeams((current) =>
|
||
current.map((team, index) =>
|
||
index === teamIndex
|
||
? {
|
||
...team,
|
||
members: team.members.map((member, innerIndex) =>
|
||
innerIndex === memberIndex ? { ...member, ...update } : member,
|
||
),
|
||
}
|
||
: team,
|
||
),
|
||
);
|
||
};
|
||
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 appendImportedTeams = (importedTeams: Array<Omit<TeamToolValue, "clientId">>) => {
|
||
if (!importedTeams.length) return false;
|
||
if (teams.length + importedTeams.length > 30) {
|
||
toast.error("Import ไม่ได้ เพราะ Guide จะมี Team เกิน 30 Team");
|
||
return false;
|
||
}
|
||
for (const imported of importedTeams) {
|
||
const catalogIssue = findInvalidTeamCatalogReference(
|
||
imported,
|
||
characters,
|
||
weapons,
|
||
artifacts,
|
||
);
|
||
if (catalogIssue) {
|
||
toast.error(catalogIssue);
|
||
return false;
|
||
}
|
||
}
|
||
setTeams((current) => [
|
||
...current,
|
||
...cloneTeamImports(importedTeams, () => createClientId("team")),
|
||
]);
|
||
return true;
|
||
};
|
||
const importTeamFiles = async () => {
|
||
try {
|
||
const serializedTeams = await Promise.all(importFiles.map((file) => file.text()));
|
||
if (!serializedTeams.length) return;
|
||
const importedTeams = serializedTeams.map(parseTeamTransfer);
|
||
if (!appendImportedTeams(importedTeams)) return;
|
||
setImportFiles([]);
|
||
setImportOpen(false);
|
||
toast.success(`Import ${importedTeams.length} Team แล้ว`);
|
||
} catch {
|
||
toast.error("มีไฟล์ Team ที่ไม่ถูกต้องหรือเป็นเวอร์ชันที่ไม่รองรับ");
|
||
}
|
||
};
|
||
const openGuideImport = async () => {
|
||
setImportOpen(false);
|
||
setGuideImportOpen(true);
|
||
setActiveImportGuide(null);
|
||
setSelectedImports({});
|
||
setGuideListQuery("");
|
||
setGuideImportError(null);
|
||
setGuideImportLoading(true);
|
||
const result = await listTeamImportGuides(guide.id);
|
||
setGuideImportLoading(false);
|
||
if (result.status === "success") setImportGuides(result.guides);
|
||
else setGuideImportError(result.message);
|
||
};
|
||
const enterImportGuide = async (guideId: string) => {
|
||
setGuideImportError(null);
|
||
setGuideImportLoading(true);
|
||
const result = await loadTeamImportGuide(guideId);
|
||
setGuideImportLoading(false);
|
||
if (result.status === "success") {
|
||
setGuideImportCondition("0");
|
||
setGuideTeamQuery("");
|
||
setGuideTeamSearchTarget("all");
|
||
setActiveImportGuide(result.guide);
|
||
}
|
||
else setGuideImportError(result.message);
|
||
};
|
||
const confirmGuideImport = () => {
|
||
const importedTeams = Object.values(selectedImports);
|
||
if (!appendImportedTeams(importedTeams)) return;
|
||
setGuideImportOpen(false);
|
||
setSelectedImports({});
|
||
toast.success(`Import ${importedTeams.length} Team แล้ว`);
|
||
};
|
||
return (
|
||
<Card>
|
||
<CardHeader>
|
||
<CardTitle>Team Calc</CardTitle>
|
||
<CardDescription>
|
||
ลากเพื่อเรียง Team แล้วใส่ความเสียหายของตัวละคร 4 ตัว
|
||
ระบบจะแสดงสัดส่วนและ Total DPS และบันทึกอัตโนมัติ
|
||
</CardDescription>
|
||
</CardHeader>
|
||
<CardContent className="flex flex-col gap-4">
|
||
<ConditionControls
|
||
ariaLabel="เลือกเงื่อนไข Team"
|
||
items={teamGroups}
|
||
activeIndex={activeTeamGroupIndex}
|
||
onActiveIndexChange={(nextIndex) => {
|
||
const group = teamGroups[nextIndex];
|
||
if (!group) return;
|
||
setActiveTeamGroupIndex(nextIndex);
|
||
setSelectedTeamIds(new Set());
|
||
setSourceTeamId(group.teams[0]?.clientId ?? "");
|
||
}}
|
||
onRename={setRenamingTeamGroupIndex}
|
||
onReorder={(from, to) => {
|
||
const reordered = reorderConditions(teamGroups, activeTeamGroupIndex, from, to);
|
||
setTeamGroups(reordered.items);
|
||
setActiveTeamGroupIndex(reordered.activeIndex);
|
||
}}
|
||
onAdd={() => {
|
||
setTeamGroups([...teamGroups, { clientId: createClientId("team-condition"), name: "", note: "", teams: [] }]);
|
||
setActiveTeamGroupIndex(teamGroups.length);
|
||
setSelectedTeamIds(new Set());
|
||
setSourceTeamId("");
|
||
}}
|
||
onDuplicate={() => {
|
||
const source = teamGroups[activeTeamGroupIndex];
|
||
if (!source) return;
|
||
const copiedTeams = source.teams.map((team) => ({ ...team, clientId: createClientId("team"), members: team.members.map((member) => ({ ...member })) }));
|
||
setTeamGroups([...teamGroups, { clientId: createClientId("team-condition"), name: source.name, note: source.note, teams: copiedTeams }]);
|
||
setActiveTeamGroupIndex(teamGroups.length);
|
||
setSelectedTeamIds(new Set());
|
||
setSourceTeamId(copiedTeams[0]?.clientId ?? "");
|
||
}}
|
||
onDelete={() => {
|
||
setTeamGroups(teamGroups.filter((_, index) => index !== activeTeamGroupIndex));
|
||
setActiveTeamGroupIndex(Math.max(0, activeTeamGroupIndex - 1));
|
||
setSelectedTeamIds(new Set());
|
||
}}
|
||
/>
|
||
<ConditionRenameDialog
|
||
open={renamingTeamGroupIndex !== null}
|
||
fallback={`Condition ${(renamingTeamGroupIndex ?? 0) + 1}`}
|
||
value={renamingTeamGroupIndex === null ? "" : teamGroups[renamingTeamGroupIndex]?.name ?? ""}
|
||
onOpenChange={(open) => { if (!open) setRenamingTeamGroupIndex(null); }}
|
||
onChange={(name) => setTeamGroups(teamGroups.map((group, index) => index === renamingTeamGroupIndex ? { ...group, name } : group))}
|
||
/>
|
||
<Field>
|
||
<FieldLabel htmlFor={`team-condition-note-${teamGroups[activeTeamGroupIndex]?.clientId}`}>Condition note (optional)</FieldLabel>
|
||
<Textarea id={`team-condition-note-${teamGroups[activeTeamGroupIndex]?.clientId}`} value={teamGroups[activeTeamGroupIndex]?.note ?? ""} onChange={(event) => setTeamGroups(teamGroups.map((group, index) => index === activeTeamGroupIndex ? { ...group, note: event.target.value } : group))} />
|
||
</Field>
|
||
<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="w-full basis-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={rectSortingStrategy}
|
||
>
|
||
{filteredTeams.length ? (
|
||
<div className="grid gap-5 xl:grid-cols-2">
|
||
{filteredTeams.map(({ team, teamIndex }) => {
|
||
const total = team.members.reduce(
|
||
(sum, member) => sum + (Number(member.damage) || 0),
|
||
0,
|
||
);
|
||
const seconds = Number(team.rotationSeconds);
|
||
const dps = seconds > 0 ? total / seconds : 0;
|
||
return (
|
||
<SortableTeamItem key={team.clientId} id={team.clientId}>
|
||
{(dragHandle) => (
|
||
<Card
|
||
className="h-full"
|
||
size="sm"
|
||
data-invalid={teamFieldInvalid(teamIndex, "members")}
|
||
>
|
||
<CardHeader>
|
||
<CardTitle>
|
||
<Field data-invalid={teamFieldInvalid(teamIndex, "title")}>
|
||
<FieldLabel className="sr-only" htmlFor={`team-title-${team.clientId}`}>
|
||
ชื่อ Team
|
||
</FieldLabel>
|
||
<Input
|
||
id={`team-title-${team.clientId}`}
|
||
value={team.title}
|
||
placeholder={`Team ${teamIndex + 1}`}
|
||
aria-invalid={teamFieldInvalid(teamIndex, "title")}
|
||
onChange={(event) =>
|
||
updateTeam(teamIndex, { title: event.target.value })
|
||
}
|
||
/>
|
||
</Field>
|
||
</CardTitle>
|
||
<CardDescription>
|
||
<Field
|
||
className="w-fit justify-start"
|
||
orientation="horizontal"
|
||
data-invalid={teamFieldInvalid(teamIndex, "rotationSeconds")}
|
||
>
|
||
<FieldLabel
|
||
className="flex-none"
|
||
htmlFor={`team-rotation-${team.clientId}`}
|
||
>
|
||
Rotation
|
||
</FieldLabel>
|
||
<Input
|
||
id={`team-rotation-${team.clientId}`}
|
||
className="w-24 text-foreground"
|
||
type="number"
|
||
min="0.01"
|
||
step="0.5"
|
||
value={team.rotationSeconds}
|
||
aria-invalid={teamFieldInvalid(teamIndex, "rotationSeconds")}
|
||
onChange={(event) =>
|
||
updateTeam(teamIndex, {
|
||
rotationSeconds: event.target.value,
|
||
})
|
||
}
|
||
/>
|
||
<span>วินาที</span>
|
||
</Field>
|
||
</CardDescription>
|
||
<CardAction className="flex max-w-44 flex-wrap justify-end gap-1">
|
||
<Checkbox
|
||
checked={selectedTeamIds.has(team.clientId)}
|
||
onCheckedChange={(checked) =>
|
||
toggleTeamSelection(team.clientId, checked === true)
|
||
}
|
||
aria-label={`เลือก ${team.title || `Team ${teamIndex + 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)`;
|
||
return current.toSpliced(sourceIndex + 1, 0, {
|
||
...source,
|
||
clientId: createClientId("team"),
|
||
title,
|
||
members: source.members.map((member) => ({
|
||
...member,
|
||
})),
|
||
});
|
||
})
|
||
}
|
||
>
|
||
<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>
|
||
</CardAction>
|
||
</CardHeader>
|
||
<CardContent className="flex flex-col gap-4">
|
||
<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 grid-cols-4 gap-1 sm:gap-3">
|
||
{team.members.map((member, memberIndex) => {
|
||
const character = characters.find(
|
||
(item) => item.key === member.characterKey,
|
||
);
|
||
const compatibleWeapons = weapons.filter(
|
||
(weapon) =>
|
||
supportsWeaponType(
|
||
character?.weaponType,
|
||
weapon.weaponType,
|
||
),
|
||
);
|
||
const selectedWeapon = compatibleWeapons.find(
|
||
(weapon) => weapon.key === member.weaponKey,
|
||
);
|
||
const share = total
|
||
? ((Number(member.damage) || 0) / total) * 100
|
||
: 0;
|
||
return (
|
||
<SortableTeamMember
|
||
key={`${team.clientId}-member-${memberIndex}`}
|
||
id={`${team.clientId}-member-${memberIndex}`}
|
||
>
|
||
{(memberDragHandle) => (
|
||
<div
|
||
className={cn(
|
||
"flex min-w-0 flex-col rounded-lg p-0.5 text-center sm:p-1",
|
||
!character && "bg-muted/40",
|
||
)}
|
||
style={
|
||
character?.element
|
||
? {
|
||
backgroundColor:
|
||
elementBackgroundColor(
|
||
character.element,
|
||
),
|
||
}
|
||
: undefined
|
||
}
|
||
>
|
||
<div className="flex items-center justify-between">
|
||
<span className="text-xs text-muted-foreground">
|
||
#{memberIndex + 1}
|
||
</span>
|
||
{memberDragHandle}
|
||
</div>
|
||
<div className="flex flex-col gap-1 sm:grid sm:grid-cols-3">
|
||
<div className="sm:col-span-2 sm:row-span-2">
|
||
<CatalogSlotPicker
|
||
items={characters}
|
||
value={member.characterKey}
|
||
label={`เลือกตัวละครตำแหน่ง ${memberIndex + 1}`}
|
||
placeholder="ค้นหาชื่อตัวละคร…"
|
||
rarityBackground
|
||
invalid={memberFieldInvalid(
|
||
teamIndex,
|
||
memberIndex,
|
||
"characterKey",
|
||
)}
|
||
className="rounded-lg border-0 [&>img]:object-cover [&>img]:p-0"
|
||
onChange={(characterKey) =>
|
||
updateTeamMember(
|
||
teamIndex,
|
||
memberIndex,
|
||
{
|
||
characterKey,
|
||
weaponKey: null,
|
||
artifactKey: null,
|
||
secondArtifactKey: null,
|
||
constellation: 0,
|
||
refinement: 1,
|
||
},
|
||
)
|
||
}
|
||
/>
|
||
</div>
|
||
<div className="grid min-w-0 grid-cols-2 gap-1 sm:row-span-2 sm:grid-cols-1">
|
||
<CatalogSlotPicker
|
||
items={compatibleWeapons}
|
||
value={member.weaponKey ?? ""}
|
||
label={`เลือกอาวุธของตัวละครตำแหน่ง ${memberIndex + 1}`}
|
||
placeholder="ค้นหาอาวุธที่รองรับ…"
|
||
rarityBackground
|
||
invalid={memberFieldInvalid(
|
||
teamIndex,
|
||
memberIndex,
|
||
"weaponKey",
|
||
)}
|
||
onChange={(weaponKey) =>
|
||
updateTeamMember(
|
||
teamIndex,
|
||
memberIndex,
|
||
{
|
||
weaponKey:
|
||
weaponKey || null,
|
||
refinement: 1,
|
||
},
|
||
)
|
||
}
|
||
/>
|
||
<ArtifactSlotPicker
|
||
items={artifacts}
|
||
values={[
|
||
member.artifactKey,
|
||
member.secondArtifactKey,
|
||
].filter(
|
||
(key): key is string => Boolean(key),
|
||
)}
|
||
label={`เลือก Artifact สูงสุด 2 ชุดของตัวละครตำแหน่ง ${memberIndex + 1}`}
|
||
invalid={memberFieldInvalid(
|
||
teamIndex,
|
||
memberIndex,
|
||
"artifactKey",
|
||
)}
|
||
onChange={(artifactKeys) =>
|
||
updateTeamMember(
|
||
teamIndex,
|
||
memberIndex,
|
||
{
|
||
artifactKey:
|
||
artifactKeys[0] ?? null,
|
||
secondArtifactKey:
|
||
artifactKeys[1] ?? null,
|
||
},
|
||
)
|
||
}
|
||
/>
|
||
</div>
|
||
</div>
|
||
<div className="mt-1 flex min-w-0 gap-1">
|
||
<Select
|
||
value={String(member.constellation)}
|
||
disabled={!character}
|
||
onValueChange={(value) => {
|
||
if (value !== null)
|
||
updateTeamMember(
|
||
teamIndex,
|
||
memberIndex,
|
||
{
|
||
constellation: Number(value),
|
||
},
|
||
);
|
||
}}
|
||
>
|
||
<SelectTrigger
|
||
size="sm"
|
||
className="h-6 min-w-0 flex-1 justify-center px-1 text-xs [&>svg]:hidden"
|
||
aria-label={`Constellation ของตัวละครตำแหน่ง ${memberIndex + 1}`}
|
||
>
|
||
<SelectValue>
|
||
C{member.constellation}
|
||
</SelectValue>
|
||
</SelectTrigger>
|
||
<SelectContent>
|
||
<SelectGroup>
|
||
{[0, 1, 2, 3, 4, 5, 6].map(
|
||
(constellation) => (
|
||
<SelectItem
|
||
key={constellation}
|
||
value={String(constellation)}
|
||
>
|
||
C{constellation}
|
||
</SelectItem>
|
||
),
|
||
)}
|
||
</SelectGroup>
|
||
</SelectContent>
|
||
</Select>
|
||
<Select
|
||
value={String(member.refinement)}
|
||
disabled={!selectedWeapon}
|
||
onValueChange={(value) => {
|
||
if (value !== null)
|
||
updateTeamMember(
|
||
teamIndex,
|
||
memberIndex,
|
||
{
|
||
refinement: Number(value),
|
||
},
|
||
);
|
||
}}
|
||
>
|
||
<SelectTrigger
|
||
size="sm"
|
||
className="h-6 min-w-0 flex-1 justify-center px-1 text-xs [&>svg]:hidden"
|
||
aria-label={`Refinement ของอาวุธตำแหน่ง ${memberIndex + 1}`}
|
||
>
|
||
<SelectValue>
|
||
R{member.refinement}
|
||
</SelectValue>
|
||
</SelectTrigger>
|
||
<SelectContent>
|
||
<SelectGroup>
|
||
{[1, 2, 3, 4, 5].map(
|
||
(refinement) => (
|
||
<SelectItem
|
||
key={refinement}
|
||
value={String(refinement)}
|
||
>
|
||
R{refinement}
|
||
</SelectItem>
|
||
),
|
||
)}
|
||
</SelectGroup>
|
||
</SelectContent>
|
||
</Select>
|
||
</div>
|
||
<Field
|
||
data-invalid={memberFieldInvalid(
|
||
teamIndex,
|
||
memberIndex,
|
||
"damage",
|
||
)}
|
||
>
|
||
<FieldLabel
|
||
className="sr-only"
|
||
htmlFor={`team-${team.clientId}-damage-${memberIndex}`}
|
||
>
|
||
Damage ของตัวละครตำแหน่ง {memberIndex + 1}
|
||
</FieldLabel>
|
||
<Input
|
||
id={`team-${team.clientId}-damage-${memberIndex}`}
|
||
className="h-7 text-center [appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none"
|
||
type="number"
|
||
min="0"
|
||
step="1"
|
||
value={member.damage}
|
||
aria-invalid={memberFieldInvalid(
|
||
teamIndex,
|
||
memberIndex,
|
||
"damage",
|
||
)}
|
||
onChange={(event) =>
|
||
updateTeamMember(
|
||
teamIndex,
|
||
memberIndex,
|
||
{
|
||
damage: event.target.value,
|
||
},
|
||
)
|
||
}
|
||
/>
|
||
</Field>
|
||
<p className="mt-0.5 text-xs text-muted-foreground">
|
||
{share.toFixed(2)}%
|
||
</p>
|
||
</div>
|
||
)}
|
||
</SortableTeamMember>
|
||
);
|
||
})}
|
||
</div>
|
||
</SortableContext>
|
||
</DndContext>
|
||
<Separator />
|
||
<div className="flex items-end justify-between gap-4">
|
||
<div>
|
||
<p className="text-sm text-muted-foreground">
|
||
ดาเมจรวม
|
||
</p>
|
||
<p className="font-semibold">
|
||
{total.toLocaleString()}
|
||
</p>
|
||
</div>
|
||
<div className="text-right">
|
||
<p className="text-sm text-muted-foreground">DPS</p>
|
||
<p className="text-3xl font-semibold text-primary">
|
||
{dps.toLocaleString(undefined, {
|
||
maximumFractionDigits: 0,
|
||
})}
|
||
</p>
|
||
</div>
|
||
</div>
|
||
<Field data-invalid={teamFieldInvalid(teamIndex, "combo")}>
|
||
<FieldLabel
|
||
className="sr-only"
|
||
htmlFor={`team-combo-${team.clientId}`}
|
||
>
|
||
Combo
|
||
</FieldLabel>
|
||
<Textarea
|
||
id={`team-combo-${team.clientId}`}
|
||
value={team.combo}
|
||
placeholder="ใส่ลำดับ Combo…"
|
||
aria-invalid={teamFieldInvalid(teamIndex, "combo")}
|
||
onChange={(event) =>
|
||
updateTeam(teamIndex, {
|
||
combo: event.target.value,
|
||
})
|
||
}
|
||
/>
|
||
</Field>
|
||
</CardContent>
|
||
</Card>
|
||
)}
|
||
</SortableTeamItem>
|
||
);
|
||
})}
|
||
</div>
|
||
) : (
|
||
<Empty>
|
||
<EmptyHeader>
|
||
<EmptyMedia variant="icon">
|
||
<SearchIcon />
|
||
</EmptyMedia>
|
||
<EmptyTitle>
|
||
{teams.length
|
||
? "ไม่พบ Team ที่ตรงกับคำค้นหา"
|
||
: "ยังไม่มี Team ใน Condition นี้"}
|
||
</EmptyTitle>
|
||
<EmptyDescription>
|
||
{teams.length
|
||
? "ลองเปลี่ยนคำค้นหาหรือประเภทข้อมูล"
|
||
: "เพิ่ม Team เพื่อเริ่มจัดตัวละครและคำนวณ DPS"}
|
||
</EmptyDescription>
|
||
</EmptyHeader>
|
||
{!teams.length ? (
|
||
<EmptyContent>
|
||
<Button
|
||
type="button"
|
||
variant="outline"
|
||
disabled={characters.length < 4}
|
||
onClick={() =>
|
||
setTeams([
|
||
...teams,
|
||
{
|
||
clientId: createClientId("team"),
|
||
title: "",
|
||
rotationSeconds: "20",
|
||
combo: "",
|
||
members: fallbackMembers.map((member) => ({
|
||
...member,
|
||
})),
|
||
},
|
||
])
|
||
}
|
||
>
|
||
<PlusIcon data-icon="inline-start" />
|
||
เพิ่ม Team
|
||
</Button>
|
||
</EmptyContent>
|
||
) : null}
|
||
</Empty>
|
||
)}
|
||
</SortableContext>
|
||
</DndContext>
|
||
</CardContent>
|
||
<CardFooter className="flex-wrap justify-between gap-3">
|
||
<div className="flex flex-wrap gap-3">
|
||
<Button
|
||
variant="outline"
|
||
disabled={characters.length < 4}
|
||
onClick={() =>
|
||
setTeams([
|
||
...teams,
|
||
{
|
||
clientId: createClientId("team"),
|
||
title: "",
|
||
rotationSeconds: "20",
|
||
combo: "",
|
||
members: fallbackMembers.map((member) => ({ ...member })),
|
||
},
|
||
])
|
||
}
|
||
>
|
||
<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>
|
||
</div>
|
||
<AutosaveStatus
|
||
state={autosave.state}
|
||
conflictVersion={autosave.conflictVersion}
|
||
canOverwrite={autosave.canOverwrite}
|
||
onReloadLatest={autosave.reloadLatest}
|
||
onOverwrite={autosave.overwrite}
|
||
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="wrap-break-word">คัดลอกค่าจาก {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 sm:max-w-lg">
|
||
<DialogHeader>
|
||
<DialogTitle>Import Team</DialogTitle>
|
||
<DialogDescription>
|
||
เลือก Team จาก Guide อื่น หรืออัปโหลดไฟล์ JSON ที่เคย Export ไว้
|
||
</DialogDescription>
|
||
</DialogHeader>
|
||
<div className="flex min-h-0 flex-col gap-4 overflow-y-auto pr-1">
|
||
<Card size="sm">
|
||
<CardHeader>
|
||
<CardTitle>เลือกจาก Guide</CardTitle>
|
||
<CardDescription>เลือก Team ที่บันทึกอยู่ใน Guide อื่นได้โดยไม่ต้องดาวน์โหลดไฟล์</CardDescription>
|
||
</CardHeader>
|
||
<CardFooter>
|
||
<Button type="button" variant="outline" onClick={() => void openGuideImport()}>
|
||
<BookOpenIcon data-icon="inline-start" />
|
||
เปิดรายการ Guide
|
||
</Button>
|
||
</CardFooter>
|
||
</Card>
|
||
<Card size="sm">
|
||
<CardHeader>
|
||
<CardTitle>อัปโหลดไฟล์ JSON</CardTitle>
|
||
<CardDescription>เลือกหลายไฟล์เพื่อ Import หลาย Team พร้อมกันได้</CardDescription>
|
||
</CardHeader>
|
||
<CardContent>
|
||
<Field>
|
||
<FieldLabel htmlFor="team-import-files">ไฟล์ Team JSON</FieldLabel>
|
||
<Input
|
||
id="team-import-files"
|
||
type="file"
|
||
accept=".json,application/json"
|
||
multiple
|
||
onChange={(event) => setImportFiles(Array.from(event.target.files ?? []))}
|
||
/>
|
||
</Field>
|
||
</CardContent>
|
||
<CardFooter>
|
||
<Button type="button" disabled={!importFiles.length} onClick={() => void importTeamFiles()}>
|
||
Import {importFiles.length || ""} Team
|
||
</Button>
|
||
</CardFooter>
|
||
</Card>
|
||
</div>
|
||
<DialogFooter>
|
||
<Button type="button" variant="outline" onClick={() => setImportOpen(false)}>
|
||
ปิด
|
||
</Button>
|
||
</DialogFooter>
|
||
</DialogContent>
|
||
</Dialog>
|
||
<Dialog open={guideImportOpen} onOpenChange={setGuideImportOpen}>
|
||
<DialogContent className="max-h-[90svh] grid-rows-[auto_minmax(0,1fr)_auto] overflow-hidden sm:max-w-5xl">
|
||
<DialogHeader>
|
||
<DialogTitle>{activeImportGuide ? activeImportGuide.name : "เลือก Guide"}</DialogTitle>
|
||
<DialogDescription>
|
||
{activeImportGuide
|
||
? "เลือก Team ที่ต้องการ สามารถย้อนกลับไปเลือกจาก Guide อื่นเพิ่มได้"
|
||
: "ค้นหาและเปิด Guide เพื่อเลือก Team ที่ต้องการ Import"}
|
||
</DialogDescription>
|
||
</DialogHeader>
|
||
<div className="min-h-0 overflow-hidden">
|
||
{guideImportError ? (
|
||
<Alert variant="destructive">
|
||
<AlertTitle>โหลดข้อมูลไม่สำเร็จ</AlertTitle>
|
||
<AlertDescription>{guideImportError}</AlertDescription>
|
||
</Alert>
|
||
) : null}
|
||
{guideImportLoading ? (
|
||
<div className="flex h-48 items-center justify-center">
|
||
<Spinner />
|
||
</div>
|
||
) : activeImportGuide ? (
|
||
<div className="flex h-full min-h-0 flex-col gap-3">
|
||
<Button type="button" variant="ghost" className="self-start" onClick={() => setActiveImportGuide(null)}>
|
||
<ArrowLeftIcon data-icon="inline-start" />
|
||
กลับไปเลือก Guide
|
||
</Button>
|
||
{activeImportGuide.groups.length > 1 ? (
|
||
<ScrollableNav ariaLabel="เลือกเงื่อนไขทีม" className="w-full min-w-0">
|
||
<ToggleGroup
|
||
aria-label="เลือกเงื่อนไขทีม"
|
||
variant="outline"
|
||
value={[guideImportCondition]}
|
||
onValueChange={(values) => { if (values[0]) setGuideImportCondition(values[0]); }}
|
||
>
|
||
{activeImportGuide.groups.map((group, index) => (
|
||
<ToggleGroupItem key={group.id} value={String(index)} className="aria-pressed:bg-primary aria-pressed:text-primary-foreground">
|
||
{group.name || `Condition ${index + 1}`}
|
||
</ToggleGroupItem>
|
||
))}
|
||
</ToggleGroup>
|
||
</ScrollableNav>
|
||
) : null}
|
||
<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
|
||
aria-label="ค้นหาทีม ตัวละคร อาวุธ หรืออาร์ติแฟกต์"
|
||
className="pl-9"
|
||
placeholder="ค้นหาทีม ตัวละคร อาวุธ หรืออาร์ติแฟกต์"
|
||
value={guideTeamQuery}
|
||
onValueChange={setGuideTeamQuery}
|
||
/>
|
||
</div>
|
||
<ToggleGroup
|
||
aria-label="เลือกประเภทข้อมูลที่ต้องการค้นหา"
|
||
className="max-w-full flex-wrap justify-start"
|
||
variant="outline"
|
||
size="sm"
|
||
value={[guideTeamSearchTarget]}
|
||
onValueChange={(values) => { if (values[0]) setGuideTeamSearchTarget(values[0] as TeamSearchTarget); }}
|
||
>
|
||
<ToggleGroupItem value="all">ทั้งหมด</ToggleGroupItem>
|
||
<ToggleGroupItem value="team">ชื่อทีม</ToggleGroupItem>
|
||
<ToggleGroupItem value="character">ตัวละคร</ToggleGroupItem>
|
||
<ToggleGroupItem value="weapon">อาวุธ</ToggleGroupItem>
|
||
<ToggleGroupItem value="artifact">อาร์ติแฟกต์</ToggleGroupItem>
|
||
</ToggleGroup>
|
||
<div className="min-h-0 overflow-y-auto pr-1">
|
||
|
||
{filteredImportTeams.length ? (
|
||
<div className="grid gap-4 lg:grid-cols-2">
|
||
{filteredImportTeams.map((team) => {
|
||
const checked = Boolean(selectedImports[team.id]);
|
||
const total = team.members.reduce((sum, member) => sum + Number(member.damage), 0);
|
||
const seconds = Number(team.rotationSeconds);
|
||
return (
|
||
<Card key={team.id} className={cn(checked && "ring-2 ring-primary")}>
|
||
<CardHeader>
|
||
<CardTitle>{team.title}</CardTitle>
|
||
<CardDescription>โรเทชั่น {seconds} วิ</CardDescription>
|
||
<CardAction>
|
||
<Field orientation="horizontal">
|
||
<Checkbox
|
||
id={`guide-team-${team.id}`}
|
||
checked={checked}
|
||
onCheckedChange={(nextChecked) => setSelectedImports((current) => {
|
||
if (nextChecked === true) {
|
||
return { ...current, [team.id]: {
|
||
title: team.title,
|
||
rotationSeconds: team.rotationSeconds,
|
||
combo: team.combo,
|
||
members: team.members.map((member) => ({
|
||
characterKey: member.characterKey,
|
||
weaponKey: member.weaponKey,
|
||
artifactKey: member.artifactKey,
|
||
secondArtifactKey: member.secondArtifactKey,
|
||
constellation: member.constellation,
|
||
refinement: member.refinement,
|
||
damage: member.damage,
|
||
})),
|
||
} };
|
||
}
|
||
const next = { ...current };
|
||
delete next[team.id];
|
||
return next;
|
||
})}
|
||
/>
|
||
<FieldLabel htmlFor={`guide-team-${team.id}`}>เลือก</FieldLabel>
|
||
</Field>
|
||
</CardAction>
|
||
</CardHeader>
|
||
<CardContent className="flex flex-col gap-4">
|
||
<div className="grid grid-cols-4 gap-2">
|
||
{team.members.map((member) => {
|
||
const character = characters.find((item) => item.key === member.characterKey);
|
||
const weapon = weapons.find((item) => item.key === member.weaponKey);
|
||
const artifact = artifacts.find((item) => item.key === member.artifactKey);
|
||
const secondArtifact = artifacts.find((item) => item.key === member.secondArtifactKey);
|
||
return (
|
||
<div key={member.characterKey} className="min-w-0 text-center">
|
||
<div className="flex items-center justify-center gap-2">
|
||
<div className={cn("relative size-16 shrink-0 overflow-hidden rounded-lg bg-muted", rarityGradientClass(isCustomTravelerKey(member.characterKey) ? 5 : character?.rarity))}>
|
||
{character ? <Image src={character.imageUrl} alt={character.name} fill sizes="64px" className="object-contain" /> : null}
|
||
</div>
|
||
<div className="flex flex-col gap-1">
|
||
{[weapon, artifact, secondArtifact].filter((item): item is CatalogItem => Boolean(item)).map((item) => (
|
||
<div key={item.key} className={cn("relative size-7 overflow-hidden rounded-md", rarityGradientClass(item.rarity))}>
|
||
<Image src={item.imageUrl} alt={item.name} fill sizes="28px" className="object-contain p-0.5" />
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
<div className="mt-2 flex items-center justify-center gap-1">
|
||
<Badge variant="secondary">C{member.constellation}</Badge>
|
||
<Badge variant="secondary">R{member.refinement}</Badge>
|
||
</div>
|
||
<p className="mt-1 truncate text-sm font-medium">{character?.name ?? member.characterName}</p>
|
||
<p className="mt-1 text-sm font-semibold">{Number(member.damage).toLocaleString()}</p>
|
||
<p className="text-xs text-muted-foreground">{total ? ((Number(member.damage) / total) * 100).toFixed(1) : "0.0"}%</p>
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
<Separator />
|
||
<div className="flex items-end justify-between gap-4">
|
||
<div><p className="text-sm text-muted-foreground">ดาเมจรวม</p><p className="font-semibold">{total.toLocaleString()}</p></div>
|
||
<div className="text-right"><p className="text-sm text-muted-foreground">DPS</p><p className="text-3xl font-semibold text-primary">{seconds > 0 ? (total / seconds).toLocaleString(undefined, { maximumFractionDigits: 0 }) : "0"}</p></div>
|
||
</div>
|
||
<p className="rounded-lg bg-muted p-3 text-sm leading-6">{team.combo}</p>
|
||
</CardContent>
|
||
</Card>
|
||
);
|
||
})}
|
||
</div>
|
||
) : (
|
||
<Empty>
|
||
<EmptyHeader>
|
||
<EmptyTitle>{activeImportGroup?.teams.length ? "ไม่พบ Team ที่ตรงกับคำค้นหา" : "Condition นี้ยังไม่มี Team"}</EmptyTitle>
|
||
<EmptyDescription>{activeImportGroup?.teams.length ? "ลองเปลี่ยนคำค้นหาหรือประเภทข้อมูล" : "เลือก Condition หรือ Guide อื่นเพื่อ Import"}</EmptyDescription>
|
||
</EmptyHeader>
|
||
</Empty>
|
||
)}
|
||
</div>
|
||
</div>
|
||
) : importGuides?.length ? (
|
||
<div className="flex h-full min-h-0 flex-col gap-4">
|
||
<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
|
||
aria-label="ค้นหา Guide"
|
||
className="pl-9"
|
||
placeholder="ค้นหาชื่อ Guide หรือตัวละคร…"
|
||
value={guideListQuery}
|
||
onValueChange={setGuideListQuery}
|
||
/>
|
||
</div>
|
||
<div className="min-h-0 overflow-y-auto pr-1">
|
||
|
||
{filteredImportGuides.length ? (
|
||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||
{filteredImportGuides.map((sourceGuide) => (
|
||
<Card key={sourceGuide.id} className="relative overflow-hidden py-0 transition-colors hover:bg-muted/50">
|
||
<button
|
||
type="button"
|
||
aria-label={`เปิด Guide ${sourceGuide.name}`}
|
||
className="absolute inset-0 rounded-xl outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||
onClick={() => void enterImportGuide(sourceGuide.id)}
|
||
/>
|
||
<div className="relative aspect-4/3 bg-muted">
|
||
<Image
|
||
src={sourceGuide.coverUrl}
|
||
alt={`Guide ${sourceGuide.name}`}
|
||
fill
|
||
sizes="(min-width: 1024px) 30vw, (min-width: 640px) 45vw, 90vw"
|
||
className="object-cover"
|
||
/>
|
||
</div>
|
||
<CardHeader className="pt-4">
|
||
<CardTitle>{sourceGuide.name}</CardTitle>
|
||
<CardDescription>{sourceGuide.characterKey}</CardDescription>
|
||
<CardAction>
|
||
<Badge variant={sourceGuide.isPublic ? "default" : "secondary"}>
|
||
{sourceGuide.isPublic ? "Published" : "Draft"}
|
||
</Badge>
|
||
</CardAction>
|
||
</CardHeader>
|
||
<CardFooter className="relative pb-4">
|
||
<Button type="button" size="xs" variant="outline" onClick={() => void enterImportGuide(sourceGuide.id)}>
|
||
เปิด Guide
|
||
</Button>
|
||
</CardFooter>
|
||
</Card>
|
||
))}
|
||
</div>
|
||
) : (
|
||
<Empty>
|
||
<EmptyHeader>
|
||
<EmptyTitle>ไม่พบ Guide ที่ตรงกับคำค้นหา</EmptyTitle>
|
||
<EmptyDescription>ลองค้นหาด้วยชื่อ Guide หรือตัวละครอื่น</EmptyDescription>
|
||
</EmptyHeader>
|
||
</Empty>
|
||
)}
|
||
</div>
|
||
</div>
|
||
) : (
|
||
<Empty>
|
||
<EmptyHeader>
|
||
<EmptyMedia variant="icon"><BookOpenIcon /></EmptyMedia>
|
||
<EmptyTitle>ไม่มี Guide อื่นให้เลือก</EmptyTitle>
|
||
<EmptyDescription>สร้างหรือบันทึก Team ใน Guide อื่นก่อน</EmptyDescription>
|
||
</EmptyHeader>
|
||
</Empty>
|
||
)}
|
||
</div>
|
||
<DialogFooter>
|
||
<Button type="button" variant="outline" onClick={() => setGuideImportOpen(false)}>ยกเลิก</Button>
|
||
<Button type="button" disabled={!Object.keys(selectedImports).length} onClick={confirmGuideImport}>
|
||
Import {Object.keys(selectedImports).length || ""} 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 sensors = useSensors(
|
||
useSensor(PointerSensor),
|
||
useSensor(KeyboardSensor, {
|
||
coordinateGetter: sortableKeyboardCoordinates,
|
||
}),
|
||
);
|
||
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];
|
||
const itemId = (item: ExtraCatalogItem) => `${item.kind}:${item.key}`;
|
||
const handleDragEnd = ({ active, over }: DragEndEvent) => {
|
||
if (!over || active.id === over.id) return;
|
||
const from = value.findIndex((item) => itemId(item) === active.id);
|
||
const to = value.findIndex((item) => itemId(item) === over.id);
|
||
if (from !== -1 && to !== -1) onChange(arrayMove(value, from, to));
|
||
};
|
||
|
||
return (
|
||
<Field>
|
||
<FieldLabel>ตัวละคร อาวุธ และอาร์ติแฟกต์</FieldLabel>
|
||
<div className="flex flex-col gap-3">
|
||
{value.length ? (
|
||
<DndContext
|
||
sensors={sensors}
|
||
collisionDetection={closestCenter}
|
||
onDragEnd={handleDragEnd}
|
||
>
|
||
<SortableContext
|
||
items={value.map(itemId)}
|
||
strategy={verticalListSortingStrategy}
|
||
>
|
||
<div className="flex flex-col gap-2">
|
||
{value.map((reference, index) => {
|
||
const item = catalogs[reference.kind].find(
|
||
(candidate) => candidate.key === reference.key,
|
||
);
|
||
const label = item?.name ?? reference.key;
|
||
return (
|
||
<SortableExtraItem
|
||
key={itemId(reference)}
|
||
id={itemId(reference)}
|
||
label={`ลากเพื่อจัดลำดับ ${label}`}
|
||
>
|
||
{(dragHandle) => (
|
||
<Card 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 sizes="48px" className="object-contain" />
|
||
</div>
|
||
) : null}
|
||
<div className="min-w-0 flex-1">
|
||
<p className="truncate font-medium">{label}</p>
|
||
<p className="text-sm text-muted-foreground">{labels[reference.kind]}</p>
|
||
</div>
|
||
{dragHandle}
|
||
<Button type="button" variant="ghost" size="icon-sm" onClick={() => onChange(value.filter((_, itemIndex) => itemIndex !== index))} aria-label={`ลบ ${label}`}>
|
||
<XIcon />
|
||
</Button>
|
||
</CardContent>
|
||
</Card>
|
||
)}
|
||
</SortableExtraItem>
|
||
);
|
||
})}
|
||
</div>
|
||
</SortableContext>
|
||
</DndContext>
|
||
) : 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={(nextKey) => {
|
||
if (
|
||
nextKey &&
|
||
!value.some(
|
||
(item) => item.kind === kind && item.key === nextKey,
|
||
)
|
||
) {
|
||
onChange([...value, { kind, key: nextKey }]);
|
||
}
|
||
setKey("");
|
||
}}
|
||
placeholder={`เลือก${labels[kind]}`}
|
||
showSelectedImage
|
||
rarityBackground
|
||
/>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</Field>
|
||
);
|
||
}
|
||
|
||
function ExtraEditor({ guide, extraSectionId, initial, characters, weapons, artifacts }: EditorBase) {
|
||
const router = useRouter();
|
||
const sectionId = extraSectionId ?? initial.extras[0]?.id ?? "";
|
||
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,
|
||
sheet: row.sheet,
|
||
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({
|
||
guideId: guide.id,
|
||
scope: `extra:${sectionId}`,
|
||
value,
|
||
valid: validation.success,
|
||
initialVersion: guide.version,
|
||
persist: (next, expectedVersion, intent) =>
|
||
saveGuideExtra(guide.id, sectionId, {
|
||
section: next.sections[0],
|
||
expectedVersion,
|
||
}, intent),
|
||
storageKey: adminDraftKey(guide.id, `extra:${sectionId}`),
|
||
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,
|
||
sheet: row.sheet ?? null,
|
||
catalogItems: row.catalogItems ?? [],
|
||
highlights: [],
|
||
clientId: row.clientId || createClientId("extra-row"),
|
||
})),
|
||
}));
|
||
setSections(restoredSections);
|
||
setOpenSectionIds(restoredSections.map((section) => section.clientId));
|
||
},
|
||
});
|
||
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>{sections[0]?.title ?? "Extra section"}</CardTitle>
|
||
<CardDescription>
|
||
เพิ่มส่วนเสริมแบบภาพ หัวข้อ ข้อความที่จัดรูปแบบได้ และเปอร์เซ็นต์
|
||
ระบบจะบันทึกอัตโนมัติ
|
||
</CardDescription>
|
||
</CardHeader>
|
||
<CardContent className="flex flex-col gap-5">
|
||
<Accordion
|
||
multiple
|
||
value={openSectionIds}
|
||
onValueChange={setOpenSectionIds}
|
||
className="gap-5"
|
||
>
|
||
{sections.map((section, sectionIndex) => (
|
||
<AccordionItem key={section.clientId} 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">
|
||
<DeleteConfirmationDialog
|
||
trigger={
|
||
<Button type="button" variant="ghost" size="icon-sm" />
|
||
}
|
||
title={`ลบ ${section.title || `Extra section ${sectionIndex + 1}`}?`}
|
||
description="แถว ข้อความ รูปภาพ และเปอร์เซ็นต์ทั้งหมดในส่วนนี้จะถูกลบออกจาก Guide"
|
||
showSuccessToast={false}
|
||
onConfirm={async () => {
|
||
const result = await deleteGuideExtra(guide.id, section.id);
|
||
if (result.status !== "saved") {
|
||
toast.error(result.message);
|
||
return;
|
||
}
|
||
window.localStorage.removeItem(adminDraftKey(guide.id, `extra:${section.id}`));
|
||
toast.success("ลบ Extra แล้ว");
|
||
router.push(`/admin/${guide.characterKey}`);
|
||
router.refresh();
|
||
}}
|
||
>
|
||
<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>ตารางข้อมูล (optional)</FieldLabel>
|
||
{row.sheet ? (
|
||
<ExtraSheetEditor
|
||
value={row.sheet}
|
||
createId={createClientId}
|
||
onChange={(sheet) =>
|
||
setSections(
|
||
sections.map((item, index) =>
|
||
index === sectionIndex
|
||
? {
|
||
...item,
|
||
rows: item.rows.map((candidate, inner) =>
|
||
inner === rowIndex
|
||
? { ...candidate, sheet }
|
||
: candidate,
|
||
),
|
||
}
|
||
: item,
|
||
),
|
||
)
|
||
}
|
||
onRemove={() =>
|
||
setSections(
|
||
sections.map((item, index) =>
|
||
index === sectionIndex
|
||
? {
|
||
...item,
|
||
rows: item.rows.map((candidate, inner) =>
|
||
inner === rowIndex
|
||
? { ...candidate, sheet: null }
|
||
: candidate,
|
||
),
|
||
}
|
||
: item,
|
||
),
|
||
)
|
||
}
|
||
/>
|
||
) : (
|
||
<Button
|
||
type="button"
|
||
variant="outline"
|
||
onClick={() =>
|
||
setSections(
|
||
sections.map((item, index) =>
|
||
index === sectionIndex
|
||
? {
|
||
...item,
|
||
rows: item.rows.map((candidate, inner) =>
|
||
inner === rowIndex
|
||
? { ...candidate, sheet: createExtraSheet(createClientId) }
|
||
: candidate,
|
||
),
|
||
}
|
||
: item,
|
||
),
|
||
)
|
||
}
|
||
>
|
||
<PlusIcon data-icon="inline-start" />
|
||
เพิ่มตาราง 2 × 2
|
||
</Button>
|
||
)}
|
||
</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",
|
||
)}
|
||
onClick={() => {
|
||
if (row.backgroundColor !== null) return;
|
||
setSections(
|
||
sections.map((item, index) =>
|
||
index === sectionIndex
|
||
? {
|
||
...item,
|
||
rows: item.rows.map((candidate, inner) =>
|
||
inner === rowIndex
|
||
? {
|
||
...candidate,
|
||
backgroundColor: DEFAULT_EXTRA_BACKGROUND_COLOR,
|
||
}
|
||
: candidate,
|
||
),
|
||
}
|
||
: item,
|
||
),
|
||
);
|
||
}}
|
||
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>
|
||
</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,
|
||
sheet: null,
|
||
catalogItems: [],
|
||
highlights: [],
|
||
clientId: createClientId("extra-row"),
|
||
},
|
||
],
|
||
}
|
||
: item,
|
||
),
|
||
)
|
||
}
|
||
>
|
||
<PlusIcon data-icon="inline-start" />
|
||
เพิ่มแถว
|
||
</Button>
|
||
</CardContent>
|
||
</AccordionContent>
|
||
</Card>
|
||
</AccordionItem>
|
||
))}
|
||
</Accordion>
|
||
</CardContent>
|
||
<CardFooter className="flex-wrap justify-end gap-3">
|
||
<AutosaveStatus
|
||
state={autosave.state}
|
||
conflictVersion={autosave.conflictVersion}
|
||
canOverwrite={autosave.canOverwrite}
|
||
onReloadLatest={autosave.reloadLatest}
|
||
onOverwrite={autosave.overwrite}
|
||
issues={
|
||
validation.success
|
||
? []
|
||
: formatGuideValidationIssues("extra", validation.error.issues)
|
||
}
|
||
onSave={autosave.saveNow}
|
||
/>
|
||
</CardFooter>
|
||
</Card>
|
||
);
|
||
}
|