feat : add preview export image
This commit is contained in:
@@ -72,6 +72,91 @@ export function ArtifactImageExport({
|
||||
: onlySection === "target-stats" || onlySection === "talents"
|
||||
? ".artifact-export-grid{display:block!important}.artifact-export-right{width:560px!important;max-width:100%;margin-left:auto!important;margin-right:auto!important;justify-self:center!important}"
|
||||
: "";
|
||||
const artifactOptions = (
|
||||
<>
|
||||
<FieldGroup>
|
||||
<Field orientation="horizontal">
|
||||
<Checkbox
|
||||
id="artifact-export-entire"
|
||||
checked={entireSection}
|
||||
onCheckedChange={(checked) => setSections(
|
||||
checked ? new Set(sectionOptions.map(([value]) => value)) : new Set(),
|
||||
)}
|
||||
/>
|
||||
<FieldLabel htmlFor="artifact-export-entire">ทั้งหมด</FieldLabel>
|
||||
</Field>
|
||||
{sectionOptions.map(([value, label]) => (
|
||||
<Field key={value} orientation="horizontal">
|
||||
<Checkbox
|
||||
id={`artifact-export-${value}`}
|
||||
checked={sections.has(value)}
|
||||
onCheckedChange={(checked) => {
|
||||
setSections((current) => {
|
||||
const next = new Set(current);
|
||||
if (checked) next.add(value);
|
||||
else next.delete(value);
|
||||
return next;
|
||||
});
|
||||
}}
|
||||
/>
|
||||
<FieldLabel htmlFor={`artifact-export-${value}`}>{label}</FieldLabel>
|
||||
</Field>
|
||||
))}
|
||||
</FieldGroup>
|
||||
{sections.has("best-stats") && recommendedConditionLabels.length > 1 ? (
|
||||
<div className="flex flex-col gap-2">
|
||||
<p className="text-sm font-medium">เงื่อนไขค่าสถานะแนะนำ</p>
|
||||
<ToggleGroup
|
||||
variant="outline"
|
||||
value={[recommendedCondition]}
|
||||
onValueChange={(values) => {
|
||||
if (values[0]) setRecommendedCondition(values[0]);
|
||||
}}
|
||||
>
|
||||
{recommendedConditionLabels.map((label, index) => (
|
||||
<ToggleGroupItem
|
||||
key={index}
|
||||
value={String(index)}
|
||||
className="aria-pressed:bg-primary aria-pressed:text-primary-foreground"
|
||||
>
|
||||
{label}
|
||||
</ToggleGroupItem>
|
||||
))}
|
||||
</ToggleGroup>
|
||||
</div>
|
||||
) : null}
|
||||
{sections.has("tierlist") && tierConditionLabels.length > 1 ? (
|
||||
<div className="flex flex-col gap-2">
|
||||
<p className="text-sm font-medium">เงื่อนไขอันดับอาร์ติแฟกต์</p>
|
||||
<ToggleGroup variant="outline" value={[tierCondition]} onValueChange={(values) => { if (values[0]) setTierCondition(values[0]); }}>
|
||||
{tierConditionLabels.map((label, index) => <ToggleGroupItem key={index} value={String(index)} className="aria-pressed:bg-primary aria-pressed:text-primary-foreground">{label}</ToggleGroupItem>)}
|
||||
</ToggleGroup>
|
||||
</div>
|
||||
) : null}
|
||||
{sections.has("target-stats") && targetConditionLabels.length > 1 ? (
|
||||
<div className="flex flex-col gap-2">
|
||||
<p className="text-sm font-medium">เงื่อนไขค่าสถานะเป้าหมาย</p>
|
||||
<ToggleGroup
|
||||
variant="outline"
|
||||
value={[targetCondition]}
|
||||
onValueChange={(values) => {
|
||||
if (values[0]) setTargetCondition(values[0]);
|
||||
}}
|
||||
>
|
||||
{targetConditionLabels.map((label, index) => (
|
||||
<ToggleGroupItem
|
||||
key={index}
|
||||
value={String(index)}
|
||||
className="aria-pressed:bg-primary aria-pressed:text-primary-foreground"
|
||||
>
|
||||
{label}
|
||||
</ToggleGroupItem>
|
||||
))}
|
||||
</ToggleGroup>
|
||||
</div>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<Dialog>
|
||||
@@ -79,94 +164,13 @@ export function ArtifactImageExport({
|
||||
<DownloadIcon data-icon="inline-start" />
|
||||
ส่งออกภาพ
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogContent className="max-h-[90svh] overflow-y-auto sm:w-fit sm:max-w-[calc(100%-2rem)]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>เลือกข้อมูลสำหรับส่งออก</DialogTitle>
|
||||
<DialogDescription>
|
||||
เลือกได้หลายส่วน หรือเลือกทั้งหมดเพื่อส่งออกทุกส่วน
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<FieldGroup>
|
||||
<Field orientation="horizontal">
|
||||
<Checkbox
|
||||
id="artifact-export-entire"
|
||||
checked={entireSection}
|
||||
onCheckedChange={(checked) => setSections(
|
||||
checked ? new Set(sectionOptions.map(([value]) => value)) : new Set(),
|
||||
)}
|
||||
/>
|
||||
<FieldLabel htmlFor="artifact-export-entire">ทั้งหมด</FieldLabel>
|
||||
</Field>
|
||||
{sectionOptions.map(([value, label]) => (
|
||||
<Field key={value} orientation="horizontal">
|
||||
<Checkbox
|
||||
id={`artifact-export-${value}`}
|
||||
checked={sections.has(value)}
|
||||
onCheckedChange={(checked) => {
|
||||
setSections((current) => {
|
||||
const next = new Set(current);
|
||||
if (checked) next.add(value);
|
||||
else next.delete(value);
|
||||
return next;
|
||||
});
|
||||
}}
|
||||
/>
|
||||
<FieldLabel htmlFor={`artifact-export-${value}`}>{label}</FieldLabel>
|
||||
</Field>
|
||||
))}
|
||||
</FieldGroup>
|
||||
{sections.has("best-stats") && recommendedConditionLabels.length > 1 ? (
|
||||
<div className="flex flex-col gap-2">
|
||||
<p className="text-sm font-medium">เงื่อนไขค่าสถานะแนะนำ</p>
|
||||
<ToggleGroup
|
||||
variant="outline"
|
||||
value={[recommendedCondition]}
|
||||
onValueChange={(values) => {
|
||||
if (values[0]) setRecommendedCondition(values[0]);
|
||||
}}
|
||||
>
|
||||
{recommendedConditionLabels.map((label, index) => (
|
||||
<ToggleGroupItem
|
||||
key={index}
|
||||
value={String(index)}
|
||||
className="aria-pressed:bg-primary aria-pressed:text-primary-foreground"
|
||||
>
|
||||
{label}
|
||||
</ToggleGroupItem>
|
||||
))}
|
||||
</ToggleGroup>
|
||||
</div>
|
||||
) : null}
|
||||
{sections.has("tierlist") && tierConditionLabels.length > 1 ? (
|
||||
<div className="flex flex-col gap-2">
|
||||
<p className="text-sm font-medium">เงื่อนไขอันดับอาร์ติแฟกต์</p>
|
||||
<ToggleGroup variant="outline" value={[tierCondition]} onValueChange={(values) => { if (values[0]) setTierCondition(values[0]); }}>
|
||||
{tierConditionLabels.map((label, index) => <ToggleGroupItem key={index} value={String(index)} className="aria-pressed:bg-primary aria-pressed:text-primary-foreground">{label}</ToggleGroupItem>)}
|
||||
</ToggleGroup>
|
||||
</div>
|
||||
) : null}
|
||||
{sections.has("target-stats") && targetConditionLabels.length > 1 ? (
|
||||
<div className="flex flex-col gap-2">
|
||||
<p className="text-sm font-medium">เงื่อนไขค่าสถานะเป้าหมาย</p>
|
||||
<ToggleGroup
|
||||
variant="outline"
|
||||
value={[targetCondition]}
|
||||
onValueChange={(values) => {
|
||||
if (values[0]) setTargetCondition(values[0]);
|
||||
}}
|
||||
>
|
||||
{targetConditionLabels.map((label, index) => (
|
||||
<ToggleGroupItem
|
||||
key={index}
|
||||
value={String(index)}
|
||||
className="aria-pressed:bg-primary aria-pressed:text-primary-foreground"
|
||||
>
|
||||
{label}
|
||||
</ToggleGroupItem>
|
||||
))}
|
||||
</ToggleGroup>
|
||||
</div>
|
||||
) : null}
|
||||
<GuideImageExport
|
||||
fileName={fileName}
|
||||
width={exportWidth}
|
||||
@@ -174,6 +178,8 @@ export function ArtifactImageExport({
|
||||
disabled={sections.size === 0}
|
||||
captureClassName="p-0"
|
||||
modeSelection="inline"
|
||||
additionalOptions={artifactOptions}
|
||||
previewPosition="right"
|
||||
>
|
||||
<style>{hiddenSections + targetRule + recommendedRule + tierRule + hideEmptyRightColumn + layoutRules}</style>
|
||||
{children}
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
@@ -65,6 +66,9 @@ export function GuideImageExport({
|
||||
promptForLanguage = false,
|
||||
localizeFileName = true,
|
||||
modeSelection = "dialog",
|
||||
additionalOptions,
|
||||
previewPosition = "top",
|
||||
optionsBeforePreview = false,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
fileName: string;
|
||||
@@ -81,6 +85,9 @@ export function GuideImageExport({
|
||||
promptForLanguage?: boolean;
|
||||
localizeFileName?: boolean;
|
||||
modeSelection?: "dialog" | "inline";
|
||||
additionalOptions?: ReactNode;
|
||||
previewPosition?: "top" | "right";
|
||||
optionsBeforePreview?: boolean;
|
||||
}) {
|
||||
const captureRef = useRef<HTMLDivElement>(null);
|
||||
const [captureMode, setCaptureMode] = useState<
|
||||
@@ -93,6 +100,10 @@ export function GuideImageExport({
|
||||
"with-notes",
|
||||
);
|
||||
const [modeDialogOpen, setModeDialogOpen] = useState(false);
|
||||
const previewRef = useRef<HTMLDivElement>(null);
|
||||
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
|
||||
const [previewing, setPreviewing] = useState(false);
|
||||
const previewEnabled = modeSelection === "inline" || modeDialogOpen;
|
||||
|
||||
function requestCapture() {
|
||||
setModeDialogOpen(false);
|
||||
@@ -121,7 +132,29 @@ export function GuideImageExport({
|
||||
);
|
||||
}
|
||||
|
||||
const exportSettings = (
|
||||
function applyNoteMode(
|
||||
node: HTMLDivElement,
|
||||
mode: "with-notes" | "without-notes",
|
||||
) {
|
||||
node.querySelectorAll<HTMLElement>("[data-export-note]").forEach((note) => {
|
||||
note.style.display = mode === "without-notes" ? "none" : "";
|
||||
});
|
||||
|
||||
const right = node.querySelector<HTMLElement>(".artifact-export-right");
|
||||
if (!right) return;
|
||||
right.style.display = "";
|
||||
if (mode === "without-notes") {
|
||||
const sections = right.querySelectorAll<HTMLElement>(
|
||||
"[data-artifact-export-section]",
|
||||
);
|
||||
const hasVisibleSection = Array.from(sections).some(
|
||||
(section) => getComputedStyle(section).display !== "none",
|
||||
);
|
||||
if (!hasVisibleSection) right.style.display = "none";
|
||||
}
|
||||
}
|
||||
|
||||
const exportOptions = (
|
||||
<>
|
||||
{showLanguageSelector || promptForLanguage ? (
|
||||
<div className="flex flex-col gap-2">
|
||||
@@ -146,39 +179,90 @@ export function GuideImageExport({
|
||||
</ToggleGroup>
|
||||
</div>
|
||||
) : null}
|
||||
<Button type="button" disabled={capturing || disabled} onClick={requestCapture}>
|
||||
{capturing ? <Spinner data-icon="inline-start" /> : <DownloadIcon data-icon="inline-start" />}
|
||||
{capturing ? "กำลังส่งออก…" : buttonLabel}
|
||||
</Button>
|
||||
</>
|
||||
);
|
||||
|
||||
const exportAction = (
|
||||
<Button type="button" disabled={capturing || disabled} onClick={requestCapture}>
|
||||
{capturing ? <Spinner data-icon="inline-start" /> : <DownloadIcon data-icon="inline-start" />}
|
||||
{capturing ? "กำลังส่งออก…" : buttonLabel}
|
||||
</Button>
|
||||
);
|
||||
|
||||
const exportPreview = (
|
||||
<div className="flex flex-col gap-2">
|
||||
<p className="text-sm font-medium">ตัวอย่างภาพส่งออก</p>
|
||||
<div className="flex min-h-32 items-center justify-center overflow-hidden rounded-lg border bg-muted/30 p-2">
|
||||
{previewing ? (
|
||||
<Spinner className="size-6" />
|
||||
) : previewUrl ? (
|
||||
// This is a temporary client-generated blob, not an optimizable remote asset.
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img src={previewUrl} alt="ตัวอย่างภาพที่จะส่งออก" className="max-h-[50svh] max-w-full object-contain" />
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">ไม่สามารถสร้างตัวอย่างได้</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!previewEnabled) return;
|
||||
|
||||
let cancelled = false;
|
||||
let nextUrl: string | null = null;
|
||||
|
||||
async function createPreview() {
|
||||
setPreviewing(true);
|
||||
try {
|
||||
const node = previewRef.current;
|
||||
if (!node) throw new Error("Preview target is unavailable");
|
||||
applyNoteMode(node, selectedMode);
|
||||
|
||||
const { domToBlob, waitUntilLoad } = await import("modern-screenshot");
|
||||
await Promise.all([document.fonts.ready, waitUntilLoad(node)]);
|
||||
const blob = await domToBlob(node, {
|
||||
backgroundColor: getComputedStyle(node).backgroundColor,
|
||||
type: `image/${format}`,
|
||||
scale: 1,
|
||||
width,
|
||||
height,
|
||||
});
|
||||
if (cancelled) return;
|
||||
|
||||
nextUrl = URL.createObjectURL(blob);
|
||||
setPreviewUrl((current) => {
|
||||
if (current) URL.revokeObjectURL(current);
|
||||
return nextUrl;
|
||||
});
|
||||
} catch {
|
||||
if (!cancelled) setPreviewUrl(null);
|
||||
} finally {
|
||||
if (!cancelled) setPreviewing(false);
|
||||
}
|
||||
}
|
||||
|
||||
void createPreview();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
if (nextUrl) URL.revokeObjectURL(nextUrl);
|
||||
};
|
||||
}, [children, format, height, language, previewEnabled, selectedMode, width]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!capturing) return;
|
||||
|
||||
let cancelled = false;
|
||||
|
||||
async function capture() {
|
||||
const activeCaptureMode = captureMode;
|
||||
if (!activeCaptureMode) return;
|
||||
|
||||
try {
|
||||
const node = captureRef.current;
|
||||
if (!node) throw new Error("Capture target is unavailable");
|
||||
|
||||
if (captureMode === "without-notes") {
|
||||
node.querySelectorAll<HTMLElement>("[data-export-note]").forEach((note) => {
|
||||
note.style.display = "none";
|
||||
});
|
||||
// A note can be the only remaining content in the artifact right column.
|
||||
const right = node.querySelector<HTMLElement>(".artifact-export-right");
|
||||
if (right) {
|
||||
const sections = right.querySelectorAll<HTMLElement>(
|
||||
"[data-artifact-export-section]",
|
||||
);
|
||||
const hasVisibleSection = Array.from(sections).some(
|
||||
(section) => getComputedStyle(section).display !== "none",
|
||||
);
|
||||
if (!hasVisibleSection) right.style.display = "none";
|
||||
}
|
||||
}
|
||||
applyNoteMode(node, activeCaptureMode);
|
||||
|
||||
const { domToBlob, waitUntilLoad } = await import("modern-screenshot");
|
||||
await Promise.all([document.fonts.ready, waitUntilLoad(node)]);
|
||||
@@ -196,7 +280,7 @@ export function GuideImageExport({
|
||||
link.download = imageExportFileName(
|
||||
fileName,
|
||||
language,
|
||||
captureMode === "without-notes",
|
||||
activeCaptureMode === "without-notes",
|
||||
localizeFileName,
|
||||
);
|
||||
link.href = url;
|
||||
@@ -220,21 +304,78 @@ export function GuideImageExport({
|
||||
return (
|
||||
<>
|
||||
{modeSelection === "inline" ? (
|
||||
exportSettings
|
||||
<>
|
||||
{previewPosition === "right" ? (
|
||||
<div className="grid min-h-0 gap-4 lg:grid-cols-[16rem_fit-content(42rem)]">
|
||||
<div className="flex min-w-0 flex-col gap-4">
|
||||
{additionalOptions}
|
||||
{exportOptions}
|
||||
</div>
|
||||
<div className="min-w-0">{exportPreview}</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{additionalOptions}
|
||||
{exportPreview}
|
||||
{exportOptions}
|
||||
</>
|
||||
)}
|
||||
<DialogFooter className="sticky bottom-0">
|
||||
{exportAction}
|
||||
</DialogFooter>
|
||||
</>
|
||||
) : (
|
||||
<div className="flex flex-wrap gap-2">{exportButton(buttonLabel)}</div>
|
||||
)}
|
||||
{modeSelection === "dialog" ? (
|
||||
<Dialog open={modeDialogOpen} onOpenChange={setModeDialogOpen}>
|
||||
<DialogContent>
|
||||
<DialogContent
|
||||
className={cn(
|
||||
"max-h-[90svh] overflow-y-auto sm:max-w-3xl",
|
||||
previewPosition === "right" && "sm:w-fit sm:max-w-[calc(100%-2rem)]",
|
||||
)}
|
||||
>
|
||||
<DialogHeader>
|
||||
<DialogTitle>ตั้งค่าการส่งออกภาพ</DialogTitle>
|
||||
<DialogDescription>เลือกตัวเลือกที่ต้องการก่อนส่งออกภาพ</DialogDescription>
|
||||
</DialogHeader>
|
||||
{exportSettings}
|
||||
{previewPosition === "right" ? (
|
||||
<div className="grid min-h-0 gap-4 lg:grid-cols-[16rem_fit-content(42rem)]">
|
||||
<div className="flex min-w-0 flex-col gap-4">
|
||||
{additionalOptions}
|
||||
{exportOptions}
|
||||
</div>
|
||||
<div className="min-w-0">{exportPreview}</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{additionalOptions}
|
||||
{optionsBeforePreview ? exportOptions : exportPreview}
|
||||
{optionsBeforePreview ? exportPreview : exportOptions}
|
||||
</>
|
||||
)}
|
||||
<DialogFooter className="sticky bottom-0">
|
||||
{exportAction}
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
) : null}
|
||||
{previewEnabled && typeof document !== "undefined"
|
||||
? createPortal(
|
||||
<div aria-hidden="true" className="pointer-events-none fixed top-0 left-[-10000px]">
|
||||
<div
|
||||
ref={previewRef}
|
||||
data-export-language={language}
|
||||
className={cn("bg-background p-10 text-foreground", captureClassName)}
|
||||
style={{ width, height }}
|
||||
>
|
||||
<style>{'[data-export-language="th"] [data-export-lang="en"],[data-export-language="en"] [data-export-lang="th"]{display:none!important}'}</style>
|
||||
{children}
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
)
|
||||
: null}
|
||||
{capturing
|
||||
? createPortal(
|
||||
<div
|
||||
|
||||
@@ -1193,6 +1193,7 @@ function ConstellationSection({ guide }: { guide: PublicGuide }) {
|
||||
captureClassName="p-0"
|
||||
showLanguageSelector={false}
|
||||
promptForLanguage
|
||||
previewPosition="right"
|
||||
>
|
||||
<ConstellationImage
|
||||
guide={guide}
|
||||
|
||||
@@ -497,6 +497,7 @@ export function TeamSearch({ groups }: { groups: PublicGuide["teamGroups"] }) {
|
||||
mobileIconOnly
|
||||
showLanguageSelector={false}
|
||||
promptForLanguage
|
||||
optionsBeforePreview
|
||||
>
|
||||
<TeamExportImage team={team} />
|
||||
</GuideImageExport>
|
||||
|
||||
@@ -137,6 +137,26 @@ export function WeaponImageExport({
|
||||
metricOptions[0]?.[0] ?? "overallPercent",
|
||||
);
|
||||
const metricLabel = metric === "overallPercent" ? "Overall Damage" : "Personal Damage";
|
||||
const metricOptionsControl = (
|
||||
<div className="flex flex-col gap-2">
|
||||
<p className="text-sm font-medium">ประเภทดาเมจ</p>
|
||||
<ToggleGroup
|
||||
aria-label="ประเภทดาเมจในภาพส่งออก"
|
||||
variant="outline"
|
||||
value={[metric]}
|
||||
onValueChange={(values) => {
|
||||
const next = values[0];
|
||||
if (next === "overallPercent" || next === "personalPercent") setMetric(next);
|
||||
}}
|
||||
>
|
||||
{metricOptions.map(([value]) => (
|
||||
<ToggleGroupItem key={value} value={value}>
|
||||
{value === "overallPercent" ? "ดาเมจทั้งทีม" : "ดาเมจส่วนตัว"}
|
||||
</ToggleGroupItem>
|
||||
))}
|
||||
</ToggleGroup>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<Dialog>
|
||||
@@ -144,36 +164,20 @@ export function WeaponImageExport({
|
||||
<DownloadIcon data-icon="inline-start" />
|
||||
ส่งออกภาพ
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogContent className="max-h-[90svh] overflow-y-auto sm:w-fit sm:max-w-[calc(100%-2rem)]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>เลือกประเภทดาเมจ</DialogTitle>
|
||||
<DialogDescription>
|
||||
เลือกค่าที่ต้องการใช้สร้างภาพเปรียบเทียบอาวุธ
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="flex flex-col gap-2">
|
||||
<p className="text-sm font-medium">ประเภทดาเมจ</p>
|
||||
<ToggleGroup
|
||||
aria-label="ประเภทดาเมจในภาพส่งออก"
|
||||
variant="outline"
|
||||
value={[metric]}
|
||||
onValueChange={(values) => {
|
||||
const next = values[0];
|
||||
if (next === "overallPercent" || next === "personalPercent") setMetric(next);
|
||||
}}
|
||||
>
|
||||
{metricOptions.map(([value]) => (
|
||||
<ToggleGroupItem key={value} value={value}>
|
||||
{value === "overallPercent" ? "ดาเมจทั้งทีม" : "ดาเมจส่วนตัว"}
|
||||
</ToggleGroupItem>
|
||||
))}
|
||||
</ToggleGroup>
|
||||
</div>
|
||||
<GuideImageExport
|
||||
fileName={`${baseName}-${metricLabel}.png`}
|
||||
width={1200}
|
||||
captureClassName="p-0"
|
||||
modeSelection="inline"
|
||||
additionalOptions={metricOptionsControl}
|
||||
previewPosition="right"
|
||||
>
|
||||
<WeaponComparisonImage
|
||||
guide={guide}
|
||||
|
||||
Reference in New Issue
Block a user