섹션 레이아웃 및 템플릿 스타일 개선
This commit is contained in:
@@ -1,6 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import type { SectionBlockProps } from "@/features/editor/state/editorStore";
|
||||
import { ColorPickerField, TEXT_COLOR_PALETTE } from "@/features/editor/components/ColorPickerField";
|
||||
import { NumericPropertyControl } from "@/features/editor/components/NumericPropertyControl";
|
||||
|
||||
export type SectionPropertiesPanelProps = {
|
||||
sectionProps: SectionBlockProps;
|
||||
@@ -11,42 +13,374 @@ export type SectionPropertiesPanelProps = {
|
||||
export function SectionPropertiesPanel({ sectionProps, selectedBlockId, updateBlock }: SectionPropertiesPanelProps) {
|
||||
return (
|
||||
<>
|
||||
{/* 섹션 배경 */}
|
||||
<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={sectionProps.background ?? "default"}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value as NonNullable<SectionBlockProps["background"]>;
|
||||
updateBlock(selectedBlockId, { background: value } as any);
|
||||
}}
|
||||
>
|
||||
<option value="default">기본</option>
|
||||
<option value="muted">Muted</option>
|
||||
<option value="primary">Primary</option>
|
||||
</select>
|
||||
</label>
|
||||
{/* 섹션 배경: 커스텀 색상 피커만 사용 */}
|
||||
<div className="mt-1">
|
||||
<ColorPickerField
|
||||
label="배경 색상"
|
||||
ariaLabelColorInput="섹션 배경 색상 선택"
|
||||
ariaLabelHexInput="섹션 배경 색상 HEX 입력"
|
||||
value={sectionProps.backgroundColorCustom ?? ""}
|
||||
onChange={(hex) => {
|
||||
const next = hex && hex.trim().length > 0 ? hex : undefined;
|
||||
updateBlock(selectedBlockId, { backgroundColorCustom: next } as any);
|
||||
}}
|
||||
palette={TEXT_COLOR_PALETTE}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 상하 패딩 */}
|
||||
<div className="space-y-1">
|
||||
<label className="flex flex-col gap-1 text-xs text-slate-400">
|
||||
<span>세로 패딩</span>
|
||||
{/* 세로 패딩 슬라이더 (px) - 프리셋 + 자유 슬라이더 */}
|
||||
<div className="mt-3 space-y-1">
|
||||
<NumericPropertyControl
|
||||
label="세로 패딩"
|
||||
unitLabel="(px)"
|
||||
value={typeof sectionProps.paddingYPx === "number" ? sectionProps.paddingYPx : 48}
|
||||
min={16}
|
||||
max={80}
|
||||
step={2}
|
||||
presets={[
|
||||
{ id: "x-tight", label: "매우 좁게", value: 16 },
|
||||
{ id: "tight", label: "좁게", value: 32 },
|
||||
{ id: "normal", label: "보통", value: 48 },
|
||||
{ id: "relaxed", label: "넓게", value: 64 },
|
||||
{ id: "x-relaxed", label: "아주 넓게", value: 80 },
|
||||
]}
|
||||
onChangeValue={(next) => {
|
||||
const safe = Number.isFinite(next) ? next : 48;
|
||||
updateBlock(selectedBlockId, { paddingYPx: safe } as any);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 space-y-2 border-t border-slate-800 pt-3 text-xs text-slate-400">
|
||||
<h4 className="text-[11px] font-semibold text-slate-200">섹션 레이아웃</h4>
|
||||
|
||||
{/* 컬럼 레이아웃 프리셋 */}
|
||||
<label className="flex flex-col gap-1">
|
||||
<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={sectionProps.paddingY ?? "md"}
|
||||
aria-label="섹션 컬럼 레이아웃"
|
||||
value={(() => {
|
||||
const cols = sectionProps.columns ?? [];
|
||||
const spans = cols.map((c) => c.span).join("-");
|
||||
if (spans === "12") return "one-full";
|
||||
if (spans === "6-6") return "two-equal";
|
||||
if (spans === "4-8") return "two-1-2";
|
||||
if (spans === "8-4") return "two-2-1";
|
||||
if (spans === "4-4-4") return "three-equal";
|
||||
return "custom";
|
||||
})()}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value as NonNullable<SectionBlockProps["paddingY"]>;
|
||||
updateBlock(selectedBlockId, { paddingY: value } as any);
|
||||
const v = e.target.value as
|
||||
| "one-full"
|
||||
| "two-equal"
|
||||
| "two-1-2"
|
||||
| "two-2-1"
|
||||
| "three-equal"
|
||||
| "custom";
|
||||
|
||||
if (v === "custom") return;
|
||||
const current = sectionProps.columns ?? [];
|
||||
|
||||
const ensureId = (index: number) => {
|
||||
if (current[index]?.id) return current[index].id;
|
||||
return `${selectedBlockId}_col_${index + 1}`;
|
||||
};
|
||||
|
||||
const nextColumns =
|
||||
v === "one-full"
|
||||
? [
|
||||
{ id: ensureId(0), span: 12 },
|
||||
]
|
||||
: v === "two-equal"
|
||||
? [
|
||||
{ id: ensureId(0), span: 6 },
|
||||
{ id: ensureId(1), span: 6 },
|
||||
]
|
||||
: v === "two-1-2"
|
||||
? [
|
||||
{ id: ensureId(0), span: 4 },
|
||||
{ id: ensureId(1), span: 8 },
|
||||
]
|
||||
: v === "two-2-1"
|
||||
? [
|
||||
{ id: ensureId(0), span: 8 },
|
||||
{ id: ensureId(1), span: 4 },
|
||||
]
|
||||
: [
|
||||
{ id: ensureId(0), span: 4 },
|
||||
{ id: ensureId(1), span: 4 },
|
||||
{ id: ensureId(2), span: 4 },
|
||||
];
|
||||
|
||||
updateBlock(selectedBlockId, { columns: nextColumns } as any);
|
||||
}}
|
||||
>
|
||||
<option value="sm">작게</option>
|
||||
<option value="md">보통</option>
|
||||
<option value="lg">크게</option>
|
||||
<option value="one-full">1열 (1/1)</option>
|
||||
<option value="two-equal">2열 (1/2 - 1/2)</option>
|
||||
<option value="two-1-2">2열 (1/3 - 2/3)</option>
|
||||
<option value="two-2-1">2열 (2/3 - 1/3)</option>
|
||||
<option value="three-equal">3열 (1/3 - 1/3 - 1/3)</option>
|
||||
<option value="custom">직접 조정</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
{/* 컬럼 개수 직접 조정 (1~5열) */}
|
||||
{(() => {
|
||||
const current = sectionProps.columns ?? [];
|
||||
const count = current.length || 1;
|
||||
|
||||
const handleChangeCount = (nextCount: number) => {
|
||||
const clamped = Math.max(1, Math.min(5, Math.floor(nextCount)));
|
||||
if (clamped === count) return;
|
||||
|
||||
const base = Math.floor(12 / clamped) || 1;
|
||||
const rest = 12 - base * clamped;
|
||||
|
||||
const nextColumns = Array.from({ length: clamped }).map((_, index) => {
|
||||
const existing = current[index];
|
||||
const id = existing?.id ?? `${selectedBlockId}_col_${index + 1}`;
|
||||
const span = base + (index === clamped - 1 ? rest : 0);
|
||||
return { id, span };
|
||||
});
|
||||
|
||||
updateBlock(selectedBlockId, { columns: nextColumns } as any);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mt-2 flex items-center justify-between text-xs text-slate-300">
|
||||
<span>{`컬럼 개수: ${count}열 (최대 5열)`}</span>
|
||||
<div className="flex gap-1">
|
||||
<button
|
||||
type="button"
|
||||
className="rounded border border-slate-700 px-2 py-0.5 text-[11px] disabled:opacity-40"
|
||||
onClick={() => handleChangeCount(count - 1)}
|
||||
disabled={count <= 1}
|
||||
>
|
||||
- 열 제거
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="rounded border border-slate-700 px-2 py-0.5 text-[11px] disabled:opacity-40"
|
||||
onClick={() => handleChangeCount(count + 1)}
|
||||
disabled={count >= 5}
|
||||
>
|
||||
+ 열 추가
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
|
||||
{/* 컬럼별 span 조정
|
||||
- 컬럼이 1개일 때: 12 고정, 편집 불가능
|
||||
- 컬럼이 2개 이상일 때: 앞의 N-1개 컬럼만 직접 조정, 마지막 컬럼은 합이 항상 12가 되도록 자동 계산 */}
|
||||
{(() => {
|
||||
const columns = sectionProps.columns ?? [];
|
||||
const colCount = columns.length || 1;
|
||||
|
||||
// 1컬럼인 경우: 12 고정 표시
|
||||
if (colCount === 1) {
|
||||
const only = columns[0];
|
||||
return (
|
||||
<label key={only.id} className="flex flex-col gap-1">
|
||||
<span>{`1열 폭 (고정 12/12)`}</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="range"
|
||||
min={12}
|
||||
max={12}
|
||||
step={1}
|
||||
className="flex-1 opacity-60"
|
||||
aria-label="1열 폭 슬라이더 (고정)"
|
||||
value={12}
|
||||
disabled
|
||||
/>
|
||||
<input
|
||||
type="number"
|
||||
min={12}
|
||||
max={12}
|
||||
className="w-16 rounded border border-slate-700 bg-slate-900 px-2 py-1 text-xs outline-none text-slate-400"
|
||||
aria-label="1열 폭 (고정 12/12)"
|
||||
value={12}
|
||||
disabled
|
||||
/>
|
||||
</div>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
const lastIndex = colCount - 1;
|
||||
|
||||
return columns.map((col, index) => {
|
||||
const maxSpanForEach = Math.max(1, 12 - (colCount - 1));
|
||||
|
||||
// 마지막 컬럼: 자동 계산, 편집 불가
|
||||
if (index === lastIndex) {
|
||||
const nonLast = columns.slice(0, lastIndex);
|
||||
const nonLastSum = nonLast.reduce(
|
||||
(sum, c) => sum + (Number.isFinite(c.span) ? c.span : 0),
|
||||
0,
|
||||
);
|
||||
let autoSpan = 12 - nonLastSum;
|
||||
if (!Number.isFinite(autoSpan) || autoSpan < 1) autoSpan = 1;
|
||||
|
||||
return (
|
||||
<label key={col.id} className="flex flex-col gap-1">
|
||||
<span>{`${index + 1}열 폭 (자동, 합계 12 유지)`}</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="range"
|
||||
min={1}
|
||||
max={maxSpanForEach}
|
||||
step={1}
|
||||
className="flex-1 opacity-60"
|
||||
aria-label={`${index + 1}열 폭 슬라이더 (자동)`}
|
||||
value={Number.isFinite(col.span) ? col.span : autoSpan}
|
||||
disabled
|
||||
/>
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
max={maxSpanForEach}
|
||||
className="w-16 rounded border border-slate-700 bg-slate-900 px-2 py-1 text-xs outline-none text-slate-400"
|
||||
aria-label={`${index + 1}열 폭 (자동)`}
|
||||
value={Number.isFinite(col.span) ? col.span : autoSpan}
|
||||
disabled
|
||||
/>
|
||||
</div>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
// 앞의 N-1개 컬럼: 직접 조정하면 마지막 컬럼을 자동으로 보정해서 합계 12 유지
|
||||
const otherNonLastSum = columns.reduce((sum, c, idx) => {
|
||||
if (idx === index || idx === lastIndex) return sum;
|
||||
return sum + (Number.isFinite(c.span) ? c.span : 0);
|
||||
}, 0);
|
||||
|
||||
const maxForCurrent = Math.max(
|
||||
1,
|
||||
Math.min(maxSpanForEach, 11 - otherNonLastSum),
|
||||
);
|
||||
|
||||
const handleChange = (raw: number) => {
|
||||
if (Number.isNaN(raw)) return;
|
||||
const clampedCurrent = Math.min(
|
||||
maxForCurrent,
|
||||
Math.max(1, Math.floor(raw)),
|
||||
);
|
||||
|
||||
const newNonLastTotal = otherNonLastSum + clampedCurrent;
|
||||
let lastSpan = 12 - newNonLastTotal;
|
||||
if (!Number.isFinite(lastSpan) || lastSpan < 1) {
|
||||
lastSpan = 1;
|
||||
}
|
||||
|
||||
const nextColumns = columns.map((c, idx) => {
|
||||
if (idx === index) {
|
||||
return { ...c, span: clampedCurrent };
|
||||
}
|
||||
if (idx === lastIndex) {
|
||||
return { ...c, span: lastSpan };
|
||||
}
|
||||
return c;
|
||||
});
|
||||
|
||||
updateBlock(selectedBlockId, { columns: nextColumns } as any);
|
||||
};
|
||||
|
||||
return (
|
||||
<label key={col.id} className="flex flex-col gap-1">
|
||||
<span>{`${index + 1}열 폭 (1~${maxForCurrent})`}</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="range"
|
||||
min={1}
|
||||
max={maxForCurrent}
|
||||
step={1}
|
||||
className="flex-1"
|
||||
aria-label={`${index + 1}열 폭 슬라이더`}
|
||||
value={Number.isFinite(col.span) ? col.span : 12}
|
||||
onChange={(e) => {
|
||||
handleChange(Number(e.target.value));
|
||||
}}
|
||||
/>
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
max={maxForCurrent}
|
||||
className="w-16 rounded border border-slate-700 bg-slate-900 px-2 py-1 text-xs outline-none focus:border-sky-500"
|
||||
aria-label={`${index + 1}열 폭 (1~${maxForCurrent})`}
|
||||
value={Number.isFinite(col.span) ? col.span : 12}
|
||||
onChange={(e) => {
|
||||
handleChange(Number(e.target.value));
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</label>
|
||||
);
|
||||
});
|
||||
})()}
|
||||
|
||||
{/* 최대 폭 슬라이더 (px) - 프리셋 + 자유 슬라이더 */}
|
||||
<NumericPropertyControl
|
||||
label="최대 폭"
|
||||
unitLabel="(px)"
|
||||
value={typeof sectionProps.maxWidthPx === "number" ? sectionProps.maxWidthPx : 960}
|
||||
min={640}
|
||||
max={1440}
|
||||
step={16}
|
||||
presets={[
|
||||
{ id: "x-narrow", label: "매우 좁게", value: 640 },
|
||||
{ id: "narrow", label: "좁게", value: 800 },
|
||||
{ id: "normal", label: "보통", value: 960 },
|
||||
{ id: "wide", label: "넓게", value: 1200 },
|
||||
{ id: "x-wide", label: "아주 넓게", value: 1440 },
|
||||
]}
|
||||
onChangeValue={(next) => {
|
||||
const safe = Number.isFinite(next) ? next : 960;
|
||||
updateBlock(selectedBlockId, { maxWidthPx: safe } as any);
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* 컬럼 간 간격 슬라이더 (px) - 프리셋 + 자유 슬라이더 */}
|
||||
<NumericPropertyControl
|
||||
label="컬럼 간 간격"
|
||||
unitLabel="(px)"
|
||||
value={typeof sectionProps.gapXPx === "number" ? sectionProps.gapXPx : 24}
|
||||
min={0}
|
||||
max={64}
|
||||
step={2}
|
||||
presets={[
|
||||
{ id: "zero", label: "0", value: 0 },
|
||||
{ id: "tight", label: "좁게", value: 16 },
|
||||
{ id: "normal", label: "보통", value: 24 },
|
||||
{ id: "relaxed", label: "넓게", value: 32 },
|
||||
{ id: "x-relaxed", label: "아주 넓게", value: 48 },
|
||||
{ id: "max", label: "최대", value: 64 },
|
||||
]}
|
||||
onChangeValue={(next) => {
|
||||
const safe = Number.isFinite(next) ? next : 24;
|
||||
updateBlock(selectedBlockId, { gapXPx: safe } as any);
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* 세로 정렬 */}
|
||||
<label className="flex flex-col gap-1">
|
||||
<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={sectionProps.alignItems ?? "top"}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value as NonNullable<SectionBlockProps["alignItems"]>;
|
||||
updateBlock(selectedBlockId, { alignItems: value } as any);
|
||||
}}
|
||||
>
|
||||
<option value="top">위</option>
|
||||
<option value="center">가운데</option>
|
||||
<option value="bottom">아래</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user