섹션 레이아웃 및 템플릿 스타일 개선
CI / test (push) Failing after 6m10s
CI / pr_and_merge (push) Has been skipped

This commit is contained in:
2025-11-21 16:25:55 +09:00
parent 2f59e3781e
commit 546c961a31
25 changed files with 2843 additions and 151 deletions
+363 -29
View File
@@ -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>