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 ค่าต่อชิ้น
- Note (optional)
+
+
+
+
+
+
+
+
+
+ Note (optional)
+
+
+
diff --git a/components/public/structured-guide.tsx b/components/public/structured-guide.tsx
index 25a94fa..c584de4 100644
--- a/components/public/structured-guide.tsx
+++ b/components/public/structured-guide.tsx
@@ -19,6 +19,7 @@ import { ElementIcon } from "@/components/public/element-icon";
import { LiveRefresh } from "@/components/live-refresh";
import { rarityGradientClass, rarityTextClass } from "@/lib/catalog/rarity";
import type { PublicGuide } from "@/lib/guides/queries";
+import { normalizeTalentPriorities, talentRelationSymbol } from "@/lib/guides/talents";
import { cn } from "@/lib/utils";
function hasSection(guide: PublicGuide, slug: string): boolean {
@@ -374,17 +375,27 @@ function ArtifactSection({ guide }: { guide: PublicGuide }) {
{profile.talents.length ? (
ลำดับการอัพพรสวรรค์
-
- {[...profile.talents]
- .sort((a, b) => a.priority - b.priority)
- .map((talent) => {
+
+ {normalizeTalentPriorities(profile.talents)
+ .map((talent, index, talents) => {
const catalogTalent = guide.character.talents.find((item) => item.type === talent.type);
- return catalogTalent?.iconUrl ? (
-
-
-
{talent.priority}
+ const talentName = catalogTalent?.name ?? talent.type;
+ return (
+
+
+
+ {catalogTalent?.iconUrl ? : {talent.type}}
+
+
{talent.level ? `Lv.${talent.level}+` : "—"}
+
{talentName}
+
+ {index < talents.length - 1 ? (
+
+ {talentRelationSymbol(talent.relationToNext)}
+
+ ) : null}
- ) :
{talent.priority}. {talent.type};
+ );
})}
diff --git a/db/schema.ts b/db/schema.ts
index ff9618a..8172597 100644
--- a/db/schema.ts
+++ b/db/schema.ts
@@ -606,7 +606,7 @@ export const artifactProfiles = guidesSchema.table("artifact_profile", {
circlet: jsonb("circlet").$type
().default([]).notNull(),
substats: jsonb("substats").$type().default([]).notNull(),
targets: jsonb("targets").$type>().default([]).notNull(),
- talents: jsonb("talents").$type>().default([]).notNull(),
+ talents: jsonb("talents").$type>().default([]).notNull(),
note: text("note"),
...timestamps,
});
diff --git a/lib/guides/schemas.test.ts b/lib/guides/schemas.test.ts
index c0a7370..e846dd2 100644
--- a/lib/guides/schemas.test.ts
+++ b/lib/guides/schemas.test.ts
@@ -35,6 +35,13 @@ describe("structured guide validation", () => {
expect(artifactSectionSchema.safeParse({ ...base, talents: [{ type: "NA", priority: 1, level: "11" }] }).success).toBe(false);
});
+ it("accepts talent comparison relations while keeping legacy talents valid", () => {
+ const base = { expectedVersion: 1, groups: artifactGroups, sands: [], goblet: [], circlet: [], substats: [], targets: [], note: null };
+ expect(artifactSectionSchema.safeParse({ ...base, talents: [{ type: "E", priority: 1, relationToNext: "gte" }] }).success).toBe(true);
+ expect(artifactSectionSchema.safeParse({ ...base, talents: [{ type: "E", priority: 1 }] }).success).toBe(true);
+ expect(artifactSectionSchema.safeParse({ ...base, talents: [{ type: "E", priority: 1, relationToNext: "equal" }] }).success).toBe(false);
+ });
+
it("requires four unique team members and non-zero damage", () => {
const member = (characterKey: string) => ({ characterKey, weaponKey: null, artifactKey: null, constellation: 0, refinement: 1, damage: 100 });
const base = { expectedVersion: 1, teams: [{ title: "Test", rotationSeconds: 20, combo: "E > Q", members: ["1", "2", "3", "4"].map(member) }] };
diff --git a/lib/guides/schemas.ts b/lib/guides/schemas.ts
index 26b0052..7db33d3 100644
--- a/lib/guides/schemas.ts
+++ b/lib/guides/schemas.ts
@@ -38,6 +38,7 @@ export const mainStatOptions = {
export const substatOptions = ["CRIT Rate%/CRIT DMG%", "HP%", "ATK%", "DEF%", "EM", "ER"] as const;
export const talentLevelOptions = ["1", "2", "3", "4", "5", "6", "7", "8", "9", "10"] as const;
+export const talentRelationOptions = ["gt", "gte"] as const;
const artifactGroupSchema = (name: "Late Game" | "Early Game") => z.strictObject({
name: z.literal(name),
@@ -66,6 +67,7 @@ export const artifactSectionSchema = z.strictObject({
type: z.enum(["NA", "E", "Q"]),
priority: z.number().int().min(1).max(3),
level: z.enum(talentLevelOptions).optional(),
+ relationToNext: z.enum(talentRelationOptions).optional(),
})).max(3),
note: z.string().trim().max(2_000).nullable(),
});
diff --git a/lib/guides/talents.test.ts b/lib/guides/talents.test.ts
new file mode 100644
index 0000000..58b1553
--- /dev/null
+++ b/lib/guides/talents.test.ts
@@ -0,0 +1,35 @@
+import { describe, expect, it } from "vitest";
+
+import { normalizeTalentPriorities, reorderTalentPriorities, talentRelationSymbol } from "@/lib/guides/talents";
+
+describe("talent priorities", () => {
+ it("normalizes legacy talents to strict comparisons", () => {
+ expect(normalizeTalentPriorities([
+ { type: "Q", priority: 2 },
+ { type: "E", priority: 1 },
+ { type: "NA", priority: 3 },
+ ])).toEqual([
+ { type: "E", priority: 1, relationToNext: "gt" },
+ { type: "Q", priority: 2, relationToNext: "gt" },
+ { type: "NA", priority: 3, relationToNext: undefined },
+ ]);
+ });
+
+ it("reorders talents while preserving relations between positions", () => {
+ expect(reorderTalentPriorities([
+ { type: "E", priority: 1, relationToNext: "gte" },
+ { type: "Q", priority: 2, relationToNext: "gt" },
+ { type: "NA", priority: 3 },
+ ], "NA", "E")).toEqual([
+ { type: "NA", priority: 1, relationToNext: "gte" },
+ { type: "E", priority: 2, relationToNext: "gt" },
+ { type: "Q", priority: 3, relationToNext: undefined },
+ ]);
+ });
+
+ it("formats strict and greater-than-or-equal relations", () => {
+ expect(talentRelationSymbol("gt")).toBe(">");
+ expect(talentRelationSymbol("gte")).toBe("≥");
+ expect(talentRelationSymbol()).toBe(">");
+ });
+});
diff --git a/lib/guides/talents.ts b/lib/guides/talents.ts
new file mode 100644
index 0000000..dece5a7
--- /dev/null
+++ b/lib/guides/talents.ts
@@ -0,0 +1,43 @@
+export type TalentRelation = "gt" | "gte";
+
+export interface TalentPriority {
+ type: "NA" | "E" | "Q";
+ priority: number;
+ level?: string;
+ relationToNext?: TalentRelation;
+}
+
+export function normalizeTalentPriorities(talents: readonly TalentPriority[]): TalentPriority[] {
+ const ordered = [...talents].sort((a, b) => a.priority - b.priority);
+ return ordered.map((talent, index) => ({
+ ...talent,
+ priority: index + 1,
+ relationToNext: index < ordered.length - 1 ? talent.relationToNext ?? "gt" : undefined,
+ }));
+}
+
+export function reorderTalentPriorities(
+ talents: readonly TalentPriority[],
+ activeType: TalentPriority["type"],
+ overType: TalentPriority["type"],
+): TalentPriority[] {
+ const ordered = normalizeTalentPriorities(talents);
+ const from = ordered.findIndex((talent) => talent.type === activeType);
+ const to = ordered.findIndex((talent) => talent.type === overType);
+ if (from < 0 || to < 0 || from === to) return ordered;
+
+ const relations = ordered.slice(0, -1).map((talent) => talent.relationToNext ?? "gt");
+ const next = [...ordered];
+ const [moved] = next.splice(from, 1);
+ next.splice(to, 0, moved);
+
+ return next.map((talent, index) => ({
+ ...talent,
+ priority: index + 1,
+ relationToNext: index < next.length - 1 ? relations[index] : undefined,
+ }));
+}
+
+export function talentRelationSymbol(relation?: TalentRelation): ">" | "≥" {
+ return relation === "gte" ? "≥" : ">";
+}
diff --git a/package.json b/package.json
index 2a6d5bd..51e5b65 100644
--- a/package.json
+++ b/package.json
@@ -20,6 +20,9 @@
},
"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",