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";
|
||||
|
||||
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<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({
|
||||
@@ -157,16 +180,16 @@ export function WeaponEditor({ guide, weapons, characters, initial }: EditorBase
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Weapon tier list</CardTitle>
|
||||
<CardTitle>จัดอันดับอาวุธ</CardTitle>
|
||||
<CardDescription>
|
||||
ลากเพื่อเรียงอาวุธจากดีที่สุดลงมา และกรอกเปอร์เซ็นต์อย่างน้อยหนึ่งช่อง
|
||||
กดปุ่มบันทึกเพื่อบันทึกการเปลี่ยนแปลง
|
||||
ลากเพื่อเรียงอาวุธจากดีที่สุดลงมา กรอกเปอร์เซ็นต์อย่างน้อยหนึ่งช่องต่ออาวุธ
|
||||
โดย 100% คือค่าฐานในการเปรียบเทียบ แล้วกดบันทึกเพื่อบันทึกการเปลี่ยนแปลง
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-4">
|
||||
<Field>
|
||||
<FieldLabel htmlFor="weapon-section-note">
|
||||
Header note (optional)
|
||||
หมายเหตุส่วนอาวุธ (ไม่บังคับ)
|
||||
</FieldLabel>
|
||||
<Textarea
|
||||
id="weapon-section-note"
|
||||
@@ -225,7 +248,7 @@ export function WeaponEditor({ guide, weapons, characters, initial }: EditorBase
|
||||
/>
|
||||
<Field>
|
||||
<FieldLabel htmlFor={`weapon-condition-note-${groups[activeGroupIndex]?.clientId}`}>
|
||||
Condition note (optional)
|
||||
หมายเหตุเงื่อนไข (ไม่บังคับ)
|
||||
</FieldLabel>
|
||||
<Textarea
|
||||
id={`weapon-condition-note-${groups[activeGroupIndex]?.clientId}`}
|
||||
@@ -250,200 +273,120 @@ export function WeaponEditor({ guide, weapons, characters, initial }: EditorBase
|
||||
return (
|
||||
<SortableWeaponCard key={row.clientId} id={row.clientId}>
|
||||
{(dragHandle) => (
|
||||
<Card size="sm">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-3">
|
||||
{selectedWeapon ? (
|
||||
<CatalogItemImage item={selectedWeapon} kind="weapon" alt="" className="size-10 shrink-0 rounded-lg" sizes="40px" />
|
||||
) : null}
|
||||
<span>อันดับ {rowIndex + 1}</span>
|
||||
</CardTitle>
|
||||
<CardAction>
|
||||
<div className="flex gap-1">
|
||||
{dragHandle}
|
||||
<DeleteConfirmationDialog
|
||||
trigger={
|
||||
<Button
|
||||
type="button"
|
||||
variant="plain"
|
||||
size="icon-sm"
|
||||
/>
|
||||
}
|
||||
title={`ลบ ${selectedWeapon?.name ?? `อาวุธอันดับ ${rowIndex + 1}`}?`}
|
||||
description="รายการอาวุธและเปอร์เซ็นต์เปรียบเทียบจะถูกลบออกจาก Guide"
|
||||
onConfirm={() =>
|
||||
setRows(
|
||||
rows.filter(
|
||||
(item) => item.clientId !== row.clientId,
|
||||
),
|
||||
)
|
||||
}
|
||||
<WeaponComparisonCard
|
||||
weapon={selectedWeapon ?? { key: row.weaponKey, name: "เลือกอาวุธ", imageUrl: null, rarity: 1 }}
|
||||
rank={rowIndex + 1}
|
||||
refinement={row.refinement}
|
||||
note={row.note}
|
||||
overallPercent={row.overallPercent}
|
||||
personalPercent={row.personalPercent}
|
||||
imageControl={
|
||||
<Field className="w-20 shrink-0" data-invalid={weaponIssue("groups", activeGroupIndex, "rows", rowIndex, "weaponKey")}>
|
||||
<WeaponSelect
|
||||
presentation="slot"
|
||||
className="size-20 border-0 bg-transparent"
|
||||
label={`เลือกอาวุธอันดับ ${rowIndex + 1}`}
|
||||
items={compatibleWeapons}
|
||||
value={row.weaponKey}
|
||||
invalid={weaponIssue("groups", activeGroupIndex, "rows", rowIndex, "weaponKey")}
|
||||
placeholder="ค้นหาชื่ออาวุธ…"
|
||||
rarityBackground={false}
|
||||
onValueChange={(weaponKey) => setRows((current) => current.map((item) =>
|
||||
item.clientId === row.clientId ? { ...item, weaponKey } : item,
|
||||
))}
|
||||
/>
|
||||
</Field>
|
||||
}
|
||||
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 />
|
||||
<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,
|
||||
),
|
||||
);
|
||||
}}
|
||||
>
|
||||
<SelectGroup>
|
||||
{["1", "2", "3", "4", "5", "1-5"].map((refinement) => (
|
||||
<ToggleGroupItem
|
||||
className={SELECTED_TOGGLE_CLASS}
|
||||
key={refinement}
|
||||
value={`R${refinement}`}
|
||||
>
|
||||
R{refinement}
|
||||
</ToggleGroupItem>
|
||||
<SelectItem key={refinement} value={refinement}>R{refinement}</SelectItem>
|
||||
))}
|
||||
</ToggleGroup>
|
||||
</Field>
|
||||
</div>
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<Field data-invalid={weaponIssue("groups", activeGroupIndex, "rows", rowIndex)}>
|
||||
<FieldLabel>Overall %</FieldLabel>
|
||||
<Input
|
||||
type="text"
|
||||
inputMode="decimal"
|
||||
value={row.overallPercent}
|
||||
aria-invalid={weaponIssue("groups", activeGroupIndex, "rows", rowIndex)}
|
||||
onChange={(event) =>
|
||||
setRows(
|
||||
rows.map((item) =>
|
||||
item.clientId === row.clientId
|
||||
? {
|
||||
...item,
|
||||
overallPercent:
|
||||
normalizePercentInput(
|
||||
event.target.value,
|
||||
item.overallPercent,
|
||||
),
|
||||
}
|
||||
: item,
|
||||
),
|
||||
)
|
||||
}
|
||||
onBlur={() =>
|
||||
setRows(
|
||||
rows.map((item) =>
|
||||
item.clientId === row.clientId
|
||||
? {
|
||||
...item,
|
||||
overallPercent:
|
||||
finalizePercentInput(
|
||||
item.overallPercent,
|
||||
),
|
||||
}
|
||||
: item,
|
||||
),
|
||||
)
|
||||
}
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</span>
|
||||
}
|
||||
noteControl={
|
||||
<Field>
|
||||
<FieldLabel className="sr-only" htmlFor={`weapon-note-${row.clientId}`}>หมายเหตุอาวุธอันดับ {rowIndex + 1}</FieldLabel>
|
||||
<Textarea
|
||||
id={`weapon-note-${row.clientId}`}
|
||||
rows={1}
|
||||
className="min-h-8 bg-white/10! text-white placeholder:text-white/70"
|
||||
placeholder="หมายเหตุอาวุธ (ไม่บังคับ)"
|
||||
value={row.note}
|
||||
onChange={(event) => setRows((current) => current.map((item) =>
|
||||
item.clientId === row.clientId ? { ...item, note: event.target.value } : item,
|
||||
))}
|
||||
/>
|
||||
</Field>
|
||||
}
|
||||
actions={
|
||||
<div className="absolute top-2 right-2 flex gap-1">
|
||||
{dragHandle}
|
||||
<DeleteConfirmationDialog
|
||||
trigger={
|
||||
<Button
|
||||
type="button"
|
||||
variant="plain"
|
||||
size="icon-sm"
|
||||
/>
|
||||
</Field>
|
||||
<Field data-invalid={weaponIssue("groups", activeGroupIndex, "rows", rowIndex)}>
|
||||
<FieldLabel>Personal %</FieldLabel>
|
||||
<Input
|
||||
type="text"
|
||||
inputMode="decimal"
|
||||
value={row.personalPercent}
|
||||
aria-invalid={weaponIssue("groups", activeGroupIndex, "rows", rowIndex)}
|
||||
onChange={(event) =>
|
||||
setRows(
|
||||
rows.map((item) =>
|
||||
item.clientId === row.clientId
|
||||
? {
|
||||
...item,
|
||||
personalPercent:
|
||||
normalizePercentInput(
|
||||
event.target.value,
|
||||
item.personalPercent,
|
||||
),
|
||||
}
|
||||
: item,
|
||||
),
|
||||
)
|
||||
}
|
||||
onBlur={() =>
|
||||
setRows(
|
||||
rows.map((item) =>
|
||||
item.clientId === row.clientId
|
||||
? {
|
||||
...item,
|
||||
personalPercent:
|
||||
finalizePercentInput(
|
||||
item.personalPercent,
|
||||
),
|
||||
}
|
||||
: 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>
|
||||
}
|
||||
title={`ลบ ${selectedWeapon?.name ?? `อาวุธอันดับ ${rowIndex + 1}`}?`}
|
||||
description="รายการอาวุธและเปอร์เซ็นต์เปรียบเทียบจะถูกลบออกจาก Guide"
|
||||
onConfirm={() =>
|
||||
setRows(
|
||||
rows.filter(
|
||||
(item) => item.clientId !== row.clientId,
|
||||
),
|
||||
)
|
||||
}
|
||||
>
|
||||
<Trash2Icon />
|
||||
<span className="sr-only">ลบอาวุธ</span>
|
||||
</DeleteConfirmationDialog>
|
||||
</div>
|
||||
}
|
||||
renderMetric={(metric) => <WeaponDamageInput
|
||||
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" ? "ดาเมจทั้งทีม (%)" : "ดาเมจส่วนตัว (%)"}
|
||||
type="text"
|
||||
inputMode="decimal"
|
||||
value={row[metric]}
|
||||
aria-invalid={weaponIssue("groups", activeGroupIndex, "rows", rowIndex)}
|
||||
onChange={(event) => setRows((current) => current.map((item) =>
|
||||
item.clientId === row.clientId
|
||||
? { ...item, [metric]: normalizePercentInput(event.target.value, item[metric]) }
|
||||
: item,
|
||||
))}
|
||||
onBlur={() => setRows((current) => current.map((item) =>
|
||||
item.clientId === row.clientId
|
||||
? { ...item, [metric]: finalizePercentInput(item[metric]) }
|
||||
: item,
|
||||
))}
|
||||
/>}
|
||||
/>
|
||||
)}
|
||||
</SortableWeaponCard>
|
||||
);
|
||||
|
||||
@@ -37,7 +37,7 @@ import { LunarisText } from "@/components/public/lunaris-text";
|
||||
import { ConditionSwitcher } from "@/components/public/condition-switcher";
|
||||
import { MaterialPoster } from "@/components/public/material-poster";
|
||||
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 type { PublicGuide } from "@/lib/guides/queries";
|
||||
import { artifactTierCount } from "@/lib/guides/artifacts";
|
||||
@@ -391,138 +391,18 @@ function WeaponSectionContent({
|
||||
className={cn("grid gap-3", useCompactExportGrid && "grid-cols-2")}
|
||||
>
|
||||
{group.rows.map((row, index) => (
|
||||
<Card key={row.id} size="sm" className="bg-white py-0">
|
||||
<CardContent
|
||||
className={cn(
|
||||
"grid p-0",
|
||||
useCompactExportGrid
|
||||
? "grid-cols-[minmax(0,3fr)_minmax(0,2fr)]"
|
||||
: exporting
|
||||
? "grid-cols-[minmax(0,3fr)_minmax(0,1fr)]"
|
||||
: "md:grid-cols-[minmax(0,3fr)_minmax(0,1fr)]",
|
||||
)}
|
||||
>
|
||||
<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>
|
||||
<WeaponComparisonCard
|
||||
key={row.id}
|
||||
weapon={row.weapon}
|
||||
rank={index + 1}
|
||||
refinement={row.refinement}
|
||||
note={row.note}
|
||||
overallPercent={row.overallPercent}
|
||||
personalPercent={row.personalPercent}
|
||||
weaponHref={lunarisItemUrl("weapon", row.weapon.key)}
|
||||
exporting={exporting}
|
||||
compactExportGrid={useCompactExportGrid}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</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