745 lines
29 KiB
TypeScript
745 lines
29 KiB
TypeScript
"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;
|
|
selectedBlockId: string;
|
|
updateBlock: (id: string, partial: Partial<SectionBlockProps>) => void;
|
|
};
|
|
|
|
export function SectionPropertiesPanel({ sectionProps, selectedBlockId, updateBlock }: SectionPropertiesPanelProps) {
|
|
const backgroundSource: "none" | "url" | "upload" = (() => {
|
|
const src = sectionProps.backgroundImageSrc?.trim();
|
|
const type = sectionProps.backgroundImageSourceType;
|
|
|
|
if (type === "asset") return "upload";
|
|
if (type === "externalUrl") return "url";
|
|
|
|
if (src && src.startsWith("/api/image/")) return "upload";
|
|
if (src) return "url";
|
|
|
|
return "none";
|
|
})();
|
|
|
|
const positionMode: "preset" | "custom" = sectionProps.backgroundImagePositionMode ?? "preset";
|
|
|
|
const backgroundVideoSource: "none" | "url" | "upload" = (() => {
|
|
const src = sectionProps.backgroundVideoSrc?.trim();
|
|
const type = sectionProps.backgroundVideoSourceType;
|
|
|
|
if (type === "asset") return "upload";
|
|
if (type === "externalUrl") return "url";
|
|
|
|
if (src && src.startsWith("/api/video/")) return "upload";
|
|
if (src) return "url";
|
|
|
|
return "none";
|
|
})();
|
|
|
|
return (
|
|
<>
|
|
{/* 섹션 배경: 커스텀 색상 피커만 사용 */}
|
|
<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="mt-3 space-y-2 text-xs text-slate-400">
|
|
<label className="flex flex-col gap-1">
|
|
<span>배경 이미지 소스</span>
|
|
<select
|
|
className="w-full rounded border border-slate-700 bg-slate-900 px-2 py-1 text-[11px] outline-none focus:border-sky-500"
|
|
aria-label="배경 이미지 소스"
|
|
value={backgroundSource}
|
|
onChange={(e) => {
|
|
const next = e.target.value as "none" | "url" | "upload";
|
|
if (next === "none") {
|
|
updateBlock(selectedBlockId, {
|
|
backgroundImageSrc: undefined,
|
|
backgroundImageSourceType: undefined,
|
|
backgroundImageAssetId: null,
|
|
} as any);
|
|
} else if (next === "url") {
|
|
updateBlock(selectedBlockId, {
|
|
backgroundImageSourceType: "externalUrl",
|
|
backgroundImageAssetId: null,
|
|
} as any);
|
|
} else {
|
|
updateBlock(selectedBlockId, {
|
|
backgroundImageSourceType: "asset",
|
|
} as any);
|
|
}
|
|
}}
|
|
>
|
|
<option value="none">없음</option>
|
|
<option value="url">URL</option>
|
|
<option value="upload">파일 업로드</option>
|
|
</select>
|
|
</label>
|
|
|
|
{backgroundSource === "url" && (
|
|
<label className="flex flex-col gap-1">
|
|
<span>배경 이미지 URL</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="배경 이미지 URL"
|
|
value={sectionProps.backgroundImageSrc ?? ""}
|
|
onChange={(e) => {
|
|
const value = e.target.value;
|
|
updateBlock(selectedBlockId, {
|
|
backgroundImageSrc: value,
|
|
backgroundImageSourceType: "externalUrl",
|
|
backgroundImageAssetId: null,
|
|
} as any);
|
|
}}
|
|
/>
|
|
</label>
|
|
)}
|
|
|
|
{backgroundSource === "upload" && (
|
|
<label className="flex flex-col gap-1">
|
|
<span>배경 이미지 파일 업로드</span>
|
|
<input
|
|
type="file"
|
|
accept="image/*"
|
|
className="w-full text-[11px] text-slate-300 file:mr-2 file:rounded file:border file:border-slate-700 file:bg-slate-800 file:px-2 file:py-1 file:text-[11px] file:text-slate-100 hover:file:bg-slate-700"
|
|
aria-label="배경 이미지 파일 업로드"
|
|
onChange={async (event) => {
|
|
const file = event.target.files?.[0];
|
|
if (!file) return;
|
|
|
|
try {
|
|
const formData = new FormData();
|
|
formData.append("file", file);
|
|
|
|
const response = await fetch("/api/image", {
|
|
method: "POST",
|
|
body: formData,
|
|
});
|
|
|
|
if (!response.ok) {
|
|
console.error("배경 이미지 업로드 실패", await response.text());
|
|
return;
|
|
}
|
|
|
|
const data = (await response.json()) as { id: string; servedUrl?: string | null };
|
|
const servedUrl = data.servedUrl ?? `/api/image/${data.id}`;
|
|
|
|
updateBlock(selectedBlockId, {
|
|
backgroundImageSrc: servedUrl,
|
|
backgroundImageSourceType: "asset",
|
|
backgroundImageAssetId: data.id,
|
|
} as any);
|
|
} catch (error) {
|
|
console.error("배경 이미지 업로드 중 오류", error);
|
|
} finally {
|
|
event.target.value = "";
|
|
}
|
|
}}
|
|
/>
|
|
</label>
|
|
)}
|
|
{backgroundSource !== "none" && (
|
|
<>
|
|
<label className="flex flex-col gap-1">
|
|
<span>배경 이미지 위치 모드</span>
|
|
<select
|
|
className="w-full rounded border border-slate-700 bg-slate-900 px-2 py-1 text-[11px] outline-none focus:border-sky-500"
|
|
aria-label="배경 이미지 위치 모드"
|
|
value={positionMode}
|
|
onChange={(e) =>
|
|
updateBlock(selectedBlockId, {
|
|
backgroundImagePositionMode: e.target.value as SectionBlockProps["backgroundImagePositionMode"],
|
|
} as any)
|
|
}
|
|
>
|
|
<option value="preset">프리셋</option>
|
|
<option value="custom">커스텀 (X/Y)</option>
|
|
</select>
|
|
</label>
|
|
|
|
<label className="flex flex-col gap-1">
|
|
<span>배경 이미지 크기</span>
|
|
<select
|
|
className="w-full rounded border border-slate-700 bg-slate-900 px-2 py-1 text-[11px] outline-none focus:border-sky-500"
|
|
aria-label="배경 이미지 크기"
|
|
value={sectionProps.backgroundImageSize ?? "cover"}
|
|
onChange={(e) =>
|
|
updateBlock(selectedBlockId, {
|
|
backgroundImageSize: e.target.value as SectionBlockProps["backgroundImageSize"],
|
|
} as any)
|
|
}
|
|
>
|
|
<option value="cover">cover</option>
|
|
<option value="contain">contain</option>
|
|
<option value="auto">auto</option>
|
|
</select>
|
|
</label>
|
|
|
|
{positionMode === "preset" && (
|
|
<label className="flex flex-col gap-1">
|
|
<span>배경 이미지 위치</span>
|
|
<select
|
|
className="w-full rounded border border-slate-700 bg-slate-900 px-2 py-1 text-[11px] outline-none focus:border-sky-500"
|
|
aria-label="배경 이미지 위치"
|
|
value={sectionProps.backgroundImagePosition ?? "center"}
|
|
onChange={(e) =>
|
|
updateBlock(selectedBlockId, {
|
|
backgroundImagePosition: e.target.value as SectionBlockProps["backgroundImagePosition"],
|
|
} as any)
|
|
}
|
|
>
|
|
<option value="center">가운데</option>
|
|
<option value="top">위</option>
|
|
<option value="bottom">아래</option>
|
|
<option value="left">왼쪽</option>
|
|
<option value="right">오른쪽</option>
|
|
</select>
|
|
</label>
|
|
)}
|
|
|
|
{positionMode === "custom" && (
|
|
<>
|
|
<NumericPropertyControl
|
|
label="배경 이미지 가로 위치"
|
|
unitLabel="(%)"
|
|
value={
|
|
typeof sectionProps.backgroundImagePositionXPercent === "number"
|
|
? sectionProps.backgroundImagePositionXPercent
|
|
: 50
|
|
}
|
|
min={0}
|
|
max={100}
|
|
step={1}
|
|
presets={[
|
|
{ id: "left", label: "왼쪽", value: 0 },
|
|
{ id: "center", label: "가운데", value: 50 },
|
|
{ id: "right", label: "오른쪽", value: 100 },
|
|
]}
|
|
onChangeValue={(next) =>
|
|
updateBlock(selectedBlockId, {
|
|
backgroundImagePositionMode: "custom",
|
|
backgroundImagePositionXPercent: next,
|
|
} as any)
|
|
}
|
|
/>
|
|
|
|
<NumericPropertyControl
|
|
label="배경 이미지 세로 위치"
|
|
unitLabel="(%)"
|
|
value={
|
|
typeof sectionProps.backgroundImagePositionYPercent === "number"
|
|
? sectionProps.backgroundImagePositionYPercent
|
|
: 50
|
|
}
|
|
min={0}
|
|
max={100}
|
|
step={1}
|
|
presets={[
|
|
{ id: "top", label: "위", value: 0 },
|
|
{ id: "center", label: "가운데", value: 50 },
|
|
{ id: "bottom", label: "아래", value: 100 },
|
|
]}
|
|
onChangeValue={(next) =>
|
|
updateBlock(selectedBlockId, {
|
|
backgroundImagePositionMode: "custom",
|
|
backgroundImagePositionYPercent: next,
|
|
} as any)
|
|
}
|
|
/>
|
|
</>
|
|
)}
|
|
|
|
<label className="flex flex-col gap-1">
|
|
<span>배경 이미지 반복</span>
|
|
<select
|
|
className="w-full rounded border border-slate-700 bg-slate-900 px-2 py-1 text-[11px] outline-none focus:border-sky-500"
|
|
aria-label="배경 이미지 반복"
|
|
value={sectionProps.backgroundImageRepeat ?? "no-repeat"}
|
|
onChange={(e) =>
|
|
updateBlock(selectedBlockId, {
|
|
backgroundImageRepeat: e.target.value as SectionBlockProps["backgroundImageRepeat"],
|
|
} as any)
|
|
}
|
|
>
|
|
<option value="no-repeat">반복 없음</option>
|
|
<option value="repeat">가로/세로 반복</option>
|
|
<option value="repeat-x">가로 반복</option>
|
|
<option value="repeat-y">세로 반복</option>
|
|
</select>
|
|
</label>
|
|
</>
|
|
)}
|
|
</div>
|
|
|
|
{/* 섹션 배경 비디오 */}
|
|
<div className="mt-3 space-y-2 text-xs text-slate-400">
|
|
<label className="flex flex-col gap-1">
|
|
<span>배경 비디오 소스</span>
|
|
<select
|
|
className="w-full rounded border border-slate-700 bg-slate-900 px-2 py-1 text-[11px] outline-none focus:border-sky-500"
|
|
aria-label="배경 비디오 소스"
|
|
value={backgroundVideoSource}
|
|
onChange={(e) => {
|
|
const next = e.target.value as "none" | "url" | "upload";
|
|
if (next === "none") {
|
|
updateBlock(selectedBlockId, {
|
|
backgroundVideoSrc: undefined,
|
|
backgroundVideoSourceType: undefined,
|
|
backgroundVideoAssetId: null,
|
|
} as any);
|
|
} else if (next === "url") {
|
|
updateBlock(selectedBlockId, {
|
|
backgroundVideoSourceType: "externalUrl",
|
|
backgroundVideoAssetId: null,
|
|
} as any);
|
|
} else {
|
|
updateBlock(selectedBlockId, {
|
|
backgroundVideoSourceType: "asset",
|
|
} as any);
|
|
}
|
|
}}
|
|
>
|
|
<option value="none">없음</option>
|
|
<option value="url">URL</option>
|
|
<option value="upload">파일 업로드</option>
|
|
</select>
|
|
</label>
|
|
|
|
{backgroundVideoSource === "url" && (
|
|
<label className="flex flex-col gap-1">
|
|
<span>배경 비디오 URL</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="배경 비디오 URL"
|
|
value={sectionProps.backgroundVideoSrc ?? ""}
|
|
onChange={(e) => {
|
|
const value = e.target.value;
|
|
updateBlock(selectedBlockId, {
|
|
backgroundVideoSrc: value,
|
|
backgroundVideoSourceType: "externalUrl",
|
|
backgroundVideoAssetId: null,
|
|
} as any);
|
|
}}
|
|
/>
|
|
</label>
|
|
)}
|
|
|
|
{backgroundVideoSource === "upload" && (
|
|
<label className="flex flex-col gap-1">
|
|
<span>배경 비디오 파일 업로드</span>
|
|
<input
|
|
type="file"
|
|
accept="video/*"
|
|
className="w-full text-[11px] text-slate-300 file:mr-2 file:rounded file:border file:border-slate-700 file:bg-slate-800 file:px-2 file:py-1 file:text-[11px] file:text-slate-100 hover:file:bg-slate-700"
|
|
aria-label="배경 비디오 파일 업로드"
|
|
onChange={async (event) => {
|
|
const file = event.target.files?.[0];
|
|
if (!file) return;
|
|
|
|
try {
|
|
const formData = new FormData();
|
|
formData.append("file", file);
|
|
|
|
const response = await fetch("/api/video", {
|
|
method: "POST",
|
|
body: formData,
|
|
});
|
|
|
|
if (!response.ok) {
|
|
console.error("배경 비디오 업로드 실패", await response.text());
|
|
return;
|
|
}
|
|
|
|
const data = (await response.json()) as { id: string; servedUrl?: string | null };
|
|
const servedUrl = data.servedUrl ?? `/api/video/${data.id}`;
|
|
|
|
updateBlock(selectedBlockId, {
|
|
backgroundVideoSrc: servedUrl,
|
|
backgroundVideoSourceType: "asset",
|
|
backgroundVideoAssetId: data.id,
|
|
} as any);
|
|
} catch (error) {
|
|
console.error("배경 비디오 업로드 중 오류", error);
|
|
} finally {
|
|
event.target.value = "";
|
|
}
|
|
}}
|
|
/>
|
|
</label>
|
|
)}
|
|
</div>
|
|
|
|
{/* 세로 패딩 슬라이더 (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={(() => {
|
|
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 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="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] ?? { id: `${selectedBlockId}_col_1`, span: 12 };
|
|
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="최대 폭 (px)"
|
|
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="컬럼 간 간격 (px)"
|
|
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>
|
|
</>
|
|
);
|
|
}
|