image-tool / frontend /src /components /ObjectRemover.tsx
JunyoungK7's picture
Claude Sonnet 4.6
Make UI responsive for mobile: scale canvas, scrollable tabs, flexible toolbar
4a4b2e4
Raw History Blame Contribute Delete
29.3 kB
/**
* ObjectRemover.tsx โ€” self-contained component for the "๊ฐ์ฒด ์ œ๊ฑฐ" tab.
*
* Fully independent of EditorProvider โ€” all state is local.
* Phases: "upload" โ†’ "uploading" โ†’ "painting" โ†’ "generating" โ†’ "completed" | "failed"
*
* Canvas logic is shared with MaskEditor via useMaskCanvas.
* Polling is shared with MaskEditor via useJobPolling (custom fetcher).
* Storage lives under storage/object-removal/{job_id}/steps/{n}/.
*/
import { useCallback, useEffect, useRef, useState } from "react";
import {
Scissors,
Upload,
Paintbrush,
Eraser,
Undo2,
Download,
PencilLine,
RotateCcw,
History,
RefreshCw,
} from "lucide-react";
import { useMaskCanvas } from "../hooks/useMaskCanvas";
import { useJobPolling } from "../hooks/useJobPolling";
import {
uploadForObjectRemoval,
generateObjectRemoval,
getObjectRemovalResult,
} from "../api/objectRemovalClient";
import { goToStep, API_BASE } from "../api/client";
import type { StepSummary } from "../api/client";
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
type Phase = "upload" | "uploading" | "painting" | "generating" | "completed" | "failed";
// ---------------------------------------------------------------------------
// StepHistory โ€” inline copy (same as MaskEditor; not imported to keep components independent)
// ---------------------------------------------------------------------------
function StepHistory({
steps,
activeStepNumber,
currentStep,
onGotoStep,
}: {
steps: StepSummary[];
activeStepNumber: number;
currentStep: number;
onGotoStep: (n: number) => void;
}) {
const completed = steps
.filter(s => s.status === "completed")
.sort((a, b) => a.step_number - b.step_number);
if (completed.length <= 1) return null;
const maxStep = completed[completed.length - 1].step_number;
return (
<div style={histStyles.wrap}>
<span style={histStyles.label}>
<History size={13} style={{ verticalAlign: "middle", marginRight: 4 }} />
ํžˆ์Šคํ† ๋ฆฌ
</span>
<div style={histStyles.strip}>
{completed.map(s => {
const isActive = s.step_number === activeStepNumber;
return (
<button
key={s.step_number}
onClick={() => onGotoStep(s.step_number)}
style={{
...histStyles.stepBtn,
...(isActive ? histStyles.stepBtnActive : {}),
}}
title={s.step_number === 0 ? "์—…๋กœ๋“œ ์›๋ณธ" : `Step ${s.step_number} ๊ฒฐ๊ณผ`}
>
{s.step_number === 0 ? "์›๋ณธ" : `Step ${s.step_number}`}
</button>
);
})}
</div>
{currentStep < maxStep && (
<p style={histStyles.discardNote}>
์—ฌ๊ธฐ์„œ ์ถ”๊ฐ€ ์ž‘์—… ์‹œ Step {currentStep + 1}โ€“{maxStep}์ด ์‚ญ์ œ๋ฉ๋‹ˆ๋‹ค.
</p>
)}
</div>
);
}
const histStyles: Record<string, React.CSSProperties> = {
wrap: {
marginTop: 24,
padding: "14px 16px",
background: "#ffffff",
border: "1px solid #e8eaed",
borderRadius: 10,
boxShadow: "0 1px 6px rgba(0,0,0,0.10)",
},
label: {
fontSize: 11,
fontWeight: 700,
color: "#94a3b8",
textTransform: "uppercase" as const,
letterSpacing: "0.07em",
display: "block",
marginBottom: 10,
},
strip: {
display: "flex",
gap: 6,
flexWrap: "wrap" as const,
},
stepBtn: {
padding: "4px 12px",
background: "#fff",
border: "1px solid #e2e8f0",
borderRadius: 6,
cursor: "pointer",
fontSize: 13,
fontWeight: 500,
color: "#475569",
},
stepBtnActive: {
background: "#3b82f6",
border: "1px solid #2563eb",
color: "#fff",
fontWeight: 600,
boxShadow: "0 1px 3px rgba(59,130,246,0.3)",
},
discardNote: {
margin: "12px 0 0",
fontSize: 12,
color: "#92400e",
background: "#fffbeb",
border: "1px solid #fde68a",
borderRadius: 6,
padding: "4px 10px",
display: "inline-block",
},
};
// ---------------------------------------------------------------------------
// Main component
// ---------------------------------------------------------------------------
export function ObjectRemover() {
const [imageFile, setImageFile] = useState<File | null>(null);
const [imageNaturalWidth, setImageNaturalWidth] = useState<number | null>(null);
const [imageNaturalHeight, setImageNaturalHeight] = useState<number | null>(null);
const [jobId, setJobId] = useState<string | null>(null);
const [phase, setPhase] = useState<Phase>("upload");
const [uploadError, setUploadError] = useState<string | null>(null);
const [generateError, setGenerateError] = useState<string | null>(null);
const [viewedStepNumber, setViewedStepNumber] = useState<number | null>(null);
const [isDragOver, setIsDragOver] = useState(false);
// CSS scale factor applied to the canvas wrapper so it fits narrow viewports.
const [canvasScale, setCanvasScale] = useState(1);
const fileInputRef = useRef<HTMLInputElement>(null);
// โ”€โ”€ Canvas hook โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
const {
canvasElRef,
displayWidth,
displayHeight,
toOriginalScale,
brushSize,
setBrushSize,
strokeCount,
buildMaskJson,
handleClear,
handleUndo,
setDrawingMode,
setBaseUrl,
} = useMaskCanvas({ imageFile, imageNaturalWidth, imageNaturalHeight });
// โ”€โ”€ Polling hook with object-removal fetcher โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
const {
pollPhase,
errorMessage: pollError,
steps,
currentStep,
startPolling,
refreshResult,
reset: resetPoll,
resetPollPhase,
} = useJobPolling(getObjectRemovalResult);
// โ”€โ”€ Phase sync effects โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
useEffect(() => {
if (pollPhase === "completed" && phase !== "completed") {
setPhase("completed");
setViewedStepNumber(null);
}
if (pollPhase === "failed") setPhase("failed");
}, [pollPhase, phase]);
useEffect(() => {
setDrawingMode(phase === "painting");
}, [phase, setDrawingMode]);
// โ”€โ”€ Canvas CSS scale โ€” keeps canvas within page width on mobile โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
// Fabric.js uses getBoundingClientRect() internally, so coordinate mapping
// stays correct when a CSS transform scale is applied to the canvas wrapper.
useEffect(() => {
if (!displayWidth) return;
const update = () => {
const avail = Math.min(window.innerWidth - 48, 880);
setCanvasScale(Math.min(1, avail / displayWidth));
};
update();
window.addEventListener("resize", update);
return () => window.removeEventListener("resize", update);
}, [displayWidth]);
// โ”€โ”€ Upload handling โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
const handleFile = useCallback(async (file: File) => {
if (!file.type.startsWith("image/")) {
setUploadError(`"${file.name}"์€(๋Š”) ์ด๋ฏธ์ง€ ํŒŒ์ผ์ด ์•„๋‹™๋‹ˆ๋‹ค.`);
return;
}
setUploadError(null);
setPhase("uploading");
try {
// Backend returns EXIF-corrected width/height โ€” use directly
const { job_id, width, height } = await uploadForObjectRemoval(file);
setImageFile(file);
setImageNaturalWidth(width);
setImageNaturalHeight(height);
setJobId(job_id);
setPhase("painting");
} catch (e) {
setUploadError((e as Error).message);
setPhase("upload");
}
}, []);
const onInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (file) handleFile(file);
};
const onDragOver = (e: React.DragEvent) => { e.preventDefault(); setIsDragOver(true); };
const onDragLeave = () => setIsDragOver(false);
const onDrop = (e: React.DragEvent) => {
e.preventDefault();
setIsDragOver(false);
const file = e.dataTransfer.files?.[0];
if (file) handleFile(file);
};
// โ”€โ”€ Generate โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
const handleGenerate = async () => {
if (!jobId) return;
const maskJson = buildMaskJson();
if (!maskJson) return;
setPhase("generating");
setGenerateError(null);
try {
await generateObjectRemoval(jobId, maskJson);
startPolling(jobId);
} catch (err) {
setGenerateError((err as Error).message);
setPhase("failed");
}
};
// โ”€โ”€ Do More Work โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
const handleDoMoreWork = () => {
const latestStepData = steps.find(s => s.step_number === currentStep);
if (!latestStepData?.result_image_url) return;
resetPollPhase();
setBaseUrl(`${API_BASE}${latestStepData.result_image_url}`);
setViewedStepNumber(null);
setGenerateError(null);
setPhase("painting");
};
// โ”€โ”€ Step navigation โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
const handleGotoStep = async (stepNumber: number) => {
if (!jobId) return;
try {
await goToStep(jobId, stepNumber);
setViewedStepNumber(stepNumber);
refreshResult(jobId);
} catch { /* non-critical */ }
};
// โ”€โ”€ Try Again โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
const handleTryAgain = () => {
setGenerateError(null);
setPhase("painting");
};
// โ”€โ”€ Start Over โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
const handleStartOver = () => {
resetPoll();
setImageFile(null);
setImageNaturalWidth(null);
setImageNaturalHeight(null);
setJobId(null);
setViewedStepNumber(null);
setUploadError(null);
setGenerateError(null);
setPhase("upload");
};
// โ”€โ”€ Download โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
const handleDownload = async (imageUrl: string | null | undefined) => {
if (!imageUrl) return;
try {
const res = await fetch(`${API_BASE}${imageUrl}`);
const blob = await res.blob();
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = "result.jpg";
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
} catch { /* user can right-click as fallback */ }
};
// โ”€โ”€ Upload phase UI โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
if (phase === "upload" || phase === "uploading") {
const isLoading = phase === "uploading";
return (
<div style={styles.page}>
<h1 style={styles.heading}>
<Scissors size={20} style={{ verticalAlign: "middle", marginRight: 8 }} />
์˜ค๋ธŒ์ ํŠธ ์ œ๊ฑฐ
</h1>
<p style={styles.sub}>์ง€์šฐ๊ณ  ์‹ถ์€ ๋ฌผ์ฒด๊ฐ€ ์žˆ๋Š” ์ด๋ฏธ์ง€๋ฅผ ์—…๋กœ๋“œํ•˜์„ธ์š”.</p>
<div
style={{
...styles.dropZone,
...(isDragOver ? styles.dropZoneActive : {}),
...(isLoading ? styles.dropZoneDisabled : {}),
}}
onDragOver={isLoading ? undefined : onDragOver}
onDragLeave={isLoading ? undefined : onDragLeave}
onDrop={isLoading ? undefined : onDrop}
onClick={() => !isLoading && fileInputRef.current?.click()}
role="button"
tabIndex={0}
onKeyDown={(e) => e.key === "Enter" && fileInputRef.current?.click()}
aria-label="์ด๋ฏธ์ง€ ์—…๋กœ๋“œ"
>
<input
ref={fileInputRef}
type="file"
accept="image/*"
style={{ display: "none" }}
onChange={onInputChange}
disabled={isLoading}
/>
{isLoading ? (
<p style={styles.statusText}>์—…๋กœ๋“œ ์ค‘โ€ฆ</p>
) : (
<>
<Upload size={32} color="#aaa" style={{ marginBottom: 10 }} />
<p style={styles.dropLabel}>
{isDragOver ? "์—ฌ๊ธฐ์— ๋†“์œผ์„ธ์š”!" : "ํด๋ฆญํ•˜๊ฑฐ๋‚˜ ์ด๋ฏธ์ง€๋ฅผ ์—ฌ๊ธฐ์— ๋“œ๋ž˜๊ทธํ•˜์„ธ์š”"}
</p>
<p style={styles.dropHint}>JPEG, PNG, WEBP, HEICโ€ฆ</p>
</>
)}
</div>
{uploadError && (
<div style={styles.errorBox}>
<strong>์˜ค๋ฅ˜: </strong>{uploadError}
<button
style={styles.retryBtn}
onClick={() => { setUploadError(null); }}
>
<RefreshCw size={13} style={{ marginRight: 4, verticalAlign: "middle" }} />
๋‹ค์‹œ ์‹œ๋„
</button>
</div>
)}
</div>
);
}
// โ”€โ”€ Canvas phase UI โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
const isScaledDown = toOriginalScale > 1.0;
const isPainting = phase === "painting";
const isGenerating = phase === "generating";
const isCompleted = phase === "completed";
const isFailed = phase === "failed";
const displayError = generateError ?? pollError;
const effectiveViewedStep = viewedStepNumber ?? currentStep;
const displayedStep = steps.find(s => s.step_number === effectiveViewedStep) ?? null;
const latestStepData = steps.find(s => s.step_number === currentStep) ?? null;
const canDoMoreWork = isCompleted && !!latestStepData?.result_image_url;
return (
<div style={styles.page}>
{/* Header */}
<div style={styles.header}>
<h2 style={styles.heading2}>์ง€์šฐ๊ณ  ์‹ถ์€ ๋ถ€๋ถ„์„ ์น ํ•˜์„ธ์š”</h2>
<p style={styles.meta}>
Job: <code>{jobId}</code>
&nbsp;ยท&nbsp;์›๋ณธ: {imageNaturalWidth}ร—{imageNaturalHeight}px
{isScaledDown
? ` ยท ์บ”๋ฒ„์Šค: ${displayWidth}ร—${displayHeight}px ยท ${(1 / toOriginalScale).toFixed(3)}ร—`
: " ยท ์›๋ณธ ํฌ๊ธฐ ํ‘œ์‹œ (1.0ร—)"}
</p>
</div>
{/* Toolbar โ€” visible only while painting */}
{isPainting && (
<div style={styles.toolbar}>
<label style={styles.label}>
<Paintbrush size={15} style={styles.iconGap} />
๋ธŒ๋Ÿฌ์‹œ:&nbsp;<strong>{brushSize}px</strong>
<input
type="range"
min={5}
max={60}
value={brushSize}
onChange={e => setBrushSize(Number(e.target.value))}
style={styles.slider}
/>
</label>
<button
style={styles.btnSecondary}
onClick={handleClear}
disabled={strokeCount === 0}
>
<Eraser size={15} style={styles.iconGap} />
์ง€์šฐ๊ธฐ ({strokeCount})
</button>
<button
style={styles.btnSecondary}
onClick={handleUndo}
disabled={strokeCount === 0}
>
<Undo2 size={15} style={styles.iconGap} />
์‹คํ–‰ ์ทจ์†Œ
</button>
<button
style={{
...styles.btnPrimary,
...(strokeCount === 0 ? styles.btnDisabled : {}),
}}
onClick={handleGenerate}
disabled={strokeCount === 0}
>
<Scissors size={15} style={styles.iconGap} />
์˜ค๋ธŒ์ ํŠธ ์ œ๊ฑฐ
</button>
</div>
)}
{/* Canvas + overlay โ€” scales down to fit narrow viewports */}
<div style={{ width: "100%", overflow: "hidden" }}>
<div style={{
position: "relative",
width: displayWidth * canvasScale,
height: displayHeight * canvasScale,
}}>
<div style={{
position: "absolute",
top: 0,
left: 0,
width: displayWidth,
height: displayHeight,
transform: canvasScale < 1 ? `scale(${canvasScale})` : undefined,
transformOrigin: "top left",
}}>
<div style={{ ...styles.canvasWrapper, width: displayWidth, height: displayHeight }}>
<canvas ref={canvasElRef} />
</div>
</div>
{isGenerating && (
<div style={styles.overlay}>
<div style={styles.overlayCard}>
<Spinner />
<p style={styles.overlayTitle}>์˜ค๋ธŒ์ ํŠธ ์ œ๊ฑฐ ์ค‘โ€ฆ</p>
<p style={styles.overlaySubtitle}>2์ดˆ๋งˆ๋‹ค ๊ฒฐ๊ณผ ํ™•์ธ ์ค‘</p>
</div>
</div>
)}
</div>
</div>
{/* Painting hint */}
{isPainting && (
<p style={styles.hint}>
{strokeCount === 0
? <>์ง€์šฐ๊ณ  ์‹ถ์€ ๋ถ€๋ถ„์„ ๋นจ๊ฐ„์ƒ‰์œผ๋กœ ์น ํ•œ ๋’ค <strong>์˜ค๋ธŒ์ ํŠธ ์ œ๊ฑฐ</strong>๋ฅผ ํด๋ฆญํ•˜์„ธ์š”.</>
: <>{strokeCount}๊ฐœ์˜ ํš์„ ๊ทธ๋ ธ์Šต๋‹ˆ๋‹ค โ€” ์ˆ˜์ •ํ•˜๊ฑฐ๋‚˜ <strong>์˜ค๋ธŒ์ ํŠธ ์ œ๊ฑฐ</strong>๋ฅผ ํด๋ฆญํ•˜์„ธ์š”.</>}
</p>
)}
{/* Completed result section */}
{isCompleted && displayedStep && (
<div style={styles.resultSection}>
<StepHistory
steps={steps}
activeStepNumber={effectiveViewedStep}
currentStep={currentStep}
onGotoStep={handleGotoStep}
/>
<div style={styles.comparisonHeader}>
{effectiveViewedStep === 0 ? (
<p style={styles.bannerTitle}>Step 0 โ€” ์—…๋กœ๋“œ ์›๋ณธ</p>
) : (
<p style={styles.bannerTitle}>โœ“ Step {effectiveViewedStep} ์™„๋ฃŒ.</p>
)}
</div>
<div style={styles.comparison}>
{effectiveViewedStep > 0 && (
<div style={styles.compareCol}>
<p style={styles.compareLabel}>์ด์ „</p>
{displayedStep.original_image_url && (
<img
src={`${API_BASE}${displayedStep.original_image_url}`}
alt="Before"
style={styles.compareImg}
/>
)}
</div>
)}
<div style={styles.compareCol}>
<p style={styles.compareLabel}>
{effectiveViewedStep === 0 ? "์›๋ณธ" : "์ดํ›„"}
</p>
{displayedStep.result_image_url && (
<img
src={`${API_BASE}${displayedStep.result_image_url}`}
alt="After"
style={styles.compareImg}
/>
)}
</div>
</div>
<div style={styles.resultActions}>
{canDoMoreWork && (
<button style={styles.btnPrimary} onClick={handleDoMoreWork}>
<PencilLine size={15} style={styles.iconGap} />
์ถ”๊ฐ€ ์ž‘์—…ํ•˜๊ธฐ
</button>
)}
<button
style={styles.btnSecondary}
onClick={() => handleDownload(displayedStep.result_image_url)}
>
<Download size={15} style={styles.iconGap} />
๋‹ค์šด๋กœ๋“œ
</button>
<button style={styles.btnGhost} onClick={handleStartOver}>
<RotateCcw size={14} style={styles.iconGap} />
์ฒ˜์Œ๋ถ€ํ„ฐ
</button>
</div>
</div>
)}
{/* Failed section */}
{isFailed && (
<div style={styles.errorBanner}>
<p style={styles.bannerTitle}>โœ— ์ œ๊ฑฐ์— ์‹คํŒจํ–ˆ์Šต๋‹ˆ๋‹ค.</p>
<p style={styles.bannerSub}>
<strong>์˜ค๋ฅ˜:</strong> {displayError}
</p>
<p style={styles.bannerSub}>ํš์€ ๋ณด์กด๋˜์–ด ์žˆ์Šต๋‹ˆ๋‹ค โ€” ๋‹ค์‹œ ์‹œ๋„ํ•ด ๋ณด์„ธ์š”.</p>
{steps.length > 1 && (
<StepHistory
steps={steps}
activeStepNumber={effectiveViewedStep}
currentStep={currentStep}
onGotoStep={handleGotoStep}
/>
)}
<div style={{ display: "flex", gap: 10, marginTop: 14 }}>
<button style={styles.btnPrimary} onClick={handleTryAgain}>
โ† ๋‹ค์‹œ ๊ทธ๋ฆฌ๊ธฐ
</button>
<button style={styles.btnGhost} onClick={handleStartOver}>
<RotateCcw size={14} style={styles.iconGap} />
์ฒ˜์Œ๋ถ€ํ„ฐ
</button>
</div>
</div>
)}
</div>
);
}
// ---------------------------------------------------------------------------
// Spinner
// ---------------------------------------------------------------------------
function Spinner() {
return <div style={spinnerStyle} />;
}
const spinnerStyle: React.CSSProperties = {
width: 36,
height: 36,
border: "4px solid #e5e7eb",
borderTop: "4px solid #3b82f6",
borderRadius: "50%",
animation: "spin 0.9s linear infinite",
};
if (typeof document !== "undefined") {
const id = "__obj_remover_spin";
if (!document.getElementById(id)) {
const s = document.createElement("style");
s.id = id;
s.textContent = "@keyframes spin { to { transform: rotate(360deg); } }";
document.head.appendChild(s);
}
}
// ---------------------------------------------------------------------------
// Styles
// ---------------------------------------------------------------------------
const FONT = "-apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif";
const styles: Record<string, React.CSSProperties> = {
page: {
maxWidth: 880,
margin: "48px auto 48px",
padding: "0 24px",
fontFamily: FONT,
color: "#111827",
},
heading: {
fontSize: 22,
fontWeight: 600,
marginBottom: 4,
letterSpacing: "-0.01em",
color: "#111827",
},
heading2: {
fontSize: 22,
fontWeight: 600,
margin: "0 0 4px",
letterSpacing: "-0.01em",
color: "#111827",
},
sub: {
color: "#6b7280",
fontSize: 14,
margin: "0 0 24px",
},
header: {
marginBottom: 24,
paddingBottom: 16,
borderBottom: "1px solid #f0f2f4",
},
meta: {
fontSize: 12,
color: "#9ca3af",
margin: 0,
},
dropZone: {
border: "2px dashed #b0b7c3",
borderRadius: 12,
minHeight: 220,
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
cursor: "pointer",
transition: "border-color 0.15s, background 0.15s",
background: "#ffffff",
overflow: "hidden",
boxShadow: "0 1px 4px rgba(0,0,0,0.08)",
},
dropZoneActive: {
borderColor: "#3b82f6",
background: "#eff6ff",
},
dropZoneDisabled: {
cursor: "not-allowed",
opacity: 0.6,
},
dropLabel: {
fontSize: 14,
fontWeight: 500,
color: "#374151",
margin: 0,
},
dropHint: {
fontSize: 12,
color: "#9ca3af",
marginTop: 4,
},
statusText: {
color: "#3b82f6",
fontSize: 14,
},
errorBox: {
marginTop: 16,
padding: "12px 16px",
background: "#fef2f2",
border: "1px solid #fca5a5",
borderRadius: 8,
color: "#b91c1c",
fontSize: 13,
display: "flex",
alignItems: "center",
gap: 12,
boxShadow: "0 1px 3px rgba(239,68,68,0.08)",
},
retryBtn: {
marginLeft: "auto",
padding: "4px 12px",
background: "#fff",
border: "1px solid #fca5a5",
borderRadius: 6,
cursor: "pointer",
fontSize: 13,
color: "#b91c1c",
},
toolbar: {
display: "flex",
alignItems: "center",
gap: 8,
marginBottom: 16,
padding: "10px 12px",
background: "#ffffff",
borderRadius: 8,
border: "1px solid #e8eaed",
flexWrap: "wrap",
},
label: {
display: "flex",
alignItems: "center",
gap: 6,
fontSize: 14,
color: "#374151",
flex: "1 1 auto",
minWidth: 0,
},
iconGap: {
marginRight: 4,
verticalAlign: "middle",
flexShrink: 0,
} as React.CSSProperties,
slider: {
flex: "1 1 60px",
minWidth: 60,
maxWidth: 160,
marginLeft: 6,
accentColor: "#3b82f6",
},
btnPrimary: {
display: "inline-flex",
alignItems: "center",
padding: "8px 18px",
background: "#3b82f6",
border: "none",
borderRadius: 7,
cursor: "pointer",
fontSize: 14,
fontWeight: 600,
color: "#fff",
letterSpacing: "-0.01em",
boxShadow: "0 1px 3px rgba(59,130,246,0.25)",
},
btnSecondary: {
display: "inline-flex",
alignItems: "center",
padding: "7px 14px",
background: "#fff",
border: "1px solid #d1d5db",
borderRadius: 7,
cursor: "pointer",
fontSize: 14,
fontWeight: 500,
color: "#374151",
boxShadow: "0 1px 2px rgba(0,0,0,0.05)",
},
btnGhost: {
display: "inline-flex",
alignItems: "center",
padding: "7px 12px",
background: "transparent",
border: "1px solid #e5e7eb",
borderRadius: 7,
cursor: "pointer",
fontSize: 13,
color: "#6b7280",
},
btnDisabled: {
background: "#d1d5db",
cursor: "not-allowed",
boxShadow: "none",
},
canvasWrapper: {
border: "1px solid #e5e7eb",
borderRadius: 10,
overflow: "hidden",
lineHeight: 0,
boxShadow: "0 2px 16px rgba(0,0,0,0.12)",
},
overlay: {
position: "absolute",
inset: 0,
background: "rgba(255,255,255,0.80)",
display: "flex",
alignItems: "center",
justifyContent: "center",
borderRadius: 10,
backdropFilter: "blur(3px)",
},
overlayCard: {
display: "flex",
flexDirection: "column",
alignItems: "center",
gap: 12,
background: "#fff",
padding: "28px 40px",
borderRadius: 12,
boxShadow: "0 8px 32px rgba(0,0,0,0.12)",
},
overlayTitle: {
margin: 0,
fontWeight: 600,
fontSize: 14,
color: "#111827",
},
overlaySubtitle: {
margin: 0,
fontSize: 13,
color: "#6b7280",
},
hint: {
marginTop: 12,
fontSize: 13,
color: "#6b7280",
lineHeight: 1.5,
},
resultSection: {
marginTop: 24,
},
comparisonHeader: {
marginTop: 20,
},
comparison: {
display: "flex",
gap: 20,
marginTop: 16,
flexWrap: "wrap",
},
compareCol: {
flex: 1,
minWidth: 200,
textAlign: "center" as const,
},
compareLabel: {
margin: "0 0 8px",
fontWeight: 600,
fontSize: 13,
color: "#6b7280",
textTransform: "uppercase" as const,
letterSpacing: "0.05em",
},
compareImg: {
maxWidth: "100%",
borderRadius: 8,
border: "1px solid #e5e7eb",
display: "block",
margin: "0 auto",
boxShadow: "0 1px 4px rgba(0,0,0,0.06)",
},
resultActions: {
display: "flex",
gap: 8,
marginTop: 20,
flexWrap: "wrap",
},
bannerTitle: {
margin: "0 0 6px",
fontWeight: 600,
fontSize: 14,
color: "#111827",
},
errorBanner: {
marginTop: 16,
padding: "16px 20px",
background: "#fef2f2",
border: "1px solid #fca5a5",
borderRadius: 10,
color: "#991b1b",
boxShadow: "0 1px 4px rgba(239,68,68,0.08)",
},
bannerSub: {
margin: "4px 0",
fontSize: 13,
},
};