Files
page-builder/src/app/editor/panels/ButtonPropertiesPanel.tsx
T
jaybe a6ef5f01cd
CI / pr_and_merge (push) Blocked by required conditions
CI / test (push) Has been cancelled
에디터 인라인/속성 패널 리팩터링 및 폼 전송 기본 기능 추가
2025-11-20 00:53:39 +09:00

296 lines
11 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">
<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">
<NumericPropertyControl
label="버튼 크기"
value={(() => {
const s = buttonProps.size ?? "md";
return s === "xs" ? 1 : s === "sm" ? 2 : s === "lg" ? 4 : s === "xl" ? 5 : 3;
})()}
min={1}
max={5}
step={1}
presets={[
{ id: "xs", label: "아주 작게", value: 1 },
{ id: "sm", label: "작게", value: 2 },
{ id: "md", label: "보통", value: 3 },
{ id: "lg", label: "크게", value: 4 },
{ id: "xl", label: "아주 크게", value: 5 },
]}
onChangeValue={(v) => {
const next = v <= 1 ? "xs" : v === 2 ? "sm" : v === 4 ? "lg" : v >= 5 ? "xl" : "md";
updateBlock(selectedBlockId, { size: next } as any);
}}
/>
</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">
<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>
</>
);
}