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,
},
};