Compare commits

...

1 Commits

4 changed files with 213 additions and 2 deletions
+11 -2
View File
@@ -2,6 +2,7 @@
import type { CSSProperties, ReactNode } from "react";
import { useState } from "react";
import Link from "next/link";
import {
DndContext,
PointerSensor,
@@ -222,8 +223,16 @@ export default function EditorPage() {
return (
<main className="min-h-screen flex flex-col bg-slate-950 text-slate-50">
<header className="border-b border-slate-800 px-6 py-4 flex items-center justify-between">
<h1 className="text-xl font-semibold">Page Editor</h1>
<p className="text-xs text-slate-400"> MVP용 </p>
<div className="flex items-baseline gap-3">
<h1 className="text-xl font-semibold">Page Editor</h1>
<p className="text-xs text-slate-400"> MVP용 </p>
</div>
<Link
href="/preview"
className="inline-flex items-center rounded border border-slate-700 bg-slate-900 px-3 py-1 text-xs text-slate-100 hover:bg-slate-800"
>
</Link>
</header>
<section className="flex flex-1 overflow-hidden">
<aside className="w-60 border-r border-slate-800 p-4 text-sm space-y-3">
+19
View File
@@ -0,0 +1,19 @@
"use client";
import { useEditorStore } from "@/features/editor/state/editorStore";
import { PublicPageRenderer } from "@/features/editor/components/PublicPageRenderer";
export default function PreviewPage() {
const blocks = useEditorStore((state) => state.blocks);
return (
<main className="min-h-screen flex flex-col bg-slate-950 text-slate-50">
<header className="border-b border-slate-800 px-6 py-4 flex items-center justify-between">
<h1 className="text-xl font-semibold">Page Preview</h1>
<p className="text-xs text-slate-400"> </p>
</header>
{/* 에디터 크롬 없이 순수 페이지 형태로 블록들을 렌더링 */}
<PublicPageRenderer blocks={blocks} />
</main>
);
}
@@ -0,0 +1,106 @@
"use client";
import type { Block, TextBlockProps, ButtonBlockProps, ImageBlockProps, SectionBlockProps } from "@/features/editor/state/editorStore";
// 에디터 크롬 없이 실제 랜딩 페이지처럼 블록들을 렌더링하는 컴포넌트
interface PublicPageRendererProps {
blocks: Block[];
}
export function PublicPageRenderer({ blocks }: PublicPageRendererProps) {
const sectionBlocks = blocks.filter((b) => b.type === "section");
const rootBlocks = blocks.filter((b) => !b.sectionId && b.type !== "section");
const renderBlock = (block: Block) => {
if (block.type === "text") {
const props = block.props as TextBlockProps;
const alignClass =
props.align === "center" ? "text-center" : props.align === "right" ? "text-right" : "text-left";
const sizeClass = props.size === "sm" ? "text-sm" : props.size === "lg" ? "text-2xl" : "text-base";
return (
<p key={block.id} className={`${alignClass} ${sizeClass} text-slate-50 leading-relaxed`}>
{props.text}
</p>
);
}
if (block.type === "button") {
const props = block.props as ButtonBlockProps;
return (
<a
key={block.id}
href={props.href}
className="inline-flex items-center justify-center rounded-md bg-sky-600 px-4 py-2 text-sm font-medium text-white hover:bg-sky-500 transition-colors"
>
{props.label}
</a>
);
}
if (block.type === "image") {
const props = block.props as ImageBlockProps;
return (
<div key={block.id} className="w-full flex justify-center">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src={props.src || ""} alt={props.alt} className="max-w-full h-auto object-contain" />
</div>
);
}
return null;
};
const renderSection = (section: Block) => {
const props = section.props as SectionBlockProps;
const bgClass =
props.background === "muted" ? "bg-slate-900" : props.background === "primary" ? "bg-sky-900" : "bg-slate-950";
const pyClass = props.paddingY === "sm" ? "py-8" : props.paddingY === "lg" ? "py-20" : "py-12";
const columns = props.columns && props.columns.length > 0 ? props.columns : [{ id: `${section.id}_col`, span: 12 }];
return (
<section key={section.id} className={`${bgClass} ${pyClass}`}>
<div className="mx-auto max-w-5xl px-4">
<div className="flex gap-8">
{columns.map((col) => {
const basis = `${(col.span / 12) * 100}%`;
const columnBlocks = blocks.filter((b) => b.sectionId === section.id && b.columnId === col.id);
return (
<div key={col.id} className="flex flex-col gap-4" style={{ flexBasis: basis }}>
{columnBlocks.map((b) => (
<div key={b.id}>{renderBlock(b)}</div>
))}
</div>
);
})}
</div>
</div>
</section>
);
};
return (
<div className="flex-1 flex flex-col bg-slate-950 text-slate-50">
{/* 루트 텍스트/버튼/이미지 블록들이 있다면 페이지 상단에 노출 */}
{rootBlocks.length > 0 && (
<section className="bg-slate-950 py-12">
<div className="mx-auto max-w-3xl px-4 flex flex-col gap-4">
{rootBlocks.map((b) => (
<div key={b.id}>{renderBlock(b)}</div>
))}
</div>
</section>
)}
{/* 섹션 블록들 */}
{sectionBlocks.map((section) => renderSection(section))}
</div>
);
}
+77
View File
@@ -0,0 +1,77 @@
import { test, expect } from "@playwright/test";
// 프리뷰/뷰 모드 TDD: 먼저 실패하는 E2E부터 작성한다.
test("/preview 페이지는 에디터 크롬 없이 컨텐츠만 보여야 한다", async ({ page }) => {
// 프리뷰 페이지로 이동한다.
await page.goto("/preview");
// 프리뷰 전용 헤더가 있어야 한다.
await expect(page.getByRole("heading", { name: "Page Preview" })).toBeVisible();
// 에디터 좌측 패널용 버튼들은 나타나지 않아야 한다.
await expect(page.getByRole("button", { name: "텍스트 블록 추가" })).toHaveCount(0);
await expect(page.getByRole("button", { name: "Hero 템플릿 추가" })).toHaveCount(0);
});
test("에디터에서 Hero 템플릿을 추가하면 프리뷰에서 동일한 Hero 콘텐츠가 보여야 한다", async ({ page }) => {
// 에디터에서 Hero 템플릿을 추가한다.
await page.goto("/editor");
await page.getByRole("button", { name: "Hero 템플릿 추가" }).click();
// 헤드라인과 CTA 버튼이 에디터에서 생성되었는지 한 번 확인한다.
const editorCanvas = page.getByTestId("editor-canvas");
await expect(editorCanvas.getByText("Hero 제목을 여기에 입력하세요")).toBeVisible();
await expect(editorCanvas.getByRole("button", { name: "지금 시작하기" })).toBeVisible();
// 헤더의 프리뷰 열기 링크를 통해 /preview 로 이동한다 (클라이언트 내 내비게이션 유지).
await page.getByRole("link", { name: "프리뷰 열기" }).click();
// 프리뷰 헤더가 보여야 한다.
await expect(page.getByRole("heading", { name: "Page Preview" })).toBeVisible();
// 프리뷰에서도 Hero 헤드라인과 CTA 버튼이 그대로 보여야 한다.
await expect(page.getByText("Hero 제목을 여기에 입력하세요")).toBeVisible();
// 프리뷰에서는 CTA를 실제 링크(<a>)로 렌더링하므로 텍스트 기준으로만 검증한다.
await expect(page.getByText("지금 시작하기")).toBeVisible();
// 프리뷰 화면에는 에디터용 "텍스트 블록 추가" 버튼이 없어야 한다.
await expect(page.getByRole("button", { name: "텍스트 블록 추가" })).toHaveCount(0);
});
test("에디터에서 Features 템플릿을 추가하면 프리뷰에서 3개의 Feature 섹션이 보여야 한다", async ({ page }) => {
await page.goto("/editor");
await page.getByRole("button", { name: "Features 템플릿 추가" }).click();
const editorCanvas = page.getByTestId("editor-canvas");
await expect(editorCanvas.getByText("Feature 1 제목")).toBeVisible();
await expect(editorCanvas.getByText("Feature 2 제목")).toBeVisible();
await expect(editorCanvas.getByText("Feature 3 제목")).toBeVisible();
await page.getByRole("link", { name: "프리뷰 열기" }).click();
await expect(page.getByRole("heading", { name: "Page Preview" })).toBeVisible();
await expect(page.getByText("Feature 1 제목")).toBeVisible();
await expect(page.getByText("Feature 2 제목")).toBeVisible();
await expect(page.getByText("Feature 3 제목")).toBeVisible();
});
test("에디터에서 CTA 템플릿을 추가하면 프리뷰에서 CTA 텍스트와 버튼이 보여야 한다", async ({ page }) => {
await page.goto("/editor");
await page.getByRole("button", { name: "CTA 템플릿 추가" }).click();
const editorCanvas = page.getByTestId("editor-canvas");
await expect(editorCanvas.getByText("지금 바로 행동을 유도하는 CTA 텍스트를 입력하세요.")).toBeVisible();
await expect(editorCanvas.getByRole("button", { name: "CTA 버튼" })).toBeVisible();
await page.getByRole("link", { name: "프리뷰 열기" }).click();
await expect(page.getByRole("heading", { name: "Page Preview" })).toBeVisible();
await expect(page.getByText("지금 바로 행동을 유도하는 CTA 텍스트를 입력하세요.")).toBeVisible();
await expect(page.getByText("CTA 버튼")).toBeVisible();
});