video 블록 및 리펙터링
This commit is contained in:
+195
-358
@@ -28,6 +28,7 @@ import type {
|
||||
TextBlockProps,
|
||||
ButtonBlockProps,
|
||||
ImageBlockProps,
|
||||
VideoBlockProps,
|
||||
SectionBlockProps,
|
||||
DividerBlockProps,
|
||||
ListBlockProps,
|
||||
@@ -40,6 +41,23 @@ import type {
|
||||
ListItemNode,
|
||||
ProjectConfig,
|
||||
} from "@/features/editor/state/editorStore";
|
||||
import {
|
||||
normalizeVideoSourceUrl,
|
||||
resolveVideoPlatform,
|
||||
buildVideoEmbedUrl,
|
||||
computeVideoEditorTokens,
|
||||
} from "@/features/editor/utils/videoHelpers";
|
||||
import { computeTextEditorTokens } from "@/features/editor/utils/textHelpers";
|
||||
import { computeButtonPbTokens, computeButtonEditorTokens } from "@/features/editor/utils/buttonHelpers";
|
||||
import { computeDividerEditorTokens } from "@/features/editor/utils/dividerHelpers";
|
||||
import { computeListEditorTokens } from "@/features/editor/utils/listHelpers";
|
||||
import { computeImageEditorTokens } from "@/features/editor/utils/imageHelpers";
|
||||
import { computeSectionEditorTokens } from "@/features/editor/utils/sectionHelpers";
|
||||
import {
|
||||
computeFormInputEditorTokens,
|
||||
computeFormSelectEditorTokens,
|
||||
computeFormOptionGroupEditorTokens,
|
||||
} from "@/features/editor/utils/formHelpers";
|
||||
import { ButtonPropertiesPanel } from "./panels/ButtonPropertiesPanel";
|
||||
import { TextPropertiesPanel } from "./panels/TextPropertiesPanel";
|
||||
import { ListPropertiesPanel } from "./panels/ListPropertiesPanel";
|
||||
@@ -143,7 +161,14 @@ export default function EditorPage() {
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
console.error("정적 파일 내보내기 실패", await response.text().catch(() => ""));
|
||||
const text = await response.text().catch(() => "");
|
||||
const truncated = text.length > 500 ? `${text.slice(0, 500)}...` : text;
|
||||
console.error(
|
||||
"정적 파일 내보내기 실패",
|
||||
response.status,
|
||||
response.statusText,
|
||||
truncated,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -949,85 +974,16 @@ function SortableEditorBlock({
|
||||
|
||||
if (block.type === "text") {
|
||||
const textProps = block.props as TextBlockProps;
|
||||
const textTokens = computeTextEditorTokens(textProps);
|
||||
|
||||
// 정렬: pb-text-*
|
||||
alignClass =
|
||||
textProps.align === "center"
|
||||
? "pb-text-center"
|
||||
: textProps.align === "right"
|
||||
? "pb-text-right"
|
||||
: "pb-text-left";
|
||||
|
||||
// 폰트 크기: scale/custom + 기존 size 값 fallback
|
||||
const fontSizeMode = textProps.fontSizeMode ?? "scale";
|
||||
const fallbackScale =
|
||||
textProps.size === "sm" ? "sm" : textProps.size === "lg" ? "lg" : "base";
|
||||
const fontSizeScale = textProps.fontSizeScale ?? fallbackScale;
|
||||
|
||||
// 스케일/커스텀 모드와 무관하게 pb-text-* 스케일 클래스는 항상 유지한다.
|
||||
sizeClass = `pb-text-${fontSizeScale}`;
|
||||
|
||||
// custom 모드에서는 inline 스타일로 실제 폰트 크기를 덮어쓴다.
|
||||
if (fontSizeMode === "custom" && textProps.fontSizeCustom) {
|
||||
textStyleOverrides.fontSize = textProps.fontSizeCustom;
|
||||
}
|
||||
|
||||
// 줄 간격: scale/custom
|
||||
const lineHeightMode = textProps.lineHeightMode ?? "scale";
|
||||
const lineHeightScale = textProps.lineHeightScale ?? "normal";
|
||||
if (lineHeightMode === "scale") {
|
||||
leadingClass = `pb-leading-${lineHeightScale}`;
|
||||
} else if (lineHeightMode === "custom" && textProps.lineHeightCustom) {
|
||||
textStyleOverrides.lineHeight = textProps.lineHeightCustom;
|
||||
}
|
||||
|
||||
// 굵기: scale/custom
|
||||
const fontWeightMode = textProps.fontWeightMode ?? "scale";
|
||||
const fontWeightScale = textProps.fontWeightScale ?? "normal";
|
||||
if (fontWeightMode === "scale") {
|
||||
weightClass = `pb-font-${fontWeightScale}`;
|
||||
} else if (fontWeightMode === "custom" && textProps.fontWeightCustom) {
|
||||
textStyleOverrides.fontWeight = textProps.fontWeightCustom as CSSProperties["fontWeight"];
|
||||
}
|
||||
|
||||
// 색상: colorCustom이 있으면 항상 우선 사용, 없으면 팔레트
|
||||
const colorPalette = textProps.colorPalette ?? "default";
|
||||
if (textProps.colorCustom && textProps.colorCustom.trim() !== "") {
|
||||
textStyleOverrides.color = textProps.colorCustom;
|
||||
} else {
|
||||
colorClass = `pb-text-color-${colorPalette}`;
|
||||
}
|
||||
|
||||
// 블록 배경색: backgroundColorCustom 이 설정된 경우 편집기 텍스트 컨테이너 배경에도 반영한다.
|
||||
if (textProps.backgroundColorCustom && textProps.backgroundColorCustom.trim() !== "") {
|
||||
textStyleOverrides.backgroundColor = textProps.backgroundColorCustom.trim();
|
||||
}
|
||||
|
||||
// 최대 너비: 커스텀 값이 있으면 우선 사용, 없으면 scale 프리셋
|
||||
const maxWidthScale = textProps.maxWidthScale ?? "none";
|
||||
if (textProps.maxWidthCustom && textProps.maxWidthCustom.trim() !== "") {
|
||||
textStyleOverrides.maxWidth = textProps.maxWidthCustom;
|
||||
} else if (maxWidthScale === "prose") {
|
||||
maxWidthClass = "pb-text-maxw-prose";
|
||||
} else if (maxWidthScale === "narrow") {
|
||||
maxWidthClass = "pb-text-maxw-narrow";
|
||||
}
|
||||
|
||||
// 글자 간격: 커스텀 값이 있으면 em 단위 그대로 사용
|
||||
if (textProps.letterSpacingCustom && textProps.letterSpacingCustom.trim() !== "") {
|
||||
textStyleOverrides.letterSpacing = textProps.letterSpacingCustom;
|
||||
}
|
||||
|
||||
// 텍스트 장식: 밑줄/가운데줄/이탤릭
|
||||
if (textProps.underline) {
|
||||
decorationClass += " pb-underline";
|
||||
}
|
||||
if (textProps.strike) {
|
||||
decorationClass += " pb-line-through";
|
||||
}
|
||||
if (textProps.italic) {
|
||||
decorationClass += " pb-italic";
|
||||
}
|
||||
alignClass = textTokens.alignClass;
|
||||
sizeClass = textTokens.sizeClass;
|
||||
leadingClass = textTokens.leadingClass;
|
||||
weightClass = textTokens.weightClass;
|
||||
colorClass = textTokens.colorClass;
|
||||
maxWidthClass = textTokens.maxWidthClass;
|
||||
decorationClass = textTokens.decorationClass;
|
||||
Object.assign(textStyleOverrides, textTokens.styleOverrides);
|
||||
} else if (block.type === "button") {
|
||||
const buttonProps = block.props as ButtonBlockProps;
|
||||
const align = buttonProps.align ?? "left";
|
||||
@@ -1051,7 +1007,12 @@ function SortableEditorBlock({
|
||||
: listProps.align === "right"
|
||||
? "pb-text-right"
|
||||
: "pb-text-left";
|
||||
} else if (block.type === "section") {
|
||||
} else if (block.type === "video") {
|
||||
const videoProps = block.props as VideoBlockProps;
|
||||
const align = videoProps.align ?? "center";
|
||||
alignClass =
|
||||
align === "center" ? "pb-text-center" : align === "right" ? "pb-text-right" : "pb-text-left";
|
||||
} else if (block.type === "section") {
|
||||
// 섹션 블록: 텍스트 크기 대신 섹션 자체에 패딩/배경을 입히므로 텍스트 클래스는 기본값만
|
||||
alignClass = "pb-text-left";
|
||||
} else if (block.type === "form") {
|
||||
@@ -1143,33 +1104,13 @@ function SortableEditorBlock({
|
||||
const inputType = inputProps.inputType ?? "text";
|
||||
|
||||
// 필드 스타일: 텍스트 색상/배경/테두리/모서리 등은 커스텀 props 를 통해 제어한다.
|
||||
const fieldStyle: CSSProperties = {};
|
||||
if (inputProps.textColorCustom && inputProps.textColorCustom.trim() !== "") {
|
||||
fieldStyle.color = inputProps.textColorCustom;
|
||||
}
|
||||
if (inputProps.fillColorCustom && inputProps.fillColorCustom.trim() !== "") {
|
||||
fieldStyle.backgroundColor = inputProps.fillColorCustom;
|
||||
}
|
||||
if (inputProps.strokeColorCustom && inputProps.strokeColorCustom.trim() !== "") {
|
||||
fieldStyle.borderColor = inputProps.strokeColorCustom;
|
||||
}
|
||||
const widthMode = inputProps.widthMode ?? "full";
|
||||
if (widthMode === "fixed" && typeof inputProps.widthPx === "number" && inputProps.widthPx > 0) {
|
||||
fieldStyle.width = `${inputProps.widthPx}px`;
|
||||
}
|
||||
const radius = inputProps.borderRadius ?? "md";
|
||||
if (radius === "none") fieldStyle.borderRadius = 0;
|
||||
else if (radius === "sm") fieldStyle.borderRadius = 4;
|
||||
else if (radius === "lg") fieldStyle.borderRadius = 9999;
|
||||
else if (radius === "full") fieldStyle.borderRadius = 9999;
|
||||
// md 는 기본 tailwind rounded 와 비슷한 값으로 둔다 (별도 스타일 없음이면 클래스에 맡긴다).
|
||||
const inputTokens = computeFormInputEditorTokens(inputProps);
|
||||
const fieldStyle = inputTokens.fieldStyle;
|
||||
|
||||
const labelLayout = inputProps.labelLayout ?? "stacked";
|
||||
const isInline = labelLayout === "inline";
|
||||
|
||||
const align = inputProps.align ?? "left";
|
||||
const inputAlignClass =
|
||||
align === "center" ? "text-center" : align === "right" ? "text-right" : "text-left";
|
||||
const inputAlignClass = inputTokens.inputAlignClass;
|
||||
|
||||
// 에디터에서는 폼 입력 블록이 실제 입력 UI처럼 보이도록 라벨 + 인풋(또는 textarea)을 함께 렌더링한다.
|
||||
return (
|
||||
@@ -1186,24 +1127,7 @@ function SortableEditorBlock({
|
||||
)}
|
||||
{(() => {
|
||||
// widthMode 와 labelLayout 에 따라 wrapper 의 Tailwind width/flex 클래스를 결정한다.
|
||||
let widthClass = "w-full";
|
||||
if (isInline) {
|
||||
if (widthMode === "fixed") {
|
||||
// 고정 너비일 때는 flex 레이아웃의 너비 확장을 막기 위해 flex-none 으로 둔다.
|
||||
widthClass = "flex-none";
|
||||
} else if (widthMode === "full") {
|
||||
widthClass = "flex-1";
|
||||
} else {
|
||||
// auto: 인라인 레이아웃에서 내용 기반 너비 사용
|
||||
widthClass = "";
|
||||
}
|
||||
} else {
|
||||
if (widthMode === "fixed") {
|
||||
widthClass = ""; // 고정 너비는 style.width 로만 제어
|
||||
} else {
|
||||
widthClass = "w-full";
|
||||
}
|
||||
}
|
||||
const widthClass = inputTokens.widthClass;
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -1242,28 +1166,8 @@ function SortableEditorBlock({
|
||||
{ label: "옵션 2", value: "option_2" },
|
||||
];
|
||||
|
||||
const fieldStyle: CSSProperties = {};
|
||||
if (selectProps.textColorCustom && selectProps.textColorCustom.trim() !== "") {
|
||||
fieldStyle.color = selectProps.textColorCustom;
|
||||
}
|
||||
if (selectProps.fillColorCustom && selectProps.fillColorCustom.trim() !== "") {
|
||||
fieldStyle.backgroundColor = selectProps.fillColorCustom;
|
||||
}
|
||||
if (selectProps.strokeColorCustom && selectProps.strokeColorCustom.trim() !== "") {
|
||||
fieldStyle.borderColor = selectProps.strokeColorCustom;
|
||||
}
|
||||
const selectWidthMode = selectProps.widthMode ?? "full";
|
||||
if (
|
||||
selectWidthMode === "fixed" &&
|
||||
typeof selectProps.widthPx === "number" &&
|
||||
selectProps.widthPx > 0
|
||||
) {
|
||||
fieldStyle.width = `${selectProps.widthPx}px`;
|
||||
}
|
||||
const selectRadius = selectProps.borderRadius ?? "md";
|
||||
if (selectRadius === "none") fieldStyle.borderRadius = 0;
|
||||
else if (selectRadius === "sm") fieldStyle.borderRadius = 4;
|
||||
else if (selectRadius === "lg" || selectRadius === "full") fieldStyle.borderRadius = 9999;
|
||||
const selectTokens = computeFormSelectEditorTokens(selectProps);
|
||||
const fieldStyle = selectTokens.fieldStyle;
|
||||
|
||||
// 에디터에서는 폼 셀렉트 블록이 라벨 + 셀렉트 박스로 보이도록 렌더링한다.
|
||||
return (
|
||||
@@ -1308,28 +1212,8 @@ function SortableEditorBlock({
|
||||
|
||||
const groupLabelMode = radioProps.groupLabelMode ?? "text";
|
||||
|
||||
const fieldStyle: CSSProperties = {};
|
||||
if (radioProps.textColorCustom && radioProps.textColorCustom.trim() !== "") {
|
||||
fieldStyle.color = radioProps.textColorCustom;
|
||||
}
|
||||
if (radioProps.fillColorCustom && radioProps.fillColorCustom.trim() !== "") {
|
||||
fieldStyle.backgroundColor = radioProps.fillColorCustom;
|
||||
}
|
||||
if (radioProps.strokeColorCustom && radioProps.strokeColorCustom.trim() !== "") {
|
||||
fieldStyle.borderColor = radioProps.strokeColorCustom;
|
||||
}
|
||||
const radioWidthMode = radioProps.widthMode ?? "full";
|
||||
if (
|
||||
radioWidthMode === "fixed" &&
|
||||
typeof radioProps.widthPx === "number" &&
|
||||
radioProps.widthPx > 0
|
||||
) {
|
||||
fieldStyle.width = `${radioProps.widthPx}px`;
|
||||
}
|
||||
const radioRadius = radioProps.borderRadius ?? "md";
|
||||
if (radioRadius === "none") fieldStyle.borderRadius = 0;
|
||||
else if (radioRadius === "sm") fieldStyle.borderRadius = 4;
|
||||
else if (radioRadius === "lg" || radioRadius === "full") fieldStyle.borderRadius = 9999;
|
||||
const radioTokens = computeFormOptionGroupEditorTokens(radioProps);
|
||||
const fieldStyle = radioTokens.fieldStyle;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-1 text-xs">
|
||||
@@ -1385,28 +1269,8 @@ function SortableEditorBlock({
|
||||
|
||||
const groupLabelMode = checkboxProps.groupLabelMode ?? "text";
|
||||
|
||||
const fieldStyle: CSSProperties = {};
|
||||
if (checkboxProps.textColorCustom && checkboxProps.textColorCustom.trim() !== "") {
|
||||
fieldStyle.color = checkboxProps.textColorCustom;
|
||||
}
|
||||
if (checkboxProps.fillColorCustom && checkboxProps.fillColorCustom.trim() !== "") {
|
||||
fieldStyle.backgroundColor = checkboxProps.fillColorCustom;
|
||||
}
|
||||
if (checkboxProps.strokeColorCustom && checkboxProps.strokeColorCustom.trim() !== "") {
|
||||
fieldStyle.borderColor = checkboxProps.strokeColorCustom;
|
||||
}
|
||||
const checkboxWidthMode = checkboxProps.widthMode ?? "full";
|
||||
if (
|
||||
checkboxWidthMode === "fixed" &&
|
||||
typeof checkboxProps.widthPx === "number" &&
|
||||
checkboxProps.widthPx > 0
|
||||
) {
|
||||
fieldStyle.width = `${checkboxProps.widthPx}px`;
|
||||
}
|
||||
const checkboxRadius = checkboxProps.borderRadius ?? "md";
|
||||
if (checkboxRadius === "none") fieldStyle.borderRadius = 0;
|
||||
else if (checkboxRadius === "sm") fieldStyle.borderRadius = 4;
|
||||
else if (checkboxRadius === "lg" || checkboxRadius === "full") fieldStyle.borderRadius = 9999;
|
||||
const checkboxTokens = computeFormOptionGroupEditorTokens(checkboxProps);
|
||||
const fieldStyle = checkboxTokens.fieldStyle;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-1 text-xs text-slate-200" style={fieldStyle}>
|
||||
@@ -1449,71 +1313,31 @@ function SortableEditorBlock({
|
||||
})()}
|
||||
{block.type === "button" && (() => {
|
||||
const buttonProps = block.props as ButtonBlockProps;
|
||||
const size = buttonProps.size ?? "md";
|
||||
const variant = buttonProps.variant ?? "solid";
|
||||
const colorPalette = buttonProps.colorPalette ?? "primary";
|
||||
const radius = buttonProps.borderRadius ?? "md";
|
||||
const baseFullWidth = buttonProps.fullWidth ?? false;
|
||||
const widthMode = buttonProps.widthMode ?? (baseFullWidth ? "full" : "auto");
|
||||
const isFullWidth = widthMode === "full" || baseFullWidth;
|
||||
|
||||
const sizeClassBtn =
|
||||
size === "xs"
|
||||
? "pb-btn-size-xs"
|
||||
: size === "sm"
|
||||
? "pb-btn-size-sm"
|
||||
: size === "lg"
|
||||
? "pb-btn-size-lg"
|
||||
: size === "xl"
|
||||
? "pb-btn-size-xl"
|
||||
: "pb-btn-size-md";
|
||||
const radiusClassBtn =
|
||||
radius === "none"
|
||||
? "pb-btn-radius-none"
|
||||
: radius === "sm"
|
||||
? "pb-btn-radius-sm"
|
||||
: radius === "lg"
|
||||
? "pb-btn-radius-lg"
|
||||
: radius === "full"
|
||||
? "pb-btn-radius-full"
|
||||
: "pb-btn-radius-md";
|
||||
const variantClassBtn = `pb-btn-variant-${variant}-${colorPalette}`;
|
||||
const pbTokens = computeButtonPbTokens({
|
||||
align: buttonProps.align ?? "left",
|
||||
size: buttonProps.size ?? "md",
|
||||
variant: buttonProps.variant ?? "solid",
|
||||
colorPalette: buttonProps.colorPalette ?? "primary",
|
||||
borderRadius: buttonProps.borderRadius ?? "md",
|
||||
fullWidth: buttonProps.fullWidth ?? false,
|
||||
widthMode: buttonProps.widthMode ?? undefined,
|
||||
widthPx: typeof buttonProps.widthPx === "number" ? buttonProps.widthPx : undefined,
|
||||
paddingX: typeof buttonProps.paddingX === "number" ? buttonProps.paddingX : undefined,
|
||||
paddingY: typeof buttonProps.paddingY === "number" ? buttonProps.paddingY : undefined,
|
||||
fillColorCustom: buttonProps.fillColorCustom ?? undefined,
|
||||
strokeColorCustom: buttonProps.strokeColorCustom ?? undefined,
|
||||
textColorCustom: buttonProps.textColorCustom ?? undefined,
|
||||
});
|
||||
|
||||
const buttonStyle: CSSProperties = {};
|
||||
if (buttonProps.fontSizeCustom && buttonProps.fontSizeCustom.trim() !== "") {
|
||||
buttonStyle.fontSize = buttonProps.fontSizeCustom;
|
||||
}
|
||||
if (buttonProps.lineHeightCustom && buttonProps.lineHeightCustom.trim() !== "") {
|
||||
buttonStyle.lineHeight = buttonProps.lineHeightCustom;
|
||||
}
|
||||
if (buttonProps.letterSpacingCustom && buttonProps.letterSpacingCustom.trim() !== "") {
|
||||
buttonStyle.letterSpacing = buttonProps.letterSpacingCustom;
|
||||
}
|
||||
if (buttonProps.fillColorCustom && buttonProps.fillColorCustom.trim() !== "") {
|
||||
buttonStyle.backgroundColor = buttonProps.fillColorCustom;
|
||||
}
|
||||
if (buttonProps.strokeColorCustom && buttonProps.strokeColorCustom.trim() !== "") {
|
||||
buttonStyle.borderColor = buttonProps.strokeColorCustom;
|
||||
}
|
||||
if (buttonProps.textColorCustom && buttonProps.textColorCustom.trim() !== "") {
|
||||
buttonStyle.color = buttonProps.textColorCustom;
|
||||
}
|
||||
if (widthMode === "fixed" && typeof buttonProps.widthPx === "number" && buttonProps.widthPx > 0) {
|
||||
buttonStyle.width = `${buttonProps.widthPx}px`;
|
||||
}
|
||||
if (typeof buttonProps.paddingX === "number" && buttonProps.paddingX >= 0) {
|
||||
buttonStyle.paddingInline = `${buttonProps.paddingX}px`;
|
||||
}
|
||||
if (typeof buttonProps.paddingY === "number" && buttonProps.paddingY >= 0) {
|
||||
buttonStyle.paddingBlock = `${buttonProps.paddingY}px`;
|
||||
}
|
||||
const editorTokens = computeButtonEditorTokens(buttonProps);
|
||||
|
||||
return (
|
||||
<div className={isFullWidth ? "w-full" : "inline-block"}>
|
||||
<div className={editorTokens.wrapperWidthClass}>
|
||||
<button
|
||||
type="button"
|
||||
className={`pb-btn-base ${sizeClassBtn} ${radiusClassBtn} ${variantClassBtn} w-full`}
|
||||
style={buttonStyle}
|
||||
className={`pb-btn-base ${pbTokens.sizeClass} ${pbTokens.radiusClass} ${pbTokens.variantClass} w-full`}
|
||||
style={editorTokens.buttonStyle}
|
||||
aria-label={buttonProps.label}
|
||||
onClick={(e) => {
|
||||
// 에디터 내 버튼 클릭은 선택만 유지하고 실제 이동은 막는다.
|
||||
@@ -1525,39 +1349,110 @@ function SortableEditorBlock({
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
{block.type === "video" && (() => {
|
||||
const videoProps = block.props as VideoBlockProps;
|
||||
const normalizedUrl = normalizeVideoSourceUrl(videoProps.sourceUrl ?? "");
|
||||
const platform = resolveVideoPlatform(normalizedUrl, videoProps.platform ?? "auto");
|
||||
const embedUrl = buildVideoEmbedUrl(normalizedUrl, platform, { enableVimeoEmbed: false });
|
||||
const tokens = computeVideoEditorTokens(videoProps);
|
||||
|
||||
const startTimeSec =
|
||||
typeof videoProps.startTimeSec === "number" && videoProps.startTimeSec >= 0
|
||||
? videoProps.startTimeSec
|
||||
: null;
|
||||
const endTimeSec =
|
||||
typeof videoProps.endTimeSec === "number" && videoProps.endTimeSec >= 0
|
||||
? videoProps.endTimeSec
|
||||
: null;
|
||||
const isEmbed = platform === "youtube" || platform === "vimeo";
|
||||
|
||||
return (
|
||||
<div className={`w-full flex ${tokens.justifyClass}`}>
|
||||
<div>
|
||||
<div className={`pb-video-wrapper${tokens.aspectClass}`} style={tokens.wrapperStyle}>
|
||||
{isEmbed ? (
|
||||
<iframe
|
||||
title={videoProps.titleText && videoProps.titleText.trim() !== "" ? videoProps.titleText.trim() : "비디오"}
|
||||
src={embedUrl}
|
||||
className="pb-video-frame"
|
||||
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
|
||||
allowFullScreen
|
||||
/>
|
||||
) : (
|
||||
<video
|
||||
data-testid="editor-video"
|
||||
className="pb-video-frame"
|
||||
src={normalizedUrl !== "" ? normalizedUrl : undefined}
|
||||
style={tokens.videoStyle}
|
||||
poster={videoProps.posterImageSrc && videoProps.posterImageSrc.trim() !== "" ? videoProps.posterImageSrc.trim() : undefined}
|
||||
aria-label={videoProps.ariaLabel && videoProps.ariaLabel.trim() !== "" ? videoProps.ariaLabel.trim() : undefined}
|
||||
onLoadedMetadata={(event) => {
|
||||
if (startTimeSec != null) {
|
||||
const el = event.currentTarget;
|
||||
try {
|
||||
el.currentTime = startTimeSec;
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
}}
|
||||
onTimeUpdate={(event) => {
|
||||
if (endTimeSec != null) {
|
||||
const el = event.currentTarget;
|
||||
if (el.currentTime >= endTimeSec) {
|
||||
el.pause();
|
||||
try {
|
||||
if (!Number.isNaN(endTimeSec)) {
|
||||
el.currentTime = endTimeSec;
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
}
|
||||
}}
|
||||
controls={videoProps.controls !== false}
|
||||
autoPlay={!!videoProps.autoplay}
|
||||
loop={!!videoProps.loop}
|
||||
muted={!!videoProps.muted}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{videoProps.captionText && videoProps.captionText.trim() !== "" && (
|
||||
<p
|
||||
data-testid="editor-video-caption"
|
||||
className="mt-2 text-xs text-slate-400 hidden"
|
||||
>
|
||||
{videoProps.captionText}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
{block.type === "image" && (() => {
|
||||
const imageProps = block.props as ImageBlockProps;
|
||||
const hasSrc = imageProps.src.trim().length > 0;
|
||||
const align = imageProps.align ?? "center";
|
||||
const alignClass =
|
||||
align === "left" ? "justify-start" : align === "right" ? "justify-end" : "justify-center";
|
||||
const tokens = computeImageEditorTokens({
|
||||
widthMode: imageProps.widthMode,
|
||||
widthPx: imageProps.widthPx,
|
||||
borderRadius: imageProps.borderRadius,
|
||||
borderRadiusPx: imageProps.borderRadiusPx,
|
||||
});
|
||||
|
||||
const containerStyle: React.CSSProperties = {};
|
||||
const widthMode = imageProps.widthMode ?? "auto";
|
||||
if (widthMode === "fixed" && typeof imageProps.widthPx === "number" && imageProps.widthPx > 0) {
|
||||
containerStyle.width = `${imageProps.widthPx}px`;
|
||||
if (typeof tokens.widthPx === "number") {
|
||||
containerStyle.width = `${tokens.widthPx}px`;
|
||||
}
|
||||
|
||||
const imageStyle: React.CSSProperties = {
|
||||
maxWidth: "100%",
|
||||
height: "auto",
|
||||
borderRadius: `${tokens.radiusPx}px`,
|
||||
};
|
||||
const radiusToken = imageProps.borderRadius ?? "md";
|
||||
const fallbackRadiusPx =
|
||||
radiusToken === "none"
|
||||
? 0
|
||||
: radiusToken === "sm"
|
||||
? 4
|
||||
: radiusToken === "lg"
|
||||
? 16
|
||||
: radiusToken === "full"
|
||||
? 9999
|
||||
: 8;
|
||||
const radiusPx =
|
||||
typeof imageProps.borderRadiusPx === "number" && imageProps.borderRadiusPx >= 0
|
||||
? imageProps.borderRadiusPx
|
||||
: fallbackRadiusPx;
|
||||
imageStyle.borderRadius = `${radiusPx}px`;
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -1581,51 +1476,35 @@ function SortableEditorBlock({
|
||||
})()}
|
||||
{block.type === "divider" && (() => {
|
||||
const dividerProps = block.props as DividerBlockProps;
|
||||
const thicknessClass = dividerProps.thickness === "medium" ? "border-t-2" : "border-t";
|
||||
const tokens = computeDividerEditorTokens(dividerProps);
|
||||
|
||||
// 색상: colorHex 가 있으면 사용, 없으면 기본 슬레이트 색상
|
||||
const borderColor =
|
||||
dividerProps.colorHex && dividerProps.colorHex.trim() !== ""
|
||||
? dividerProps.colorHex
|
||||
: "#475569";
|
||||
const borderColor = tokens.borderColor;
|
||||
|
||||
// 길이/너비: widthMode/widthPx 에 따라 가로 길이를 조정
|
||||
const widthMode = dividerProps.widthMode ?? "full";
|
||||
const widthPx = tokens.widthPx;
|
||||
|
||||
// 위/아래 여백: marginYPx 가 있으면 px 값 그대로 사용, 없으면 토큰 기반 기본값
|
||||
const margin = tokens.marginPx;
|
||||
|
||||
const innerStyle: React.CSSProperties = {
|
||||
borderColor,
|
||||
};
|
||||
let innerWidthClass = "w-full";
|
||||
|
||||
if (widthMode === "auto") {
|
||||
innerWidthClass = "w-1/2";
|
||||
} else if (widthMode === "fixed") {
|
||||
innerWidthClass = "";
|
||||
if (typeof dividerProps.widthPx === "number" && dividerProps.widthPx > 0) {
|
||||
innerStyle.width = `${dividerProps.widthPx}px`;
|
||||
} else {
|
||||
innerStyle.width = "320px";
|
||||
}
|
||||
if (typeof widthPx === "number") {
|
||||
innerStyle.width = `${widthPx}px`;
|
||||
}
|
||||
|
||||
// 위/아래 여백: marginYPx 가 있으면 px 값 그대로 사용, 없으면 토큰 기반 기본값
|
||||
const margin =
|
||||
typeof dividerProps.marginYPx === "number"
|
||||
? dividerProps.marginYPx
|
||||
: dividerProps.marginY === "sm"
|
||||
? 8
|
||||
: dividerProps.marginY === "lg"
|
||||
? 24
|
||||
: 16;
|
||||
|
||||
return (
|
||||
<div className="w-full" style={{ marginTop: margin, marginBottom: margin }}>
|
||||
<div className={`${thicknessClass} ${innerWidthClass} border-t`} style={innerStyle} />
|
||||
<div
|
||||
className={`${tokens.thicknessClass} ${tokens.innerWidthClass} border-t`}
|
||||
style={innerStyle}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
{block.type === "list" && (() => {
|
||||
const listProps = block.props as ListBlockProps;
|
||||
const bulletStyleRaw = listProps.bulletStyle ?? (listProps.ordered ? "decimal" : "disc");
|
||||
const alignClass =
|
||||
listProps.align === "center"
|
||||
? "text-center"
|
||||
@@ -1633,30 +1512,16 @@ function SortableEditorBlock({
|
||||
? "text-right"
|
||||
: "text-left";
|
||||
|
||||
const tokens = computeListEditorTokens(listProps);
|
||||
|
||||
// 줄 간 간격: gapYPx 숫자 값을 우선 사용하고, 없으면 gapY 토큰을 space-y 클래스로 매핑
|
||||
const gapPx =
|
||||
typeof listProps.gapYPx === "number"
|
||||
? listProps.gapYPx
|
||||
: listProps.gapY === "sm"
|
||||
? 4
|
||||
: listProps.gapY === "lg"
|
||||
? 16
|
||||
: 8;
|
||||
const gapPx = tokens.gapPx;
|
||||
|
||||
// 타이포/색상 스타일
|
||||
const listStyle: React.CSSProperties = {};
|
||||
if (listProps.fontSizeCustom && listProps.fontSizeCustom.trim() !== "") {
|
||||
listStyle.fontSize = listProps.fontSizeCustom;
|
||||
}
|
||||
if (listProps.lineHeightCustom && listProps.lineHeightCustom.trim() !== "") {
|
||||
listStyle.lineHeight = listProps.lineHeightCustom;
|
||||
}
|
||||
if (listProps.textColorCustom && listProps.textColorCustom.trim() !== "") {
|
||||
listStyle.color = listProps.textColorCustom;
|
||||
}
|
||||
const listStyle = tokens.listStyle;
|
||||
|
||||
// 불릿 스타일 (none 인 경우만 제거, decimal 은 숫자형으로 매핑)
|
||||
const bulletStyle = bulletStyleRaw;
|
||||
const bulletStyle = tokens.bulletStyle;
|
||||
|
||||
const itemsTree: ListItemNode[] =
|
||||
(listProps.itemsTree as ListItemNode[] | undefined) && listProps.itemsTree!.length > 0
|
||||
@@ -1770,66 +1635,38 @@ function SortableEditorBlock({
|
||||
allBlocks.some(
|
||||
(candidate) => candidate.id === selectedBlockId && candidate.sectionId === block.id,
|
||||
));
|
||||
|
||||
const bgClass =
|
||||
sectionProps.background === "muted"
|
||||
? "bg-slate-950/40"
|
||||
: sectionProps.background === "primary"
|
||||
? "bg-sky-950/40 border-sky-900/60"
|
||||
: "bg-slate-900/60";
|
||||
|
||||
const pyClass =
|
||||
sectionProps.paddingY === "sm"
|
||||
? "py-4"
|
||||
: sectionProps.paddingY === "lg"
|
||||
? "py-10"
|
||||
: "py-6";
|
||||
|
||||
const alignItemsClass =
|
||||
sectionProps.alignItems === "center"
|
||||
? "items-center"
|
||||
: sectionProps.alignItems === "bottom"
|
||||
? "items-end"
|
||||
: "items-start";
|
||||
const sectionTokens = computeSectionEditorTokens(sectionProps);
|
||||
|
||||
const columns = sectionProps.columns && sectionProps.columns.length > 0
|
||||
? sectionProps.columns
|
||||
: [{ id: `${block.id}_col_fallback`, span: 12 }];
|
||||
|
||||
const sectionStyle: CSSProperties = {};
|
||||
if (sectionProps.backgroundColorCustom && sectionProps.backgroundColorCustom.trim() !== "") {
|
||||
sectionStyle.backgroundColor = sectionProps.backgroundColorCustom;
|
||||
}
|
||||
if (typeof sectionProps.paddingYPx === "number" && sectionProps.paddingYPx > 0) {
|
||||
sectionStyle.paddingTop = `${sectionProps.paddingYPx}px`;
|
||||
sectionStyle.paddingBottom = `${sectionProps.paddingYPx}px`;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
data-testid="editor-section"
|
||||
className={`w-full ${bgClass} ${pyClass} rounded border ${
|
||||
className={`w-full ${sectionTokens.bgClass} ${sectionTokens.pyClass} rounded border ${
|
||||
isSectionSelected ? "border-sky-500" : "border-dashed border-slate-700"
|
||||
}`}
|
||||
style={sectionStyle}
|
||||
style={sectionTokens.wrapperStyle}
|
||||
>
|
||||
{sectionTokens.hasBackgroundVideo && (
|
||||
<video
|
||||
data-testid="editor-section-bg-video"
|
||||
className="absolute inset-0 w-full h-full object-cover"
|
||||
src={sectionTokens.backgroundVideoSrc!}
|
||||
autoPlay
|
||||
loop
|
||||
muted
|
||||
playsInline
|
||||
/>
|
||||
)}
|
||||
<div
|
||||
className="mx-auto px-4"
|
||||
style={{
|
||||
maxWidth:
|
||||
typeof sectionProps.maxWidthPx === "number" && sectionProps.maxWidthPx > 0
|
||||
? `${sectionProps.maxWidthPx}px`
|
||||
: undefined,
|
||||
}}
|
||||
style={sectionTokens.innerWrapperStyle}
|
||||
>
|
||||
<div
|
||||
className={`flex ${alignItemsClass}`}
|
||||
style={{
|
||||
columnGap:
|
||||
typeof sectionProps.gapXPx === "number" && sectionProps.gapXPx > 0
|
||||
? `${sectionProps.gapXPx}px`
|
||||
: undefined,
|
||||
}}
|
||||
className={`flex ${sectionTokens.alignItemsClass}`}
|
||||
style={sectionTokens.columnsContainerStyle}
|
||||
>
|
||||
{columns.map((col) => {
|
||||
const basis = `${(col.span / 12) * 100}%`;
|
||||
|
||||
@@ -8,6 +8,7 @@ export function BlocksSidebar() {
|
||||
const addTextBlock = useEditorStore((state) => state.addTextBlock);
|
||||
const addButtonBlock = useEditorStore((state) => state.addButtonBlock);
|
||||
const addImageBlock = useEditorStore((state) => state.addImageBlock);
|
||||
const addVideoBlock = useEditorStore((state) => (state as any).addVideoBlock);
|
||||
const addDividerBlock = useEditorStore((state) => state.addDividerBlock);
|
||||
const addListBlock = useEditorStore((state) => state.addListBlock);
|
||||
const addSectionBlock = useEditorStore((state) => state.addSectionBlock);
|
||||
@@ -32,6 +33,7 @@ export function BlocksSidebar() {
|
||||
const handleAddText = useCallback(() => addTextBlock(), [addTextBlock]);
|
||||
const handleAddButton = useCallback(() => addButtonBlock(), [addButtonBlock]);
|
||||
const handleAddImage = useCallback(() => addImageBlock(), [addImageBlock]);
|
||||
const handleAddVideo = useCallback(() => addVideoBlock(), [addVideoBlock]);
|
||||
const handleAddDivider = useCallback(() => addDividerBlock(), [addDividerBlock]);
|
||||
const handleAddList = useCallback(() => addListBlock(), [addListBlock]);
|
||||
const handleAddSection = useCallback(() => addSectionBlock(), [addSectionBlock]);
|
||||
@@ -91,6 +93,13 @@ export function BlocksSidebar() {
|
||||
>
|
||||
이미지 블록 추가
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="w-full rounded border border-slate-700 bg-slate-900 px-3 py-2 text-left text-xs hover:bg-slate-800"
|
||||
onClick={handleAddVideo}
|
||||
>
|
||||
비디오 블록 추가
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="w-full rounded border border-slate-700 bg-slate-900 px-3 py-2 text-left text-xs hover:bg-slate-800"
|
||||
@@ -169,9 +178,13 @@ export function BlocksSidebar() {
|
||||
</button>
|
||||
<div
|
||||
data-testid="template-preview-hero"
|
||||
className="shrink-0 flex h-6 w-10 items-stretch gap-[2px] rounded border border-slate-700 bg-slate-900"
|
||||
className="shrink-0 flex h-6 w-10 rounded border border-slate-700 bg-slate-900 p-[2px]"
|
||||
>
|
||||
<div className="flex-1 bg-slate-700/70" />
|
||||
<div className="flex h-full w-full flex-col items-center justify-center gap-[2px]">
|
||||
<div className="h-[2px] w-3/4 bg-slate-700/70" />
|
||||
<div className="h-[2px] w-1/2 bg-slate-700/50" />
|
||||
<div className="h-2 w-4 rounded-[1px] bg-slate-700/70 mt-[1px]" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-[10px] text-slate-400 leading-snug">
|
||||
@@ -190,9 +203,15 @@ export function BlocksSidebar() {
|
||||
</button>
|
||||
<div
|
||||
data-testid="template-preview-cta"
|
||||
className="shrink-0 flex h-6 w-10 items-center justify-center gap-[2px] rounded border border-slate-700 bg-slate-900"
|
||||
className="shrink-0 flex h-6 w-10 rounded border border-slate-700 bg-slate-900 p-[2px]"
|
||||
>
|
||||
<div className="h-2 w-6 rounded bg-slate-700/70" />
|
||||
<div className="flex h-full w-full items-center gap-[2px]">
|
||||
<div className="flex-1 flex flex-col justify-center gap-[1px]">
|
||||
<div className="h-[2px] w-full bg-slate-700/70" />
|
||||
<div className="h-[2px] w-2/3 bg-slate-700/50" />
|
||||
</div>
|
||||
<div className="w-3 h-2 rounded-[1px] bg-slate-700/70" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-[10px] text-slate-400 leading-snug">콜투액션(CTA) 섹션</p>
|
||||
@@ -214,11 +233,20 @@ export function BlocksSidebar() {
|
||||
</button>
|
||||
<div
|
||||
data-testid="template-preview-features"
|
||||
className="shrink-0 flex h-6 w-10 items-stretch gap-[2px] rounded border border-slate-700 bg-slate-900"
|
||||
className="shrink-0 flex h-6 w-10 items-stretch gap-[2px] rounded border border-slate-700 bg-slate-900 p-[2px]"
|
||||
>
|
||||
<div className="flex-1 bg-slate-700/70" />
|
||||
<div className="flex-1 bg-slate-700/50" />
|
||||
<div className="flex-1 bg-slate-700/70" />
|
||||
<div className="flex-1 flex flex-col gap-[1px]">
|
||||
<div className="h-[2px] w-full bg-slate-700/70" />
|
||||
<div className="h-[2px] w-full bg-slate-700/50" />
|
||||
</div>
|
||||
<div className="flex-1 flex flex-col gap-[1px]">
|
||||
<div className="h-[2px] w-full bg-slate-700/70" />
|
||||
<div className="h-[2px] w-full bg-slate-700/50" />
|
||||
</div>
|
||||
<div className="flex-1 flex flex-col gap-[1px]">
|
||||
<div className="h-[2px] w-full bg-slate-700/70" />
|
||||
<div className="h-[2px] w-full bg-slate-700/50" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-[10px] text-slate-400 leading-snug">3컬럼 기능 소개 섹션</p>
|
||||
@@ -235,11 +263,16 @@ export function BlocksSidebar() {
|
||||
</button>
|
||||
<div
|
||||
data-testid="template-preview-faq"
|
||||
className="shrink-0 flex h-6 w-10 flex-col justify-center gap-[2px] rounded border border-slate-700 bg-slate-900"
|
||||
className="shrink-0 flex h-6 w-10 items-start gap-[2px] rounded border border-slate-700 bg-slate-900 p-[2px]"
|
||||
>
|
||||
<div className="h-[2px] w-full bg-slate-700/70" />
|
||||
<div className="h-[2px] w-full bg-slate-700/40" />
|
||||
<div className="h-[2px] w-full bg-slate-700/70" />
|
||||
<div className="w-2 flex flex-col gap-[1px]">
|
||||
<div className="h-[2px] w-full bg-slate-700/70" />
|
||||
</div>
|
||||
<div className="flex-1 flex flex-col gap-[2px]">
|
||||
<div className="h-[1px] w-full bg-slate-700/50" />
|
||||
<div className="h-[1px] w-full bg-slate-700/50" />
|
||||
<div className="h-[1px] w-full bg-slate-700/50" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-[10px] text-slate-400 leading-snug">자주 묻는 질문(FAQ) 섹션</p>
|
||||
@@ -256,11 +289,21 @@ export function BlocksSidebar() {
|
||||
</button>
|
||||
<div
|
||||
data-testid="template-preview-pricing"
|
||||
className="shrink-0 flex h-6 w-10 items-end gap-[2px] rounded border border-slate-700 bg-slate-900"
|
||||
className="shrink-0 flex h-6 w-10 items-stretch gap-[2px] rounded border border-slate-700 bg-slate-900 p-[2px]"
|
||||
>
|
||||
<div className="h-2 flex-1 bg-slate-700/40" />
|
||||
<div className="h-4 flex-1 bg-slate-700/70" />
|
||||
<div className="h-3 flex-1 bg-slate-700/50" />
|
||||
<div className="flex-1 flex flex-col items-center justify-end gap-[1px] border border-slate-700/30 rounded-[1px]">
|
||||
<div className="h-[1px] w-1/2 bg-slate-700/70" />
|
||||
<div className="h-[1px] w-3/4 bg-slate-700/50" />
|
||||
</div>
|
||||
<div className="flex-1 flex flex-col items-center justify-end gap-[1px] border border-sky-700/50 bg-sky-900/20 rounded-[1px]">
|
||||
<div className="h-[1px] w-1/2 bg-sky-500/70" />
|
||||
<div className="h-[1px] w-3/4 bg-sky-500/50" />
|
||||
<div className="h-[2px] w-3/4 bg-sky-500/70 mt-[1px] rounded-[1px]" />
|
||||
</div>
|
||||
<div className="flex-1 flex flex-col items-center justify-end gap-[1px] border border-slate-700/30 rounded-[1px]">
|
||||
<div className="h-[1px] w-1/2 bg-slate-700/70" />
|
||||
<div className="h-[1px] w-3/4 bg-slate-700/50" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-[10px] text-slate-400 leading-snug">요금제/플랜 소개 섹션</p>
|
||||
@@ -277,11 +320,23 @@ export function BlocksSidebar() {
|
||||
</button>
|
||||
<div
|
||||
data-testid="template-preview-blog"
|
||||
className="shrink-0 flex h-6 w-10 items-stretch gap-[2px] rounded border border-slate-700 bg-slate-900"
|
||||
className="shrink-0 flex h-6 w-10 items-stretch gap-[2px] rounded border border-slate-700 bg-slate-900 p-[2px]"
|
||||
>
|
||||
<div className="flex-1 bg-slate-700/70" />
|
||||
<div className="flex-1 bg-slate-700/50" />
|
||||
<div className="flex-1 bg-slate-700/70" />
|
||||
<div className="flex-1 flex flex-col gap-[1px]">
|
||||
<div className="h-2 w-full bg-slate-600/50 rounded-[1px]" />
|
||||
<div className="h-[1px] w-full bg-slate-700/70" />
|
||||
<div className="h-[1px] w-2/3 bg-slate-700/50" />
|
||||
</div>
|
||||
<div className="flex-1 flex flex-col gap-[1px]">
|
||||
<div className="h-2 w-full bg-slate-600/50 rounded-[1px]" />
|
||||
<div className="h-[1px] w-full bg-slate-700/70" />
|
||||
<div className="h-[1px] w-2/3 bg-slate-700/50" />
|
||||
</div>
|
||||
<div className="flex-1 flex flex-col gap-[1px]">
|
||||
<div className="h-2 w-full bg-slate-600/50 rounded-[1px]" />
|
||||
<div className="h-[1px] w-full bg-slate-700/70" />
|
||||
<div className="h-[1px] w-2/3 bg-slate-700/50" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-[10px] text-slate-400 leading-snug">블로그 포스트 목록 섹션</p>
|
||||
@@ -303,10 +358,20 @@ export function BlocksSidebar() {
|
||||
</button>
|
||||
<div
|
||||
data-testid="template-preview-testimonials"
|
||||
className="shrink-0 flex h-6 w-10 flex-col justify-center gap-[2px] rounded border border-slate-700 bg-slate-900"
|
||||
className="shrink-0 flex h-6 w-10 items-stretch gap-[2px] rounded border border-slate-700 bg-slate-900 p-[2px]"
|
||||
>
|
||||
<div className="h-[2px] w-full bg-slate-700/70" />
|
||||
<div className="h-[2px] w-3/4 bg-slate-700/50" />
|
||||
<div className="flex-1 flex flex-col justify-between border border-slate-700/30 rounded-[1px] p-[1px]">
|
||||
<div className="h-[1px] w-full bg-slate-700/50" />
|
||||
<div className="h-[1px] w-1/2 bg-slate-700/70" />
|
||||
</div>
|
||||
<div className="flex-1 flex flex-col justify-between border border-slate-700/30 rounded-[1px] p-[1px]">
|
||||
<div className="h-[1px] w-full bg-slate-700/50" />
|
||||
<div className="h-[1px] w-1/2 bg-slate-700/70" />
|
||||
</div>
|
||||
<div className="flex-1 flex flex-col justify-between border border-slate-700/30 rounded-[1px] p-[1px]">
|
||||
<div className="h-[1px] w-full bg-slate-700/50" />
|
||||
<div className="h-[1px] w-1/2 bg-slate-700/70" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-[10px] text-slate-400 leading-snug">고객 후기(Testimonials) 섹션</p>
|
||||
@@ -323,11 +388,23 @@ export function BlocksSidebar() {
|
||||
</button>
|
||||
<div
|
||||
data-testid="template-preview-team"
|
||||
className="shrink-0 flex h-6 w-10 items-end gap-[2px] rounded border border-slate-700 bg-slate-900"
|
||||
className="shrink-0 flex h-6 w-10 items-stretch gap-[2px] rounded border border-slate-700 bg-slate-900 p-[2px]"
|
||||
>
|
||||
<div className="h-3 flex-1 bg-slate-700/60" />
|
||||
<div className="h-4 flex-1 bg-slate-700/80" />
|
||||
<div className="h-2 flex-1 bg-slate-700/50" />
|
||||
<div className="flex-1 flex flex-col items-center gap-[1px]">
|
||||
<div className="h-2 w-2 rounded-full bg-slate-600/80" />
|
||||
<div className="h-[1px] w-full bg-slate-700/70" />
|
||||
<div className="h-[1px] w-2/3 bg-slate-700/50" />
|
||||
</div>
|
||||
<div className="flex-1 flex flex-col items-center gap-[1px]">
|
||||
<div className="h-2 w-2 rounded-full bg-slate-600/80" />
|
||||
<div className="h-[1px] w-full bg-slate-700/70" />
|
||||
<div className="h-[1px] w-2/3 bg-slate-700/50" />
|
||||
</div>
|
||||
<div className="flex-1 flex flex-col items-center gap-[1px]">
|
||||
<div className="h-2 w-2 rounded-full bg-slate-600/80" />
|
||||
<div className="h-[1px] w-full bg-slate-700/70" />
|
||||
<div className="h-[1px] w-2/3 bg-slate-700/50" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-[10px] text-slate-400 leading-snug">팀 소개 섹션</p>
|
||||
@@ -349,10 +426,14 @@ export function BlocksSidebar() {
|
||||
</button>
|
||||
<div
|
||||
data-testid="template-preview-footer"
|
||||
className="shrink-0 flex h-6 w-10 flex-col justify-end gap-[2px] rounded border border-slate-700 bg-slate-900"
|
||||
className="shrink-0 flex h-6 w-10 items-center justify-between gap-[2px] rounded border border-slate-700 bg-slate-900 p-[2px]"
|
||||
>
|
||||
<div className="h-[2px] w-full bg-slate-700/40" />
|
||||
<div className="h-[2px] w-3/4 bg-slate-700/70" />
|
||||
<div className="flex-1 h-[2px] bg-slate-700/70" />
|
||||
<div className="flex-1 flex flex-col gap-[1px]">
|
||||
<div className="h-[1px] w-full bg-slate-700/50" />
|
||||
<div className="h-[1px] w-full bg-slate-700/50" />
|
||||
</div>
|
||||
<div className="flex-1 h-[1px] bg-slate-700/40" />
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-[10px] text-slate-400 leading-snug">페이지 푸터 섹션</p>
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
"use client";
|
||||
|
||||
import type { Block, ButtonBlockProps, FormBlockProps, ListBlockProps, SectionBlockProps, TextBlockProps } from "@/features/editor/state/editorStore";
|
||||
import type { Block, ButtonBlockProps, FormBlockProps, ListBlockProps, SectionBlockProps, TextBlockProps, VideoBlockProps } from "@/features/editor/state/editorStore";
|
||||
import { ButtonPropertiesPanel } from "./ButtonPropertiesPanel";
|
||||
import { TextPropertiesPanel } from "./TextPropertiesPanel";
|
||||
import { ListPropertiesPanel } from "./ListPropertiesPanel";
|
||||
import { DividerPropertiesPanel } from "./DividerPropertiesPanel";
|
||||
import { ImagePropertiesPanel } from "./ImagePropertiesPanel";
|
||||
import { SectionPropertiesPanel } from "./SectionPropertiesPanel";
|
||||
import { VideoPropertiesPanel } from "./VideoPropertiesPanel";
|
||||
import { FormInputPropertiesPanel } from "../forms/FormInputPropertiesPanel";
|
||||
import { FormSelectPropertiesPanel } from "../forms/FormSelectPropertiesPanel";
|
||||
import { FormCheckboxPropertiesPanel } from "../forms/FormCheckboxPropertiesPanel";
|
||||
@@ -142,6 +143,18 @@ export function PropertiesSidebar(props: PropertiesSidebarProps) {
|
||||
);
|
||||
}
|
||||
|
||||
if (selectedBlock.type === "video") {
|
||||
const videoProps = selectedBlock.props as VideoBlockProps;
|
||||
|
||||
return (
|
||||
<VideoPropertiesPanel
|
||||
videoProps={videoProps}
|
||||
selectedBlockId={selectedBlockId}
|
||||
updateBlock={(id, partial) => updateBlock(id, partial as any)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (selectedBlock.type === "section") {
|
||||
const sectionProps = selectedBlock.props as SectionBlockProps;
|
||||
|
||||
|
||||
@@ -11,6 +11,34 @@ export type SectionPropertiesPanelProps = {
|
||||
};
|
||||
|
||||
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 (
|
||||
<>
|
||||
{/* 섹션 배경: 커스텀 색상 피커만 사용 */}
|
||||
@@ -28,6 +56,333 @@ export function SectionPropertiesPanel({ sectionProps, selectedBlockId, updateBl
|
||||
/>
|
||||
</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
|
||||
|
||||
@@ -0,0 +1,387 @@
|
||||
"use client";
|
||||
|
||||
import type { VideoBlockProps } from "@/features/editor/state/editorStore";
|
||||
import { NumericPropertyControl } from "@/features/editor/components/NumericPropertyControl";
|
||||
import { ColorPickerField, TEXT_COLOR_PALETTE } from "@/features/editor/components/ColorPickerField";
|
||||
|
||||
export type VideoPropertiesPanelProps = {
|
||||
videoProps: VideoBlockProps;
|
||||
selectedBlockId: string;
|
||||
updateBlock: (id: string, partial: Partial<VideoBlockProps>) => void;
|
||||
};
|
||||
|
||||
export function VideoPropertiesPanel({ videoProps, selectedBlockId, updateBlock }: VideoPropertiesPanelProps) {
|
||||
// 비디오 소스 유형: 업로드(/api/video/:id) 또는 외부 URL 구분
|
||||
const source: "url" | "upload" =
|
||||
videoProps.sourceType === "asset" || (videoProps.sourceUrl && videoProps.sourceUrl.startsWith("/api/video/"))
|
||||
? "upload"
|
||||
: "url";
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* 비디오 소스 선택 (URL / 업로드) */}
|
||||
<div className="space-y-1">
|
||||
<label className="flex flex-col gap-1 text-xs text-slate-400">
|
||||
<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={source}
|
||||
onChange={(e) => {
|
||||
const next = e.target.value as "url" | "upload";
|
||||
if (next === "url") {
|
||||
updateBlock(selectedBlockId, {
|
||||
sourceType: "externalUrl",
|
||||
assetId: null,
|
||||
} as any);
|
||||
} else {
|
||||
updateBlock(selectedBlockId, {
|
||||
sourceType: "asset",
|
||||
} as any);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<option value="url">URL</option>
|
||||
<option value="upload">파일 업로드</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* URL 입력 */}
|
||||
{source === "url" && (
|
||||
<div className="space-y-1">
|
||||
<label className="flex flex-col gap-1 text-xs text-slate-400">
|
||||
<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={videoProps.sourceUrl ?? ""}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value;
|
||||
updateBlock(selectedBlockId, {
|
||||
sourceUrl: value,
|
||||
sourceType: "externalUrl",
|
||||
assetId: null,
|
||||
} as any);
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
|
||||
{/* 접근성: 제목 / aria-label / 캡션 텍스트 (현재는 UI에서 숨김 처리) */}
|
||||
<div className="hidden">
|
||||
<label className="flex flex-col gap-1 mt-2">
|
||||
<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={(videoProps as any).titleText ?? ""}
|
||||
onChange={(e) => {
|
||||
updateBlock(selectedBlockId, { titleText: e.target.value } as any);
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="mt-2 flex flex-col gap-1">
|
||||
<span>비디오 aria-label</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="비디오 aria-label"
|
||||
value={(videoProps as any).ariaLabel ?? ""}
|
||||
onChange={(e) => {
|
||||
updateBlock(selectedBlockId, { ariaLabel: e.target.value } as any);
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="mt-2 flex flex-col gap-1">
|
||||
<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={(videoProps as any).captionText ?? ""}
|
||||
onChange={(e) => {
|
||||
updateBlock(selectedBlockId, { captionText: e.target.value } as any);
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-3 space-y-1 text-xs text-slate-400">
|
||||
<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={videoProps.posterImageSrc ?? ""}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value;
|
||||
updateBlock(selectedBlockId, {
|
||||
posterImageSrc: value,
|
||||
posterSourceType: "externalUrl",
|
||||
posterAssetId: null,
|
||||
} as any);
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* 파일 업로드 */}
|
||||
{source === "upload" && (
|
||||
<div className="space-y-1">
|
||||
<label className="flex flex-col gap-1 text-xs text-slate-400">
|
||||
<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, {
|
||||
sourceUrl: servedUrl,
|
||||
sourceType: "asset",
|
||||
assetId: data.id,
|
||||
} as any);
|
||||
} catch (error) {
|
||||
console.error("비디오 업로드 중 오류", error);
|
||||
} finally {
|
||||
event.target.value = "";
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
</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>
|
||||
|
||||
{/* 카드 배경색 */}
|
||||
<div className="space-y-1">
|
||||
<ColorPickerField
|
||||
label="카드 배경색"
|
||||
ariaLabelColorInput="비디오 카드 배경색 피커"
|
||||
ariaLabelHexInput="비디오 카드 배경색 HEX"
|
||||
value={videoProps.backgroundColorCustom ?? ""}
|
||||
onChange={(hex) => {
|
||||
const next = hex && hex.trim().length > 0 ? hex : undefined;
|
||||
updateBlock(selectedBlockId, { backgroundColorCustom: next } as any);
|
||||
}}
|
||||
palette={TEXT_COLOR_PALETTE}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 정렬 */}
|
||||
<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={videoProps.align ?? "center"}
|
||||
onChange={(e) =>
|
||||
updateBlock(selectedBlockId, {
|
||||
align: e.target.value as VideoBlockProps["align"],
|
||||
} as any)
|
||||
}
|
||||
>
|
||||
<option value="left">왼쪽</option>
|
||||
<option value="center">가운데</option>
|
||||
<option value="right">오른쪽</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={videoProps.widthMode ?? "auto"}
|
||||
onChange={(e) =>
|
||||
updateBlock(selectedBlockId, {
|
||||
widthMode: e.target.value as VideoBlockProps["widthMode"],
|
||||
} as any)
|
||||
}
|
||||
>
|
||||
<option value="auto">내용에 맞춤</option>
|
||||
<option value="full">가로 전체</option>
|
||||
<option value="fixed">고정 너비 (px)</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
{(videoProps.widthMode ?? "auto") === "fixed" && (
|
||||
<NumericPropertyControl
|
||||
label="고정 너비 (px)"
|
||||
unitLabel="(px)"
|
||||
value={videoProps.widthPx ?? 640}
|
||||
min={160}
|
||||
max={1920}
|
||||
step={10}
|
||||
presets={[
|
||||
{ id: "sm", label: "작게", value: 480 },
|
||||
{ id: "md", label: "보통", value: 640 },
|
||||
{ id: "lg", label: "넓게", value: 960 },
|
||||
]}
|
||||
onChangeValue={(v) => {
|
||||
updateBlock(selectedBlockId, {
|
||||
widthPx: v,
|
||||
} as any);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 카드 패딩 */}
|
||||
<NumericPropertyControl
|
||||
label="카드 패딩"
|
||||
unitLabel="(px)"
|
||||
value={typeof videoProps.cardPaddingPx === "number" ? videoProps.cardPaddingPx : 0}
|
||||
min={0}
|
||||
max={64}
|
||||
step={2}
|
||||
onChangeValue={(v) => {
|
||||
const safe = Number.isFinite(v) && v >= 0 ? v : 0;
|
||||
updateBlock(selectedBlockId, { cardPaddingPx: safe } as any);
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* 카드 모서리 둥글기 */}
|
||||
<NumericPropertyControl
|
||||
label="카드 모서리 둥글기"
|
||||
unitLabel="(px)"
|
||||
value={typeof videoProps.borderRadiusPx === "number" ? videoProps.borderRadiusPx : 0}
|
||||
min={0}
|
||||
max={64}
|
||||
step={1}
|
||||
onChangeValue={(v) => {
|
||||
const safe = Number.isFinite(v) && v >= 0 ? v : 0;
|
||||
updateBlock(selectedBlockId, { borderRadiusPx: safe } as any);
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* 시작 시점 (초) */}
|
||||
<NumericPropertyControl
|
||||
label="시작 시점 (초)"
|
||||
unitLabel="(초)"
|
||||
value={typeof videoProps.startTimeSec === "number" ? videoProps.startTimeSec : 0}
|
||||
min={0}
|
||||
max={600}
|
||||
step={1}
|
||||
onChangeValue={(v) => {
|
||||
const safe = Number.isFinite(v) && v >= 0 ? v : 0;
|
||||
updateBlock(selectedBlockId, { startTimeSec: safe } as any);
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* 종료 시점 (초) */}
|
||||
<NumericPropertyControl
|
||||
label="종료 시점 (초)"
|
||||
unitLabel="(초)"
|
||||
value={typeof videoProps.endTimeSec === "number" ? videoProps.endTimeSec : 0}
|
||||
min={0}
|
||||
max={600}
|
||||
step={1}
|
||||
onChangeValue={(v) => {
|
||||
const safe = Number.isFinite(v) && v >= 0 ? v : 0;
|
||||
updateBlock(selectedBlockId, { endTimeSec: safe } 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={videoProps.aspectRatio ?? "16:9"}
|
||||
onChange={(e) =>
|
||||
updateBlock(selectedBlockId, {
|
||||
aspectRatio: e.target.value as VideoBlockProps["aspectRatio"],
|
||||
} as any)
|
||||
}
|
||||
>
|
||||
<option value="16:9">16:9</option>
|
||||
<option value="4:3">4:3</option>
|
||||
<option value="1:1">1:1</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
{/* 재생 옵션 */}
|
||||
<div className="mt-2 grid grid-cols-2 gap-2">
|
||||
<label className="flex items-center gap-1">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="h-3 w-3 rounded border-slate-700 bg-slate-900"
|
||||
checked={!!videoProps.autoplay}
|
||||
onChange={(e) =>
|
||||
updateBlock(selectedBlockId, {
|
||||
autoplay: e.target.checked,
|
||||
} as any)
|
||||
}
|
||||
/>
|
||||
<span className="text-[11px]">자동 재생</span>
|
||||
</label>
|
||||
<label className="flex items-center gap-1">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="h-3 w-3 rounded border-slate-700 bg-slate-900"
|
||||
checked={!!videoProps.loop}
|
||||
onChange={(e) =>
|
||||
updateBlock(selectedBlockId, {
|
||||
loop: e.target.checked,
|
||||
} as any)
|
||||
}
|
||||
/>
|
||||
<span className="text-[11px]">반복 재생</span>
|
||||
</label>
|
||||
<label className="flex items-center gap-1">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="h-3 w-3 rounded border-slate-700 bg-slate-900"
|
||||
checked={!!videoProps.muted}
|
||||
onChange={(e) =>
|
||||
updateBlock(selectedBlockId, {
|
||||
muted: e.target.checked,
|
||||
} as any)
|
||||
}
|
||||
/>
|
||||
<span className="text-[11px]">음소거</span>
|
||||
</label>
|
||||
<label className="flex items-center gap-1">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="h-3 w-3 rounded border-slate-700 bg-slate-900"
|
||||
checked={videoProps.controls !== false}
|
||||
onChange={(e) =>
|
||||
updateBlock(selectedBlockId, {
|
||||
controls: e.target.checked,
|
||||
} as any)
|
||||
}
|
||||
/>
|
||||
<span className="text-[11px]">재생 컨트롤 표시</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import type { Block, SectionBlockProps, TextBlockProps } from "@/features/editor/state/editorStore";
|
||||
import type { Block, SectionBlockProps, TextBlockProps, ImageBlockProps } from "@/features/editor/state/editorStore";
|
||||
|
||||
export function createBlogTemplateBlocks(opts: {
|
||||
sectionId: string;
|
||||
@@ -42,21 +42,40 @@ export function createBlogTemplateBlocks(opts: {
|
||||
const blogBlocks: Block[] = columns.flatMap((col, index) => {
|
||||
const post = postDefinitions[index] ?? postDefinitions[0];
|
||||
|
||||
const imageId = createId();
|
||||
const titleId = createId();
|
||||
const summaryId = createId();
|
||||
|
||||
const imageProps: ImageBlockProps = {
|
||||
src: "https://via.placeholder.com/400x250/1e293b/94a3b8?text=Blog+Image",
|
||||
alt: post.title,
|
||||
align: "center",
|
||||
widthMode: "auto",
|
||||
borderRadius: "md",
|
||||
};
|
||||
|
||||
const titleProps: TextBlockProps = {
|
||||
text: post.title,
|
||||
align: "left",
|
||||
size: "lg",
|
||||
bold: true,
|
||||
} as any;
|
||||
|
||||
const summaryProps: TextBlockProps = {
|
||||
text: post.summary,
|
||||
align: "left",
|
||||
size: "sm",
|
||||
color: "muted",
|
||||
} as any;
|
||||
|
||||
const imageBlock: Block = {
|
||||
id: imageId,
|
||||
type: "image",
|
||||
props: imageProps,
|
||||
sectionId,
|
||||
columnId: col.id,
|
||||
};
|
||||
|
||||
const titleBlock: Block = {
|
||||
id: titleId,
|
||||
type: "text",
|
||||
@@ -73,7 +92,7 @@ export function createBlogTemplateBlocks(opts: {
|
||||
columnId: col.id,
|
||||
};
|
||||
|
||||
return [titleBlock, summaryBlock];
|
||||
return [imageBlock, titleBlock, summaryBlock];
|
||||
});
|
||||
|
||||
const blocks: Block[] = [sectionBlock, ...blogBlocks];
|
||||
|
||||
@@ -9,7 +9,8 @@ export function createCtaTemplateBlocks(opts: {
|
||||
const { sectionId, createId } = opts;
|
||||
|
||||
const columns: SectionBlockProps["columns"] = [
|
||||
{ id: `${sectionId}_col_1`, span: 12 },
|
||||
{ id: `${sectionId}_col_1`, span: 8 },
|
||||
{ id: `${sectionId}_col_2`, span: 4 },
|
||||
];
|
||||
|
||||
const sectionProps: SectionBlockProps = {
|
||||
@@ -31,20 +32,21 @@ export function createCtaTemplateBlocks(opts: {
|
||||
columnId: null,
|
||||
};
|
||||
|
||||
const firstColumnId = `${sectionId}_col_1`;
|
||||
const leftColumnId = `${sectionId}_col_1`;
|
||||
const rightColumnId = `${sectionId}_col_2`;
|
||||
|
||||
const textId = createId();
|
||||
const textProps: TextBlockProps = {
|
||||
text: "지금 바로 행동을 유도하는 CTA 텍스트를 입력하세요.",
|
||||
align: "center",
|
||||
size: "base",
|
||||
text: "지금 바로 행동을 유도하는 CTA 텍스트를 입력하세요.\n망설이지 말고 시작하세요!",
|
||||
align: "left",
|
||||
size: "lg",
|
||||
} as any;
|
||||
const textBlock: Block = {
|
||||
id: textId,
|
||||
type: "text",
|
||||
props: textProps,
|
||||
sectionId,
|
||||
columnId: firstColumnId,
|
||||
columnId: leftColumnId,
|
||||
};
|
||||
|
||||
const buttonId = createId();
|
||||
@@ -52,10 +54,10 @@ export function createCtaTemplateBlocks(opts: {
|
||||
label: "CTA 버튼",
|
||||
href: "#",
|
||||
align: "center",
|
||||
size: "md",
|
||||
size: "lg",
|
||||
variant: "solid",
|
||||
colorPalette: "primary",
|
||||
fullWidth: false,
|
||||
fullWidth: true,
|
||||
borderRadius: "md",
|
||||
textColorCustom: "#0b1120",
|
||||
fillColorCustom: "#0ea5e9",
|
||||
@@ -66,7 +68,7 @@ export function createCtaTemplateBlocks(opts: {
|
||||
type: "button",
|
||||
props: buttonProps,
|
||||
sectionId,
|
||||
columnId: firstColumnId,
|
||||
columnId: rightColumnId,
|
||||
};
|
||||
|
||||
const blocks: Block[] = [sectionBlock, textBlock, buttonBlock];
|
||||
|
||||
@@ -9,15 +9,17 @@ export function createFaqTemplateBlocks(opts: {
|
||||
const { sectionId, createId } = opts;
|
||||
|
||||
const columns: SectionBlockProps["columns"] = [
|
||||
{ id: `${sectionId}_col_1`, span: 12 },
|
||||
{ id: `${sectionId}_col_1`, span: 3 },
|
||||
{ id: `${sectionId}_col_2`, span: 9 },
|
||||
];
|
||||
|
||||
const sectionProps: SectionBlockProps = {
|
||||
background: "default",
|
||||
paddingY: "md",
|
||||
// FAQ 는 비교적 좁은 폭과 보통 여백을 사용한다.
|
||||
paddingYPx: 56,
|
||||
maxWidthPx: 720,
|
||||
paddingYPx: 80,
|
||||
maxWidthPx: 1024,
|
||||
gapXPx: 48,
|
||||
backgroundColorCustom: "#020617",
|
||||
columns,
|
||||
} as any;
|
||||
@@ -30,20 +32,54 @@ export function createFaqTemplateBlocks(opts: {
|
||||
columnId: null,
|
||||
};
|
||||
|
||||
const firstColumnId = `${sectionId}_col_1`;
|
||||
const leftColumnId = `${sectionId}_col_1`;
|
||||
const rightColumnId = `${sectionId}_col_2`;
|
||||
|
||||
// Left Column: Title
|
||||
const titleId = createId();
|
||||
const titleProps: TextBlockProps = {
|
||||
text: "FAQ",
|
||||
align: "left",
|
||||
size: "xl",
|
||||
bold: true,
|
||||
} as any;
|
||||
const titleBlock: Block = {
|
||||
id: titleId,
|
||||
type: "text",
|
||||
props: titleProps,
|
||||
sectionId,
|
||||
columnId: leftColumnId,
|
||||
};
|
||||
|
||||
const subTitleId = createId();
|
||||
const subTitleProps: TextBlockProps = {
|
||||
text: "자주 묻는 질문",
|
||||
align: "left",
|
||||
size: "sm",
|
||||
color: "muted",
|
||||
} as any;
|
||||
const subTitleBlock: Block = {
|
||||
id: subTitleId,
|
||||
type: "text",
|
||||
props: subTitleProps,
|
||||
sectionId,
|
||||
columnId: leftColumnId,
|
||||
};
|
||||
|
||||
|
||||
// Right Column: Q&A List
|
||||
const faqPairs = [
|
||||
{
|
||||
question: "자주 묻는 질문 1",
|
||||
answer: "첫 번째 질문에 대한 답변을 여기에 입력하세요.",
|
||||
question: "서비스 이용료는 얼마인가요?",
|
||||
answer: "기본 기능은 무료로 제공되며, 프리미엄 기능은 월 구독료가 발생합니다.",
|
||||
},
|
||||
{
|
||||
question: "자주 묻는 질문 2",
|
||||
answer: "두 번째 질문에 대한 답변을 여기에 입력하세요.",
|
||||
question: "환불 정책은 어떻게 되나요?",
|
||||
answer: "결제 후 7일 이내에 사용 이력이 없는 경우 전액 환불 가능합니다.",
|
||||
},
|
||||
{
|
||||
question: "자주 묻는 질문 3",
|
||||
answer: "세 번째 질문에 대한 답변을 여기에 입력하세요.",
|
||||
question: "팀원 초대 기능이 있나요?",
|
||||
answer: "네, 프로 요금제 이상부터 팀원 초대가 가능합니다.",
|
||||
},
|
||||
];
|
||||
|
||||
@@ -55,12 +91,14 @@ export function createFaqTemplateBlocks(opts: {
|
||||
text: pair.question,
|
||||
align: "left",
|
||||
size: "lg",
|
||||
bold: true,
|
||||
} as any;
|
||||
|
||||
const answerProps: TextBlockProps = {
|
||||
text: pair.answer,
|
||||
align: "left",
|
||||
size: "sm",
|
||||
size: "base",
|
||||
color: "muted",
|
||||
} as any;
|
||||
|
||||
const questionBlock: Block = {
|
||||
@@ -68,7 +106,7 @@ export function createFaqTemplateBlocks(opts: {
|
||||
type: "text",
|
||||
props: questionProps,
|
||||
sectionId,
|
||||
columnId: firstColumnId,
|
||||
columnId: rightColumnId,
|
||||
};
|
||||
|
||||
const answerBlock: Block = {
|
||||
@@ -76,13 +114,13 @@ export function createFaqTemplateBlocks(opts: {
|
||||
type: "text",
|
||||
props: answerProps,
|
||||
sectionId,
|
||||
columnId: firstColumnId,
|
||||
columnId: rightColumnId,
|
||||
};
|
||||
|
||||
return [questionBlock, answerBlock];
|
||||
});
|
||||
|
||||
const blocks: Block[] = [sectionBlock, ...faqBlocks];
|
||||
const blocks: Block[] = [sectionBlock, titleBlock, subTitleBlock, ...faqBlocks];
|
||||
const lastSelectedId = faqBlocks[faqBlocks.length - 1]?.id ?? sectionId;
|
||||
|
||||
return { blocks, lastSelectedId };
|
||||
|
||||
@@ -9,15 +9,17 @@ export function createFooterTemplateBlocks(opts: {
|
||||
const { sectionId, createId } = opts;
|
||||
|
||||
const columns: SectionBlockProps["columns"] = [
|
||||
{ id: `${sectionId}_col_1`, span: 12 },
|
||||
{ id: `${sectionId}_col_1`, span: 4 },
|
||||
{ id: `${sectionId}_col_2`, span: 4 },
|
||||
{ id: `${sectionId}_col_3`, span: 4 },
|
||||
];
|
||||
|
||||
const sectionProps: SectionBlockProps = {
|
||||
background: "muted",
|
||||
paddingY: "md",
|
||||
// 푸터는 상대적으로 낮은 높이와 좁은 최대 폭을 사용한다.
|
||||
paddingYPx: 40,
|
||||
maxWidthPx: 800,
|
||||
paddingYPx: 64,
|
||||
maxWidthPx: 1120,
|
||||
backgroundColorCustom: "#020617",
|
||||
columns,
|
||||
} as any;
|
||||
@@ -30,37 +32,74 @@ export function createFooterTemplateBlocks(opts: {
|
||||
columnId: null,
|
||||
};
|
||||
|
||||
const firstColumnId = `${sectionId}_col_1`;
|
||||
const col1Id = `${sectionId}_col_1`;
|
||||
const col2Id = `${sectionId}_col_2`;
|
||||
const col3Id = `${sectionId}_col_3`;
|
||||
|
||||
// Col 1: Brand
|
||||
const brandId = createId();
|
||||
const brandProps: TextBlockProps = {
|
||||
text: "MyLanding",
|
||||
align: "left",
|
||||
size: "xl",
|
||||
bold: true,
|
||||
} as any;
|
||||
const brandBlock: Block = {
|
||||
id: brandId,
|
||||
type: "text",
|
||||
props: brandProps,
|
||||
sectionId,
|
||||
columnId: col1Id,
|
||||
};
|
||||
|
||||
const descId = createId();
|
||||
const descProps: TextBlockProps = {
|
||||
text: "더 나은 웹사이트를 위한 최고의 선택.",
|
||||
align: "left",
|
||||
size: "sm",
|
||||
color: "muted",
|
||||
} as any;
|
||||
const descBlock: Block = {
|
||||
id: descId,
|
||||
type: "text",
|
||||
props: descProps,
|
||||
sectionId,
|
||||
columnId: col1Id,
|
||||
};
|
||||
|
||||
// Col 2: Links
|
||||
const linksId = createId();
|
||||
const linksProps: TextBlockProps = {
|
||||
text: "이용약관 · 개인정보처리방침",
|
||||
text: "서비스 소개\n요금제\n고객지원\n문의하기",
|
||||
align: "center",
|
||||
size: "sm",
|
||||
color: "muted",
|
||||
} as any;
|
||||
const linksBlock: Block = {
|
||||
id: linksId,
|
||||
type: "text",
|
||||
props: linksProps,
|
||||
sectionId,
|
||||
columnId: firstColumnId,
|
||||
columnId: col2Id,
|
||||
};
|
||||
|
||||
// Col 3: Copyright
|
||||
const copyrightId = createId();
|
||||
const copyrightProps: TextBlockProps = {
|
||||
text: "© 2025 MyLanding. All rights reserved.",
|
||||
align: "center",
|
||||
size: "sm",
|
||||
text: "© 2025 MyLanding.\nAll rights reserved.",
|
||||
align: "right",
|
||||
size: "xs",
|
||||
color: "muted",
|
||||
} as any;
|
||||
const copyrightBlock: Block = {
|
||||
id: copyrightId,
|
||||
type: "text",
|
||||
props: copyrightProps,
|
||||
sectionId,
|
||||
columnId: firstColumnId,
|
||||
columnId: col3Id,
|
||||
};
|
||||
|
||||
const blocks: Block[] = [sectionBlock, linksBlock, copyrightBlock];
|
||||
const blocks: Block[] = [sectionBlock, brandBlock, descBlock, linksBlock, copyrightBlock];
|
||||
const lastSelectedId = copyrightId;
|
||||
|
||||
return { blocks, lastSelectedId };
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import type { Block, SectionBlockProps, TextBlockProps } from "@/features/editor/state/editorStore";
|
||||
import type { Block, SectionBlockProps, TextBlockProps, ButtonBlockProps } from "@/features/editor/state/editorStore";
|
||||
|
||||
export function createPricingTemplateBlocks(opts: {
|
||||
sectionId: string;
|
||||
@@ -33,47 +33,106 @@ export function createPricingTemplateBlocks(opts: {
|
||||
columnId: null,
|
||||
};
|
||||
|
||||
const planDefinitions: Array<{ name: string; price: string }> = [
|
||||
const planDefinitions: Array<{ name: string; price: string; popular?: boolean }> = [
|
||||
{ name: "Basic", price: "₩9,900/월" },
|
||||
{ name: "Pro", price: "₩29,900/월" },
|
||||
{ name: "Pro", price: "₩29,900/월", popular: true },
|
||||
{ name: "Enterprise", price: "문의" },
|
||||
];
|
||||
|
||||
const pricingBlocks: Block[] = columns.flatMap((col, index) => {
|
||||
const plan = planDefinitions[index] ?? planDefinitions[0];
|
||||
const isPopular = !!plan.popular;
|
||||
|
||||
const blocksInCol: Block[] = [];
|
||||
|
||||
// Popular Badge (only for middle)
|
||||
if (isPopular) {
|
||||
const badgeId = createId();
|
||||
const badgeProps: TextBlockProps = {
|
||||
text: "MOST POPULAR",
|
||||
align: "center",
|
||||
size: "xs",
|
||||
color: "primary",
|
||||
bold: true,
|
||||
} as any;
|
||||
blocksInCol.push({
|
||||
id: badgeId,
|
||||
type: "text",
|
||||
props: badgeProps,
|
||||
sectionId,
|
||||
columnId: col.id,
|
||||
});
|
||||
}
|
||||
|
||||
const nameId = createId();
|
||||
const priceId = createId();
|
||||
|
||||
const nameProps: TextBlockProps = {
|
||||
text: plan.name,
|
||||
align: "center",
|
||||
size: "lg",
|
||||
bold: true,
|
||||
} as any;
|
||||
|
||||
const priceProps: TextBlockProps = {
|
||||
text: plan.price,
|
||||
align: "center",
|
||||
size: "base",
|
||||
} as any;
|
||||
|
||||
const nameBlock: Block = {
|
||||
blocksInCol.push({
|
||||
id: nameId,
|
||||
type: "text",
|
||||
props: nameProps,
|
||||
sectionId,
|
||||
columnId: col.id,
|
||||
};
|
||||
});
|
||||
|
||||
const priceBlock: Block = {
|
||||
const priceId = createId();
|
||||
const priceProps: TextBlockProps = {
|
||||
text: plan.price,
|
||||
align: "center",
|
||||
size: "xl", // Price larger
|
||||
} as any;
|
||||
blocksInCol.push({
|
||||
id: priceId,
|
||||
type: "text",
|
||||
props: priceProps,
|
||||
sectionId,
|
||||
columnId: col.id,
|
||||
};
|
||||
});
|
||||
|
||||
return [nameBlock, priceBlock];
|
||||
// Features list (simple text for now)
|
||||
const featuresId = createId();
|
||||
const featuresProps: TextBlockProps = {
|
||||
text: "• 모든 기본 기능\n• 이메일 지원\n• 1GB 스토리지",
|
||||
align: "center",
|
||||
size: "sm",
|
||||
color: "muted",
|
||||
} as any;
|
||||
blocksInCol.push({
|
||||
id: featuresId,
|
||||
type: "text",
|
||||
props: featuresProps,
|
||||
sectionId,
|
||||
columnId: col.id,
|
||||
});
|
||||
|
||||
// Button
|
||||
const buttonId = createId();
|
||||
const buttonProps: ButtonBlockProps = {
|
||||
label: isPopular ? "시작하기" : "선택하기",
|
||||
href: "#",
|
||||
align: "center",
|
||||
size: "md",
|
||||
variant: isPopular ? "solid" : "outline",
|
||||
colorPalette: "primary",
|
||||
fullWidth: true,
|
||||
borderRadius: "md",
|
||||
textColorCustom: isPopular ? "#0b1120" : "#e2e8f0",
|
||||
fillColorCustom: isPopular ? "#0ea5e9" : "transparent",
|
||||
strokeColorCustom: "#0ea5e9",
|
||||
};
|
||||
blocksInCol.push({
|
||||
id: buttonId,
|
||||
type: "button",
|
||||
props: buttonProps,
|
||||
sectionId,
|
||||
columnId: col.id,
|
||||
});
|
||||
|
||||
return blocksInCol;
|
||||
});
|
||||
|
||||
const blocks: Block[] = [sectionBlock, ...pricingBlocks];
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import type { Block, SectionBlockProps, TextBlockProps } from "@/features/editor/state/editorStore";
|
||||
import type { Block, SectionBlockProps, TextBlockProps, ImageBlockProps } from "@/features/editor/state/editorStore";
|
||||
|
||||
export function createTeamTemplateBlocks(opts: {
|
||||
sectionId: string;
|
||||
@@ -42,28 +42,49 @@ export function createTeamTemplateBlocks(opts: {
|
||||
const teamBlocks: Block[] = columns.flatMap((col, index) => {
|
||||
const m = memberDefinitions[index] ?? memberDefinitions[0];
|
||||
|
||||
const imageId = createId();
|
||||
const nameId = createId();
|
||||
const roleId = createId();
|
||||
const bioId = createId();
|
||||
|
||||
const imageProps: ImageBlockProps = {
|
||||
src: "https://via.placeholder.com/150x150/334155/94a3b8?text=User",
|
||||
alt: m.name,
|
||||
align: "center",
|
||||
widthMode: "fixed",
|
||||
widthPx: 120,
|
||||
borderRadius: "full",
|
||||
};
|
||||
|
||||
const nameProps: TextBlockProps = {
|
||||
text: m.name,
|
||||
align: "center",
|
||||
size: "lg",
|
||||
bold: true,
|
||||
} as any;
|
||||
|
||||
const roleProps: TextBlockProps = {
|
||||
text: m.role,
|
||||
align: "center",
|
||||
size: "base",
|
||||
color: "primary",
|
||||
} as any;
|
||||
|
||||
const bioProps: TextBlockProps = {
|
||||
text: m.bio,
|
||||
align: "center",
|
||||
size: "sm",
|
||||
color: "muted",
|
||||
} as any;
|
||||
|
||||
const imageBlock: Block = {
|
||||
id: imageId,
|
||||
type: "image",
|
||||
props: imageProps,
|
||||
sectionId,
|
||||
columnId: col.id,
|
||||
};
|
||||
|
||||
const nameBlock: Block = {
|
||||
id: nameId,
|
||||
type: "text",
|
||||
@@ -88,7 +109,7 @@ export function createTeamTemplateBlocks(opts: {
|
||||
columnId: col.id,
|
||||
};
|
||||
|
||||
return [nameBlock, roleBlock, bioBlock];
|
||||
return [imageBlock, nameBlock, roleBlock, bioBlock];
|
||||
});
|
||||
|
||||
const blocks: Block[] = [sectionBlock, ...teamBlocks];
|
||||
|
||||
Reference in New Issue
Block a user