TDD,E2E 개선, 미적용 스타일 개선
CI / test (push) Failing after 2m54s
CI / e2e (push) Has been skipped
CI / pr_and_merge (push) Has been skipped

This commit is contained in:
2025-12-07 09:52:42 +09:00
parent e726f43f7c
commit a5b432fb7b
167 changed files with 7397 additions and 663 deletions
+184 -49
View File
@@ -184,6 +184,21 @@ function EditorPageInner() {
const slug = initialSlugFromQuery.trim();
if (!slug) return;
// 이미 해당 slug 에 대한 autosave 스냅샷이 있으면,
// 서버에서 프로젝트를 다시 불러와서 현재 로컬 상태를 덮어쓰지 않는다.
if (typeof window !== "undefined") {
try {
const key = `pb:autosave:${slug}`;
const raw = window.localStorage.getItem(key);
if (raw) {
setHasLoadedInitialProjectFromSlug(true);
return;
}
} catch {
// localStorage 접근 실패 시에는 서버 로드를 계속 시도한다.
}
}
let cancelled = false;
const loadFromServer = async () => {
@@ -218,7 +233,7 @@ function EditorPageInner() {
return () => {
cancelled = true;
};
}, [authChecked, initialSlugFromQuery, hasLoadedInitialProjectFromSlug, replaceBlocks]);
}, [authChecked, initialSlugFromQuery, hasLoadedInitialProjectFromSlug, replaceBlocks, updateProjectConfig, resetHistory]);
useEffect(() => {
let cancelled = false;
@@ -707,6 +722,13 @@ function EditorPageInner() {
const rootBlocks = blocks.filter((block) => !block.sectionId);
const projectSlugRaw = projectConfig?.slug?.trim?.();
const projectSlug =
typeof projectSlugRaw === "string" && projectSlugRaw.length > 0 ? projectSlugRaw : "";
const previewHref = projectSlug
? `/preview?slug=${encodeURIComponent(projectSlug)}`
: "/preview";
useEffect(() => {
if (!authChecked) return;
if (typeof window === "undefined") return;
@@ -951,7 +973,7 @@ function EditorPageInner() {
<span> </span>
</Link>
<Link
href="/preview"
href={previewHref}
className="inline-flex items-center gap-1 rounded border border-slate-700 bg-slate-900 px-3 py-1 text-slate-100 hover:bg-slate-800"
>
<Eye className="w-3 h-3" aria-hidden="true" />
@@ -1443,6 +1465,7 @@ function SortableEditorBlock({
{block.type === "formInput" && (() => {
const inputProps = block.props as FormInputBlockProps;
const inputType = inputProps.inputType ?? "text";
const fieldName = inputProps.formFieldName ?? block.id;
// 필드 스타일: 텍스트 색상/배경/테두리/모서리 등은 커스텀 props 를 통해 제어한다.
const inputTokens = computeFormInputEditorTokens(inputProps);
@@ -1452,47 +1475,100 @@ function SortableEditorBlock({
const isInline = labelLayout === "inline";
const inputAlignClass = inputTokens.inputAlignClass;
const labelDisplay = (inputProps as any).labelDisplay ?? "visible";
// 프리뷰/퍼블릭과 동일한 pb-input/pb-textarea 클래스를 사용해 높이/패딩을 통일한다.
const baseInputClass = `pb-input w-full text-xs outline-none ${inputAlignClass}`;
const baseTextareaClass = `pb-textarea w-full text-xs outline-none ${inputAlignClass}`;
// 에디터에서는 폼 입력 블록이 실제 입력 UI처럼 보이도록 라벨 + 인풋(또는 textarea)을 함께 렌더링한다.
return (
<div className={`text-xs ${isInline ? "flex items-center gap-2" : "flex flex-col gap-1"}`}>
{inputProps.labelMode === "image" && inputProps.labelImageUrl ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={inputProps.labelImageUrl}
alt={inputProps.labelImageAlt || inputProps.label || "폼 라벨"}
className="h-4 w-auto"
/>
) : (
<span className="text-slate-200 shrink-0">{inputProps.label}</span>
{labelDisplay === "visible" && (
inputProps.labelMode === "image" && inputProps.labelImageUrl ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={inputProps.labelImageUrl}
alt={inputProps.labelImageAlt || inputProps.label || "폼 라벨"}
className="h-4 w-auto"
/>
) : (
<span className="text-slate-200 shrink-0">{inputProps.label}</span>
)
)}
{(() => {
// widthMode 와 labelLayout 에 따라 wrapper 의 Tailwind width/flex 클래스를 결정한다.
const widthClass = inputTokens.widthClass;
if (labelDisplay === "floating") {
const placeholder = " ";
return (
<div className={`${widthClass} relative`}>
<span
data-testid="editor-form-input-floating-label"
className="absolute left-2 pointer-events-none"
style={{
top: "-0.3rem",
fontSize: "0.725rem",
color: "#e5e7eb",
backgroundColor: "#020617",
padding: "0 0.25rem",
}}
>
{inputProps.label}
</span>
{inputType === "textarea" ? (
<textarea
data-testid="form-input-field"
className={`${baseTextareaClass} pt-6`}
style={fieldStyle}
name={fieldName}
placeholder={placeholder}
aria-label={inputProps.label}
readOnly
/>
) : (
<input
data-testid="form-input-field"
className={`${baseInputClass} pt-6`}
style={fieldStyle}
type={inputType === "email" ? "email" : "text"}
name={fieldName}
placeholder={placeholder}
aria-label={inputProps.label}
readOnly
/>
)}
</div>
);
}
return (
<div
data-testid="form-input-field"
style={fieldStyle}
className={`${widthClass} rounded border border-slate-700 bg-slate-950`}
>
{inputType === "textarea" ? (
<textarea
className={`w-full min-h-[60px] bg-transparent px-2 py-1 text-xs outline-none ${inputAlignClass}`}
placeholder={inputProps.placeholder || inputProps.label}
aria-label={inputProps.label}
readOnly
/>
) : (
<input
className={`w-full bg-transparent px-2 py-1 text-xs outline-none ${inputAlignClass}`}
type={inputType === "email" ? "email" : "text"}
placeholder={inputProps.placeholder || inputProps.label}
aria-label={inputProps.label}
readOnly
/>
)}
</div>
<div className={widthClass}>
{inputType === "textarea" ? (
<textarea
data-testid="form-input-field"
className={baseTextareaClass}
style={fieldStyle}
name={fieldName}
placeholder={inputProps.placeholder || inputProps.label}
aria-label={inputProps.label}
readOnly
/>
) : (
<input
data-testid="form-input-field"
className={baseInputClass}
style={fieldStyle}
type={inputType === "email" ? "email" : "text"}
name={fieldName}
placeholder={inputProps.placeholder || inputProps.label}
aria-label={inputProps.label}
readOnly
/>
)}
</div>
);
})()}
</div>
@@ -1500,6 +1576,7 @@ function SortableEditorBlock({
})()}
{block.type === "formSelect" && (() => {
const selectProps = block.props as FormSelectBlockProps;
const fieldName = selectProps.formFieldName ?? block.id;
const options = Array.isArray(selectProps.options) && selectProps.options.length > 0
? selectProps.options
: [
@@ -1523,12 +1600,12 @@ function SortableEditorBlock({
) : (
<span className="text-slate-200">{selectProps.label}</span>
)}
<div
style={fieldStyle}
className="w-full rounded border border-slate-700 bg-slate-950"
>
<div className="w-full">
<select
className="w-full bg-transparent px-2 py-1 text-xs outline-none"
data-testid="form-select-field"
className="pb-select w-full text-xs outline-none"
style={fieldStyle}
name={fieldName}
aria-label={selectProps.label}
defaultValue={options[0]?.value}
>
@@ -1552,12 +1629,42 @@ function SortableEditorBlock({
];
const groupLabelMode = radioProps.groupLabelMode ?? "text";
const groupLabelDisplay = radioProps.groupLabelDisplay ?? "visible";
const labelLayout = radioProps.labelLayout ?? "stacked";
const isInlineLayout = labelLayout === "inline" && groupLabelDisplay === "visible";
const radioTokens = computeFormOptionGroupEditorTokens(radioProps);
const fieldStyle = radioTokens.fieldStyle;
const optionLayout = radioProps.optionLayout ?? "stacked";
const optionsLayoutClass = [
"pb-form-options",
optionLayout === "inline" ? "pb-form-options--inline" : "pb-form-options--stacked",
].join(" ");
// optionGapPx 를 사용해 옵션 컨테이너의 rowGap/columnGap 을 제어한다.
const optionGapPx =
typeof radioProps.optionGapPx === "number" && radioProps.optionGapPx >= 0
? radioProps.optionGapPx
: undefined;
const gapStyle: CSSProperties =
optionGapPx !== undefined
? optionLayout === "inline"
? { rowGap: `${optionGapPx}px`, columnGap: `${optionGapPx}px` }
: { rowGap: `${optionGapPx}px` }
: {};
// 그룹 타이틀 레이아웃이 inline 인 경우, 라벨과 옵션 컨테이너를 가로 방향으로 배치하고 labelGapPx 를 columnGap 으로 사용한다.
const inlineGapPx = typeof radioProps.labelGapPx === "number" ? radioProps.labelGapPx : 8;
const groupContainerStyle: CSSProperties = isInlineLayout
? { columnGap: `${inlineGapPx}px` }
: {};
const groupContainerClassName = isInlineLayout
? "flex flex-row items-center text-xs"
: "flex flex-col gap-1 text-xs";
return (
<div className="flex flex-col gap-1 text-xs">
<div className={groupContainerClassName} style={groupContainerStyle}>
{/* 그룹 타이틀은 텍스트/이미지 모드를 지원한다. */}
{groupLabelMode === "image" && radioProps.groupLabelImageUrl ? (
// eslint-disable-next-line @next/next/no-img-element
@@ -1569,15 +1676,12 @@ function SortableEditorBlock({
) : (
<span className="text-slate-200">{radioProps.groupLabel}</span>
)}
<div
style={fieldStyle}
className="flex flex-col gap-1 rounded border border-slate-700 bg-slate-950"
>
<div style={{ ...fieldStyle, ...gapStyle }} className={optionsLayoutClass}>
{options.map((opt) => (
<label key={opt.value} className="inline-flex items-center gap-2 text-slate-200">
<label key={opt.value} className="pb-form-option">
<input
type="radio"
className="h-3 w-3 rounded border border-slate-700 bg-slate-950"
className="h-4 w-4 border-slate-700 bg-slate-900 text-sky-500"
name={radioProps.formFieldName}
value={opt.value}
readOnly
@@ -1609,12 +1713,43 @@ function SortableEditorBlock({
];
const groupLabelMode = checkboxProps.groupLabelMode ?? "text";
const groupLabelDisplay = checkboxProps.groupLabelDisplay ?? "visible";
const labelLayout = checkboxProps.labelLayout ?? "stacked";
const isInlineLayout = labelLayout === "inline" && groupLabelDisplay === "visible";
const checkboxTokens = computeFormOptionGroupEditorTokens(checkboxProps);
const fieldStyle = checkboxTokens.fieldStyle;
const optionLayout = checkboxProps.optionLayout ?? "stacked";
const optionsLayoutClass = [
"pb-form-options",
optionLayout === "inline" ? "pb-form-options--inline" : "pb-form-options--stacked",
].join(" ");
// 그룹 타이틀 레이아웃이 inline 인 경우, 라벨과 옵션 컨테이너를 가로 방향으로 배치하고 labelGapPx 를 columnGap 으로 사용한다.
const inlineGapPx = typeof checkboxProps.labelGapPx === "number" ? checkboxProps.labelGapPx : 8;
const groupContainerStyle: CSSProperties = {
...fieldStyle,
...(isInlineLayout ? { columnGap: `${inlineGapPx}px` } : {}),
};
// optionGapPx 를 사용해 옵션 컨테이너의 rowGap/columnGap 을 제어한다.
const optionGapPx =
typeof checkboxProps.optionGapPx === "number" && checkboxProps.optionGapPx >= 0
? checkboxProps.optionGapPx
: undefined;
const optionsStyle: CSSProperties =
optionGapPx !== undefined
? optionLayout === "inline"
? { rowGap: `${optionGapPx}px`, columnGap: `${optionGapPx}px` }
: { rowGap: `${optionGapPx}px` }
: {};
const groupContainerClassName = isInlineLayout
? "flex flex-row items-center text-xs text-slate-200"
: "flex flex-col gap-1 text-xs text-slate-200";
return (
<div className="flex flex-col gap-1 text-xs text-slate-200" style={fieldStyle}>
<div className={groupContainerClassName} style={groupContainerStyle}>
{/* 체크박스 그룹 타이틀도 텍스트/이미지 모드를 지원한다. */}
{groupLabelMode === "image" && checkboxProps.groupLabelImageUrl ? (
// eslint-disable-next-line @next/next/no-img-element
@@ -1626,12 +1761,12 @@ function SortableEditorBlock({
) : (
<span className="text-slate-200">{checkboxProps.groupLabel}</span>
)}
<div className="flex flex-col gap-1">
<div className={optionsLayoutClass} style={optionsStyle}>
{options.map((opt) => (
<label key={opt.value} className="inline-flex items-center gap-2">
<label key={opt.value} className="pb-form-option">
<input
type="checkbox"
className="h-3 w-3 rounded border border-slate-700 bg-slate-950"
className="h-4 w-4 rounded border-slate-700 bg-slate-900 text-sky-500"
name={checkboxProps.formFieldName}
value={opt.value}
readOnly