diff --git a/components/admin/guide-section-editor.tsx b/components/admin/guide-section-editor.tsx index 72d4438..b5ef2f8 100644 --- a/components/admin/guide-section-editor.tsx +++ b/components/admin/guide-section-editor.tsx @@ -4,22 +4,17 @@ import dynamic from "next/dynamic"; import Image from "next/image"; import { useRouter } from "next/navigation"; import { - ChartNoAxesCombinedIcon, ClipboardCopyIcon, ClipboardPasteIcon, CopyIcon, - GemIcon, + CircleHelpIcon, GripVerticalIcon, HeartPulseIcon, - ListOrderedIcon, PlusIcon, RefreshCcwIcon, SearchIcon, SparklesIcon, - SwordIcon, - TimerIcon, Trash2Icon, - UserRoundIcon, XIcon, } from "lucide-react"; import { useEffect, useMemo, useState, type ReactNode, type SetStateAction } from "react"; @@ -54,6 +49,7 @@ import { import { AutosaveStatus } from "@/components/admin/autosave-status"; import { DeleteConfirmationDialog } from "@/components/admin/delete-confirmation-dialog"; import { MediaUploader } from "@/components/admin/media-uploader"; +import { TeamArtifactIcon } from "@/components/public/team-search"; import { SimpleTooltip } from "@/components/tooltip"; import { Accordion, @@ -61,7 +57,6 @@ import { AccordionItem, AccordionTrigger, } from "@/components/ui/accordion"; -import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; import { Button } from "@/components/ui/button"; import { Checkbox } from "@/components/ui/checkbox"; import { @@ -80,7 +75,16 @@ import { ComboboxInput, ComboboxItem, ComboboxList, + ComboboxTrigger, } from "@/components/ui/combobox"; +import { + Empty, + EmptyContent, + EmptyDescription, + EmptyHeader, + EmptyMedia, + EmptyTitle, +} from "@/components/ui/empty"; import { Field, FieldDescription, @@ -99,6 +103,7 @@ import { SelectValue, } from "@/components/ui/select"; import { Textarea } from "@/components/ui/textarea"; +import { Separator } from "@/components/ui/separator"; import { Skeleton } from "@/components/ui/skeleton"; import { Toggle } from "@/components/ui/toggle"; import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"; @@ -131,7 +136,7 @@ import { targetStatOptions, statPriorityColorClass, } from "@/lib/guides/stats"; -import { elementIconFilter } from "@/lib/catalog/element"; +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"; @@ -181,6 +186,8 @@ type CatalogItem = { element?: string | null; elementIconUrl?: string | null; }; +const catalogItemsEqual = (item: CatalogItem, value: CatalogItem) => + item.key === value.key; type ExtraCatalogItem = { kind: "character" | "weapon" | "artifact"; key: string; @@ -453,6 +460,196 @@ function CatalogPicker({ ); } +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 ( + onChange(next?.key ?? "")} + itemToStringLabel={(item) => item.name} + itemToStringValue={(item) => item.key} + filter={(item, query) => + [item.name, ...(item.aliases ?? [])] + .join(" ") + .toLocaleLowerCase("th") + .includes(query.trim().toLocaleLowerCase("th")) + } + autoHighlight + > + svg]:hidden", + rarityBackground && selected && rarityGradientClass(selected.rarity), + className, + )} + > + {selected ? ( + + ) : ( + + + + ไม่พบรายการ + + {(item) => ( + +
+ +
+ {item.name} +
+ )} +
+
+
+ ); +} + +function ArtifactSlotPicker({ + items, + values, + onChange, + label, +}: { + items: CatalogItem[]; + values: string[]; + onChange: (values: string[]) => void; + label: string; +}) { + 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 ( + + onChange(next.slice(0, 2).map((item) => item.key)) + } + itemToStringLabel={(item) => item.name} + itemToStringValue={(item) => item.key} + isItemEqualToValue={catalogItemsEqual} + filter={(item, query) => + [item.name, ...(item.aliases ?? [])] + .join(" ") + .toLocaleLowerCase("th") + .includes(query.trim().toLocaleLowerCase("th")) + } + autoHighlight + > + item.name).join(", ")}` + : label} + className="relative aspect-square w-full overflow-hidden rounded-md border bg-muted outline-none focus-visible:ring-2 focus-visible:ring-ring [&>svg]:hidden" + > + {selected.length ? ( + + ) : ( + + + + ไม่พบชุด Artifact + + {(item) => ( + = 2 && + !selected.some((candidate) => candidate.key === item.key) + } + > +
+ +
+ {item.name} +
+ )} +
+
+
+ ); +} + function StatSelect({ label, options, @@ -771,11 +968,10 @@ function SortableTeamItem({ isDragging, } = useSortable({ id }); return ( - {children( @@ -792,7 +988,7 @@ function SortableTeamItem({ , )} - + ); } @@ -2489,6 +2685,34 @@ function TeamEditor({ }), ); }; + const updateTeam = ( + teamIndex: number, + update: Partial>, + ) => { + setTeams((current) => + current.map((team, index) => + index === teamIndex ? { ...team, ...update } : team, + ), + ); + }; + const updateTeamMember = ( + teamIndex: number, + memberIndex: number, + update: Partial, + ) => { + 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); @@ -2643,13 +2867,25 @@ function TeamEditor({
- {teamGroups.map((group, index) => ( - - ))} + }} + > + {teamGroups.map((group, index) => ( + + Condition {index + 1} + + ))} + - - - - - + {dragHandle} +
- - - - - - - ชื่อ Team - - + onClick={() => void exportTeam(team)} + > + + + Export {team.title || `Team ${teamIndex + 1}`} + + + + + + + + } + title={`ลบ ${team.title || `Team ${teamIndex + 1}`}?`} + description="ข้อมูล Team และตัวละครทั้ง 4 คนจะถูกลบออกจาก Guide" + confirmLabel="ยืนยันลบ Team" + onConfirm={() => setTeams( - teams.map((item, index) => - index === teamIndex - ? { ...item, title: event.target.value } - : item, + teams.filter( + (item) => item.clientId !== team.clientId, ), ) } - /> - + > + + ลบ Team + + + + -
+
{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, - ); - return ( - - {(memberDragHandle) => ( - - - - ตัวละคร {memberIndex + 1} - - - {total - ? `${(((Number(member.damage) || 0) / total) * 100).toFixed(2)}% ของ Team` - : "0% ของ Team"} - - - {character ? ( - - - - {character.name - .slice(0, 2) - .toUpperCase()} - - - ) : null} - {selectedWeapon ? ( - - - - {selectedWeapon.name.slice(0, 2).toUpperCase()} - - - ) : null} - {memberDragHandle} - - - - - - - - ตัวละคร - - - setTeams( - teams.map((item, index) => - index === teamIndex - ? { - ...item, - members: - item.members.map( - ( - candidate, - inner, - ) => - inner === - memberIndex - ? { - ...candidate, - characterKey, - weaponKey: - null, - artifactKey: - null, - secondArtifactKey: - null, - constellation: 0, - refinement: 1, - } - : candidate, - ), - } - : item, - ), - ) - } - /> - - - - - Damage - - - setTeams( - teams.map((item, index) => - index === teamIndex - ? { - ...item, - members: - item.members.map( - ( - candidate, - inner, - ) => - inner === - memberIndex - ? { - ...candidate, - damage: - event - .target - .value, - } - : candidate, - ), - } - : item, - ), - ) - } - /> - - - - - Constellation - - { - const constellation = selected[0]; - if (constellation) - setTeams( - teams.map((item, index) => - index === teamIndex - ? { - ...item, - members: - item.members.map( - ( - candidate, - inner, - ) => - inner === - memberIndex - ? { - ...candidate, - constellation: - Number( - constellation.slice( - 1, - ), - ), - } - : candidate, - ), - } - : item, - ), - ); - }} - > - {[0, 1, 2, 3, 4, 5, 6].map( - (constellation) => ( - - C{constellation} - - ), + 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 ( + + {(memberDragHandle) => ( +
- -
- - - - Weapon - - - setTeams( - teams.map((item, index) => - index === teamIndex - ? { - ...item, - members: - item.members.map( - ( - candidate, - inner, - ) => - inner === - memberIndex - ? { - ...candidate, - weaponKey: - weaponKey || - null, - } - : candidate, - ), - } - : item, - ), - ) - } - /> - - - - - Refinement - - { - const refinement = selected[0]; - if (refinement) - setTeams( - teams.map((item, index) => - index === teamIndex - ? { - ...item, - members: - item.members.map( - ( - candidate, - inner, - ) => - inner === - memberIndex - ? { - ...candidate, - refinement: - Number( - refinement.slice( - 1, - ), - ), - } - : candidate, - ), - } - : item, - ), - ); - }} - > - {[1, 2, 3, 4, 5].map( - (refinement) => ( - - R{refinement} - - ), + style={ + character?.element + ? { + backgroundColor: + elementBackgroundColor( + character.element, + ), + } + : undefined + } + > +
+ + #{memberIndex + 1} + + {memberDragHandle} +
+
+
+ + updateTeamMember( + teamIndex, + memberIndex, + { + characterKey, + weaponKey: null, + artifactKey: null, + secondArtifactKey: null, + constellation: 0, + refinement: 1, + }, + ) + } + /> +
+
+ + updateTeamMember( + teamIndex, + memberIndex, + { + weaponKey: + weaponKey || null, + refinement: 1, + }, + ) + } + /> + Boolean(key), + )} + label={`เลือก Artifact สูงสุด 2 ชุดของตัวละครตำแหน่ง ${memberIndex + 1}`} + onChange={(artifactKeys) => + updateTeamMember( + teamIndex, + memberIndex, + { + artifactKey: + artifactKeys[0] ?? null, + secondArtifactKey: + artifactKeys[1] ?? null, + }, + ) + } + /> +
+
+
+ + +
+

+ {character?.name ?? "เลือกตัวละคร"} +

+ - -
-
- - - - Artifact set 1 - - - setTeams( - teams.map((item, index) => - index === teamIndex - ? { - ...item, - members: - item.members.map( - ( - candidate, - inner, - ) => - inner === - memberIndex - ? { - ...candidate, - artifactKey: - artifactKey || - null, - } - : candidate, - ), - } - : item, - ), - ) - } - /> - - - - - Artifact set 2 - - - setTeams( - teams.map((item, index) => - index === teamIndex - ? { - ...item, - members: - item.members.map( - ( - candidate, - inner, - ) => - inner === - memberIndex - ? { - ...candidate, - secondArtifactKey: - secondArtifactKey || - null, - } - : candidate, - ), - } - : item, - ), - ) - } - /> - -
- - - - )} - - ); + > + + Damage ของตัวละครตำแหน่ง {memberIndex + 1} + + + updateTeamMember( + teamIndex, + memberIndex, + { + damage: event.target.value, + }, + ) + } + /> + +

+ {share.toFixed(2)}% +

+
+ )} +
+ ); })}
- - - - Rotation (seconds) - - - setTeams( - teams.map((item, index) => - index === teamIndex - ? { - ...item, - rotationSeconds: event.target.value, - } - : item, - ), - ) - } - /> - + +
+
+

+ ดาเมจรวม +

+

+ {total.toLocaleString()} +

+
+
+

DPS

+

+ {dps.toLocaleString(undefined, { + maximumFractionDigits: 0, + })} +

+
+
- - + Combo