diff --git a/bun.lock b/bun.lock index 241cac1..dc8cab3 100644 --- a/bun.lock +++ b/bun.lock @@ -6,6 +6,9 @@ "name": "buzz-sheet", "dependencies": { "@base-ui/react": "^1.7.0", + "@dnd-kit/core": "^6.3.1", + "@dnd-kit/sortable": "^10.0.0", + "@dnd-kit/utilities": "^3.2.2", "better-auth": "^1.7.2", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", @@ -134,6 +137,14 @@ "@better-fetch/fetch": ["@better-fetch/fetch@1.3.1", "", {}, "sha512-ABkD1WhyfPZprKRQI3bhATjeiFuNWC9PXhfGWqL+sg/gKrM977oFrYkdb4msM3hgUGonr7KlOsOFT5TU2rht9g=="], + "@dnd-kit/accessibility": ["@dnd-kit/accessibility@3.1.1", "", { "dependencies": { "tslib": "^2.0.0" }, "peerDependencies": { "react": ">=16.8.0" } }, "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw=="], + + "@dnd-kit/core": ["@dnd-kit/core@6.3.1", "", { "dependencies": { "@dnd-kit/accessibility": "^3.1.1", "@dnd-kit/utilities": "^3.2.2", "tslib": "^2.0.0" }, "peerDependencies": { "react": ">=16.8.0", "react-dom": ">=16.8.0" } }, "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ=="], + + "@dnd-kit/sortable": ["@dnd-kit/sortable@10.0.0", "", { "dependencies": { "@dnd-kit/utilities": "^3.2.2", "tslib": "^2.0.0" }, "peerDependencies": { "@dnd-kit/core": "^6.3.0", "react": ">=16.8.0" } }, "sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg=="], + + "@dnd-kit/utilities": ["@dnd-kit/utilities@3.2.2", "", { "dependencies": { "tslib": "^2.0.0" }, "peerDependencies": { "react": ">=16.8.0" } }, "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg=="], + "@dotenvx/dotenvx": ["@dotenvx/dotenvx@1.75.1", "", { "dependencies": { "@dotenvx/primitives": "^0.8.0", "commander": "^11.1.0", "conf": "^10.2.0", "dotenv": "^17.2.1", "enquirer": "^2.4.1", "env-paths": "^2.2.1", "execa": "^5.1.1", "fdir": "^6.2.0", "ignore": "^5.3.0", "object-treeify": "1.1.33", "open": "^8.4.2", "picomatch": "^4.0.4", "systeminformation": "^5.22.11", "undici": "^7.11.0", "which": "^4.0.0", "yocto-spinner": "^1.1.0" }, "bin": { "dotenvx": "src/cli/dotenvx.js" } }, "sha512-/BITOC9dmS/edY2zQwZNicQ059O6RKabtQfyEafV0nGtfYRNHYy1DIPiYVcov40+tob9hfmBnbR963dS+EQ1DQ=="], "@dotenvx/primitives": ["@dotenvx/primitives@0.8.0", "", {}, "sha512-VYJy0uhFm9zTJ1TxBaW/pA8bjbOM/OttaNMwZ1RHG4JKyRG7DhSdiqD1ipQoAyoD22olUtxbP78W9xY3Wd11bg=="], diff --git a/components/admin/guide-section-editor.tsx b/components/admin/guide-section-editor.tsx index d054a8d..9594b16 100644 --- a/components/admin/guide-section-editor.tsx +++ b/components/admin/guide-section-editor.tsx @@ -1,8 +1,24 @@ "use client"; import Image from "next/image"; -import { ArrowDownIcon, ArrowUpIcon, PlusIcon, Trash2Icon } from "lucide-react"; +import { ArrowDownIcon, ArrowUpIcon, GripVerticalIcon, PlusIcon, Trash2Icon } from "lucide-react"; import { useState } from "react"; +import { + closestCenter, + DndContext, + KeyboardSensor, + PointerSensor, + useSensor, + useSensors, + type DragEndEvent, +} from "@dnd-kit/core"; +import { + SortableContext, + sortableKeyboardCoordinates, + useSortable, + verticalListSortingStrategy, +} from "@dnd-kit/sortable"; +import { CSS } from "@dnd-kit/utilities"; import { saveArtifactSection, @@ -43,13 +59,19 @@ import { } from "@/lib/guides/schemas"; import { supportsWeaponType } from "@/lib/catalog/lunaris"; import { rarityGradientClass } from "@/lib/catalog/rarity"; +import { + normalizeTalentPriorities, + reorderTalentPriorities, + type TalentPriority, + type TalentRelation, +} from "@/lib/guides/talents"; import { cn } from "@/lib/utils"; type CatalogItem = { key: string; name: string; imageUrl: string; rarity?: number | null; weaponType?: string | null }; type Guide = { id: string; characterKey: string; version: number; name: string }; type WeaponValue = { weaponKey: string; refinement: number; overallPercent: string | null; personalPercent: string | null; weapon: CatalogItem }; type ArtifactValue = { id: string; name: string; rows: Array<{ firstArtifactKey: string; secondArtifactKey: string | null; percent: string }> }; -type ArtifactProfile = { sands: string[]; goblet: string[]; circlet: string[]; substats: string[]; targets: Array<{ stat: string; value: string }>; talents: Array<{ type: "NA" | "E" | "Q"; priority: number; level?: string }>; note: string | null } | null; +type ArtifactProfile = { sands: string[]; goblet: string[]; circlet: string[]; substats: string[]; targets: Array<{ stat: string; value: string }>; talents: TalentPriority[]; note: string | null } | null; type ConstellationValue = { level: number; detail: string; cumulativePercent: string }; type CatalogConstellation = { level: number; name: string; description: string; iconUrl: string | null }; type CatalogTalent = { type: "NA" | "E" | "Q"; name: string; iconUrl: string | null }; @@ -119,6 +141,46 @@ function TalentLevelPicker({ value, onChange }: { value?: string; onChange: (val ); } +function SortableTalentCard({ + talent, + index, + catalogTalent, + onLevelChange, +}: { + talent: TalentPriority; + index: number; + catalogTalent?: CatalogTalent; + onLevelChange: (level?: string) => void; +}) { + const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: talent.type }); + return ( +
+ + + {catalogTalent?.iconUrl ? : null} + {index + 1}. {catalogTalent?.name ?? talent.type} + + + + + + + ระดับเป้าหมาย + + + + +
+ ); +} + function RowActions({ index, length, move, remove }: { index: number; length: number; move: (from: number, to: number) => void; remove?: () => void }) { return
{remove ? : null}
; } @@ -163,6 +225,10 @@ function WeaponEditor({ guide, weapons, characters, initial }: EditorBase) { function ArtifactEditor({ guide, artifacts, initial, catalogTalents }: EditorBase) { const profile = initial.artifactProfile; + const talentSensors = useSensors( + useSensor(PointerSensor), + useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }), + ); const [groups, setGroups] = useState(() => (["Late Game", "Early Game"] as const).map((name, index) => ({ name, rows: initial.artifactGroups[index]?.rows.map((row) => ({ ...row })) ?? [], @@ -176,19 +242,26 @@ function ArtifactEditor({ guide, artifacts, initial, catalogTalents }: EditorBas const defaults = DEFAULT_TARGET_STATS.map((stat) => existing.find((target) => target.stat === stat) ?? { stat, value: "" }); return [...defaults, ...existing.filter((target) => !DEFAULT_TARGET_STATS.includes(target.stat as (typeof DEFAULT_TARGET_STATS)[number]))]; }); - const [talents, setTalents] = useState>(() => { - const source: Array<{ type: "NA" | "E" | "Q"; priority: number; level?: string }> = profile?.talents.length + const [talents, setTalents] = useState(() => { + const source: TalentPriority[] = profile?.talents.length ? profile.talents : (["NA", "E", "Q"] as const).map((type, priority) => ({ type, priority: priority + 1 })); - return source.map((talent) => ({ + 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) => ({ ...group, rows: group.rows.map((row) => ({ ...row, percent: Number(row.percent) })) })), sands, goblet, circlet, substats, targets: targets.filter((target) => target.stat.trim() && target.value.trim()), talents, note: note || null }; const autosave = useAutosave({ value, valid: artifactSectionSchema.safeParse({ ...value, expectedVersion: guide.version }).success, initialVersion: guide.version, persist: (next, expectedVersion) => saveArtifactSection(guide.id, { ...next, expectedVersion }) }); - const orderedTalents = [...talents].sort((a, b) => a.priority - b.priority); + const orderedTalents = normalizeTalentPriorities(talents); + 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 (
@@ -199,7 +272,61 @@ function ArtifactEditor({ guide, artifacts, initial, catalogTalents }: EditorBas Recommended Statsเลือกค่าจากรายการแทนปุ่ม และเลือก Main Stat ได้สูงสุด 2 ค่าต่อชิ้น -
Target Stats{targets.map((target, index) =>
setTargets(targets.map((item, itemIndex) => itemIndex === index ? { ...item, stat: event.target.value } : item))} /> setTargets(targets.map((item, itemIndex) => itemIndex === index ? { ...item, value: event.target.value } : item))} />
)}
Talent Priority{orderedTalents.map((talent, index) => { const catalogTalent = catalogTalents.find((item) => item.type === talent.type); return {catalogTalent?.iconUrl ? : null}{index + 1}. {catalogTalent?.name ?? talent.type} setTalents(moveItem(orderedTalents, from, to).map((item, priority) => ({ ...item, priority: priority + 1 })))} />ระดับเป้าหมาย setTalents(talents.map((item) => item.type === talent.type ? { ...item, level } : item))} />; })}
Note (optional)