feat : improve export image dialog
This commit is contained in:
@@ -11,7 +11,6 @@ import { saveGuideOverview } from "@/app/admin/actions";
|
||||
import { AutosaveStatus } from "@/components/admin/autosave-status";
|
||||
import { MediaUploader } from "@/components/admin/media-uploader";
|
||||
import { MaterialPoster, type PosterBlendMode } from "@/components/public/material-poster";
|
||||
import { GuideImageExport } from "@/components/public/guide-image-export";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
@@ -468,27 +467,6 @@ export function GuideOverviewForm({
|
||||
ลบภาพตัวละคร
|
||||
</Button>
|
||||
) : null}
|
||||
<GuideImageExport
|
||||
width={1600}
|
||||
height={2000}
|
||||
format="png"
|
||||
disabled={!posterArt.url}
|
||||
fileName={`${guide.name}-materials.png`}
|
||||
allowWithoutNotes={false}
|
||||
captureClassName="p-0"
|
||||
>
|
||||
<MaterialPoster
|
||||
character={guide.character}
|
||||
characterArtUrl={posterArt.url}
|
||||
artworkOffsetX={posterArtOffset.x}
|
||||
artworkOffsetY={posterArtOffset.y}
|
||||
artworkScale={posterArtScale}
|
||||
artworkRotation={posterArtRotation}
|
||||
topColors={posterTopColors}
|
||||
bottomColors={posterBottomColors}
|
||||
bleeding={bleeding}
|
||||
/>
|
||||
</GuideImageExport>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2 rounded-lg border p-3">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
|
||||
@@ -1,53 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
ExportLanguageOption,
|
||||
type ExportLanguage,
|
||||
} from "@/components/public/export-language";
|
||||
|
||||
export function ExportLanguageDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
onSelect,
|
||||
}: {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onSelect: (language: ExportLanguage) => void;
|
||||
}) {
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>เลือกภาษาในภาพ</DialogTitle>
|
||||
<DialogDescription>What language do you want?</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
className="flex-1"
|
||||
onClick={() => onSelect("th")}
|
||||
>
|
||||
<ExportLanguageOption language="th" />
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
className="flex-1"
|
||||
onClick={() => onSelect("en")}
|
||||
>
|
||||
<ExportLanguageOption language="en" />
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -13,13 +13,13 @@ import {
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { ExportLanguageDialog } from "@/components/public/export-language-dialog";
|
||||
import {
|
||||
ExportLanguageToggle,
|
||||
useExportLanguage,
|
||||
type ExportLanguage,
|
||||
} from "@/components/public/export-language";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const EXPORT_WIDTH = 1200;
|
||||
@@ -89,55 +89,70 @@ export function GuideImageExport({
|
||||
const capturing = captureMode !== null;
|
||||
const [storedLanguage, setStoredLanguage] = useExportLanguage();
|
||||
const language = controlledLanguage ?? storedLanguage;
|
||||
const [pendingCaptureMode, setPendingCaptureMode] = useState<
|
||||
"with-notes" | "without-notes" | null
|
||||
>(null);
|
||||
const [selectedMode, setSelectedMode] = useState<"with-notes" | "without-notes">(
|
||||
"with-notes",
|
||||
);
|
||||
const [modeDialogOpen, setModeDialogOpen] = useState(false);
|
||||
|
||||
function requestCapture(mode: "with-notes" | "without-notes") {
|
||||
function requestCapture() {
|
||||
setModeDialogOpen(false);
|
||||
if (promptForLanguage) setPendingCaptureMode(mode);
|
||||
else setCaptureMode(mode);
|
||||
setCaptureMode(selectedMode);
|
||||
}
|
||||
|
||||
function selectLanguage(nextLanguage: ExportLanguage) {
|
||||
if (!pendingCaptureMode) return;
|
||||
setStoredLanguage(nextLanguage);
|
||||
setCaptureMode(pendingCaptureMode);
|
||||
setPendingCaptureMode(null);
|
||||
}
|
||||
|
||||
function exportButton(
|
||||
mode: "with-notes" | "without-notes" | null,
|
||||
label: string,
|
||||
) {
|
||||
const loading = capturing && (mode === null || captureMode === mode);
|
||||
function exportButton(label: string) {
|
||||
return (
|
||||
<Button
|
||||
key={mode ?? "export"}
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className={mobileIconOnly ? "max-sm:size-7 max-sm:px-0 max-sm:has-data-[icon=inline-start]:pl-0" : undefined}
|
||||
disabled={capturing || disabled}
|
||||
onClick={() => {
|
||||
if (mode) requestCapture(mode);
|
||||
else if (allowWithoutNotes) setModeDialogOpen(true);
|
||||
else requestCapture("with-notes");
|
||||
}}
|
||||
onClick={() => setModeDialogOpen(true)}
|
||||
>
|
||||
{loading ? (
|
||||
{capturing ? (
|
||||
<Spinner data-icon="inline-start" />
|
||||
) : (
|
||||
<DownloadIcon data-icon="inline-start" />
|
||||
)}
|
||||
<span className={mobileIconOnly ? "sr-only sm:not-sr-only" : undefined}>
|
||||
{loading ? "กำลังส่งออก…" : label}
|
||||
{capturing ? "กำลังส่งออก…" : label}
|
||||
</span>
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
const exportSettings = (
|
||||
<>
|
||||
{showLanguageSelector || promptForLanguage ? (
|
||||
<div className="flex flex-col gap-2">
|
||||
<p className="text-sm font-medium">ภาษาในภาพ</p>
|
||||
<ExportLanguageToggle language={language} onChange={setStoredLanguage} />
|
||||
</div>
|
||||
) : null}
|
||||
{allowWithoutNotes ? (
|
||||
<div className="flex flex-col gap-2">
|
||||
<p className="text-sm font-medium">โน้ต</p>
|
||||
<ToggleGroup
|
||||
aria-label="การแสดงโน้ตในภาพส่งออก"
|
||||
variant="outline"
|
||||
value={[selectedMode]}
|
||||
onValueChange={(values) => {
|
||||
const next = values[0];
|
||||
if (next === "with-notes" || next === "without-notes") setSelectedMode(next);
|
||||
}}
|
||||
>
|
||||
<ToggleGroupItem value="with-notes">รวมโน้ต</ToggleGroupItem>
|
||||
<ToggleGroupItem value="without-notes">ไม่รวมโน้ต</ToggleGroupItem>
|
||||
</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>
|
||||
</>
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!capturing) return;
|
||||
|
||||
@@ -204,52 +219,22 @@ export function GuideImageExport({
|
||||
|
||||
return (
|
||||
<>
|
||||
{showLanguageSelector ? (
|
||||
<ExportLanguageToggle language={language} onChange={setStoredLanguage} />
|
||||
) : null}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{allowWithoutNotes && modeSelection === "inline"
|
||||
? [
|
||||
exportButton("with-notes", buttonLabel),
|
||||
exportButton("without-notes", "ส่งออกภาพโดยไม่รวมโน้ต"),
|
||||
]
|
||||
: exportButton(null, buttonLabel)}
|
||||
</div>
|
||||
{allowWithoutNotes && modeSelection === "dialog" ? (
|
||||
{modeSelection === "inline" ? (
|
||||
exportSettings
|
||||
) : (
|
||||
<div className="flex flex-wrap gap-2">{exportButton(buttonLabel)}</div>
|
||||
)}
|
||||
{modeSelection === "dialog" ? (
|
||||
<Dialog open={modeDialogOpen} onOpenChange={setModeDialogOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>เลือกรูปแบบการส่งออก</DialogTitle>
|
||||
<DialogDescription>เลือกว่าจะรวมโน้ตไว้ในภาพหรือไม่</DialogDescription>
|
||||
<DialogTitle>ตั้งค่าการส่งออกภาพ</DialogTitle>
|
||||
<DialogDescription>เลือกตัวเลือกที่ต้องการก่อนส่งออกภาพ</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="flex flex-col gap-2 sm:flex-row">
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
className="flex-1"
|
||||
onClick={() => requestCapture("with-notes")}
|
||||
>
|
||||
ส่งออกพร้อมโน้ต
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
className="flex-1"
|
||||
onClick={() => requestCapture("without-notes")}
|
||||
>
|
||||
ส่งออกโดยไม่รวมโน้ต
|
||||
</Button>
|
||||
</div>
|
||||
{exportSettings}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
) : null}
|
||||
<ExportLanguageDialog
|
||||
open={pendingCaptureMode !== null}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setPendingCaptureMode(null);
|
||||
}}
|
||||
onSelect={selectLanguage}
|
||||
/>
|
||||
{capturing
|
||||
? createPortal(
|
||||
<div
|
||||
|
||||
@@ -2,13 +2,11 @@
|
||||
|
||||
import Image from "next/image";
|
||||
import { DownloadIcon } from "lucide-react";
|
||||
import type { ReactNode } from "react";
|
||||
import { useState, type ReactNode } from "react";
|
||||
|
||||
import { GuideExportBranding } from "@/components/public/guide-export-branding";
|
||||
import {
|
||||
ExportLanguageToggle,
|
||||
ExportText,
|
||||
useExportLanguage,
|
||||
} from "@/components/public/export-language";
|
||||
import { GuideImageExport } from "@/components/public/guide-image-export";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -20,6 +18,7 @@ import {
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
|
||||
import type { PublicGuide } from "@/lib/guides/queries";
|
||||
|
||||
type WeaponGroup = PublicGuide["weaponGroups"][number];
|
||||
@@ -122,7 +121,6 @@ export function WeaponImageExport({
|
||||
group: WeaponGroup;
|
||||
conditionIndex: number;
|
||||
}) {
|
||||
const [language, setLanguage] = useExportLanguage();
|
||||
const conditionLabel = group.name;
|
||||
const baseName = `${guide.name}-Weapon-${conditionLabel || `Condition ${conditionIndex + 1}`}`;
|
||||
const metricOptions = ([
|
||||
@@ -131,6 +129,10 @@ export function WeaponImageExport({
|
||||
] as const).filter(([metric]) =>
|
||||
group.rows.some((row) => row[metric] !== null),
|
||||
);
|
||||
const [metric, setMetric] = useState<DamageMetric>(
|
||||
metricOptions[0]?.[0] ?? "overallPercent",
|
||||
);
|
||||
const metricLabel = metric === "overallPercent" ? "Overall Damage" : "Personal Damage";
|
||||
|
||||
return (
|
||||
<Dialog>
|
||||
@@ -145,17 +147,29 @@ export function WeaponImageExport({
|
||||
เลือกค่าที่ต้องการใช้สร้างภาพเปรียบเทียบอาวุธ
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<ExportLanguageToggle language={language} onChange={setLanguage} />
|
||||
<div className="grid gap-2 sm:grid-cols-2">
|
||||
{metricOptions.map(([metric, label]) => (
|
||||
<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
|
||||
key={metric}
|
||||
fileName={`${baseName}-${label}.png`}
|
||||
fileName={`${baseName}-${metricLabel}.png`}
|
||||
width={1200}
|
||||
captureClassName="p-0"
|
||||
language={language}
|
||||
showLanguageSelector={false}
|
||||
buttonLabel={metric === "overallPercent" ? "ดาเมจทั้งทีม" : "ดาเมจส่วนตัว"}
|
||||
modeSelection="inline"
|
||||
>
|
||||
<WeaponComparisonImage
|
||||
guide={guide}
|
||||
@@ -164,8 +178,6 @@ export function WeaponImageExport({
|
||||
conditionLabel={conditionLabel}
|
||||
/>
|
||||
</GuideImageExport>
|
||||
))}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
|
||||
@@ -31,30 +31,29 @@ describe("image export controls", () => {
|
||||
expect(html).not.toContain("เงื่อนไข 1");
|
||||
});
|
||||
|
||||
it("shows one export action when both note modes are available", () => {
|
||||
it("shows one export trigger and keeps settings in its dialog", () => {
|
||||
const html = renderToStaticMarkup(<GuideImageExport fileName="guide.png">No notes</GuideImageExport>);
|
||||
expect(html).toContain("ส่งออกภาพ");
|
||||
expect(html).toContain("ไทย");
|
||||
expect(html).toContain("English");
|
||||
expect(html).toContain('data-language-flag="th"');
|
||||
expect(html).toContain('data-language-flag="en"');
|
||||
expect(html.match(/<button\b/gu)).toHaveLength(3);
|
||||
expect(html.match(/<button\b/gu)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("shows only one export action when no-note export is disabled", () => {
|
||||
const html = renderToStaticMarkup(<GuideImageExport fileName="guide.png" allowWithoutNotes={false}>Content</GuideImageExport>);
|
||||
expect(html).toContain("ส่งออกภาพ");
|
||||
expect(html.match(/<button\b/gu)).toHaveLength(3);
|
||||
expect(html.match(/<button\b/gu)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("can show note modes inline inside an existing dialog", () => {
|
||||
it("shows all settings inline with one export action inside an existing dialog", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<GuideImageExport fileName="guide.png" modeSelection="inline">
|
||||
Content
|
||||
</GuideImageExport>,
|
||||
);
|
||||
expect(html).toContain("ส่งออกภาพโดยไม่รวมโน้ต");
|
||||
expect(html.match(/<button\b/gu)).toHaveLength(4);
|
||||
expect(html).toContain("รวมโน้ต");
|
||||
expect(html).toContain("ไม่รวมโน้ต");
|
||||
expect(html).toContain("ไทย");
|
||||
expect(html).toContain("English");
|
||||
expect(html.match(/>ส่งออกภาพ<\/button>/gu)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("hides the persistent language chooser when language is prompted on export", () => {
|
||||
|
||||
Reference in New Issue
Block a user