feat(weapon) : share public cards with inline admin editing

This commit is contained in:
2026-10-14 23:50:14 +07:00 Unverified
parent 024553b516
commit 8ad431e68f
4 changed files with 351 additions and 335 deletions
@@ -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) }] }] });
});
});
+145 -202
View File
@@ -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>
); );
+13 -133
View File
@@ -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>
+108
View File
@@ -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>;
}