Files
page-builder/src/app/editor/panels/ButtonPropertiesPanel.tsx
T
jaybe 4e4c9cd37a
CI / test (push) Successful in 11m2s
CI / pr_and_merge (push) Successful in 1m19s
CI / test (pull_request) Successful in 51m33s
CI / pr_and_merge (pull_request) Has been skipped
섹션 삭제 후 텍스트 블록 추가 버그 수정 및 버튼/구분선 스타일-속성 패널 동기화
2025-11-22 15:18:32 +09:00

359 lines
13 KiB
TypeScript

"use client";
import type { ButtonBlockProps } from "@/features/editor/state/editorStore";
import { NumericPropertyControl } from "@/features/editor/components/NumericPropertyControl";
import { ColorPickerField, TEXT_COLOR_PALETTE } from "@/features/editor/components/ColorPickerField";
export type ButtonPropertiesPanelProps = {
buttonProps: ButtonBlockProps;
selectedBlockId: string;
updateBlock: (id: string, partial: Partial<ButtonBlockProps>) => void;
};
export function ButtonPropertiesPanel({ buttonProps, selectedBlockId, updateBlock }: ButtonPropertiesPanelProps) {
return (
<>
<div className="space-y-1">
<label className="flex flex-col gap-1 text-xs text-slate-400">
<span>버튼 텍스트</span>
<textarea
className="w-full min-h-[60px] rounded border border-slate-700 bg-slate-900 px-2 py-1 text-xs outline-none focus:border-sky-500"
aria-label="버튼 텍스트"
value={buttonProps.label}
onChange={(e) => {
updateBlock(selectedBlockId, { label: e.target.value } as any);
}}
/>
</label>
</div>
<div className="space-y-1">
<NumericPropertyControl
label="가로 패딩 (px)"
unitLabel="(px)"
value={buttonProps.paddingX ?? 16}
min={0}
max={120}
step={4}
presets={[
{ id: "xs", label: "XS", value: 8 },
{ id: "sm", label: "S", value: 12 },
{ id: "md", label: "M", value: 16 },
{ id: "lg", label: "L", value: 24 },
{ id: "xl", label: "XL", value: 32 },
]}
onChangeValue={(v) => {
updateBlock(selectedBlockId, {
paddingX: v,
} as any);
}}
/>
</div>
<div className="space-y-1">
<NumericPropertyControl
label="세로 패딩 (px)"
unitLabel="(px)"
value={buttonProps.paddingY ?? 10}
min={0}
max={80}
step={2}
presets={[
{ id: "xs", label: "XS", value: 6 },
{ id: "sm", label: "S", value: 8 },
{ id: "md", label: "M", value: 10 },
{ id: "lg", label: "L", value: 14 },
{ id: "xl", label: "XL", value: 20 },
]}
onChangeValue={(v) => {
updateBlock(selectedBlockId, {
paddingY: v,
} as any);
}}
/>
</div>
<div className="space-y-1">
<label className="flex flex-col gap-1 text-xs text-slate-400">
<span>버튼 링크</span>
<input
className="w-full rounded border border-slate-700 bg-slate-900 px-2 py-1 text-xs outline-none focus:border-sky-500"
aria-label="버튼 링크"
value={buttonProps.href}
onChange={(e) => {
updateBlock(selectedBlockId, { href: e.target.value } as any);
}}
/>
</label>
</div>
<div className="space-y-1">
<label className="flex flex-col gap-1 text-xs text-slate-400">
<span>정렬</span>
<select
className="rounded border border-slate-700 bg-slate-900 px-2 py-1 text-xs outline-none focus:border-sky-500"
aria-label="버튼 정렬"
value={buttonProps.align ?? "left"}
onChange={(e) => {
const value = e.target.value as NonNullable<ButtonBlockProps["align"]>;
updateBlock(selectedBlockId, { align: value } as any);
}}
>
<option value="left">왼쪽</option>
<option value="center">가운데</option>
<option value="right">오른쪽</option>
</select>
</label>
</div>
<div className="space-y-1">
{/* 버튼 크기 스케일 컨트롤은 paddingX/paddingY 및 폰트 크기(px) 컨트롤로 충분하므로 제거했다. */}
</div>
<div className="space-y-1">
<ColorPickerField
label="텍스트 색상"
ariaLabelColorInput="버튼 텍스트 색상 피커"
ariaLabelHexInput="버튼 텍스트 색상 HEX"
value={
buttonProps.textColorCustom && buttonProps.textColorCustom.startsWith("#")
? buttonProps.textColorCustom
: "#f9fafb"
}
onChange={(hex) => {
updateBlock(selectedBlockId, {
textColorCustom: hex,
} as any);
}}
palette={TEXT_COLOR_PALETTE}
onPaletteSelect={(item) => {
updateBlock(selectedBlockId, {
textColorCustom: item.color,
} as any);
}}
/>
</div>
<div className="space-y-1">
<label className="flex flex-col gap-1 text-xs text-slate-400">
<span>스타일</span>
<select
className="rounded border border-slate-700 bg-slate-900 px-2 py-1 text-xs outline-none focus:border-sky-500"
aria-label="버튼 스타일"
value={buttonProps.variant ?? "solid"}
onChange={(e) => {
const value = e.target.value as NonNullable<ButtonBlockProps["variant"]>;
updateBlock(selectedBlockId, { variant: value } as any);
}}
>
<option value="solid">채움</option>
<option value="outline">외곽선</option>
<option value="ghost">고스트</option>
</select>
</label>
</div>
<div className="space-y-1">
<ColorPickerField
label="채움 색상"
ariaLabelColorInput="버튼 채움 색상 피커"
ariaLabelHexInput="버튼 채움 색상 HEX"
value={
buttonProps.fillColorCustom && buttonProps.fillColorCustom.startsWith("#")
? buttonProps.fillColorCustom
: TEXT_COLOR_PALETTE[0]?.color ?? "#0ea5e9"
}
onChange={(hex) => {
updateBlock(selectedBlockId, {
fillColorCustom: hex,
} as any);
}}
palette={TEXT_COLOR_PALETTE}
onPaletteSelect={(item) => {
updateBlock(selectedBlockId, {
fillColorCustom: item.color,
} as any);
}}
/>
</div>
<div className="space-y-1">
<ColorPickerField
label="외곽선 색상"
ariaLabelColorInput="버튼 외곽선 색상 피커"
ariaLabelHexInput="버튼 외곽선 색상 HEX"
value={
buttonProps.strokeColorCustom && buttonProps.strokeColorCustom.startsWith("#")
? buttonProps.strokeColorCustom
: TEXT_COLOR_PALETTE[0]?.color ?? "#0ea5e9"
}
onChange={(hex) => {
updateBlock(selectedBlockId, {
strokeColorCustom: hex,
} as any);
}}
palette={TEXT_COLOR_PALETTE}
onPaletteSelect={(item) => {
updateBlock(selectedBlockId, {
strokeColorCustom: item.color,
} as any);
}}
/>
</div>
<div className="space-y-1">
<NumericPropertyControl
label="모서리 둥글기"
value={(() => {
const r = buttonProps.borderRadius ?? "md";
return r === "none" ? 0 : r === "sm" ? 1 : r === "lg" ? 3 : r === "full" ? 4 : 2;
})()}
min={0}
max={4}
step={1}
presets={[
{ id: "none", label: "없음", value: 0 },
{ id: "sm", label: "작게", value: 1 },
{ id: "md", label: "보통", value: 2 },
{ id: "lg", label: "크게", value: 3 },
{ id: "full", label: "완전 둥글게", value: 4 },
]}
onChangeValue={(v) => {
const next =
v <= 0 ? "none" : v === 1 ? "sm" : v === 3 ? "lg" : v >= 4 ? "full" : "md";
updateBlock(selectedBlockId, { borderRadius: next } as any);
}}
/>
</div>
<div className="space-y-1">
<label className="flex flex-col gap-1 text-xs text-slate-400">
<span>버튼 너비 모드</span>
<select
className="rounded border border-slate-700 bg-slate-900 px-2 py-1 text-xs outline-none focus:border-sky-500"
aria-label="버튼 너비 모드"
value={buttonProps.widthMode ?? (buttonProps.fullWidth ? "full" : "auto")}
onChange={(e) => {
updateBlock(selectedBlockId, {
widthMode: e.target.value as NonNullable<ButtonBlockProps["widthMode"]>,
} as any);
}}
>
<option value="auto">자동</option>
<option value="full">전체 </option>
<option value="fixed">고정 </option>
</select>
</label>
{(buttonProps.widthMode ?? (buttonProps.fullWidth ? "full" : "auto")) === "fixed" && (
<NumericPropertyControl
label="버튼 고정 너비"
unitLabel="(px)"
value={buttonProps.widthPx ?? 240}
min={80}
max={600}
step={10}
presets={[
{ id: "sm", label: "작게", value: 180 },
{ id: "md", label: "보통", value: 240 },
{ id: "lg", label: "넓게", value: 320 },
]}
onChangeValue={(v) => {
updateBlock(selectedBlockId, {
widthPx: v,
} as any);
}}
/>
)}
</div>
<div className="space-y-1">
<NumericPropertyControl
label="버튼 크기"
unitLabel="(px)"
value={(() => {
const raw = buttonProps.fontSizeCustom ?? "";
const match = raw.match(/([0-9]+(?:\.[0-9]+)?)/);
if (match) return Number(match[1]);
// 기본 버튼 텍스트 크기: md 스케일(16px) 기준
return 16;
})()}
min={10}
max={32}
step={1}
presets={[
{ id: "xs", label: "XS", value: 12 },
{ id: "sm", label: "S", value: 14 },
{ id: "base", label: "M", value: 16 },
{ id: "lg", label: "L", value: 18 },
{ id: "xl", label: "XL", value: 20 },
{ id: "2xl", label: "2XL", value: 24 },
{ id: "3xl", label: "3XL", value: 30 },
]}
onChangeValue={(px) => {
// 버튼은 텍스트 스케일 상태 없이 px만 저장하지만,
// 프리셋 값은 TextPropertiesPanel과 동일한 테이블을 사용한다.
updateBlock(selectedBlockId, {
fontSizeCustom: `${px}px`,
} as any);
}}
/>
</div>
<div className="space-y-1">
<NumericPropertyControl
label="줄 간격"
value={(() => {
const raw = buttonProps.lineHeightCustom ?? "";
const match = raw.match(/(-?[0-9]+(?:\.[0-9]+)?)/);
if (match) return Number(match[1]);
return 1.4;
})()}
min={0.8}
max={3}
step={0.05}
presets={[
{ id: "tight", label: "좁게", value: 1.1 },
{ id: "normal", label: "보통", value: 1.4 },
{ id: "relaxed", label: "넓게", value: 1.8 },
]}
onChangeValue={(v) => {
updateBlock(selectedBlockId, {
lineHeightCustom: v.toString(),
} as any);
}}
/>
</div>
<div className="space-y-1">
<NumericPropertyControl
label="글자 간격"
unitLabel="(px)"
value={(() => {
const raw = buttonProps.letterSpacingCustom ?? "";
const match = raw.match(/(-?[0-9]+(?:\.[0-9]+)?)/);
if (match) {
const em = Number(match[1]);
if (Number.isFinite(em)) return em * 16;
}
return 0;
})()}
min={-2}
max={10}
step={0.1}
presets={[
{ id: "tighter", label: "아주 좁게", value: -1.5 },
{ id: "tight", label: "좁게", value: -0.5 },
{ id: "normal", label: "보통", value: 0 },
{ id: "wide", label: "넓게", value: 1 },
{ id: "wider", label: "아주 넓게", value: 2 },
]}
onChangeValue={(px) => {
const em = px / 16;
updateBlock(selectedBlockId, {
letterSpacingCustom: `${em}em`,
} as any);
}}
/>
</div>
<div className="space-y-1">
<label className="flex items-center justify-between text-xs text-slate-400">
<span>가로 전체 사용</span>
<input
type="checkbox"
checked={buttonProps.fullWidth ?? false}
onChange={(e) => {
updateBlock(selectedBlockId, { fullWidth: e.target.checked } as any);
}}
/>
</label>
</div>
</>
);
}