feat(weapon) : share public cards with inline admin editing
This commit is contained in:
@@ -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<typeof WeaponEditor>;
|
||||||
|
await act(async () => root.render(createElement(WeaponEditor, props)));
|
||||||
|
return container.querySelector<HTMLInputElement>("#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) }] }] });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { GripVerticalIcon, PlusIcon, Trash2Icon } from "lucide-react";
|
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 { closestCenter, DndContext, KeyboardSensor, PointerSensor, useSensor, useSensors, type DragEndEvent } from "@dnd-kit/core";
|
||||||
import { arrayMove, SortableContext, sortableKeyboardCoordinates, useSortable, verticalListSortingStrategy } from "@dnd-kit/sortable";
|
import { arrayMove, SortableContext, sortableKeyboardCoordinates, useSortable, verticalListSortingStrategy } from "@dnd-kit/sortable";
|
||||||
import { CSS } from "@dnd-kit/utilities";
|
import { CSS } from "@dnd-kit/utilities";
|
||||||
@@ -9,13 +9,14 @@ import { saveWeaponSection } from "@/app/admin/actions";
|
|||||||
import { SaveStatus } from "@/components/admin/save-status";
|
import { SaveStatus } from "@/components/admin/save-status";
|
||||||
import { DeleteConfirmationDialog } from "@/components/admin/ui/delete-confirmation-dialog";
|
import { DeleteConfirmationDialog } from "@/components/admin/ui/delete-confirmation-dialog";
|
||||||
import { WeaponSelect } from "@/components/catalog-select";
|
import { WeaponSelect } from "@/components/catalog-select";
|
||||||
import { CatalogItemImage } from "@/components/catalog-item-image";
|
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Card, CardAction, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import { Field, FieldGroup, FieldLabel } from "@/components/ui/field";
|
import { Field, FieldLabel } from "@/components/ui/field";
|
||||||
import { ClearableInput as Input } from "@/components/admin/ui/clearable-input";
|
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 { 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 { useManualSave } from "@/hooks/use-manual-save";
|
||||||
import { adminDraftKey } from "@/lib/editor/local-draft";
|
import { adminDraftKey } from "@/lib/editor/local-draft";
|
||||||
import { weaponSectionSchema } from "@/lib/guides/schemas";
|
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 { finalizePercentInput, normalizePercentInput } from "@/lib/guides/percent-input";
|
||||||
import { formatGuideValidationIssues } from "@/lib/guides/validation-messages";
|
import { formatGuideValidationIssues } from "@/lib/guides/validation-messages";
|
||||||
import { cn } from "@/lib/utils";
|
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<typeof Input> & { value: string }) {
|
||||||
|
const [focused, setFocused] = useState(false);
|
||||||
|
const numeric = Number(value);
|
||||||
|
const displayValue = !value.trim() || !Number.isFinite(numeric)
|
||||||
|
? value
|
||||||
|
: weaponComparisonPercent(value) ?? "";
|
||||||
|
|
||||||
|
return <Input
|
||||||
|
{...props}
|
||||||
|
value={focused ? value : displayValue}
|
||||||
|
onFocus={(event) => {
|
||||||
|
setFocused(true);
|
||||||
|
onFocus?.(event);
|
||||||
|
}}
|
||||||
|
onBlur={(event) => {
|
||||||
|
setFocused(false);
|
||||||
|
onBlur?.(event);
|
||||||
|
}}
|
||||||
|
/>;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
export function SortableWeaponCard({
|
export function SortableWeaponCard({
|
||||||
@@ -157,16 +180,16 @@ export function WeaponEditor({ guide, weapons, characters, initial }: EditorBase
|
|||||||
return (
|
return (
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Weapon tier list</CardTitle>
|
<CardTitle>จัดอันดับอาวุธ</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
ลากเพื่อเรียงอาวุธจากดีที่สุดลงมา และกรอกเปอร์เซ็นต์อย่างน้อยหนึ่งช่อง
|
ลากเพื่อเรียงอาวุธจากดีที่สุดลงมา กรอกเปอร์เซ็นต์อย่างน้อยหนึ่งช่องต่ออาวุธ
|
||||||
กดปุ่มบันทึกเพื่อบันทึกการเปลี่ยนแปลง
|
โดย 100% คือค่าฐานในการเปรียบเทียบ แล้วกดบันทึกเพื่อบันทึกการเปลี่ยนแปลง
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="flex flex-col gap-4">
|
<CardContent className="flex flex-col gap-4">
|
||||||
<Field>
|
<Field>
|
||||||
<FieldLabel htmlFor="weapon-section-note">
|
<FieldLabel htmlFor="weapon-section-note">
|
||||||
Header note (optional)
|
หมายเหตุส่วนอาวุธ (ไม่บังคับ)
|
||||||
</FieldLabel>
|
</FieldLabel>
|
||||||
<Textarea
|
<Textarea
|
||||||
id="weapon-section-note"
|
id="weapon-section-note"
|
||||||
@@ -225,7 +248,7 @@ export function WeaponEditor({ guide, weapons, characters, initial }: EditorBase
|
|||||||
/>
|
/>
|
||||||
<Field>
|
<Field>
|
||||||
<FieldLabel htmlFor={`weapon-condition-note-${groups[activeGroupIndex]?.clientId}`}>
|
<FieldLabel htmlFor={`weapon-condition-note-${groups[activeGroupIndex]?.clientId}`}>
|
||||||
Condition note (optional)
|
หมายเหตุเงื่อนไข (ไม่บังคับ)
|
||||||
</FieldLabel>
|
</FieldLabel>
|
||||||
<Textarea
|
<Textarea
|
||||||
id={`weapon-condition-note-${groups[activeGroupIndex]?.clientId}`}
|
id={`weapon-condition-note-${groups[activeGroupIndex]?.clientId}`}
|
||||||
@@ -250,200 +273,120 @@ export function WeaponEditor({ guide, weapons, characters, initial }: EditorBase
|
|||||||
return (
|
return (
|
||||||
<SortableWeaponCard key={row.clientId} id={row.clientId}>
|
<SortableWeaponCard key={row.clientId} id={row.clientId}>
|
||||||
{(dragHandle) => (
|
{(dragHandle) => (
|
||||||
<Card size="sm">
|
<WeaponComparisonCard
|
||||||
<CardHeader>
|
weapon={selectedWeapon ?? { key: row.weaponKey, name: "เลือกอาวุธ", imageUrl: null, rarity: 1 }}
|
||||||
<CardTitle className="flex items-center gap-3">
|
rank={rowIndex + 1}
|
||||||
{selectedWeapon ? (
|
refinement={row.refinement}
|
||||||
<CatalogItemImage item={selectedWeapon} kind="weapon" alt="" className="size-10 shrink-0 rounded-lg" sizes="40px" />
|
note={row.note}
|
||||||
) : null}
|
overallPercent={row.overallPercent}
|
||||||
<span>อันดับ {rowIndex + 1}</span>
|
personalPercent={row.personalPercent}
|
||||||
</CardTitle>
|
imageControl={
|
||||||
<CardAction>
|
<Field className="w-20 shrink-0" data-invalid={weaponIssue("groups", activeGroupIndex, "rows", rowIndex, "weaponKey")}>
|
||||||
<div className="flex gap-1">
|
<WeaponSelect
|
||||||
{dragHandle}
|
presentation="slot"
|
||||||
<DeleteConfirmationDialog
|
className="size-20 border-0 bg-transparent"
|
||||||
trigger={
|
label={`เลือกอาวุธอันดับ ${rowIndex + 1}`}
|
||||||
<Button
|
items={compatibleWeapons}
|
||||||
type="button"
|
value={row.weaponKey}
|
||||||
variant="plain"
|
invalid={weaponIssue("groups", activeGroupIndex, "rows", rowIndex, "weaponKey")}
|
||||||
size="icon-sm"
|
placeholder="ค้นหาชื่ออาวุธ…"
|
||||||
/>
|
rarityBackground={false}
|
||||||
}
|
onValueChange={(weaponKey) => setRows((current) => current.map((item) =>
|
||||||
title={`ลบ ${selectedWeapon?.name ?? `อาวุธอันดับ ${rowIndex + 1}`}?`}
|
item.clientId === row.clientId ? { ...item, weaponKey } : item,
|
||||||
description="รายการอาวุธและเปอร์เซ็นต์เปรียบเทียบจะถูกลบออกจาก Guide"
|
))}
|
||||||
onConfirm={() =>
|
/>
|
||||||
setRows(
|
</Field>
|
||||||
rows.filter(
|
}
|
||||||
(item) => item.clientId !== row.clientId,
|
refinementControl={
|
||||||
),
|
<span className="inline-flex shrink-0" data-invalid={weaponIssue("groups", activeGroupIndex, "rows", rowIndex, "refinement")}>
|
||||||
)
|
<Select
|
||||||
}
|
value={row.refinement}
|
||||||
|
onValueChange={(refinement) => {
|
||||||
|
if (refinement) setRows((current) => current.map((item) =>
|
||||||
|
item.clientId === row.clientId ? { ...item, refinement } : item,
|
||||||
|
));
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SelectTrigger
|
||||||
|
className="border-white/30 text-white hover:border-white/60 [&_svg]:text-white"
|
||||||
|
style={{ backgroundColor: rarityColor(selectedWeapon?.rarity ?? 1) }}
|
||||||
|
size="sm" aria-label={`ระดับขัดเกลาอาวุธอันดับ ${rowIndex + 1}`} aria-invalid={weaponIssue("groups", activeGroupIndex, "rows", rowIndex, "refinement")}>
|
||||||
|
<SelectValue>{`R${row.refinement}`}</SelectValue>
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent
|
||||||
|
className="text-white [--color-accent:rgba(255,255,255,0.2)] [--color-accent-foreground:white]"
|
||||||
|
style={{ backgroundColor: rarityColor(selectedWeapon?.rarity ?? 1) }}
|
||||||
>
|
>
|
||||||
<Trash2Icon />
|
<SelectGroup>
|
||||||
<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>
|
|
||||||
<WeaponSelect
|
|
||||||
items={compatibleWeapons}
|
|
||||||
value={row.weaponKey}
|
|
||||||
invalid={weaponIssue("groups", activeGroupIndex, "rows", rowIndex, "weaponKey")}
|
|
||||||
placeholder="ค้นหาชื่ออาวุธ…"
|
|
||||||
rarityBackground
|
|
||||||
onValueChange={(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) => (
|
{["1", "2", "3", "4", "5", "1-5"].map((refinement) => (
|
||||||
<ToggleGroupItem
|
<SelectItem key={refinement} value={refinement}>R{refinement}</SelectItem>
|
||||||
className={SELECTED_TOGGLE_CLASS}
|
|
||||||
key={refinement}
|
|
||||||
value={`R${refinement}`}
|
|
||||||
>
|
|
||||||
R{refinement}
|
|
||||||
</ToggleGroupItem>
|
|
||||||
))}
|
))}
|
||||||
</ToggleGroup>
|
</SelectGroup>
|
||||||
</Field>
|
</SelectContent>
|
||||||
</div>
|
</Select>
|
||||||
<div className="grid gap-4 sm:grid-cols-2">
|
</span>
|
||||||
<Field data-invalid={weaponIssue("groups", activeGroupIndex, "rows", rowIndex)}>
|
}
|
||||||
<FieldLabel>Overall %</FieldLabel>
|
noteControl={
|
||||||
<Input
|
<Field>
|
||||||
type="text"
|
<FieldLabel className="sr-only" htmlFor={`weapon-note-${row.clientId}`}>หมายเหตุอาวุธอันดับ {rowIndex + 1}</FieldLabel>
|
||||||
inputMode="decimal"
|
<Textarea
|
||||||
value={row.overallPercent}
|
id={`weapon-note-${row.clientId}`}
|
||||||
aria-invalid={weaponIssue("groups", activeGroupIndex, "rows", rowIndex)}
|
rows={1}
|
||||||
onChange={(event) =>
|
className="min-h-8 bg-white/10! text-white placeholder:text-white/70"
|
||||||
setRows(
|
placeholder="หมายเหตุอาวุธ (ไม่บังคับ)"
|
||||||
rows.map((item) =>
|
value={row.note}
|
||||||
item.clientId === row.clientId
|
onChange={(event) => setRows((current) => current.map((item) =>
|
||||||
? {
|
item.clientId === row.clientId ? { ...item, note: event.target.value } : item,
|
||||||
...item,
|
))}
|
||||||
overallPercent:
|
/>
|
||||||
normalizePercentInput(
|
</Field>
|
||||||
event.target.value,
|
}
|
||||||
item.overallPercent,
|
actions={
|
||||||
),
|
<div className="absolute top-2 right-2 flex gap-1">
|
||||||
}
|
{dragHandle}
|
||||||
: item,
|
<DeleteConfirmationDialog
|
||||||
),
|
trigger={
|
||||||
)
|
<Button
|
||||||
}
|
type="button"
|
||||||
onBlur={() =>
|
variant="plain"
|
||||||
setRows(
|
size="icon-sm"
|
||||||
rows.map((item) =>
|
|
||||||
item.clientId === row.clientId
|
|
||||||
? {
|
|
||||||
...item,
|
|
||||||
overallPercent:
|
|
||||||
finalizePercentInput(
|
|
||||||
item.overallPercent,
|
|
||||||
),
|
|
||||||
}
|
|
||||||
: item,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
</Field>
|
}
|
||||||
<Field data-invalid={weaponIssue("groups", activeGroupIndex, "rows", rowIndex)}>
|
title={`ลบ ${selectedWeapon?.name ?? `อาวุธอันดับ ${rowIndex + 1}`}?`}
|
||||||
<FieldLabel>Personal %</FieldLabel>
|
description="รายการอาวุธและเปอร์เซ็นต์เปรียบเทียบจะถูกลบออกจาก Guide"
|
||||||
<Input
|
onConfirm={() =>
|
||||||
type="text"
|
setRows(
|
||||||
inputMode="decimal"
|
rows.filter(
|
||||||
value={row.personalPercent}
|
(item) => item.clientId !== row.clientId,
|
||||||
aria-invalid={weaponIssue("groups", activeGroupIndex, "rows", rowIndex)}
|
),
|
||||||
onChange={(event) =>
|
)
|
||||||
setRows(
|
}
|
||||||
rows.map((item) =>
|
>
|
||||||
item.clientId === row.clientId
|
<Trash2Icon />
|
||||||
? {
|
<span className="sr-only">ลบอาวุธ</span>
|
||||||
...item,
|
</DeleteConfirmationDialog>
|
||||||
personalPercent:
|
</div>
|
||||||
normalizePercentInput(
|
}
|
||||||
event.target.value,
|
renderMetric={(metric) => <WeaponDamageInput
|
||||||
item.personalPercent,
|
className="h-[1em] rounded-none border-0 bg-transparent! p-0 text-center text-[length:inherit]! leading-none font-bold focus-visible:ring-2"
|
||||||
),
|
id={`weapon-${metric === "overallPercent" ? "overall" : "personal"}-${row.clientId}`}
|
||||||
}
|
aria-label={metric === "overallPercent" ? "ดาเมจทั้งทีม (%)" : "ดาเมจส่วนตัว (%)"}
|
||||||
: item,
|
type="text"
|
||||||
),
|
inputMode="decimal"
|
||||||
)
|
value={row[metric]}
|
||||||
}
|
aria-invalid={weaponIssue("groups", activeGroupIndex, "rows", rowIndex)}
|
||||||
onBlur={() =>
|
onChange={(event) => setRows((current) => current.map((item) =>
|
||||||
setRows(
|
item.clientId === row.clientId
|
||||||
rows.map((item) =>
|
? { ...item, [metric]: normalizePercentInput(event.target.value, item[metric]) }
|
||||||
item.clientId === row.clientId
|
: item,
|
||||||
? {
|
))}
|
||||||
...item,
|
onBlur={() => setRows((current) => current.map((item) =>
|
||||||
personalPercent:
|
item.clientId === row.clientId
|
||||||
finalizePercentInput(
|
? { ...item, [metric]: finalizePercentInput(item[metric]) }
|
||||||
item.personalPercent,
|
: item,
|
||||||
),
|
))}
|
||||||
}
|
/>}
|
||||||
: 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>
|
</SortableWeaponCard>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ import { LunarisText } from "@/components/public/lunaris-text";
|
|||||||
import { ConditionSwitcher } from "@/components/public/condition-switcher";
|
import { ConditionSwitcher } from "@/components/public/condition-switcher";
|
||||||
import { MaterialPoster } from "@/components/public/material-poster";
|
import { MaterialPoster } from "@/components/public/material-poster";
|
||||||
import { RichTextContent } from "@/components/rich-text-content";
|
import { RichTextContent } from "@/components/rich-text-content";
|
||||||
import { rarityGradientClass, rarityTextClass } from "@/lib/catalog/rarity";
|
import { WeaponComparisonCard } from "@/components/weapon-comparison-card";
|
||||||
import { elementIconFilter } from "@/lib/catalog/element";
|
import { elementIconFilter } from "@/lib/catalog/element";
|
||||||
import type { PublicGuide } from "@/lib/guides/queries";
|
import type { PublicGuide } from "@/lib/guides/queries";
|
||||||
import { artifactTierCount } from "@/lib/guides/artifacts";
|
import { artifactTierCount } from "@/lib/guides/artifacts";
|
||||||
@@ -391,138 +391,18 @@ function WeaponSectionContent({
|
|||||||
className={cn("grid gap-3", useCompactExportGrid && "grid-cols-2")}
|
className={cn("grid gap-3", useCompactExportGrid && "grid-cols-2")}
|
||||||
>
|
>
|
||||||
{group.rows.map((row, index) => (
|
{group.rows.map((row, index) => (
|
||||||
<Card key={row.id} size="sm" className="bg-white py-0">
|
<WeaponComparisonCard
|
||||||
<CardContent
|
key={row.id}
|
||||||
className={cn(
|
weapon={row.weapon}
|
||||||
"grid p-0",
|
rank={index + 1}
|
||||||
useCompactExportGrid
|
refinement={row.refinement}
|
||||||
? "grid-cols-[minmax(0,3fr)_minmax(0,2fr)]"
|
note={row.note}
|
||||||
: exporting
|
overallPercent={row.overallPercent}
|
||||||
? "grid-cols-[minmax(0,3fr)_minmax(0,1fr)]"
|
personalPercent={row.personalPercent}
|
||||||
: "md:grid-cols-[minmax(0,3fr)_minmax(0,1fr)]",
|
weaponHref={lunarisItemUrl("weapon", row.weapon.key)}
|
||||||
)}
|
exporting={exporting}
|
||||||
>
|
compactExportGrid={useCompactExportGrid}
|
||||||
<a
|
/>
|
||||||
href={lunarisItemUrl("weapon", row.weapon.key)}
|
|
||||||
target="_blank"
|
|
||||||
rel="noreferrer"
|
|
||||||
tabIndex={exporting ? -1 : undefined}
|
|
||||||
aria-label={`เปิดข้อมูลอาวุธ ${row.weapon.name} บน Lunaris`}
|
|
||||||
className={cn(
|
|
||||||
"flex min-h-28 min-w-0 items-center gap-4 rounded-br-[4rem] p-4 pr-10 text-white",
|
|
||||||
useCompactExportGrid
|
|
||||||
? "rounded-br-[8rem] pr-10"
|
|
||||||
: exporting
|
|
||||||
? "rounded-br-[8rem] pr-16"
|
|
||||||
: "md:rounded-br-[8rem] md:pr-16",
|
|
||||||
rarityGradientClass(row.weapon.rarity),
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<CatalogItemImage
|
|
||||||
item={row.weapon}
|
|
||||||
kind="weapon"
|
|
||||||
alt={row.weapon.name}
|
|
||||||
rarityBackground={false}
|
|
||||||
className="size-20 shrink-0"
|
|
||||||
loading={exporting ? "eager" : undefined}
|
|
||||||
sizes="(min-width: 768px) 5rem, 4rem"
|
|
||||||
/>
|
|
||||||
<div className="flex min-w-0 flex-col gap-1">
|
|
||||||
<p
|
|
||||||
className={cn(
|
|
||||||
"truncate font-semibold",
|
|
||||||
useCompactExportGrid
|
|
||||||
? "text-sm"
|
|
||||||
: exporting
|
|
||||||
? "text-lg"
|
|
||||||
: "text-base sm:text-lg",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{index + 1}. {row.weapon.name} R{row.refinement}
|
|
||||||
</p>
|
|
||||||
{row.note ? (
|
|
||||||
<p
|
|
||||||
className={cn(
|
|
||||||
"whitespace-pre-wrap",
|
|
||||||
useCompactExportGrid ? "text-xs" : "text-sm",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{row.note}
|
|
||||||
</p>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
</a>
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
"flex items-center justify-around bg-white py-5 text-black",
|
|
||||||
useCompactExportGrid ? "gap-1 px-2" : "gap-4 px-4",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{row.overallPercent ? (
|
|
||||||
<div className="min-w-0 flex-1 text-center">
|
|
||||||
<p
|
|
||||||
className={cn(
|
|
||||||
"whitespace-nowrap leading-none font-bold",
|
|
||||||
useCompactExportGrid
|
|
||||||
? "text-2xl"
|
|
||||||
: exporting
|
|
||||||
? "text-5xl"
|
|
||||||
: "text-4xl sm:text-5xl",
|
|
||||||
rarityTextClass(row.weapon.rarity),
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{Number(row.overallPercent) === 0
|
|
||||||
? (exporting ? "-%" : "-")
|
|
||||||
: metric(row.overallPercent)}
|
|
||||||
</p>
|
|
||||||
<p
|
|
||||||
className={cn(
|
|
||||||
"mt-1 whitespace-nowrap",
|
|
||||||
useCompactExportGrid
|
|
||||||
? "text-[10px]"
|
|
||||||
: exporting
|
|
||||||
? "text-base"
|
|
||||||
: "text-sm sm:text-base",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
ดาเมจทั้งทีม
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
{row.personalPercent ? (
|
|
||||||
<div className="min-w-0 flex-1 text-center">
|
|
||||||
<p
|
|
||||||
className={cn(
|
|
||||||
"whitespace-nowrap leading-none font-bold",
|
|
||||||
useCompactExportGrid
|
|
||||||
? "text-2xl"
|
|
||||||
: exporting
|
|
||||||
? "text-5xl"
|
|
||||||
: "text-4xl sm:text-5xl",
|
|
||||||
rarityTextClass(row.weapon.rarity),
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{Number(row.personalPercent) === 0
|
|
||||||
? (exporting ? "-%" : "-")
|
|
||||||
: metric(row.personalPercent)}
|
|
||||||
</p>
|
|
||||||
<p
|
|
||||||
className={cn(
|
|
||||||
"mt-1 whitespace-nowrap",
|
|
||||||
useCompactExportGrid
|
|
||||||
? "text-[10px]"
|
|
||||||
: exporting
|
|
||||||
? "text-base"
|
|
||||||
: "text-sm sm:text-base",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
ดาเมจส่วนตัว
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</WeaponDisplayMode>
|
</WeaponDisplayMode>
|
||||||
|
|||||||
@@ -0,0 +1,108 @@
|
|||||||
|
import type { ReactNode } from "react";
|
||||||
|
|
||||||
|
import { CatalogItemImage, type CatalogItemImageData } from "@/components/catalog-item-image";
|
||||||
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
|
import { rarityGradientClass, rarityTextClass } from "@/lib/catalog/rarity";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
type DamageMetric = "overallPercent" | "personalPercent";
|
||||||
|
|
||||||
|
export function weaponComparisonPercent(value: string | null, exporting = false) {
|
||||||
|
if (value === null) return null;
|
||||||
|
return Number(value) === 0
|
||||||
|
? exporting ? "-%" : "-"
|
||||||
|
: `${Number(value).toLocaleString(undefined, { maximumFractionDigits: 2 })}%`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function WeaponComparisonCard({
|
||||||
|
weapon, rank, refinement, note, overallPercent, personalPercent,
|
||||||
|
exporting = false, compactExportGrid = false, weaponHref,
|
||||||
|
imageControl, refinementControl, noteControl, actions, renderMetric,
|
||||||
|
}: {
|
||||||
|
weapon: CatalogItemImageData & { name: string };
|
||||||
|
rank: number;
|
||||||
|
refinement: string;
|
||||||
|
note: string | null;
|
||||||
|
overallPercent: string | null;
|
||||||
|
personalPercent: string | null;
|
||||||
|
exporting?: boolean;
|
||||||
|
compactExportGrid?: boolean;
|
||||||
|
weaponHref?: string;
|
||||||
|
imageControl?: ReactNode;
|
||||||
|
refinementControl?: ReactNode;
|
||||||
|
noteControl?: ReactNode;
|
||||||
|
actions?: ReactNode;
|
||||||
|
renderMetric?: (metric: DamageMetric) => ReactNode;
|
||||||
|
}) {
|
||||||
|
const Panel = weaponHref ? "a" : "div";
|
||||||
|
const title = <p className={cn(
|
||||||
|
"truncate font-semibold",
|
||||||
|
compactExportGrid ? "text-sm" : exporting ? "text-lg" : "text-base sm:text-lg",
|
||||||
|
)}>
|
||||||
|
{rank}. {weapon.name}{refinementControl ? null : ` R${refinement}`}
|
||||||
|
</p>;
|
||||||
|
|
||||||
|
return <Card size="sm" className="bg-white py-0">
|
||||||
|
<CardContent className={cn(
|
||||||
|
"grid p-0",
|
||||||
|
compactExportGrid
|
||||||
|
? "grid-cols-[minmax(0,3fr)_minmax(0,2fr)]"
|
||||||
|
: exporting
|
||||||
|
? "grid-cols-[minmax(0,3fr)_minmax(0,1fr)]"
|
||||||
|
: renderMetric
|
||||||
|
? "md:grid-cols-[minmax(0,5fr)_minmax(0,2fr)]"
|
||||||
|
: "md:grid-cols-[minmax(0,3fr)_minmax(0,1fr)]",
|
||||||
|
)}>
|
||||||
|
<Panel
|
||||||
|
href={weaponHref}
|
||||||
|
target={weaponHref ? "_blank" : undefined}
|
||||||
|
rel={weaponHref ? "noreferrer" : undefined}
|
||||||
|
tabIndex={exporting ? -1 : undefined}
|
||||||
|
aria-label={weaponHref ? `เปิดข้อมูลอาวุธ ${weapon.name} บน Lunaris` : undefined}
|
||||||
|
className={cn(
|
||||||
|
"flex min-h-28 min-w-0 items-center gap-4 rounded-br-[4rem] p-4 pr-10 text-white",
|
||||||
|
compactExportGrid
|
||||||
|
? "rounded-br-[8rem] pr-10"
|
||||||
|
: exporting ? "rounded-br-[8rem] pr-16" : "md:rounded-br-[8rem] md:pr-16",
|
||||||
|
rarityGradientClass(weapon.rarity),
|
||||||
|
actions && "relative",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{imageControl ?? <CatalogItemImage
|
||||||
|
item={weapon} kind="weapon" alt={weapon.name} rarityBackground={false}
|
||||||
|
className="size-20 shrink-0" loading={exporting ? "eager" : undefined}
|
||||||
|
sizes="(min-width: 768px) 5rem, 4rem"
|
||||||
|
/>}
|
||||||
|
<div className="flex min-w-0 flex-col gap-1">
|
||||||
|
{refinementControl ? <div className="flex min-w-0 flex-wrap items-center gap-2">{title}{refinementControl}</div> : title}
|
||||||
|
{noteControl ?? (note ? <p className={cn("whitespace-pre-wrap", compactExportGrid ? "text-xs" : "text-sm")}>{note}</p> : null)}
|
||||||
|
</div>
|
||||||
|
{actions}
|
||||||
|
</Panel>
|
||||||
|
<div className={cn(
|
||||||
|
"flex items-center justify-around bg-white py-5 text-black",
|
||||||
|
compactExportGrid ? "gap-1 px-2" : "gap-4 px-4",
|
||||||
|
)}>
|
||||||
|
{(["overallPercent", "personalPercent"] as const).map((metric) => {
|
||||||
|
const value = metric === "overallPercent" ? overallPercent : personalPercent;
|
||||||
|
if (!renderMetric && !value) return null;
|
||||||
|
return <div key={metric} className="min-w-0 flex-1 text-center">
|
||||||
|
<p className={cn(
|
||||||
|
"whitespace-nowrap leading-none font-bold",
|
||||||
|
compactExportGrid ? "text-2xl" : exporting ? "text-5xl" : "text-4xl sm:text-5xl",
|
||||||
|
rarityTextClass(weapon.rarity),
|
||||||
|
)}>
|
||||||
|
{renderMetric ? renderMetric(metric) : weaponComparisonPercent(value, exporting)}
|
||||||
|
</p>
|
||||||
|
<p className={cn(
|
||||||
|
"mt-1 whitespace-nowrap",
|
||||||
|
compactExportGrid ? "text-[10px]" : exporting ? "text-base" : "text-sm sm:text-base",
|
||||||
|
)}>
|
||||||
|
{metric === "overallPercent" ? "ดาเมจทั้งทีม" : "ดาเมจส่วนตัว"}
|
||||||
|
</p>
|
||||||
|
</div>;
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user