From 8ad431e68fc35df921b47b30b2fa440515c1b38a Mon Sep 17 00:00:00 2001 From: gunshiz Date: Wed, 14 Oct 2026 23:50:14 +0700 Subject: [PATCH] feat(weapon) : share public cards with inline admin editing --- .../admin/editors/weapon-editor.test.tsx | 85 +++++ components/admin/editors/weapon-editor.tsx | 347 ++++++++---------- components/public/structured-guide.tsx | 146 +------- components/weapon-comparison-card.tsx | 108 ++++++ 4 files changed, 351 insertions(+), 335 deletions(-) create mode 100644 components/admin/editors/weapon-editor.test.tsx create mode 100644 components/weapon-comparison-card.tsx diff --git a/components/admin/editors/weapon-editor.test.tsx b/components/admin/editors/weapon-editor.test.tsx new file mode 100644 index 0000000..dbf5ad7 --- /dev/null +++ b/components/admin/editors/weapon-editor.test.tsx @@ -0,0 +1,85 @@ +// @vitest-environment happy-dom + +import { act, createElement, type ComponentProps } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { renderToStaticMarkup } from "react-dom/server"; +import { WeaponComparisonCard } from "@/components/weapon-comparison-card"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { useManualSave } from "@/hooks/use-manual-save"; +import { WeaponEditor } from "./weapon-editor"; + +vi.mock("@/app/admin/actions", () => ({ saveWeaponSection: vi.fn() })); +vi.mock("@/hooks/use-manual-save", () => ({ + useManualSave: vi.fn(() => ({ state: "saved", canSave: false })), +})); + +let root: Root; +let container: HTMLDivElement; + +beforeEach(() => { + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + vi.clearAllMocks(); + container = document.createElement("div"); + document.body.append(container); + root = createRoot(container); +}); + +afterEach(async () => { + await act(async () => root.unmount()); + container.remove(); + vi.unstubAllGlobals(); +}); + +async function render(value: string) { + const props = { + guide: { id: "guide", characterKey: "character", version: 1 }, + characters: [{ key: "character", weaponType: "sword" }], + weapons: [{ key: "weapon", name: "Weapon", weaponType: "sword", rarity: 5, imageUrl: "/weapon.png" }], + initial: { sectionNote: null, weaponGroups: [{ + id: "condition", name: null, note: null, rows: [{ + id: "row", weaponKey: "weapon", refinement: "1", overallPercent: value, personalPercent: null, note: null, + }], + }] }, + } as unknown as ComponentProps; + await act(async () => root.render(createElement(WeaponEditor, props))); + return container.querySelector("#weapon-overall-row")!; +} + +describe("weapon damage editing", () => { + it("uses the public curve and damage typography with more room for inputs", async () => { + const input = await render("120.00"); + const publicCard = document.createElement("div"); + publicCard.innerHTML = renderToStaticMarkup(createElement(WeaponComparisonCard, { + weapon: { key: "weapon", name: "Weapon", rarity: 5, imageUrl: "/weapon.png" }, + rank: 1, refinement: "1", note: null, overallPercent: "120.00", personalPercent: null, + weaponHref: "https://lunaris.moe/weapon/weapon", + })); + const publicContent = publicCard.querySelector('[data-slot="card-content"]')!; + const adminContent = input.closest('[data-slot="card-content"]')!; + expect(adminContent.className.replace(/md:grid-cols-\S+/u, "")) + .toBe(publicContent.className.replace(/md:grid-cols-\S+/u, "")); + expect(adminContent.className).toContain("md:grid-cols-[minmax(0,5fr)_minmax(0,2fr)]"); + expect(adminContent.firstElementChild!.className.replace(" relative", "")) + .toBe(publicContent.firstElementChild!.className); + const publicValue = publicContent.lastElementChild!.querySelector("p")!; + expect(input.parentElement!.className).toBe(publicValue.className); + expect(input.value).toBe(publicValue.textContent); + expect(adminContent.lastElementChild!.className).toBe(publicContent.lastElementChild!.className); + }); + + it.each([ + ["0.00", "-"], + ["120.00", "120%"], + ["120.125", "120.13%"], + ["", ""], + ])("displays %s as %s while preserving its raw draft value", async (raw, display) => { + const input = await render(raw); + expect(input.value).toBe(display); + await act(async () => input.focus()); + expect(input.value).toBe(raw); + await act(async () => input.blur()); + expect(input.value).toBe(display); + const options = vi.mocked(useManualSave).mock.lastCall![0]; + expect(options.value).toMatchObject({ groups: [{ rows: [{ overallPercent: raw === "" ? null : Number(raw) }] }] }); + }); +}); diff --git a/components/admin/editors/weapon-editor.tsx b/components/admin/editors/weapon-editor.tsx index 03bc072..a83b679 100644 --- a/components/admin/editors/weapon-editor.tsx +++ b/components/admin/editors/weapon-editor.tsx @@ -1,7 +1,7 @@ "use client"; import { GripVerticalIcon, PlusIcon, Trash2Icon } from "lucide-react"; -import { useState, type ReactNode } from "react"; +import { useState, type ComponentProps, type ReactNode } from "react"; import { closestCenter, DndContext, KeyboardSensor, PointerSensor, useSensor, useSensors, type DragEndEvent } from "@dnd-kit/core"; import { arrayMove, SortableContext, sortableKeyboardCoordinates, useSortable, verticalListSortingStrategy } from "@dnd-kit/sortable"; import { CSS } from "@dnd-kit/utilities"; @@ -9,13 +9,14 @@ import { saveWeaponSection } from "@/app/admin/actions"; import { SaveStatus } from "@/components/admin/save-status"; import { DeleteConfirmationDialog } from "@/components/admin/ui/delete-confirmation-dialog"; import { WeaponSelect } from "@/components/catalog-select"; -import { CatalogItemImage } from "@/components/catalog-item-image"; import { Button } from "@/components/ui/button"; -import { Card, CardAction, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card"; -import { Field, FieldGroup, FieldLabel } from "@/components/ui/field"; -import { ClearableInput as Input } from "@/components/admin/ui/clearable-input"; +import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card"; +import { Field, FieldLabel } from "@/components/ui/field"; +import { Input } from "@/components/ui/input"; +import { WeaponComparisonCard, weaponComparisonPercent } from "@/components/weapon-comparison-card"; +import { rarityColor } from "@/lib/catalog/rarity"; import { Textarea } from "@/components/ui/textarea"; -import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"; +import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { useManualSave } from "@/hooks/use-manual-save"; import { adminDraftKey } from "@/lib/editor/local-draft"; import { weaponSectionSchema } from "@/lib/guides/schemas"; @@ -23,7 +24,29 @@ import { supportsWeaponType } from "@/lib/catalog/lunaris"; import { finalizePercentInput, normalizePercentInput } from "@/lib/guides/percent-input"; import { formatGuideValidationIssues } from "@/lib/guides/validation-messages"; import { cn } from "@/lib/utils"; -import { type EditorBase, hasIssuePrefix, parseEditorDraft, ConditionControls, reorderConditions, createClientId, ConditionRenameDialog, SELECTED_TOGGLE_CLASS } from "./shared"; +import { type EditorBase, hasIssuePrefix, parseEditorDraft, ConditionControls, reorderConditions, createClientId, ConditionRenameDialog } from "./shared"; + + +function WeaponDamageInput({ value, onFocus, onBlur, ...props }: ComponentProps & { value: string }) { + const [focused, setFocused] = useState(false); + const numeric = Number(value); + const displayValue = !value.trim() || !Number.isFinite(numeric) + ? value + : weaponComparisonPercent(value) ?? ""; + + return { + setFocused(true); + onFocus?.(event); + }} + onBlur={(event) => { + setFocused(false); + onBlur?.(event); + }} + />; +} export function SortableWeaponCard({ @@ -157,16 +180,16 @@ export function WeaponEditor({ guide, weapons, characters, initial }: EditorBase return ( - Weapon tier list + จัดอันดับอาวุธ - ลากเพื่อเรียงอาวุธจากดีที่สุดลงมา และกรอกเปอร์เซ็นต์อย่างน้อยหนึ่งช่อง - กดปุ่มบันทึกเพื่อบันทึกการเปลี่ยนแปลง + ลากเพื่อเรียงอาวุธจากดีที่สุดลงมา กรอกเปอร์เซ็นต์อย่างน้อยหนึ่งช่องต่ออาวุธ + โดย 100% คือค่าฐานในการเปรียบเทียบ แล้วกดบันทึกเพื่อบันทึกการเปลี่ยนแปลง - Header note (optional) + หมายเหตุส่วนอาวุธ (ไม่บังคับ)