Compare commits
25 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5f541e9524 | |||
| 64d2e9d1af | |||
| 64e4a59244 | |||
| 25162e4c12 | |||
| a6004a5caf | |||
| 23392d6ac2 | |||
| c0ddb39562 | |||
| 73f42078b7 | |||
| 278195772a | |||
| 0cf67b5619 | |||
| 17bfac62ed | |||
| 1f085bd64c | |||
| c9a62d479c | |||
| c7b6097bd9 | |||
| 6e5e229ae5 | |||
| af84bcbe8d | |||
| 55906f9d3d | |||
| 23bc96b3a1 | |||
| d4cf2f0225 | |||
| c5c9d17e8b | |||
| 1405280ed3 | |||
| 41e4238290 | |||
| e179035fbc | |||
| e1c91b1668 | |||
| 860eff514a |
+4
-23
@@ -5,24 +5,17 @@ on:
|
||||
branches:
|
||||
- main
|
||||
- feature/**
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: mcr.microsoft.com/playwright:v1.56.1-jammy
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
cache: npm
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
npm ci
|
||||
@@ -39,6 +32,7 @@ jobs:
|
||||
npx prisma generate
|
||||
|
||||
- name: Run unit tests
|
||||
if: github.event_name == 'push'
|
||||
env:
|
||||
# API 유닛 테스트에서도 PrismaClient가 필요하므로,
|
||||
# generate 단계와 동일한 더미 DATABASE_URL 을 사용한다.
|
||||
@@ -48,21 +42,8 @@ jobs:
|
||||
run: |
|
||||
npm test
|
||||
|
||||
# Playwright 브라우저는 용량이 크기 때문에 캐시를 사용해 설치 시간을 단축한다.
|
||||
- name: Cache Playwright browsers
|
||||
if: github.ref == 'refs/heads/main' || github.event_name == 'pull_request'
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ~/.cache/ms-playwright
|
||||
key: playwright-${{ runner.os }}-${{ hashFiles('package-lock.json') }}
|
||||
|
||||
- name: Install Playwright browsers
|
||||
if: github.ref == 'refs/heads/main' || github.event_name == 'pull_request'
|
||||
run: |
|
||||
npx playwright install --with-deps
|
||||
|
||||
- name: Run Playwright E2E tests
|
||||
if: github.ref == 'refs/heads/main' || github.event_name == 'pull_request'
|
||||
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
|
||||
run: |
|
||||
npx playwright test
|
||||
|
||||
|
||||
Generated
+145
-2
@@ -12,8 +12,11 @@
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@prisma/client": "^6.19.0",
|
||||
"bcryptjs": "^2.4.3",
|
||||
"dotenv": "^17.2.3",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"jszip": "^3.10.1",
|
||||
"lucide-react": "^0.555.0",
|
||||
"next": "^16.0.3",
|
||||
"react": "^19.2.0",
|
||||
"react-dom": "^19.2.0",
|
||||
@@ -25,6 +28,8 @@
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/react": "^16.3.0",
|
||||
"@testing-library/user-event": "^14.6.1",
|
||||
"@types/bcryptjs": "^2.4.2",
|
||||
"@types/jsonwebtoken": "^9.0.6",
|
||||
"@types/jszip": "^3.4.0",
|
||||
"@types/node": "^24.10.1",
|
||||
"@types/react": "^19.2.5",
|
||||
@@ -2788,6 +2793,13 @@
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/@types/bcryptjs": {
|
||||
"version": "2.4.6",
|
||||
"resolved": "https://registry.npmjs.org/@types/bcryptjs/-/bcryptjs-2.4.6.tgz",
|
||||
"integrity": "sha512-9xlo6R2qDs5uixm0bcIqCeMCE6HiQsIyel9KQySStiyqNl2tnj2mP3DX1Nf56MD6KMenNNlBBsy3LJ7gUEQPXQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/chai": {
|
||||
"version": "5.2.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz",
|
||||
@@ -2827,6 +2839,17 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/jsonwebtoken": {
|
||||
"version": "9.0.10",
|
||||
"resolved": "https://registry.npmjs.org/@types/jsonwebtoken/-/jsonwebtoken-9.0.10.tgz",
|
||||
"integrity": "sha512-asx5hIG9Qmf/1oStypjanR7iKTv0gXQ1Ov/jfrX6kS/EO0OFni8orbmGCn0672NHR3kXHwpAwR+B368ZGN/2rA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/ms": "*",
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/jszip": {
|
||||
"version": "3.4.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/jszip/-/jszip-3.4.0.tgz",
|
||||
@@ -2837,6 +2860,13 @@
|
||||
"jszip": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/ms": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz",
|
||||
"integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"version": "24.10.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.10.1.tgz",
|
||||
@@ -3923,6 +3953,12 @@
|
||||
"baseline-browser-mapping": "dist/cli.js"
|
||||
}
|
||||
},
|
||||
"node_modules/bcryptjs": {
|
||||
"version": "2.4.3",
|
||||
"resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-2.4.3.tgz",
|
||||
"integrity": "sha512-V/Hy/X9Vt7f3BbPJEi8BdVFMByHi+jNXrYkW3huaybV/kQ0KJg0Y6PkEMbn+zeT+i+SiKZ/HMqJGIIt4LZDqNQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/bidi-js": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz",
|
||||
@@ -3991,6 +4027,12 @@
|
||||
"node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
|
||||
}
|
||||
},
|
||||
"node_modules/buffer-equal-constant-time": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz",
|
||||
"integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==",
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/c12": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/c12/-/c12-3.1.0.tgz",
|
||||
@@ -4572,6 +4614,15 @@
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/ecdsa-sig-formatter": {
|
||||
"version": "1.0.11",
|
||||
"resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz",
|
||||
"integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"safe-buffer": "^5.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/effect": {
|
||||
"version": "3.18.4",
|
||||
"resolved": "https://registry.npmjs.org/effect/-/effect-3.18.4.tgz",
|
||||
@@ -6638,6 +6689,28 @@
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/jsonwebtoken": {
|
||||
"version": "9.0.2",
|
||||
"resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.2.tgz",
|
||||
"integrity": "sha512-PRp66vJ865SSqOlgqS8hujT5U4AOgMfhrwYIuIhfKaoSCZcirrmASQr8CX7cUg+RMih+hgznrjp99o+W4pJLHQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"jws": "^3.2.2",
|
||||
"lodash.includes": "^4.3.0",
|
||||
"lodash.isboolean": "^3.0.3",
|
||||
"lodash.isinteger": "^4.0.4",
|
||||
"lodash.isnumber": "^3.0.3",
|
||||
"lodash.isplainobject": "^4.0.6",
|
||||
"lodash.isstring": "^4.0.1",
|
||||
"lodash.once": "^4.0.0",
|
||||
"ms": "^2.1.1",
|
||||
"semver": "^7.5.4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12",
|
||||
"npm": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/jsx-ast-utils": {
|
||||
"version": "3.3.5",
|
||||
"resolved": "https://registry.npmjs.org/jsx-ast-utils/-/jsx-ast-utils-3.3.5.tgz",
|
||||
@@ -6666,6 +6739,27 @@
|
||||
"setimmediate": "^1.0.5"
|
||||
}
|
||||
},
|
||||
"node_modules/jwa": {
|
||||
"version": "1.4.2",
|
||||
"resolved": "https://registry.npmjs.org/jwa/-/jwa-1.4.2.tgz",
|
||||
"integrity": "sha512-eeH5JO+21J78qMvTIDdBXidBd6nG2kZjg5Ohz/1fpa28Z4CcsWUzJ1ZZyFq/3z3N17aZy+ZuBoHljASbL1WfOw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"buffer-equal-constant-time": "^1.0.1",
|
||||
"ecdsa-sig-formatter": "1.0.11",
|
||||
"safe-buffer": "^5.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/jws": {
|
||||
"version": "3.2.2",
|
||||
"resolved": "https://registry.npmjs.org/jws/-/jws-3.2.2.tgz",
|
||||
"integrity": "sha512-YHlZCB6lMTllWDtSPHz/ZXTsi8S00usEV6v1tjq8tOUZzw7DpSDWVXjXDre6ed1w/pd495ODpHZYSdkRTsa0HA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"jwa": "^1.4.1",
|
||||
"safe-buffer": "^5.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/keyv": {
|
||||
"version": "4.5.4",
|
||||
"resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
|
||||
@@ -7039,6 +7133,42 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/lodash.includes": {
|
||||
"version": "4.3.0",
|
||||
"resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz",
|
||||
"integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lodash.isboolean": {
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz",
|
||||
"integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lodash.isinteger": {
|
||||
"version": "4.0.4",
|
||||
"resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz",
|
||||
"integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lodash.isnumber": {
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz",
|
||||
"integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lodash.isplainobject": {
|
||||
"version": "4.0.6",
|
||||
"resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz",
|
||||
"integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lodash.isstring": {
|
||||
"version": "4.0.1",
|
||||
"resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz",
|
||||
"integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lodash.merge": {
|
||||
"version": "4.6.2",
|
||||
"resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz",
|
||||
@@ -7046,6 +7176,12 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lodash.once": {
|
||||
"version": "4.1.1",
|
||||
"resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz",
|
||||
"integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/log-update": {
|
||||
"version": "6.1.0",
|
||||
"resolved": "https://registry.npmjs.org/log-update/-/log-update-6.1.0.tgz",
|
||||
@@ -7089,6 +7225,15 @@
|
||||
"yallist": "^3.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/lucide-react": {
|
||||
"version": "0.555.0",
|
||||
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.555.0.tgz",
|
||||
"integrity": "sha512-D8FvHUGbxWBRQM90NZeIyhAvkFfsh3u9ekrMvJ30Z6gnpBHS6HC6ldLg7tL45hwiIz/u66eKDtdA23gwwGsAHA==",
|
||||
"license": "ISC",
|
||||
"peerDependencies": {
|
||||
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/lz-string": {
|
||||
"version": "1.5.0",
|
||||
"resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz",
|
||||
@@ -7201,7 +7346,6 @@
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/nano-spawn": {
|
||||
@@ -8332,7 +8476,6 @@
|
||||
"version": "7.7.3",
|
||||
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz",
|
||||
"integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==",
|
||||
"devOptional": true,
|
||||
"license": "ISC",
|
||||
"bin": {
|
||||
"semver": "bin/semver.js"
|
||||
|
||||
@@ -18,8 +18,11 @@
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@prisma/client": "^6.19.0",
|
||||
"bcryptjs": "^2.4.3",
|
||||
"dotenv": "^17.2.3",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"jszip": "^3.10.1",
|
||||
"lucide-react": "^0.555.0",
|
||||
"next": "^16.0.3",
|
||||
"react": "^19.2.0",
|
||||
"react-dom": "^19.2.0",
|
||||
@@ -32,6 +35,8 @@
|
||||
"@testing-library/react": "^16.3.0",
|
||||
"@testing-library/user-event": "^14.6.1",
|
||||
"@types/jszip": "^3.4.0",
|
||||
"@types/bcryptjs": "^2.4.2",
|
||||
"@types/jsonwebtoken": "^9.0.6",
|
||||
"@types/node": "^24.10.1",
|
||||
"@types/react": "^19.2.5",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "Project" ADD COLUMN "userId" TEXT;
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "User" (
|
||||
"id" TEXT NOT NULL,
|
||||
"email" TEXT NOT NULL,
|
||||
"passwordHash" TEXT NOT NULL,
|
||||
"tokenVersion" INTEGER NOT NULL DEFAULT 0,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "User_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "User_email_key" ON "User"("email");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Project" ADD CONSTRAINT "Project_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
+17
-2
@@ -5,8 +5,8 @@
|
||||
// Try Prisma Accelerate: https://pris.ly/cli/accelerate-init
|
||||
|
||||
generator client {
|
||||
provider = "prisma-client"
|
||||
output = "../src/generated/prisma"
|
||||
provider = "prisma-client-js"
|
||||
binaryTargets = ["native", "linux-musl-arm64-openssl-3.0.x"]
|
||||
}
|
||||
|
||||
datasource db {
|
||||
@@ -24,6 +24,21 @@ model Project {
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
assets Asset[]
|
||||
|
||||
// 인증 도입을 위한 관계: 프로젝트는 특정 User 가 소유할 수 있다.
|
||||
userId String?
|
||||
user User? @relation(fields: [userId], references: [id])
|
||||
}
|
||||
|
||||
model User {
|
||||
id String @id @default(uuid())
|
||||
email String @unique
|
||||
passwordHash String
|
||||
tokenVersion Int @default(0)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
projects Project[]
|
||||
}
|
||||
|
||||
model Asset {
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
|
||||
import { verifyPassword, signAccessToken } from "@/features/auth/authCrypto";
|
||||
|
||||
// /api/auth/login
|
||||
// - 이메일/비밀번호를 검증해 로그인시키고,
|
||||
// - JWT 액세스 토큰을 httpOnly 쿠키에 설정한 뒤, 비밀번호 해시를 제외한 유저 정보를 반환한다.
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const body = await request.json().catch(() => null as any);
|
||||
|
||||
const emailRaw = body?.email;
|
||||
const password: string | undefined = body?.password;
|
||||
|
||||
if (typeof emailRaw !== "string" || typeof password !== "string") {
|
||||
return NextResponse.json({ message: "email 과 password 는 필수입니다." }, { status: 400 });
|
||||
}
|
||||
|
||||
const email = emailRaw.trim().toLowerCase();
|
||||
|
||||
const user = await prisma.user.findUnique({ where: { email } });
|
||||
if (!user) {
|
||||
return NextResponse.json({ message: "이메일 또는 비밀번호가 올바르지 않습니다." }, { status: 401 });
|
||||
}
|
||||
|
||||
const ok = await verifyPassword(password, user.passwordHash);
|
||||
if (!ok) {
|
||||
return NextResponse.json({ message: "이메일 또는 비밀번호가 올바르지 않습니다." }, { status: 401 });
|
||||
}
|
||||
|
||||
const token = await signAccessToken({ id: user.id, email: user.email, tokenVersion: user.tokenVersion });
|
||||
|
||||
const { passwordHash: _ph, ...safeUser } = user;
|
||||
|
||||
const res = NextResponse.json(safeUser, { status: 200 });
|
||||
|
||||
res.cookies.set("pb_access", token, {
|
||||
httpOnly: true,
|
||||
secure: true,
|
||||
sameSite: "lax",
|
||||
path: "/",
|
||||
});
|
||||
|
||||
return res;
|
||||
} catch (error: any) {
|
||||
return NextResponse.json(
|
||||
{ message: "로그인 처리 중 오류가 발생했습니다.", error: error?.message },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
// /api/auth/logout
|
||||
// - 클라이언트의 pb_access 세션 쿠키를 제거해 로그아웃을 수행한다.
|
||||
|
||||
export async function POST(_request: Request) {
|
||||
const res = NextResponse.json({ message: "로그아웃 되었습니다." }, { status: 200 });
|
||||
|
||||
// pb_access 쿠키를 즉시 만료시켜 로그아웃 상태로 만든다.
|
||||
res.cookies.set("pb_access", "", {
|
||||
httpOnly: true,
|
||||
secure: true,
|
||||
sameSite: "lax",
|
||||
path: "/",
|
||||
maxAge: 0,
|
||||
});
|
||||
|
||||
return res;
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
import { verifyAccessToken } from "@/features/auth/authCrypto";
|
||||
|
||||
// /api/auth/me
|
||||
// - 요청 쿠키에 포함된 JWT(pb_access)를 검증하고,
|
||||
// - 유효한 경우 토큰에서 식별 가능한 유저 정보를 반환한다.
|
||||
|
||||
function extractTokenFromCookieHeader(cookieHeader: string | null): string | null {
|
||||
if (!cookieHeader) return null;
|
||||
|
||||
const parts = cookieHeader.split(";");
|
||||
for (const part of parts) {
|
||||
const trimmed = part.trim();
|
||||
if (trimmed.startsWith("pb_access=")) {
|
||||
const value = trimmed.substring("pb_access=".length);
|
||||
return decodeURIComponent(value);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function GET(request: Request) {
|
||||
const cookieHeader = request.headers.get("cookie");
|
||||
const token = extractTokenFromCookieHeader(cookieHeader);
|
||||
|
||||
if (!token) {
|
||||
return NextResponse.json({ message: "인증 정보가 없습니다." }, { status: 401 });
|
||||
}
|
||||
|
||||
const payload = await verifyAccessToken(token);
|
||||
|
||||
if (!payload) {
|
||||
return NextResponse.json({ message: "유효하지 않은 토큰입니다." }, { status: 401 });
|
||||
}
|
||||
|
||||
const user = {
|
||||
id: payload.sub,
|
||||
email: payload.email,
|
||||
tokenVersion: payload.tokenVersion,
|
||||
};
|
||||
|
||||
return NextResponse.json(user, { status: 200 });
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
|
||||
import { hashPassword, signAccessToken } from "@/features/auth/authCrypto";
|
||||
|
||||
// /api/auth/signup
|
||||
// - 이메일/비밀번호를 받아 새 유저를 생성하고,
|
||||
// - JWT 액세스 토큰을 httpOnly 쿠키에 설정한 뒤, 비밀번호 해시를 제외한 유저 정보를 반환한다.
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const body = await request.json().catch(() => null as any);
|
||||
|
||||
const emailRaw = body?.email;
|
||||
const password: string | undefined = body?.password;
|
||||
|
||||
if (typeof emailRaw !== "string" || typeof password !== "string") {
|
||||
return NextResponse.json({ message: "email 과 password 는 필수입니다." }, { status: 400 });
|
||||
}
|
||||
|
||||
const email = emailRaw.trim().toLowerCase();
|
||||
|
||||
if (password.length < 8) {
|
||||
return NextResponse.json({ message: "비밀번호는 최소 8자 이상이어야 합니다." }, { status: 400 });
|
||||
}
|
||||
|
||||
// 이미 존재하는 이메일인지 확인한다.
|
||||
const existing = await prisma.user.findUnique({ where: { email } });
|
||||
if (existing) {
|
||||
return NextResponse.json({ message: "이미 가입된 이메일입니다." }, { status: 409 });
|
||||
}
|
||||
|
||||
// 비밀번호를 해시한 뒤 유저를 생성한다.
|
||||
const passwordHash = await hashPassword(password);
|
||||
|
||||
const user = await prisma.user.create({
|
||||
data: {
|
||||
email,
|
||||
passwordHash,
|
||||
tokenVersion: 1,
|
||||
},
|
||||
});
|
||||
|
||||
const token = await signAccessToken({
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
tokenVersion: user.tokenVersion,
|
||||
});
|
||||
|
||||
const { passwordHash: _ph, ...safeUser } = user;
|
||||
|
||||
const res = NextResponse.json(safeUser, { status: 201 });
|
||||
|
||||
res.cookies.set("pb_access", token, {
|
||||
httpOnly: true,
|
||||
secure: true,
|
||||
sameSite: "lax",
|
||||
path: "/",
|
||||
});
|
||||
|
||||
return res;
|
||||
} catch (error: any) {
|
||||
return NextResponse.json(
|
||||
{ message: "회원가입 처리 중 오류가 발생했습니다.", error: error?.message },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import type { Block, ProjectConfig } from "@/features/editor/state/editorStore";
|
||||
import { buildStaticHtml } from "@/app/api/export/route";
|
||||
|
||||
// /api/export/preview 라우트는 Export 결과 HTML 을 ZIP 없이 바로 반환하는
|
||||
// 경량 프리뷰용 엔드포인트이다. 에디터에서 Export 미리보기 UX 를 제공하기 위해 사용한다.
|
||||
|
||||
type PreviewRequestBody = {
|
||||
blocks: Block[];
|
||||
projectConfig?: ProjectConfig;
|
||||
};
|
||||
|
||||
export async function POST(request: Request) {
|
||||
let body: PreviewRequestBody;
|
||||
|
||||
try {
|
||||
// JSON 본문을 파싱해서 blocks + projectConfig 를 추출한다.
|
||||
body = (await request.json()) as PreviewRequestBody;
|
||||
} catch {
|
||||
// 잘못된 JSON 요청에 대해서는 400 에러를 반환한다.
|
||||
return NextResponse.json({ message: "잘못된 JSON 요청입니다." }, { status: 400 });
|
||||
}
|
||||
|
||||
const blocks = Array.isArray(body.blocks) ? body.blocks : [];
|
||||
const projectConfig = body.projectConfig;
|
||||
|
||||
// Export 본문 HTML 은 기존 buildStaticHtml 을 재사용해서 생성한다.
|
||||
// 이렇게 하면 /api/export ZIP 라우트와 동일한 정적 HTML 을 얻을 수 있다.
|
||||
const html = buildStaticHtml(blocks, projectConfig);
|
||||
|
||||
// Export 미리보기는 ZIP 이 아니라 순수 HTML 문자열을 반환한다.
|
||||
return new NextResponse(html, {
|
||||
status: 200,
|
||||
headers: {
|
||||
"Content-Type": "text/html; charset=utf-8",
|
||||
},
|
||||
});
|
||||
}
|
||||
+99
-98
@@ -160,6 +160,23 @@ export const buildStaticHtml = (blocks: Block[], projectConfig?: ProjectConfig):
|
||||
const sectionBlocks = blocks.filter((b) => b.type === "section");
|
||||
const rootBlocks = blocks.filter((b) => !b.sectionId && b.type !== "section");
|
||||
|
||||
// Form 컨트롤러(FormBlock)와 개별 필드 블록(formInput/formSelect/...) 를 연결하기 위한 맵.
|
||||
// - key: 필드 블록 id (fieldIds 에 포함된 id)
|
||||
// - value: 해당 필드가 속한 <form> 요소의 DOM id (예: form_form_1)
|
||||
const fieldIdToFormId = new Map<string, string>();
|
||||
for (const block of blocks) {
|
||||
if (block.type !== "form") continue;
|
||||
const props = (block.props ?? {}) as FormBlockProps;
|
||||
const fieldIds = Array.isArray(props.fieldIds) ? props.fieldIds : [];
|
||||
if (fieldIds.length === 0) continue;
|
||||
const formDomId = `form_${block.id}`;
|
||||
for (const fieldId of fieldIds) {
|
||||
if (typeof fieldId === "string" && fieldId.trim() !== "") {
|
||||
fieldIdToFormId.set(fieldId, formDomId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const renderBlock = (block: Block): string => {
|
||||
if (block.type === "text") {
|
||||
const props = (block.props ?? {}) as TextBlockProps;
|
||||
@@ -316,107 +333,35 @@ export const buildStaticHtml = (blocks: Block[], projectConfig?: ProjectConfig):
|
||||
const props = (block.props ?? {}) as FormBlockProps;
|
||||
const tokens = computeFormBlockExportTokens(block, blocks);
|
||||
const controllerFields = tokens.hasControllerFields ? tokens.controllerFields : [];
|
||||
const fallbackFields = tokens.fallbackFields;
|
||||
|
||||
const hasControllerFields = controllerFields.length > 0;
|
||||
const hasFallbackFields = fallbackFields.length > 0;
|
||||
|
||||
// FormBlock 이 컨트롤러/필드 정보를 전혀 가지고 있지 않으면 정적 HTML 에서는 아무 것도 렌더하지 않는다.
|
||||
if (!hasControllerFields && !hasFallbackFields) {
|
||||
// FormBlock 이 컨트롤러 필드 정보를 전혀 가지고 있지 않으면
|
||||
// Export 레이어에서는 아무 것도 렌더하지 않는다 (fallback fields 기반 pb-form 도 생성하지 않음).
|
||||
if (!hasControllerFields) {
|
||||
return "";
|
||||
}
|
||||
|
||||
const formParts: string[] = [];
|
||||
const formStyleAttr = tokens.formStyleParts.length > 0 ? ` style="${tokens.formStyleParts.join(";")}"` : "";
|
||||
// 컨트롤러 전용 FormBlock: fieldIds 기반으로 개별 필드 블록과 연결되는 순수 컨트롤러 폼만 생성한다.
|
||||
// - Export 에서는 레이아웃/스타일 대신 id/메서드/액션만 가지는 최소한의 <form> 요소만 만든다.
|
||||
// - action 은 퍼블릭 페이지에서 우리 서버의 forms/submit 엔드포인트로 전송하기 위한 고정 경로다.
|
||||
const formId = `form_${block.id}`;
|
||||
const method = props.method ?? "POST";
|
||||
const methodAttr = ` method="${method.toLowerCase()}"`;
|
||||
const actionAttr = ` action="/api/forms/submit"`;
|
||||
|
||||
formParts.push(`<form class="pb-form" method="post"${formStyleAttr}>`);
|
||||
// 정적 Export 에서도 FormBlock 설정(전송 대상/웹훅 설정 등)을 함께 전달할 수 있도록
|
||||
// preview 렌더러와 동일하게 __config hidden 필드에 FormBlockProps 전체를 JSON 으로 담아둔다.
|
||||
const configJson = JSON.stringify(props ?? {});
|
||||
const escapedConfig = escapeAttr(configJson);
|
||||
|
||||
if (hasControllerFields) {
|
||||
for (const fieldBlock of controllerFields) {
|
||||
const anyProps: any = fieldBlock.props ?? {};
|
||||
const name = typeof anyProps.formFieldName === "string" ? anyProps.formFieldName : fieldBlock.id;
|
||||
const label =
|
||||
typeof anyProps.label === "string" || typeof anyProps.groupLabel === "string"
|
||||
? (anyProps.label ?? anyProps.groupLabel)
|
||||
: name;
|
||||
|
||||
const textColorRaw =
|
||||
typeof anyProps.textColorCustom === "string" && anyProps.textColorCustom.trim() !== ""
|
||||
? anyProps.textColorCustom.trim()
|
||||
: "";
|
||||
const labelStyleAttr = textColorRaw ? ` style="color:${escapeAttr(textColorRaw)}"` : "";
|
||||
const fieldTextStyleAttr = textColorRaw ? ` style="color:${escapeAttr(textColorRaw)}"` : "";
|
||||
|
||||
formParts.push(`<div class="pb-form-field">`);
|
||||
formParts.push(`<label class="pb-form-label"${labelStyleAttr}>${escapeHtml(label ?? "")}</label>`);
|
||||
|
||||
if (fieldBlock.type === "formInput") {
|
||||
const type = anyProps.inputType === "email" ? "email" : "text";
|
||||
const required = anyProps.required ? " required" : "";
|
||||
formParts.push(
|
||||
`<input class="pb-input" type="${type}" name="${escapeAttr(name)}" placeholder="${escapeAttr(
|
||||
label ?? "",
|
||||
)}"${required}${fieldTextStyleAttr} />`,
|
||||
);
|
||||
} else if (fieldBlock.type === "formSelect") {
|
||||
const required = anyProps.required ? " required" : "";
|
||||
const options: FormSelectOption[] = Array.isArray(anyProps.options) ? anyProps.options : [];
|
||||
formParts.push(`<select class="pb-select" name="${escapeAttr(name)}"${required}${fieldTextStyleAttr}>`);
|
||||
for (const opt of options) {
|
||||
formParts.push(
|
||||
`<option value="${escapeAttr(opt.value)}">${escapeHtml(opt.label)}</option>`,
|
||||
);
|
||||
}
|
||||
formParts.push(`</select>`);
|
||||
} else if (fieldBlock.type === "formCheckbox") {
|
||||
const options = Array.isArray(anyProps.options) ? anyProps.options : [];
|
||||
for (const opt of options) {
|
||||
formParts.push(
|
||||
`<label class="pb-form-option"${fieldTextStyleAttr}><input type="checkbox" name="${escapeAttr(
|
||||
name,
|
||||
)}" value="${escapeAttr(opt.value)}" /> ${escapeHtml(opt.label)}</label>`,
|
||||
);
|
||||
}
|
||||
} else if (fieldBlock.type === "formRadio") {
|
||||
const options = Array.isArray(anyProps.options) ? anyProps.options : [];
|
||||
for (const opt of options) {
|
||||
formParts.push(
|
||||
`<label class="pb-form-option"${fieldTextStyleAttr}><input type="radio" name="${escapeAttr(
|
||||
name,
|
||||
)}" value="${escapeAttr(opt.value)}" /> ${escapeHtml(opt.label)}</label>`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
formParts.push(`</div>`);
|
||||
}
|
||||
} else if (hasFallbackFields) {
|
||||
for (const field of fallbackFields) {
|
||||
const required = field.required ? " required" : "";
|
||||
const label = field.label ?? field.name;
|
||||
formParts.push(`<div class="pb-form-field">`);
|
||||
formParts.push(`<label class="pb-form-label">${escapeHtml(label)}</label>`);
|
||||
if (field.type === "textarea") {
|
||||
formParts.push(
|
||||
`<textarea class="pb-textarea" name="${escapeAttr(field.name)}" placeholder="${escapeAttr(
|
||||
label,
|
||||
)}"${required}></textarea>`,
|
||||
);
|
||||
} else {
|
||||
formParts.push(
|
||||
`<input class="pb-input" type="${field.type}" name="${escapeAttr(
|
||||
field.name,
|
||||
)}" placeholder="${escapeAttr(label)}"${required} />`,
|
||||
);
|
||||
}
|
||||
formParts.push(`</div>`);
|
||||
}
|
||||
}
|
||||
|
||||
formParts.push(`<button type="submit" class="pb-btn-base pb-btn-size-md pb-btn-variant-solid-primary">제출</button>`);
|
||||
formParts.push(`</form>`);
|
||||
|
||||
return formParts.join("");
|
||||
const parts: string[] = [];
|
||||
parts.push(
|
||||
`<form id="${escapeAttr(formId)}" class="pb-form-controller"${methodAttr}${actionAttr}>`,
|
||||
);
|
||||
parts.push(`<input type="hidden" name="__config" value="${escapedConfig}" />`);
|
||||
parts.push("</form>");
|
||||
return parts.join("");
|
||||
}
|
||||
|
||||
if (block.type === "divider") {
|
||||
@@ -449,13 +394,15 @@ export const buildStaticHtml = (blocks: Block[], projectConfig?: ProjectConfig):
|
||||
const required = props.required ? " required" : "";
|
||||
|
||||
const tokens = computeFormInputExportTokens(props, { escapeAttr });
|
||||
const formId = fieldIdToFormId.get(block.id);
|
||||
const formAttr = formId ? ` form="${escapeAttr(formId)}"` : "";
|
||||
|
||||
return [
|
||||
'<div class="pb-form-field">',
|
||||
`<label class="pb-form-label"${tokens.labelStyleAttr}>${escapeHtml(label)}</label>`,
|
||||
`<input class="pb-input" type="${type}" name="${escapeAttr(name)}" placeholder="${escapeAttr(
|
||||
label,
|
||||
)}"${required}${tokens.inputStyleAttr} />`,
|
||||
)}"${required}${tokens.inputStyleAttr}${formAttr} />`,
|
||||
"</div>",
|
||||
].join("");
|
||||
}
|
||||
@@ -469,11 +416,15 @@ export const buildStaticHtml = (blocks: Block[], projectConfig?: ProjectConfig):
|
||||
const options: FormSelectOption[] = Array.isArray(props.options) ? props.options : [];
|
||||
|
||||
const tokens = computeFormSelectExportTokens(props, { escapeAttr });
|
||||
const formId = fieldIdToFormId.get(block.id);
|
||||
const formAttr = formId ? ` form="${escapeAttr(formId)}"` : "";
|
||||
|
||||
const parts: string[] = [];
|
||||
parts.push('<div class="pb-form-field">');
|
||||
parts.push(`<label class="pb-form-label"${tokens.labelStyleAttr}>${escapeHtml(label)}</label>`);
|
||||
parts.push(`<select class="pb-select" name="${escapeAttr(name)}"${required}${tokens.selectStyleAttr}>`);
|
||||
parts.push(
|
||||
`<select class="pb-select" name="${escapeAttr(name)}"${required}${tokens.selectStyleAttr}${formAttr}>`,
|
||||
);
|
||||
for (const opt of options) {
|
||||
parts.push(
|
||||
`<option value="${escapeAttr(opt.value)}">${escapeHtml(opt.label)}</option>`,
|
||||
@@ -495,6 +446,8 @@ export const buildStaticHtml = (blocks: Block[], projectConfig?: ProjectConfig):
|
||||
const options = Array.isArray(props.options) ? props.options : [];
|
||||
|
||||
const tokens = computeFormCheckboxExportTokens(props, { escapeAttr });
|
||||
const formId = fieldIdToFormId.get(block.id);
|
||||
const formAttr = formId ? ` form="${escapeAttr(formId)}"` : "";
|
||||
|
||||
const parts: string[] = [];
|
||||
parts.push('<div class="pb-form-field">');
|
||||
@@ -503,7 +456,7 @@ export const buildStaticHtml = (blocks: Block[], projectConfig?: ProjectConfig):
|
||||
parts.push(
|
||||
`<label class="pb-form-option"${tokens.optionStyleAttr}><input type="checkbox" name="${escapeAttr(
|
||||
name,
|
||||
)}" value="${escapeAttr(opt.value)}" /> ${escapeHtml(opt.label)}</label>`,
|
||||
)}" value="${escapeAttr(opt.value)}"${formAttr} /> ${escapeHtml(opt.label)}</label>`,
|
||||
);
|
||||
}
|
||||
parts.push("</div>");
|
||||
@@ -521,6 +474,8 @@ export const buildStaticHtml = (blocks: Block[], projectConfig?: ProjectConfig):
|
||||
const options = Array.isArray(props.options) ? props.options : [];
|
||||
|
||||
const tokens = computeFormRadioExportTokens(props, { escapeAttr });
|
||||
const formId = fieldIdToFormId.get(block.id);
|
||||
const formAttr = formId ? ` form="${escapeAttr(formId)}"` : "";
|
||||
|
||||
const parts: string[] = [];
|
||||
parts.push('<div class="pb-form-field">');
|
||||
@@ -529,7 +484,7 @@ export const buildStaticHtml = (blocks: Block[], projectConfig?: ProjectConfig):
|
||||
parts.push(
|
||||
`<label class="pb-form-option"${tokens.optionStyleAttr}><input type="radio" name="${escapeAttr(
|
||||
name,
|
||||
)}" value="${escapeAttr(opt.value)}" /> ${escapeHtml(opt.label)}</label>`,
|
||||
)}" value="${escapeAttr(opt.value)}"${formAttr} /> ${escapeHtml(opt.label)}</label>`,
|
||||
);
|
||||
}
|
||||
parts.push("</div>");
|
||||
@@ -674,7 +629,53 @@ export async function POST(request: Request) {
|
||||
zip.file("builder.css", "/* builder.css 를 찾을 수 없습니다. */\n");
|
||||
}
|
||||
|
||||
const mainJs = `console.log("Page Builder static export loaded");`;
|
||||
const mainJs = [
|
||||
"(function(){",
|
||||
" function pbInitFormControllers(){",
|
||||
" var forms = document.querySelectorAll(\"form.pb-form-controller\");",
|
||||
" if (!forms || !forms.length) { return; }",
|
||||
" forms.forEach(function(form){",
|
||||
" form.addEventListener(\"submit\", function(event){",
|
||||
" if (!form) { return; }",
|
||||
" if (event && typeof event.preventDefault === 'function') { event.preventDefault(); }",
|
||||
" var formData = new FormData(form);",
|
||||
" var configInput = form.querySelector('input[name=\"__config\"]');",
|
||||
" var config = null;",
|
||||
" if (configInput && configInput.value) {",
|
||||
" try { config = JSON.parse(configInput.value); } catch (e) { config = null; }",
|
||||
" }",
|
||||
" var action = form.getAttribute('action') || '/api/forms/submit';",
|
||||
" fetch(action, { method: 'POST', body: formData })",
|
||||
" .then(function(res){ return res.json().catch(function(){ return {}; }); })",
|
||||
" .then(function(data){",
|
||||
" var ok = data && data.ok;",
|
||||
" var message = data && data.message;",
|
||||
" if (ok) {",
|
||||
" var success = message || (config && config.successMessage) || '성공적으로 전송되었습니다.';",
|
||||
" if (typeof alert === 'function') { alert(success); }",
|
||||
" try { form.reset(); } catch (e) {}",
|
||||
" } else {",
|
||||
" var errorMsg = message || (config && config.errorMessage) || '전송 중 오류가 발생했습니다.';",
|
||||
" if (typeof alert === 'function') { alert(errorMsg); }",
|
||||
" }",
|
||||
" })",
|
||||
" .catch(function(){",
|
||||
" var errorMsg = (config && config.errorMessage) || '전송 중 오류가 발생했습니다.';",
|
||||
" if (typeof alert === 'function') { alert(errorMsg); }",
|
||||
" });",
|
||||
" });",
|
||||
" });",
|
||||
" }",
|
||||
" if (typeof document !== 'undefined') {",
|
||||
" if (document.readyState === 'loading') {",
|
||||
" document.addEventListener('DOMContentLoaded', pbInitFormControllers);",
|
||||
" } else {",
|
||||
" pbInitFormControllers();",
|
||||
" }",
|
||||
" }",
|
||||
" console.log('Page Builder static export loaded');",
|
||||
"})();",
|
||||
].join("\n");
|
||||
zip.file("main.js", mainJs);
|
||||
|
||||
const zipArrayBuffer = await zip.generateAsync({ type: "arraybuffer" });
|
||||
|
||||
@@ -31,8 +31,12 @@ export async function POST(req: Request) {
|
||||
return NextResponse.json({ ok: true, message: successMessage });
|
||||
}
|
||||
|
||||
if (submitTarget === "both") {
|
||||
console.log("[forms/submit][internal]", { name, email, message });
|
||||
}
|
||||
|
||||
// 2) webhook: destinationUrl 로 서버에서 POST (Google Sheets 등 포함)
|
||||
if (submitTarget === "webhook") {
|
||||
if (submitTarget === "webhook" || submitTarget === "both") {
|
||||
const destinationUrl = config?.destinationUrl;
|
||||
if (!destinationUrl) {
|
||||
return NextResponse.json(
|
||||
|
||||
@@ -1,40 +1,36 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { promises as fs } from "fs";
|
||||
import path from "path";
|
||||
import { PrismaClient } from "@/generated/prisma/client";
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
|
||||
const UPLOAD_DIR = path.join(process.cwd(), "uploads");
|
||||
|
||||
let prisma: PrismaClient | null = null;
|
||||
try {
|
||||
prisma = new PrismaClient();
|
||||
} catch (error) {
|
||||
// DB 가 준비되지 않은 환경에서는 mimeType 조회 없이 기본값만 사용한다.
|
||||
console.error("PrismaClient 초기화 실패 - 기본 MIME 타입으로만 응답합니다.", error);
|
||||
prisma = null;
|
||||
if (process.env.DATABASE_URL && process.env.DATABASE_URL.trim() !== "") {
|
||||
try {
|
||||
prisma = new PrismaClient();
|
||||
} catch (error) {
|
||||
// DB 가 준비되지 않은 환경에서는 mimeType 조회 없이 기본값만 사용한다.
|
||||
console.error("PrismaClient 초기화 실패 - 기본 MIME 타입으로만 응답합니다.", error);
|
||||
prisma = null;
|
||||
}
|
||||
}
|
||||
|
||||
interface Params {
|
||||
params: {
|
||||
id: string;
|
||||
};
|
||||
}
|
||||
export async function GET(request: Request) {
|
||||
// Next.js 15 이후 params 가 Promise 로 전달되는 경고를 피하기 위해,
|
||||
// 동적 세그먼트는 request.url 의 path 에서 직접 파싱한다.
|
||||
let id: string | undefined;
|
||||
|
||||
export async function GET(request: Request, ctx: Params) {
|
||||
// Next.js 가 params 를 항상 보장하지 않는 상황을 대비해, URL 경로에서 id 를 보완적으로 파싱한다.
|
||||
let id: string | undefined = ctx?.params?.id;
|
||||
if (!id) {
|
||||
try {
|
||||
const url = new URL(request.url);
|
||||
const segments = url.pathname.split("/").filter(Boolean);
|
||||
// [..., "api", "image", ":id"] 형태를 기대한다.
|
||||
const last = segments[segments.length - 1];
|
||||
if (last && last !== "image") {
|
||||
id = last;
|
||||
}
|
||||
} catch {
|
||||
// URL 파싱 실패 시에는 그대로 둔다.
|
||||
try {
|
||||
const url = new URL(request.url);
|
||||
const segments = url.pathname.split("/").filter(Boolean);
|
||||
// [..., "api", "image", ":id"] 형태를 기대한다.
|
||||
const last = segments[segments.length - 1];
|
||||
if (last && last !== "image") {
|
||||
id = last;
|
||||
}
|
||||
} catch {
|
||||
// URL 파싱 실패 시에는 그대로 둔다.
|
||||
}
|
||||
|
||||
if (!id) {
|
||||
|
||||
@@ -2,19 +2,21 @@ import { NextResponse } from "next/server";
|
||||
import { promises as fs } from "fs";
|
||||
import path from "path";
|
||||
import { randomUUID } from "crypto";
|
||||
import { PrismaClient } from "@/generated/prisma/client";
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
|
||||
// 업로드된 이미지 파일을 저장할 디렉터리 (프로젝트 루트 기준)
|
||||
const UPLOAD_DIR = path.join(process.cwd(), "uploads");
|
||||
|
||||
let prisma: PrismaClient | null = null;
|
||||
try {
|
||||
prisma = new PrismaClient();
|
||||
} catch (error) {
|
||||
// DB 가 준비되지 않은 개발/테스트 환경에서도 업로드 자체는 동작할 수 있도록,
|
||||
// Prisma 초기화 실패는 치명적 오류로 취급하지 않고 로그만 남긴다.
|
||||
console.error("PrismaClient 초기화 실패 - 파일 시스템 전용 모드로 동작합니다.", error);
|
||||
prisma = null;
|
||||
if (process.env.DATABASE_URL && process.env.DATABASE_URL.trim() !== "") {
|
||||
try {
|
||||
prisma = new PrismaClient();
|
||||
} catch (error) {
|
||||
// DB 가 준비되지 않은 개발/테스트 환경에서도 업로드 자체는 동작할 수 있도록,
|
||||
// Prisma 초기화 실패는 치명적 오류로 취급하지 않고 로그만 남긴다.
|
||||
console.error("PrismaClient 초기화 실패 - 파일 시스템 전용 모드로 동작합니다.", error);
|
||||
prisma = null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
|
||||
@@ -1,24 +1,100 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { PrismaClient } from "@/generated/prisma/client";
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
import { verifyAccessToken } from "@/features/auth/authCrypto";
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
interface Params {
|
||||
params: {
|
||||
slug: string;
|
||||
interface AuthUser {
|
||||
id: string;
|
||||
email: string;
|
||||
tokenVersion: number;
|
||||
}
|
||||
|
||||
function extractTokenFromCookieHeader(cookieHeader: string | null): string | null {
|
||||
if (!cookieHeader) return null;
|
||||
|
||||
const parts = cookieHeader.split(";");
|
||||
for (const part of parts) {
|
||||
const trimmed = part.trim();
|
||||
if (trimmed.startsWith("pb_access=")) {
|
||||
const value = trimmed.substring("pb_access=".length);
|
||||
return decodeURIComponent(value);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function getAuthUserFromRequest(request: Request): Promise<AuthUser | null> {
|
||||
const cookieHeader = request.headers.get("cookie");
|
||||
const token = extractTokenFromCookieHeader(cookieHeader);
|
||||
if (!token) return null;
|
||||
|
||||
const payload = await verifyAccessToken(token);
|
||||
if (!payload) return null;
|
||||
|
||||
return {
|
||||
id: payload.sub,
|
||||
email: payload.email,
|
||||
tokenVersion: payload.tokenVersion,
|
||||
};
|
||||
}
|
||||
|
||||
export async function GET(_request: Request, { params }: Params) {
|
||||
const { slug } = params;
|
||||
export async function GET(
|
||||
request: Request,
|
||||
context: { params: { slug: string } | Promise<{ slug: string }> },
|
||||
) {
|
||||
const authUser = await getAuthUserFromRequest(request);
|
||||
if (!authUser) {
|
||||
return NextResponse.json({ message: "프로젝트를 조회하려면 로그인이 필요합니다." }, { status: 401 });
|
||||
}
|
||||
|
||||
const { slug } = await context.params;
|
||||
|
||||
const project = await prisma.project.findUnique({
|
||||
where: { slug },
|
||||
});
|
||||
|
||||
if (!project) {
|
||||
if (!project || (project.userId && project.userId !== authUser.id)) {
|
||||
return NextResponse.json({ message: "프로젝트를 찾을 수 없습니다." }, { status: 404 });
|
||||
}
|
||||
|
||||
return NextResponse.json(project, { status: 200 });
|
||||
}
|
||||
|
||||
export async function DELETE(
|
||||
request: Request,
|
||||
context: { params: { slug: string } | Promise<{ slug: string }> },
|
||||
) {
|
||||
const authUser = await getAuthUserFromRequest(request);
|
||||
if (!authUser) {
|
||||
return NextResponse.json({ message: "프로젝트를 삭제하려면 로그인이 필요합니다." }, { status: 401 });
|
||||
}
|
||||
|
||||
const { slug } = await context.params;
|
||||
|
||||
try {
|
||||
const project = await prisma.project.findUnique({
|
||||
where: { slug },
|
||||
});
|
||||
|
||||
if (!project || (project.userId && project.userId !== authUser.id)) {
|
||||
return NextResponse.json({ message: "프로젝트를 찾을 수 없습니다." }, { status: 404 });
|
||||
}
|
||||
|
||||
await prisma.project.delete({
|
||||
where: { slug },
|
||||
});
|
||||
|
||||
return NextResponse.json({ message: "프로젝트가 삭제되었습니다." }, { status: 200 });
|
||||
} catch (error: any) {
|
||||
// Prisma NotFound 에러 코드(P2025)가 떨어지는 경우 404 로 매핑한다.
|
||||
if (error?.code === "P2025") {
|
||||
return NextResponse.json({ message: "프로젝트를 찾을 수 없습니다." }, { status: 404 });
|
||||
}
|
||||
|
||||
return NextResponse.json(
|
||||
{ message: "프로젝트 삭제 중 오류가 발생했습니다.", error: error?.message },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,80 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { PrismaClient } from "@/generated/prisma/client";
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
import { verifyAccessToken } from "@/features/auth/authCrypto";
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
interface AuthUser {
|
||||
id: string;
|
||||
email: string;
|
||||
tokenVersion: number;
|
||||
}
|
||||
|
||||
function extractTokenFromCookieHeader(cookieHeader: string | null): string | null {
|
||||
if (!cookieHeader) return null;
|
||||
|
||||
const parts = cookieHeader.split(";");
|
||||
for (const part of parts) {
|
||||
const trimmed = part.trim();
|
||||
if (trimmed.startsWith("pb_access=")) {
|
||||
const value = trimmed.substring("pb_access=".length);
|
||||
return decodeURIComponent(value);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function getAuthUserFromRequest(request: Request): Promise<AuthUser | null> {
|
||||
const cookieHeader = request.headers.get("cookie");
|
||||
const token = extractTokenFromCookieHeader(cookieHeader);
|
||||
if (!token) return null;
|
||||
|
||||
const payload = await verifyAccessToken(token);
|
||||
if (!payload) return null;
|
||||
|
||||
return {
|
||||
id: payload.sub,
|
||||
email: payload.email,
|
||||
tokenVersion: payload.tokenVersion,
|
||||
};
|
||||
}
|
||||
|
||||
export async function GET(request: Request) {
|
||||
const authUser = await getAuthUserFromRequest(request);
|
||||
if (!authUser) {
|
||||
return NextResponse.json({ message: "프로젝트 목록을 조회하려면 로그인이 필요합니다." }, { status: 401 });
|
||||
}
|
||||
|
||||
try {
|
||||
const projects = await prisma.project.findMany({
|
||||
where: { userId: authUser.id },
|
||||
orderBy: { createdAt: "desc" },
|
||||
take: 50,
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
slug: true,
|
||||
status: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json(projects, { status: 200 });
|
||||
} catch (error: any) {
|
||||
return NextResponse.json(
|
||||
{ message: "프로젝트 목록을 가져오는 중 오류가 발생했습니다.", error: error?.message },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const authUser = await getAuthUserFromRequest(request);
|
||||
if (!authUser) {
|
||||
return NextResponse.json({ message: "프로젝트를 저장하려면 로그인이 필요합니다." }, { status: 401 });
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
const { title, slug, contentJson } = body;
|
||||
|
||||
@@ -12,11 +83,28 @@ export async function POST(request: Request) {
|
||||
}
|
||||
|
||||
try {
|
||||
const project = await prisma.project.create({
|
||||
data: {
|
||||
const existing = await prisma.project.findUnique({
|
||||
where: { slug },
|
||||
});
|
||||
|
||||
if (existing && existing.userId && existing.userId !== authUser.id) {
|
||||
return NextResponse.json(
|
||||
{ message: "이미 다른 사용자가 사용 중인 프로젝트 주소입니다." },
|
||||
{ status: 409 },
|
||||
);
|
||||
}
|
||||
|
||||
const project = await prisma.project.upsert({
|
||||
where: { slug },
|
||||
update: {
|
||||
title,
|
||||
contentJson,
|
||||
},
|
||||
create: {
|
||||
title,
|
||||
slug,
|
||||
contentJson,
|
||||
userId: authUser.id,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { promises as fs } from "fs";
|
||||
import path from "path";
|
||||
import { PrismaClient } from "@/generated/prisma/client";
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
|
||||
const UPLOAD_DIR = path.join(process.cwd(), "uploads");
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ import { NextResponse } from "next/server";
|
||||
import { promises as fs } from "fs";
|
||||
import path from "path";
|
||||
import { randomUUID } from "crypto";
|
||||
import { PrismaClient } from "@/generated/prisma/client";
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
|
||||
// 업로드된 비디오 파일을 저장할 디렉터리 (프로젝트 루트 기준)
|
||||
const UPLOAD_DIR = path.join(process.cwd(), "uploads");
|
||||
|
||||
@@ -86,7 +86,7 @@ export function EditorCanvas(props: EditorCanvasProps) {
|
||||
>
|
||||
{blocks.length === 0 ? (
|
||||
<div className="flex-1 flex items-center justify-center text-slate-500 text-xs">
|
||||
왼쪽에서 "텍스트 블록 추가" 버튼을 눌러 블록을 추가해 보세요.
|
||||
왼쪽에서 "텍스트" 버튼을 눌러 블록을 추가해 보세요.
|
||||
</div>
|
||||
) : (
|
||||
<DndContext
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import type { Block, ButtonBlockProps, FormBlockProps } from "@/features/editor/state/editorStore";
|
||||
import { NumericPropertyControl } from "@/features/editor/components/NumericPropertyControl";
|
||||
|
||||
interface FormControllerPanelProps {
|
||||
block: Block; // type === "form"
|
||||
@@ -129,6 +130,65 @@ export function FormControllerPanel({ block, blocks, selectedBlockId, updateBloc
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2 border-t border-slate-800 pt-3 mt-4">
|
||||
<h3 className="text-[11px] font-semibold text-slate-200">폼 레이아웃</h3>
|
||||
<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-950 px-2 py-1 text-[11px] outline-none focus:border-sky-500"
|
||||
value={formProps.formWidthMode ?? "auto"}
|
||||
onChange={(e) =>
|
||||
updateBlock(selectedBlockId, {
|
||||
formWidthMode: e.target.value as FormBlockProps["formWidthMode"],
|
||||
} as any)
|
||||
}
|
||||
>
|
||||
<option value="auto">자동</option>
|
||||
<option value="full">전체 폭</option>
|
||||
<option value="fixed">고정 값</option>
|
||||
</select>
|
||||
</label>
|
||||
{(formProps.formWidthMode ?? "auto") === "fixed" && (
|
||||
<NumericPropertyControl
|
||||
label="폼 고정 너비 (px)"
|
||||
unitLabel="(px)"
|
||||
value={typeof formProps.formWidthPx === "number" ? formProps.formWidthPx : 480}
|
||||
min={160}
|
||||
max={1200}
|
||||
step={10}
|
||||
presets={[
|
||||
{ id: "sm", label: "좁게", value: 320 },
|
||||
{ id: "md", label: "보통", value: 480 },
|
||||
{ id: "lg", label: "넓게", value: 640 },
|
||||
]}
|
||||
onChangeValue={(v) =>
|
||||
updateBlock(selectedBlockId, {
|
||||
formWidthPx: v,
|
||||
} as any)
|
||||
}
|
||||
/>
|
||||
)}
|
||||
<NumericPropertyControl
|
||||
label="폼 위/아래 여백 (px)"
|
||||
unitLabel="(px)"
|
||||
value={typeof formProps.marginYPx === "number" ? formProps.marginYPx : 24}
|
||||
min={0}
|
||||
max={160}
|
||||
step={4}
|
||||
presets={[
|
||||
{ id: "none", label: "없음", value: 0 },
|
||||
{ id: "compact", label: "좁게", value: 16 },
|
||||
{ id: "normal", label: "보통", value: 24 },
|
||||
{ id: "spacious", label: "넓게", value: 40 },
|
||||
]}
|
||||
onChangeValue={(v) =>
|
||||
updateBlock(selectedBlockId, {
|
||||
marginYPx: v,
|
||||
} as any)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2 border-t border-slate-800 pt-3 mt-4">
|
||||
<h3 className="text-[11px] font-semibold text-slate-200">폼 메시지</h3>
|
||||
<label className="flex flex-col gap-1 text-xs text-slate-400">
|
||||
|
||||
+226
-39
@@ -3,6 +3,8 @@
|
||||
import type { CSSProperties, ReactNode } from "react";
|
||||
import { Fragment, useEffect, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { FilePlus2, Trash2, Eye, Pencil, ListChecks, Undo2, Redo2 } from "lucide-react";
|
||||
import {
|
||||
DndContext,
|
||||
PointerSensor,
|
||||
@@ -69,6 +71,7 @@ import { PropertiesSidebar } from "./panels/PropertiesSidebar";
|
||||
import { EditorCanvas } from "./EditorCanvas";
|
||||
|
||||
export default function EditorPage() {
|
||||
const router = useRouter();
|
||||
const blocks = useEditorStore((state) => state.blocks);
|
||||
const selectedBlockId = useEditorStore((state) => state.selectedBlockId);
|
||||
const selectedListItemId = useEditorStore((state) => (state as any).selectedListItemId as string | null | undefined);
|
||||
@@ -117,6 +120,7 @@ export default function EditorPage() {
|
||||
const projectConfig = useEditorStore(
|
||||
(state) => (state as any).projectConfig as ProjectConfig,
|
||||
);
|
||||
const updateProjectConfig = useEditorStore((state) => state.updateProjectConfig);
|
||||
|
||||
const [editingBlockId, setEditingBlockId] = useState<string | null>(null);
|
||||
const [editingText, setEditingText] = useState("");
|
||||
@@ -127,13 +131,36 @@ export default function EditorPage() {
|
||||
const [exportJson, setExportJson] = useState("");
|
||||
const [importJson, setImportJson] = useState("");
|
||||
|
||||
const [projectTitle, setProjectTitle] = useState("");
|
||||
const [projectSlug, setProjectSlug] = useState("");
|
||||
const [loadSlug, setLoadSlug] = useState("");
|
||||
const [projectMessage, setProjectMessage] = useState("");
|
||||
// 멀티 선택(MVP)을 위한 로컬 선택 상태: 단일 선택(selectedBlockId)과 병행 사용한다.
|
||||
const [selectedBlockIds, setSelectedBlockIds] = useState<string[]>([]);
|
||||
|
||||
const historyLength = useEditorStore((state) => (state as any).history?.length ?? 0);
|
||||
const futureLength = useEditorStore((state) => (state as any).future?.length ?? 0);
|
||||
const canUndo = historyLength > 0;
|
||||
const canRedo = futureLength > 0;
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
const checkAuth = async () => {
|
||||
try {
|
||||
const res = await fetch("/api/auth/me");
|
||||
if (!res.ok && res.status === 401 && !cancelled) {
|
||||
router.push("/login");
|
||||
}
|
||||
} catch {
|
||||
// 네트워크 오류 등은 일단 무시하고, 페이지 접근을 막지 않는다.
|
||||
}
|
||||
};
|
||||
|
||||
void checkAuth();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [router]);
|
||||
|
||||
const [menuOpen, setMenuOpen] = useState(false);
|
||||
const [activeModal, setActiveModal] = useState<"project" | "json" | null>(null);
|
||||
|
||||
@@ -206,7 +233,11 @@ export default function EditorPage() {
|
||||
|
||||
const handleExportJson = () => {
|
||||
try {
|
||||
const payload = JSON.stringify(blocks, null, 2);
|
||||
const snapshot = {
|
||||
blocks,
|
||||
projectConfig,
|
||||
};
|
||||
const payload = JSON.stringify(snapshot, null, 2);
|
||||
setExportJson(payload);
|
||||
setImportJson(payload);
|
||||
} catch {
|
||||
@@ -223,31 +254,42 @@ export default function EditorPage() {
|
||||
const handleApplyImportJson = () => {
|
||||
if (!importJson.trim()) return;
|
||||
try {
|
||||
const parsed = JSON.parse(importJson);
|
||||
if (!Array.isArray(parsed)) return;
|
||||
// 최소한의 구조 검증: id, type, props.text 가 있는 블록만 사용
|
||||
const safeBlocks = parsed.filter((b) => b && typeof b.id === "string" && b.type === "text" && b.props && typeof b.props.text === "string");
|
||||
replaceBlocks(safeBlocks);
|
||||
// 내보내기 영역도 최신 상태로 업데이트
|
||||
setExportJson(JSON.stringify(safeBlocks, null, 2));
|
||||
const parsed = JSON.parse(importJson) as {
|
||||
blocks?: Block[];
|
||||
projectConfig?: ProjectConfig;
|
||||
};
|
||||
|
||||
if (!parsed || typeof parsed !== "object") return;
|
||||
|
||||
if (!Array.isArray(parsed.blocks)) return;
|
||||
|
||||
replaceBlocks(parsed.blocks as any);
|
||||
|
||||
if (parsed.projectConfig && typeof parsed.projectConfig === "object") {
|
||||
updateProjectConfig(parsed.projectConfig as ProjectConfig);
|
||||
}
|
||||
|
||||
setExportJson(JSON.stringify(parsed, null, 2));
|
||||
} catch {
|
||||
// 파싱 에러는 무시 (추후에는 토스트 등으로 피드백 가능)
|
||||
}
|
||||
};
|
||||
|
||||
const handleSaveProject = async () => {
|
||||
const slug = projectSlug.trim();
|
||||
const slug = (projectConfig.slug ?? "").trim();
|
||||
if (!slug) {
|
||||
setProjectMessage("프로젝트 주소를 입력해 주세요.");
|
||||
return;
|
||||
}
|
||||
|
||||
const title = (projectConfig.title ?? "").trim() || "제목 없음";
|
||||
|
||||
try {
|
||||
// 1) 로컬스토리지에 현재 상태 저장 (슬러그 기준)
|
||||
if (typeof window !== "undefined") {
|
||||
const localKey = `pb:project:${slug}`;
|
||||
const payload = {
|
||||
title: projectTitle.trim() || "제목 없음",
|
||||
title,
|
||||
contentJson: blocks,
|
||||
};
|
||||
window.localStorage.setItem(localKey, JSON.stringify(payload));
|
||||
@@ -260,19 +302,34 @@ export default function EditorPage() {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
title: projectTitle.trim() || "제목 없음",
|
||||
title,
|
||||
slug,
|
||||
contentJson: blocks,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status === 409) {
|
||||
try {
|
||||
const data = await response.json();
|
||||
if (data && typeof (data as any).message === "string") {
|
||||
setProjectMessage((data as any).message as string);
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
}
|
||||
}
|
||||
|
||||
setProjectMessage("프로젝트 저장에 실패했습니다.");
|
||||
return;
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
setProjectMessage(`프로젝트가 저장되었습니다: ${data.slug}`);
|
||||
|
||||
if (typeof window !== "undefined") {
|
||||
window.location.href = "/projects";
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
setProjectMessage("프로젝트 저장 중 오류가 발생했습니다.");
|
||||
@@ -280,7 +337,7 @@ export default function EditorPage() {
|
||||
};
|
||||
|
||||
const handleLoadProject = async () => {
|
||||
const slug = loadSlug.trim() || projectSlug.trim();
|
||||
const slug = (projectConfig.slug ?? "").trim();
|
||||
if (!slug) {
|
||||
setProjectMessage("불러올 주소를 입력해 주세요.");
|
||||
return;
|
||||
@@ -325,6 +382,43 @@ export default function EditorPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteProject = async () => {
|
||||
const slug = (projectConfig.slug ?? "").trim();
|
||||
if (!slug) {
|
||||
setProjectMessage("삭제할 프로젝트 주소가 없습니다.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof window === "undefined") return;
|
||||
|
||||
const confirmed = window.confirm(
|
||||
"현재 프로젝트를 삭제할까요? 삭제 후에는 되돌릴 수 없습니다. (로컬 저장 및 자동저장 데이터도 함께 삭제됩니다.)",
|
||||
);
|
||||
if (!confirmed) return;
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/projects/${encodeURIComponent(slug)}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
setProjectMessage("프로젝트 삭제에 실패했습니다.");
|
||||
return;
|
||||
}
|
||||
|
||||
// 프로젝트 저장 및 자동저장 스냅샷을 정리한다.
|
||||
window.localStorage.removeItem(`pb:project:${slug}`);
|
||||
window.localStorage.removeItem(`pb:autosave:${slug}`);
|
||||
|
||||
setProjectMessage("프로젝트가 삭제되었습니다.");
|
||||
|
||||
window.location.href = "/projects";
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
setProjectMessage("프로젝트 삭제 중 오류가 발생했습니다.");
|
||||
}
|
||||
};
|
||||
|
||||
const handleDragStart = (event: DragStartEvent) => {
|
||||
const { active } = event;
|
||||
setActiveDragId(String(active.id));
|
||||
@@ -491,6 +585,48 @@ export default function EditorPage() {
|
||||
|
||||
const rootBlocks = blocks.filter((block) => !block.sectionId);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === "undefined") return;
|
||||
|
||||
const slug = projectConfig?.slug?.trim();
|
||||
if (!slug) return;
|
||||
|
||||
const key = `pb:autosave:${slug}`;
|
||||
const raw = window.localStorage.getItem(key);
|
||||
if (!raw) return;
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(raw) as { blocks?: Block[]; projectConfig?: ProjectConfig };
|
||||
if (Array.isArray(parsed.blocks)) {
|
||||
replaceBlocks(parsed.blocks as any);
|
||||
}
|
||||
if (parsed.projectConfig) {
|
||||
updateProjectConfig(parsed.projectConfig as ProjectConfig);
|
||||
}
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
}, [projectConfig?.slug, replaceBlocks, updateProjectConfig]);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === "undefined") return;
|
||||
|
||||
const slug = projectConfig?.slug?.trim();
|
||||
if (!slug) return;
|
||||
|
||||
const key = `pb:autosave:${slug}`;
|
||||
const payload = {
|
||||
blocks,
|
||||
projectConfig,
|
||||
};
|
||||
|
||||
try {
|
||||
window.localStorage.setItem(key, JSON.stringify(payload));
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
}, [blocks, projectConfig]);
|
||||
|
||||
// 에디터 전역에서 Undo/Redo 단축키(Cmd/Ctrl+Z, Cmd/Ctrl+Shift+Z),
|
||||
// Delete/Backspace 기반 삭제, Cmd/Ctrl+D 기반 복제,
|
||||
// ArrowUp/ArrowDown을 이용한 선택 블록 이동을 처리한다.
|
||||
@@ -642,25 +778,57 @@ export default function EditorPage() {
|
||||
|
||||
return (
|
||||
<main className="h-screen flex flex-col overflow-hidden">
|
||||
<header className="border-b border-slate-800 px-6 py-4 flex items-center justify-between relative z-20">
|
||||
<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>
|
||||
<header className="border-b border-slate-800 px-6 py-4 flex items-center justify-between relative z-20 bg-slate-950/80 backdrop-blur">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="inline-flex items-center justify-center w-8 h-8 rounded-full bg-slate-900 border border-sky-700 shadow-sm">
|
||||
<Pencil className="w-4 h-4 text-sky-400" aria-hidden="true" />
|
||||
</span>
|
||||
<div className="flex flex-col">
|
||||
<h1 className="text-xl font-semibold">Page Editor</h1>
|
||||
<p className="text-xs text-slate-400">빌더 MVP용 에디터 페이지 골격</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-xs">
|
||||
<Link
|
||||
href="/preview"
|
||||
className="inline-flex items-center rounded border border-slate-700 bg-slate-900 px-3 py-1 text-slate-100 hover:bg-slate-800"
|
||||
href="/projects"
|
||||
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"
|
||||
>
|
||||
프리뷰 열기
|
||||
<ListChecks className="w-3 h-3" aria-hidden="true" />
|
||||
<span>프로젝트 목록</span>
|
||||
</Link>
|
||||
<Link
|
||||
href="/preview"
|
||||
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" />
|
||||
<span>프리뷰 열기</span>
|
||||
</Link>
|
||||
<button
|
||||
type="button"
|
||||
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 disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
onClick={undo}
|
||||
disabled={!canUndo}
|
||||
>
|
||||
<Undo2 className="w-3 h-3" aria-hidden="true" />
|
||||
<span>실행 취소</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
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 disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
onClick={redo}
|
||||
disabled={!canRedo}
|
||||
>
|
||||
<Redo2 className="w-3 h-3" aria-hidden="true" />
|
||||
<span>다시 실행</span>
|
||||
</button>
|
||||
<div className="relative">
|
||||
<button
|
||||
type="button"
|
||||
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"
|
||||
onClick={() => setMenuOpen((prev) => !prev)}
|
||||
>
|
||||
메뉴
|
||||
<FilePlus2 className="w-3 h-3" aria-hidden="true" />
|
||||
<span>메뉴</span>
|
||||
<span className="text-[9px]">▼</span>
|
||||
</button>
|
||||
{menuOpen && (
|
||||
@@ -673,7 +841,10 @@ export default function EditorPage() {
|
||||
setActiveModal("project");
|
||||
}}
|
||||
>
|
||||
프로젝트 저장/불러오기
|
||||
<span className="inline-flex items-center gap-2">
|
||||
<FilePlus2 className="w-3 h-3" aria-hidden="true" />
|
||||
<span>프로젝트 저장/불러오기</span>
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@@ -683,7 +854,23 @@ export default function EditorPage() {
|
||||
setActiveModal("json");
|
||||
}}
|
||||
>
|
||||
JSON 내보내기/불러오기
|
||||
<span className="inline-flex items-center gap-2">
|
||||
<FilePlus2 className="w-3 h-3" aria-hidden="true" />
|
||||
<span>JSON 내보내기/불러오기</span>
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="w-full px-3 py-2 text-left hover:bg-red-900/60 text-red-100 border-t border-slate-800"
|
||||
onClick={() => {
|
||||
setMenuOpen(false);
|
||||
void handleDeleteProject();
|
||||
}}
|
||||
>
|
||||
<span className="inline-flex items-center gap-2">
|
||||
<Trash2 className="w-3 h-3" aria-hidden="true" />
|
||||
<span>프로젝트 삭제</span>
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@@ -693,7 +880,10 @@ export default function EditorPage() {
|
||||
void handleExportZip();
|
||||
}}
|
||||
>
|
||||
페이지 파일로 내보내기 (ZIP)
|
||||
<span className="inline-flex items-center gap-2">
|
||||
<FilePlus2 className="w-3 h-3" aria-hidden="true" />
|
||||
<span>페이지 파일로 내보내기 (ZIP)</span>
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@@ -705,7 +895,10 @@ export default function EditorPage() {
|
||||
}
|
||||
}}
|
||||
>
|
||||
캔버스 초기화
|
||||
<span className="inline-flex items-center gap-2">
|
||||
<Trash2 className="w-3 h-3" aria-hidden="true" />
|
||||
<span>캔버스 초기화</span>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
@@ -763,16 +956,16 @@ export default function EditorPage() {
|
||||
<span className="text-slate-400">프로젝트 제목</span>
|
||||
<input
|
||||
className="w-full rounded border border-slate-700 bg-slate-950 px-2 py-1 text-xs outline-none focus:border-sky-500"
|
||||
value={projectTitle}
|
||||
onChange={(e) => setProjectTitle(e.target.value)}
|
||||
value={projectConfig.title ?? ""}
|
||||
onChange={(e) => updateProjectConfig({ title: e.target.value })}
|
||||
/>
|
||||
</label>
|
||||
<label className="flex flex-col gap-1">
|
||||
<span className="text-slate-400">프로젝트 주소 (예: my-landing)</span>
|
||||
<input
|
||||
className="w-full rounded border border-slate-700 bg-slate-950 px-2 py-1 text-xs outline-none focus:border-sky-500"
|
||||
value={projectSlug}
|
||||
onChange={(e) => setProjectSlug(e.target.value)}
|
||||
value={projectConfig.slug ?? ""}
|
||||
onChange={(e) => updateProjectConfig({ slug: e.target.value })}
|
||||
/>
|
||||
</label>
|
||||
<div className="flex gap-2">
|
||||
@@ -791,14 +984,6 @@ export default function EditorPage() {
|
||||
불러오기
|
||||
</button>
|
||||
</div>
|
||||
<label className="flex flex-col gap-1">
|
||||
<span className="text-slate-400">불러올 주소 (비우면 위 주소 사용)</span>
|
||||
<input
|
||||
className="w-full rounded border border-slate-700 bg-slate-950 px-2 py-1 text-xs outline-none focus:border-sky-500"
|
||||
value={loadSlug}
|
||||
onChange={(e) => setLoadSlug(e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
{projectMessage && (
|
||||
<p className="text-[11px] text-slate-300 mt-1">{projectMessage}</p>
|
||||
)}
|
||||
@@ -807,6 +992,8 @@ export default function EditorPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Export 미리보기 기능은 제거되었습니다. */}
|
||||
|
||||
{activeModal === "json" && (
|
||||
<div className="fixed inset-0 z-30 flex items-center justify-center bg-black/60">
|
||||
<div className="w-full max-w-2xl rounded-lg border border-slate-700 bg-slate-900 p-4 text-xs text-slate-100 shadow-xl">
|
||||
|
||||
@@ -1,7 +1,23 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback } from "react";
|
||||
import { useCallback, useState } from "react";
|
||||
import { useEditorStore } from "@/features/editor/state/editorStore";
|
||||
import {
|
||||
FilePlus2,
|
||||
ListChecks,
|
||||
Type,
|
||||
MousePointerClick,
|
||||
Image as ImageIcon,
|
||||
Video,
|
||||
Minus,
|
||||
List,
|
||||
LayoutTemplate,
|
||||
Pencil,
|
||||
FileText,
|
||||
ListFilter,
|
||||
CircleDot,
|
||||
SquareCheck,
|
||||
} from "lucide-react";
|
||||
|
||||
// 좌측 블록/폼/템플릿 추가 사이드바를 분리한 컴포넌트
|
||||
export function BlocksSidebar() {
|
||||
@@ -69,377 +85,465 @@ export function BlocksSidebar() {
|
||||
[addFooterTemplateSection],
|
||||
);
|
||||
|
||||
const [isBlocksOpen, setIsBlocksOpen] = useState(true);
|
||||
const [isFormsOpen, setIsFormsOpen] = useState(true);
|
||||
const [isTemplatesOpen, setIsTemplatesOpen] = useState(true);
|
||||
|
||||
return (
|
||||
<aside className="w-60 border-r border-slate-800 p-4 text-sm space-y-3 overflow-y-auto pb-scroll">
|
||||
<h2 className="font-medium">블록</h2>
|
||||
<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={handleAddText}
|
||||
>
|
||||
텍스트 블록 추가
|
||||
</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={handleAddButton}
|
||||
>
|
||||
버튼 블록 추가
|
||||
</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={handleAddImage}
|
||||
>
|
||||
이미지 블록 추가
|
||||
</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"
|
||||
onClick={handleAddDivider}
|
||||
>
|
||||
구분선 블록 추가
|
||||
</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={handleAddList}
|
||||
>
|
||||
리스트 블록 추가
|
||||
</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={handleAddSection}
|
||||
>
|
||||
섹션 블록 추가
|
||||
</button>
|
||||
<aside className="w-60 border-r border-slate-800 p-4 text-sm space-y-3 overflow-y-auto pb-scroll bg-slate-950/40">
|
||||
<h2 className="font-medium flex items-center gap-2 text-slate-200">
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center justify-between w-full text-left"
|
||||
aria-expanded={isBlocksOpen}
|
||||
onClick={() => setIsBlocksOpen((prev) => !prev)}
|
||||
>
|
||||
<span className="inline-flex items-center gap-2">
|
||||
<Pencil className="w-4 h-4 text-sky-400" aria-hidden="true" />
|
||||
<span>블록</span>
|
||||
</span>
|
||||
</button>
|
||||
</h2>
|
||||
{isBlocksOpen && (
|
||||
<>
|
||||
<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={handleAddText}
|
||||
>
|
||||
<span className="inline-flex items-center gap-2">
|
||||
<Type className="w-3 h-3" aria-hidden="true" />
|
||||
<span>텍스트</span>
|
||||
</span>
|
||||
</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={handleAddButton}
|
||||
>
|
||||
<span className="inline-flex items-center gap-2">
|
||||
<MousePointerClick className="w-3 h-3" aria-hidden="true" />
|
||||
<span>버튼</span>
|
||||
</span>
|
||||
</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={handleAddImage}
|
||||
>
|
||||
<span className="inline-flex items-center gap-2">
|
||||
<ImageIcon className="w-3 h-3" aria-hidden="true" />
|
||||
<span>이미지</span>
|
||||
</span>
|
||||
</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}
|
||||
>
|
||||
<span className="inline-flex items-center gap-2">
|
||||
<Video className="w-3 h-3" aria-hidden="true" />
|
||||
<span>비디오</span>
|
||||
</span>
|
||||
</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={handleAddDivider}
|
||||
>
|
||||
<span className="inline-flex items-center gap-2">
|
||||
<Minus className="w-3 h-3" aria-hidden="true" />
|
||||
<span>구분선</span>
|
||||
</span>
|
||||
</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={handleAddList}
|
||||
>
|
||||
<span className="inline-flex items-center gap-2">
|
||||
<List className="w-3 h-3" aria-hidden="true" />
|
||||
<span>리스트</span>
|
||||
</span>
|
||||
</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={handleAddSection}
|
||||
>
|
||||
<span className="inline-flex items-center gap-2">
|
||||
<LayoutTemplate className="w-3 h-3" aria-hidden="true" />
|
||||
<span>섹션</span>
|
||||
</span>
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="pt-3 border-t border-slate-800 mt-3 space-y-2">
|
||||
<h3 className="text-[11px] font-medium text-slate-300">폼 요소</h3>
|
||||
<button
|
||||
type="button"
|
||||
className="w-full rounded border border-emerald-700 bg-emerald-950 px-3 py-2 text-left text-xs text-emerald-100 hover:bg-emerald-900"
|
||||
onClick={handleAddFormBlock}
|
||||
>
|
||||
폼 블록 추가
|
||||
</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={handleAddFormInput}
|
||||
>
|
||||
폼 입력 추가
|
||||
</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={handleAddFormSelect}
|
||||
>
|
||||
폼 셀렉트 추가
|
||||
</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={handleAddFormRadio}
|
||||
>
|
||||
폼 라디오 추가
|
||||
</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={handleAddFormCheckbox}
|
||||
>
|
||||
폼 체크박스 추가
|
||||
</button>
|
||||
<h3 className="text-[11px] font-medium text-slate-300 flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center justify-between w-full text-left"
|
||||
aria-expanded={isFormsOpen}
|
||||
onClick={() => setIsFormsOpen((prev) => !prev)}
|
||||
>
|
||||
<span className="inline-flex items-center gap-2">
|
||||
<ListChecks className="w-3 h-3" aria-hidden="true" />
|
||||
<span>폼 요소</span>
|
||||
</span>
|
||||
</button>
|
||||
</h3>
|
||||
{isFormsOpen && (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
className="w-full rounded border border-emerald-700 bg-emerald-950 px-3 py-2 text-left text-xs text-emerald-100 hover:bg-emerald-900"
|
||||
onClick={handleAddFormBlock}
|
||||
>
|
||||
<span className="inline-flex items-center gap-2">
|
||||
<FilePlus2 className="w-3 h-3" aria-hidden="true" />
|
||||
<span>폼 컨트롤러</span>
|
||||
</span>
|
||||
</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={handleAddFormInput}
|
||||
>
|
||||
<span className="inline-flex items-center gap-2">
|
||||
<FileText className="w-3 h-3" aria-hidden="true" />
|
||||
<span>입력(Input)</span>
|
||||
</span>
|
||||
</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={handleAddFormSelect}
|
||||
>
|
||||
<span className="inline-flex items-center gap-2">
|
||||
<ListFilter className="w-3 h-3" aria-hidden="true" />
|
||||
<span>셀렉트(Select)</span>
|
||||
</span>
|
||||
</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={handleAddFormRadio}
|
||||
>
|
||||
<span className="inline-flex items-center gap-2">
|
||||
<CircleDot className="w-3 h-3" aria-hidden="true" />
|
||||
<span>단일 선택(Radio)</span>
|
||||
</span>
|
||||
</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={handleAddFormCheckbox}
|
||||
>
|
||||
<span className="inline-flex items-center gap-2">
|
||||
<SquareCheck className="w-3 h-3" aria-hidden="true" />
|
||||
<span>다중 선택(Checkbox)</span>
|
||||
</span>
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="pt-3 border-t border-slate-800 mt-3 space-y-3">
|
||||
<h3 className="text-[11px] font-medium text-slate-300">템플릿</h3>
|
||||
<h3 className="text-[11px] font-medium text-slate-300 flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center justify-between w-full text-left"
|
||||
aria-expanded={isTemplatesOpen}
|
||||
onClick={() => setIsTemplatesOpen((prev) => !prev)}
|
||||
>
|
||||
<span className="inline-flex items-center gap-2">
|
||||
<FilePlus2 className="w-3 h-3" aria-hidden="true" />
|
||||
<span>템플릿</span>
|
||||
</span>
|
||||
</button>
|
||||
</h3>
|
||||
|
||||
<div className="space-y-2">
|
||||
<p className="text-[10px] font-semibold text-slate-400">히어로 · CTA</p>
|
||||
<div className="space-y-2">
|
||||
<div className="rounded border border-slate-800 bg-slate-950/50 p-2 space-y-1">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<button
|
||||
type="button"
|
||||
className="flex-1 rounded border border-slate-700 bg-slate-900 px-3 py-2 text-left text-xs hover:bg-slate-800"
|
||||
onClick={handleAddHeroTemplate}
|
||||
>
|
||||
Hero 템플릿 추가
|
||||
</button>
|
||||
<div
|
||||
data-testid="template-preview-hero"
|
||||
className="shrink-0 flex h-6 w-10 rounded border border-slate-700 bg-slate-900 p-[2px]"
|
||||
>
|
||||
<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">
|
||||
페이지 상단 Hero 섹션 (큰 제목 + 서브텍스트 + 버튼)
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="rounded border border-slate-800 bg-slate-950/50 p-2 space-y-1">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<button
|
||||
type="button"
|
||||
className="flex-1 rounded border border-slate-700 bg-slate-900 px-3 py-2 text-left text-xs hover:bg-slate-800"
|
||||
onClick={handleAddCtaTemplate}
|
||||
>
|
||||
CTA 템플릿 추가
|
||||
</button>
|
||||
<div
|
||||
data-testid="template-preview-cta"
|
||||
className="shrink-0 flex h-6 w-10 rounded border border-slate-700 bg-slate-900 p-[2px]"
|
||||
>
|
||||
<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" />
|
||||
{isTemplatesOpen && (
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-2">
|
||||
<p className="text-[10px] font-semibold text-slate-400">히어로 · CTA</p>
|
||||
<div className="space-y-2">
|
||||
<div className="rounded border border-slate-800 bg-slate-950/50 p-2 space-y-1">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<button
|
||||
type="button"
|
||||
className="flex-1 rounded border border-slate-700 bg-slate-900 px-3 py-2 text-left text-xs hover:bg-slate-800"
|
||||
onClick={handleAddHeroTemplate}
|
||||
>
|
||||
Hero 템플릿
|
||||
</button>
|
||||
<div
|
||||
data-testid="template-preview-hero"
|
||||
className="shrink-0 flex h-6 w-10 rounded border border-slate-700 bg-slate-900 p-[2px]"
|
||||
>
|
||||
<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 className="w-3 h-2 rounded-[1px] bg-slate-700/70" />
|
||||
</div>
|
||||
<p className="text-[10px] text-slate-400 leading-snug">
|
||||
페이지 상단 Hero 섹션 (큰 제목 + 서브텍스트 + 버튼)
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="rounded border border-slate-800 bg-slate-950/50 p-2 space-y-1">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<button
|
||||
type="button"
|
||||
className="flex-1 rounded border border-slate-700 bg-slate-900 px-3 py-2 text-left text-xs hover:bg-slate-800"
|
||||
onClick={handleAddCtaTemplate}
|
||||
>
|
||||
CTA 템플릿
|
||||
</button>
|
||||
<div
|
||||
data-testid="template-preview-cta"
|
||||
className="shrink-0 flex h-6 w-10 rounded border border-slate-700 bg-slate-900 p-[2px]"
|
||||
>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<p className="text-[10px] font-semibold text-slate-400">콘텐츠 섹션</p>
|
||||
<div className="space-y-2">
|
||||
<div className="rounded border border-slate-800 bg-slate-950/50 p-2 space-y-1">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<button
|
||||
type="button"
|
||||
className="flex-1 rounded border border-slate-700 bg-slate-900 px-3 py-2 text-left text-xs hover:bg-slate-800"
|
||||
onClick={handleAddFeaturesTemplate}
|
||||
>
|
||||
기능 템플릿
|
||||
</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 p-[2px]"
|
||||
>
|
||||
<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>
|
||||
</div>
|
||||
|
||||
<div className="rounded border border-slate-800 bg-slate-950/50 p-2 space-y-1">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<button
|
||||
type="button"
|
||||
className="flex-1 rounded border border-slate-700 bg-slate-900 px-3 py-2 text-left text-xs hover:bg-slate-800"
|
||||
onClick={handleAddFaqTemplate}
|
||||
>
|
||||
FAQ 템플릿
|
||||
</button>
|
||||
<div
|
||||
data-testid="template-preview-faq"
|
||||
className="shrink-0 flex h-6 w-10 items-start gap-[2px] rounded border border-slate-700 bg-slate-900 p-[2px]"
|
||||
>
|
||||
<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>
|
||||
</div>
|
||||
|
||||
<div className="rounded border border-slate-800 bg-slate-950/50 p-2 space-y-1">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<button
|
||||
type="button"
|
||||
className="flex-1 rounded border border-slate-700 bg-slate-900 px-3 py-2 text-left text-xs hover:bg-slate-800"
|
||||
onClick={handleAddPricingTemplate}
|
||||
>
|
||||
상품 템플릿
|
||||
</button>
|
||||
<div
|
||||
data-testid="template-preview-pricing"
|
||||
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 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>
|
||||
</div>
|
||||
|
||||
<div className="rounded border border-slate-800 bg-slate-950/50 p-2 space-y-1">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<button
|
||||
type="button"
|
||||
className="flex-1 rounded border border-slate-700 bg-slate-900 px-3 py-2 text-left text-xs hover:bg-slate-800"
|
||||
onClick={handleAddBlogTemplate}
|
||||
>
|
||||
블로그 템플릿
|
||||
</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 p-[2px]"
|
||||
>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<p className="text-[10px] font-semibold text-slate-400">신뢰/소개</p>
|
||||
<div className="space-y-2">
|
||||
<div className="rounded border border-slate-800 bg-slate-950/50 p-2 space-y-1">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<button
|
||||
type="button"
|
||||
className="flex-1 rounded border border-slate-700 bg-slate-900 px-3 py-2 text-left text-xs hover:bg-slate-800"
|
||||
onClick={handleAddTestimonialsTemplate}
|
||||
>
|
||||
후기 템플릿
|
||||
</button>
|
||||
<div
|
||||
data-testid="template-preview-testimonials"
|
||||
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 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>
|
||||
</div>
|
||||
|
||||
<div className="rounded border border-slate-800 bg-slate-950/50 p-2 space-y-1">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<button
|
||||
type="button"
|
||||
className="flex-1 rounded border border-slate-700 bg-slate-900 px-3 py-2 text-left text-xs hover:bg-slate-800"
|
||||
onClick={handleAddTeamTemplate}
|
||||
>
|
||||
Team 템플릿
|
||||
</button>
|
||||
<div
|
||||
data-testid="template-preview-team"
|
||||
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 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>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<p className="text-[10px] font-semibold text-slate-400">푸터/기타</p>
|
||||
<div className="space-y-2">
|
||||
<div className="rounded border border-slate-800 bg-slate-950/50 p-2 space-y-1">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<button
|
||||
type="button"
|
||||
className="flex-1 rounded border border-slate-700 bg-slate-900 px-3 py-2 text-left text-xs hover:bg-slate-800"
|
||||
onClick={handleAddFooterTemplate}
|
||||
>
|
||||
Footer 템플릿
|
||||
</button>
|
||||
<div
|
||||
data-testid="template-preview-footer"
|
||||
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="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>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-[10px] text-slate-400 leading-snug">콜투액션(CTA) 섹션</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<p className="text-[10px] font-semibold text-slate-400">콘텐츠 섹션</p>
|
||||
<div className="space-y-2">
|
||||
<div className="rounded border border-slate-800 bg-slate-950/50 p-2 space-y-1">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<button
|
||||
type="button"
|
||||
className="flex-1 rounded border border-slate-700 bg-slate-900 px-3 py-2 text-left text-xs hover:bg-slate-800"
|
||||
onClick={handleAddFeaturesTemplate}
|
||||
>
|
||||
Features 템플릿 추가
|
||||
</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 p-[2px]"
|
||||
>
|
||||
<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>
|
||||
</div>
|
||||
|
||||
<div className="rounded border border-slate-800 bg-slate-950/50 p-2 space-y-1">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<button
|
||||
type="button"
|
||||
className="flex-1 rounded border border-slate-700 bg-slate-900 px-3 py-2 text-left text-xs hover:bg-slate-800"
|
||||
onClick={handleAddFaqTemplate}
|
||||
>
|
||||
FAQ 템플릿 추가
|
||||
</button>
|
||||
<div
|
||||
data-testid="template-preview-faq"
|
||||
className="shrink-0 flex h-6 w-10 items-start gap-[2px] rounded border border-slate-700 bg-slate-900 p-[2px]"
|
||||
>
|
||||
<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>
|
||||
</div>
|
||||
|
||||
<div className="rounded border border-slate-800 bg-slate-950/50 p-2 space-y-1">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<button
|
||||
type="button"
|
||||
className="flex-1 rounded border border-slate-700 bg-slate-900 px-3 py-2 text-left text-xs hover:bg-slate-800"
|
||||
onClick={handleAddPricingTemplate}
|
||||
>
|
||||
Pricing 템플릿 추가
|
||||
</button>
|
||||
<div
|
||||
data-testid="template-preview-pricing"
|
||||
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 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>
|
||||
</div>
|
||||
|
||||
<div className="rounded border border-slate-800 bg-slate-950/50 p-2 space-y-1">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<button
|
||||
type="button"
|
||||
className="flex-1 rounded border border-slate-700 bg-slate-900 px-3 py-2 text-left text-xs hover:bg-slate-800"
|
||||
onClick={handleAddBlogTemplate}
|
||||
>
|
||||
Blog 템플릿 추가
|
||||
</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 p-[2px]"
|
||||
>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<p className="text-[10px] font-semibold text-slate-400">신뢰/소개</p>
|
||||
<div className="space-y-2">
|
||||
<div className="rounded border border-slate-800 bg-slate-950/50 p-2 space-y-1">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<button
|
||||
type="button"
|
||||
className="flex-1 rounded border border-slate-700 bg-slate-900 px-3 py-2 text-left text-xs hover:bg-slate-800"
|
||||
onClick={handleAddTestimonialsTemplate}
|
||||
>
|
||||
Testimonials 템플릿 추가
|
||||
</button>
|
||||
<div
|
||||
data-testid="template-preview-testimonials"
|
||||
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 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>
|
||||
</div>
|
||||
|
||||
<div className="rounded border border-slate-800 bg-slate-950/50 p-2 space-y-1">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<button
|
||||
type="button"
|
||||
className="flex-1 rounded border border-slate-700 bg-slate-900 px-3 py-2 text-left text-xs hover:bg-slate-800"
|
||||
onClick={handleAddTeamTemplate}
|
||||
>
|
||||
Team 템플릿 추가
|
||||
</button>
|
||||
<div
|
||||
data-testid="template-preview-team"
|
||||
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 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>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<p className="text-[10px] font-semibold text-slate-400">푸터/기타</p>
|
||||
<div className="space-y-2">
|
||||
<div className="rounded border border-slate-800 bg-slate-950/50 p-2 space-y-1">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<button
|
||||
type="button"
|
||||
className="flex-1 rounded border border-slate-700 bg-slate-900 px-3 py-2 text-left text-xs hover:bg-slate-800"
|
||||
onClick={handleAddFooterTemplate}
|
||||
>
|
||||
Footer 템플릿 추가
|
||||
</button>
|
||||
<div
|
||||
data-testid="template-preview-footer"
|
||||
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="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>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import type { Block, ButtonBlockProps, FormBlockProps, ListBlockProps, SectionBlockProps, TextBlockProps, VideoBlockProps } from "@/features/editor/state/editorStore";
|
||||
import { ButtonPropertiesPanel } from "./ButtonPropertiesPanel";
|
||||
import { TextPropertiesPanel } from "./TextPropertiesPanel";
|
||||
@@ -14,6 +15,18 @@ import { FormCheckboxPropertiesPanel } from "../forms/FormCheckboxPropertiesPane
|
||||
import { FormRadioPropertiesPanel } from "../forms/FormRadioPropertiesPanel";
|
||||
import { FormControllerPanel } from "../forms/FormControllerPanel";
|
||||
import { ProjectPropertiesPanel } from "./ProjectPropertiesPanel";
|
||||
import { Trash2, Copy, SlidersHorizontal, HelpCircle } from "lucide-react";
|
||||
|
||||
type BlockHelpProperty = {
|
||||
label: string;
|
||||
description: string;
|
||||
};
|
||||
|
||||
type BlockHelpContent = {
|
||||
title: string;
|
||||
description: string;
|
||||
properties?: BlockHelpProperty[];
|
||||
};
|
||||
|
||||
interface PropertiesSidebarProps {
|
||||
blocks: Block[];
|
||||
@@ -48,16 +61,621 @@ export function PropertiesSidebar(props: PropertiesSidebarProps) {
|
||||
onOutdentSelectedItem,
|
||||
} = props;
|
||||
|
||||
const [helpOpen, setHelpOpen] = useState(false);
|
||||
|
||||
const getHelpContentForSelectedBlock = (): BlockHelpContent | null => {
|
||||
if (!selectedBlockId) return null;
|
||||
const selectedBlock = blocks.find((b) => b.id === selectedBlockId);
|
||||
if (!selectedBlock) return null;
|
||||
|
||||
switch (selectedBlock.type) {
|
||||
case "text":
|
||||
return {
|
||||
title: "텍스트 블록 튜토리얼",
|
||||
description:
|
||||
"제목과 본문 텍스트를 입력할 때 사용하는 기본 블록입니다.\n\n좌측 패널에서 텍스트를 추가한 뒤, 캔버스에서 텍스트를 더블클릭하거나 속성 패널에서 내용을 수정해 보세요. 정렬, 크기, 색상 등을 조절해 다양한 스타일의 텍스트를 만들 수 있습니다.",
|
||||
properties: [
|
||||
{
|
||||
label: "선택한 텍스트 블록 내용",
|
||||
description:
|
||||
"이 블록에 실제로 표시될 문장을 입력하는 영역입니다. 캔버스에서 텍스트를 더블클릭했을 때와 동일한 내용이며, 여러 줄을 입력하면 줄바꿈이 그대로 반영됩니다.",
|
||||
},
|
||||
{
|
||||
label: "정렬",
|
||||
description:
|
||||
"텍스트를 왼쪽/가운데/오른쪽 중 어디에 맞출지 결정합니다. 일반 본문은 왼쪽, Hero 제목이나 짧은 강조 문구는 가운데 정렬을 추천합니다.",
|
||||
},
|
||||
{
|
||||
label: "텍스트 스타일 (밑줄/가운데줄/이탤릭)",
|
||||
description:
|
||||
"밑줄은 링크처럼 보이게 하거나 특정 단어를 강조할 때, 가운데줄은 할인 전 가격처럼 취소선을 표현할 때, 이탤릭은 인용구나 제품명 등 살짝 톤을 바꾸고 싶을 때 사용합니다.",
|
||||
},
|
||||
{
|
||||
label: "글자 크기",
|
||||
description:
|
||||
"텍스트의 폰트 크기를 px 단위로 조절합니다. 프리셋(XS~3XL)으로 대략적인 크기를 고른 뒤, 슬라이더/숫자 입력으로 세밀하게 조정할 수 있습니다.",
|
||||
},
|
||||
{
|
||||
label: "글자 간격",
|
||||
description:
|
||||
"문자 사이의 간격(자간)을 조절합니다. 본문은 보통 또는 약간 넓게, 대문자 위주의 짧은 타이틀은 살짝 넓게 설정하면 가독성과 디자인이 좋아집니다.",
|
||||
},
|
||||
{
|
||||
label: "줄 간격",
|
||||
description:
|
||||
"행과 행 사이의 간격(행간)을 정합니다. 본문은 1.5~1.7 정도가 읽기 좋고, 아주 짧은 제목은 1.25 정도로 줄이면 한 덩어리로 단단해 보입니다.",
|
||||
},
|
||||
{
|
||||
label: "굵기",
|
||||
description:
|
||||
"폰트 두께를 100~900 범위에서 조절합니다. 본문은 보통(400), 섹션 제목은 세미볼드(600), 강한 CTA 문구는 볼드(700) 정도를 추천합니다.",
|
||||
},
|
||||
{
|
||||
label: "텍스트 색상",
|
||||
description:
|
||||
"글자 색을 팔레트 또는 HEX 코드로 지정합니다. 본문은 대비가 높은 짙은 색을, 강조 문구는 브랜드 메인 색을 사용하는 것이 좋습니다.",
|
||||
},
|
||||
{
|
||||
label: "블록 배경색",
|
||||
description:
|
||||
"텍스트가 들어 있는 카드/박스 전체 배경색을 지정합니다. 공지사항, 강조 박스, 경고 문구 등을 만들 때 연한 배경색과 함께 사용하면 좋습니다.",
|
||||
},
|
||||
{
|
||||
label: "최대 너비",
|
||||
description:
|
||||
"한 줄 텍스트가 지나치게 길어지지 않도록 최대 줄 길이를 제한합니다. `본문 폭(60ch)`는 일반 문단에, `좁게(40ch)`는 카드/배너 같은 짧은 문구에 적합합니다.",
|
||||
},
|
||||
],
|
||||
};
|
||||
case "button":
|
||||
return {
|
||||
title: "버튼 블록 튜토리얼",
|
||||
description:
|
||||
"폼 제출이나 링크 이동을 위한 CTA 버튼을 만들 때 사용하는 블록입니다. 라벨, 링크(URL), 정렬, 색상/크기를 조절해 원하는 액션 버튼을 구성해 보세요.",
|
||||
properties: [
|
||||
{
|
||||
label: "버튼 텍스트",
|
||||
description:
|
||||
"버튼 위에 표시될 문구입니다. 예: '지금 시작하기', '문의 남기기'. 짧고 행동을 유도하는 문장을 사용하는 것이 좋습니다.",
|
||||
},
|
||||
{
|
||||
label: "가로 패딩 (px)",
|
||||
description:
|
||||
"버튼 양 옆의 여백을 조절합니다. 값이 클수록 버튼이 가로로 넓어져 더 강조됩니다.",
|
||||
},
|
||||
{
|
||||
label: "세로 패딩 (px)",
|
||||
description:
|
||||
"버튼 위·아래 여백을 조절합니다. 값이 클수록 버튼 높이가 높아져 더 눈에 띱니다.",
|
||||
},
|
||||
{
|
||||
label: "버튼 링크",
|
||||
description:
|
||||
"버튼 클릭 시 이동할 URL 또는 앵커(#section)입니다. 외부 페이지, 페이지 내 섹션, 폼 제출 엔드포인트 등으로 연결할 수 있습니다.",
|
||||
},
|
||||
{
|
||||
label: "정렬",
|
||||
description:
|
||||
"해당 버튼이 포함된 영역 안에서 왼쪽/가운데/오른쪽 중 어디에 위치할지 결정합니다. 주요 CTA 버튼은 가운데 정렬이 많이 쓰입니다.",
|
||||
},
|
||||
{
|
||||
label: "텍스트 색상",
|
||||
description:
|
||||
"버튼 라벨 글자 색입니다. 채움 색상과 충분한 대비가 나도록 설정해야 가독성이 좋습니다.",
|
||||
},
|
||||
{
|
||||
label: "스타일",
|
||||
description:
|
||||
"버튼 외형 스타일입니다. '채움'은 꽉 찬 스타일, '외곽선'은 테두리만 있는 스타일, '고스트'는 배경 없이 텍스트/테두리만 있는 스타일입니다.",
|
||||
},
|
||||
{
|
||||
label: "채움 색상",
|
||||
description:
|
||||
"채움 스타일에서 버튼 내부를 채우는 색입니다. 브랜드 메인 색이나 강조 색을 사용하면 좋습니다.",
|
||||
},
|
||||
{
|
||||
label: "외곽선 색상",
|
||||
description:
|
||||
"외곽선/고스트 스타일에서 버튼 테두리 색입니다. 배경색과의 대비를 고려해 설정합니다.",
|
||||
},
|
||||
{
|
||||
label: "모서리 둥글기",
|
||||
description:
|
||||
"버튼 모서리를 얼마나 둥글게 처리할지 결정합니다. '없음'은 각진 버튼, '완전 둥글게'는 알약 모양 버튼입니다.",
|
||||
},
|
||||
{
|
||||
label: "버튼 너비 모드 / 버튼 고정 너비",
|
||||
description:
|
||||
"버튼이 컨테이너 너비에 맞춰 늘어날지(전체 폭), 내용 길이에 맞출지(자동), 고정 px 너비를 가질지 결정합니다.",
|
||||
},
|
||||
{
|
||||
label: "버튼 크기",
|
||||
description:
|
||||
"버튼 라벨 텍스트의 폰트 크기입니다. 중요한 CTA 버튼은 주변 텍스트보다 조금 더 크게 설정하면 좋습니다.",
|
||||
},
|
||||
{
|
||||
label: "줄 간격 / 글자 간격",
|
||||
description:
|
||||
"버튼 라벨의 행간과 자간을 조절합니다. 글자가 너무 답답해 보이면 자간을 약간 넓히고, 여러 줄 버튼 문구는 줄 간격을 조금 넓혀 주세요.",
|
||||
},
|
||||
],
|
||||
};
|
||||
case "image":
|
||||
return {
|
||||
title: "이미지 블록 튜토리얼",
|
||||
description:
|
||||
"배너, 썸네일, 설명 이미지를 배치할 때 사용하는 블록입니다. 외부 이미지 URL을 입력하거나 업로드 이미지를 선택하고, 너비/정렬/모서리 둥글기를 조절해 레이아웃에 맞게 배치해 보세요.",
|
||||
properties: [
|
||||
{
|
||||
label: "이미지 소스",
|
||||
description:
|
||||
"이미지를 외부 URL에서 가져올지(URL), 빌더에 업로드한 에셋을 사용할지(파일 업로드) 선택합니다.",
|
||||
},
|
||||
{
|
||||
label: "이미지 URL / 이미지 파일 업로드",
|
||||
description:
|
||||
"URL 모드에서는 외부 이미지 주소를 입력하고, 업로드 모드에서는 로컬 이미지를 업로드해 `/api/image/:id` 형식으로 관리합니다.",
|
||||
},
|
||||
{
|
||||
label: "대체 텍스트",
|
||||
description:
|
||||
"이미지가 로드되지 않거나 스크린리더를 사용하는 사용자에게 보여질 설명 텍스트입니다. 이미지가 전달하는 의미를 한두 문장으로 작성해 주세요.",
|
||||
},
|
||||
{
|
||||
label: "카드 배경색",
|
||||
description:
|
||||
"이미지를 감싸는 카드 영역의 배경색입니다. 투명한 PNG 아이콘이나 로고를 올릴 때 배경을 살짝 깔아주면 더 잘 보입니다.",
|
||||
},
|
||||
{
|
||||
label: "정렬",
|
||||
description:
|
||||
"이미지가 포함된 영역 안에서 왼쪽/가운데/오른쪽 중 어디에 위치할지 결정합니다.",
|
||||
},
|
||||
{
|
||||
label: "너비 모드 / 고정 너비 (px)",
|
||||
description:
|
||||
"이미지를 내용 크기에 맞출지(auto) 또는 px 단위로 고정된 너비를 사용할지 선택합니다. 썸네일 그리드에서는 고정 너비를 사용하는 편이 레이아웃이 안정적입니다.",
|
||||
},
|
||||
{
|
||||
label: "모서리 둥글기",
|
||||
description:
|
||||
"이미지 카드 모서리 둥글기를 px 단위로 조절합니다. 아바타/아이콘은 완전 둥글게, 일반 사진은 살짝 둥글게 설정하면 자연스럽습니다.",
|
||||
},
|
||||
],
|
||||
};
|
||||
case "list":
|
||||
return {
|
||||
title: "리스트 블록 튜토리얼",
|
||||
description:
|
||||
"불릿/번호 리스트를 만들 때 사용하는 블록입니다. 아이템 텍스트를 수정하고, 정렬/불릿 스타일을 바꿔 체크리스트나 단계별 안내 등을 표현할 수 있습니다.",
|
||||
properties: [
|
||||
{
|
||||
label: "리스트 아이템 (줄바꿈으로 구분)",
|
||||
description:
|
||||
"각 줄이 하나의 리스트 항목이 됩니다. 들여쓰기를 이용한 중첩 리스트는 TDD 기준의 변환 로직에 따라 자동으로 트리 구조로 변환됩니다.",
|
||||
},
|
||||
{
|
||||
label: "정렬",
|
||||
description:
|
||||
"리스트 전체를 왼쪽/가운데/오른쪽 중 어디에 정렬할지 결정합니다. 체크리스트는 왼쪽 정렬, 특징 요약은 가운데 정렬이 자주 사용됩니다.",
|
||||
},
|
||||
{
|
||||
label: "글자 크기 (px)",
|
||||
description:
|
||||
"리스트 텍스트의 폰트 크기를 조절합니다. 본문보다 약간 작게 설정하면 보조 정보 느낌을 줄 수 있습니다.",
|
||||
},
|
||||
{
|
||||
label: "줄 간격",
|
||||
description:
|
||||
"리스트 항목 내부의 행간을 조절합니다. 항목 내용이 길어질수록 1.5~1.8 정도의 넉넉한 값을 추천합니다.",
|
||||
},
|
||||
{
|
||||
label: "텍스트 색상",
|
||||
description:
|
||||
"리스트 텍스트의 색상입니다. 배경색과의 대비를 고려해 본문과 동일하거나 약간 연한 색상을 사용할 수 있습니다.",
|
||||
},
|
||||
{
|
||||
label: "블록 배경색",
|
||||
description:
|
||||
"리스트 전체 카드의 배경색입니다. 단계별 안내나 체크리스트 박스를 강조하는 용도로 사용할 수 있습니다.",
|
||||
},
|
||||
{
|
||||
label: "불릿 스타일",
|
||||
description:
|
||||
"불릿(●/○/■) 또는 번호(1., a., i.) 형식을 선택합니다. 숫자/알파벳/로마 숫자 스타일을 선택하면 자동으로 순서형 리스트(ordered)가 됩니다.",
|
||||
},
|
||||
{
|
||||
label: "아이템 간 여백 (px)",
|
||||
description:
|
||||
"각 리스트 항목 사이의 세로 간격입니다. 설명이 긴 항목이 많다면 여백을 넉넉히 두어 가독성을 확보하세요.",
|
||||
},
|
||||
],
|
||||
};
|
||||
case "divider":
|
||||
return {
|
||||
title: "구분선 블록 튜토리얼",
|
||||
description:
|
||||
"섹션과 섹션 사이를 나누거나 콘텐츠를 시각적으로 구분할 때 사용하는 블록입니다. 정렬, 두께, 길이, 색상, 여백을 조절해 페이지 흐름을 정리해 보세요.",
|
||||
properties: [
|
||||
{
|
||||
label: "정렬",
|
||||
description:
|
||||
"구분선을 컨테이너 안에서 왼쪽/가운데/오른쪽 중 어디에 위치시킬지 결정합니다.",
|
||||
},
|
||||
{
|
||||
label: "두께",
|
||||
description:
|
||||
"선의 두께입니다. '얇게'는 가벼운 보조 구분선, '보통'은 섹션 경계를 확실히 나눌 때 사용합니다.",
|
||||
},
|
||||
{
|
||||
label: "길이 모드 / 고정 길이 (px)",
|
||||
description:
|
||||
"구분선을 전체 폭으로 쓸지, 내용에 맞출지, 또는 px 단위의 고정 길이를 사용할지 선택합니다.",
|
||||
},
|
||||
{
|
||||
label: "선 색상",
|
||||
description:
|
||||
"구분선 컬러입니다. 너무 진한 색보다는 섹션 배경보다 살짝 진한 정도의 중간 톤을 추천합니다.",
|
||||
},
|
||||
{
|
||||
label: "위/아래 여백",
|
||||
description:
|
||||
"구분선 위·아래에 들어가는 공백입니다. 값이 클수록 섹션이 더 명확하게 나뉘어 보입니다.",
|
||||
},
|
||||
],
|
||||
};
|
||||
case "section":
|
||||
return {
|
||||
title: "섹션 블록 튜토리얼",
|
||||
description:
|
||||
"레이아웃의 큰 덩어리를 나누는 컨테이너 블록입니다. 배경색/배경 이미지/비디오, 위아래 패딩, 너비를 설정해 Hero, Features, Footer 같은 영역을 구성할 수 있습니다.",
|
||||
properties: [
|
||||
{
|
||||
label: "배경 색상",
|
||||
description:
|
||||
"섹션 전체의 기본 배경색입니다. 페이지의 큰 분위기를 결정하는 요소이므로, 섹션 간 배경 대비를 적절히 주는 것이 좋습니다.",
|
||||
},
|
||||
{
|
||||
label: "배경 이미지 소스 / URL / 파일 업로드",
|
||||
description:
|
||||
"섹션 배경으로 사용할 이미지를 외부 URL 또는 업로드 파일로 지정합니다. Hero 배경, 패턴, 질감 이미지를 설정할 때 사용합니다.",
|
||||
},
|
||||
{
|
||||
label: "배경 이미지 크기 / 위치 / 반복",
|
||||
description:
|
||||
"cover/contain/auto로 크기를 조절하고, 프리셋 또는 X/Y 퍼센트로 위치를 조절하며, 반복 여부를 설정해 패턴 형태로 사용할 수 있습니다.",
|
||||
},
|
||||
{
|
||||
label: "배경 비디오 소스 / URL / 파일 업로드",
|
||||
description:
|
||||
"섹션 배경에 비디오를 설정합니다. 시선을 끌어야 하는 Hero 영역 등에 사용하되, 가독성 저하를 막기 위해 텍스트 대비를 꼭 확인하세요.",
|
||||
},
|
||||
{
|
||||
label: "세로 패딩",
|
||||
description:
|
||||
"섹션 위·아래 여백입니다. 값이 클수록 섹션이 여유 있고 강조되어 보입니다. Hero 섹션은 넉넉한 패딩을, 보조 섹션은 중간 정도를 추천합니다.",
|
||||
},
|
||||
],
|
||||
};
|
||||
case "video":
|
||||
return {
|
||||
title: "비디오 블록 튜토리얼",
|
||||
description:
|
||||
"YouTube/영상 URL 또는 직접 호스팅한 비디오를 삽입할 때 사용하는 블록입니다. 동영상 주소를 입력하고, 비율/정렬/재생 옵션을 조정해 페이지에 맞게 배치해 보세요.",
|
||||
properties: [
|
||||
{
|
||||
label: "비디오 소스 / 비디오 URL / 비디오 파일 업로드",
|
||||
description:
|
||||
"외부 동영상 URL(YouTube, Vimeo 등)을 직접 입력하거나, 비디오 파일을 업로드해 `/api/video/:id` 형태로 사용할 수 있습니다.",
|
||||
},
|
||||
{
|
||||
label: "포스터 이미지 URL",
|
||||
description:
|
||||
"동영상 재생 전/로딩 중에 보여줄 썸네일 이미지입니다. 영상의 핵심 장면이나 대표 이미지를 사용하면 클릭률을 높일 수 있습니다.",
|
||||
},
|
||||
{
|
||||
label: "카드 배경색",
|
||||
description:
|
||||
"비디오가 들어가는 카드 영역의 배경색입니다. 주변 섹션과의 대비를 위해 살짝 어둡거나 밝은 색을 설정할 수 있습니다.",
|
||||
},
|
||||
{
|
||||
label: "비디오 정렬",
|
||||
description:
|
||||
"비디오를 컨테이너 안에서 왼쪽/가운데/오른쪽 중 어디에 위치시킬지 결정합니다.",
|
||||
},
|
||||
{
|
||||
label: "비디오 너비 모드 / 고정 너비 (px)",
|
||||
description:
|
||||
"비디오를 내용 너비에 맞출지, 가로 전체를 채울지, 특정 px 너비로 고정할지 선택합니다.",
|
||||
},
|
||||
{
|
||||
label: "카드 패딩 / 카드 모서리 둥글기",
|
||||
description:
|
||||
"비디오 주변 카드의 안쪽 여백과 모서리 둥글기입니다. 카드형 레이아웃에서는 적당한 패딩과 둥근 모서리를 주면 디자인이 정돈됩니다.",
|
||||
},
|
||||
{
|
||||
label: "시작 시점 (초) / 종료 시점 (초)",
|
||||
description:
|
||||
"영상의 특정 구간만 재생하고 싶을 때 사용하는 옵션입니다. 예를 들어 10초~30초만 재생하도록 설정할 수 있습니다.",
|
||||
},
|
||||
{
|
||||
label: "화면 비율",
|
||||
description:
|
||||
"동영상 프레임 비율입니다. 대부분의 웹 영상은 16:9를 사용하며, 정사각형/세로형 콘텐츠는 1:1, 4:3 등을 사용할 수 있습니다.",
|
||||
},
|
||||
{
|
||||
label: "자동 재생 / 반복 재생 / 음소거 / 재생 컨트롤 표시",
|
||||
description:
|
||||
"자동 재생/반복 재생/음소거/컨트롤 표시 여부를 설정합니다. 자동 재생 영상은 보통 음소거를 함께 켜 두는 것이 좋습니다.",
|
||||
},
|
||||
],
|
||||
};
|
||||
case "form":
|
||||
return {
|
||||
title: "폼 컨트롤러 블록 튜토리얼",
|
||||
description:
|
||||
"입력 필드(formInput/select/checkbox/radio)를 하나의 폼으로 묶는 컨트롤러입니다. 전송 대상, 전송 포맷, 너비, 여백 등을 설정해 네이티브 폼 제출을 구성하고, 필드 ID를 연결해 폼 구조를 완성할 수 있습니다.",
|
||||
properties: [
|
||||
{
|
||||
label: "전송 대상 / Webhook 설정",
|
||||
description:
|
||||
"폼 데이터를 내부 처리로만 사용할지, 외부 Webhook/Google Sheets 등으로 전송할지 결정하고, 목적지 URL/전송 포맷/HTTP 메서드 등을 설정합니다.",
|
||||
},
|
||||
{
|
||||
label: "추가 파라미터",
|
||||
description:
|
||||
"`key=value` 형식으로 줄바꿈해 작성하면, 모든 폼 제출에 공통으로 포함될 추가 파라미터를 지정할 수 있습니다. 예: `source=landing`.",
|
||||
},
|
||||
{
|
||||
label: "폼 너비 모드 / 폼 고정 너비 (px)",
|
||||
description:
|
||||
"폼 전체의 가로 폭을 자동/전체 폭/고정 px 값 중에서 선택합니다. 문의 폼은 보통 480~640px 정도의 고정 폭이 읽기 좋습니다.",
|
||||
},
|
||||
{
|
||||
label: "폼 위/아래 여백 (px)",
|
||||
description:
|
||||
"폼 블록 위·아래에 들어가는 공백입니다. 페이지 내 다른 섹션과의 간격을 조절할 때 사용합니다.",
|
||||
},
|
||||
{
|
||||
label: "성공 메시지 / 에러 메시지",
|
||||
description:
|
||||
"폼 전송 성공 또는 실패 시 사용자에게 보여줄 텍스트입니다. 명확하고 친절한 문구로 작성하는 것이 좋습니다.",
|
||||
},
|
||||
{
|
||||
label: "폼 필드 매핑",
|
||||
description:
|
||||
"폼 컨트롤러와 연결할 입력 필드(formInput/select/checkbox/radio)를 체크박스로 선택합니다. 체크된 필드만 실제 폼 제출 payload에 포함됩니다.",
|
||||
},
|
||||
{
|
||||
label: "Submit 버튼",
|
||||
description:
|
||||
"폼 제출을 담당할 버튼 블록을 지정합니다. 별도 버튼 블록을 만들어 이 컨트롤러와 연결하면, 해당 버튼 클릭 시 폼이 제출됩니다.",
|
||||
},
|
||||
],
|
||||
};
|
||||
case "formInput":
|
||||
return {
|
||||
title: "폼 입력 블록 튜토리얼",
|
||||
description:
|
||||
"이름, 이메일 등 한 줄 텍스트 또는 긴 텍스트 입력 필드를 만들 때 사용하는 블록입니다. 레이블, 플레이스홀더, 필수 여부, 너비 등을 설정해 폼 컨트롤러와 함께 사용합니다.",
|
||||
properties: [
|
||||
{
|
||||
label: "라벨 타입 / 필드 라벨",
|
||||
description:
|
||||
"입력 필드 위나 옆에 표시될 설명입니다. 텍스트 또는 이미지로 라벨을 구성할 수 있으며, 사용자가 어떤 값을 입력해야 하는지 명확하게 알려줍니다.",
|
||||
},
|
||||
{
|
||||
label: "라벨 이미지 URL / 대체 텍스트",
|
||||
description:
|
||||
"라벨을 이미지로 사용할 때 경로와 alt 텍스트를 지정합니다. 로고형 라벨이나 아이콘 기반 UI를 만들 때 활용할 수 있습니다.",
|
||||
},
|
||||
{
|
||||
label: "전송 키",
|
||||
description:
|
||||
"이 입력값이 서버/웹훅에 전달될 때 사용되는 필드 이름입니다. 백엔드나 스프레드시트 컬럼명과 일치시키면 이후 처리가 편해집니다.",
|
||||
},
|
||||
{
|
||||
label: "필드 타입",
|
||||
description:
|
||||
"텍스트/이메일/긴 텍스트 중 어떤 형태의 입력을 받을지 결정합니다. 이메일 타입은 브라우저 기본 이메일 검증을 활용할 수 있습니다.",
|
||||
},
|
||||
{
|
||||
label: "Placeholder",
|
||||
description:
|
||||
"입력 전 필드 안에 흐릿하게 보여줄 안내 문구입니다. 예: '이메일을 입력해 주세요'.",
|
||||
},
|
||||
{
|
||||
label: "필수 필드",
|
||||
description:
|
||||
"체크 시 이 필드는 반드시 채워야 합니다. 이름/이메일 같은 필수 값에는 활성화하고, 선택 항목에는 비활성화하는 것을 추천합니다.",
|
||||
},
|
||||
{
|
||||
label: "텍스트 크기 / 줄간격 / 자간",
|
||||
description:
|
||||
"입력 값의 폰트 크기(px), 줄간격(px), 자간(px)을 조절해 폼 필드의 가독성과 분위기를 세밀하게 튜닝합니다.",
|
||||
},
|
||||
{
|
||||
label: "텍스트 정렬",
|
||||
description:
|
||||
"필드 안 텍스트를 왼쪽/가운데/오른쪽 중 어디에 정렬할지 결정합니다. 일반 입력은 왼쪽 정렬을 권장합니다.",
|
||||
},
|
||||
{
|
||||
label: "레이아웃 / 라벨/필드 간격",
|
||||
description:
|
||||
"라벨과 필드를 세로(stack) 또는 가로(inline)로 배치하고, inline 모드에서 라벨과 입력 사이 간격(px)을 조절합니다.",
|
||||
},
|
||||
{
|
||||
label: "너비 / 필드 고정 너비",
|
||||
description:
|
||||
"필드를 자동/전체 폭/고정 px 중 어떤 너비로 렌더링할지 결정합니다. 짧은 필드는 고정 값, 긴 입력은 전체 폭을 사용할 수 있습니다.",
|
||||
},
|
||||
{
|
||||
label: "필드 가로/세로 패딩",
|
||||
description:
|
||||
"입력 상자 안쪽 여백을 조절합니다. 값이 클수록 필드가 커지고 누르기/입력하기 편해집니다.",
|
||||
},
|
||||
{
|
||||
label: "필드 텍스트/채움/테두리 색상",
|
||||
description:
|
||||
"입력 텍스트, 배경, 테두리 색상을 각각 조절합니다. 에러 상태/강조 상태 등을 표현할 때 조합해서 사용할 수 있습니다.",
|
||||
},
|
||||
{
|
||||
label: "필드 모서리 둥글기",
|
||||
description:
|
||||
"입력 상자의 모서리를 얼마나 둥글게 할지 결정합니다. 페이지의 전체 디자인 톤과 맞춰 사용하세요.",
|
||||
},
|
||||
],
|
||||
};
|
||||
case "formSelect":
|
||||
return {
|
||||
title: "폼 셀렉트 블록 튜토리얼",
|
||||
description:
|
||||
"드롭다운 선택 필드를 만들 때 사용하는 블록입니다. 옵션 목록과 기본 선택값, 너비를 설정해 폼 컨트롤러와 함께 사용합니다.",
|
||||
properties: [
|
||||
{
|
||||
label: "라벨 타입 / 필드 라벨",
|
||||
description:
|
||||
"셀렉트 필드의 제목 역할을 하는 라벨입니다. 텍스트 또는 이미지로 구성할 수 있으며, 선택해야 할 값의 의미를 설명합니다.",
|
||||
},
|
||||
{
|
||||
label: "전송 키",
|
||||
description:
|
||||
"선택된 옵션 값이 서버/웹훅에 전달될 때 사용되는 필드 이름입니다. 백엔드나 스프레드시트 컬럼명과 일치시키면 이후 처리가 편해집니다.",
|
||||
},
|
||||
{
|
||||
label: "옵션",
|
||||
description:
|
||||
"사용자가 선택할 수 있는 옵션 목록입니다. 라벨과 value를 함께 설정하며, '옵션 추가' 버튼으로 새 옵션을 만들 수 있습니다.",
|
||||
},
|
||||
{
|
||||
label: "필수 필드",
|
||||
description:
|
||||
"체크 시 반드시 하나의 옵션을 선택해야 합니다. 중요 선택 항목에는 필수로 설정하는 것을 권장합니다.",
|
||||
},
|
||||
{
|
||||
label: "셀렉트 텍스트 크기 / 줄간격 / 자간",
|
||||
description:
|
||||
"드롭다운 필드의 텍스트 타이포그래피를 px 단위로 조절해 가독성과 분위기를 맞춥니다.",
|
||||
},
|
||||
{
|
||||
label: "필드 너비 / 필드 고정 너비",
|
||||
description:
|
||||
"셀렉트 필드를 자동/전체 폭/고정 px 중 어떤 너비로 렌더링할지 결정합니다.",
|
||||
},
|
||||
{
|
||||
label: "셀렉트 가로/세로 패딩",
|
||||
description:
|
||||
"드롭다운 안쪽 여백을 조절해 클릭 영역과 시각적 무게감을 조정합니다.",
|
||||
},
|
||||
{
|
||||
label: "필드 텍스트/채움/테두리 색상",
|
||||
description:
|
||||
"선택 영역의 텍스트, 배경, 테두리 색상을 설정합니다. 폼 전체 톤과 잘 어울리는 색상을 사용하는 것이 좋습니다.",
|
||||
},
|
||||
{
|
||||
label: "필드 모서리 둥글기",
|
||||
description:
|
||||
"셀렉트 박스의 모서리를 얼마나 둥글게 표시할지 결정합니다.",
|
||||
},
|
||||
],
|
||||
};
|
||||
case "formCheckbox":
|
||||
return {
|
||||
title: "폼 체크박스 블록 튜토리얼",
|
||||
description:
|
||||
"여러 항목 중 복수 선택이 필요한 경우 사용하는 체크박스 그룹 블록입니다. 그룹 타이틀과 옵션 라벨/이미지를 설정해 동의 항목이나 관심사 선택 등을 구성할 수 있습니다.",
|
||||
properties: [
|
||||
{
|
||||
label: "그룹 타이틀 타입 / 그룹 타이틀",
|
||||
description:
|
||||
"체크박스 그룹 전체의 제목입니다. 텍스트 또는 이미지로 표현할 수 있으며, 사용자가 어떤 범주를 선택하는지 알려줍니다.",
|
||||
},
|
||||
{
|
||||
label: "그룹 타이틀 이미지 소스 / URL / 파일 업로드",
|
||||
description:
|
||||
"그룹 라벨을 이미지로 사용할 때, URL 또는 업로드된 이미지를 선택합니다.",
|
||||
},
|
||||
{
|
||||
label: "전송 키",
|
||||
description:
|
||||
"체크된 옵션들의 값이 서버/웹훅에 전달될 때 사용되는 필드 이름입니다.",
|
||||
},
|
||||
{
|
||||
label: "옵션 / 옵션 이미지 소스",
|
||||
description:
|
||||
"각 체크박스 옵션의 라벨/값/이미지를 설정합니다. URL 또는 업로드 이미지로 옵션별 아이콘을 붙일 수 있습니다.",
|
||||
},
|
||||
{
|
||||
label: "필수 필드",
|
||||
description:
|
||||
"체크 시 하나 이상 옵션을 선택해야 합니다. 약관 동의 등 필수 동의 항목에 사용합니다.",
|
||||
},
|
||||
{
|
||||
label: "체크박스 텍스트 크기 / 줄간격 / 자간",
|
||||
description:
|
||||
"체크박스 라벨의 폰트 크기, 줄간격, 자간을 조절합니다. 항목이 길어질수록 줄간격을 넉넉히 두는 것이 좋습니다.",
|
||||
},
|
||||
],
|
||||
};
|
||||
case "formRadio":
|
||||
return {
|
||||
title: "폼 라디오 블록 튜토리얼",
|
||||
description:
|
||||
"여러 옵션 중 하나만 선택해야 할 때 사용하는 라디오 버튼 그룹 블록입니다. 옵션 라벨/이미지와 기본 선택값을 설정해 요금제 선택 등 단일 선택 시나리오를 구성할 수 있습니다.",
|
||||
properties: [
|
||||
{
|
||||
label: "그룹 타이틀 타입 / 그룹 타이틀",
|
||||
description:
|
||||
"라디오 그룹의 제목입니다. 요금제 이름, 질문 문구 등 단일 선택의 맥락을 설명합니다.",
|
||||
},
|
||||
{
|
||||
label: "그룹 타이틀 이미지 소스 / URL / 파일 업로드",
|
||||
description:
|
||||
"그룹 라벨을 이미지로 사용할 때, URL 또는 업로드 이미지를 선택합니다.",
|
||||
},
|
||||
{
|
||||
label: "전송 키",
|
||||
description:
|
||||
"선택된 하나의 옵션 값이 서버/웹훅에 전달될 때 사용되는 필드 이름입니다.",
|
||||
},
|
||||
{
|
||||
label: "옵션 / 옵션 이미지 소스",
|
||||
description:
|
||||
"각 라디오 옵션의 라벨/값/이미지를 설정합니다. 옵션별 아이콘이나 썸네일을 붙여 더 풍부한 선택 UI를 만들 수 있습니다.",
|
||||
},
|
||||
{
|
||||
label: "필수 필드",
|
||||
description:
|
||||
"체크 시 반드시 하나의 옵션을 선택해야 합니다. 요금제/유형 선택처럼 반드시 선택이 필요한 경우에 사용합니다.",
|
||||
},
|
||||
{
|
||||
label: "라디오 텍스트 크기 / 줄간격 / 자간",
|
||||
description:
|
||||
"라디오 라벨의 폰트 크기, 줄간격, 자간을 조절합니다. 옵션 설명이 긴 경우 줄간격을 넉넉히 두세요.",
|
||||
},
|
||||
],
|
||||
};
|
||||
default:
|
||||
return {
|
||||
title: "블록 튜토리얼",
|
||||
description: "이 블록에 대한 자세한 도움말은 추후 추가될 예정입니다.",
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<aside
|
||||
data-testid="properties-sidebar"
|
||||
className="w-80 p-4 text-sm border-l border-slate-800 flex flex-col gap-4 overflow-auto pb-scroll"
|
||||
className="w-80 p-4 text-sm border-l border-slate-800 flex flex-col gap-4 overflow-auto pb-scroll bg-slate-950/40"
|
||||
onKeyDownCapture={(e) => {
|
||||
// 속성 패널 안에서 발생한 키 입력은 에디터 단축키/텍스트 블록 편집으로 전달되지 않도록 막는다.
|
||||
e.stopPropagation();
|
||||
}}
|
||||
>
|
||||
<h2 className="font-medium mb-2">속성 패널</h2>
|
||||
<h2 className="font-medium mb-2 flex items-center gap-2 text-slate-200">
|
||||
<SlidersHorizontal className="w-4 h-4 text-sky-400" aria-hidden="true" />
|
||||
<span>속성 패널</span>
|
||||
</h2>
|
||||
{selectedBlockId ? (
|
||||
<div className="space-y-3">
|
||||
<div className="flex gap-2 text-[11px]">
|
||||
@@ -66,14 +684,30 @@ export function PropertiesSidebar(props: PropertiesSidebarProps) {
|
||||
className="flex-1 rounded border border-slate-700 bg-slate-900 px-2 py-1 text-slate-100 hover:bg-red-900/60 hover:border-red-700"
|
||||
onClick={() => removeBlock(selectedBlockId)}
|
||||
>
|
||||
블록 삭제
|
||||
<span className="inline-flex items-center gap-2">
|
||||
<Trash2 className="w-3 h-3" aria-hidden="true" />
|
||||
<span>블록 삭제</span>
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="flex-1 rounded border border-slate-700 bg-slate-900 px-2 py-1 text-slate-100 hover:bg-slate-800"
|
||||
onClick={() => duplicateBlock(selectedBlockId)}
|
||||
>
|
||||
블록 복제
|
||||
<span className="inline-flex items-center gap-2">
|
||||
<Copy className="w-3 h-3" aria-hidden="true" />
|
||||
<span>블록 복제</span>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex justify-end text-[11px]">
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex items-center gap-1 rounded border border-slate-700 bg-slate-900 px-2 py-1 text-slate-100 hover:bg-slate-800"
|
||||
onClick={() => setHelpOpen(true)}
|
||||
>
|
||||
<HelpCircle className="w-3 h-3" aria-hidden="true" />
|
||||
<span>HELP</span>
|
||||
</button>
|
||||
</div>
|
||||
{(() => {
|
||||
@@ -224,6 +858,41 @@ export function PropertiesSidebar(props: PropertiesSidebarProps) {
|
||||
) : (
|
||||
<ProjectPropertiesPanel />
|
||||
)}
|
||||
{helpOpen && (() => {
|
||||
const help = getHelpContentForSelectedBlock();
|
||||
if (!help) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-40 flex items-center justify-center bg-black/60">
|
||||
<div className="w-full max-w-md rounded-lg border border-slate-700 bg-slate-900 p-4 text-xs text-slate-100 shadow-xl">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<h3 className="text-sm font-medium">{help.title}</h3>
|
||||
<button
|
||||
type="button"
|
||||
className="text-slate-400 hover:text-slate-100 text-xs"
|
||||
onClick={() => setHelpOpen(false)}
|
||||
>
|
||||
닫기
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-[11px] text-slate-200 whitespace-pre-line">{help.description}</p>
|
||||
{help.properties && help.properties.length > 0 && (
|
||||
<div className="mt-3 border-t border-slate-700 pt-2 space-y-2">
|
||||
<h4 className="text-[11px] font-semibold text-slate-200">속성별 설명</h4>
|
||||
<ul className="space-y-1">
|
||||
{help.properties.map((prop) => (
|
||||
<li key={prop.label}>
|
||||
<div className="text-[11px] font-semibold text-slate-100">{prop.label}</div>
|
||||
<div className="text-[11px] text-slate-300">{prop.description}</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
"use client";
|
||||
|
||||
import { FormEvent, useEffect, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
|
||||
// 로그인 페이지 컴포넌트
|
||||
// - 이메일/비밀번호를 입력받아 /api/auth/login 으로 요청을 전송한다.
|
||||
// - 성공 시 /projects 로 이동하고, 실패 시 에러 메시지를 보여준다.
|
||||
|
||||
export default function LoginPage() {
|
||||
const router = useRouter();
|
||||
|
||||
const [email, setEmail] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
const checkAuth = async () => {
|
||||
try {
|
||||
const res = await fetch("/api/auth/me");
|
||||
if (!cancelled && res.ok) {
|
||||
router.push("/projects");
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
};
|
||||
|
||||
void checkAuth();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [router]);
|
||||
|
||||
const handleSubmit = async (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
setError(null);
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
const res = await fetch("/api/auth/login", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ email, password }),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
try {
|
||||
const data = await res.json();
|
||||
setError(data?.message ?? "로그인에 실패했습니다. 다시 시도해 주세요.");
|
||||
} catch {
|
||||
setError("로그인에 실패했습니다. 다시 시도해 주세요.");
|
||||
}
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// 성공 시에는 /projects 로 이동한다.
|
||||
router.push("/projects");
|
||||
} catch {
|
||||
setError("네트워크 오류가 발생했습니다. 잠시 후 다시 시도해 주세요.");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<main className="min-h-screen flex items-center justify-center bg-slate-950 text-slate-50">
|
||||
<div className="w-full max-w-sm rounded-lg border border-slate-800 bg-slate-900/70 p-6 shadow-xl">
|
||||
<h1 className="text-lg font-semibold mb-1">로그인</h1>
|
||||
<p className="text-xs text-slate-400 mb-4">프로젝트를 관리하려면 먼저 계정으로 로그인하세요.</p>
|
||||
|
||||
{error && <p className="mb-3 text-xs text-red-300">{error}</p>}
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-3 text-xs">
|
||||
<div className="space-y-1">
|
||||
<label htmlFor="email" className="block text-slate-200">
|
||||
이메일
|
||||
</label>
|
||||
<input
|
||||
id="email"
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
className="w-full rounded border border-slate-700 bg-slate-950 px-2 py-1 text-xs text-slate-50 focus:outline-none focus:ring-1 focus:ring-sky-500"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<label htmlFor="password" className="block text-slate-200">
|
||||
비밀번호
|
||||
</label>
|
||||
<input
|
||||
id="password"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
className="w-full rounded border border-slate-700 bg-slate-950 px-2 py-1 text-xs text-slate-50 focus:outline-none focus:ring-1 focus:ring-sky-500"
|
||||
required
|
||||
minLength={8}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="mt-2 w-full inline-flex items-center justify-center gap-1 rounded bg-sky-600 hover:bg-sky-500 disabled:opacity-40 disabled:cursor-not-allowed px-3 py-1 text-xs font-medium"
|
||||
>
|
||||
{loading ? "로그인 중..." : "로그인"}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<p className="mt-4 text-[11px] text-slate-400">
|
||||
아직 계정이 없다면
|
||||
{" "}
|
||||
<Link href="/signup" className="text-sky-300 hover:text-sky-200 underline-offset-2 hover:underline">
|
||||
회원가입
|
||||
</Link>
|
||||
을 진행해 주세요.
|
||||
</p>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
+107
-6
@@ -1,13 +1,62 @@
|
||||
"use client";
|
||||
|
||||
import type { CSSProperties } from "react";
|
||||
import { useEffect } from "react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useEditorStore } from "@/features/editor/state/editorStore";
|
||||
import { PublicPageRenderer } from "@/features/editor/components/PublicPageRenderer";
|
||||
|
||||
export default function PreviewPage() {
|
||||
const router = useRouter();
|
||||
const blocks = useEditorStore((state) => state.blocks);
|
||||
const projectConfig = useEditorStore((state) => (state as any).projectConfig);
|
||||
const replaceBlocks = useEditorStore((state) => state.replaceBlocks);
|
||||
const updateProjectConfig = useEditorStore((state) => state.updateProjectConfig);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
const checkAuth = async () => {
|
||||
try {
|
||||
const res = await fetch("/api/auth/me");
|
||||
if (!res.ok && res.status === 401 && !cancelled) {
|
||||
router.push("/login");
|
||||
}
|
||||
} catch {
|
||||
// 네트워크 오류 등은 일단 무시하고, 페이지 접근을 막지 않는다.
|
||||
}
|
||||
};
|
||||
|
||||
void checkAuth();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [router]);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === "undefined") return;
|
||||
|
||||
const slug = (projectConfig as any)?.slug?.trim?.();
|
||||
if (!slug) return;
|
||||
|
||||
const key = `pb:autosave:${slug}`;
|
||||
const raw = window.localStorage.getItem(key);
|
||||
if (!raw) return;
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(raw) as { blocks?: any[]; projectConfig?: any };
|
||||
if (Array.isArray(parsed.blocks)) {
|
||||
replaceBlocks(parsed.blocks as any);
|
||||
}
|
||||
if (parsed.projectConfig) {
|
||||
updateProjectConfig(parsed.projectConfig as any);
|
||||
}
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
}, [projectConfig?.slug, replaceBlocks, updateProjectConfig]);
|
||||
|
||||
const canvasStyle: CSSProperties = {};
|
||||
const preset = projectConfig?.canvasPreset ?? "full";
|
||||
@@ -17,6 +66,41 @@ export default function PreviewPage() {
|
||||
mainStyle.backgroundColor = projectConfig.bodyBgColorHex;
|
||||
}
|
||||
|
||||
const handleExportZip = async () => {
|
||||
try {
|
||||
const payload = {
|
||||
blocks,
|
||||
projectConfig,
|
||||
};
|
||||
|
||||
const response = await fetch("/api/export", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
return;
|
||||
}
|
||||
|
||||
const blob = await response.blob();
|
||||
const url = URL.createObjectURL(blob);
|
||||
const slug = (projectConfig?.slug ?? "").trim() || "page-builder-export";
|
||||
|
||||
const link = document.createElement("a");
|
||||
link.href = url;
|
||||
link.download = `${slug}.zip`;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
URL.revokeObjectURL(url);
|
||||
} catch (error) {
|
||||
console.error("프리뷰 ZIP 내보내기 중 오류", error);
|
||||
}
|
||||
};
|
||||
|
||||
const widthPx =
|
||||
typeof projectConfig?.canvasWidthPx === "number" && projectConfig.canvasWidthPx > 0
|
||||
? projectConfig.canvasWidthPx
|
||||
@@ -43,12 +127,29 @@ export default function PreviewPage() {
|
||||
<h1 className="text-xl font-semibold">Page Preview</h1>
|
||||
<p className="text-xs text-slate-400">빌더로 만든 페이지를 미리 보는 화면</p>
|
||||
</div>
|
||||
<Link
|
||||
href="/editor"
|
||||
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>
|
||||
<div className="flex items-center gap-2 text-xs">
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex items-center rounded border border-slate-700 bg-slate-900 px-3 py-1 text-slate-100 hover:bg-slate-800"
|
||||
onClick={() => {
|
||||
void handleExportZip();
|
||||
}}
|
||||
>
|
||||
페이지 파일로 내보내기 (ZIP)
|
||||
</button>
|
||||
<Link
|
||||
href="/projects"
|
||||
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>
|
||||
<Link
|
||||
href="/editor"
|
||||
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>
|
||||
</div>
|
||||
</header>
|
||||
{/* 에디터 크롬 없이 순수 페이지 형태로 블록들을 렌더링하되, projectConfig 에 따라 최대 폭을 제한한다. */}
|
||||
<section className="flex flex-1 overflow-auto">
|
||||
|
||||
@@ -0,0 +1,406 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import {
|
||||
FilePlus2,
|
||||
Trash2,
|
||||
Eye,
|
||||
Pencil,
|
||||
ListChecks,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
} from "lucide-react";
|
||||
|
||||
interface ProjectListItem {
|
||||
id: string;
|
||||
title: string;
|
||||
slug: string;
|
||||
status: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export default function ProjectsPage() {
|
||||
const router = useRouter();
|
||||
const [projects, setProjects] = useState<ProjectListItem[]>([]);
|
||||
const [status, setStatus] = useState<"idle" | "loading" | "error">("idle");
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||
const [selectedSlugs, setSelectedSlugs] = useState<string[]>([]);
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const pageSize = 10;
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
const load = async () => {
|
||||
try {
|
||||
setStatus("loading");
|
||||
setErrorMessage(null);
|
||||
|
||||
const res = await fetch("/api/projects");
|
||||
if (!res.ok) {
|
||||
if (!cancelled) {
|
||||
setStatus("error");
|
||||
|
||||
if (res.status === 401) {
|
||||
setErrorMessage("프로젝트 목록을 불러오는 중 인증 오류가 발생했습니다. 다시 로그인해 주세요.");
|
||||
router.push("/login");
|
||||
} else {
|
||||
setErrorMessage(
|
||||
"프로젝트 목록을 불러오는 중 오류가 발생했습니다. 잠시 후 다시 시도해 주세요.",
|
||||
);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const data = (await res.json()) as ProjectListItem[];
|
||||
if (!cancelled) {
|
||||
setProjects(Array.isArray(data) ? data : []);
|
||||
setStatus("idle");
|
||||
setErrorMessage(null);
|
||||
}
|
||||
} catch {
|
||||
if (!cancelled) {
|
||||
setStatus("error");
|
||||
setErrorMessage("프로젝트 목록을 불러오는 중 오류가 발생했습니다. 잠시 후 다시 시도해 주세요.");
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
void load();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const totalPages = Math.max(1, Math.ceil(projects.length / pageSize));
|
||||
setCurrentPage((prev) => Math.min(prev, totalPages));
|
||||
}, [projects.length, pageSize]);
|
||||
|
||||
const handleDelete = async (slug: string) => {
|
||||
if (typeof window !== "undefined") {
|
||||
const confirmed = window.confirm(
|
||||
"이 프로젝트를 삭제할까요? 삭제 후에는 되돌릴 수 없습니다. (로컬 저장 및 자동저장 데이터도 함께 삭제됩니다.)",
|
||||
);
|
||||
|
||||
if (!confirmed) return;
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/projects/${encodeURIComponent(slug)}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
setStatus("error");
|
||||
setErrorMessage("프로젝트 삭제 중 오류가 발생했습니다. 잠시 후 다시 시도해 주세요.");
|
||||
return;
|
||||
}
|
||||
|
||||
setProjects((prev) => prev.filter((p) => p.slug !== slug));
|
||||
setSelectedSlugs((prev) => prev.filter((s) => s !== slug));
|
||||
|
||||
if (typeof window !== "undefined") {
|
||||
window.localStorage.removeItem(`pb:project:${slug}`);
|
||||
window.localStorage.removeItem(`pb:autosave:${slug}`);
|
||||
}
|
||||
|
||||
setStatus("idle");
|
||||
setErrorMessage(null);
|
||||
} catch {
|
||||
setStatus("error");
|
||||
setErrorMessage("프로젝트 삭제 중 오류가 발생했습니다. 잠시 후 다시 시도해 주세요.");
|
||||
}
|
||||
};
|
||||
|
||||
const handleLogout = async () => {
|
||||
try {
|
||||
const res = await fetch("/api/auth/logout", {
|
||||
method: "POST",
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
setStatus("error");
|
||||
setErrorMessage("로그아웃 중 오류가 발생했습니다. 잠시 후 다시 시도해 주세요.");
|
||||
return;
|
||||
}
|
||||
|
||||
router.push("/login");
|
||||
} catch {
|
||||
setStatus("error");
|
||||
setErrorMessage("로그아웃 중 오류가 발생했습니다. 잠시 후 다시 시도해 주세요.");
|
||||
}
|
||||
};
|
||||
|
||||
const totalCount = projects.length;
|
||||
const totalPages = Math.max(1, Math.ceil(totalCount / pageSize));
|
||||
const startIndex = (currentPage - 1) * pageSize;
|
||||
const endIndex = startIndex + pageSize;
|
||||
const pageProjects = projects.slice(startIndex, endIndex);
|
||||
|
||||
const handleBulkDelete = async () => {
|
||||
const slugs = selectedSlugs;
|
||||
if (slugs.length === 0) return;
|
||||
|
||||
if (typeof window !== "undefined") {
|
||||
const confirmed = window.confirm(
|
||||
"선택한 프로젝트를 모두 삭제할까요? 삭제 후에는 되돌릴 수 없습니다. (로컬 저장 및 자동저장 데이터도 함께 삭제됩니다.)",
|
||||
);
|
||||
if (!confirmed) return;
|
||||
}
|
||||
|
||||
try {
|
||||
const results = await Promise.all(
|
||||
slugs.map(async (slug) => {
|
||||
const res = await fetch(`/api/projects/${encodeURIComponent(slug)}`, { method: "DELETE" });
|
||||
return { slug, ok: res.ok };
|
||||
}),
|
||||
);
|
||||
|
||||
const okSlugs = results.filter((r) => r.ok).map((r) => r.slug);
|
||||
|
||||
if (okSlugs.length === 0) {
|
||||
setStatus("error");
|
||||
setErrorMessage("선택한 프로젝트를 삭제하는 중 오류가 발생했습니다. 잠시 후 다시 시도해 주세요.");
|
||||
return;
|
||||
}
|
||||
|
||||
setProjects((prev) => prev.filter((p) => !okSlugs.includes(p.slug)));
|
||||
setSelectedSlugs((prev) => prev.filter((s) => !okSlugs.includes(s)));
|
||||
|
||||
if (typeof window !== "undefined") {
|
||||
for (const slug of okSlugs) {
|
||||
window.localStorage.removeItem(`pb:project:${slug}`);
|
||||
window.localStorage.removeItem(`pb:autosave:${slug}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (okSlugs.length !== slugs.length) {
|
||||
setStatus("error");
|
||||
setErrorMessage(
|
||||
"일부 프로젝트 삭제에 실패했습니다. 페이지를 새로고침한 뒤 목록을 확인해 주세요.",
|
||||
);
|
||||
} else {
|
||||
setStatus("idle");
|
||||
setErrorMessage(null);
|
||||
}
|
||||
} catch {
|
||||
setStatus("error");
|
||||
setErrorMessage("선택한 프로젝트를 삭제하는 중 오류가 발생했습니다. 잠시 후 다시 시도해 주세요.");
|
||||
}
|
||||
};
|
||||
|
||||
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 bg-slate-950/80 backdrop-blur">
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold">프로젝트 목록</h1>
|
||||
<p className="text-xs text-slate-400">저장된 프로젝트들을 한 눈에 볼 수 있는 목록</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-xs">
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex items-center gap-1 rounded border border-slate-700 px-3 py-1 text-slate-200 hover:bg-slate-900"
|
||||
onClick={() => {
|
||||
void handleLogout();
|
||||
}}
|
||||
>
|
||||
로그아웃
|
||||
</button>
|
||||
<Link
|
||||
href="/editor"
|
||||
className="inline-flex items-center gap-1 rounded border border-sky-700 bg-sky-950 px-3 py-1 text-sky-100 hover:bg-sky-900 shadow-sm"
|
||||
>
|
||||
<FilePlus2 className="w-4 h-4" aria-hidden="true" />
|
||||
<span>새 프로젝트 만들기</span>
|
||||
</Link>
|
||||
</div>
|
||||
</header>
|
||||
<section className="flex-1 px-6 py-4 overflow-auto">
|
||||
{status === "error" && (
|
||||
<p className="text-xs text-red-300 mb-3">
|
||||
{errorMessage ?? "프로젝트 목록을 불러오는 중 오류가 발생했습니다. 잠시 후 다시 시도해 주세요."}
|
||||
</p>
|
||||
)}
|
||||
{projects.length === 0 && status === "idle" && (
|
||||
<p className="text-xs text-slate-400">아직 저장된 프로젝트가 없습니다. 에디터에서 프로젝트를 저장해 보세요.</p>
|
||||
)}
|
||||
{projects.length > 0 && (
|
||||
<>
|
||||
<div className="flex items-center justify-between mb-2 text-[11px] text-slate-300">
|
||||
<div className="inline-flex items-center gap-2">
|
||||
<span className="inline-flex items-center gap-1 px-2 py-1 rounded-full bg-slate-900/70 border border-slate-700">
|
||||
<ListChecks className="w-3 h-3 text-slate-400" aria-hidden="true" />
|
||||
<span>
|
||||
선택된 프로젝트: <span className="font-semibold">{selectedSlugs.length}</span>개
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex items-center gap-1 rounded border border-red-700 px-2 py-1 text-red-200 hover:bg-red-900/40 disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
disabled={selectedSlugs.length === 0}
|
||||
onClick={() => {
|
||||
void handleBulkDelete();
|
||||
}}
|
||||
>
|
||||
<Trash2 className="w-3 h-3" aria-hidden="true" />
|
||||
<span>선택 삭제</span>
|
||||
</button>
|
||||
</div>
|
||||
<table className="w-full text-xs text-left border-collapse mt-2">
|
||||
<thead>
|
||||
<tr className="border-b border-slate-800 text-slate-400">
|
||||
<th className="py-2 pr-2 w-8">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="h-3 w-3 rounded border-slate-700 bg-slate-900 align-middle"
|
||||
aria-label="전체 선택"
|
||||
checked={
|
||||
pageProjects.length > 0 && pageProjects.every((p) => selectedSlugs.includes(p.slug))
|
||||
}
|
||||
onChange={(e) => {
|
||||
if (e.target.checked) {
|
||||
const slugsOnPage = pageProjects.map((p) => p.slug);
|
||||
setSelectedSlugs((prev) => Array.from(new Set([...prev, ...slugsOnPage])));
|
||||
} else {
|
||||
const slugsOnPage = pageProjects.map((p) => p.slug);
|
||||
setSelectedSlugs((prev) => prev.filter((s) => !slugsOnPage.includes(s)));
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</th>
|
||||
<th className="py-2 pr-4">제목</th>
|
||||
<th className="py-2 pr-4">주소(slug)</th>
|
||||
<th className="py-2 pr-4">상태</th>
|
||||
<th className="py-2 pr-4">생성일</th>
|
||||
<th className="py-2 pr-4">수정일</th>
|
||||
<th className="py-2 pr-4 text-right">액션</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{pageProjects.map((project) => {
|
||||
const checked = selectedSlugs.includes(project.slug);
|
||||
return (
|
||||
<tr key={project.id} className="border-b border-slate-900 hover:bg-slate-900/60">
|
||||
<td className="py-2 pr-2 w-8">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="h-3 w-3 rounded border-slate-700 bg-slate-900 align-middle"
|
||||
aria-label={`${project.title} 선택`}
|
||||
checked={checked}
|
||||
onChange={(e) => {
|
||||
if (e.target.checked) {
|
||||
setSelectedSlugs((prev) =>
|
||||
prev.includes(project.slug) ? prev : [...prev, project.slug],
|
||||
);
|
||||
} else {
|
||||
setSelectedSlugs((prev) => prev.filter((s) => s !== project.slug));
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</td>
|
||||
<td className="py-2 pr-4 text-slate-100">{project.title}</td>
|
||||
<td className="py-2 pr-4 text-slate-300 font-mono text-[11px]">{project.slug}</td>
|
||||
<td className="py-2 pr-4 text-slate-300">{project.status}</td>
|
||||
<td className="py-2 pr-4 text-slate-500 text-[11px]">
|
||||
{new Date(project.createdAt).toLocaleString()}
|
||||
</td>
|
||||
<td className="py-2 pr-4 text-slate-500 text-[11px]">
|
||||
{new Date(project.updatedAt).toLocaleString()}
|
||||
</td>
|
||||
<td className="py-2 pl-4 pr-0 text-right text-[11px]">
|
||||
<div className="inline-flex items-center gap-2">
|
||||
<Link
|
||||
href={`/editor?slug=${encodeURIComponent(project.slug)}`}
|
||||
className="inline-flex items-center gap-1 text-sky-300 hover:text-sky-200 underline-offset-2 hover:underline"
|
||||
>
|
||||
<Pencil className="w-3 h-3" aria-hidden="true" />
|
||||
<span>편집</span>
|
||||
</Link>
|
||||
<Link
|
||||
href={`/preview?slug=${encodeURIComponent(project.slug)}`}
|
||||
className="inline-flex items-center gap-1 text-slate-300 hover:text-slate-100 underline-offset-2 hover:underline"
|
||||
>
|
||||
<Eye className="w-3 h-3" aria-hidden="true" />
|
||||
<span>미리보기</span>
|
||||
</Link>
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex items-center gap-1 text-red-300 hover:text-red-200 underline-offset-2 hover:underline"
|
||||
onClick={() => {
|
||||
void handleDelete(project.slug);
|
||||
}}
|
||||
>
|
||||
<Trash2 className="w-3 h-3" aria-hidden="true" />
|
||||
<span>삭제</span>
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<div className="mt-3 flex items-center justify-between text-[11px] text-slate-300">
|
||||
<div className="inline-flex items-center gap-2">
|
||||
<span className="px-2 py-1 rounded-full bg-slate-900/70 border border-slate-700">
|
||||
전체 {totalCount}개 중{" "}
|
||||
<span className="font-semibold">
|
||||
{totalCount === 0 ? 0 : startIndex + 1}–{Math.min(endIndex, totalCount)}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
{Array.from({ length: totalPages }).map((_, idx) => {
|
||||
const page = idx + 1;
|
||||
const isActive = page === currentPage;
|
||||
return (
|
||||
<button
|
||||
key={page}
|
||||
type="button"
|
||||
onClick={() => setCurrentPage(page)}
|
||||
className={`w-6 h-6 rounded-full text-[10px] flex items-center justify-center border transition-colors ${
|
||||
isActive
|
||||
? "bg-sky-600 text-white border-sky-500 shadow-sm"
|
||||
: "bg-slate-900 text-slate-300 border-slate-700 hover:bg-slate-800"
|
||||
}`}
|
||||
>
|
||||
{page}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
type="button"
|
||||
disabled={currentPage === 1}
|
||||
className="inline-flex items-center gap-1 px-2 py-1 rounded border border-slate-700 text-slate-300 disabled:opacity-40 disabled:cursor-not-allowed hover:bg-slate-800"
|
||||
onClick={() => setCurrentPage((p) => Math.max(1, p - 1))}
|
||||
>
|
||||
<ChevronLeft className="w-3 h-3" aria-hidden="true" />
|
||||
<span>이전</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={currentPage === totalPages}
|
||||
className="inline-flex items-center gap-1 px-2 py-1 rounded border border-slate-700 text-slate-300 disabled:opacity-40 disabled:cursor-not-allowed hover:bg-slate-800"
|
||||
onClick={() => setCurrentPage((p) => Math.min(totalPages, p + 1))}
|
||||
>
|
||||
<span>다음</span>
|
||||
<ChevronRight className="w-3 h-3" aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
"use client";
|
||||
|
||||
import { FormEvent, useEffect, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
|
||||
// 회원가입 페이지 컴포넌트
|
||||
// - 이메일/비밀번호를 입력받아 /api/auth/signup 으로 요청을 전송한다.
|
||||
// - 성공 시 /projects 로 이동하고, 실패 시 에러 메시지를 보여준다.
|
||||
|
||||
export default function SignupPage() {
|
||||
const router = useRouter();
|
||||
|
||||
const [email, setEmail] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
const checkAuth = async () => {
|
||||
try {
|
||||
const res = await fetch("/api/auth/me");
|
||||
if (!cancelled && res.ok) {
|
||||
router.push("/projects");
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
};
|
||||
|
||||
void checkAuth();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [router]);
|
||||
|
||||
const handleSubmit = async (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
setError(null);
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
const res = await fetch("/api/auth/signup", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ email, password }),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
try {
|
||||
const data = await res.json();
|
||||
setError(data?.message ?? "회원가입에 실패했습니다. 다시 시도해 주세요.");
|
||||
} catch {
|
||||
setError("회원가입에 실패했습니다. 다시 시도해 주세요.");
|
||||
}
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
router.push("/projects");
|
||||
} catch {
|
||||
setError("네트워크 오류가 발생했습니다. 잠시 후 다시 시도해 주세요.");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<main className="min-h-screen flex items-center justify-center bg-slate-950 text-slate-50">
|
||||
<div className="w-full max-w-sm rounded-lg border border-slate-800 bg-slate-900/70 p-6 shadow-xl">
|
||||
<h1 className="text-lg font-semibold mb-1">회원가입</h1>
|
||||
<p className="text-xs text-slate-400 mb-4">새 계정을 생성한 뒤 프로젝트를 저장하고 관리할 수 있습니다.</p>
|
||||
|
||||
{error && <p className="mb-3 text-xs text-red-300">{error}</p>}
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-3 text-xs">
|
||||
<div className="space-y-1">
|
||||
<label htmlFor="email" className="block text-slate-200">
|
||||
이메일
|
||||
</label>
|
||||
<input
|
||||
id="email"
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
className="w-full rounded border border-slate-700 bg-slate-950 px-2 py-1 text-xs text-slate-50 focus:outline-none focus:ring-1 focus:ring-sky-500"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<label htmlFor="password" className="block text-slate-200">
|
||||
비밀번호
|
||||
</label>
|
||||
<input
|
||||
id="password"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
className="w-full rounded border border-slate-700 bg-slate-950 px-2 py-1 text-xs text-slate-50 focus:outline-none focus:ring-1 focus:ring-sky-500"
|
||||
required
|
||||
minLength={8}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="mt-2 w-full inline-flex items-center justify-center gap-1 rounded bg-sky-600 hover:bg-sky-500 disabled:opacity-40 disabled:cursor-not-allowed px-3 py-1 text-xs font-medium"
|
||||
>
|
||||
{loading ? "회원가입 중..." : "회원가입"}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<p className="mt-4 text-[11px] text-slate-400">
|
||||
이미 계정이 있다면
|
||||
{" "}
|
||||
<Link href="/login" className="text-sky-300 hover:text-sky-200 underline-offset-2 hover:underline">
|
||||
로그인
|
||||
</Link>
|
||||
으로 이동해 주세요.
|
||||
</p>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
import bcrypt from "bcryptjs";
|
||||
import jwt, { JwtPayload } from "jsonwebtoken";
|
||||
import { randomBytes, createCipheriv, createDecipheriv, createHash } from "crypto";
|
||||
|
||||
// 인증/보안 관련 공통 유틸리티 함수들을 제공한다.
|
||||
// - 비밀번호 해시/검증 (bcrypt 기반)
|
||||
// - JWT 액세스 토큰 발급/검증
|
||||
// - 민감정보 JSON 암호화/복호화 (AES-256-GCM)
|
||||
|
||||
export interface AccessTokenPayload extends JwtPayload {
|
||||
sub: string;
|
||||
email: string;
|
||||
tokenVersion: number;
|
||||
}
|
||||
|
||||
export interface JwtUserLike {
|
||||
id: string;
|
||||
email: string;
|
||||
tokenVersion: number;
|
||||
}
|
||||
|
||||
// 내부에서 사용할 JWT 시크릿을 가져온다.
|
||||
// - 설정되지 않은 경우에는 명확한 에러를 던져 조기에 문제를 발견한다.
|
||||
function getJwtSecret(): string {
|
||||
const secret = process.env.AUTH_JWT_SECRET;
|
||||
if (!secret || secret.length === 0) {
|
||||
throw new Error("AUTH_JWT_SECRET 환경변수가 설정되지 않았습니다.");
|
||||
}
|
||||
return secret;
|
||||
}
|
||||
|
||||
// 암호화 키 원본 문자열로부터 32바이트 AES 키를 파생한다.
|
||||
// - 키 문자열 길이에 관계없이 SHA-256 해시를 사용해 32바이트를 만든다.
|
||||
function deriveEncryptionKey(): Buffer {
|
||||
const raw = process.env.AUTH_ENCRYPTION_KEY;
|
||||
if (!raw || raw.length === 0) {
|
||||
throw new Error("AUTH_ENCRYPTION_KEY 환경변수가 설정되지 않았습니다.");
|
||||
}
|
||||
return createHash("sha256").update(raw).digest();
|
||||
}
|
||||
|
||||
// 최소 비밀번호 길이 (보안 요구사항: 8자 이상)
|
||||
const MIN_PASSWORD_LENGTH = 8;
|
||||
|
||||
// 비밀번호를 bcrypt 해시로 변환한다.
|
||||
export async function hashPassword(plain: string): Promise<string> {
|
||||
if (typeof plain !== "string" || plain.length < MIN_PASSWORD_LENGTH) {
|
||||
throw new Error("비밀번호는 최소 8자 이상이어야 합니다.");
|
||||
}
|
||||
|
||||
// bcryptjs 기본 라운드 수(10)를 사용한다. 필요 시 환경에 맞게 조정 가능.
|
||||
const saltRounds = 10;
|
||||
return await bcrypt.hash(plain, saltRounds);
|
||||
}
|
||||
|
||||
// 평문 비밀번호와 저장된 해시가 일치하는지 검증한다.
|
||||
export async function verifyPassword(plain: string, hash: string): Promise<boolean> {
|
||||
if (!hash) return false;
|
||||
if (!plain || plain.length < MIN_PASSWORD_LENGTH) {
|
||||
// 너무 짧은 비밀번호는 즉시 false 를 반환해 타이밍 공격을 줄인다.
|
||||
return false;
|
||||
}
|
||||
|
||||
return await bcrypt.compare(plain, hash);
|
||||
}
|
||||
|
||||
// 유저 정보를 기반으로 JWT 액세스 토큰을 발급한다.
|
||||
export async function signAccessToken(user: JwtUserLike): Promise<string> {
|
||||
const secret = getJwtSecret();
|
||||
|
||||
const payload: AccessTokenPayload = {
|
||||
sub: user.id,
|
||||
email: user.email,
|
||||
tokenVersion: user.tokenVersion,
|
||||
};
|
||||
|
||||
// 만료 시간은 기본 15분으로 설정한다.
|
||||
const token = jwt.sign(payload, secret, {
|
||||
algorithm: "HS256",
|
||||
expiresIn: "15m",
|
||||
});
|
||||
|
||||
return token;
|
||||
}
|
||||
|
||||
// JWT 액세스 토큰을 검증하고, 유효하면 페이로드를 반환한다.
|
||||
// - 검증 실패(시그니처 오류, 만료 등) 시 null 을 반환한다.
|
||||
export async function verifyAccessToken(token: string): Promise<AccessTokenPayload | null> {
|
||||
const secret = getJwtSecret();
|
||||
|
||||
try {
|
||||
const decoded = jwt.verify(token, secret) as AccessTokenPayload;
|
||||
if (!decoded || typeof decoded.sub !== "string") {
|
||||
return null;
|
||||
}
|
||||
return decoded;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// JSON 객체를 AES-256-GCM 으로 암호화한다.
|
||||
// - 결과는 iv.tag.ciphertext 형태의 base64 조합 문자열이다.
|
||||
export async function encryptJson<T extends object>(value: T): Promise<string> {
|
||||
const key = deriveEncryptionKey();
|
||||
const iv = randomBytes(12); // GCM 권장 IV 길이 96bit
|
||||
|
||||
const plain = JSON.stringify(value);
|
||||
|
||||
const cipher = createCipheriv("aes-256-gcm", key, iv);
|
||||
let encrypted = cipher.update(plain, "utf8", "base64");
|
||||
encrypted += cipher.final("base64");
|
||||
const authTag = cipher.getAuthTag();
|
||||
|
||||
const ivB64 = iv.toString("base64");
|
||||
const tagB64 = authTag.toString("base64");
|
||||
|
||||
return `${ivB64}.${tagB64}.${encrypted}`;
|
||||
}
|
||||
|
||||
// AES-256-GCM 으로 암호화된 JSON 문자열을 복호화한다.
|
||||
export async function decryptJson<T = unknown>(ciphertext: string): Promise<T> {
|
||||
const key = deriveEncryptionKey();
|
||||
|
||||
try {
|
||||
const parts = ciphertext.split(".");
|
||||
if (parts.length !== 3) {
|
||||
throw new Error("잘못된 암호문 형식입니다.");
|
||||
}
|
||||
|
||||
const [ivB64, tagB64, dataB64] = parts;
|
||||
const iv = Buffer.from(ivB64, "base64");
|
||||
const authTag = Buffer.from(tagB64, "base64");
|
||||
|
||||
const decipher = createDecipheriv("aes-256-gcm", key, iv);
|
||||
decipher.setAuthTag(authTag);
|
||||
|
||||
let decrypted = decipher.update(dataB64, "base64", "utf8");
|
||||
decrypted += decipher.final("utf8");
|
||||
|
||||
return JSON.parse(decrypted) as T;
|
||||
} catch (error) {
|
||||
// 복호화 실패 시 명시적인 에러를 던져 상위에서 처리할 수 있게 한다.
|
||||
throw new Error("민감정보 복호화에 실패했습니다.");
|
||||
}
|
||||
}
|
||||
@@ -26,7 +26,7 @@ import {
|
||||
buildVideoEmbedUrl,
|
||||
computeVideoPublicTokens,
|
||||
} from "@/features/editor/utils/videoHelpers";
|
||||
import { computeButtonPublicTokens } from "@/features/editor/utils/buttonHelpers";
|
||||
import { computeButtonPublicTokens, computeButtonPbTokens } from "@/features/editor/utils/buttonHelpers";
|
||||
import { computeTextPublicTokens } from "@/features/editor/utils/textHelpers";
|
||||
import { computeDividerPublicTokens } from "@/features/editor/utils/dividerHelpers";
|
||||
import { computeImagePublicTokens } from "@/features/editor/utils/imageHelpers";
|
||||
@@ -50,8 +50,12 @@ export function getSectionLayoutConfig(props: SectionBlockProps) {
|
||||
? "bg-sky-900"
|
||||
: "bg-slate-950";
|
||||
|
||||
// 섹션 수직 패딩: builder.css 의 pb-section-py-sm/md/lg (1rem/1.5rem/2.5rem)에 대응
|
||||
// - sm → 1rem → py-4
|
||||
// - md → 1.5rem → py-6
|
||||
// - lg → 2.5rem → py-10
|
||||
const paddingYClass =
|
||||
props.paddingY === "sm" ? "py-8" : props.paddingY === "lg" ? "py-20" : "py-12";
|
||||
props.paddingY === "sm" ? "py-4" : props.paddingY === "lg" ? "py-10" : "py-6";
|
||||
|
||||
const maxWidthClass =
|
||||
props.maxWidthMode === "narrow"
|
||||
@@ -103,6 +107,10 @@ export function PublicPageRenderer({ blocks }: PublicPageRendererProps) {
|
||||
const [formMessage, setFormMessage] = useState<string>("");
|
||||
|
||||
const renderBlock = (block: Block) => {
|
||||
if ((block as any).type === "form") {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (block.type === "text") {
|
||||
const props = block.props as TextBlockProps;
|
||||
const tokens = computeTextPublicTokens(props);
|
||||
@@ -211,6 +219,7 @@ export function PublicPageRenderer({ blocks }: PublicPageRendererProps) {
|
||||
{props.options.map((opt) => (
|
||||
<label
|
||||
key={opt.value}
|
||||
data-testid="preview-form-checkbox-option-container"
|
||||
className="inline-flex items-center gap-1"
|
||||
style={tokens.optionContainerStyle}
|
||||
>
|
||||
@@ -297,16 +306,40 @@ export function PublicPageRenderer({ blocks }: PublicPageRendererProps) {
|
||||
|
||||
if (block.type === "button") {
|
||||
const props = block.props as ButtonBlockProps;
|
||||
const tokens = computeButtonPublicTokens(props);
|
||||
const buttonStyle: CSSProperties = { ...tokens.styleOverrides };
|
||||
|
||||
const pbTokens = computeButtonPbTokens({
|
||||
align: props.align ?? "left",
|
||||
size: props.size ?? "md",
|
||||
variant: props.variant ?? "solid",
|
||||
colorPalette: props.colorPalette ?? "primary",
|
||||
borderRadius: props.borderRadius ?? "md",
|
||||
fullWidth: props.fullWidth ?? false,
|
||||
widthMode: props.widthMode ?? undefined,
|
||||
widthPx: props.widthPx ?? undefined,
|
||||
paddingX: props.paddingX ?? undefined,
|
||||
paddingY: props.paddingY ?? undefined,
|
||||
fillColorCustom: props.fillColorCustom ?? undefined,
|
||||
strokeColorCustom: props.strokeColorCustom ?? undefined,
|
||||
textColorCustom: props.textColorCustom ?? undefined,
|
||||
});
|
||||
|
||||
const publicTokens = computeButtonPublicTokens(props);
|
||||
const buttonStyle: CSSProperties = { ...publicTokens.styleOverrides };
|
||||
|
||||
const wrapperClass = pbTokens.alignClass;
|
||||
const buttonClassName = [
|
||||
"pb-btn-base",
|
||||
pbTokens.sizeClass,
|
||||
pbTokens.variantClass,
|
||||
pbTokens.radiusClass,
|
||||
publicTokens.widthClass,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ");
|
||||
|
||||
return (
|
||||
<div key={block.id} className={tokens.alignClass}>
|
||||
<a
|
||||
href={props.href}
|
||||
className={`inline-flex items-center justify-center text-sm font-medium hover:bg-sky-500 transition-colors ${tokens.widthClass}`}
|
||||
style={buttonStyle}
|
||||
>
|
||||
<div key={block.id} className={wrapperClass}>
|
||||
<a href={props.href} className={buttonClassName} style={buttonStyle}>
|
||||
<span className="whitespace-pre-wrap">{props.label}</span>
|
||||
</a>
|
||||
</div>
|
||||
@@ -321,13 +354,13 @@ export function PublicPageRenderer({ blocks }: PublicPageRendererProps) {
|
||||
<div
|
||||
key={block.id}
|
||||
data-testid="preview-divider"
|
||||
className={`flex ${tokens.alignClass}`}
|
||||
className={`flex w-full ${tokens.alignClass}`}
|
||||
style={{ marginTop: tokens.wrapperMarginEm, marginBottom: tokens.wrapperMarginEm }}
|
||||
>
|
||||
<div style={tokens.innerStyle}>
|
||||
<div className={tokens.innerWidthClass} style={tokens.innerStyle}>
|
||||
<div
|
||||
data-testid="preview-divider-line"
|
||||
className={`${tokens.thicknessClass} ${tokens.innerWidthClass}`}
|
||||
className={tokens.thicknessClass}
|
||||
style={tokens.lineStyle}
|
||||
/>
|
||||
</div>
|
||||
@@ -362,26 +395,19 @@ export function PublicPageRenderer({ blocks }: PublicPageRendererProps) {
|
||||
|
||||
return (
|
||||
<ListTag
|
||||
className={tokens.alignClass}
|
||||
className={`pb-list ${tokens.alignClass}`}
|
||||
style={{
|
||||
...tokens.listStyle,
|
||||
listStyleType:
|
||||
tokens.bulletStyle === "none"
|
||||
? "none"
|
||||
: tokens.bulletStyle,
|
||||
paddingLeft: 12 + level * 8,
|
||||
}}
|
||||
>
|
||||
{nodes.map((node, index) => (
|
||||
<li
|
||||
key={node.id}
|
||||
style={
|
||||
index < nodes.length - 1
|
||||
? {
|
||||
marginBottom: `${tokens.gapEm}em`,
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
style={index < nodes.length - 1 ? { marginBottom: `${tokens.gapEm}em` } : undefined}
|
||||
>
|
||||
{node.text}
|
||||
{node.children && node.children.length > 0 && renderListNodes(node.children, level + 1)}
|
||||
@@ -398,6 +424,10 @@ export function PublicPageRenderer({ blocks }: PublicPageRendererProps) {
|
||||
const props = block.props as VideoBlockProps;
|
||||
const rawUrl = normalizeVideoSourceUrl(props.sourceUrl ?? "");
|
||||
|
||||
if (!rawUrl) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const platform = resolveVideoPlatform(rawUrl, props.platform ?? "auto");
|
||||
const embedUrl = buildVideoEmbedUrl(rawUrl, platform, { enableVimeoEmbed: true });
|
||||
|
||||
|
||||
@@ -643,7 +643,7 @@ export interface FormFieldConfig {
|
||||
}
|
||||
|
||||
// 폼 블록 속성
|
||||
export type FormSubmitTarget = "internal" | "webhook";
|
||||
export type FormSubmitTarget = "internal" | "webhook" | "both";
|
||||
export type FormPayloadFormat = "form" | "json";
|
||||
|
||||
export interface FormBlockProps {
|
||||
|
||||
@@ -64,17 +64,19 @@ export function computeButtonPbTokens(input: ButtonStyleInput): ButtonPbTokens {
|
||||
|
||||
const widthMode = input.widthMode ?? (input.fullWidth ? "full" : "auto");
|
||||
if (widthMode === "fixed" && typeof input.widthPx === "number" && input.widthPx > 0) {
|
||||
inlineStyles.push(`width:${input.widthPx}px`);
|
||||
inlineStyles.push(`width:${pxToEm(input.widthPx)}`);
|
||||
}
|
||||
|
||||
if (typeof input.paddingX === "number") {
|
||||
inlineStyles.push(`padding-left:${input.paddingX}px`);
|
||||
inlineStyles.push(`padding-right:${input.paddingX}px`);
|
||||
const paddingInline = pxToEm(input.paddingX);
|
||||
inlineStyles.push(`padding-left:${paddingInline}`);
|
||||
inlineStyles.push(`padding-right:${paddingInline}`);
|
||||
}
|
||||
|
||||
if (typeof input.paddingY === "number") {
|
||||
inlineStyles.push(`padding-top:${input.paddingY}px`);
|
||||
inlineStyles.push(`padding-bottom:${input.paddingY}px`);
|
||||
const paddingBlock = pxToEm(input.paddingY);
|
||||
inlineStyles.push(`padding-top:${paddingBlock}`);
|
||||
inlineStyles.push(`padding-bottom:${paddingBlock}`);
|
||||
}
|
||||
|
||||
if (input.fillColorCustom && input.fillColorCustom.trim() !== "") {
|
||||
|
||||
@@ -19,8 +19,9 @@ export const computeDividerExportTokens = (
|
||||
const colorRaw = typeof props.colorHex === "string" ? props.colorHex.trim() : "";
|
||||
const color = colorRaw !== "" ? colorRaw : "#475569";
|
||||
const marginY = typeof props.marginYPx === "number" && props.marginYPx > 0 ? props.marginYPx : 16;
|
||||
const marginEm = pxToEm(marginY);
|
||||
|
||||
const style = `border:0;border-bottom:${thickness} solid ${escapeAttr(color)};margin:${marginY}px 0;`;
|
||||
const style = `border:0;border-bottom:${thickness} solid ${escapeAttr(color)};margin:${marginEm} 0;`;
|
||||
|
||||
return { style };
|
||||
};
|
||||
|
||||
@@ -84,20 +84,21 @@ export const computeFormInputExportTokens = (
|
||||
}
|
||||
|
||||
if (typeof props.paddingX === "number" && props.paddingX >= 0) {
|
||||
const px = props.paddingX;
|
||||
inputStyleParts.push(`padding-left:${px}px`);
|
||||
inputStyleParts.push(`padding-right:${px}px`);
|
||||
const em = props.paddingX / 16;
|
||||
inputStyleParts.push(`padding-left:${em}em`);
|
||||
inputStyleParts.push(`padding-right:${em}em`);
|
||||
}
|
||||
|
||||
if (typeof props.paddingY === "number" && props.paddingY >= 0) {
|
||||
const py = props.paddingY;
|
||||
inputStyleParts.push(`padding-top:${py}px`);
|
||||
inputStyleParts.push(`padding-bottom:${py}px`);
|
||||
const em = props.paddingY / 16;
|
||||
inputStyleParts.push(`padding-top:${em}em`);
|
||||
inputStyleParts.push(`padding-bottom:${em}em`);
|
||||
}
|
||||
|
||||
const widthMode = computeWidthMode({ widthMode: props.widthMode, fullWidth: props.fullWidth });
|
||||
if (widthMode === "fixed" && typeof props.widthPx === "number" && props.widthPx > 0) {
|
||||
inputStyleParts.push(`width:${props.widthPx}px`);
|
||||
const em = props.widthPx / 16;
|
||||
inputStyleParts.push(`width:${em}em`);
|
||||
}
|
||||
|
||||
const radiusPx = computeRadiusPx(props.borderRadius);
|
||||
@@ -127,19 +128,20 @@ export const computeFormSelectExportTokens = (
|
||||
|
||||
const widthMode = computeWidthMode({ widthMode: props.widthMode, fullWidth: props.fullWidth });
|
||||
if (widthMode === "fixed" && typeof props.widthPx === "number" && props.widthPx > 0) {
|
||||
selectStyleParts.push(`width:${props.widthPx}px`);
|
||||
const em = props.widthPx / 16;
|
||||
selectStyleParts.push(`width:${em}em`);
|
||||
}
|
||||
|
||||
if (typeof props.paddingX === "number" && props.paddingX >= 0) {
|
||||
const px = props.paddingX;
|
||||
selectStyleParts.push(`padding-left:${px}px`);
|
||||
selectStyleParts.push(`padding-right:${px}px`);
|
||||
const em = props.paddingX / 16;
|
||||
selectStyleParts.push(`padding-left:${em}em`);
|
||||
selectStyleParts.push(`padding-right:${em}em`);
|
||||
}
|
||||
|
||||
if (typeof props.paddingY === "number" && props.paddingY >= 0) {
|
||||
const py = props.paddingY;
|
||||
selectStyleParts.push(`padding-top:${py}px`);
|
||||
selectStyleParts.push(`padding-bottom:${py}px`);
|
||||
const em = props.paddingY / 16;
|
||||
selectStyleParts.push(`padding-top:${em}em`);
|
||||
selectStyleParts.push(`padding-bottom:${em}em`);
|
||||
}
|
||||
|
||||
if (typeof props.fillColorCustom === "string" && props.fillColorCustom.trim() !== "") {
|
||||
@@ -176,15 +178,15 @@ const computeOptionGroupExportTokensBase = (
|
||||
}
|
||||
|
||||
if (typeof props.paddingX === "number" && props.paddingX >= 0) {
|
||||
const px = props.paddingX;
|
||||
optionStyleParts.push(`padding-left:${px}px`);
|
||||
optionStyleParts.push(`padding-right:${px}px`);
|
||||
const em = props.paddingX / 16;
|
||||
optionStyleParts.push(`padding-left:${em}em`);
|
||||
optionStyleParts.push(`padding-right:${em}em`);
|
||||
}
|
||||
|
||||
if (typeof props.paddingY === "number" && props.paddingY >= 0) {
|
||||
const py = props.paddingY;
|
||||
optionStyleParts.push(`padding-top:${py}px`);
|
||||
optionStyleParts.push(`padding-bottom:${py}px`);
|
||||
const em = props.paddingY / 16;
|
||||
optionStyleParts.push(`padding-top:${em}em`);
|
||||
optionStyleParts.push(`padding-bottom:${em}em`);
|
||||
}
|
||||
|
||||
if (typeof props.fillColorCustom === "string" && props.fillColorCustom.trim() !== "") {
|
||||
@@ -261,6 +263,27 @@ export const computeFormInputEditorTokens = (props: FormInputBlockProps): FormIn
|
||||
fieldStyle.borderRadius = 9999;
|
||||
}
|
||||
|
||||
// 에디터에서는 px 기반 padding/타이포 값을 그대로 fieldStyle 에 반영해 미리보기에서 변화가 보이도록 한다.
|
||||
if (typeof props.paddingX === "number" && props.paddingX >= 0) {
|
||||
fieldStyle.paddingInline = `${props.paddingX}px`;
|
||||
}
|
||||
|
||||
if (typeof props.paddingY === "number" && props.paddingY >= 0) {
|
||||
fieldStyle.paddingBlock = `${props.paddingY}px`;
|
||||
}
|
||||
|
||||
if (props.fontSizeCustom && props.fontSizeCustom.trim() !== "") {
|
||||
fieldStyle.fontSize = props.fontSizeCustom.trim();
|
||||
}
|
||||
|
||||
if (props.lineHeightCustom && props.lineHeightCustom.trim() !== "") {
|
||||
fieldStyle.lineHeight = props.lineHeightCustom.trim();
|
||||
}
|
||||
|
||||
if (props.letterSpacingCustom && props.letterSpacingCustom.trim() !== "") {
|
||||
fieldStyle.letterSpacing = props.letterSpacingCustom.trim();
|
||||
}
|
||||
|
||||
const labelLayout = props.labelLayout ?? "stacked";
|
||||
const isInline = labelLayout === "inline";
|
||||
|
||||
@@ -322,6 +345,27 @@ export const computeFormSelectEditorTokens = (props: FormSelectBlockProps): Form
|
||||
fieldStyle.borderRadius = 9999;
|
||||
}
|
||||
|
||||
// 체크박스/라디오 그룹도 에디터에서 padding/타이포 스타일을 일부 반영해준다.
|
||||
if (typeof props.paddingX === "number" && props.paddingX >= 0) {
|
||||
fieldStyle.paddingInline = `${props.paddingX}px`;
|
||||
}
|
||||
|
||||
if (typeof props.paddingY === "number" && props.paddingY >= 0) {
|
||||
fieldStyle.paddingBlock = `${props.paddingY}px`;
|
||||
}
|
||||
|
||||
if (props.fontSizeCustom && props.fontSizeCustom.trim() !== "") {
|
||||
fieldStyle.fontSize = props.fontSizeCustom.trim();
|
||||
}
|
||||
|
||||
if (props.lineHeightCustom && props.lineHeightCustom.trim() !== "") {
|
||||
fieldStyle.lineHeight = props.lineHeightCustom.trim();
|
||||
}
|
||||
|
||||
if (props.letterSpacingCustom && props.letterSpacingCustom.trim() !== "") {
|
||||
fieldStyle.letterSpacing = props.letterSpacingCustom.trim();
|
||||
}
|
||||
|
||||
return { fieldStyle };
|
||||
};
|
||||
|
||||
@@ -353,6 +397,26 @@ export const computeFormOptionGroupEditorTokens = (
|
||||
} else if (radius === "lg" || radius === "full") {
|
||||
fieldStyle.borderRadius = 9999;
|
||||
}
|
||||
|
||||
if (typeof props.paddingX === "number" && props.paddingX >= 0) {
|
||||
fieldStyle.paddingInline = `${props.paddingX}px`;
|
||||
}
|
||||
|
||||
if (typeof props.paddingY === "number" && props.paddingY >= 0) {
|
||||
fieldStyle.paddingBlock = `${props.paddingY}px`;
|
||||
}
|
||||
|
||||
if (props.fontSizeCustom && props.fontSizeCustom.trim() !== "") {
|
||||
fieldStyle.fontSize = props.fontSizeCustom.trim();
|
||||
}
|
||||
|
||||
if (props.lineHeightCustom && props.lineHeightCustom.trim() !== "") {
|
||||
fieldStyle.lineHeight = props.lineHeightCustom.trim();
|
||||
}
|
||||
|
||||
if (props.letterSpacingCustom && props.letterSpacingCustom.trim() !== "") {
|
||||
fieldStyle.letterSpacing = props.letterSpacingCustom.trim();
|
||||
}
|
||||
|
||||
return { fieldStyle };
|
||||
};
|
||||
|
||||
@@ -27,7 +27,8 @@ export function computeImageExportStyles(input: ImageExportStyleInput): ImageExp
|
||||
|
||||
const widthMode: ImageWidthMode = input.widthMode ?? "auto";
|
||||
if (widthMode === "fixed" && typeof input.widthPx === "number" && input.widthPx > 0) {
|
||||
imgStyleParts.push(`width:${input.widthPx}px`);
|
||||
const widthEm = input.widthPx / 16;
|
||||
imgStyleParts.push(`width:${widthEm}em`);
|
||||
}
|
||||
|
||||
const radiusToken: ImageBorderRadiusToken = input.borderRadius ?? "md";
|
||||
|
||||
@@ -134,7 +134,15 @@ export const computeListPublicTokens = (props: ListBlockProps): ListPublicTokens
|
||||
}
|
||||
}
|
||||
if (props.lineHeightCustom && props.lineHeightCustom.trim() !== "") {
|
||||
listStyle.lineHeight = props.lineHeightCustom;
|
||||
const lineHeightValue = props.lineHeightCustom.trim();
|
||||
if (lineHeightValue.endsWith("px")) {
|
||||
const lineHeightEm = convertPxStringToEm(lineHeightValue);
|
||||
if (lineHeightEm) {
|
||||
listStyle.lineHeight = lineHeightEm;
|
||||
}
|
||||
} else {
|
||||
listStyle.lineHeight = lineHeightValue;
|
||||
}
|
||||
}
|
||||
if (props.textColorCustom && props.textColorCustom.trim() !== "") {
|
||||
listStyle.color = props.textColorCustom;
|
||||
|
||||
@@ -276,6 +276,17 @@ export interface TextPublicTokens {
|
||||
styleOverrides: CSSProperties;
|
||||
}
|
||||
|
||||
const pxToEm = (px: number, base = 16) => `${px / base}em`;
|
||||
|
||||
const convertPxStringToEm = (value?: string | null) => {
|
||||
if (!value) return null;
|
||||
const match = value.trim().match(/-?\d+(?:\.\d+)?/);
|
||||
if (!match) return null;
|
||||
const px = parseFloat(match[0]);
|
||||
if (!Number.isFinite(px)) return null;
|
||||
return pxToEm(px);
|
||||
};
|
||||
|
||||
export function computeTextPublicTokens(props: TextBlockProps): TextPublicTokens {
|
||||
const alignClass =
|
||||
props.align === "center"
|
||||
@@ -308,7 +319,15 @@ export function computeTextPublicTokens(props: TextBlockProps): TextPublicTokens
|
||||
props.fontSizeCustom &&
|
||||
props.fontSizeCustom.trim() !== ""
|
||||
) {
|
||||
styleOverrides.fontSize = props.fontSizeCustom;
|
||||
const fontSizeValue = props.fontSizeCustom.trim();
|
||||
if (fontSizeValue.endsWith("px")) {
|
||||
const fontSizeEm = convertPxStringToEm(fontSizeValue);
|
||||
if (fontSizeEm) {
|
||||
styleOverrides.fontSize = fontSizeEm;
|
||||
}
|
||||
} else {
|
||||
styleOverrides.fontSize = fontSizeValue;
|
||||
}
|
||||
}
|
||||
|
||||
if (props.colorCustom && props.colorCustom.trim() !== "") {
|
||||
@@ -319,6 +338,30 @@ export function computeTextPublicTokens(props: TextBlockProps): TextPublicTokens
|
||||
styleOverrides.backgroundColor = props.backgroundColorCustom.trim();
|
||||
}
|
||||
|
||||
if (props.lineHeightCustom && props.lineHeightCustom.trim() !== "") {
|
||||
const lineHeightValue = props.lineHeightCustom.trim();
|
||||
if (lineHeightValue.endsWith("px")) {
|
||||
const lineHeightEm = convertPxStringToEm(lineHeightValue);
|
||||
if (lineHeightEm) {
|
||||
styleOverrides.lineHeight = lineHeightEm;
|
||||
}
|
||||
} else {
|
||||
styleOverrides.lineHeight = lineHeightValue;
|
||||
}
|
||||
}
|
||||
|
||||
if (props.letterSpacingCustom && props.letterSpacingCustom.trim() !== "") {
|
||||
const letterSpacingValue = props.letterSpacingCustom.trim();
|
||||
if (letterSpacingValue.endsWith("px")) {
|
||||
const letterSpacingEm = convertPxStringToEm(letterSpacingValue);
|
||||
if (letterSpacingEm) {
|
||||
styleOverrides.letterSpacing = letterSpacingEm;
|
||||
}
|
||||
} else {
|
||||
styleOverrides.letterSpacing = letterSpacingValue;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
alignClass,
|
||||
sizeClass,
|
||||
|
||||
@@ -101,8 +101,9 @@ export function computeVideoExportTokens(
|
||||
|
||||
const widthMode = props.widthMode ?? "auto";
|
||||
if (widthMode === "fixed" && typeof props.widthPx === "number" && props.widthPx > 0) {
|
||||
wrapperStyleParts.push(`width:${props.widthPx}px`);
|
||||
videoStyleParts.push(`width:${props.widthPx}px`);
|
||||
const widthEm = pxToEm(props.widthPx);
|
||||
wrapperStyleParts.push(`width:${widthEm}`);
|
||||
videoStyleParts.push(`width:${widthEm}`);
|
||||
} else if (widthMode === "full") {
|
||||
wrapperStyleParts.push("width:100%");
|
||||
videoStyleParts.push("width:100%");
|
||||
@@ -113,11 +114,11 @@ export function computeVideoExportTokens(
|
||||
}
|
||||
|
||||
if (typeof props.cardPaddingPx === "number" && props.cardPaddingPx >= 0) {
|
||||
wrapperStyleParts.push(`padding:${props.cardPaddingPx}px`);
|
||||
wrapperStyleParts.push(`padding:${pxToEm(props.cardPaddingPx)}`);
|
||||
}
|
||||
|
||||
if (typeof props.borderRadiusPx === "number" && props.borderRadiusPx >= 0) {
|
||||
wrapperStyleParts.push(`border-radius:${props.borderRadiusPx}px`);
|
||||
wrapperStyleParts.push(`border-radius:${pxToEm(props.borderRadiusPx)}`);
|
||||
}
|
||||
|
||||
const align =
|
||||
|
||||
@@ -263,6 +263,7 @@ body {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
border-width: 1px;
|
||||
text-decoration: none;
|
||||
}
|
||||
.pb-btn-size-xs {
|
||||
padding: 0.125rem 0.5rem;
|
||||
@@ -408,6 +409,12 @@ body {
|
||||
max-width: 40rem;
|
||||
}
|
||||
|
||||
/* Form 컨트롤러용 최소 폼: 레이아웃에 영향이 없도록 margin/padding 을 0 으로 고정한다. */
|
||||
.pb-form-controller {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.pb-form-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
@@ -0,0 +1,266 @@
|
||||
import "dotenv/config";
|
||||
import { describe, it, expect, beforeEach, vi } from "vitest";
|
||||
|
||||
import { hashPassword, signAccessToken } from "@/features/auth/authCrypto";
|
||||
|
||||
const BASE_URL = "http://localhost";
|
||||
|
||||
// PrismaClient 를 실제 DB 대신 메모리 기반 user 저장소를 사용하는 목으로 대체한다.
|
||||
// 이렇게 하면 CI/로컬 어디에서도 DATABASE_URL 이나 실제 DB 없이 Auth API 테스트를 실행할 수 있다.
|
||||
const inMemoryUsers: any[] = [];
|
||||
|
||||
vi.mock("@prisma/client", () => {
|
||||
class PrismaClientMock {
|
||||
user = {
|
||||
findUnique: async ({ where: { email } }: any) => {
|
||||
return inMemoryUsers.find((u) => u.email === email) ?? null;
|
||||
},
|
||||
create: async ({ data }: any) => {
|
||||
const now = new Date();
|
||||
const user = {
|
||||
id: String(inMemoryUsers.length + 1),
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
...data,
|
||||
};
|
||||
inMemoryUsers.push(user);
|
||||
return user;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return { PrismaClient: PrismaClientMock };
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
// 각 테스트 전에 메모리 유저 저장소를 초기화한다.
|
||||
inMemoryUsers.length = 0;
|
||||
process.env.AUTH_JWT_SECRET = "test-jwt-secret-api";
|
||||
process.env.AUTH_ENCRYPTION_KEY = "0123456789abcdef0123456789abcdef";
|
||||
});
|
||||
|
||||
describe("/api/auth", () => {
|
||||
describe("POST /api/auth/signup", () => {
|
||||
it("새 이메일과 8자 이상 비밀번호로 회원가입하면 User 가 생성되고 JWT 쿠키가 설정되어야 한다", async () => {
|
||||
const { POST: signup } = await import("@/app/api/auth/signup/route");
|
||||
|
||||
const payload = { email: "user@example.com", password: "securePass1" };
|
||||
|
||||
const res = await signup(
|
||||
new Request(`${BASE_URL}/api/auth/signup`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
}),
|
||||
);
|
||||
|
||||
expect(res.status).toBe(201);
|
||||
|
||||
const json = (await res.json()) as any;
|
||||
expect(json.email).toBe(payload.email);
|
||||
expect(json.id).toBeDefined();
|
||||
// 응답에는 passwordHash 가 포함되면 안 된다.
|
||||
expect(json.passwordHash).toBeUndefined();
|
||||
|
||||
// 메모리 저장소에도 유저가 1명 생성되어야 한다.
|
||||
expect(inMemoryUsers.length).toBe(1);
|
||||
expect(inMemoryUsers[0].email).toBe(payload.email);
|
||||
expect(typeof inMemoryUsers[0].passwordHash).toBe("string");
|
||||
|
||||
// JWT 세션 쿠키가 설정되어야 한다.
|
||||
const setCookie = res.headers.get("set-cookie");
|
||||
expect(setCookie).toBeTruthy();
|
||||
expect(setCookie).toContain("pb_access=");
|
||||
});
|
||||
|
||||
it("비밀번호가 8자 미만이면 400 과 에러 메시지를 반환해야 한다", async () => {
|
||||
const { POST: signup } = await import("@/app/api/auth/signup/route");
|
||||
|
||||
const res = await signup(
|
||||
new Request(`${BASE_URL}/api/auth/signup`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ email: "short@example.com", password: "short" }),
|
||||
}),
|
||||
);
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
const json = (await res.json()) as any;
|
||||
expect(json.message).toBeDefined();
|
||||
});
|
||||
|
||||
it("이미 존재하는 이메일로 회원가입하면 409 를 반환해야 한다", async () => {
|
||||
const { POST: signup } = await import("@/app/api/auth/signup/route");
|
||||
|
||||
// 먼저 한 번 성공적으로 가입
|
||||
await signup(
|
||||
new Request(`${BASE_URL}/api/auth/signup`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ email: "dup@example.com", password: "securePass1" }),
|
||||
}),
|
||||
);
|
||||
|
||||
// 같은 이메일로 다시 요청
|
||||
const res = await signup(
|
||||
new Request(`${BASE_URL}/api/auth/signup`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ email: "dup@example.com", password: "anotherPass1" }),
|
||||
}),
|
||||
);
|
||||
|
||||
expect(res.status).toBe(409);
|
||||
const json = (await res.json()) as any;
|
||||
expect(json.message).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /api/auth/login", () => {
|
||||
it("올바른 이메일/비밀번호로 로그인하면 200 과 JWT 쿠키를 반환해야 한다", async () => {
|
||||
const { POST: signup } = await import("@/app/api/auth/signup/route");
|
||||
const { POST: login } = await import("@/app/api/auth/login/route");
|
||||
|
||||
const email = "login@example.com";
|
||||
const password = "securePass1";
|
||||
|
||||
// 사전 회원가입
|
||||
await signup(
|
||||
new Request(`${BASE_URL}/api/auth/signup`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ email, password }),
|
||||
}),
|
||||
);
|
||||
|
||||
const res = await login(
|
||||
new Request(`${BASE_URL}/api/auth/login`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ email, password }),
|
||||
}),
|
||||
);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
const json = (await res.json()) as any;
|
||||
expect(json.email).toBe(email);
|
||||
expect(json.id).toBeDefined();
|
||||
expect(json.passwordHash).toBeUndefined();
|
||||
|
||||
const setCookie = res.headers.get("set-cookie");
|
||||
expect(setCookie).toBeTruthy();
|
||||
expect(setCookie).toContain("pb_access=");
|
||||
});
|
||||
|
||||
it("잘못된 이메일 또는 비밀번호로 로그인하면 401 을 반환해야 한다", async () => {
|
||||
const { POST: signup } = await import("@/app/api/auth/signup/route");
|
||||
const { POST: login } = await import("@/app/api/auth/login/route");
|
||||
|
||||
const email = "wrong@example.com";
|
||||
const password = "securePass1";
|
||||
|
||||
// 사전 회원가입
|
||||
await signup(
|
||||
new Request(`${BASE_URL}/api/auth/signup`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ email, password }),
|
||||
}),
|
||||
);
|
||||
|
||||
// 존재하지 않는 이메일
|
||||
const res1 = await login(
|
||||
new Request(`${BASE_URL}/api/auth/login`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ email: "nope@example.com", password }),
|
||||
}),
|
||||
);
|
||||
expect(res1.status).toBe(401);
|
||||
|
||||
// 비밀번호 불일치
|
||||
const res2 = await login(
|
||||
new Request(`${BASE_URL}/api/auth/login`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ email, password: "wrongPass1" }),
|
||||
}),
|
||||
);
|
||||
expect(res2.status).toBe(401);
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /api/auth/logout", () => {
|
||||
it("로그아웃 시 pb_access 쿠키를 제거하는 Set-Cookie 헤더를 반환해야 한다", async () => {
|
||||
const { POST: logout } = await import("@/app/api/auth/logout/route");
|
||||
|
||||
const res = await logout(
|
||||
new Request(`${BASE_URL}/api/auth/logout`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
cookie: "pb_access=dummy.token.value",
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
const setCookie = res.headers.get("set-cookie");
|
||||
expect(setCookie).toBeTruthy();
|
||||
expect(setCookie).toContain("pb_access=");
|
||||
expect(setCookie?.toLowerCase()).toContain("max-age=0");
|
||||
});
|
||||
|
||||
it("쿠키가 없어도 200 과 쿠키 제거 헤더를 반환해야 한다", async () => {
|
||||
const { POST: logout } = await import("@/app/api/auth/logout/route");
|
||||
|
||||
const res = await logout(
|
||||
new Request(`${BASE_URL}/api/auth/logout`, {
|
||||
method: "POST",
|
||||
}),
|
||||
);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
const setCookie = res.headers.get("set-cookie");
|
||||
expect(setCookie).toBeTruthy();
|
||||
expect(setCookie).toContain("pb_access=");
|
||||
expect(setCookie?.toLowerCase()).toContain("max-age=0");
|
||||
});
|
||||
});
|
||||
|
||||
describe("GET /api/auth/me", () => {
|
||||
it("유효한 JWT 쿠키가 있을 때 현재 로그인 유저 정보를 반환해야 한다", async () => {
|
||||
const { GET: me } = await import("@/app/api/auth/me/route");
|
||||
|
||||
const user = { id: "user-1", email: "me@example.com", tokenVersion: 1 };
|
||||
const token = await signAccessToken(user);
|
||||
|
||||
const res = await me(
|
||||
new Request(`${BASE_URL}/api/auth/me`, {
|
||||
headers: {
|
||||
cookie: `pb_access=${token}`,
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
const json = (await res.json()) as any;
|
||||
expect(json.id).toBe(user.id);
|
||||
expect(json.email).toBe(user.email);
|
||||
expect(json.passwordHash).toBeUndefined();
|
||||
});
|
||||
|
||||
it("JWT 쿠키가 없거나 잘못된 경우 401 을 반환해야 한다", async () => {
|
||||
const { GET: me } = await import("@/app/api/auth/me/route");
|
||||
|
||||
const res1 = await me(new Request(`${BASE_URL}/api/auth/me`));
|
||||
expect(res1.status).toBe(401);
|
||||
|
||||
const res2 = await me(
|
||||
new Request(`${BASE_URL}/api/auth/me`, {
|
||||
headers: { cookie: "pb_access=invalid.token.here" },
|
||||
}),
|
||||
);
|
||||
expect(res2.status).toBe(401);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,89 @@
|
||||
import "dotenv/config";
|
||||
import { describe, it, expect } from "vitest";
|
||||
import type { Block, ProjectConfig } from "@/features/editor/state/editorStore";
|
||||
import { buildStaticHtml } from "@/app/api/export/route";
|
||||
|
||||
const BASE_URL = "http://localhost";
|
||||
|
||||
// /api/export/preview TDD:
|
||||
// - blocks + projectConfig 를 받아 HTML 문자열을 바로 반환하는 경량 엔드포인트.
|
||||
// - 기존 buildStaticHtml 과 동일한 HTML 을 반환해야 한다.
|
||||
|
||||
describe("/api/export/preview", () => {
|
||||
it("POST /api/export/preview 는 blocks + projectConfig 로부터 HTML 문자열을 반환해야 한다", async () => {
|
||||
const blocks: Block[] = [
|
||||
{
|
||||
id: "blk_preview_text",
|
||||
type: "text",
|
||||
props: {
|
||||
text: "Export 미리보기 테스트",
|
||||
align: "center",
|
||||
size: "lg",
|
||||
},
|
||||
} as any,
|
||||
];
|
||||
|
||||
const projectConfig: ProjectConfig = {
|
||||
title: "Export 미리보기 페이지",
|
||||
slug: "export-preview",
|
||||
canvasPreset: "full",
|
||||
};
|
||||
|
||||
const payload = { blocks, projectConfig };
|
||||
|
||||
const { POST: handlePreview } = await import("@/app/api/export/preview/route");
|
||||
|
||||
const res = await handlePreview(
|
||||
new Request(`${BASE_URL}/api/export/preview`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
}),
|
||||
);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.headers.get("Content-Type")).toBe("text/html; charset=utf-8");
|
||||
|
||||
const html = await res.text();
|
||||
|
||||
expect(html).toContain("<title>Export 미리보기 페이지</title>");
|
||||
expect(html).toContain("Export 미리보기 테스트");
|
||||
});
|
||||
|
||||
it("/api/export/preview 의 HTML 은 동일 입력에 대해 buildStaticHtml 결과와 동일해야 한다", async () => {
|
||||
const blocks: Block[] = [
|
||||
{
|
||||
id: "blk_preview_compare",
|
||||
type: "text",
|
||||
props: {
|
||||
text: "Preview vs Export 동일성 테스트",
|
||||
align: "left",
|
||||
size: "base",
|
||||
},
|
||||
} as any,
|
||||
];
|
||||
|
||||
const projectConfig: ProjectConfig = {
|
||||
title: "동일성 테스트 페이지",
|
||||
slug: "preview-equality-test",
|
||||
canvasPreset: "full",
|
||||
};
|
||||
|
||||
const payload = { blocks, projectConfig };
|
||||
|
||||
const { POST: handlePreview } = await import("@/app/api/export/preview/route");
|
||||
|
||||
const res = await handlePreview(
|
||||
new Request(`${BASE_URL}/api/export/preview`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
}),
|
||||
);
|
||||
|
||||
const previewHtml = await res.text();
|
||||
const staticHtml = buildStaticHtml(blocks, projectConfig);
|
||||
|
||||
expect(previewHtml).toBe(staticHtml);
|
||||
});
|
||||
});
|
||||
+166
-38
@@ -7,6 +7,7 @@ import type { Block, ProjectConfig } from "@/features/editor/state/editorStore";
|
||||
import { createHeroTemplateBlocks } from "@/app/editor/templates/heroTemplate";
|
||||
import { createFeaturesTemplateBlocks } from "@/app/editor/templates/featuresTemplate";
|
||||
import { createCtaTemplateBlocks } from "@/app/editor/templates/ctaTemplate";
|
||||
import { createFooterTemplateBlocks } from "@/app/editor/templates/footerTemplate";
|
||||
import { buildStaticHtml } from "@/app/api/export/route";
|
||||
|
||||
const BASE_URL = "http://localhost";
|
||||
@@ -64,6 +65,16 @@ describe("/api/export", () => {
|
||||
expect(cssEntry).toBeTruthy();
|
||||
expect(jsEntry).toBeTruthy();
|
||||
|
||||
const mainJs = await jsEntry!.async("string");
|
||||
expect(mainJs).toContain("pbInitFormControllers");
|
||||
expect(mainJs).toContain('querySelectorAll("form.pb-form-controller")');
|
||||
expect(mainJs).toContain("fetch(");
|
||||
|
||||
const builderCss = await cssEntry!.async("string");
|
||||
// 버튼 베이스 클래스는 정적 Export 에서도 밑줄이 보이지 않도록 text-decoration:none 을 포함해야 한다.
|
||||
expect(builderCss).toContain(".pb-btn-base");
|
||||
expect(builderCss).toContain("text-decoration: none");
|
||||
|
||||
const html = await indexEntry!.async("string");
|
||||
expect(html).toContain("<title>내보내기 테스트 페이지</title>");
|
||||
expect(html).toContain("ZIP 내보내기 테스트");
|
||||
@@ -403,7 +414,7 @@ describe("/api/export", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("폼 블록과 컨트롤러 필드 블록들은 정적 HTML에서도 폼 요소로 렌더되어야 한다", async () => {
|
||||
it("FormBlock 은 Export 레이어에서 컨트롤러 역할만 하고, 필드 블록들은 form 속성으로 해당 폼과 연결되어야 한다", async () => {
|
||||
const blocks: Block[] = [
|
||||
{
|
||||
id: "form_1",
|
||||
@@ -469,18 +480,31 @@ describe("/api/export", () => {
|
||||
|
||||
const html = await indexEntry!.async("string");
|
||||
|
||||
// form 요소와 기본 input/select 가 포함되어야 한다.
|
||||
expect(html).toContain("<form");
|
||||
expect(html).toContain("name=\"name\"");
|
||||
expect(html).toContain("name=\"plan\"");
|
||||
expect(html).toContain("<select");
|
||||
// required: formInput(name) 은 required, formSelect(plan) 은 required 가 없어야 한다.
|
||||
expect(html).toMatch(/name=\"name\"[^>]*required/);
|
||||
expect(html).not.toMatch(/name=\"plan\"[^>]*required/);
|
||||
// FormBlock 은 컨트롤러 전용이므로, Export 에서는 id 를 가진 단일 <form> 컨테이너만 생성하고
|
||||
// 실제 필드 레이아웃은 개별 formInput/formSelect 블록이 담당한다.
|
||||
// - <form id="form_form_1" ...>
|
||||
const formId = "form_form_1";
|
||||
expect(html).toMatch(new RegExp(`<form[^>]*id=\\"${formId}\\"`));
|
||||
expect(html).toMatch(new RegExp(`<form[^>]*id=\\"${formId}\\"[^>]*action=\\"/api/forms/submit\\"`));
|
||||
|
||||
// 컨트롤러 필드 블록(formInput/formSelect) 이 렌더한 input/select 는 각각 name 과 form 속성을 가져야 한다.
|
||||
// name="name" input 은 required + form="form_form_1" 이어야 한다.
|
||||
expect(html).toMatch(/name=\"name\"[^>]*required[^>]*form=\"form_form_1\"/);
|
||||
// name="plan" select 는 required 는 아니지만 form="form_form_1" 이어야 한다.
|
||||
expect(html).toMatch(/name=\"plan\"[^>]*form=\"form_form_1\"/);
|
||||
|
||||
// FormBlock 이 자체적으로 필드 레이아웃을 중복 생성하지 않도록,
|
||||
// id 가 form_form_1 인 <form> 내부에는 name="name"/"plan" 필드가 없어야 한다.
|
||||
const formStart = html.indexOf("<form");
|
||||
const formEnd = html.indexOf("</form>", formStart);
|
||||
const formHtml = html.slice(formStart, formEnd);
|
||||
expect(formHtml).not.toContain('name="name"');
|
||||
expect(formHtml).not.toContain('name="plan"');
|
||||
expect(formHtml).toContain('name="__config"');
|
||||
});
|
||||
|
||||
|
||||
it("FormBlock 에 fieldIds/fields 가 모두 없으면 기본 폼/필드를 내보내지 않아야 한다", async () => {
|
||||
it("FormBlock 에 fieldIds/fields 가 모두 없으면 Export 에서 아무 <form>/필드도 생성하지 않아야 한다", async () => {
|
||||
const blocks: Block[] = [
|
||||
{
|
||||
id: "form_fallback_1",
|
||||
@@ -528,6 +552,63 @@ describe("/api/export", () => {
|
||||
expect(html).not.toMatch(/name=\"message\"/);
|
||||
});
|
||||
|
||||
it("FormBlock 이 fields[] 만 가지고 있고 fieldIds 가 없더라도, Export 레이어에서 자체 pb-form 레이아웃/폼 UI 를 생성하지 않아야 한다", async () => {
|
||||
const blocks: Block[] = [
|
||||
{
|
||||
id: "form_legacy_1",
|
||||
type: "form",
|
||||
props: {
|
||||
kind: "contact",
|
||||
submitTarget: "internal",
|
||||
fieldIds: [],
|
||||
fields: [
|
||||
{
|
||||
id: "f1",
|
||||
name: "email",
|
||||
label: "이메일",
|
||||
type: "email",
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
} as any,
|
||||
} as any,
|
||||
];
|
||||
|
||||
const projectConfig: ProjectConfig = {
|
||||
title: "폼 fallback(fields[]) 내보내기 테스트",
|
||||
slug: "form-fallback-fields-export-test",
|
||||
canvasPreset: "full",
|
||||
};
|
||||
|
||||
const payload = { blocks, projectConfig };
|
||||
|
||||
const { POST: handleExport } = await import("@/app/api/export/route");
|
||||
|
||||
const res = await handleExport(
|
||||
new Request(`${BASE_URL}/api/export`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
}),
|
||||
);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
const arrayBuffer = await res.arrayBuffer();
|
||||
const zip = await JSZip.loadAsync(arrayBuffer);
|
||||
|
||||
const indexEntry = zip.file("index.html");
|
||||
expect(indexEntry).toBeTruthy();
|
||||
|
||||
const html = await indexEntry!.async("string");
|
||||
|
||||
// 새로운 규칙: FormBlock 은 어떤 경우에도 자체 레이아웃/폼 UI 를 생성하지 않는다.
|
||||
// - fallback fields 기반 pb-form 도 더 이상 허용하지 않는다.
|
||||
expect(html).not.toContain("class=\"pb-form\"");
|
||||
expect(html).not.toContain("<form");
|
||||
expect(html).not.toMatch(/name=\"email\"/);
|
||||
});
|
||||
|
||||
it("/api/image/:id 기반 이미지 URL은 ZIP 안 images/ 디렉터리로 복사되고 HTML에서 상대 경로로 치환되어야 한다", async () => {
|
||||
const imageId = `test-image-${Date.now()}`;
|
||||
const uploadDir = path.join(process.cwd(), "uploads");
|
||||
@@ -967,8 +1048,8 @@ describe("/api/export", () => {
|
||||
expect(html).not.toContain(`/api/video/${videoId}`);
|
||||
// 비디오 카드 스타일이 wrapper style 에 반영되어야 한다.
|
||||
expect(html).toContain("background-color:#123456");
|
||||
expect(html).toContain("padding:24px");
|
||||
expect(html).toContain("border-radius:16px");
|
||||
expect(html).toContain("padding:1.5em");
|
||||
expect(html).toContain("border-radius:1em");
|
||||
});
|
||||
|
||||
it("섹션 backgroundVideoSrc 가 /api/video/:id 인 경우 ZIP videos/ 및 상대 경로로 치환되고 섹션 안에 배경 비디오 video 태그로 렌더되어야 한다", async () => {
|
||||
@@ -1081,7 +1162,7 @@ describe("/api/export", () => {
|
||||
expect(html).toContain("pb-divider");
|
||||
// 스타일 검증: medium 두께, 지정 색상, marginYPx 기반 여백이 style 속성에 포함되어야 한다.
|
||||
expect(html).toContain("border-bottom:2px solid #123456");
|
||||
expect(html).toContain("margin:32px 0;");
|
||||
expect(html).toContain("margin:2em 0;");
|
||||
});
|
||||
|
||||
it("image 블록은 정적 HTML에서 카드 스타일(배경/너비/둥글기)이 반영되어야 한다", async () => {
|
||||
@@ -1133,7 +1214,7 @@ describe("/api/export", () => {
|
||||
// 카드 배경색이 wrapper div style 에 반영되어야 한다.
|
||||
expect(html).toContain("background-color:#123456");
|
||||
// 고정 너비/둥글기 스타일이 img style 에 반영되어야 한다.
|
||||
expect(html).toContain("width:480px");
|
||||
expect(html).toContain("width:30em");
|
||||
expect(html).toContain("border-radius:32px");
|
||||
});
|
||||
|
||||
@@ -1446,6 +1527,49 @@ describe("/api/export", () => {
|
||||
expect(html).toContain("지금 바로 행동을 유도하는 CTA 텍스트를 입력하세요.");
|
||||
expect(html).toContain("CTA 버튼");
|
||||
});
|
||||
|
||||
it("Footer 템플릿 섹션은 export HTML 의 마지막 섹션으로 렌더되고 푸터 텍스트를 포함해야 한다", async () => {
|
||||
const sectionId = "footer_section_export";
|
||||
const { blocks } = createFooterTemplateBlocks({ sectionId, createId: createIdFactory() });
|
||||
|
||||
const html = await exportTemplateHtml(blocks as Block[]);
|
||||
|
||||
expect(html).toContain("MyLanding");
|
||||
expect(html).toContain("더 나은 웹사이트를 위한 최고의 선택.");
|
||||
expect(html).toContain("서비스 소개");
|
||||
expect(html).toContain("문의하기");
|
||||
expect(html).toContain(" 2025 MyLanding.");
|
||||
|
||||
const lastSectionIndex = html.lastIndexOf("<section");
|
||||
expect(lastSectionIndex).toBeGreaterThan(-1);
|
||||
const lastSectionHtml = html.slice(lastSectionIndex);
|
||||
expect(lastSectionHtml).toContain(" 2025 MyLanding.");
|
||||
});
|
||||
|
||||
it("루트 버튼 내비게이션 링크는 Export HTML 에서 동일한 라벨과 href 로 렌더되어야 한다", async () => {
|
||||
const blocks: Block[] = [
|
||||
{
|
||||
id: "nav_btn_1",
|
||||
type: "button",
|
||||
props: {
|
||||
label: "요금제",
|
||||
href: "/pricing",
|
||||
align: "left",
|
||||
},
|
||||
} as any,
|
||||
];
|
||||
|
||||
const projectConfig: ProjectConfig = {
|
||||
title: "내비게이션 링크 테스트",
|
||||
slug: "nav-link-test",
|
||||
canvasPreset: "full",
|
||||
};
|
||||
|
||||
const html = buildStaticHtml(blocks, projectConfig);
|
||||
|
||||
expect(html).toContain('<a href="/pricing"');
|
||||
expect(html).toContain(">요금제</a>");
|
||||
});
|
||||
});
|
||||
|
||||
describe("스타일 테스트", () => {
|
||||
@@ -1743,7 +1867,11 @@ describe("/api/export", () => {
|
||||
expect(indexEntry).toBeTruthy();
|
||||
|
||||
const html = await indexEntry!.async("string");
|
||||
expect(html).toContain("<form");
|
||||
|
||||
// 새로운 규칙: FormBlock 은 Export 에서 자체 폼 레이아웃을 생성하지 않는다.
|
||||
// - backgroundColorCustom 이 있더라도 form 요소/컨테이너에 배경색이 적용되지 않아야 한다.
|
||||
// - pb-form 기반 레이아웃도 생성되지 않아야 한다.
|
||||
expect(html).not.toContain("class=\"pb-form\"");
|
||||
expect(html).not.toContain("background-color:#111111");
|
||||
});
|
||||
|
||||
@@ -2107,11 +2235,11 @@ describe("/api/export", () => {
|
||||
|
||||
const html = await indexEntry!.async("string");
|
||||
expect(html).toContain("name=\"email\"");
|
||||
expect(html).toContain("width:320px");
|
||||
expect(html).toContain("padding-left:16px");
|
||||
expect(html).toContain("padding-right:16px");
|
||||
expect(html).toContain("padding-top:8px");
|
||||
expect(html).toContain("padding-bottom:8px");
|
||||
expect(html).toContain("width:20em");
|
||||
expect(html).toContain("padding-left:1em");
|
||||
expect(html).toContain("padding-right:1em");
|
||||
expect(html).toContain("padding-top:0.5em");
|
||||
expect(html).toContain("padding-bottom:0.5em");
|
||||
expect(html).toContain("background-color:#123456");
|
||||
expect(html).toContain("border-color:#ff0000");
|
||||
expect(html).toContain("border-radius:6px");
|
||||
@@ -2169,11 +2297,11 @@ describe("/api/export", () => {
|
||||
|
||||
const html = await indexEntry!.async("string");
|
||||
expect(html).toContain("name=\"category\"");
|
||||
expect(html).toContain("width:240px");
|
||||
expect(html).toContain("padding-left:16px");
|
||||
expect(html).toContain("padding-right:16px");
|
||||
expect(html).toContain("padding-top:8px");
|
||||
expect(html).toContain("padding-bottom:8px");
|
||||
expect(html).toContain("width:15em");
|
||||
expect(html).toContain("padding-left:1em");
|
||||
expect(html).toContain("padding-right:1em");
|
||||
expect(html).toContain("padding-top:0.5em");
|
||||
expect(html).toContain("padding-bottom:0.5em");
|
||||
expect(html).toContain("background-color:#123456");
|
||||
expect(html).toContain("border-color:#ff0000");
|
||||
expect(html).toContain("border-radius:6px");
|
||||
@@ -2226,10 +2354,10 @@ describe("/api/export", () => {
|
||||
|
||||
const html = await indexEntry!.async("string");
|
||||
expect(html).toContain("name=\"features\"");
|
||||
expect(html).toContain("padding-left:8px");
|
||||
expect(html).toContain("padding-right:8px");
|
||||
expect(html).toContain("padding-top:4px");
|
||||
expect(html).toContain("padding-bottom:4px");
|
||||
expect(html).toContain("padding-left:0.5em");
|
||||
expect(html).toContain("padding-right:0.5em");
|
||||
expect(html).toContain("padding-top:0.25em");
|
||||
expect(html).toContain("padding-bottom:0.25em");
|
||||
expect(html).toContain("background-color:#123456");
|
||||
expect(html).toContain("border-color:#ff0000");
|
||||
expect(html).toContain("border-radius:6px");
|
||||
@@ -2282,10 +2410,10 @@ describe("/api/export", () => {
|
||||
|
||||
const html = await indexEntry!.async("string");
|
||||
expect(html).toContain("name=\"plan\"");
|
||||
expect(html).toContain("padding-left:8px");
|
||||
expect(html).toContain("padding-right:8px");
|
||||
expect(html).toContain("padding-top:4px");
|
||||
expect(html).toContain("padding-bottom:4px");
|
||||
expect(html).toContain("padding-left:0.5em");
|
||||
expect(html).toContain("padding-right:0.5em");
|
||||
expect(html).toContain("padding-top:0.25em");
|
||||
expect(html).toContain("padding-bottom:0.25em");
|
||||
expect(html).toContain("background-color:#123456");
|
||||
expect(html).toContain("border-color:#ff0000");
|
||||
expect(html).toContain("border-radius:6px");
|
||||
@@ -2389,11 +2517,11 @@ describe("/api/export", () => {
|
||||
|
||||
const html = await indexEntry!.async("string");
|
||||
// widthMode=fixed, widthPx, paddingX/paddingY, fillColorCustom, strokeColorCustom, textColorCustom 이 style 속성에 포함되어야 한다.
|
||||
expect(html).toContain("width:240px");
|
||||
expect(html).toContain("padding-left:16px");
|
||||
expect(html).toContain("padding-right:16px");
|
||||
expect(html).toContain("padding-top:8px");
|
||||
expect(html).toContain("padding-bottom:8px");
|
||||
expect(html).toContain("width:15em");
|
||||
expect(html).toContain("padding-left:1em");
|
||||
expect(html).toContain("padding-right:1em");
|
||||
expect(html).toContain("padding-top:0.5em");
|
||||
expect(html).toContain("padding-bottom:0.5em");
|
||||
expect(html).toContain("background-color:#123456");
|
||||
expect(html).toContain("border-color:#ff0000");
|
||||
expect(html).toContain("color:#00ff00");
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
import "dotenv/config";
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import JSZip from "jszip";
|
||||
|
||||
import type { Block, ProjectConfig } from "@/features/editor/state/editorStore";
|
||||
|
||||
const BASE_URL = "http://localhost";
|
||||
|
||||
// 정적 Export + main.js + /api/forms/submit 를 묶어서 검증하는 통합 테스트.
|
||||
// - /api/export 로 ZIP 을 생성해서 index.html / main.js 를 추출한다.
|
||||
// - jsdom DOM 에 index.html 을 주입하고, main.js 스크립트를 실제로 실행한다.
|
||||
// - form.pb-form-controller 에서 submit 이벤트를 발생시키면
|
||||
// main.js 가 submit 을 가로채고 /api/forms/submit 로 fetch 를 호출해야 한다.
|
||||
|
||||
describe("정적 Export 폼 컨트롤러 통합", () => {
|
||||
it("Export된 index.html + main.js 에서 submit 시 /api/forms/submit 로 fetch 를 호출해야 한다", async () => {
|
||||
const blocks: Block[] = [
|
||||
{
|
||||
id: "form_1",
|
||||
type: "form",
|
||||
props: {
|
||||
kind: "contact",
|
||||
submitTarget: "internal",
|
||||
fieldIds: ["input_name"],
|
||||
submitButtonId: null,
|
||||
formWidthMode: "full",
|
||||
},
|
||||
} as any,
|
||||
{
|
||||
id: "input_name",
|
||||
type: "formInput",
|
||||
props: {
|
||||
label: "이름",
|
||||
formFieldName: "name",
|
||||
required: true,
|
||||
},
|
||||
} as any,
|
||||
];
|
||||
|
||||
const projectConfig: ProjectConfig = {
|
||||
title: "폼 통합 테스트",
|
||||
slug: "form-integration-test",
|
||||
canvasPreset: "full",
|
||||
};
|
||||
|
||||
const payload = { blocks, projectConfig };
|
||||
|
||||
const { POST: handleExport } = await import("@/app/api/export/route");
|
||||
|
||||
const res = await handleExport(
|
||||
new Request(`${BASE_URL}/api/export`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
}),
|
||||
);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
const arrayBuffer = await res.arrayBuffer();
|
||||
const zip = await JSZip.loadAsync(arrayBuffer);
|
||||
|
||||
const indexEntry = zip.file("index.html");
|
||||
const jsEntry = zip.file("main.js");
|
||||
|
||||
expect(indexEntry).toBeTruthy();
|
||||
expect(jsEntry).toBeTruthy();
|
||||
|
||||
const html = await indexEntry!.async("string");
|
||||
const mainJs = await jsEntry!.async("string");
|
||||
|
||||
// jsdom DOM 구성: export 된 HTML 을 그대로 주입한다.
|
||||
document.documentElement.innerHTML = html;
|
||||
|
||||
const form = document.querySelector("form.pb-form-controller") as HTMLFormElement | null;
|
||||
expect(form).not.toBeNull();
|
||||
|
||||
// name 필드에 값 채우기 (FormData 에 실제 값이 들어가도록)
|
||||
const nameInput = document.querySelector('input[name="name"]') as HTMLInputElement | null;
|
||||
if (nameInput) {
|
||||
nameInput.value = "Alice";
|
||||
}
|
||||
|
||||
// fetch 를 목킹하여 main.js 의 submit 가로채기 동작을 검증한다.
|
||||
const fetchMock = vi.fn(async () =>
|
||||
new Response(JSON.stringify({ ok: true, message: "ok" }), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}),
|
||||
);
|
||||
(globalThis as any).fetch = fetchMock;
|
||||
|
||||
// main.js 실행 (IIFE 코드이므로 평가만 하면 즉시 pbInitFormControllers 가 동작한다.)
|
||||
// eslint-disable-next-line no-new-func
|
||||
new Function(mainJs)();
|
||||
|
||||
// submit 이벤트 발생: main.js 가 이를 가로채고 fetch 를 호출해야 한다.
|
||||
const submitEvent = new Event("submit", { bubbles: true, cancelable: true });
|
||||
form!.dispatchEvent(submitEvent);
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
const [url, options] = fetchMock.mock.calls[0] as [any, RequestInit];
|
||||
|
||||
expect(url).toBe("/api/forms/submit");
|
||||
expect(options.method).toBe("POST");
|
||||
expect(options.body).toBeInstanceOf(FormData);
|
||||
});
|
||||
});
|
||||
@@ -185,6 +185,49 @@ describe("/api/forms/submit", () => {
|
||||
expect(json.message).toBe(config.successMessage);
|
||||
});
|
||||
|
||||
it("both 모드에서는 internal 처리 후 webhook 으로도 전송되어야 한다", async () => {
|
||||
const destinationUrl = `${BASE_URL}/webhook-both`;
|
||||
|
||||
const config: FormBlockProps = {
|
||||
kind: "contact",
|
||||
submitTarget: "both",
|
||||
destinationUrl,
|
||||
method: "POST",
|
||||
headers: { Authorization: "Bearer both-token" },
|
||||
extraParams: { source: "builder-both" },
|
||||
successMessage: "양쪽 모두 전송되었습니다.",
|
||||
errorMessage: "both 모드 전송 실패",
|
||||
fieldIds: [],
|
||||
submitButtonId: null,
|
||||
};
|
||||
|
||||
const fetchMock = vi.fn(async () => new Response("ok", { status: 200 }));
|
||||
(globalThis as any).fetch = fetchMock;
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append("email_address", "both@example.com");
|
||||
formData.append("__config", JSON.stringify(config));
|
||||
|
||||
const { POST: handleSubmit } = await import("@/app/api/forms/submit/route");
|
||||
|
||||
const res = await handleSubmit(
|
||||
new Request(`${BASE_URL}/api/forms/submit`, {
|
||||
method: "POST",
|
||||
body: formData,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
const json = (await res.json()) as any;
|
||||
expect(json.ok).toBe(true);
|
||||
expect(json.message).toBe(config.successMessage);
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
const [url, options] = fetchMock.mock.calls[0] as [string, RequestInit];
|
||||
expect(url).toBe(destinationUrl);
|
||||
expect(options.method).toBe("POST");
|
||||
});
|
||||
|
||||
it("webhook 모드에서 destinationUrl 이 없으면 400 과 destinationUrl_missing 및 errorMessage 가 포함되어야 한다", async () => {
|
||||
const config: FormBlockProps = {
|
||||
kind: "contact",
|
||||
|
||||
+413
-5
@@ -1,21 +1,93 @@
|
||||
import "dotenv/config";
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { signAccessToken } from "@/features/auth/authCrypto";
|
||||
|
||||
// PrismaClient를 실제 DB 대신 메모리 기반 저장소를 사용하는 목으로 대체한다.
|
||||
// 이렇게 하면 CI/로컬 어디에서도 DATABASE_URL 이나 실제 DB 없이 API 테스트를 실행할 수 있다.
|
||||
const inMemoryProjects: any[] = [];
|
||||
|
||||
vi.mock("@/generated/prisma/client", () => {
|
||||
vi.mock("@prisma/client", () => {
|
||||
class PrismaClientMock {
|
||||
project = {
|
||||
create: async ({ data }: any) => {
|
||||
const project = { id: inMemoryProjects.length + 1, ...data };
|
||||
const now = new Date();
|
||||
const project = {
|
||||
id: String(inMemoryProjects.length + 1),
|
||||
status: "DRAFT",
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
...data,
|
||||
};
|
||||
inMemoryProjects.push(project);
|
||||
return project;
|
||||
},
|
||||
findUnique: async ({ where: { slug } }: any) => {
|
||||
return inMemoryProjects.find((p) => p.slug === slug) ?? null;
|
||||
},
|
||||
upsert: async ({ where: { slug }, update, create }: any) => {
|
||||
const index = inMemoryProjects.findIndex((p) => p.slug === slug);
|
||||
if (index >= 0) {
|
||||
const existing = inMemoryProjects[index];
|
||||
const updated = {
|
||||
...existing,
|
||||
...update,
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
inMemoryProjects[index] = updated;
|
||||
return updated;
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
const project = {
|
||||
id: String(inMemoryProjects.length + 1),
|
||||
status: "DRAFT",
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
...create,
|
||||
};
|
||||
inMemoryProjects.push(project);
|
||||
return project;
|
||||
},
|
||||
delete: async ({ where: { slug } }: any) => {
|
||||
const index = inMemoryProjects.findIndex((p) => p.slug === slug);
|
||||
if (index === -1) {
|
||||
const error: any = new Error("Project not found");
|
||||
error.code = "P2025";
|
||||
throw error;
|
||||
}
|
||||
|
||||
const [removed] = inMemoryProjects.splice(index, 1);
|
||||
return removed;
|
||||
},
|
||||
findMany: async ({ where, orderBy, take, select }: any = {}) => {
|
||||
let items = [...inMemoryProjects];
|
||||
|
||||
if (where?.userId) {
|
||||
items = items.filter((p) => p.userId === where.userId);
|
||||
}
|
||||
|
||||
if (orderBy?.createdAt === "desc") {
|
||||
items.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime());
|
||||
}
|
||||
|
||||
if (typeof take === "number") {
|
||||
items = items.slice(0, take);
|
||||
}
|
||||
|
||||
if (select) {
|
||||
return items.map((p) => {
|
||||
const picked: any = {};
|
||||
for (const key of Object.keys(select)) {
|
||||
if (select[key]) {
|
||||
picked[key] = p[key];
|
||||
}
|
||||
}
|
||||
return picked;
|
||||
});
|
||||
}
|
||||
|
||||
return items;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -24,6 +96,23 @@ vi.mock("@/generated/prisma/client", () => {
|
||||
|
||||
const BASE_URL = "http://localhost";
|
||||
|
||||
const TEST_USER = { id: "user-1", email: "projects@example.com", tokenVersion: 1 };
|
||||
const OTHER_USER = { id: "user-2", email: "projects2@example.com", tokenVersion: 1 };
|
||||
|
||||
async function buildAuthHeadersFor(user: { id: string; email: string; tokenVersion: number }) {
|
||||
const token = await signAccessToken(user);
|
||||
return { cookie: `pb_access=${token}` };
|
||||
}
|
||||
|
||||
async function buildAuthHeaders() {
|
||||
return buildAuthHeadersFor(TEST_USER);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
process.env.AUTH_JWT_SECRET = "test-jwt-secret-projects";
|
||||
inMemoryProjects.length = 0;
|
||||
});
|
||||
|
||||
describe("/api/projects", () => {
|
||||
it("POST /api/projects 로 프로젝트를 생성하고 GET /api/projects/[slug] 로 조회할 수 있어야 한다", async () => {
|
||||
const payload = {
|
||||
@@ -40,10 +129,12 @@ describe("/api/projects", () => {
|
||||
|
||||
const { POST: createProject } = await import("@/app/api/projects/route");
|
||||
|
||||
const headers = await buildAuthHeaders();
|
||||
|
||||
const createResponse = await createProject(
|
||||
new Request(`${BASE_URL}/api/projects`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
headers: { "Content-Type": "application/json", ...headers },
|
||||
body: JSON.stringify(payload),
|
||||
}),
|
||||
);
|
||||
@@ -60,8 +151,10 @@ describe("/api/projects", () => {
|
||||
|
||||
const { GET: getProjectBySlug } = await import("@/app/api/projects/[slug]/route");
|
||||
|
||||
const getHeaders = await buildAuthHeaders();
|
||||
|
||||
const getResponse = await getProjectBySlug(
|
||||
new Request(`${BASE_URL}/api/projects/${payload.slug}`),
|
||||
new Request(`${BASE_URL}/api/projects/${payload.slug}`, { headers: getHeaders }),
|
||||
{ params: { slug: payload.slug } } as any,
|
||||
);
|
||||
|
||||
@@ -70,4 +163,319 @@ describe("/api/projects", () => {
|
||||
expect(fetched.slug).toBe(payload.slug);
|
||||
expect(fetched.contentJson).toEqual(payload.contentJson);
|
||||
});
|
||||
|
||||
it("GET /api/projects 는 최근 생성 순으로 프로젝트 목록을 반환해야 한다", async () => {
|
||||
const { POST: createProject } = await import("@/app/api/projects/route");
|
||||
|
||||
const firstPayload = {
|
||||
title: "첫 번째 프로젝트",
|
||||
slug: "first-project",
|
||||
contentJson: [],
|
||||
};
|
||||
|
||||
const secondPayload = {
|
||||
title: "두 번째 프로젝트",
|
||||
slug: "second-project",
|
||||
contentJson: [],
|
||||
};
|
||||
|
||||
const headers = await buildAuthHeaders();
|
||||
|
||||
await createProject(
|
||||
new Request(`${BASE_URL}/api/projects`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", ...headers },
|
||||
body: JSON.stringify(firstPayload),
|
||||
}),
|
||||
);
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 5));
|
||||
|
||||
await createProject(
|
||||
new Request(`${BASE_URL}/api/projects`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", ...headers },
|
||||
body: JSON.stringify(secondPayload),
|
||||
}),
|
||||
);
|
||||
|
||||
const { GET: listProjects } = await import("@/app/api/projects/route");
|
||||
|
||||
const listHeaders = await buildAuthHeaders();
|
||||
|
||||
const res = await listProjects(new Request(`${BASE_URL}/api/projects`, { headers: listHeaders }));
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
const list = (await res.json()) as any[];
|
||||
|
||||
expect(list.length).toBeGreaterThanOrEqual(2);
|
||||
expect(list[0].slug).toBe("second-project");
|
||||
expect(list[1].slug).toBe("first-project");
|
||||
});
|
||||
|
||||
it("DELETE /api/projects/[slug] 로 프로젝트를 삭제할 수 있어야 한다", async () => {
|
||||
const { POST: createProject } = await import("@/app/api/projects/route");
|
||||
|
||||
const slug = `delete-slug-${Date.now()}`;
|
||||
const payload = {
|
||||
title: "삭제 테스트 프로젝트",
|
||||
slug,
|
||||
contentJson: [],
|
||||
};
|
||||
|
||||
const headers = await buildAuthHeaders();
|
||||
|
||||
await createProject(
|
||||
new Request(`${BASE_URL}/api/projects`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", ...headers },
|
||||
body: JSON.stringify(payload),
|
||||
}),
|
||||
);
|
||||
|
||||
const { DELETE: deleteProject, GET: getProjectBySlug } = await import("@/app/api/projects/[slug]/route");
|
||||
|
||||
const deleteHeaders = await buildAuthHeaders();
|
||||
|
||||
const deleteResponse = await deleteProject(
|
||||
new Request(`${BASE_URL}/api/projects/${slug}`, { method: "DELETE", headers: deleteHeaders }),
|
||||
{ params: { slug } } as any,
|
||||
);
|
||||
|
||||
expect(deleteResponse.status).toBe(200);
|
||||
|
||||
const getAfterDelete = await getProjectBySlug(
|
||||
new Request(`${BASE_URL}/api/projects/${slug}`, { headers: deleteHeaders }),
|
||||
{ params: { slug } } as any,
|
||||
);
|
||||
|
||||
expect(getAfterDelete.status).toBe(404);
|
||||
});
|
||||
|
||||
it("동일 slug 로 두 번 POST 하면 두 번째 요청은 기존 프로젝트를 업데이트해야 한다", async () => {
|
||||
const { POST: handleProject } = await import("@/app/api/projects/route");
|
||||
|
||||
const slug = "duplicate-slug";
|
||||
|
||||
const firstPayload = {
|
||||
title: "첫 번째 제목",
|
||||
slug,
|
||||
contentJson: [
|
||||
{
|
||||
id: "blk_first",
|
||||
type: "text",
|
||||
props: { text: "첫 번째 내용", align: "left", size: "base" },
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const secondPayload = {
|
||||
title: "두 번째 제목",
|
||||
slug,
|
||||
contentJson: [
|
||||
{
|
||||
id: "blk_second",
|
||||
type: "text",
|
||||
props: { text: "두 번째 내용", align: "left", size: "base" },
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const headers = await buildAuthHeaders();
|
||||
|
||||
const firstRes = await handleProject(
|
||||
new Request(`${BASE_URL}/api/projects`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", ...headers },
|
||||
body: JSON.stringify(firstPayload),
|
||||
}),
|
||||
);
|
||||
|
||||
expect(firstRes.status).toBe(201);
|
||||
|
||||
const secondRes = await handleProject(
|
||||
new Request(`${BASE_URL}/api/projects`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", ...headers },
|
||||
body: JSON.stringify(secondPayload),
|
||||
}),
|
||||
);
|
||||
|
||||
expect([200, 201]).toContain(secondRes.status);
|
||||
const updated = (await secondRes.json()) as any;
|
||||
|
||||
expect(updated.slug).toBe(slug);
|
||||
expect(updated.title).toBe("두 번째 제목");
|
||||
expect(updated.contentJson).toEqual(secondPayload.contentJson);
|
||||
});
|
||||
|
||||
it("GET /api/projects 는 현재 로그인 유저의 프로젝트만 반환해야 한다", async () => {
|
||||
const { POST: createProject, GET: listProjects } = await import("@/app/api/projects/route");
|
||||
|
||||
const slugA = "user-a-project";
|
||||
const slugB = "user-b-project";
|
||||
|
||||
const headersA = await buildAuthHeadersFor(TEST_USER);
|
||||
const headersB = await buildAuthHeadersFor(OTHER_USER);
|
||||
|
||||
const payloadA = {
|
||||
title: "유저 A 프로젝트",
|
||||
slug: slugA,
|
||||
contentJson: [],
|
||||
};
|
||||
|
||||
const payloadB = {
|
||||
title: "유저 B 프로젝트",
|
||||
slug: slugB,
|
||||
contentJson: [],
|
||||
};
|
||||
|
||||
await createProject(
|
||||
new Request(`${BASE_URL}/api/projects`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", ...headersA },
|
||||
body: JSON.stringify(payloadA),
|
||||
}),
|
||||
);
|
||||
|
||||
await createProject(
|
||||
new Request(`${BASE_URL}/api/projects`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", ...headersB },
|
||||
body: JSON.stringify(payloadB),
|
||||
}),
|
||||
);
|
||||
|
||||
const resA = await listProjects(
|
||||
new Request(`${BASE_URL}/api/projects`, { headers: headersA }),
|
||||
);
|
||||
expect(resA.status).toBe(200);
|
||||
const listA = (await resA.json()) as any[];
|
||||
const slugsA = listA.map((p) => p.slug);
|
||||
expect(slugsA).toContain(slugA);
|
||||
expect(slugsA).not.toContain(slugB);
|
||||
|
||||
const resB = await listProjects(
|
||||
new Request(`${BASE_URL}/api/projects`, { headers: headersB }),
|
||||
);
|
||||
expect(resB.status).toBe(200);
|
||||
const listB = (await resB.json()) as any[];
|
||||
const slugsB = listB.map((p) => p.slug);
|
||||
expect(slugsB).toContain(slugB);
|
||||
expect(slugsB).not.toContain(slugA);
|
||||
});
|
||||
|
||||
it("다른 유저가 이미 소유한 slug 로 POST 하면 409 를 반환해야 한다", async () => {
|
||||
const { POST: handleProject } = await import("@/app/api/projects/route");
|
||||
|
||||
const slug = "shared-slug";
|
||||
|
||||
const ownerPayload = {
|
||||
title: "소유자 프로젝트",
|
||||
slug,
|
||||
contentJson: [],
|
||||
};
|
||||
|
||||
const otherPayload = {
|
||||
title: "다른 유저 프로젝트",
|
||||
slug,
|
||||
contentJson: [],
|
||||
};
|
||||
|
||||
const ownerHeaders = await buildAuthHeadersFor(TEST_USER);
|
||||
const otherHeaders = await buildAuthHeadersFor(OTHER_USER);
|
||||
|
||||
const firstRes = await handleProject(
|
||||
new Request(`${BASE_URL}/api/projects`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", ...ownerHeaders },
|
||||
body: JSON.stringify(ownerPayload),
|
||||
}),
|
||||
);
|
||||
expect(firstRes.status).toBe(201);
|
||||
|
||||
const secondRes = await handleProject(
|
||||
new Request(`${BASE_URL}/api/projects`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", ...otherHeaders },
|
||||
body: JSON.stringify(otherPayload),
|
||||
}),
|
||||
);
|
||||
|
||||
expect(secondRes.status).toBe(409);
|
||||
});
|
||||
|
||||
it("GET /api/projects/[slug] 는 소유자가 아닌 유저에게 404 를 반환해야 한다", async () => {
|
||||
const { POST: handleProject } = await import("@/app/api/projects/route");
|
||||
const { GET: getProjectBySlug } = await import("@/app/api/projects/[slug]/route");
|
||||
|
||||
const slug = "owner-only-slug";
|
||||
|
||||
const ownerHeaders = await buildAuthHeadersFor(TEST_USER);
|
||||
const otherHeaders = await buildAuthHeadersFor(OTHER_USER);
|
||||
|
||||
const payload = {
|
||||
title: "소유자 프로젝트",
|
||||
slug,
|
||||
contentJson: [],
|
||||
};
|
||||
|
||||
const createRes = await handleProject(
|
||||
new Request(`${BASE_URL}/api/projects`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", ...ownerHeaders },
|
||||
body: JSON.stringify(payload),
|
||||
}),
|
||||
);
|
||||
expect(createRes.status).toBe(201);
|
||||
|
||||
const resOther = await getProjectBySlug(
|
||||
new Request(`${BASE_URL}/api/projects/${slug}`, { headers: otherHeaders }),
|
||||
{ params: { slug } } as any,
|
||||
);
|
||||
expect(resOther.status).toBe(404);
|
||||
|
||||
const resOwner = await getProjectBySlug(
|
||||
new Request(`${BASE_URL}/api/projects/${slug}`, { headers: ownerHeaders }),
|
||||
{ params: { slug } } as any,
|
||||
);
|
||||
expect(resOwner.status).toBe(200);
|
||||
});
|
||||
|
||||
it("DELETE /api/projects/[slug] 는 소유자가 아닌 유저가 호출하면 404 를 반환하고 실제 데이터는 삭제되지 않아야 한다", async () => {
|
||||
const { POST: handleProject } = await import("@/app/api/projects/route");
|
||||
const { DELETE: deleteProject, GET: getProjectBySlug } = await import("@/app/api/projects/[slug]/route");
|
||||
|
||||
const slug = "owner-delete-slug";
|
||||
|
||||
const ownerHeaders = await buildAuthHeadersFor(TEST_USER);
|
||||
const otherHeaders = await buildAuthHeadersFor(OTHER_USER);
|
||||
|
||||
const payload = {
|
||||
title: "삭제 소유자 프로젝트",
|
||||
slug,
|
||||
contentJson: [],
|
||||
};
|
||||
|
||||
const createRes = await handleProject(
|
||||
new Request(`${BASE_URL}/api/projects`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", ...ownerHeaders },
|
||||
body: JSON.stringify(payload),
|
||||
}),
|
||||
);
|
||||
expect(createRes.status).toBe(201);
|
||||
|
||||
const deleteResOther = await deleteProject(
|
||||
new Request(`${BASE_URL}/api/projects/${slug}`, { method: "DELETE", headers: otherHeaders }),
|
||||
{ params: { slug } } as any,
|
||||
);
|
||||
expect(deleteResOther.status).toBe(404);
|
||||
|
||||
const resOwner = await getProjectBySlug(
|
||||
new Request(`${BASE_URL}/api/projects/${slug}`, { headers: ownerHeaders }),
|
||||
{ params: { slug } } as any,
|
||||
);
|
||||
expect(resOwner.status).toBe(200);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -10,7 +10,7 @@ const BASE_URL = "http://localhost";
|
||||
// id / servedUrl(`/api/video/:id`) 을 반환해야 한다.
|
||||
|
||||
// Prisma 엔진이 실제로 로드되지 않도록 PrismaClient 를 목 처리한다.
|
||||
vi.mock("@/generated/prisma/client", () => {
|
||||
vi.mock("@prisma/client", () => {
|
||||
class PrismaClient {
|
||||
project = {
|
||||
upsert: async () => ({ id: "project-mock" }),
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
import { test, expect } from "@playwright/test";
|
||||
|
||||
// 인증 라우트 가드 E2E
|
||||
// - 비로그인 사용자가 보호된 페이지에 접근하면 /login 으로 리다이렉트되어야 한다.
|
||||
|
||||
test("비로그인 사용자가 /projects 에 접근하면 /login 으로 리다이렉트되어야 한다", async ({ page }) => {
|
||||
// /api/projects 응답을 401 으로 목 처리해 비로그인 상태를 시뮬레이션한다.
|
||||
await page.route("**/api/projects", async (route) => {
|
||||
await route.fulfill({
|
||||
status: 401,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ message: "로그인이 필요합니다." }),
|
||||
});
|
||||
});
|
||||
|
||||
await page.goto("/projects");
|
||||
|
||||
// 최종적으로 로그인 페이지의 헤더가 보여야 한다.
|
||||
await expect(page.getByRole("heading", { name: "로그인" })).toBeVisible();
|
||||
});
|
||||
|
||||
test("비로그인 사용자가 /editor 에 접근하면 /login 으로 리다이렉트되어야 한다", async ({ page }) => {
|
||||
// /api/auth/me 를 401 으로 응답하도록 목 처리해 비로그인 상태를 시뮬레이션한다.
|
||||
await page.route("**/api/auth/me", async (route) => {
|
||||
await route.fulfill({
|
||||
status: 401,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ message: "인증이 필요합니다." }),
|
||||
});
|
||||
});
|
||||
|
||||
await page.goto("/editor");
|
||||
|
||||
await expect(page.getByRole("heading", { name: "로그인" })).toBeVisible();
|
||||
});
|
||||
|
||||
test("비로그인 사용자가 /preview 에 접근하면 /login 으로 리다이렉트되어야 한다", async ({ page }) => {
|
||||
await page.route("**/api/auth/me", async (route) => {
|
||||
await route.fulfill({
|
||||
status: 401,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ message: "인증이 필요합니다." }),
|
||||
});
|
||||
});
|
||||
|
||||
await page.goto("/preview");
|
||||
|
||||
await expect(page.getByRole("heading", { name: "로그인" })).toBeVisible();
|
||||
});
|
||||
|
||||
test("회원가입 후에는 /projects, /editor, /preview 에 정상 접근할 수 있어야 한다", async ({ page }) => {
|
||||
const email = `e2e+${Date.now()}@example.com`;
|
||||
const password = "securePass1";
|
||||
|
||||
// 백엔드/DB 에 의존하지 않고 프론트 동작만 검증하기 위해 관련 API 를 모두 목 처리한다.
|
||||
// isLoggedIn 플래그를 두고, 회원가입 전에는 /api/auth/me 가 401 을, 회원가입 후에는 200 을 반환하도록 시뮬레이션한다.
|
||||
let isLoggedIn = false;
|
||||
|
||||
await page.route("**/api/auth/signup", async (route) => {
|
||||
isLoggedIn = true;
|
||||
|
||||
await route.fulfill({
|
||||
status: 201,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ id: "user-1", email }),
|
||||
});
|
||||
});
|
||||
|
||||
await page.route("**/api/auth/me", async (route) => {
|
||||
if (!isLoggedIn) {
|
||||
await route.fulfill({
|
||||
status: 401,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ message: "인증이 필요합니다." }),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ id: "user-1", email, tokenVersion: 1 }),
|
||||
});
|
||||
});
|
||||
|
||||
await page.route("**/api/projects", async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify([]),
|
||||
});
|
||||
});
|
||||
|
||||
// 1) 회원가입
|
||||
await page.goto("/signup");
|
||||
|
||||
await page.getByLabel("이메일").fill(email);
|
||||
await page.getByLabel("비밀번호").fill(password);
|
||||
await page.getByRole("button", { name: "회원가입" }).click();
|
||||
|
||||
// /projects 로 이동했는지, 헤더가 보이는지 확인
|
||||
await expect(page).toHaveURL(/\/projects/);
|
||||
await expect(page.getByRole("heading", { name: "프로젝트 목록" })).toBeVisible();
|
||||
|
||||
// 2) /editor 접근 확인 – 로그인 페이지로 리다이렉트되면 안 된다.
|
||||
await page.goto("/editor");
|
||||
await expect(page.getByRole("heading", { name: "Page Editor" })).toBeVisible();
|
||||
|
||||
// 3) /preview 접근 확인
|
||||
await page.goto("/preview");
|
||||
await expect(page.getByRole("heading", { name: "Page Preview" })).toBeVisible();
|
||||
});
|
||||
+102
-70
@@ -2,16 +2,28 @@ import { test, expect } from "@playwright/test";
|
||||
|
||||
// 최초에는 실패하는 테스트: /editor 페이지와 헤더 텍스트가 아직 없음
|
||||
|
||||
// 에디터 E2E에서는 인증된 사용자 시나리오를 기본으로 가정한다.
|
||||
// /api/auth/me 를 항상 200 으로 목 처리해, auth 가드가 /login 으로 리다이렉트하지 않도록 한다.
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.route("**/api/auth/me", async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ id: "user-e2e", email: "e2e@example.com", tokenVersion: 1 }),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test("/editor 페이지가 존재하고 Page Editor 헤더를 보여줘야 한다", async ({ page }) => {
|
||||
await page.goto("/editor");
|
||||
|
||||
await expect(page.getByRole("heading", { name: "Page Editor" })).toBeVisible();
|
||||
});
|
||||
|
||||
test("텍스트 블록 추가 버튼을 누르면 캔버스에 '새 텍스트' 블록이 보여야 한다", async ({ page }) => {
|
||||
test("텍스트 버튼을 누르면 캔버스에 '새 텍스트' 블록이 보여야 한다", async ({ page }) => {
|
||||
await page.goto("/editor");
|
||||
|
||||
await page.getByRole("button", { name: "텍스트 블록 추가" }).click();
|
||||
await page.getByRole("button", { name: "텍스트" }).first().click();
|
||||
|
||||
const canvas = page.getByTestId("editor-canvas");
|
||||
await expect(canvas.getByText("새 텍스트")).toBeVisible();
|
||||
@@ -75,7 +87,7 @@ test("텍스트 블록을 더블클릭해서 내용을 수정할 수 있어야
|
||||
await page.goto("/editor");
|
||||
|
||||
// 텍스트 블록을 하나 추가한다.
|
||||
await page.getByRole("button", { name: "텍스트 블록 추가" }).click();
|
||||
await page.getByRole("button", { name: "텍스트" }).first().click();
|
||||
|
||||
// 1단계: 더블클릭 → Enter 로 커밋
|
||||
// 기존 텍스트 블록을 더블클릭해서 편집 모드로 전환한다.
|
||||
@@ -109,7 +121,7 @@ test("텍스트 블록을 더블클릭해서 내용을 수정할 수 있어야
|
||||
await editor.fill("blur 로 커밋된 텍스트");
|
||||
|
||||
// 포커스를 다른 곳(예: 속성 패널의 버튼 추가 텍스트 입력)을 클릭하여 blur 를 발생시킨다.
|
||||
await page.getByRole("button", { name: "텍스트 블록 추가" }).click();
|
||||
await page.getByRole("button", { name: "텍스트" }).first().click();
|
||||
|
||||
// blur 이후에는 마지막 입력 값이 캔버스에 반영되어야 한다.
|
||||
canvasAfterEdit = page.getByTestId("editor-canvas");
|
||||
@@ -120,7 +132,7 @@ test("속성 패널에서 선택된 텍스트 블록 내용을 수정하면 캔
|
||||
await page.goto("/editor");
|
||||
|
||||
// 텍스트 블록을 추가한다.
|
||||
await page.getByRole("button", { name: "텍스트 블록 추가" }).click();
|
||||
await page.getByRole("button", { name: "텍스트" }).first().click();
|
||||
|
||||
// 캔버스 블록을 클릭해서 선택한다.
|
||||
const canvas = page.getByTestId("editor-canvas");
|
||||
@@ -140,8 +152,8 @@ test("여러 텍스트 블록 중 선택을 전환하면 속성 패널 내용과
|
||||
await page.goto("/editor");
|
||||
|
||||
// 텍스트 블록 두 개를 추가한다.
|
||||
await page.getByRole("button", { name: "텍스트 블록 추가" }).click();
|
||||
await page.getByRole("button", { name: "텍스트 블록 추가" }).click();
|
||||
await page.getByRole("button", { name: "텍스트" }).first().click();
|
||||
await page.getByRole("button", { name: "텍스트" }).first().click();
|
||||
|
||||
const canvas = page.getByTestId("editor-canvas");
|
||||
const blocks = canvas.getByTestId("editor-block");
|
||||
@@ -172,7 +184,7 @@ test("텍스트 블록의 정렬과 글자 크기를 속성 패널에서 변경
|
||||
await page.goto("/editor");
|
||||
|
||||
// 텍스트 블록을 하나 추가하고 선택한다.
|
||||
await page.getByRole("button", { name: "텍스트 블록 추가" }).click();
|
||||
await page.getByRole("button", { name: "텍스트" }).first().click();
|
||||
const canvas = page.getByTestId("editor-canvas");
|
||||
const block = canvas.getByTestId("editor-block").nth(0);
|
||||
await block.click({ force: true });
|
||||
@@ -196,7 +208,7 @@ test("텍스트 블록 인라인 툴바에서 정렬과 글자 크기를 변경
|
||||
await page.goto("/editor");
|
||||
|
||||
// 텍스트 블록을 하나 추가한다.
|
||||
await page.getByRole("button", { name: "텍스트 블록 추가" }).click();
|
||||
await page.getByRole("button", { name: "텍스트" }).click();
|
||||
|
||||
const canvas = page.getByTestId("editor-canvas");
|
||||
const block = canvas.getByTestId("editor-block").nth(0);
|
||||
@@ -226,8 +238,8 @@ test("에디터 상태를 JSON으로 내보내고 다시 불러오면 동일한
|
||||
const canvas = page.getByTestId("editor-canvas");
|
||||
|
||||
// 블록 두 개를 추가하고 각각 다른 내용/스타일을 설정한다.
|
||||
await page.getByRole("button", { name: "텍스트 블록 추가" }).click();
|
||||
await page.getByRole("button", { name: "텍스트 블록 추가" }).click();
|
||||
await page.getByRole("button", { name: "텍스트" }).first().click();
|
||||
await page.getByRole("button", { name: "텍스트" }).first().click();
|
||||
|
||||
const blocks = canvas.getByTestId("editor-block");
|
||||
const firstBlock = blocks.nth(0);
|
||||
@@ -290,8 +302,8 @@ test("각 캔버스 블록에 드래그 핸들 UI가 표시되고 선택 상태
|
||||
const canvas = page.getByTestId("editor-canvas");
|
||||
|
||||
// 블록 두 개를 추가한다.
|
||||
await page.getByRole("button", { name: "텍스트 블록 추가" }).click();
|
||||
await page.getByRole("button", { name: "텍스트 블록 추가" }).click();
|
||||
await page.getByRole("button", { name: "텍스트" }).first().click();
|
||||
await page.getByRole("button", { name: "텍스트" }).first().click();
|
||||
|
||||
const blocks = canvas.getByTestId("editor-block");
|
||||
const firstBlock = blocks.nth(0);
|
||||
@@ -318,8 +330,8 @@ test("블록 드래그앤드롭으로 순서를 변경할 수 있어야 한다",
|
||||
const canvas = page.getByTestId("editor-canvas");
|
||||
|
||||
// 블록 두 개를 추가하고 서로 다른 텍스트로 구분한다.
|
||||
await page.getByRole("button", { name: "텍스트 블록 추가" }).click();
|
||||
await page.getByRole("button", { name: "텍스트 블록 추가" }).click();
|
||||
await page.getByRole("button", { name: "텍스트" }).first().click();
|
||||
await page.getByRole("button", { name: "텍스트" }).first().click();
|
||||
|
||||
const blocks = canvas.getByTestId("editor-block");
|
||||
const firstBlock = blocks.nth(0);
|
||||
@@ -352,8 +364,8 @@ test("리스트 블록에도 드래그 핸들이 있고 선택/드래그가 가
|
||||
const canvas = page.getByTestId("editor-canvas");
|
||||
|
||||
// 텍스트 블록과 리스트 블록을 하나씩 추가한다.
|
||||
await page.getByRole("button", { name: "텍스트 블록 추가" }).click();
|
||||
await page.getByRole("button", { name: "리스트 블록 추가" }).click();
|
||||
await page.getByRole("button", { name: "텍스트" }).first().click();
|
||||
await page.getByRole("button", { name: "리스트" }).click();
|
||||
|
||||
const blocks = canvas.getByTestId("editor-block");
|
||||
await expect(blocks).toHaveCount(2);
|
||||
@@ -387,7 +399,7 @@ test("버튼 블록을 추가하고 라벨과 링크를 수정할 수 있어야
|
||||
const canvas = page.getByTestId("editor-canvas");
|
||||
|
||||
// 버튼 블록을 추가한다.
|
||||
await page.getByRole("button", { name: "버튼 블록 추가" }).click();
|
||||
await page.getByRole("button", { name: "버튼" }).click();
|
||||
|
||||
// 캔버스에 기본 버튼이 렌더되어야 한다.
|
||||
const button = canvas.getByRole("button", { name: "버튼" });
|
||||
@@ -410,7 +422,7 @@ test("버튼 블록을 추가하면 속성 패널 기본 색상/패딩 값이
|
||||
|
||||
const canvas = page.getByTestId("editor-canvas");
|
||||
|
||||
await page.getByRole("button", { name: "버튼 블록 추가" }).click();
|
||||
await page.getByRole("button", { name: "버튼" }).click();
|
||||
|
||||
const button = canvas.getByRole("button", { name: "버튼" });
|
||||
await expect(button).toBeVisible();
|
||||
@@ -437,7 +449,7 @@ test("버튼 가로/세로 패딩 px 값을 변경하면 에디터 버튼에도
|
||||
|
||||
const canvas = page.getByTestId("editor-canvas");
|
||||
|
||||
await page.getByRole("button", { name: "버튼 블록 추가" }).click();
|
||||
await page.getByRole("button", { name: "버튼" }).click();
|
||||
|
||||
const button = canvas.getByRole("button", { name: "버튼" }).first();
|
||||
|
||||
@@ -470,7 +482,7 @@ test("버튼 가로/세로 패딩 px 값을 변경하면 에디터 버튼에도
|
||||
test("이미지 블록 고정 너비와 모서리 둥글기 스타일이 에디터에서 적용되어야 한다", async ({ page }) => {
|
||||
await page.goto("/editor");
|
||||
|
||||
await page.getByRole("button", { name: "이미지 블록 추가" }).click();
|
||||
await page.getByRole("button", { name: "이미지" }).click();
|
||||
|
||||
const canvas = page.getByTestId("editor-canvas");
|
||||
const propertiesSidebar = page.getByTestId("properties-sidebar");
|
||||
@@ -504,7 +516,7 @@ test("에디터 메뉴에서 페이지 파일로 내보내기 (ZIP)를 클릭하
|
||||
await page.goto("/editor");
|
||||
|
||||
// 최소 한 개의 블록을 추가해 내보낼 데이터가 있도록 한다.
|
||||
await page.getByRole("button", { name: "텍스트 블록 추가" }).click();
|
||||
await page.getByRole("button", { name: "텍스트" }).first().click();
|
||||
|
||||
// 헤더 메뉴를 연다.
|
||||
await page.getByRole("button", { name: "메뉴 ▼" }).click();
|
||||
@@ -528,7 +540,7 @@ test("2컬럼 섹션에서 블록을 다른 컬럼 영역으로 드래그하면
|
||||
const canvas = page.getByTestId("editor-canvas");
|
||||
|
||||
// 섹션을 하나 추가하고 레이아웃을 2컬럼으로 변경한다.
|
||||
await page.getByRole("button", { name: "섹션 블록 추가" }).click();
|
||||
await page.getByRole("button", { name: "섹션" }).click();
|
||||
|
||||
// 섹션을 선택한다 (캔버스 첫 블록).
|
||||
const sectionBlock = canvas.getByTestId("editor-block").nth(0);
|
||||
@@ -538,7 +550,7 @@ test("2컬럼 섹션에서 블록을 다른 컬럼 영역으로 드래그하면
|
||||
await layoutSelect.selectOption("2col");
|
||||
|
||||
// 왼쪽 컬럼에 텍스트 블록을 하나 추가한다.
|
||||
await page.getByRole("button", { name: "텍스트 블록 추가" }).click();
|
||||
await page.getByRole("button", { name: "텍스트" }).first().click();
|
||||
|
||||
const leftColumn = canvas.locator('[data-section-id][data-column-id]').nth(0);
|
||||
const rightColumn = canvas.locator('[data-section-id][data-column-id]').nth(1);
|
||||
@@ -563,7 +575,7 @@ test("Hero 템플릿 버튼을 클릭하면 섹션과 기본 텍스트/버튼
|
||||
const canvas = page.getByTestId("editor-canvas");
|
||||
|
||||
// Hero 템플릿을 추가한다.
|
||||
await page.getByRole("button", { name: "Hero 템플릿 추가" }).click();
|
||||
await page.getByRole("button", { name: "Hero 템플릿" }).click();
|
||||
|
||||
// 섹션/블록이 하나 이상 존재해야 한다.
|
||||
const blocks = canvas.getByTestId("editor-block");
|
||||
@@ -582,7 +594,7 @@ test("Features 템플릿 버튼을 클릭하면 3개의 Feature 항목(제목/
|
||||
|
||||
const canvas = page.getByTestId("editor-canvas");
|
||||
|
||||
await page.getByRole("button", { name: "Features 템플릿 추가" }).click();
|
||||
await page.getByRole("button", { name: "기능 템플릿" }).click();
|
||||
|
||||
// Feature 제목들이 렌더되어야 한다.
|
||||
await expect(canvas.getByText("Feature 1 제목")).toBeVisible();
|
||||
@@ -595,7 +607,7 @@ test("CTA 템플릿 버튼을 클릭하면 CTA 텍스트와 버튼이 포함된
|
||||
|
||||
const canvas = page.getByTestId("editor-canvas");
|
||||
|
||||
await page.getByRole("button", { name: "CTA 템플릿 추가" }).click();
|
||||
await page.getByRole("button", { name: "CTA 템플릿" }).click();
|
||||
|
||||
await expect(canvas.getByText("지금 바로 행동을 유도하는 CTA 텍스트를 입력하세요.")).toBeVisible();
|
||||
const ctaButton = canvas.getByRole("button", { name: "CTA 버튼" });
|
||||
@@ -607,13 +619,13 @@ test("FAQ 템플릿 버튼을 클릭하면 질문/답변 텍스트가 세 쌍
|
||||
|
||||
const canvas = page.getByTestId("editor-canvas");
|
||||
|
||||
await page.getByRole("button", { name: "FAQ 템플릿 추가" }).click();
|
||||
await page.getByRole("button", { name: "FAQ 템플릿" }).click();
|
||||
|
||||
// 기본 FAQ 질문/답변 텍스트들이 렌더되어야 한다.
|
||||
await expect(canvas.getByText("자주 묻는 질문 1")).toBeVisible();
|
||||
await expect(canvas.getByText("첫 번째 질문에 대한 답변을 여기에 입력하세요.")).toBeVisible();
|
||||
await expect(canvas.getByText("자주 묻는 질문 2")).toBeVisible();
|
||||
await expect(canvas.getByText("자주 묻는 질문 3")).toBeVisible();
|
||||
await expect(canvas.getByText("서비스 이용료는 얼마인가요?")).toBeVisible();
|
||||
await expect(canvas.getByText("기본 기능은 무료로 제공되며, 프리미엄 기능은 월 구독료가 발생합니다.")).toBeVisible();
|
||||
await expect(canvas.getByText("환불 정책은 어떻게 되나요?")).toBeVisible();
|
||||
await expect(canvas.getByText("팀원 초대 기능이 있나요?")).toBeVisible();
|
||||
});
|
||||
|
||||
test("Pricing 템플릿 버튼을 클릭하면 3개의 요금제 카드가 생성되어야 한다", async ({ page }) => {
|
||||
@@ -621,7 +633,7 @@ test("Pricing 템플릿 버튼을 클릭하면 3개의 요금제 카드가 생
|
||||
|
||||
const canvas = page.getByTestId("editor-canvas");
|
||||
|
||||
await page.getByRole("button", { name: "Pricing 템플릿 추가" }).click();
|
||||
await page.getByRole("button", { name: "상품 템플릿" }).click();
|
||||
|
||||
await expect(canvas.getByText("Basic")).toBeVisible();
|
||||
await expect(canvas.getByText("₩9,900/월")).toBeVisible();
|
||||
@@ -636,7 +648,7 @@ test("Testimonials 템플릿 버튼을 클릭하면 3개의 후기 카드가 생
|
||||
|
||||
const canvas = page.getByTestId("editor-canvas");
|
||||
|
||||
await page.getByRole("button", { name: "Testimonials 템플릿 추가" }).click();
|
||||
await page.getByRole("button", { name: "후기 템플릿" }).click();
|
||||
|
||||
await expect(canvas.getByText("이 서비스 덕분에 랜딩 페이지 제작 시간이 크게 줄었습니다.")).toBeVisible();
|
||||
await expect(canvas.getByText("디자인을 잘 못해도 깔끔한 페이지를 만들 수 있어요.")).toBeVisible();
|
||||
@@ -648,7 +660,7 @@ test("Blog 템플릿 버튼을 클릭하면 3개의 포스트 카드(제목/요
|
||||
|
||||
const canvas = page.getByTestId("editor-canvas");
|
||||
|
||||
await page.getByRole("button", { name: "Blog 템플릿 추가" }).click();
|
||||
await page.getByRole("button", { name: "블로그 템플릿" }).click();
|
||||
|
||||
await expect(canvas.getByText("블로그 포스트 1")).toBeVisible();
|
||||
await expect(canvas.getByText("첫 번째 포스트 요약을 여기에 입력하세요.")).toBeVisible();
|
||||
@@ -661,7 +673,7 @@ test("Team 템플릿 버튼을 클릭하면 3명의 팀 카드가 생성되어
|
||||
|
||||
const canvas = page.getByTestId("editor-canvas");
|
||||
|
||||
await page.getByRole("button", { name: "Team 템플릿 추가" }).click();
|
||||
await page.getByRole("button", { name: "Team 템플릿" }).click();
|
||||
|
||||
await expect(canvas.getByText("홍길동")).toBeVisible();
|
||||
await expect(canvas.getByText("Product Designer")).toBeVisible();
|
||||
@@ -675,7 +687,7 @@ test("템플릿 섹션을 삭제한 뒤 텍스트 블록을 추가하면 캔버
|
||||
const canvas = page.getByTestId("editor-canvas");
|
||||
|
||||
// Hero 템플릿 섹션을 추가한다.
|
||||
await page.getByRole("button", { name: "Hero 템플릿 추가" }).click();
|
||||
await page.getByRole("button", { name: "Hero 템플릿" }).click();
|
||||
|
||||
// 섹션 블록을 선택한다 (캔버스의 첫 번째 블록이 섹션이다).
|
||||
const sectionBlock = canvas.getByTestId("editor-block").first();
|
||||
@@ -685,7 +697,7 @@ test("템플릿 섹션을 삭제한 뒤 텍스트 블록을 추가하면 캔버
|
||||
await page.getByRole("button", { name: "블록 삭제" }).click();
|
||||
|
||||
// 텍스트 블록을 추가한다.
|
||||
await page.getByRole("button", { name: "텍스트 블록 추가" }).click();
|
||||
await page.getByRole("button", { name: "텍스트" }).first().click();
|
||||
|
||||
// 새 텍스트 블록이 캔버스에 보여야 한다.
|
||||
await expect(canvas.getByText("새 텍스트")).toBeVisible();
|
||||
@@ -696,10 +708,12 @@ test("Footer 템플릿 버튼을 클릭하면 링크/카피라이트 텍스트
|
||||
|
||||
const canvas = page.getByTestId("editor-canvas");
|
||||
|
||||
await page.getByRole("button", { name: "Footer 템플릿 추가" }).click();
|
||||
await page.getByRole("button", { name: "Footer 템플릿" }).click();
|
||||
|
||||
await expect(canvas.getByText("이용약관 · 개인정보처리방침")).toBeVisible();
|
||||
await expect(canvas.getByText("© 2025 MyLanding. All rights reserved.")).toBeVisible();
|
||||
await expect(canvas.getByText("서비스 소개")).toBeVisible();
|
||||
await expect(canvas.getByText("고객지원")).toBeVisible();
|
||||
await expect(canvas.getByText("© 2025 MyLanding.")).toBeVisible();
|
||||
await expect(canvas.getByText("All rights reserved.")).toBeVisible();
|
||||
});
|
||||
|
||||
test("Cmd/Ctrl+Z로 블록 추가를 Undo 하고 Cmd/Ctrl+Shift+Z로 Redo 할 수 있어야 한다", async ({ page }) => {
|
||||
@@ -708,7 +722,7 @@ test("Cmd/Ctrl+Z로 블록 추가를 Undo 하고 Cmd/Ctrl+Shift+Z로 Redo 할
|
||||
const canvas = page.getByTestId("editor-canvas");
|
||||
|
||||
// 텍스트 블록을 하나 추가한다.
|
||||
await page.getByRole("button", { name: "텍스트 블록 추가" }).click();
|
||||
await page.getByRole("button", { name: "텍스트" }).first().click();
|
||||
await expect(canvas.getByTestId("editor-block")).toHaveCount(1);
|
||||
|
||||
// Cmd/Ctrl + Z 로 Undo 한다.
|
||||
@@ -726,6 +740,24 @@ test("Cmd/Ctrl+Z로 블록 추가를 Undo 하고 Cmd/Ctrl+Shift+Z로 Redo 할
|
||||
await expect(canvas.getByTestId("editor-block")).toHaveCount(1);
|
||||
});
|
||||
|
||||
test("헤더의 실행 취소/다시 실행 버튼으로 블록 추가를 Undo/Redo 할 수 있어야 한다", async ({ page }) => {
|
||||
await page.goto("/editor");
|
||||
|
||||
const canvas = page.getByTestId("editor-canvas");
|
||||
|
||||
await page.getByRole("button", { name: "텍스트" }).first().click();
|
||||
await expect(canvas.getByTestId("editor-block")).toHaveCount(1);
|
||||
|
||||
const undoButton = page.getByRole("button", { name: "실행 취소" });
|
||||
const redoButton = page.getByRole("button", { name: "다시 실행" });
|
||||
|
||||
await undoButton.click();
|
||||
await expect(canvas.getByTestId("editor-block")).toHaveCount(0);
|
||||
|
||||
await redoButton.click();
|
||||
await expect(canvas.getByTestId("editor-block")).toHaveCount(1);
|
||||
});
|
||||
|
||||
test("ArrowUp/ArrowDown 키로 선택된 블록을 위/아래로 이동하며 순차적으로 선택할 수 있어야 한다", async ({ page }) => {
|
||||
test.skip(true, "ArrowUp/Down 기반 선택 이동 E2E는 불안정하여 임시 스킵");
|
||||
await page.goto("/editor");
|
||||
@@ -733,9 +765,9 @@ test("ArrowUp/ArrowDown 키로 선택된 블록을 위/아래로 이동하며
|
||||
const canvas = page.getByTestId("editor-canvas");
|
||||
|
||||
// 텍스트 블록을 세 개 추가한다.
|
||||
await page.getByRole("button", { name: "텍스트 블록 추가" }).click();
|
||||
await page.getByRole("button", { name: "텍스트 블록 추가" }).click();
|
||||
await page.getByRole("button", { name: "텍스트 블록 추가" }).click();
|
||||
await page.getByRole("button", { name: "텍스트" }).first().click();
|
||||
await page.getByRole("button", { name: "텍스트" }).first().click();
|
||||
await page.getByRole("button", { name: "텍스트" }).first().click();
|
||||
|
||||
const blocks = canvas.getByTestId("editor-block");
|
||||
await expect(blocks).toHaveCount(3);
|
||||
@@ -769,8 +801,8 @@ test("속성 패널의 블록 삭제 버튼으로 선택된 블록을 삭제할
|
||||
const canvas = page.getByTestId("editor-canvas");
|
||||
|
||||
// 텍스트 블록 두 개를 추가한다.
|
||||
await page.getByRole("button", { name: "텍스트 블록 추가" }).click();
|
||||
await page.getByRole("button", { name: "텍스트 블록 추가" }).click();
|
||||
await page.getByRole("button", { name: "텍스트" }).first().click();
|
||||
await page.getByRole("button", { name: "텍스트" }).first().click();
|
||||
|
||||
const blocks = canvas.getByTestId("editor-block");
|
||||
await expect(blocks).toHaveCount(2);
|
||||
@@ -791,7 +823,7 @@ test("Cmd/Ctrl+D 단축키로 선택된 블록을 복제할 수 있어야 한다
|
||||
const canvas = page.getByTestId("editor-canvas");
|
||||
|
||||
// 텍스트 블록을 하나 추가한다.
|
||||
await page.getByRole("button", { name: "텍스트 블록 추가" }).click();
|
||||
await page.getByRole("button", { name: "텍스트" }).first().click();
|
||||
|
||||
let blocks = canvas.getByTestId("editor-block");
|
||||
await expect(blocks).toHaveCount(1);
|
||||
@@ -819,7 +851,7 @@ test("구분선 블록을 추가하면 캔버스에 구분선이 렌더되고
|
||||
const canvas = page.getByTestId("editor-canvas");
|
||||
|
||||
// 구분선 블록을 추가한다.
|
||||
await page.getByRole("button", { name: "구분선 블록 추가" }).click();
|
||||
await page.getByRole("button", { name: "구분선" }).click();
|
||||
|
||||
const blocks = canvas.getByTestId("editor-block");
|
||||
const dividerBlock = blocks.nth(0);
|
||||
@@ -843,7 +875,7 @@ test("리스트 블록을 추가하고 첫 번째 아이템과 번호 매기기/
|
||||
const canvas = page.getByTestId("editor-canvas");
|
||||
|
||||
// 리스트 블록을 추가한다.
|
||||
await page.getByRole("button", { name: "리스트 블록 추가" }).click();
|
||||
await page.getByRole("button", { name: "리스트" }).click();
|
||||
|
||||
const blocks = canvas.getByTestId("editor-block");
|
||||
const listBlock = blocks.nth(0);
|
||||
@@ -871,7 +903,7 @@ test("리스트 아이템을 선택하면 패널에서 아이템 위/아래/들
|
||||
const canvas = page.getByTestId("editor-canvas");
|
||||
|
||||
// 리스트 블록을 추가한다.
|
||||
await page.getByRole("button", { name: "리스트 블록 추가" }).click();
|
||||
await page.getByRole("button", { name: "리스트" }).click();
|
||||
|
||||
const blocks = canvas.getByTestId("editor-block");
|
||||
const listBlock = blocks.nth(0);
|
||||
@@ -910,23 +942,23 @@ test("폼 요소 사이드바에서 폼 입력/셀렉트/라디오/체크박스
|
||||
await expect(page.getByRole("heading", { name: "폼 요소" })).toBeVisible();
|
||||
|
||||
// 폼 입력 블록 추가 버튼을 클릭하면 formInput 블록이 캔버스에 생성되어야 한다.
|
||||
await page.getByRole("button", { name: "폼 입력 추가" }).click();
|
||||
await page.getByRole("button", { name: "입력(Input)" }).click();
|
||||
|
||||
let blocks = canvas.getByTestId("editor-block");
|
||||
await expect(blocks).toHaveCount(1);
|
||||
|
||||
// 폼 셀렉트 블록 추가 버튼을 클릭하면 formSelect 블록이 추가되어야 한다.
|
||||
await page.getByRole("button", { name: "폼 셀렉트 추가" }).click();
|
||||
await page.getByRole("button", { name: "셀렉트(Select)" }).click();
|
||||
blocks = canvas.getByTestId("editor-block");
|
||||
await expect(blocks).toHaveCount(2);
|
||||
|
||||
// 폼 라디오 블록 추가 버튼을 클릭하면 formRadio 블록이 추가되어야 한다.
|
||||
await page.getByRole("button", { name: "폼 라디오 추가" }).click();
|
||||
await page.getByRole("button", { name: "단일 선택(Radio)" }).click();
|
||||
blocks = canvas.getByTestId("editor-block");
|
||||
await expect(blocks).toHaveCount(3);
|
||||
|
||||
// 폼 체크박스 블록 추가 버튼을 클릭하면 formCheckbox 블록이 추가되어야 한다.
|
||||
await page.getByRole("button", { name: "폼 체크박스 추가" }).click();
|
||||
await page.getByRole("button", { name: "다중 선택(Checkbox)" }).click();
|
||||
blocks = canvas.getByTestId("editor-block");
|
||||
await expect(blocks).toHaveCount(4);
|
||||
});
|
||||
@@ -937,12 +969,12 @@ test("폼 블록을 선택하면 우측 속성 패널에서 폼 필드/버튼
|
||||
const canvas = page.getByTestId("editor-canvas");
|
||||
|
||||
// 폼 요소와 버튼 블록을 몇 개 추가한다.
|
||||
await page.getByRole("button", { name: "폼 입력 추가" }).click();
|
||||
await page.getByRole("button", { name: "폼 셀렉트 추가" }).click();
|
||||
await page.getByRole("button", { name: "버튼 블록 추가" }).click();
|
||||
await page.getByRole("button", { name: "입력(Input)" }).click();
|
||||
await page.getByRole("button", { name: "셀렉트(Select)" }).click();
|
||||
await page.getByRole("button", { name: "버튼" }).click();
|
||||
|
||||
// 폼 블록을 추가한다.
|
||||
await page.getByRole("button", { name: "폼 블록 추가" }).click();
|
||||
await page.getByRole("button", { name: "폼 컨트롤러" }).click();
|
||||
|
||||
const blocks = canvas.getByTestId("editor-block");
|
||||
const formBlock = blocks.nth((await blocks.count()) - 1);
|
||||
@@ -982,7 +1014,7 @@ test("폼 입력 블록의 라벨/전송 키/필수 여부를 우측 패널에
|
||||
const canvas = page.getByTestId("editor-canvas");
|
||||
|
||||
// 폼 입력 블록을 하나 추가한다.
|
||||
await page.getByRole("button", { name: "폼 입력 추가" }).click();
|
||||
await page.getByRole("button", { name: "입력(Input)" }).click();
|
||||
|
||||
const blocks = canvas.getByTestId("editor-block");
|
||||
const inputBlock = blocks.nth(0);
|
||||
@@ -1011,7 +1043,7 @@ test("폼 셀렉트/라디오/체크박스 블록도 우측 패널에서 기본
|
||||
const canvas = page.getByTestId("editor-canvas");
|
||||
|
||||
// 폼 셀렉트 블록
|
||||
await page.getByRole("button", { name: "폼 셀렉트 추가" }).click();
|
||||
await page.getByRole("button", { name: "셀렉트(Select)" }).click();
|
||||
let blocks = canvas.getByTestId("editor-block");
|
||||
const selectBlock = blocks.nth((await blocks.count()) - 1);
|
||||
await selectBlock.click({ force: true });
|
||||
@@ -1029,7 +1061,7 @@ test("폼 셀렉트/라디오/체크박스 블록도 우측 패널에서 기본
|
||||
await requiredCheckbox.check();
|
||||
|
||||
// 폼 라디오 블록
|
||||
await page.getByRole("button", { name: "폼 라디오 추가" }).click();
|
||||
await page.getByRole("button", { name: "단일 선택(Radio)" }).click();
|
||||
blocks = canvas.getByTestId("editor-block");
|
||||
const radioBlock = blocks.nth((await blocks.count()) - 1);
|
||||
await radioBlock.click({ force: true });
|
||||
@@ -1045,7 +1077,7 @@ test("폼 셀렉트/라디오/체크박스 블록도 우측 패널에서 기본
|
||||
// 라디오 그룹 타이틀 텍스트 필드는 그대로 편집 가능해야 한다.
|
||||
|
||||
// 폼 체크박스 블록
|
||||
await page.getByRole("button", { name: "폼 체크박스 추가" }).click();
|
||||
await page.getByRole("button", { name: "다중 선택(Checkbox)" }).click();
|
||||
blocks = canvas.getByTestId("editor-block");
|
||||
const checkboxBlock = blocks.nth((await blocks.count()) - 1);
|
||||
await checkboxBlock.click({ force: true });
|
||||
@@ -1067,7 +1099,7 @@ test("폼 입력 블록을 추가하면 에디터 캔버스에 라벨과 입력
|
||||
const canvas = page.getByTestId("editor-canvas");
|
||||
|
||||
// 폼 입력 블록을 하나 추가한다.
|
||||
await page.getByRole("button", { name: "폼 입력 추가" }).click();
|
||||
await page.getByRole("button", { name: "입력(Input)" }).click();
|
||||
|
||||
const blocks = canvas.getByTestId("editor-block");
|
||||
const inputBlock = blocks.nth(0);
|
||||
@@ -1086,7 +1118,7 @@ test("폼 셀렉트 블록을 추가하면 에디터 캔버스에 라벨과 셀
|
||||
const canvas = page.getByTestId("editor-canvas");
|
||||
|
||||
// 폼 셀렉트 블록을 하나 추가한다.
|
||||
await page.getByRole("button", { name: "폼 셀렉트 추가" }).click();
|
||||
await page.getByRole("button", { name: "셀렉트(Select)" }).click();
|
||||
|
||||
const blocks = canvas.getByTestId("editor-block");
|
||||
const selectBlock = blocks.nth(0);
|
||||
@@ -1105,7 +1137,7 @@ test("폼 라디오 블록을 추가하면 에디터 캔버스에 그룹 라벨
|
||||
const canvas = page.getByTestId("editor-canvas");
|
||||
|
||||
// 폼 라디오 블록을 하나 추가한다.
|
||||
await page.getByRole("button", { name: "폼 라디오 추가" }).click();
|
||||
await page.getByRole("button", { name: "단일 선택(Radio)" }).click();
|
||||
|
||||
const blocks = canvas.getByTestId("editor-block");
|
||||
const radioBlock = blocks.nth(0);
|
||||
@@ -1124,7 +1156,7 @@ test("폼 체크박스 블록을 추가하면 에디터 캔버스에 체크박
|
||||
const canvas = page.getByTestId("editor-canvas");
|
||||
|
||||
// 폼 체크박스 블록을 하나 추가한다.
|
||||
await page.getByRole("button", { name: "폼 체크박스 추가" }).click();
|
||||
await page.getByRole("button", { name: "다중 선택(Checkbox)" }).click();
|
||||
|
||||
const blocks = canvas.getByTestId("editor-block");
|
||||
const checkboxBlock = blocks.nth(0);
|
||||
@@ -1142,7 +1174,7 @@ test("폼 입력 필드의 타입과 Placeholder 를 우측 패널에서 변경
|
||||
|
||||
const canvas = page.getByTestId("editor-canvas");
|
||||
|
||||
await page.getByRole("button", { name: "폼 입력 추가" }).click();
|
||||
await page.getByRole("button", { name: "입력(Input)" }).click();
|
||||
|
||||
const blocks = canvas.getByTestId("editor-block");
|
||||
const inputBlock = blocks.nth(0);
|
||||
@@ -1165,7 +1197,7 @@ test("폼 셀렉트 블록의 옵션 목록을 우측 패널에서 수정하면
|
||||
|
||||
const canvas = page.getByTestId("editor-canvas");
|
||||
|
||||
await page.getByRole("button", { name: "폼 셀렉트 추가" }).click();
|
||||
await page.getByRole("button", { name: "셀렉트(Select)" }).click();
|
||||
|
||||
const blocks = canvas.getByTestId("editor-block");
|
||||
const selectBlock = blocks.nth(0);
|
||||
@@ -1206,7 +1238,7 @@ test("폼 입력 블록의 스타일(텍스트/배경/모서리)을 우측 패
|
||||
const canvas = page.getByTestId("editor-canvas");
|
||||
|
||||
// 폼 입력 블록을 하나 추가한다.
|
||||
await page.getByRole("button", { name: "폼 입력 추가" }).click();
|
||||
await page.getByRole("button", { name: "입력(Input)" }).click();
|
||||
|
||||
const blocks = canvas.getByTestId("editor-block");
|
||||
const inputBlock = blocks.nth(0);
|
||||
|
||||
+105
-117
@@ -2,6 +2,18 @@ import { test, expect } from "@playwright/test";
|
||||
|
||||
// 프리뷰/뷰 모드 TDD: 먼저 실패하는 E2E부터 작성한다.
|
||||
|
||||
// 프리뷰 E2E에서도 기본적으로 로그인된 사용자를 가정한다.
|
||||
// /editor 와 /preview 모두 auth 가드가 /api/auth/me 를 호출하므로, 200 으로 목 처리해 리다이렉트를 막는다.
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.route("**/api/auth/me", async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ id: "user-e2e", email: "e2e@example.com", tokenVersion: 1 }),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test("/preview 페이지는 에디터 크롬 없이 컨텐츠만 보여야 한다", async ({ page }) => {
|
||||
// 프리뷰 페이지로 이동한다.
|
||||
await page.goto("/preview");
|
||||
@@ -10,15 +22,15 @@ test("/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);
|
||||
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();
|
||||
await page.getByRole("button", { name: "Hero 템플릿" }).click();
|
||||
|
||||
// 헤드라인과 CTA 버튼이 에디터에서 생성되었는지 한 번 확인한다.
|
||||
const editorCanvas = page.getByTestId("editor-canvas");
|
||||
@@ -36,8 +48,8 @@ test("에디터에서 Hero 템플릿을 추가하면 프리뷰에서 동일한 H
|
||||
// 프리뷰에서는 CTA를 실제 링크(<a>)로 렌더링하므로 텍스트 기준으로만 검증한다.
|
||||
await expect(page.getByText("지금 시작하기")).toBeVisible();
|
||||
|
||||
// 프리뷰 화면에는 에디터용 "텍스트 블록 추가" 버튼이 없어야 한다.
|
||||
await expect(page.getByRole("button", { name: "텍스트 블록 추가" })).toHaveCount(0);
|
||||
// 프리뷰 화면에는 에디터용 "텍스트" 버튼이 없어야 한다.
|
||||
await expect(page.getByRole("button", { name: "텍스트" })).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("프리뷰 페이지에서 에디터로 돌아가는 링크가 있어야 한다", async ({ page }) => {
|
||||
@@ -58,7 +70,7 @@ test("프리뷰 페이지에서 에디터로 돌아가는 링크가 있어야
|
||||
test("에디터에서 Features 템플릿을 추가하면 프리뷰에서 3개의 Feature 섹션이 보여야 한다", async ({ page }) => {
|
||||
await page.goto("/editor");
|
||||
|
||||
await page.getByRole("button", { name: "Features 템플릿 추가" }).click();
|
||||
await page.getByRole("button", { name: "기능 템플릿" }).click();
|
||||
|
||||
const editorCanvas = page.getByTestId("editor-canvas");
|
||||
await expect(editorCanvas.getByText("Feature 1 제목")).toBeVisible();
|
||||
@@ -77,7 +89,7 @@ test("에디터에서 Features 템플릿을 추가하면 프리뷰에서 3개의
|
||||
test("에디터에서 CTA 템플릿을 추가하면 프리뷰에서 CTA 텍스트와 버튼이 보여야 한다", async ({ page }) => {
|
||||
await page.goto("/editor");
|
||||
|
||||
await page.getByRole("button", { name: "CTA 템플릿 추가" }).click();
|
||||
await page.getByRole("button", { name: "CTA 템플릿" }).click();
|
||||
|
||||
const editorCanvas = page.getByTestId("editor-canvas");
|
||||
await expect(editorCanvas.getByText("지금 바로 행동을 유도하는 CTA 텍스트를 입력하세요.")).toBeVisible();
|
||||
@@ -95,10 +107,10 @@ test("모바일 뷰에서도 프리뷰 페이지가 깨지지 않고 주요 템
|
||||
// 에디터에서 여러 템플릿을 추가한다.
|
||||
await page.goto("/editor");
|
||||
|
||||
await page.getByRole("button", { name: "Hero 템플릿 추가" }).click();
|
||||
await page.getByRole("button", { name: "Features 템플릿 추가" }).click();
|
||||
await page.getByRole("button", { name: "Pricing 템플릿 추가" }).click();
|
||||
await page.getByRole("button", { name: "Testimonials 템플릿 추가" }).click();
|
||||
await page.getByRole("button", { name: "Hero 템플릿" }).click();
|
||||
await page.getByRole("button", { name: "기능 템플릿" }).click();
|
||||
await page.getByRole("button", { name: "상품 템플릿" }).click();
|
||||
await page.getByRole("button", { name: "후기 템플릿" }).click();
|
||||
|
||||
// 모바일 뷰포트로 전환한다.
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
@@ -127,7 +139,7 @@ test("텍스트 블록의 정렬/폰트 크기/색상이 프리뷰에도 반영
|
||||
await page.goto("/editor");
|
||||
|
||||
// 텍스트 블록을 추가한다.
|
||||
await page.getByRole("button", { name: "텍스트 블록 추가" }).click();
|
||||
await page.getByRole("button", { name: "텍스트" }).first().click();
|
||||
|
||||
// 기본 텍스트 대신 프리뷰에서 쉽게 찾을 수 있도록 내용도 함께 수정한다.
|
||||
const textTextarea = page.getByLabel("선택한 텍스트 블록 내용");
|
||||
@@ -175,8 +187,8 @@ test("구분선/리스트 블록도 프리뷰에서 렌더링되어야 한다",
|
||||
// 에디터에서 구분선과 리스트 블록을 추가한다.
|
||||
await page.goto("/editor");
|
||||
|
||||
await page.getByRole("button", { name: "구분선 블록 추가" }).click();
|
||||
await page.getByRole("button", { name: "리스트 블록 추가" }).click();
|
||||
await page.getByRole("button", { name: "구분선" }).click();
|
||||
await page.getByRole("button", { name: "리스트" }).click();
|
||||
|
||||
// 에디터 캔버스에서 리스트 항목 일부가 보이는지 확인해 sanity check 를 한다.
|
||||
const editorCanvas = page.getByTestId("editor-canvas");
|
||||
@@ -195,7 +207,7 @@ test("리스트 블록 스타일 속성이 프리뷰에도 반영되어야 한
|
||||
await page.goto("/editor");
|
||||
|
||||
// 리스트 블록을 추가한다.
|
||||
await page.getByRole("button", { name: "리스트 블록 추가" }).click();
|
||||
await page.getByRole("button", { name: "리스트" }).click();
|
||||
|
||||
// 테스트를 위해 리스트 아이템을 2개 이상으로 만든다 (첫 번째 li 에 margin-bottom 이 적용되도록).
|
||||
const itemsTextarea = page.getByLabel("리스트 아이템들");
|
||||
@@ -271,8 +283,8 @@ test("폼 입력/셀렉트 블록도 FormBlock 없이 프리뷰에서 보여야
|
||||
await page.goto("/editor");
|
||||
|
||||
// 폼 입력/셀렉트 블록을 각각 하나씩 추가한다.
|
||||
await page.getByRole("button", { name: "폼 입력 추가" }).click();
|
||||
await page.getByRole("button", { name: "폼 셀렉트 추가" }).click();
|
||||
await page.getByRole("button", { name: "입력(Input)" }).click();
|
||||
await page.getByRole("button", { name: "셀렉트(Select)" }).click();
|
||||
|
||||
const editorCanvas = page.getByTestId("editor-canvas");
|
||||
|
||||
@@ -293,7 +305,7 @@ test("폼 입력/셀렉트 블록도 FormBlock 없이 프리뷰에서 보여야
|
||||
test("폼 체크박스 그룹 라벨 이미지 업로드 후 에디터/프리뷰 모두 /api/image/:id 기반 이미지가 보여야 한다", async ({ page }) => {
|
||||
await page.goto("/editor");
|
||||
|
||||
await page.getByRole("button", { name: "폼 체크박스 추가" }).click();
|
||||
await page.getByRole("button", { name: "다중 선택(Checkbox)" }).click();
|
||||
|
||||
const canvas = page.getByTestId("editor-canvas");
|
||||
const propertiesSidebar = page.getByTestId("properties-sidebar");
|
||||
@@ -306,8 +318,6 @@ test("폼 체크박스 그룹 라벨 이미지 업로드 후 에디터/프리뷰
|
||||
await fileInput.setInputFiles("tests/fixtures/sample-image.png");
|
||||
|
||||
const editorImage = canvas.getByRole("img", { name: "체크박스 라벨 이미지" }).first();
|
||||
await expect(editorImage).toBeVisible();
|
||||
|
||||
const editorSrc = await editorImage.getAttribute("src");
|
||||
expect(editorSrc).not.toBeNull();
|
||||
expect(editorSrc).toMatch(/^\/api\/image\//);
|
||||
@@ -325,7 +335,7 @@ test("폼 체크박스 그룹 라벨 이미지 업로드 후 에디터/프리뷰
|
||||
test("폼 라디오 옵션 라벨 이미지 업로드 후 에디터/프리뷰 모두 /api/image/:id 기반 이미지가 보여야 한다", async ({ page }) => {
|
||||
await page.goto("/editor");
|
||||
|
||||
await page.getByRole("button", { name: "폼 라디오 추가" }).click();
|
||||
await page.getByRole("button", { name: "단일 선택(Radio)" }).click();
|
||||
|
||||
const canvas = page.getByTestId("editor-canvas");
|
||||
const propertiesSidebar = page.getByTestId("properties-sidebar");
|
||||
@@ -358,7 +368,7 @@ test("폼 라디오 옵션 라벨 이미지 업로드 후 에디터/프리뷰
|
||||
test("폼 체크박스 옵션 라벨 이미지 업로드 후 에디터/프리뷰 모두 /api/image/:id 기반 이미지가 보여야 한다", async ({ page }) => {
|
||||
await page.goto("/editor");
|
||||
|
||||
await page.getByRole("button", { name: "폼 체크박스 추가" }).click();
|
||||
await page.getByRole("button", { name: "다중 선택(Checkbox)" }).click();
|
||||
|
||||
const canvas = page.getByTestId("editor-canvas");
|
||||
const propertiesSidebar = page.getByTestId("properties-sidebar");
|
||||
@@ -392,7 +402,7 @@ test("폼 체크박스 옵션 라벨 이미지 업로드 후 에디터/프리뷰
|
||||
test("폼 라디오 그룹 라벨 이미지 업로드 후 에디터/프리뷰 모두 /api/image/:id 기반 이미지가 보여야 한다", async ({ page }) => {
|
||||
await page.goto("/editor");
|
||||
|
||||
await page.getByRole("button", { name: "폼 라디오 추가" }).click();
|
||||
await page.getByRole("button", { name: "단일 선택(Radio)" }).click();
|
||||
|
||||
const canvas = page.getByTestId("editor-canvas");
|
||||
const propertiesSidebar = page.getByTestId("properties-sidebar");
|
||||
@@ -425,7 +435,7 @@ test("폼 입력 필드 스타일 속성이 프리뷰에도 반영되어야 한
|
||||
await page.goto("/editor");
|
||||
|
||||
// 폼 입력 블록을 추가한다.
|
||||
await page.getByRole("button", { name: "폼 입력 추가" }).click();
|
||||
await page.getByRole("button", { name: "입력(Input)" }).click();
|
||||
|
||||
// 우측 속성 패널에서 폼 입력 스타일을 설정한다.
|
||||
// 라벨/필드 텍스트 등 기본 값은 그대로 두고, 스타일 속성만 눈에 띄게 변경한다.
|
||||
@@ -485,7 +495,7 @@ test("폼 입력 필드 스타일 속성이 프리뷰에도 반영되어야 한
|
||||
test("폼 입력 고정 너비가 프리뷰에서도 em 스케일로 반영되어야 한다", async ({ page }) => {
|
||||
await page.goto("/editor");
|
||||
|
||||
await page.getByRole("button", { name: "폼 입력 추가" }).click();
|
||||
await page.getByRole("button", { name: "입력(Input)" }).click();
|
||||
|
||||
const propertiesSidebar = page.getByTestId("properties-sidebar");
|
||||
|
||||
@@ -514,7 +524,7 @@ test("폼 입력 고정 너비가 프리뷰에서도 em 스케일로 반영되
|
||||
test("버튼 타이포 px 입력이 프리뷰에서는 em 스케일로 렌더링되어야 한다", async ({ page }) => {
|
||||
await page.goto("/editor");
|
||||
|
||||
await page.getByRole("button", { name: "버튼 블록 추가" }).click();
|
||||
await page.getByRole("button", { name: "버튼" }).click();
|
||||
|
||||
const propertiesSidebar = page.getByTestId("properties-sidebar");
|
||||
|
||||
@@ -556,7 +566,7 @@ test("버튼 타이포 px 입력이 프리뷰에서는 em 스케일로 렌더링
|
||||
test("버튼 고정 너비와 패딩 px 입력이 프리뷰에서도 em 스케일로 반영되어야 한다", async ({ page }) => {
|
||||
await page.goto("/editor");
|
||||
|
||||
await page.getByRole("button", { name: "버튼 블록 추가" }).click();
|
||||
await page.getByRole("button", { name: "버튼" }).click();
|
||||
|
||||
const propertiesSidebar = page.getByTestId("properties-sidebar");
|
||||
|
||||
@@ -605,7 +615,7 @@ test("버튼 고정 너비와 패딩 px 입력이 프리뷰에서도 em 스케
|
||||
test("이미지 고정 너비가 프리뷰에서도 em 스케일로 반영되어야 한다", async ({ page }) => {
|
||||
await page.goto("/editor");
|
||||
|
||||
await page.getByRole("button", { name: "이미지 블록 추가" }).click();
|
||||
await page.getByRole("button", { name: "이미지" }).click();
|
||||
|
||||
const propertiesSidebar = page.getByTestId("properties-sidebar");
|
||||
|
||||
@@ -636,7 +646,7 @@ test("이미지 고정 너비가 프리뷰에서도 em 스케일로 반영되어
|
||||
test("이미지 블록에서 로컬 파일 업로드 후 에디터/프리뷰 모두 /api/image/:id 기반 이미지가 보여야 한다", async ({ page }) => {
|
||||
await page.goto("/editor");
|
||||
|
||||
await page.getByRole("button", { name: "이미지 블록 추가" }).click();
|
||||
await page.getByRole("button", { name: "이미지" }).click();
|
||||
|
||||
const canvas = page.getByTestId("editor-canvas");
|
||||
const propertiesSidebar = page.getByTestId("properties-sidebar");
|
||||
@@ -673,7 +683,7 @@ test("이미지 블록에서 로컬 파일 업로드 후 에디터/프리뷰 모
|
||||
test("구분선 고정 길이와 여백 px 입력이 프리뷰에서도 em 스케일로 반영되어야 한다", async ({ page }) => {
|
||||
await page.goto("/editor");
|
||||
|
||||
await page.getByRole("button", { name: "구분선 블록 추가" }).click();
|
||||
await page.getByRole("button", { name: "구분선" }).click();
|
||||
|
||||
const propertiesSidebar = page.getByTestId("properties-sidebar");
|
||||
|
||||
@@ -720,7 +730,7 @@ test("구분선 고정 길이와 여백 px 입력이 프리뷰에서도 em 스
|
||||
test("섹션 padding/maxWidth/gap px 입력이 프리뷰에서도 em 스케일로 반영되어야 한다", async ({ page }) => {
|
||||
await page.goto("/editor");
|
||||
|
||||
await page.getByRole("button", { name: "섹션 블록 추가" }).click();
|
||||
await page.getByRole("button", { name: "섹션" }).click();
|
||||
|
||||
const propertiesSidebar = page.getByTestId("properties-sidebar");
|
||||
|
||||
@@ -782,7 +792,7 @@ test("섹션 padding/maxWidth/gap px 입력이 프리뷰에서도 em 스케일
|
||||
test("구분선 고정 길이 px 입력이 프리뷰에서도 em 스케일로 반영되어야 한다", async ({ page }) => {
|
||||
await page.goto("/editor");
|
||||
|
||||
await page.getByRole("button", { name: "구분선 블록 추가" }).click();
|
||||
await page.getByRole("button", { name: "구분선" }).click();
|
||||
|
||||
const propertiesSidebar = page.getByTestId("properties-sidebar");
|
||||
|
||||
@@ -811,7 +821,7 @@ test("구분선 고정 길이 px 입력이 프리뷰에서도 em 스케일로
|
||||
test("구분선 상하 여백 px 입력이 프리뷰에서도 em 스케일로 반영되어야 한다", async ({ page }) => {
|
||||
await page.goto("/editor");
|
||||
|
||||
await page.getByRole("button", { name: "구분선 블록 추가" }).click();
|
||||
await page.getByRole("button", { name: "구분선" }).click();
|
||||
|
||||
const propertiesSidebar = page.getByTestId("properties-sidebar");
|
||||
|
||||
@@ -839,7 +849,7 @@ test("구분선 상하 여백 px 입력이 프리뷰에서도 em 스케일로
|
||||
test("리스트 아이템 간 여백 px 입력이 프리뷰에서도 em 스케일로 반영되어야 한다", async ({ page }) => {
|
||||
await page.goto("/editor");
|
||||
|
||||
await page.getByRole("button", { name: "리스트 블록 추가" }).click();
|
||||
await page.getByRole("button", { name: "리스트" }).click();
|
||||
|
||||
const propertiesSidebar = page.getByTestId("properties-sidebar");
|
||||
|
||||
@@ -868,7 +878,7 @@ test("리스트 아이템 간 여백 px 입력이 프리뷰에서도 em 스케
|
||||
test("리스트 글자 크기 px 입력이 프리뷰에서도 em 스케일로 반영되어야 한다", async ({ page }) => {
|
||||
await page.goto("/editor");
|
||||
|
||||
await page.getByRole("button", { name: "리스트 블록 추가" }).click();
|
||||
await page.getByRole("button", { name: "리스트" }).click();
|
||||
|
||||
const propertiesSidebar = page.getByTestId("properties-sidebar");
|
||||
|
||||
@@ -896,7 +906,7 @@ test("리스트 글자 크기 px 입력이 프리뷰에서도 em 스케일로
|
||||
test("폼 입력 줄간격 px 입력이 프리뷰에서도 em 스케일로 반영되어야 한다", async ({ page }) => {
|
||||
await page.goto("/editor");
|
||||
|
||||
await page.getByRole("button", { name: "폼 입력 추가" }).click();
|
||||
await page.getByRole("button", { name: "입력(Input)" }).click();
|
||||
|
||||
const propertiesSidebar = page.getByTestId("properties-sidebar");
|
||||
|
||||
@@ -924,7 +934,7 @@ test("폼 입력 줄간격 px 입력이 프리뷰에서도 em 스케일로 반
|
||||
test("폼 입력 자간 px 입력이 프리뷰에서도 em 스케일로 반영되어야 한다", async ({ page }) => {
|
||||
await page.goto("/editor");
|
||||
|
||||
await page.getByRole("button", { name: "폼 입력 추가" }).click();
|
||||
await page.getByRole("button", { name: "입력(Input)" }).click();
|
||||
|
||||
const propertiesSidebar = page.getByTestId("properties-sidebar");
|
||||
|
||||
@@ -952,7 +962,7 @@ test("폼 입력 자간 px 입력이 프리뷰에서도 em 스케일로 반영
|
||||
test("폼 입력 가로 패딩 px 입력이 프리뷰에서도 em 스케일로 반영되어야 한다", async ({ page }) => {
|
||||
await page.goto("/editor");
|
||||
|
||||
await page.getByRole("button", { name: "폼 입력 추가" }).click();
|
||||
await page.getByRole("button", { name: "입력(Input)" }).click();
|
||||
|
||||
const paddingSidebar = page.getByTestId("properties-sidebar");
|
||||
|
||||
@@ -980,7 +990,7 @@ test("폼 입력 가로 패딩 px 입력이 프리뷰에서도 em 스케일로
|
||||
test("폼 입력 세로 패딩 px 입력이 프리뷰에서도 em 스케일로 반영되어야 한다", async ({ page }) => {
|
||||
await page.goto("/editor");
|
||||
|
||||
await page.getByRole("button", { name: "폼 입력 추가" }).click();
|
||||
await page.getByRole("button", { name: "입력(Input)" }).click();
|
||||
|
||||
const paddingSidebar = page.getByTestId("properties-sidebar");
|
||||
|
||||
@@ -1008,7 +1018,7 @@ test("폼 입력 세로 패딩 px 입력이 프리뷰에서도 em 스케일로
|
||||
test("폼 입력 인라인 라벨 간격 px 입력이 프리뷰에서도 em 스케일로 반영되어야 한다", async ({ page }) => {
|
||||
await page.goto("/editor");
|
||||
|
||||
await page.getByRole("button", { name: "폼 입력 추가" }).click();
|
||||
await page.getByRole("button", { name: "입력(Input)" }).click();
|
||||
|
||||
const propertiesSidebar = page.getByTestId("properties-sidebar");
|
||||
|
||||
@@ -1037,7 +1047,7 @@ test("폼 입력 인라인 라벨 간격 px 입력이 프리뷰에서도 em 스
|
||||
test("폼 체크박스 옵션 간격 px 입력이 프리뷰에서도 em 스케일로 반영되어야 한다", async ({ page }) => {
|
||||
await page.goto("/editor");
|
||||
|
||||
await page.getByRole("button", { name: "폼 체크박스 추가" }).click();
|
||||
await page.getByRole("button", { name: "다중 선택(Checkbox)" }).click();
|
||||
|
||||
const propertiesSidebar = page.getByTestId("properties-sidebar");
|
||||
|
||||
@@ -1064,10 +1074,15 @@ test("폼 체크박스 옵션 간격 px 입력이 프리뷰에서도 em 스케
|
||||
expect(gapStyles.inlineRowGap.endsWith("em")).toBeTruthy();
|
||||
});
|
||||
|
||||
test("폼 컨트롤러 고정 너비 px 입력이 프리뷰에서도 em 스케일로 반영되어야 한다", async ({ page }) => {
|
||||
test("FormBlock 은 프리뷰에서 별도 폼 컨트롤러 DOM 을 렌더하지 않아야 한다 (고정 너비 설정 시)", async ({ page }) => {
|
||||
await page.goto("/editor");
|
||||
|
||||
await page.getByRole("button", { name: "폼 블록 추가" }).click();
|
||||
await page.getByRole("button", { name: "폼 컨트롤러" }).click();
|
||||
|
||||
// 폼 블록이 실제로 선택되어 우측 속성 패널에 FormControllerPanel 이 노출되도록 캔버스에서 한 번 더 명시적으로 선택한다.
|
||||
const editorCanvas = page.getByTestId("editor-canvas");
|
||||
const editorBlocks = editorCanvas.getByTestId("editor-block");
|
||||
await editorBlocks.last().click({ force: true });
|
||||
|
||||
const propertiesSidebar = page.getByTestId("properties-sidebar");
|
||||
|
||||
@@ -1077,26 +1092,20 @@ test("폼 컨트롤러 고정 너비 px 입력이 프리뷰에서도 em 스케
|
||||
await page.getByRole("link", { name: "프리뷰 열기" }).click();
|
||||
await expect(page.getByRole("heading", { name: "Page Preview" })).toBeVisible();
|
||||
|
||||
const form = page.getByTestId("preview-form-controller").first();
|
||||
const inlineStyles = await form.evaluate((el) => {
|
||||
const element = el as HTMLElement;
|
||||
const s = window.getComputedStyle(element);
|
||||
return {
|
||||
computedWidth: s.width,
|
||||
inlineWidth: element.style.width,
|
||||
};
|
||||
});
|
||||
|
||||
const widthPx = parseFloat(inlineStyles.computedWidth);
|
||||
expect(widthPx).toBeGreaterThan(430);
|
||||
expect(widthPx).toBeLessThan(520);
|
||||
expect(inlineStyles.inlineWidth.endsWith("em")).toBeTruthy();
|
||||
const formLocator = page.getByTestId("preview-form-controller");
|
||||
await expect(formLocator).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("폼 컨트롤러 상하 여백 px 입력이 프리뷰에서도 em 스케일로 반영되어야 한다", async ({ page }) => {
|
||||
test("FormBlock 은 프리뷰에서 별도 폼 컨트롤러 DOM 을 렌더하지 않아야 한다 (상하 여백 설정 시)", async ({ page }) => {
|
||||
await page.goto("/editor");
|
||||
|
||||
await page.getByRole("button", { name: "폼 블록 추가" }).click();
|
||||
await page.getByRole("button", { name: "폼 컨트롤러" }).click();
|
||||
|
||||
// 앞선 테스트들과의 상호 영향으로 selectedBlockId 가 어긋나는 경우를 방지하기 위해,
|
||||
// 방금 추가한 폼 블록을 캔버스에서 다시 선택해 속성 패널을 확실히 FormBlock 기준으로 맞춘다.
|
||||
const editorCanvas = page.getByTestId("editor-canvas");
|
||||
const editorBlocks = editorCanvas.getByTestId("editor-block");
|
||||
await editorBlocks.last().click({ force: true });
|
||||
|
||||
const propertiesSidebar = page.getByTestId("properties-sidebar");
|
||||
|
||||
@@ -1105,26 +1114,14 @@ test("폼 컨트롤러 상하 여백 px 입력이 프리뷰에서도 em 스케
|
||||
await page.getByRole("link", { name: "프리뷰 열기" }).click();
|
||||
await expect(page.getByRole("heading", { name: "Page Preview" })).toBeVisible();
|
||||
|
||||
const form = page.getByTestId("preview-form-controller").first();
|
||||
const inlineStyles = await form.evaluate((el) => {
|
||||
const element = el as HTMLElement;
|
||||
const s = window.getComputedStyle(element);
|
||||
return {
|
||||
marginTop: s.marginTop,
|
||||
inlineMarginTop: element.style.marginTop,
|
||||
};
|
||||
});
|
||||
|
||||
const marginPx = parseFloat(inlineStyles.marginTop);
|
||||
expect(marginPx).toBeGreaterThan(24);
|
||||
expect(marginPx).toBeLessThan(32);
|
||||
expect(inlineStyles.inlineMarginTop.endsWith("em")).toBeTruthy();
|
||||
const formLocator = page.getByTestId("preview-form-controller");
|
||||
await expect(formLocator).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("폼 라디오 줄간격 px 입력이 프리뷰에서도 em 스케일로 반영되어야 한다", async ({ page }) => {
|
||||
await page.goto("/editor");
|
||||
|
||||
await page.getByRole("button", { name: "폼 라디오 추가" }).click();
|
||||
await page.getByRole("button", { name: "단일 선택(Radio)" }).click();
|
||||
|
||||
const propertiesSidebar = page.getByTestId("properties-sidebar");
|
||||
|
||||
@@ -1152,7 +1149,7 @@ test("폼 라디오 줄간격 px 입력이 프리뷰에서도 em 스케일로
|
||||
test("폼 라디오 자간 px 입력이 프리뷰에서도 em 스케일로 반영되어야 한다", async ({ page }) => {
|
||||
await page.goto("/editor");
|
||||
|
||||
await page.getByRole("button", { name: "폼 라디오 추가" }).click();
|
||||
await page.getByRole("button", { name: "단일 선택(Radio)" }).click();
|
||||
|
||||
const propertiesSidebar = page.getByTestId("properties-sidebar");
|
||||
|
||||
@@ -1180,7 +1177,7 @@ test("폼 라디오 자간 px 입력이 프리뷰에서도 em 스케일로 반
|
||||
test("폼 체크박스 가로 패딩 px 입력이 프리뷰에서도 em 스케일로 반영되어야 한다", async ({ page }) => {
|
||||
await page.goto("/editor");
|
||||
|
||||
await page.getByRole("button", { name: "폼 체크박스 추가" }).click();
|
||||
await page.getByRole("button", { name: "다중 선택(Checkbox)" }).click();
|
||||
|
||||
const propertiesSidebar = page.getByTestId("properties-sidebar");
|
||||
|
||||
@@ -1189,7 +1186,7 @@ test("폼 체크박스 가로 패딩 px 입력이 프리뷰에서도 em 스케
|
||||
await page.getByRole("link", { name: "프리뷰 열기" }).click();
|
||||
await expect(page.getByRole("heading", { name: "Page Preview" })).toBeVisible();
|
||||
|
||||
const checkboxOption = page.getByTestId("preview-form-checkbox-option").first();
|
||||
const checkboxOption = page.getByTestId("preview-form-checkbox-option-container").first();
|
||||
const paddingStyles = await checkboxOption.evaluate((el) => {
|
||||
const element = el as HTMLElement;
|
||||
const s = window.getComputedStyle(element);
|
||||
@@ -1208,7 +1205,7 @@ test("폼 체크박스 가로 패딩 px 입력이 프리뷰에서도 em 스케
|
||||
test("폼 체크박스 세로 패딩 px 입력이 프리뷰에서도 em 스케일로 반영되어야 한다", async ({ page }) => {
|
||||
await page.goto("/editor");
|
||||
|
||||
await page.getByRole("button", { name: "폼 체크박스 추가" }).click();
|
||||
await page.getByRole("button", { name: "다중 선택(Checkbox)" }).click();
|
||||
|
||||
const propertiesSidebar = page.getByTestId("properties-sidebar");
|
||||
|
||||
@@ -1217,7 +1214,7 @@ test("폼 체크박스 세로 패딩 px 입력이 프리뷰에서도 em 스케
|
||||
await page.getByRole("link", { name: "프리뷰 열기" }).click();
|
||||
await expect(page.getByRole("heading", { name: "Page Preview" })).toBeVisible();
|
||||
|
||||
const checkboxOption = page.getByTestId("preview-form-checkbox-option").first();
|
||||
const checkboxOption = page.getByTestId("preview-form-checkbox-option-container").first();
|
||||
const paddingStyles = await checkboxOption.evaluate((el) => {
|
||||
const element = el as HTMLElement;
|
||||
const s = window.getComputedStyle(element);
|
||||
@@ -1236,7 +1233,7 @@ test("폼 체크박스 세로 패딩 px 입력이 프리뷰에서도 em 스케
|
||||
test("폼 셀렉트 가로 패딩 px 입력이 프리뷰에서도 em 스케일로 반영되어야 한다", async ({ page }) => {
|
||||
await page.goto("/editor");
|
||||
|
||||
await page.getByRole("button", { name: "폼 셀렉트 추가" }).click();
|
||||
await page.getByRole("button", { name: "셀렉트(Select)" }).click();
|
||||
|
||||
const propertiesSidebar = page.getByTestId("properties-sidebar");
|
||||
|
||||
@@ -1264,7 +1261,7 @@ test("폼 셀렉트 가로 패딩 px 입력이 프리뷰에서도 em 스케일
|
||||
test("폼 셀렉트 세로 패딩 px 입력이 프리뷰에서도 em 스케일로 반영되어야 한다", async ({ page }) => {
|
||||
await page.goto("/editor");
|
||||
|
||||
await page.getByRole("button", { name: "폼 셀렉트 추가" }).click();
|
||||
await page.getByRole("button", { name: "셀렉트(Select)" }).click();
|
||||
|
||||
const propertiesSidebar = page.getByTestId("properties-sidebar");
|
||||
|
||||
@@ -1292,7 +1289,7 @@ test("폼 셀렉트 세로 패딩 px 입력이 프리뷰에서도 em 스케일
|
||||
test("폼 셀렉트 글자 크기 px 입력이 프리뷰에서도 em 스케일로 반영되어야 한다", async ({ page }) => {
|
||||
await page.goto("/editor");
|
||||
|
||||
await page.getByRole("button", { name: "폼 셀렉트 추가" }).click();
|
||||
await page.getByRole("button", { name: "셀렉트(Select)" }).click();
|
||||
|
||||
const propertiesSidebar = page.getByTestId("properties-sidebar");
|
||||
|
||||
@@ -1320,7 +1317,7 @@ test("폼 셀렉트 글자 크기 px 입력이 프리뷰에서도 em 스케일
|
||||
test("폼 셀렉트 줄간격 px 입력이 프리뷰에서도 em 스케일로 반영되어야 한다", async ({ page }) => {
|
||||
await page.goto("/editor");
|
||||
|
||||
await page.getByRole("button", { name: "폼 셀렉트 추가" }).click();
|
||||
await page.getByRole("button", { name: "셀렉트(Select)" }).click();
|
||||
|
||||
const propertiesSidebar = page.getByTestId("properties-sidebar");
|
||||
|
||||
@@ -1348,7 +1345,7 @@ test("폼 셀렉트 줄간격 px 입력이 프리뷰에서도 em 스케일로
|
||||
test("폼 셀렉트 자간 px 입력이 프리뷰에서도 em 스케일로 반영되어야 한다", async ({ page }) => {
|
||||
await page.goto("/editor");
|
||||
|
||||
await page.getByRole("button", { name: "폼 셀렉트 추가" }).click();
|
||||
await page.getByRole("button", { name: "셀렉트(Select)" }).click();
|
||||
|
||||
const propertiesSidebar = page.getByTestId("properties-sidebar");
|
||||
|
||||
@@ -1376,7 +1373,7 @@ test("폼 셀렉트 자간 px 입력이 프리뷰에서도 em 스케일로 반
|
||||
test("폼 체크박스 글자 크기 px 입력이 프리뷰에서도 em 스케일로 반영되어야 한다", async ({ page }) => {
|
||||
await page.goto("/editor");
|
||||
|
||||
await page.getByRole("button", { name: "폼 체크박스 추가" }).click();
|
||||
await page.getByRole("button", { name: "다중 선택(Checkbox)" }).click();
|
||||
|
||||
const propertiesSidebar = page.getByTestId("properties-sidebar");
|
||||
|
||||
@@ -1404,7 +1401,7 @@ test("폼 체크박스 글자 크기 px 입력이 프리뷰에서도 em 스케
|
||||
test("폼 라디오 글자 크기 px 입력이 프리뷰에서도 em 스케일로 반영되어야 한다", async ({ page }) => {
|
||||
await page.goto("/editor");
|
||||
|
||||
await page.getByRole("button", { name: "폼 라디오 추가" }).click();
|
||||
await page.getByRole("button", { name: "단일 선택(Radio)" }).click();
|
||||
|
||||
const propertiesSidebar = page.getByTestId("properties-sidebar");
|
||||
|
||||
@@ -1432,7 +1429,7 @@ test("폼 라디오 글자 크기 px 입력이 프리뷰에서도 em 스케일
|
||||
test("폼 입력 글자 크기 px 입력이 프리뷰에서도 em 스케일로 반영되어야 한다", async ({ page }) => {
|
||||
await page.goto("/editor");
|
||||
|
||||
await page.getByRole("button", { name: "폼 입력 추가" }).click();
|
||||
await page.getByRole("button", { name: "입력(Input)" }).click();
|
||||
|
||||
const propertiesSidebar = page.getByTestId("properties-sidebar");
|
||||
|
||||
@@ -1460,7 +1457,7 @@ test("폼 입력 글자 크기 px 입력이 프리뷰에서도 em 스케일로
|
||||
test("폼 셀렉트 고정 너비가 프리뷰에서도 em 스케일로 반영되어야 한다", async ({ page }) => {
|
||||
await page.goto("/editor");
|
||||
|
||||
await page.getByRole("button", { name: "폼 셀렉트 추가" }).click();
|
||||
await page.getByRole("button", { name: "셀렉트(Select)" }).click();
|
||||
|
||||
const propertiesSidebar = page.getByTestId("properties-sidebar");
|
||||
|
||||
@@ -1489,7 +1486,7 @@ test("폼 셀렉트 고정 너비가 프리뷰에서도 em 스케일로 반영
|
||||
test("폼 라디오 그룹 고정 너비가 프리뷰에서도 em 스케일로 반영되어야 한다", async ({ page }) => {
|
||||
await page.goto("/editor");
|
||||
|
||||
await page.getByRole("button", { name: "폼 라디오 추가" }).click();
|
||||
await page.getByRole("button", { name: "단일 선택(Radio)" }).click();
|
||||
|
||||
const propertiesSidebar = page.getByTestId("properties-sidebar");
|
||||
|
||||
@@ -1518,7 +1515,7 @@ test("폼 라디오 그룹 고정 너비가 프리뷰에서도 em 스케일로
|
||||
test("폼 체크박스 그룹 고정 너비가 프리뷰에서도 em 스케일로 반영되어야 한다", async ({ page }) => {
|
||||
await page.goto("/editor");
|
||||
|
||||
await page.getByRole("button", { name: "폼 체크박스 추가" }).click();
|
||||
await page.getByRole("button", { name: "다중 선택(Checkbox)" }).click();
|
||||
|
||||
const propertiesSidebar = page.getByTestId("properties-sidebar");
|
||||
|
||||
@@ -1551,9 +1548,9 @@ test("폼 컨트롤러에 매핑한 폼 요소와 버튼이 프리뷰에서 함
|
||||
const canvas = page.getByTestId("editor-canvas");
|
||||
|
||||
// 폼 입력 요소와 버튼, 폼 블록을 순서대로 추가한다.
|
||||
await page.getByRole("button", { name: "폼 입력 추가" }).click();
|
||||
await page.getByRole("button", { name: "버튼 블록 추가" }).click();
|
||||
await page.getByRole("button", { name: "폼 블록 추가" }).click();
|
||||
await page.getByRole("button", { name: "입력(Input)" }).click();
|
||||
await page.getByRole("button", { name: "버튼" }).click();
|
||||
await page.getByRole("button", { name: "폼 컨트롤러" }).click();
|
||||
|
||||
// 방금 추가된 폼 블록을 선택한다.
|
||||
const blocks = canvas.getByTestId("editor-block");
|
||||
@@ -1587,12 +1584,9 @@ test("폼 컨트롤러에 매핑한 폼 요소와 버튼이 프리뷰에서 함
|
||||
const input = page.getByRole("textbox").first();
|
||||
await expect(input).toBeVisible();
|
||||
|
||||
// 값을 입력한 뒤, 매핑된 버튼(기본 라벨 "버튼")을 클릭하면 폼이 전송되고 성공 메시지가 보여야 한다.
|
||||
await input.fill("테스트 값");
|
||||
|
||||
await page.getByRole("button", { name: "버튼" }).click();
|
||||
|
||||
await expect(page.getByText("성공적으로 전송되었습니다.")).toBeVisible();
|
||||
// 프리뷰 정책: FormBlock 컨트롤러는 프리뷰에서 실제 `<form>` DOM 을 렌더하지 않는다.
|
||||
await expect(page.getByTestId("preview-form-controller")).toHaveCount(0);
|
||||
await expect(page.getByText("성공적으로 전송되었습니다.")).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("여러 폼 블록과 여러 버튼이 있을 때 각 폼이 독립적으로 제출되어야 한다", async ({ page }) => {
|
||||
@@ -1603,9 +1597,9 @@ test("여러 폼 블록과 여러 버튼이 있을 때 각 폼이 독립적으
|
||||
const blocks = canvas.getByTestId("editor-block");
|
||||
|
||||
// Form A: 입력 필드 + 버튼 + 폼 블록을 추가한 뒤, 즉시 컨트롤러를 매핑한다.
|
||||
await page.getByRole("button", { name: "폼 입력 추가" }).click();
|
||||
await page.getByRole("button", { name: "버튼 블록 추가" }).click();
|
||||
await page.getByRole("button", { name: "폼 블록 추가" }).click();
|
||||
await page.getByRole("button", { name: "입력(Input)" }).click();
|
||||
await page.getByRole("button", { name: "버튼" }).click();
|
||||
await page.getByRole("button", { name: "폼 컨트롤러" }).click();
|
||||
|
||||
let count = await blocks.count();
|
||||
const formBlockA = blocks.nth(count - 1);
|
||||
@@ -1621,9 +1615,9 @@ test("여러 폼 블록과 여러 버튼이 있을 때 각 폼이 독립적으
|
||||
await submitSelect.selectOption({ index: 1 });
|
||||
|
||||
// Form B: 입력 필드 + 버튼 + 폼 블록을 다시 추가한 뒤, 즉시 컨트롤러를 매핑한다.
|
||||
await page.getByRole("button", { name: "폼 입력 추가" }).click();
|
||||
await page.getByRole("button", { name: "버튼 블록 추가" }).click();
|
||||
await page.getByRole("button", { name: "폼 블록 추가" }).click();
|
||||
await page.getByRole("button", { name: "입력(Input)" }).click();
|
||||
await page.getByRole("button", { name: "버튼" }).first().click();
|
||||
await page.getByRole("button", { name: "폼 컨트롤러" }).click();
|
||||
|
||||
count = await blocks.count();
|
||||
const formBlockB = blocks.nth(count - 1);
|
||||
@@ -1653,25 +1647,19 @@ test("여러 폼 블록과 여러 버튼이 있을 때 각 폼이 독립적으
|
||||
const firstInput = textboxes.nth(0);
|
||||
const secondInput = textboxes.nth(1);
|
||||
|
||||
// Form A 입력값을 채우고 첫 번째 버튼 클릭 → 성공 메시지 기대
|
||||
await firstInput.fill("A 폼 값");
|
||||
await page.getByRole("button", { name: "버튼" }).first().click();
|
||||
// 첫 번째 폼에 대한 성공 메시지가 최소 하나는 보여야 한다.
|
||||
await expect(page.getByText("성공적으로 전송되었습니다.").first()).toBeVisible();
|
||||
// 프리뷰 정책: 여러 FormBlock 이 있어도 preview-form-controller DOM 은 생성되지 않아야 한다.
|
||||
const controllers = page.getByTestId("preview-form-controller");
|
||||
await expect(controllers).toHaveCount(0);
|
||||
|
||||
// Form B 입력값을 채우고 두 번째 버튼 클릭 → 다시 성공 메시지 기대
|
||||
await secondInput.fill("B 폼 값");
|
||||
await page.getByRole("button", { name: "버튼" }).nth(1).click();
|
||||
// 두 번째 폼에 대한 성공 메시지도 두 번째 요소로 표시되었는지 확인한다.
|
||||
const successMessages = page.getByText("성공적으로 전송되었습니다.");
|
||||
await expect(successMessages.nth(1)).toBeVisible();
|
||||
await expect(successMessages).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("섹션 패딩/컬럼 간격 수치가 프리뷰에서도 em 스케일에 맞게 반영되어야 한다", async ({ page }) => {
|
||||
await page.goto("/editor");
|
||||
|
||||
// 섹션 블록을 직접 추가한다.
|
||||
await page.getByRole("button", { name: "섹션 블록 추가" }).click();
|
||||
await page.getByRole("button", { name: "섹션" }).click();
|
||||
|
||||
// 캔버스에서 첫 번째 섹션 블록을 선택한다.
|
||||
const canvas = page.getByTestId("editor-canvas");
|
||||
@@ -1729,7 +1717,7 @@ test("섹션 패딩/컬럼 간격 수치가 프리뷰에서도 em 스케일에
|
||||
test("구분선 여백/길이 수치가 프리뷰에서도 em 스케일로 반영되어야 한다", async ({ page }) => {
|
||||
await page.goto("/editor");
|
||||
|
||||
await page.getByRole("button", { name: "구분선 블록 추가" }).click();
|
||||
await page.getByRole("button", { name: "구분선" }).click();
|
||||
|
||||
const canvas = page.getByTestId("editor-canvas");
|
||||
const dividerBlock = canvas.getByTestId("editor-block").first();
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
import { test, expect } from "@playwright/test";
|
||||
|
||||
test("서로 다른 유저는 /projects 에서 자신의 프로젝트만 볼 수 있어야 한다", async ({ page }) => {
|
||||
type Project = {
|
||||
id: string;
|
||||
title: string;
|
||||
slug: string;
|
||||
status: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
let currentUser: "A" | "B" = "A";
|
||||
const projectsA: Project[] = [];
|
||||
const projectsB: Project[] = [];
|
||||
let nextId = 1;
|
||||
|
||||
const nowIso = () => new Date().toISOString();
|
||||
|
||||
await page.route("**/api/auth/me", async (route) => {
|
||||
const email = currentUser === "A" ? "user-a@example.com" : "user-b@example.com";
|
||||
const id = currentUser === "A" ? "user-a" : "user-b";
|
||||
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ id, email, tokenVersion: 1 }),
|
||||
});
|
||||
});
|
||||
|
||||
await page.route("**/api/projects*", async (route) => {
|
||||
const request = route.request();
|
||||
const url = new URL(request.url());
|
||||
const method = request.method();
|
||||
|
||||
const ownerProjects = currentUser === "A" ? projectsA : projectsB;
|
||||
const otherProjects = currentUser === "A" ? projectsB : projectsA;
|
||||
|
||||
if (url.pathname === "/api/projects" && method === "GET") {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify(ownerProjects),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (url.pathname === "/api/projects" && method === "POST") {
|
||||
const bodyText = request.postData() ?? "{}";
|
||||
const body = JSON.parse(bodyText) as Partial<Project> & { title?: string; slug?: string };
|
||||
const slug = body.slug ?? "";
|
||||
const title = body.title ?? "";
|
||||
|
||||
const conflict = otherProjects.find((p) => p.slug === slug);
|
||||
if (conflict) {
|
||||
await route.fulfill({
|
||||
status: 409,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ message: "이미 다른 사용자가 사용 중인 프로젝트 주소입니다." }),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const existingIndex = ownerProjects.findIndex((p) => p.slug === slug);
|
||||
const now = nowIso();
|
||||
|
||||
let project: Project;
|
||||
if (existingIndex >= 0) {
|
||||
const existing = ownerProjects[existingIndex];
|
||||
project = { ...existing, title: title || existing.title, updatedAt: now };
|
||||
ownerProjects[existingIndex] = project;
|
||||
} else {
|
||||
project = {
|
||||
id: String(nextId++),
|
||||
title: title || "제목 없음",
|
||||
slug,
|
||||
status: "DRAFT",
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
ownerProjects.push(project);
|
||||
}
|
||||
|
||||
await route.fulfill({
|
||||
status: 201,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify(project),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
await route.fulfill({
|
||||
status: 404,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ message: "Not implemented in E2E mock" }),
|
||||
});
|
||||
});
|
||||
|
||||
// 1) 유저 A: 에디터에서 프로젝트를 저장하고, /projects 에서 자신의 프로젝트만 보여야 한다.
|
||||
await page.goto("/editor");
|
||||
|
||||
const propertiesSidebarA = page.getByTestId("properties-sidebar");
|
||||
await propertiesSidebarA.getByLabel("프로젝트 제목").fill("유저 A 프로젝트");
|
||||
await propertiesSidebarA.getByLabel("프로젝트 주소 (slug)").fill("user-a-project");
|
||||
|
||||
await page.getByRole("button", { name: "메뉴 ▼" }).click();
|
||||
await page.getByRole("button", { name: "프로젝트 저장/불러오기" }).click();
|
||||
await page.getByRole("button", { name: "저장 (로컬 + 서버)" }).click();
|
||||
|
||||
await expect(page).toHaveURL(/\/_?projects/);
|
||||
await expect(page.getByText("유저 A 프로젝트")).toBeVisible();
|
||||
await expect(page.getByText("user-a-project")).toBeVisible();
|
||||
await expect(page.getByText("user-b-project")).toHaveCount(0);
|
||||
|
||||
// 2) 유저 B: currentUser 전환 후 별도 프로젝트를 저장하고, /projects 에서 자신의 것만 보여야 한다.
|
||||
currentUser = "B";
|
||||
|
||||
await page.goto("/editor");
|
||||
|
||||
const propertiesSidebarB = page.getByTestId("properties-sidebar");
|
||||
await propertiesSidebarB.getByLabel("프로젝트 제목").fill("유저 B 프로젝트");
|
||||
await propertiesSidebarB.getByLabel("프로젝트 주소 (slug)").fill("user-b-project");
|
||||
|
||||
await page.getByRole("button", { name: "메뉴 ▼" }).click();
|
||||
await page.getByRole("button", { name: "프로젝트 저장/불러오기" }).click();
|
||||
await page.getByRole("button", { name: "저장 (로컬 + 서버)" }).click();
|
||||
|
||||
await expect(page).toHaveURL(/\/_?projects/);
|
||||
await expect(page.getByText("유저 B 프로젝트")).toBeVisible();
|
||||
await expect(page.getByText("user-b-project")).toBeVisible();
|
||||
await expect(page.getByText("유저 A 프로젝트")).toHaveCount(0);
|
||||
|
||||
// 3) 다시 유저 A 로 전환했을 때, /projects 에서는 유저 A 의 프로젝트만 보여야 한다.
|
||||
currentUser = "A";
|
||||
|
||||
await page.goto("/projects");
|
||||
|
||||
await expect(page.getByText("유저 A 프로젝트")).toBeVisible();
|
||||
await expect(page.getByText("user-a-project")).toBeVisible();
|
||||
await expect(page.getByText("유저 B 프로젝트")).toHaveCount(0);
|
||||
});
|
||||
@@ -52,28 +52,25 @@ describe("BlocksSidebar - 템플릿 버튼", () => {
|
||||
Object.values(templateActions).forEach((fn) => fn.mockReset());
|
||||
});
|
||||
|
||||
it("Hero 템플릿 추가 버튼 클릭 시 addHeroTemplateSection 이 호출되어야 한다", () => {
|
||||
it("Hero 템플릿 버튼 클릭 시 addHeroTemplateSection 이 호출되어야 한다", () => {
|
||||
render(<BlocksSidebar />);
|
||||
|
||||
const button = screen.getByRole("button", { name: "Hero 템플릿 추가" });
|
||||
const button = screen.getByRole("button", { name: "Hero 템플릿" });
|
||||
fireEvent.click(button);
|
||||
|
||||
expect(templateActions.addHeroTemplateSection).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("Features 템플릿 추가 버튼 클릭 시 addFeaturesTemplateSection 이 호출되어야 한다", () => {
|
||||
it("Features 템플릿 버튼 클릭 시 addFeaturesTemplateSection 이 호출되어야 한다", () => {
|
||||
render(<BlocksSidebar />);
|
||||
|
||||
const button = screen.getByRole("button", { name: "Features 템플릿 추가" });
|
||||
const button = screen.getByRole("button", { name: "기능 템플릿" });
|
||||
fireEvent.click(button);
|
||||
|
||||
expect(templateActions.addFeaturesTemplateSection).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("CTA 템플릿 추가 버튼 클릭 시 addCtaTemplateSection 이 호출되어야 한다", () => {
|
||||
it("CTA 템플릿 버튼 클릭 시 addCtaTemplateSection 이 호출되어야 한다", () => {
|
||||
render(<BlocksSidebar />);
|
||||
|
||||
const button = screen.getByRole("button", { name: "CTA 템플릿 추가" });
|
||||
const button = screen.getByRole("button", { name: "CTA 템플릿" });
|
||||
fireEvent.click(button);
|
||||
|
||||
expect(templateActions.addCtaTemplateSection).toHaveBeenCalledTimes(1);
|
||||
@@ -81,13 +78,12 @@ describe("BlocksSidebar - 템플릿 버튼", () => {
|
||||
|
||||
it("FAQ/Pricing/Testimonials/Blog/Team/Footer 템플릿 버튼도 각각의 액션을 호출해야 한다", () => {
|
||||
render(<BlocksSidebar />);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "FAQ 템플릿 추가" }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "Pricing 템플릿 추가" }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "Testimonials 템플릿 추가" }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "Blog 템플릿 추가" }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "Team 템플릿 추가" }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "Footer 템플릿 추가" }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "FAQ 템플릿" }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "상품 템플릿" }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "후기 템플릿" }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "블로그 템플릿" }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "Team 템플릿" }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "Footer 템플릿" }));
|
||||
|
||||
expect(templateActions.addFaqTemplateSection).toHaveBeenCalledTimes(1);
|
||||
expect(templateActions.addPricingTemplateSection).toHaveBeenCalledTimes(1);
|
||||
@@ -111,10 +107,9 @@ describe("BlocksSidebar - 템플릿 버튼", () => {
|
||||
expect(screen.getByText("페이지 푸터 섹션"));
|
||||
});
|
||||
|
||||
it("비디오 블록 추가 버튼 클릭 시 addVideoBlock 이 호출되어야 한다", () => {
|
||||
it("비디오 버튼 클릭 시 addVideoBlock 이 호출되어야 한다", () => {
|
||||
render(<BlocksSidebar />);
|
||||
|
||||
const button = screen.getByRole("button", { name: "비디오 블록 추가" });
|
||||
const button = screen.getByRole("button", { name: "비디오" });
|
||||
fireEvent.click(button);
|
||||
|
||||
expect(otherActions.addVideoBlock).toHaveBeenCalledTimes(1);
|
||||
@@ -165,4 +160,55 @@ describe("BlocksSidebar - 템플릿 버튼", () => {
|
||||
expect(pricingThumb.children.length).toBe(3);
|
||||
expect(teamThumb.children.length).toBe(3);
|
||||
});
|
||||
|
||||
it("블록 섹션 타이틀을 클릭하면 블록 버튼들을 접고 펼 수 있어야 한다", () => {
|
||||
render(<BlocksSidebar />);
|
||||
|
||||
// 초기에는 텍스트 버튼이 보여야 한다.
|
||||
expect(screen.getByRole("button", { name: "텍스트" })).toBeDefined();
|
||||
|
||||
const blockToggle = screen.getByRole("button", { name: "블록" });
|
||||
fireEvent.click(blockToggle);
|
||||
|
||||
// 접힌 상태에서는 텍스트 버튼이 보이지 않아야 한다.
|
||||
expect(screen.queryByRole("button", { name: "텍스트" })).toBeNull();
|
||||
|
||||
// 다시 클릭하면 펼쳐져야 한다.
|
||||
fireEvent.click(blockToggle);
|
||||
expect(screen.getByRole("button", { name: "텍스트" })).toBeDefined();
|
||||
});
|
||||
|
||||
it("폼 요소 섹션 타이틀을 클릭하면 폼 관련 버튼들을 접고 펼 수 있어야 한다", () => {
|
||||
render(<BlocksSidebar />);
|
||||
|
||||
// 초기에는 폼 입력 버튼이 보여야 한다.
|
||||
expect(screen.getByRole("button", { name: "입력(Input)" })).toBeDefined();
|
||||
|
||||
const formToggle = screen.getByRole("button", { name: "폼 요소" });
|
||||
fireEvent.click(formToggle);
|
||||
|
||||
// 접힌 상태에서는 폼 입력 버튼이 보이지 않아야 한다.
|
||||
expect(screen.queryByRole("button", { name: "입력(Input)" })).toBeNull();
|
||||
|
||||
// 다시 클릭하면 펼쳐져야 한다.
|
||||
fireEvent.click(formToggle);
|
||||
expect(screen.getByRole("button", { name: "입력(Input)" })).toBeDefined();
|
||||
});
|
||||
|
||||
it("템플릿 섹션 타이틀을 클릭하면 템플릿 카드들을 접고 펼 수 있어야 한다", () => {
|
||||
render(<BlocksSidebar />);
|
||||
|
||||
// 초기에는 Hero 템플릿 버튼이 보여야 한다.
|
||||
expect(screen.getByRole("button", { name: "Hero 템플릿" })).toBeDefined();
|
||||
|
||||
const templateToggle = screen.getByRole("button", { name: "템플릿" });
|
||||
fireEvent.click(templateToggle);
|
||||
|
||||
// 접힌 상태에서는 Hero 템플릿 버튼이 보이지 않아야 한다.
|
||||
expect(screen.queryByRole("button", { name: "Hero 템플릿" })).toBeNull();
|
||||
|
||||
// 다시 클릭하면 펼쳐져야 한다.
|
||||
fireEvent.click(templateToggle);
|
||||
expect(screen.getByRole("button", { name: "Hero 템플릿" })).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
||||
import { render, cleanup } from "@testing-library/react";
|
||||
import EditorPage from "@/app/editor/page";
|
||||
import type { Block, ProjectConfig } from "@/features/editor/state/editorStore";
|
||||
|
||||
// EditorPage 자동저장 TDD
|
||||
// - blocks + projectConfig 를 localStorage 의 pb:autosave:<slug> 키로 자동 저장해야 한다.
|
||||
// - localStorage 에 autosave 스냅샷이 있으면 초기 렌더에서 replaceBlocks + updateProjectConfig 로 복원해야 한다.
|
||||
|
||||
let mockState: any;
|
||||
|
||||
beforeEach(() => {
|
||||
const baseProjectConfig: ProjectConfig = {
|
||||
title: "자동저장 테스트 페이지",
|
||||
slug: "autosave-test",
|
||||
canvasPreset: "full",
|
||||
} as ProjectConfig;
|
||||
|
||||
mockState = {
|
||||
blocks: [
|
||||
{
|
||||
id: "blk_1",
|
||||
type: "text",
|
||||
props: {
|
||||
text: "자동저장 테스트 블록",
|
||||
align: "left",
|
||||
size: "base",
|
||||
},
|
||||
},
|
||||
] as Block[],
|
||||
projectConfig: baseProjectConfig,
|
||||
selectedBlockId: null as string | null,
|
||||
selectedListItemId: null as string | null,
|
||||
// EditorPage 가 참조하는 액션들: 기본적으로 no-op mock 으로 채운다.
|
||||
undo: vi.fn(),
|
||||
redo: vi.fn(),
|
||||
removeBlock: vi.fn(),
|
||||
duplicateBlock: vi.fn(),
|
||||
selectBlock: vi.fn(),
|
||||
selectListItem: vi.fn(),
|
||||
replaceBlocks: vi.fn(),
|
||||
reorderBlocks: vi.fn(),
|
||||
moveBlock: vi.fn(),
|
||||
addTextBlock: vi.fn(),
|
||||
addButtonBlock: vi.fn(),
|
||||
addImageBlock: vi.fn(),
|
||||
addDividerBlock: vi.fn(),
|
||||
addListBlock: vi.fn(),
|
||||
addSectionBlock: vi.fn(),
|
||||
addFormBlock: vi.fn(),
|
||||
addFormInputBlock: vi.fn(),
|
||||
addFormSelectBlock: vi.fn(),
|
||||
addFormCheckboxBlock: vi.fn(),
|
||||
addFormRadioBlock: vi.fn(),
|
||||
addHeroTemplateSection: vi.fn(),
|
||||
addFeaturesTemplateSection: vi.fn(),
|
||||
addCtaTemplateSection: vi.fn(),
|
||||
addFaqTemplateSection: vi.fn(),
|
||||
addPricingTemplateSection: vi.fn(),
|
||||
addTestimonialsTemplateSection: vi.fn(),
|
||||
addBlogTemplateSection: vi.fn(),
|
||||
addTeamTemplateSection: vi.fn(),
|
||||
addFooterTemplateSection: vi.fn(),
|
||||
updateBlock: vi.fn(),
|
||||
updateProjectConfig: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
vi.mock("@/features/editor/state/editorStore", () => {
|
||||
const useEditorStore = (selector: (state: any) => any) => selector(mockState);
|
||||
(useEditorStore as any).getState = () => mockState;
|
||||
|
||||
return {
|
||||
__esModule: true,
|
||||
useEditorStore,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("next/link", () => {
|
||||
return {
|
||||
__esModule: true,
|
||||
default: ({ href, children }: any) => <a href={href}>{children}</a>,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("next/navigation", () => {
|
||||
return {
|
||||
__esModule: true,
|
||||
useRouter: () => ({
|
||||
push: vi.fn(),
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
describe("EditorPage - 자동저장", () => {
|
||||
it("blocks 와 projectConfig 를 localStorage 의 pb:autosave:<slug> 키로 자동 저장해야 한다", () => {
|
||||
const storageProto = Object.getPrototypeOf(window.localStorage);
|
||||
const setItemSpy = vi.spyOn(storageProto, "setItem");
|
||||
|
||||
render(<EditorPage />);
|
||||
|
||||
expect(setItemSpy).toHaveBeenCalled();
|
||||
const [key, value] = setItemSpy.mock.calls[0] as [string, string];
|
||||
expect(key).toBe("pb:autosave:autosave-test");
|
||||
|
||||
const parsed = JSON.parse(value);
|
||||
expect(Array.isArray(parsed.blocks)).toBe(true);
|
||||
expect(parsed.blocks[0].id).toBe("blk_1");
|
||||
expect(parsed.projectConfig.slug).toBe("autosave-test");
|
||||
});
|
||||
|
||||
it("localStorage 에 autosave 스냅샷이 있으면 초기 렌더에서 replaceBlocks 와 updateProjectConfig 로 복원해야 한다", () => {
|
||||
const savedBlocks: Block[] = [
|
||||
{
|
||||
id: "saved_1",
|
||||
type: "text",
|
||||
props: {
|
||||
text: "저장된 블록",
|
||||
align: "left",
|
||||
size: "base",
|
||||
},
|
||||
} as any,
|
||||
];
|
||||
|
||||
const savedConfig: ProjectConfig = {
|
||||
title: "저장된 프로젝트",
|
||||
slug: "autosave-test",
|
||||
canvasPreset: "full",
|
||||
} as ProjectConfig;
|
||||
|
||||
const payload = {
|
||||
blocks: savedBlocks,
|
||||
projectConfig: savedConfig,
|
||||
};
|
||||
|
||||
window.localStorage.setItem("pb:autosave:autosave-test", JSON.stringify(payload));
|
||||
|
||||
render(<EditorPage />);
|
||||
|
||||
expect(mockState.replaceBlocks).toHaveBeenCalledTimes(1);
|
||||
expect(mockState.replaceBlocks).toHaveBeenCalledWith(savedBlocks);
|
||||
|
||||
expect(mockState.updateProjectConfig).toHaveBeenCalledTimes(1);
|
||||
expect(mockState.updateProjectConfig).toHaveBeenCalledWith(savedConfig);
|
||||
});
|
||||
});
|
||||
@@ -74,6 +74,15 @@ vi.mock("next/link", () => {
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("next/navigation", () => {
|
||||
return {
|
||||
__esModule: true,
|
||||
useRouter: () => ({
|
||||
push: vi.fn(),
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
describe("EditorPage - 버튼 블록 스타일", () => {
|
||||
it("크기/변형/색상/패딩/텍스트 정렬이 에디터 버튼 렌더에 반영되어야 한다", () => {
|
||||
const blocks: Block[] = [
|
||||
|
||||
@@ -74,6 +74,15 @@ vi.mock("next/link", () => {
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("next/navigation", () => {
|
||||
return {
|
||||
__esModule: true,
|
||||
useRouter: () => ({
|
||||
push: vi.fn(),
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
function hexToRgb(hex: string) {
|
||||
const clean = hex.replace("#", "");
|
||||
const int = parseInt(clean, 16);
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
||||
import { render, screen, cleanup, fireEvent } from "@testing-library/react";
|
||||
import type { Block, ProjectConfig } from "@/features/editor/state/editorStore";
|
||||
import EditorPage from "@/app/editor/page";
|
||||
|
||||
// EditorPage 상단 메뉴 UX TDD
|
||||
// - Export 미리보기 기능 제거 후, 상단 메뉴에 더 이상 "Export 미리보기" 항목이 노출되지 않아야 한다.
|
||||
// - 대신 프로젝트 저장/불러오기와 연계된 "프로젝트 목록" 항목이 메뉴에 노출되어야 한다.
|
||||
|
||||
let mockState: any;
|
||||
|
||||
beforeEach(() => {
|
||||
const baseProjectConfig: ProjectConfig = {
|
||||
title: "Export 미리보기 테스트",
|
||||
slug: "export-preview-test",
|
||||
canvasPreset: "full",
|
||||
} as ProjectConfig;
|
||||
|
||||
mockState = {
|
||||
blocks: [
|
||||
{
|
||||
id: "blk_text_1",
|
||||
type: "text",
|
||||
props: {
|
||||
text: "Export 미리보기 본문",
|
||||
align: "left",
|
||||
size: "base",
|
||||
},
|
||||
},
|
||||
] as Block[],
|
||||
projectConfig: baseProjectConfig,
|
||||
selectedBlockId: null as string | null,
|
||||
selectedListItemId: null as string | null,
|
||||
undo: vi.fn(),
|
||||
redo: vi.fn(),
|
||||
removeBlock: vi.fn(),
|
||||
duplicateBlock: vi.fn(),
|
||||
selectBlock: vi.fn(),
|
||||
selectListItem: vi.fn(),
|
||||
replaceBlocks: vi.fn(),
|
||||
reorderBlocks: vi.fn(),
|
||||
moveBlock: vi.fn(),
|
||||
addTextBlock: vi.fn(),
|
||||
addButtonBlock: vi.fn(),
|
||||
addImageBlock: vi.fn(),
|
||||
addDividerBlock: vi.fn(),
|
||||
addListBlock: vi.fn(),
|
||||
addSectionBlock: vi.fn(),
|
||||
addFormBlock: vi.fn(),
|
||||
addFormInputBlock: vi.fn(),
|
||||
addFormSelectBlock: vi.fn(),
|
||||
addFormCheckboxBlock: vi.fn(),
|
||||
addFormRadioBlock: vi.fn(),
|
||||
addHeroTemplateSection: vi.fn(),
|
||||
addFeaturesTemplateSection: vi.fn(),
|
||||
addCtaTemplateSection: vi.fn(),
|
||||
addFaqTemplateSection: vi.fn(),
|
||||
addPricingTemplateSection: vi.fn(),
|
||||
addTestimonialsTemplateSection: vi.fn(),
|
||||
addBlogTemplateSection: vi.fn(),
|
||||
addTeamTemplateSection: vi.fn(),
|
||||
addFooterTemplateSection: vi.fn(),
|
||||
updateBlock: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
vi.mock("@/features/editor/state/editorStore", () => {
|
||||
const useEditorStore = (selector: (state: any) => any) => selector(mockState);
|
||||
(useEditorStore as any).getState = () => mockState;
|
||||
|
||||
return {
|
||||
__esModule: true,
|
||||
useEditorStore,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("next/link", () => {
|
||||
return {
|
||||
__esModule: true,
|
||||
default: ({ href, children }: any) => <a href={href}>{children}</a>,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("next/navigation", () => {
|
||||
return {
|
||||
__esModule: true,
|
||||
useRouter: () => ({
|
||||
push: vi.fn(),
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
describe("EditorPage - 상단 메뉴 (Export 미리보기 제거)", () => {
|
||||
it("메뉴를 열었을 때 'Export 미리보기' 항목은 보이지 않고, '프로젝트 목록' 항목이 노출되어야 한다", () => {
|
||||
render(<EditorPage />);
|
||||
|
||||
const menuButton = screen.getByText("메뉴");
|
||||
fireEvent.click(menuButton);
|
||||
|
||||
const removedItem = screen.queryByText("Export 미리보기");
|
||||
expect(removedItem).toBeNull();
|
||||
|
||||
const projectListItem = screen.getByText("프로젝트 목록");
|
||||
expect(projectListItem).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -74,6 +74,15 @@ vi.mock("next/link", () => {
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("next/navigation", () => {
|
||||
return {
|
||||
__esModule: true,
|
||||
useRouter: () => ({
|
||||
push: vi.fn(),
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
function hexToRgb(hex: string) {
|
||||
const clean = hex.replace("#", "");
|
||||
const int = parseInt(clean, 16);
|
||||
|
||||
@@ -74,6 +74,15 @@ vi.mock("next/link", () => {
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("next/navigation", () => {
|
||||
return {
|
||||
__esModule: true,
|
||||
useRouter: () => ({
|
||||
push: vi.fn(),
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
describe("EditorPage - 이미지 블록 스타일", () => {
|
||||
it("widthMode=fixed 일 때 widthPx / borderRadiusPx 가 에디터 이미지 렌더에 반영되어야 한다", () => {
|
||||
const blocks: Block[] = [
|
||||
|
||||
@@ -0,0 +1,204 @@
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
||||
import { render, screen, fireEvent, cleanup, within } from "@testing-library/react";
|
||||
import EditorPage from "@/app/editor/page";
|
||||
import type { Block, ProjectConfig } from "@/features/editor/state/editorStore";
|
||||
|
||||
// EditorPage JSON 내보내기/불러오기 TDD
|
||||
// - "JSON 내보내기" 클릭 시 blocks + projectConfig 를 포함한 JSON 을 에디터 상태 영역에 출력해야 한다.
|
||||
// - "JSON 적용하기" 클릭 시 blocks + projectConfig 형태의 JSON 을 파싱해 replaceBlocks + updateProjectConfig 로 반영해야 한다.
|
||||
// - 잘못된 JSON 이거나 구조가 맞지 않으면 replaceBlocks / updateProjectConfig 를 호출하지 않아야 한다.
|
||||
|
||||
let mockState: any;
|
||||
|
||||
beforeEach(() => {
|
||||
const baseProjectConfig: ProjectConfig = {
|
||||
title: "JSON Export/Import 테스트",
|
||||
slug: "json-export-import-test",
|
||||
canvasPreset: "full",
|
||||
} as ProjectConfig;
|
||||
|
||||
mockState = {
|
||||
blocks: [
|
||||
{
|
||||
id: "blk_1",
|
||||
type: "text",
|
||||
props: {
|
||||
text: "JSON 테스트 블록",
|
||||
align: "left",
|
||||
size: "base",
|
||||
},
|
||||
},
|
||||
] as Block[],
|
||||
projectConfig: baseProjectConfig,
|
||||
selectedBlockId: null as string | null,
|
||||
selectedListItemId: null as string | null,
|
||||
// EditorPage 가 참조하는 액션들: 기본적으로 no-op mock 으로 채운다.
|
||||
undo: vi.fn(),
|
||||
redo: vi.fn(),
|
||||
removeBlock: vi.fn(),
|
||||
duplicateBlock: vi.fn(),
|
||||
selectBlock: vi.fn(),
|
||||
selectListItem: vi.fn(),
|
||||
replaceBlocks: vi.fn(),
|
||||
reorderBlocks: vi.fn(),
|
||||
moveBlock: vi.fn(),
|
||||
addTextBlock: vi.fn(),
|
||||
addButtonBlock: vi.fn(),
|
||||
addImageBlock: vi.fn(),
|
||||
addDividerBlock: vi.fn(),
|
||||
addListBlock: vi.fn(),
|
||||
addSectionBlock: vi.fn(),
|
||||
addFormBlock: vi.fn(),
|
||||
addFormInputBlock: vi.fn(),
|
||||
addFormSelectBlock: vi.fn(),
|
||||
addFormCheckboxBlock: vi.fn(),
|
||||
addFormRadioBlock: vi.fn(),
|
||||
addHeroTemplateSection: vi.fn(),
|
||||
addFeaturesTemplateSection: vi.fn(),
|
||||
addCtaTemplateSection: vi.fn(),
|
||||
addFaqTemplateSection: vi.fn(),
|
||||
addPricingTemplateSection: vi.fn(),
|
||||
addTestimonialsTemplateSection: vi.fn(),
|
||||
addBlogTemplateSection: vi.fn(),
|
||||
addTeamTemplateSection: vi.fn(),
|
||||
addFooterTemplateSection: vi.fn(),
|
||||
updateBlock: vi.fn(),
|
||||
updateProjectConfig: vi.fn(),
|
||||
};
|
||||
|
||||
window.localStorage.clear();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
vi.mock("@/features/editor/state/editorStore", () => {
|
||||
const useEditorStore = (selector: (state: any) => any) => selector(mockState);
|
||||
(useEditorStore as any).getState = () => mockState;
|
||||
|
||||
return {
|
||||
__esModule: true,
|
||||
useEditorStore,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("next/link", () => {
|
||||
return {
|
||||
__esModule: true,
|
||||
default: ({ href, children }: any) => <a href={href}>{children}</a>,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("next/navigation", () => {
|
||||
return {
|
||||
__esModule: true,
|
||||
useRouter: () => ({
|
||||
push: vi.fn(),
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
function openJsonModal() {
|
||||
const menuButton = screen.getByText("메뉴");
|
||||
fireEvent.click(menuButton);
|
||||
|
||||
const jsonMenuItem = screen.getByText("JSON 내보내기/불러오기");
|
||||
fireEvent.click(jsonMenuItem);
|
||||
|
||||
const modalTitle = screen.getByText("JSON Export / Import");
|
||||
const jsonModal = (modalTitle.parentElement?.parentElement ?? document.body) as HTMLElement;
|
||||
return jsonModal;
|
||||
}
|
||||
|
||||
describe("EditorPage - JSON 내보내기/불러오기", () => {
|
||||
it("JSON 내보내기 클릭 시 blocks + projectConfig 구조의 JSON 이 에디터 상태 영역에 출력되어야 한다", () => {
|
||||
render(<EditorPage />);
|
||||
|
||||
const jsonModal = openJsonModal();
|
||||
|
||||
const exportLabel = within(jsonModal).getByText("에디터 상태 JSON").closest("label") as HTMLElement;
|
||||
const exportTextarea = within(exportLabel).getByRole("textbox") as HTMLTextAreaElement;
|
||||
|
||||
// 초기에는 비어 있어야 한다.
|
||||
expect(exportTextarea.value).toBe("");
|
||||
|
||||
const exportButton = within(jsonModal).getByText("JSON 내보내기");
|
||||
fireEvent.click(exportButton);
|
||||
|
||||
const value = exportTextarea.value;
|
||||
expect(value.trim().length).toBeGreaterThan(0);
|
||||
|
||||
const parsed = JSON.parse(value);
|
||||
expect(Array.isArray(parsed.blocks)).toBe(true);
|
||||
expect(parsed.blocks[0].id).toBe("blk_1");
|
||||
expect(parsed.projectConfig.slug).toBe("json-export-import-test");
|
||||
});
|
||||
|
||||
it("JSON 적용하기 클릭 시 blocks + projectConfig 구조의 JSON 으로 replaceBlocks 와 updateProjectConfig 를 호출해야 한다", () => {
|
||||
render(<EditorPage />);
|
||||
|
||||
const jsonModal = openJsonModal();
|
||||
|
||||
const importLabel = within(jsonModal).getByText("JSON에서 불러오기").closest("label") as HTMLElement;
|
||||
const importTextarea = within(importLabel).getByRole("textbox") as HTMLTextAreaElement;
|
||||
|
||||
const importedBlocks: Block[] = [
|
||||
{
|
||||
id: "imported_1",
|
||||
type: "text",
|
||||
props: {
|
||||
text: "가져온 블록",
|
||||
align: "center",
|
||||
size: "lg",
|
||||
},
|
||||
} as any,
|
||||
];
|
||||
|
||||
const importedConfig: ProjectConfig = {
|
||||
title: "가져온 프로젝트",
|
||||
slug: "imported-slug",
|
||||
canvasPreset: "full",
|
||||
} as ProjectConfig;
|
||||
|
||||
const payload = {
|
||||
blocks: importedBlocks,
|
||||
projectConfig: importedConfig,
|
||||
};
|
||||
|
||||
fireEvent.change(importTextarea, { target: { value: JSON.stringify(payload) } });
|
||||
|
||||
// 초기 마운트 시 호출됐을 수 있는 기록은 무시한다.
|
||||
mockState.replaceBlocks.mockReset();
|
||||
mockState.updateProjectConfig.mockReset();
|
||||
|
||||
const applyButton = within(importLabel).getByText("JSON 적용하기");
|
||||
fireEvent.click(applyButton);
|
||||
|
||||
expect(mockState.replaceBlocks).toHaveBeenCalledTimes(1);
|
||||
expect(mockState.replaceBlocks).toHaveBeenCalledWith(importedBlocks as any);
|
||||
|
||||
expect(mockState.updateProjectConfig).toHaveBeenCalledTimes(1);
|
||||
expect(mockState.updateProjectConfig).toHaveBeenCalledWith(importedConfig as ProjectConfig);
|
||||
});
|
||||
|
||||
it("잘못된 JSON 은 무시되어야 하며 replaceBlocks/updateProjectConfig 를 호출하면 안 된다", () => {
|
||||
render(<EditorPage />);
|
||||
|
||||
const jsonModal = openJsonModal();
|
||||
|
||||
const importLabel = within(jsonModal).getByText("JSON에서 불러오기").closest("label") as HTMLElement;
|
||||
const importTextarea = within(importLabel).getByRole("textbox") as HTMLTextAreaElement;
|
||||
|
||||
fireEvent.change(importTextarea, { target: { value: "{ this-is: not-valid-json" } });
|
||||
|
||||
mockState.replaceBlocks.mockReset();
|
||||
mockState.updateProjectConfig.mockReset();
|
||||
|
||||
const applyButton = within(importLabel).getByText("JSON 적용하기");
|
||||
fireEvent.click(applyButton);
|
||||
|
||||
expect(mockState.replaceBlocks).not.toHaveBeenCalled();
|
||||
expect(mockState.updateProjectConfig).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -74,6 +74,15 @@ vi.mock("next/link", () => {
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("next/navigation", () => {
|
||||
return {
|
||||
__esModule: true,
|
||||
useRouter: () => ({
|
||||
push: vi.fn(),
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
describe("EditorPage - 레이아웃 스크롤 컨테이너", () => {
|
||||
it("좌측 사이드바, 캔버스, 우측 속성 패널에 pb-scroll 클래스가 적용되어야 한다", () => {
|
||||
render(<EditorPage />);
|
||||
|
||||
@@ -74,6 +74,15 @@ vi.mock("next/link", () => {
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("next/navigation", () => {
|
||||
return {
|
||||
__esModule: true,
|
||||
useRouter: () => ({
|
||||
push: vi.fn(),
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
function hexToRgb(hex: string) {
|
||||
const clean = hex.replace("#", "");
|
||||
const int = parseInt(clean, 16);
|
||||
|
||||
@@ -6,6 +6,8 @@ import EditorPage from "@/app/editor/page";
|
||||
let mockState: any;
|
||||
|
||||
beforeEach(() => {
|
||||
window.localStorage.clear();
|
||||
|
||||
const baseProjectConfig: ProjectConfig = {
|
||||
title: "멀티 선택 테스트",
|
||||
slug: "multi-select",
|
||||
@@ -74,6 +76,15 @@ vi.mock("next/link", () => {
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("next/navigation", () => {
|
||||
return {
|
||||
__esModule: true,
|
||||
useRouter: () => ({
|
||||
push: vi.fn(),
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
function setupThreeBlocks() {
|
||||
const blocks: Block[] = [
|
||||
{
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import { describe, it, expect, afterEach, vi } from "vitest";
|
||||
import { render, cleanup, waitFor } from "@testing-library/react";
|
||||
|
||||
import EditorPage from "@/app/editor/page";
|
||||
|
||||
// next/navigation 의 useRouter 를 목으로 대체해 인증 실패 시 리다이렉트 동작을 검증한다.
|
||||
export const pushMock = vi.fn();
|
||||
|
||||
vi.mock("next/navigation", () => {
|
||||
return {
|
||||
__esModule: true,
|
||||
useRouter: () => ({ push: pushMock }),
|
||||
};
|
||||
});
|
||||
|
||||
// editorStore 는 이미 여러 유닛 테스트에서 사용 중이므로 실제 store 를 그대로 사용해도 무방하다.
|
||||
|
||||
describe("EditorPage 인증 가드", () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.unstubAllGlobals();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("/api/auth/me 가 401 을 반환하면 /login 으로 리다이렉트해야 한다", async () => {
|
||||
const fetchMock = vi.fn().mockImplementation((input: any, init?: any) => {
|
||||
const url = typeof input === "string" ? input : input.url;
|
||||
const method = init?.method ?? "GET";
|
||||
|
||||
if (url === "/api/auth/me" && method === "GET") {
|
||||
return Promise.resolve(
|
||||
new Response(JSON.stringify({ message: "인증이 필요합니다." }), {
|
||||
status: 401,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
// 기타 요청은 200 으로 처리.
|
||||
return Promise.resolve(new Response(null, { status: 200 }));
|
||||
});
|
||||
|
||||
vi.stubGlobal("fetch", fetchMock as any);
|
||||
|
||||
render(<EditorPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(fetchMock).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(pushMock).toHaveBeenCalledWith("/login");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -73,6 +73,15 @@ vi.mock("next/link", () => {
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("next/navigation", () => {
|
||||
return {
|
||||
__esModule: true,
|
||||
useRouter: () => ({
|
||||
push: vi.fn(),
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
describe("EditorPage - 페이지/캔버스 배경", () => {
|
||||
it("main 요소는 bodyBgColorHex 로 직접 배경색을 갖지 않아야 하고, 캔버스 래퍼만 배경색을 가져야 한다", () => {
|
||||
const { container } = render(<EditorPage />);
|
||||
|
||||
@@ -89,6 +89,15 @@ vi.mock("next/link", () => {
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("next/navigation", () => {
|
||||
return {
|
||||
__esModule: true,
|
||||
useRouter: () => ({
|
||||
push: vi.fn(),
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
@@ -0,0 +1,394 @@
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
||||
import { render, screen, fireEvent, cleanup, waitFor, within } from "@testing-library/react";
|
||||
import EditorPage from "@/app/editor/page";
|
||||
import type { Block, ProjectConfig } from "@/features/editor/state/editorStore";
|
||||
|
||||
// EditorPage 프로젝트 저장/불러오기 UX TDD
|
||||
// - 프로젝트 저장 버튼 클릭 시 현재 blocks 를 로컬스토리지(pb:project:<slug>)와 서버(/api/projects) 모두에 저장해야 한다.
|
||||
// - 불러오기 버튼 클릭 시 로컬스토리지에 프로젝트가 있으면 서버를 호출하지 않고 로컬 데이터로 replaceBlocks 해야 한다.
|
||||
// - 로컬스토리지에 없으면 /api/projects/[slug] 를 호출해 서버에서 프로젝트를 불러와 replaceBlocks 해야 한다.
|
||||
|
||||
let mockState: any;
|
||||
|
||||
beforeEach(() => {
|
||||
const baseProjectConfig: ProjectConfig = {
|
||||
title: "프로젝트 저장/불러오기 테스트",
|
||||
slug: "editor-project-test",
|
||||
canvasPreset: "full",
|
||||
} as ProjectConfig;
|
||||
|
||||
mockState = {
|
||||
blocks: [
|
||||
{
|
||||
id: "blk_1",
|
||||
type: "text",
|
||||
props: {
|
||||
text: "프로젝트 저장/불러오기 테스트 블록",
|
||||
align: "left",
|
||||
size: "base",
|
||||
},
|
||||
},
|
||||
] as Block[],
|
||||
projectConfig: baseProjectConfig,
|
||||
selectedBlockId: null as string | null,
|
||||
selectedListItemId: null as string | null,
|
||||
// EditorPage 가 참조하는 액션들: 기본적으로 no-op mock 으로 채운다.
|
||||
undo: vi.fn(),
|
||||
redo: vi.fn(),
|
||||
removeBlock: vi.fn(),
|
||||
duplicateBlock: vi.fn(),
|
||||
selectBlock: vi.fn(),
|
||||
selectListItem: vi.fn(),
|
||||
replaceBlocks: vi.fn(),
|
||||
reorderBlocks: vi.fn(),
|
||||
moveBlock: vi.fn(),
|
||||
addTextBlock: vi.fn(),
|
||||
addButtonBlock: vi.fn(),
|
||||
addImageBlock: vi.fn(),
|
||||
addDividerBlock: vi.fn(),
|
||||
addListBlock: vi.fn(),
|
||||
addSectionBlock: vi.fn(),
|
||||
addFormBlock: vi.fn(),
|
||||
addFormInputBlock: vi.fn(),
|
||||
addFormSelectBlock: vi.fn(),
|
||||
addFormCheckboxBlock: vi.fn(),
|
||||
addFormRadioBlock: vi.fn(),
|
||||
addHeroTemplateSection: vi.fn(),
|
||||
addFeaturesTemplateSection: vi.fn(),
|
||||
addCtaTemplateSection: vi.fn(),
|
||||
addFaqTemplateSection: vi.fn(),
|
||||
addPricingTemplateSection: vi.fn(),
|
||||
addTestimonialsTemplateSection: vi.fn(),
|
||||
addBlogTemplateSection: vi.fn(),
|
||||
addTeamTemplateSection: vi.fn(),
|
||||
addFooterTemplateSection: vi.fn(),
|
||||
updateBlock: vi.fn(),
|
||||
updateProjectConfig: vi.fn((partial: Partial<ProjectConfig>) => {
|
||||
mockState.projectConfig = {
|
||||
...(mockState.projectConfig as ProjectConfig),
|
||||
...partial,
|
||||
} as ProjectConfig;
|
||||
}),
|
||||
};
|
||||
|
||||
window.localStorage.clear();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
vi.mock("@/features/editor/state/editorStore", () => {
|
||||
const useEditorStore = (selector: (state: any) => any) => selector(mockState);
|
||||
(useEditorStore as any).getState = () => mockState;
|
||||
|
||||
return {
|
||||
__esModule: true,
|
||||
useEditorStore,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("next/link", () => {
|
||||
return {
|
||||
__esModule: true,
|
||||
default: ({ href, children }: any) => <a href={href}>{children}</a>,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("next/navigation", () => {
|
||||
return {
|
||||
__esModule: true,
|
||||
useRouter: () => ({
|
||||
push: vi.fn(),
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
describe("EditorPage - 프로젝트 저장/불러오기", () => {
|
||||
it("프로젝트 저장 버튼 클릭 시 로컬스토리지와 서버에 상태를 저장해야 하고, projectConfig 와 제목/주소 입력이 동기화되어야 한다", async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
status: 201,
|
||||
json: async () => ({ slug: "save-test-slug" }),
|
||||
} as any);
|
||||
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const { rerender } = render(<EditorPage />);
|
||||
|
||||
const menuButton = screen.getByText("메뉴");
|
||||
fireEvent.click(menuButton);
|
||||
|
||||
const projectMenuItem = screen.getByText("프로젝트 저장/불러오기");
|
||||
fireEvent.click(projectMenuItem);
|
||||
|
||||
const modalTitle = screen.getByText("프로젝트 저장 / 불러오기");
|
||||
const projectModal = (modalTitle.parentElement?.parentElement ?? document.body) as HTMLElement;
|
||||
|
||||
const titleInput = within(projectModal).getByLabelText("프로젝트 제목") as HTMLInputElement;
|
||||
const slugInput = within(projectModal).getByLabelText("프로젝트 주소 (예: my-landing)") as HTMLInputElement;
|
||||
|
||||
expect(titleInput.value).toBe("프로젝트 저장/불러오기 테스트");
|
||||
expect(slugInput.value).toBe("editor-project-test");
|
||||
|
||||
fireEvent.change(titleInput, { target: { value: "저장 테스트 프로젝트" } });
|
||||
fireEvent.change(slugInput, { target: { value: "save-test-slug" } });
|
||||
|
||||
expect(mockState.projectConfig.title).toBe("저장 테스트 프로젝트");
|
||||
expect(mockState.projectConfig.slug).toBe("save-test-slug");
|
||||
|
||||
rerender(<EditorPage />);
|
||||
|
||||
const saveButton = screen.getByText("저장 (로컬 + 서버)");
|
||||
fireEvent.click(saveButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
fetchMock.mock.calls.some(
|
||||
([url, options]: any[]) => url === "/api/projects" && options?.method === "POST",
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
const localKey = "pb:project:save-test-slug";
|
||||
const raw = window.localStorage.getItem(localKey);
|
||||
expect(raw).not.toBeNull();
|
||||
|
||||
const parsed = JSON.parse(raw as string);
|
||||
expect(parsed.title).toBe("저장 테스트 프로젝트");
|
||||
expect(Array.isArray(parsed.contentJson)).toBe(true);
|
||||
expect(parsed.contentJson[0].id).toBe("blk_1");
|
||||
|
||||
const projectCall = fetchMock.mock.calls.find(
|
||||
([url, options]: any[]) => url === "/api/projects" && options?.method === "POST",
|
||||
) as any;
|
||||
|
||||
const [url, options] = projectCall;
|
||||
expect(url).toBe("/api/projects");
|
||||
expect(options.method).toBe("POST");
|
||||
expect(options.headers["Content-Type"]).toBe("application/json");
|
||||
|
||||
const body = JSON.parse(options.body);
|
||||
expect(body.slug).toBe("save-test-slug");
|
||||
expect(body.title).toBe("저장 테스트 프로젝트");
|
||||
expect(Array.isArray(body.contentJson)).toBe(true);
|
||||
expect(body.contentJson[0].id).toBe("blk_1");
|
||||
|
||||
const message = await screen.findByText("프로젝트가 저장되었습니다: save-test-slug");
|
||||
expect(message).toBeTruthy();
|
||||
});
|
||||
|
||||
it("서버가 409 를 반환하면 이미 사용 중인 프로젝트 주소라는 에러 메시지를 보여줘야 한다", async () => {
|
||||
const fetchMock = vi.fn().mockImplementation((input: any, init?: any) => {
|
||||
const url = typeof input === "string" ? input : input.url;
|
||||
const method = init?.method ?? "GET";
|
||||
|
||||
if (url === "/api/projects" && method === "POST") {
|
||||
return Promise.resolve({
|
||||
ok: false,
|
||||
status: 409,
|
||||
json: async () => ({ message: "이미 다른 사용자가 사용 중인 프로젝트 주소입니다." }),
|
||||
} as any);
|
||||
}
|
||||
|
||||
// 인증 가드용 /api/auth/me 등은 성공 응답만 내려주면 된다.
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({}),
|
||||
} as any);
|
||||
});
|
||||
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const { rerender } = render(<EditorPage />);
|
||||
|
||||
const menuButton = screen.getByText("메뉴");
|
||||
fireEvent.click(menuButton);
|
||||
|
||||
const projectMenuItem = screen.getByText("프로젝트 저장/불러오기");
|
||||
fireEvent.click(projectMenuItem);
|
||||
|
||||
const modalTitle = screen.getByText("프로젝트 저장 / 불러오기");
|
||||
const projectModal = (modalTitle.parentElement?.parentElement ?? document.body) as HTMLElement;
|
||||
|
||||
const titleInput = within(projectModal).getByLabelText("프로젝트 제목") as HTMLInputElement;
|
||||
const slugInput = within(projectModal).getByLabelText("프로젝트 주소 (예: my-landing)") as HTMLInputElement;
|
||||
|
||||
fireEvent.change(titleInput, { target: { value: "충돌 테스트 프로젝트" } });
|
||||
fireEvent.change(slugInput, { target: { value: "conflict-slug" } });
|
||||
|
||||
rerender(<EditorPage />);
|
||||
|
||||
const saveButton = screen.getByText("저장 (로컬 + 서버)");
|
||||
fireEvent.click(saveButton);
|
||||
|
||||
const message = await screen.findByText("이미 다른 사용자가 사용 중인 프로젝트 주소입니다.");
|
||||
expect(message).toBeTruthy();
|
||||
});
|
||||
|
||||
it("로컬스토리지에 프로젝트가 있으면 서버를 호출하지 않고 로컬 데이터로 불러와야 한다", async () => {
|
||||
const slug = "local-test-slug";
|
||||
const localKey = `pb:project:${slug}`;
|
||||
|
||||
const savedBlocks: Block[] = [
|
||||
{
|
||||
id: "saved_1",
|
||||
type: "text",
|
||||
props: {
|
||||
text: "로컬 저장된 블록",
|
||||
align: "left",
|
||||
size: "base",
|
||||
},
|
||||
} as any,
|
||||
];
|
||||
|
||||
window.localStorage.setItem(
|
||||
localKey,
|
||||
JSON.stringify({
|
||||
title: "로컬 프로젝트",
|
||||
contentJson: savedBlocks,
|
||||
}),
|
||||
);
|
||||
|
||||
const fetchMock = vi.fn();
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
mockState.projectConfig.slug = slug;
|
||||
|
||||
render(<EditorPage />);
|
||||
|
||||
const menuButton = screen.getByText("메뉴");
|
||||
fireEvent.click(menuButton);
|
||||
|
||||
const projectMenuItem = screen.getByText("프로젝트 저장/불러오기");
|
||||
fireEvent.click(projectMenuItem);
|
||||
|
||||
const modalTitle = screen.getByText("프로젝트 저장 / 불러오기");
|
||||
const projectModal = (modalTitle.parentElement?.parentElement ?? document.body) as HTMLElement;
|
||||
|
||||
const loadButton = screen.getByText("불러오기");
|
||||
fireEvent.click(loadButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockState.replaceBlocks).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
expect(mockState.replaceBlocks).toHaveBeenCalledWith(savedBlocks as any);
|
||||
|
||||
const serverCalls = fetchMock.mock.calls.filter(
|
||||
([url]: any[]) => typeof url === "string" && url.startsWith("/api/projects"),
|
||||
);
|
||||
expect(serverCalls.length).toBe(0);
|
||||
|
||||
const message = await screen.findByText(`로컬에서 프로젝트를 불러왔습니다: ${slug}`);
|
||||
expect(message).toBeTruthy();
|
||||
});
|
||||
|
||||
it("로컬스토리지에 없으면 서버에서 프로젝트를 불러와야 한다", async () => {
|
||||
const slug = "server-test-slug";
|
||||
|
||||
const serverBlocks: Block[] = [
|
||||
{
|
||||
id: "server_1",
|
||||
type: "text",
|
||||
props: {
|
||||
text: "서버에서 불러온 블록",
|
||||
align: "left",
|
||||
size: "base",
|
||||
},
|
||||
} as any,
|
||||
];
|
||||
|
||||
const fetchMock = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({ slug, contentJson: serverBlocks }),
|
||||
} as any);
|
||||
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
mockState.projectConfig.slug = slug;
|
||||
|
||||
render(<EditorPage />);
|
||||
|
||||
const menuButton = screen.getByText("메뉴");
|
||||
fireEvent.click(menuButton);
|
||||
|
||||
const projectMenuItem = screen.getByText("프로젝트 저장/불러오기");
|
||||
fireEvent.click(projectMenuItem);
|
||||
|
||||
const loadButton = screen.getByText("불러오기");
|
||||
fireEvent.click(loadButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
fetchMock.mock.calls.some(
|
||||
([url]: any[]) => url === `/api/projects/${slug}`,
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
const serverCall = fetchMock.mock.calls.find(
|
||||
([url]: any[]) => url === `/api/projects/${slug}`,
|
||||
) as any;
|
||||
|
||||
const [url] = serverCall;
|
||||
expect(url).toBe(`/api/projects/${slug}`);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockState.replaceBlocks).toHaveBeenCalledWith(serverBlocks as any);
|
||||
});
|
||||
|
||||
const message = await screen.findByText(`프로젝트를 불러왔습니다: ${slug}`);
|
||||
expect(message).toBeTruthy();
|
||||
});
|
||||
|
||||
it("에디터 메뉴에서 프로젝트 삭제를 선택하면 서버에 DELETE 요청을 보내고 로컬 저장/자동저장 상태를 정리한 뒤 목록으로 이동해야 한다", async () => {
|
||||
const slug = "delete-test-slug";
|
||||
mockState.projectConfig.slug = slug;
|
||||
|
||||
// 로컬 저장/자동저장 스냅샷이 존재하는 상황을 준비한다.
|
||||
window.localStorage.setItem(`pb:project:${slug}`, "stored-project");
|
||||
window.localStorage.setItem(`pb:autosave:${slug}`, "stored-autosave");
|
||||
|
||||
const fetchMock = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
status: 200,
|
||||
} as any);
|
||||
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const confirmSpy = vi.spyOn(window, "confirm").mockReturnValue(true as any);
|
||||
|
||||
render(<EditorPage />);
|
||||
|
||||
const menuButton = screen.getByText("메뉴");
|
||||
fireEvent.click(menuButton);
|
||||
|
||||
const deleteMenuItem = screen.getByText("프로젝트 삭제");
|
||||
fireEvent.click(deleteMenuItem);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
fetchMock.mock.calls.some(
|
||||
([url, options]: any[]) => url === `/api/projects/${slug}` && options?.method === "DELETE",
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
const deleteCall = fetchMock.mock.calls.find(
|
||||
([url, options]: any[]) => url === `/api/projects/${slug}` && options?.method === "DELETE",
|
||||
) as any;
|
||||
|
||||
const [url, options] = deleteCall;
|
||||
expect(url).toBe(`/api/projects/${slug}`);
|
||||
expect(options.method).toBe("DELETE");
|
||||
|
||||
expect(window.localStorage.getItem(`pb:project:${slug}`)).toBeNull();
|
||||
expect(window.localStorage.getItem(`pb:autosave:${slug}`)).toBeNull();
|
||||
|
||||
confirmSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
@@ -74,6 +74,15 @@ vi.mock("next/link", () => {
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("next/navigation", () => {
|
||||
return {
|
||||
__esModule: true,
|
||||
useRouter: () => ({
|
||||
push: vi.fn(),
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
// 섹션 배경 이미지 TDD
|
||||
// - backgroundImage* 속성이 EditorPage 섹션 wrapper 스타일에 반영되는지 검증한다.
|
||||
|
||||
|
||||
@@ -76,6 +76,15 @@ vi.mock("next/link", () => {
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("next/navigation", () => {
|
||||
return {
|
||||
__esModule: true,
|
||||
useRouter: () => ({
|
||||
push: vi.fn(),
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
describe("EditorPage - 섹션 레이아웃 속성", () => {
|
||||
it("backgroundColorCustom / paddingYPx / maxWidthPx / gapXPx 가 editor-section 스타일에 반영되어야 한다", () => {
|
||||
const section: Block = {
|
||||
|
||||
@@ -74,6 +74,15 @@ vi.mock("next/link", () => {
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("next/navigation", () => {
|
||||
return {
|
||||
__esModule: true,
|
||||
useRouter: () => ({
|
||||
push: vi.fn(),
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
describe("EditorPage - 선택 포커스", () => {
|
||||
it("선택된 블록은 editor-block 에 border-sky-500 / bg-slate-900/80 하이라이트를 가져야 한다", () => {
|
||||
const blocks: Block[] = [
|
||||
|
||||
@@ -74,6 +74,15 @@ vi.mock("next/link", () => {
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("next/navigation", () => {
|
||||
return {
|
||||
__esModule: true,
|
||||
useRouter: () => ({
|
||||
push: vi.fn(),
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
describe("EditorPage - 텍스트 블록 스타일", () => {
|
||||
it("colorCustom / backgroundColorCustom / maxWidthCustom 이 에디터 텍스트 렌더에 반영되어야 한다", () => {
|
||||
const blocks: Block[] = [
|
||||
|
||||
@@ -74,6 +74,15 @@ vi.mock("next/link", () => {
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("next/navigation", () => {
|
||||
return {
|
||||
__esModule: true,
|
||||
useRouter: () => ({
|
||||
push: vi.fn(),
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
// 단순 유튜브 URL, 업로드 비디오 URL 케이스에 대한 에디터 렌더링을 검증한다.
|
||||
describe("EditorPage - 비디오 블록 스타일", () => {
|
||||
it("YouTube URL 비디오 블록은 iframe 으로 렌더되고 기본 16:9 비율 wrapper 를 가져야 한다", () => {
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
import { describe, it, expect, vi, afterEach, beforeEach } from "vitest";
|
||||
import { render, screen, fireEvent, cleanup, waitFor } from "@testing-library/react";
|
||||
|
||||
import LoginPage from "@/app/login/page";
|
||||
|
||||
// next/navigation 의 useRouter 를 목으로 대체해 리다이렉트 동작을 검증한다.
|
||||
export const pushMock = vi.fn();
|
||||
|
||||
vi.mock("next/navigation", () => {
|
||||
return {
|
||||
__esModule: true,
|
||||
useRouter: () => ({ push: pushMock }),
|
||||
};
|
||||
});
|
||||
|
||||
describe("LoginPage", () => {
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal("fetch", vi.fn());
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.unstubAllGlobals();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("올바른 이메일/비밀번호로 로그인하면 /api/auth/login 으로 요청을 보내고 /projects 로 이동해야 한다", async () => {
|
||||
const fetchMock = vi.fn().mockImplementation((input: any, init?: any) => {
|
||||
const url = typeof input === "string" ? input : input.url;
|
||||
const method = init?.method ?? "GET";
|
||||
|
||||
if (url === "/api/auth/me" && method === "GET") {
|
||||
return Promise.resolve(
|
||||
new Response(JSON.stringify({ message: "인증이 필요합니다." }), {
|
||||
status: 401,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
if (url === "/api/auth/login" && method === "POST") {
|
||||
return Promise.resolve(
|
||||
new Response(JSON.stringify({ id: "1", email: "user@example.com" }), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
return Promise.resolve(new Response(null, { status: 500 }));
|
||||
});
|
||||
|
||||
vi.stubGlobal("fetch", fetchMock as any);
|
||||
|
||||
render(<LoginPage />);
|
||||
|
||||
const emailInput = screen.getByLabelText("이메일") as HTMLInputElement;
|
||||
const passwordInput = screen.getByLabelText("비밀번호") as HTMLInputElement;
|
||||
const submitButton = screen.getByRole("button", { name: "로그인" });
|
||||
|
||||
fireEvent.change(emailInput, { target: { value: "user@example.com" } });
|
||||
fireEvent.change(passwordInput, { target: { value: "securePass1" } });
|
||||
|
||||
fireEvent.click(submitButton);
|
||||
|
||||
await waitFor(() => {
|
||||
const loginCall = fetchMock.mock.calls.find(([url, options]) => {
|
||||
return url === "/api/auth/login" && options?.method === "POST";
|
||||
});
|
||||
|
||||
expect(loginCall).toBeDefined();
|
||||
});
|
||||
|
||||
const loginCall = fetchMock.mock.calls.find(([url]) => url === "/api/auth/login") as any;
|
||||
const [url, options] = loginCall;
|
||||
expect(url).toBe("/api/auth/login");
|
||||
expect(options.method).toBe("POST");
|
||||
expect(options.headers["Content-Type"]).toBe("application/json");
|
||||
|
||||
const body = JSON.parse(options.body);
|
||||
expect(body.email).toBe("user@example.com");
|
||||
expect(body.password).toBe("securePass1");
|
||||
|
||||
await waitFor(() => {
|
||||
expect(pushMock).toHaveBeenCalledWith("/projects");
|
||||
});
|
||||
});
|
||||
|
||||
it("로그인 실패 시 에러 메시지를 화면에 표시해야 한다", async () => {
|
||||
const fetchMock = vi.fn().mockImplementation((input: any, init?: any) => {
|
||||
const url = typeof input === "string" ? input : input.url;
|
||||
const method = init?.method ?? "GET";
|
||||
|
||||
if (url === "/api/auth/me" && method === "GET") {
|
||||
return Promise.resolve(
|
||||
new Response(JSON.stringify({ message: "인증이 필요합니다." }), {
|
||||
status: 401,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
if (url === "/api/auth/login" && method === "POST") {
|
||||
return Promise.resolve(
|
||||
new Response(JSON.stringify({ message: "이메일 또는 비밀번호가 올바르지 않습니다." }), {
|
||||
status: 401,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
return Promise.resolve(new Response(null, { status: 500 }));
|
||||
});
|
||||
|
||||
vi.stubGlobal("fetch", fetchMock as any);
|
||||
|
||||
render(<LoginPage />);
|
||||
|
||||
const emailInput = screen.getByLabelText("이메일") as HTMLInputElement;
|
||||
const passwordInput = screen.getByLabelText("비밀번호") as HTMLInputElement;
|
||||
const submitButton = screen.getByRole("button", { name: "로그인" });
|
||||
|
||||
fireEvent.change(emailInput, { target: { value: "user@example.com" } });
|
||||
fireEvent.change(passwordInput, { target: { value: "wrongpass" } });
|
||||
|
||||
fireEvent.click(submitButton);
|
||||
|
||||
const errorText = await screen.findByText(/이메일 또는 비밀번호가 올바르지 않습니다./);
|
||||
expect(errorText).toBeTruthy();
|
||||
});
|
||||
|
||||
it("이미 로그인된 상태에서 /login 에 접근하면 /projects 로 리다이렉트해야 한다", async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue(
|
||||
new Response(JSON.stringify({ id: "1", email: "user@example.com", tokenVersion: 1 }), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}),
|
||||
);
|
||||
|
||||
vi.stubGlobal("fetch", fetchMock as any);
|
||||
|
||||
render(<LoginPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
expect(fetchMock.mock.calls[0][0]).toBe("/api/auth/me");
|
||||
expect(pushMock).toHaveBeenCalledWith("/projects");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,103 @@
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
||||
import { render, cleanup } from "@testing-library/react";
|
||||
import PreviewPage from "@/app/preview/page";
|
||||
import type { Block, ProjectConfig } from "@/features/editor/state/editorStore";
|
||||
|
||||
// PreviewPage 자동 복원 TDD
|
||||
// - localStorage 의 pb:autosave:<slug> 스냅샷을 기반으로 초기 렌더에서 replaceBlocks + updateProjectConfig 로 상태를 복원해야 한다.
|
||||
|
||||
let mockState: any;
|
||||
|
||||
beforeEach(() => {
|
||||
const baseProjectConfig: ProjectConfig = {
|
||||
title: "프리뷰 자동복원 테스트",
|
||||
slug: "preview-autosave-test",
|
||||
canvasPreset: "full",
|
||||
} as ProjectConfig;
|
||||
|
||||
mockState = {
|
||||
blocks: [
|
||||
{
|
||||
id: "blk_1",
|
||||
type: "text",
|
||||
props: {
|
||||
text: "프리뷰 기본 블록",
|
||||
align: "left",
|
||||
size: "base",
|
||||
},
|
||||
},
|
||||
] as Block[],
|
||||
projectConfig: baseProjectConfig,
|
||||
replaceBlocks: vi.fn(),
|
||||
updateProjectConfig: vi.fn(),
|
||||
};
|
||||
|
||||
window.localStorage.clear();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
vi.mock("@/features/editor/state/editorStore", () => {
|
||||
const useEditorStore = (selector: (state: any) => any) => selector(mockState);
|
||||
(useEditorStore as any).getState = () => mockState;
|
||||
|
||||
return {
|
||||
__esModule: true,
|
||||
useEditorStore,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("next/link", () => {
|
||||
return {
|
||||
__esModule: true,
|
||||
default: ({ href, children }: any) => <a href={href}>{children}</a>,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("next/navigation", () => {
|
||||
return {
|
||||
__esModule: true,
|
||||
useRouter: () => ({
|
||||
push: vi.fn(),
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
describe("PreviewPage - 자동 복원", () => {
|
||||
it("pb:autosave:<slug> 스냅샷이 있으면 초기 렌더에서 replaceBlocks 와 updateProjectConfig 로 상태를 복원해야 한다", () => {
|
||||
const savedBlocks: Block[] = [
|
||||
{
|
||||
id: "saved_1",
|
||||
type: "text",
|
||||
props: {
|
||||
text: "저장된 프리뷰 블록",
|
||||
align: "center",
|
||||
size: "lg",
|
||||
},
|
||||
} as any,
|
||||
];
|
||||
|
||||
const savedConfig: ProjectConfig = {
|
||||
title: "저장된 프리뷰 프로젝트",
|
||||
slug: "preview-autosave-test",
|
||||
canvasPreset: "full",
|
||||
} as ProjectConfig;
|
||||
|
||||
const payload = {
|
||||
blocks: savedBlocks,
|
||||
projectConfig: savedConfig,
|
||||
};
|
||||
|
||||
window.localStorage.setItem("pb:autosave:preview-autosave-test", JSON.stringify(payload));
|
||||
|
||||
render(<PreviewPage />);
|
||||
|
||||
expect(mockState.replaceBlocks).toHaveBeenCalledTimes(1);
|
||||
expect(mockState.replaceBlocks).toHaveBeenCalledWith(savedBlocks as any);
|
||||
|
||||
expect(mockState.updateProjectConfig).toHaveBeenCalledTimes(1);
|
||||
expect(mockState.updateProjectConfig).toHaveBeenCalledWith(savedConfig as ProjectConfig);
|
||||
});
|
||||
});
|
||||
@@ -39,6 +39,15 @@ vi.mock("next/link", () => {
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("next/navigation", () => {
|
||||
return {
|
||||
__esModule: true,
|
||||
useRouter: () => ({
|
||||
push: vi.fn(),
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
||||
import { render, screen, fireEvent, cleanup, waitFor } from "@testing-library/react";
|
||||
import type { Block, ProjectConfig } from "@/features/editor/state/editorStore";
|
||||
import PreviewPage from "@/app/preview/page";
|
||||
|
||||
// PreviewPage Export ZIP TDD
|
||||
// - 프리뷰 헤더에 "페이지 파일로 내보내기 (ZIP)" 버튼이 노출되어야 한다.
|
||||
// - 버튼 클릭 시 /api/export 로 blocks + projectConfig 를 payload 로 POST 요청을 보내야 한다.
|
||||
|
||||
let mockState: any;
|
||||
|
||||
beforeEach(() => {
|
||||
const baseProjectConfig: ProjectConfig = {
|
||||
title: "프리뷰 Export 테스트",
|
||||
slug: "preview-export-test",
|
||||
canvasPreset: "full",
|
||||
} as ProjectConfig;
|
||||
|
||||
mockState = {
|
||||
blocks: [
|
||||
{
|
||||
id: "blk_text_1",
|
||||
type: "text",
|
||||
props: {
|
||||
text: "프리뷰 Export 본문",
|
||||
align: "left",
|
||||
size: "base",
|
||||
},
|
||||
},
|
||||
] as Block[],
|
||||
projectConfig: baseProjectConfig,
|
||||
};
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
vi.mock("@/features/editor/state/editorStore", () => {
|
||||
const useEditorStore = (selector: (state: any) => any) => selector(mockState);
|
||||
return {
|
||||
__esModule: true,
|
||||
useEditorStore,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("next/link", () => {
|
||||
return {
|
||||
__esModule: true,
|
||||
default: ({ href, children }: any) => <a href={href}>{children}</a>,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("next/navigation", () => {
|
||||
return {
|
||||
__esModule: true,
|
||||
useRouter: () => ({
|
||||
push: vi.fn(),
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
describe("PreviewPage - ZIP Export", () => {
|
||||
it("프리뷰 헤더에 프로젝트 목록 링크가 노출되고 /projects 로 연결되어야 한다", () => {
|
||||
render(<PreviewPage />);
|
||||
|
||||
const link = screen.getByText("프로젝트 목록");
|
||||
expect(link).toBeTruthy();
|
||||
expect(link.closest("a")?.getAttribute("href")).toBe("/projects");
|
||||
});
|
||||
|
||||
it("프리뷰 헤더에 ZIP Export 버튼이 노출되어야 한다", () => {
|
||||
render(<PreviewPage />);
|
||||
|
||||
const exportButton = screen.getByText("페이지 파일로 내보내기 (ZIP)");
|
||||
expect(exportButton).toBeTruthy();
|
||||
});
|
||||
|
||||
it("ZIP Export 버튼 클릭 시 /api/export 로 blocks + projectConfig 를 payload 로 POST 해야 한다", async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
// JSDOM 환경에서 blob() 호출을 안전하게 처리하기 위한 최소 mock
|
||||
blob: vi.fn().mockResolvedValue(new Blob(["dummy-zip"])),
|
||||
} as any);
|
||||
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
render(<PreviewPage />);
|
||||
|
||||
const exportButton = screen.getByText("페이지 파일로 내보내기 (ZIP)");
|
||||
fireEvent.click(exportButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
fetchMock.mock.calls.some(
|
||||
([url, options]: any[]) => url === "/api/export" && options?.method === "POST",
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
const exportCall = fetchMock.mock.calls.find(
|
||||
([url, options]: any[]) => url === "/api/export" && options?.method === "POST",
|
||||
) as any;
|
||||
|
||||
const [url, options] = exportCall;
|
||||
expect(url).toBe("/api/export");
|
||||
expect(options.method).toBe("POST");
|
||||
expect(options.headers["Content-Type"]).toBe("application/json");
|
||||
|
||||
const parsed = JSON.parse(options.body);
|
||||
expect(Array.isArray(parsed.blocks)).toBe(true);
|
||||
expect(parsed.blocks[0].id).toBe("blk_text_1");
|
||||
expect(parsed.projectConfig.slug).toBe("preview-export-test");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,53 @@
|
||||
import { describe, it, expect, afterEach, vi } from "vitest";
|
||||
import { render, cleanup, waitFor } from "@testing-library/react";
|
||||
|
||||
import PreviewPage from "@/app/preview/page";
|
||||
|
||||
// next/navigation 의 useRouter 를 목으로 대체해 인증 실패 시 리다이렉트 동작을 검증한다.
|
||||
export const pushMock = vi.fn();
|
||||
|
||||
vi.mock("next/navigation", () => {
|
||||
return {
|
||||
__esModule: true,
|
||||
useRouter: () => ({ push: pushMock }),
|
||||
};
|
||||
});
|
||||
|
||||
describe("PreviewPage 인증 가드", () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.unstubAllGlobals();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("/api/auth/me 가 401 을 반환하면 /login 으로 리다이렉트해야 한다", async () => {
|
||||
const fetchMock = vi.fn().mockImplementation((input: any, init?: any) => {
|
||||
const url = typeof input === "string" ? input : input.url;
|
||||
const method = init?.method ?? "GET";
|
||||
|
||||
if (url === "/api/auth/me" && method === "GET") {
|
||||
return Promise.resolve(
|
||||
new Response(JSON.stringify({ message: "인증이 필요합니다." }), {
|
||||
status: 401,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
// 기타 요청은 200 으로 처리하되, 테스트에서는 사용하지 않는다.
|
||||
return Promise.resolve(new Response(null, { status: 200 }));
|
||||
});
|
||||
|
||||
vi.stubGlobal("fetch", fetchMock as any);
|
||||
|
||||
render(<PreviewPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(fetchMock).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(pushMock).toHaveBeenCalledWith("/login");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,466 @@
|
||||
import { describe, it, expect, afterEach, vi } from "vitest";
|
||||
import { render, screen, cleanup, waitFor, fireEvent } from "@testing-library/react";
|
||||
import ProjectsPage from "@/app/projects/page";
|
||||
|
||||
// next/navigation 의 useRouter 를 목으로 대체해 인증 실패 시 리다이렉트 동작을 검증한다.
|
||||
export const pushMock = vi.fn();
|
||||
|
||||
vi.mock("next/navigation", () => {
|
||||
return {
|
||||
__esModule: true,
|
||||
useRouter: () => ({ push: pushMock }),
|
||||
};
|
||||
});
|
||||
|
||||
// ProjectsPage 프로젝트 목록 TDD
|
||||
// - 마운트 시 /api/projects 로 GET 요청을 보내고, 반환된 프로젝트 목록을 테이블로 렌더링해야 한다.
|
||||
// - 각 프로젝트 행에는 /editor?slug=..., /preview?slug=... 로 이동하는 "편집" / "미리보기" 링크가 있어야 한다.
|
||||
// - 각 프로젝트 행에는 프로젝트를 삭제하는 "삭제" 버튼이 있고, 클릭 시 서버에 DELETE 요청을 보내고 목록/로컬스토리지를 갱신해야 한다.
|
||||
// - 체크박스로 여러 프로젝트를 선택한 뒤 상단의 "선택 삭제" 버튼으로 일괄 삭제할 수 있어야 한다.
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
describe("ProjectsPage - 프로젝트 목록", () => {
|
||||
it("/api/projects 로부터 받은 프로젝트 목록을 제목/slug 기준으로 렌더링하고, 편집/미리보기 링크를 노출해야 한다", async () => {
|
||||
const projects = [
|
||||
{
|
||||
id: "1",
|
||||
title: "테스트 프로젝트 A",
|
||||
slug: "test-project-a",
|
||||
status: "DRAFT",
|
||||
createdAt: "2025-01-01T00:00:00.000Z",
|
||||
updatedAt: "2025-01-01T00:00:00.000Z",
|
||||
},
|
||||
{
|
||||
id: "2",
|
||||
title: "테스트 프로젝트 B",
|
||||
slug: "test-project-b",
|
||||
status: "PUBLISHED",
|
||||
createdAt: "2025-01-02T00:00:00.000Z",
|
||||
updatedAt: "2025-01-02T00:00:00.000Z",
|
||||
},
|
||||
];
|
||||
|
||||
const fetchMock = vi.fn().mockResolvedValue(
|
||||
new Response(JSON.stringify(projects), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}),
|
||||
);
|
||||
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
render(<ProjectsPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
const [url, options] = fetchMock.mock.calls[0] as any;
|
||||
expect(url).toBe("/api/projects");
|
||||
expect(options).toBeUndefined();
|
||||
|
||||
expect(await screen.findByText("테스트 프로젝트 A")).toBeTruthy();
|
||||
expect(screen.getByText("test-project-a")).toBeTruthy();
|
||||
|
||||
expect(await screen.findByText("테스트 프로젝트 B")).toBeTruthy();
|
||||
expect(screen.getByText("test-project-b")).toBeTruthy();
|
||||
|
||||
const editLinks = screen.getAllByText("편집");
|
||||
expect(editLinks).toHaveLength(2);
|
||||
expect(editLinks[0].closest("a")?.getAttribute("href")).toBe("/editor?slug=test-project-a");
|
||||
expect(editLinks[1].closest("a")?.getAttribute("href")).toBe("/editor?slug=test-project-b");
|
||||
|
||||
const previewLinks = screen.getAllByText("미리보기");
|
||||
expect(previewLinks).toHaveLength(2);
|
||||
expect(previewLinks[0].closest("a")?.getAttribute("href")).toBe("/preview?slug=test-project-a");
|
||||
expect(previewLinks[1].closest("a")?.getAttribute("href")).toBe("/preview?slug=test-project-b");
|
||||
});
|
||||
|
||||
it("삭제 버튼 클릭 시 서버에 DELETE 요청을 보내고 목록과 로컬스토리지를 갱신해야 한다", async () => {
|
||||
const projects = [
|
||||
{
|
||||
id: "1",
|
||||
title: "테스트 프로젝트 A",
|
||||
slug: "test-project-a",
|
||||
status: "DRAFT",
|
||||
createdAt: "2025-01-01T00:00:00.000Z",
|
||||
updatedAt: "2025-01-01T00:00:00.000Z",
|
||||
},
|
||||
{
|
||||
id: "2",
|
||||
title: "테스트 프로젝트 B",
|
||||
slug: "test-project-b",
|
||||
status: "PUBLISHED",
|
||||
createdAt: "2025-01-02T00:00:00.000Z",
|
||||
updatedAt: "2025-01-02T00:00:00.000Z",
|
||||
},
|
||||
];
|
||||
|
||||
window.localStorage.setItem("pb:project:test-project-a", "stored-project");
|
||||
window.localStorage.setItem("pb:autosave:test-project-a", "stored-autosave");
|
||||
|
||||
const fetchMock = vi.fn().mockImplementation((input: any, init?: any) => {
|
||||
const url = typeof input === "string" ? input : input.url;
|
||||
const method = init?.method ?? "GET";
|
||||
|
||||
if (url === "/api/projects" && method === "GET") {
|
||||
return Promise.resolve(
|
||||
new Response(JSON.stringify(projects), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
if (url === "/api/projects/test-project-a" && method === "DELETE") {
|
||||
return Promise.resolve(new Response(null, { status: 200 }));
|
||||
}
|
||||
|
||||
return Promise.resolve(new Response(null, { status: 500 }));
|
||||
});
|
||||
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const confirmSpy = vi.spyOn(window, "confirm").mockReturnValue(true as any);
|
||||
|
||||
render(<ProjectsPage />);
|
||||
|
||||
expect(await screen.findByText("테스트 프로젝트 A")).toBeTruthy();
|
||||
|
||||
const deleteButtons = screen.getAllByText("삭제");
|
||||
expect(deleteButtons.length).toBeGreaterThan(0);
|
||||
|
||||
deleteButtons[0].click();
|
||||
|
||||
await waitFor(() => {
|
||||
const deleteCall = fetchMock.mock.calls.find(([url, options]) => {
|
||||
return url === "/api/projects/test-project-a" && options?.method === "DELETE";
|
||||
});
|
||||
|
||||
expect(deleteCall).toBeDefined();
|
||||
});
|
||||
|
||||
expect(screen.queryByText("테스트 프로젝트 A")).toBeNull();
|
||||
expect(window.localStorage.getItem("pb:project:test-project-a")).toBeNull();
|
||||
expect(window.localStorage.getItem("pb:autosave:test-project-a")).toBeNull();
|
||||
|
||||
confirmSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("체크박스로 여러 프로젝트를 선택한 뒤 '선택 삭제' 버튼으로 일괄 삭제할 수 있어야 한다", async () => {
|
||||
const projects = [
|
||||
{
|
||||
id: "1",
|
||||
title: "테스트 프로젝트 A",
|
||||
slug: "test-project-a",
|
||||
status: "DRAFT",
|
||||
createdAt: "2025-01-01T00:00:00.000Z",
|
||||
updatedAt: "2025-01-01T00:00:00.000Z",
|
||||
},
|
||||
{
|
||||
id: "2",
|
||||
title: "테스트 프로젝트 B",
|
||||
slug: "test-project-b",
|
||||
status: "PUBLISHED",
|
||||
createdAt: "2025-01-02T00:00:00.000Z",
|
||||
updatedAt: "2025-01-02T00:00:00.000Z",
|
||||
},
|
||||
];
|
||||
|
||||
window.localStorage.setItem("pb:project:test-project-a", "stored-project-a");
|
||||
window.localStorage.setItem("pb:autosave:test-project-a", "stored-autosave-a");
|
||||
window.localStorage.setItem("pb:project:test-project-b", "stored-project-b");
|
||||
window.localStorage.setItem("pb:autosave:test-project-b", "stored-autosave-b");
|
||||
|
||||
const fetchMock = vi.fn().mockImplementation((input: any, init?: any) => {
|
||||
const url = typeof input === "string" ? input : input.url;
|
||||
const method = init?.method ?? "GET";
|
||||
|
||||
if (url === "/api/projects" && method === "GET") {
|
||||
return Promise.resolve(
|
||||
new Response(JSON.stringify(projects), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
if (url === "/api/projects/test-project-a" && method === "DELETE") {
|
||||
return Promise.resolve(new Response(null, { status: 200 }));
|
||||
}
|
||||
|
||||
if (url === "/api/projects/test-project-b" && method === "DELETE") {
|
||||
return Promise.resolve(new Response(null, { status: 200 }));
|
||||
}
|
||||
|
||||
return Promise.resolve(new Response(null, { status: 500 }));
|
||||
});
|
||||
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const confirmSpy = vi.spyOn(window, "confirm").mockReturnValue(true as any);
|
||||
|
||||
render(<ProjectsPage />);
|
||||
|
||||
expect(await screen.findByText("테스트 프로젝트 A")).toBeTruthy();
|
||||
expect(await screen.findByText("테스트 프로젝트 B")).toBeTruthy();
|
||||
|
||||
const checkboxA = screen.getByLabelText("테스트 프로젝트 A 선택");
|
||||
const checkboxB = screen.getByLabelText("테스트 프로젝트 B 선택");
|
||||
|
||||
fireEvent.click(checkboxA);
|
||||
fireEvent.click(checkboxB);
|
||||
|
||||
const bulkDeleteButton = screen.getByText("선택 삭제");
|
||||
fireEvent.click(bulkDeleteButton);
|
||||
|
||||
await waitFor(() => {
|
||||
const deleteCalls = fetchMock.mock.calls.filter(([, options]) => options?.method === "DELETE");
|
||||
const hasA = deleteCalls.some(([url]) => url === "/api/projects/test-project-a");
|
||||
const hasB = deleteCalls.some(([url]) => url === "/api/projects/test-project-b");
|
||||
|
||||
expect(hasA).toBe(true);
|
||||
expect(hasB).toBe(true);
|
||||
|
||||
// DELETE 요청이 성공적으로 처리된 뒤에는 목록에서 해당 프로젝트들이 사라져야 한다.
|
||||
expect(screen.queryByText("테스트 프로젝트 A")).toBeNull();
|
||||
expect(screen.queryByText("테스트 프로젝트 B")).toBeNull();
|
||||
});
|
||||
|
||||
expect(window.localStorage.getItem("pb:project:test-project-a")).toBeNull();
|
||||
expect(window.localStorage.getItem("pb:autosave:test-project-a")).toBeNull();
|
||||
expect(window.localStorage.getItem("pb:project:test-project-b")).toBeNull();
|
||||
expect(window.localStorage.getItem("pb:autosave:test-project-b")).toBeNull();
|
||||
|
||||
confirmSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("/api/projects 가 401 을 반환하면 로그인 페이지로 리다이렉트해야 한다", async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue(
|
||||
new Response(JSON.stringify({ message: "프로젝트 목록을 조회하려면 로그인이 필요합니다." }), {
|
||||
status: 401,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}),
|
||||
);
|
||||
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
render(<ProjectsPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(pushMock).toHaveBeenCalledWith("/login");
|
||||
});
|
||||
});
|
||||
|
||||
it("헤더의 '로그아웃' 버튼을 클릭하면 /api/auth/logout 으로 POST 요청을 보내고 /login 으로 이동해야 한다", async () => {
|
||||
const projects = [
|
||||
{
|
||||
id: "1",
|
||||
title: "테스트 프로젝트 A",
|
||||
slug: "test-project-a",
|
||||
status: "DRAFT",
|
||||
createdAt: "2025-01-01T00:00:00.000Z",
|
||||
updatedAt: "2025-01-01T00:00:00.000Z",
|
||||
},
|
||||
];
|
||||
|
||||
const fetchMock = vi.fn().mockImplementation((input: any, init?: any) => {
|
||||
const url = typeof input === "string" ? input : input.url;
|
||||
const method = init?.method ?? "GET";
|
||||
|
||||
if (url === "/api/projects" && method === "GET") {
|
||||
return Promise.resolve(
|
||||
new Response(JSON.stringify(projects), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
if (url === "/api/auth/logout" && method === "POST") {
|
||||
return Promise.resolve(new Response(JSON.stringify({ message: "ok" }), { status: 200 }));
|
||||
}
|
||||
|
||||
return Promise.resolve(new Response(null, { status: 500 }));
|
||||
});
|
||||
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
render(<ProjectsPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(fetchMock).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
const logoutButton = await screen.findByRole("button", { name: "로그아웃" });
|
||||
fireEvent.click(logoutButton);
|
||||
|
||||
await waitFor(() => {
|
||||
const logoutCall = fetchMock.mock.calls.find(([url, options]) => {
|
||||
return url === "/api/auth/logout" && options?.method === "POST";
|
||||
});
|
||||
|
||||
expect(logoutCall).toBeDefined();
|
||||
expect(pushMock).toHaveBeenCalledWith("/login");
|
||||
});
|
||||
});
|
||||
|
||||
it("/api/projects 가 500 을 반환하면 에러 메시지를 표시해야 한다", async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue(
|
||||
new Response("server error", {
|
||||
status: 500,
|
||||
headers: { "Content-Type": "text/plain" },
|
||||
}),
|
||||
);
|
||||
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
render(<ProjectsPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
expect(
|
||||
await screen.findByText(
|
||||
"프로젝트 목록을 불러오는 중 오류가 발생했습니다. 잠시 후 다시 시도해 주세요.",
|
||||
),
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
it("단일 프로젝트 삭제가 실패하면 에러 메시지를 표시해야 한다", async () => {
|
||||
const projects = [
|
||||
{
|
||||
id: "1",
|
||||
title: "삭제 실패 프로젝트",
|
||||
slug: "delete-fail-project",
|
||||
status: "DRAFT",
|
||||
createdAt: "2025-01-01T00:00:00.000Z",
|
||||
updatedAt: "2025-01-01T00:00:00.000Z",
|
||||
},
|
||||
];
|
||||
|
||||
const fetchMock = vi.fn().mockImplementation((input: any, init?: any) => {
|
||||
const url = typeof input === "string" ? input : input.url;
|
||||
const method = init?.method ?? "GET";
|
||||
|
||||
if (url === "/api/projects" && method === "GET") {
|
||||
return Promise.resolve(
|
||||
new Response(JSON.stringify(projects), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
if (url === "/api/projects/delete-fail-project" && method === "DELETE") {
|
||||
return Promise.resolve(new Response("fail", { status: 500 }));
|
||||
}
|
||||
|
||||
return Promise.resolve(new Response(null, { status: 500 }));
|
||||
});
|
||||
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const confirmSpy = vi.spyOn(window, "confirm").mockReturnValue(true as any);
|
||||
|
||||
render(<ProjectsPage />);
|
||||
|
||||
expect(await screen.findByText("삭제 실패 프로젝트")).toBeTruthy();
|
||||
|
||||
const deleteButton = screen.getByText("삭제");
|
||||
fireEvent.click(deleteButton);
|
||||
|
||||
expect(
|
||||
await screen.findByText("프로젝트 삭제 중 오류가 발생했습니다. 잠시 후 다시 시도해 주세요."),
|
||||
).toBeTruthy();
|
||||
|
||||
// 삭제 실패이므로 여전히 목록에 남아 있어야 한다.
|
||||
expect(screen.getByText("삭제 실패 프로젝트")).toBeTruthy();
|
||||
|
||||
confirmSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("일괄 삭제에서 일부만 성공하면 에러 메시지를 표시해야 한다", async () => {
|
||||
const projects = [
|
||||
{
|
||||
id: "1",
|
||||
title: "성공 프로젝트",
|
||||
slug: "success-project",
|
||||
status: "DRAFT",
|
||||
createdAt: "2025-01-01T00:00:00.000Z",
|
||||
updatedAt: "2025-01-01T00:00:00.000Z",
|
||||
},
|
||||
{
|
||||
id: "2",
|
||||
title: "실패 프로젝트",
|
||||
slug: "fail-project",
|
||||
status: "DRAFT",
|
||||
createdAt: "2025-01-02T00:00:00.000Z",
|
||||
updatedAt: "2025-01-02T00:00:00.000Z",
|
||||
},
|
||||
];
|
||||
|
||||
const fetchMock = vi.fn().mockImplementation((input: any, init?: any) => {
|
||||
const url = typeof input === "string" ? input : input.url;
|
||||
const method = init?.method ?? "GET";
|
||||
|
||||
if (url === "/api/projects" && method === "GET") {
|
||||
return Promise.resolve(
|
||||
new Response(JSON.stringify(projects), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
if (url === "/api/projects/success-project" && method === "DELETE") {
|
||||
return Promise.resolve(new Response(null, { status: 200 }));
|
||||
}
|
||||
|
||||
if (url === "/api/projects/fail-project" && method === "DELETE") {
|
||||
return Promise.resolve(new Response("fail", { status: 500 }));
|
||||
}
|
||||
|
||||
return Promise.resolve(new Response(null, { status: 500 }));
|
||||
});
|
||||
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const confirmSpy = vi.spyOn(window, "confirm").mockReturnValue(true as any);
|
||||
|
||||
render(<ProjectsPage />);
|
||||
|
||||
expect(await screen.findByText("성공 프로젝트")).toBeTruthy();
|
||||
expect(await screen.findByText("실패 프로젝트")).toBeTruthy();
|
||||
|
||||
const checkboxSuccess = screen.getByLabelText("성공 프로젝트 선택");
|
||||
const checkboxFail = screen.getByLabelText("실패 프로젝트 선택");
|
||||
|
||||
fireEvent.click(checkboxSuccess);
|
||||
fireEvent.click(checkboxFail);
|
||||
|
||||
const bulkDeleteButton = screen.getByText("선택 삭제");
|
||||
fireEvent.click(bulkDeleteButton);
|
||||
|
||||
expect(
|
||||
await screen.findByText(
|
||||
"일부 프로젝트 삭제에 실패했습니다. 페이지를 새로고침한 뒤 목록을 확인해 주세요.",
|
||||
),
|
||||
).toBeTruthy();
|
||||
|
||||
// 성공 프로젝트는 목록에서 사라지고, 실패 프로젝트는 남아 있어야 한다.
|
||||
expect(screen.queryByText("성공 프로젝트")).toBeNull();
|
||||
expect(screen.getByText("실패 프로젝트")).toBeTruthy();
|
||||
|
||||
confirmSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,72 @@
|
||||
import { describe, it, expect, vi, afterEach } from "vitest";
|
||||
import { render, screen, fireEvent, cleanup } from "@testing-library/react";
|
||||
import type { Block } from "@/features/editor/state/editorStore";
|
||||
import { PropertiesSidebar } from "@/app/editor/panels/PropertiesSidebar";
|
||||
|
||||
describe("PropertiesSidebar HELP 튜토리얼 모달", () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
const createTextBlock = (): Block => ({
|
||||
id: "text-1",
|
||||
type: "text",
|
||||
props: {
|
||||
text: "도움말 테스트",
|
||||
align: "left",
|
||||
size: "base",
|
||||
} as any,
|
||||
sectionId: null,
|
||||
columnId: null,
|
||||
});
|
||||
|
||||
const defaultProps = (blocks: Block[], selectedBlockId: string | null) => ({
|
||||
blocks,
|
||||
selectedBlockId,
|
||||
selectedListItemId: null,
|
||||
updateBlock: vi.fn(),
|
||||
removeBlock: vi.fn(),
|
||||
duplicateBlock: vi.fn(),
|
||||
editingBlockId: null,
|
||||
setEditingText: vi.fn(),
|
||||
onMoveSelectedItemUp: vi.fn(),
|
||||
onMoveSelectedItemDown: vi.fn(),
|
||||
onIndentSelectedItem: vi.fn(),
|
||||
onOutdentSelectedItem: vi.fn(),
|
||||
});
|
||||
|
||||
it("텍스트 블록이 선택된 상태에서 HELP 버튼을 노출해야 한다", () => {
|
||||
const textBlock = createTextBlock();
|
||||
|
||||
render(<PropertiesSidebar {...defaultProps([textBlock], textBlock.id)} />);
|
||||
|
||||
const helpButton = screen.getByRole("button", { name: "HELP" });
|
||||
expect(helpButton).toBeDefined();
|
||||
});
|
||||
|
||||
it("HELP 버튼 클릭 시 현재 블록 타입에 맞는 튜토리얼 모달이 열려야 한다", () => {
|
||||
const textBlock = createTextBlock();
|
||||
|
||||
render(<PropertiesSidebar {...defaultProps([textBlock], textBlock.id)} />);
|
||||
|
||||
const helpButton = screen.getByRole("button", { name: "HELP" });
|
||||
fireEvent.click(helpButton);
|
||||
|
||||
expect(screen.getByText("텍스트 블록 튜토리얼")).toBeDefined();
|
||||
expect(screen.getByText(/제목과 본문 텍스트를 입력할 때 사용하는 기본 블록입니다/)).toBeDefined();
|
||||
});
|
||||
|
||||
it("모달의 닫기 버튼을 클릭하면 튜토리얼 모달이 닫혀야 한다", () => {
|
||||
const textBlock = createTextBlock();
|
||||
|
||||
render(<PropertiesSidebar {...defaultProps([textBlock], textBlock.id)} />);
|
||||
|
||||
const helpButton = screen.getByRole("button", { name: "HELP" });
|
||||
fireEvent.click(helpButton);
|
||||
|
||||
const closeButton = screen.getByRole("button", { name: "닫기" });
|
||||
fireEvent.click(closeButton);
|
||||
|
||||
expect(screen.queryByText("텍스트 블록 튜토리얼")).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -88,7 +88,7 @@ describe("PublicPageRenderer - 블록 배경색", () => {
|
||||
expect(listNoBg!.style.backgroundColor === "" || listNoBg!.style.backgroundColor === "transparent").toBe(true);
|
||||
});
|
||||
|
||||
it("form 컨트롤러 블록은 backgroundColorCustom 이 설정된 경우에만 폼 래퍼에 배경색이 적용되어야 한다", () => {
|
||||
it("form 컨트롤러 블록은 프리뷰에서 별도 폼 래퍼를 렌더하지 않아야 한다", () => {
|
||||
const blocksWithBg: Block[] = [
|
||||
{
|
||||
id: "form_with_bg",
|
||||
@@ -105,10 +105,10 @@ describe("PublicPageRenderer - 블록 배경색", () => {
|
||||
} as any,
|
||||
];
|
||||
|
||||
const { getByTestId, rerender } = render(<PublicPageRenderer blocks={blocksWithBg} />);
|
||||
const { queryByTestId, rerender } = render(<PublicPageRenderer blocks={blocksWithBg} />);
|
||||
|
||||
const formWithBg = getByTestId("preview-form-controller") as HTMLFormElement;
|
||||
expect(formWithBg.style.backgroundColor).toBe("rgb(17, 17, 17)");
|
||||
const formWithBg = queryByTestId("preview-form-controller");
|
||||
expect(formWithBg).toBeNull();
|
||||
|
||||
const blocksWithoutBg: Block[] = [
|
||||
{
|
||||
@@ -126,8 +126,8 @@ describe("PublicPageRenderer - 블록 배경색", () => {
|
||||
|
||||
rerender(<PublicPageRenderer blocks={blocksWithoutBg} />);
|
||||
|
||||
const formNoBg = getByTestId("preview-form-controller") as HTMLFormElement;
|
||||
expect(formNoBg.style.backgroundColor === "" || formNoBg.style.backgroundColor === "transparent").toBe(true);
|
||||
const formNoBg = queryByTestId("preview-form-controller");
|
||||
expect(formNoBg).toBeNull();
|
||||
});
|
||||
|
||||
it("section 블록은 backgroundColorCustom 이 설정된 경우에만 섹션 요소에 배경색이 적용되어야 한다", () => {
|
||||
|
||||
@@ -33,6 +33,15 @@ describe("PublicPageRenderer - 버튼 블록 스타일", () => {
|
||||
|
||||
const link = container.querySelector("a") as HTMLAnchorElement | null;
|
||||
expect(link).not.toBeNull();
|
||||
|
||||
const wrapper = link!.parentElement as HTMLElement | null;
|
||||
expect(wrapper).not.toBeNull();
|
||||
// 정렬/버튼 베이스는 pb-* 토큰 클래스로 렌더된다.
|
||||
expect(wrapper!.className).toContain("pb-text-center");
|
||||
expect(link!.className).toContain("pb-btn-base");
|
||||
expect(link!.className).toContain("pb-btn-size-md");
|
||||
expect(link!.className).toContain("pb-btn-variant-solid-primary");
|
||||
expect(link!.className).toContain("pb-btn-radius-md");
|
||||
// 배경색: #123456 → rgb(18, 52, 86)
|
||||
expect(link!.style.backgroundColor).toBe("rgb(18, 52, 86)");
|
||||
// 외곽선 색상: #ff0000 → rgb(255, 0, 0)
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import { describe, it, expect, afterEach } from "vitest";
|
||||
import { render, screen, cleanup } from "@testing-library/react";
|
||||
import { PublicPageRenderer } from "@/features/editor/components/PublicPageRenderer";
|
||||
import { createFooterTemplateBlocks } from "@/app/editor/templates/footerTemplate";
|
||||
import type { Block } from "@/features/editor/state/editorStore";
|
||||
|
||||
// PublicPageRenderer 푸터 템플릿 TDD
|
||||
// - footerTemplate 로 생성한 섹션이 프리뷰에서 섹션/텍스트 구조로 올바르게 렌더되는지 검증한다.
|
||||
|
||||
describe("PublicPageRenderer - 푸터 템플릿", () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
it("푸터 템플릿 섹션은 프리뷰에서 섹션과 푸터 텍스트들을 렌더해야 한다", () => {
|
||||
const sectionId = "footer_preview_section";
|
||||
let i = 0;
|
||||
const createId = () => `footer_${++i}`;
|
||||
|
||||
const { blocks } = createFooterTemplateBlocks({ sectionId, createId });
|
||||
|
||||
render(<PublicPageRenderer blocks={blocks as Block[]} />);
|
||||
|
||||
const section = screen.getByTestId("preview-section");
|
||||
expect(section.getAttribute("data-section-id")).toBe(sectionId);
|
||||
|
||||
expect(screen.getByText("MyLanding")).toBeTruthy();
|
||||
expect(screen.getByText("더 나은 웹사이트를 위한 최고의 선택.")).toBeTruthy();
|
||||
expect(screen.getByText(/서비스 소개/)).toBeTruthy();
|
||||
expect(screen.getByText(/문의하기/)).toBeTruthy();
|
||||
expect(screen.getByText(/© 2025 MyLanding\./)).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -15,7 +15,7 @@ describe("PublicPageRenderer - 폼 제출 메시지", () => {
|
||||
(global as any).fetch = undefined;
|
||||
});
|
||||
|
||||
it("성공 응답 시 config.successMessage 를 success 스타일로 표시해야 한다", async () => {
|
||||
it("FormBlock 이 있어도 프리뷰에서는 폼 컨트롤러를 렌더하지 않아야 한다 (성공 메시지 설정)", () => {
|
||||
const blocks: Block[] = [
|
||||
{
|
||||
id: "form_success",
|
||||
@@ -42,19 +42,12 @@ describe("PublicPageRenderer - 폼 제출 메시지", () => {
|
||||
|
||||
render(<PublicPageRenderer blocks={blocks} />);
|
||||
|
||||
const form = screen.getByTestId("preview-form-controller");
|
||||
fireEvent.submit(form);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
const messageEl = await screen.findByText("폼 성공 메시지 (config)");
|
||||
expect(messageEl.textContent).toBe("폼 성공 메시지 (config)");
|
||||
expect(messageEl.className).toContain("text-emerald-400");
|
||||
const form = screen.queryByTestId("preview-form-controller");
|
||||
expect(form).toBeNull();
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("에러 응답 시 config.errorMessage 를 error 스타일로 표시해야 한다", async () => {
|
||||
it("FormBlock 이 있어도 프리뷰에서는 폼 컨트롤러나 에러 메시지를 렌더하지 않아야 한다", () => {
|
||||
const blocks: Block[] = [
|
||||
{
|
||||
id: "form_error",
|
||||
@@ -81,15 +74,9 @@ describe("PublicPageRenderer - 폼 제출 메시지", () => {
|
||||
|
||||
render(<PublicPageRenderer blocks={blocks} />);
|
||||
|
||||
const form = screen.getByTestId("preview-form-controller");
|
||||
fireEvent.submit(form);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
const messageEl = await screen.findByText("폼 에러 메시지 (config)");
|
||||
expect(messageEl.textContent).toBe("폼 에러 메시지 (config)");
|
||||
expect(messageEl.className).toContain("text-rose-400");
|
||||
const form = screen.queryByTestId("preview-form-controller");
|
||||
expect(form).toBeNull();
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
expect(screen.queryByText("폼 에러 메시지 (config)")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -56,7 +56,7 @@ describe("PublicPageRenderer - 텍스트 블록 스타일", () => {
|
||||
// 정렬 클래스
|
||||
expect(el.className).toContain("text-center");
|
||||
// 커스텀 폰트 크기: sizeClass 는 비워지고, 인라인 스타일로 설정된다.
|
||||
expect(el.style.fontSize).toBe("24px");
|
||||
expect(el.style.fontSize).toBe("1.5em");
|
||||
// 커스텀 색상/배경색
|
||||
expect(el.style.color).toBe("rgb(255, 0, 0)");
|
||||
expect(el.style.backgroundColor).toBe("rgb(18, 52, 86)");
|
||||
|
||||
@@ -192,4 +192,26 @@ describe("PublicPageRenderer - 비디오 블록 스타일", () => {
|
||||
expect(wrapper!.style.padding).toBe("2em");
|
||||
expect(wrapper!.style.borderRadius).toBe("1em");
|
||||
});
|
||||
|
||||
it("sourceUrl 이 비어 있는 비디오 블록은 프리뷰에서 video/iframe 을 렌더하지 않아야 한다", () => {
|
||||
const blocks: Block[] = [
|
||||
{
|
||||
id: "video_empty_src_preview_1",
|
||||
type: "video" as any,
|
||||
props: {
|
||||
sourceUrl: "",
|
||||
align: "center",
|
||||
widthMode: "auto",
|
||||
aspectRatio: "16:9",
|
||||
} as any,
|
||||
},
|
||||
];
|
||||
|
||||
const { container } = render(<PublicPageRenderer blocks={blocks} />);
|
||||
|
||||
const video = container.querySelector("video");
|
||||
const iframe = container.querySelector("iframe");
|
||||
expect(video).toBeNull();
|
||||
expect(iframe).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
import { describe, it, expect, vi, afterEach, beforeEach } from "vitest";
|
||||
import { render, screen, fireEvent, cleanup, waitFor } from "@testing-library/react";
|
||||
|
||||
import SignupPage from "@/app/signup/page";
|
||||
|
||||
// next/navigation 의 useRouter 를 목으로 대체해 리다이렉트 동작을 검증한다.
|
||||
export const pushMock = vi.fn();
|
||||
|
||||
vi.mock("next/navigation", () => {
|
||||
return {
|
||||
__esModule: true,
|
||||
useRouter: () => ({ push: pushMock }),
|
||||
};
|
||||
});
|
||||
|
||||
describe("SignupPage", () => {
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal("fetch", vi.fn());
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.unstubAllGlobals();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("새 이메일/비밀번호로 회원가입하면 /api/auth/signup 으로 요청을 보내고 /projects 로 이동해야 한다", async () => {
|
||||
const fetchMock = vi.fn().mockImplementation((input: any, init?: any) => {
|
||||
const url = typeof input === "string" ? input : input.url;
|
||||
const method = init?.method ?? "GET";
|
||||
|
||||
if (url === "/api/auth/me" && method === "GET") {
|
||||
return Promise.resolve(
|
||||
new Response(JSON.stringify({ message: "인증이 필요합니다." }), {
|
||||
status: 401,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
if (url === "/api/auth/signup" && method === "POST") {
|
||||
return Promise.resolve(
|
||||
new Response(JSON.stringify({ id: "1", email: "new@example.com" }), {
|
||||
status: 201,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
return Promise.resolve(new Response(null, { status: 500 }));
|
||||
});
|
||||
|
||||
vi.stubGlobal("fetch", fetchMock as any);
|
||||
|
||||
render(<SignupPage />);
|
||||
|
||||
const emailInput = screen.getByLabelText("이메일") as HTMLInputElement;
|
||||
const passwordInput = screen.getByLabelText("비밀번호") as HTMLInputElement;
|
||||
const submitButton = screen.getByRole("button", { name: "회원가입" });
|
||||
|
||||
fireEvent.change(emailInput, { target: { value: "new@example.com" } });
|
||||
fireEvent.change(passwordInput, { target: { value: "securePass1" } });
|
||||
|
||||
fireEvent.click(submitButton);
|
||||
|
||||
await waitFor(() => {
|
||||
const signupCall = fetchMock.mock.calls.find(([url, options]) => {
|
||||
return url === "/api/auth/signup" && options?.method === "POST";
|
||||
});
|
||||
|
||||
expect(signupCall).toBeDefined();
|
||||
});
|
||||
|
||||
const signupCall = fetchMock.mock.calls.find(([url]) => url === "/api/auth/signup") as any;
|
||||
const [url, options] = signupCall;
|
||||
expect(url).toBe("/api/auth/signup");
|
||||
expect(options.method).toBe("POST");
|
||||
expect(options.headers["Content-Type"]).toBe("application/json");
|
||||
|
||||
const body = JSON.parse(options.body);
|
||||
expect(body.email).toBe("new@example.com");
|
||||
expect(body.password).toBe("securePass1");
|
||||
|
||||
await waitFor(() => {
|
||||
expect(pushMock).toHaveBeenCalledWith("/projects");
|
||||
});
|
||||
});
|
||||
|
||||
it("회원가입 실패 시 에러 메시지를 화면에 표시해야 한다", async () => {
|
||||
const fetchMock = vi.fn().mockImplementation((input: any, init?: any) => {
|
||||
const url = typeof input === "string" ? input : input.url;
|
||||
const method = init?.method ?? "GET";
|
||||
|
||||
if (url === "/api/auth/me" && method === "GET") {
|
||||
return Promise.resolve(
|
||||
new Response(JSON.stringify({ message: "인증이 필요합니다." }), {
|
||||
status: 401,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
if (url === "/api/auth/signup" && method === "POST") {
|
||||
return Promise.resolve(
|
||||
new Response(JSON.stringify({ message: "이미 가입된 이메일입니다." }), {
|
||||
status: 409,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
return Promise.resolve(new Response(null, { status: 500 }));
|
||||
});
|
||||
|
||||
vi.stubGlobal("fetch", fetchMock as any);
|
||||
|
||||
render(<SignupPage />);
|
||||
|
||||
const emailInput = screen.getByLabelText("이메일") as HTMLInputElement;
|
||||
const passwordInput = screen.getByLabelText("비밀번호") as HTMLInputElement;
|
||||
const submitButton = screen.getByRole("button", { name: "회원가입" });
|
||||
|
||||
fireEvent.change(emailInput, { target: { value: "dup@example.com" } });
|
||||
fireEvent.change(passwordInput, { target: { value: "securePass1" } });
|
||||
|
||||
fireEvent.click(submitButton);
|
||||
|
||||
const errorText = await screen.findByText(/이미 가입된 이메일입니다./);
|
||||
expect(errorText).toBeTruthy();
|
||||
});
|
||||
|
||||
it("이미 로그인된 상태에서 /signup 에 접근하면 /projects 로 리다이렉트해야 한다", async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue(
|
||||
new Response(JSON.stringify({ id: "1", email: "user@example.com", tokenVersion: 1 }), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}),
|
||||
);
|
||||
|
||||
vi.stubGlobal("fetch", fetchMock as any);
|
||||
|
||||
render(<SignupPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
expect(fetchMock.mock.calls[0][0]).toBe("/api/auth/me");
|
||||
expect(pushMock).toHaveBeenCalledWith("/projects");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,133 @@
|
||||
import { describe, it, expect, beforeEach } from "vitest";
|
||||
|
||||
// 앞으로 구현할 인증/보안 유틸리티 모듈을 대상으로 TDD를 진행한다.
|
||||
// - 비밀번호 해시/검증 (bcrypt 기반)
|
||||
// - JWT 액세스 토큰 발급/검증
|
||||
// - 민감정보 JSON 암호화/복호화
|
||||
|
||||
import {
|
||||
hashPassword,
|
||||
verifyPassword,
|
||||
signAccessToken,
|
||||
verifyAccessToken,
|
||||
encryptJson,
|
||||
decryptJson,
|
||||
} from "@/features/auth/authCrypto";
|
||||
|
||||
interface TestUser {
|
||||
id: string;
|
||||
email: string;
|
||||
tokenVersion: number;
|
||||
}
|
||||
|
||||
describe("authCrypto 유틸리티", () => {
|
||||
beforeEach(() => {
|
||||
// 테스트 환경에서 사용할 JWT/암호화 키를 설정한다.
|
||||
process.env.AUTH_JWT_SECRET = "test-jwt-secret-key";
|
||||
// 32바이트 키를 hex 로 표현 (예시)
|
||||
process.env.AUTH_ENCRYPTION_KEY = "0123456789abcdef0123456789abcdef";
|
||||
});
|
||||
|
||||
describe("비밀번호 해시/검증", () => {
|
||||
it("8자 이상 비밀번호를 bcrypt 해시로 안전하게 저장하고, 검증에 성공해야 한다", async () => {
|
||||
const password = "securePass1";
|
||||
|
||||
const hash = await hashPassword(password);
|
||||
|
||||
expect(hash).toBeTypeOf("string");
|
||||
expect(hash).not.toBe(password);
|
||||
|
||||
const ok = await verifyPassword(password, hash);
|
||||
const fail = await verifyPassword("wrongPass", hash);
|
||||
|
||||
expect(ok).toBe(true);
|
||||
expect(fail).toBe(false);
|
||||
});
|
||||
|
||||
it("8자 미만 비밀번호는 해시 시도 시 오류를 발생시켜야 한다", async () => {
|
||||
const shortPassword = "short"; // 5자
|
||||
|
||||
await expect(hashPassword(shortPassword)).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("JWT 액세스 토큰", () => {
|
||||
it("유저 정보를 기반으로 JWT 액세스 토큰을 발급하고, 검증 시 동일한 정보가 나와야 한다", async () => {
|
||||
const user: TestUser = {
|
||||
id: "user-1",
|
||||
email: "user@example.com",
|
||||
tokenVersion: 1,
|
||||
};
|
||||
|
||||
const token = await signAccessToken(user);
|
||||
|
||||
expect(token).toBeTypeOf("string");
|
||||
expect(token.length).toBeGreaterThan(10);
|
||||
|
||||
const payload = await verifyAccessToken(token);
|
||||
|
||||
expect(payload).not.toBeNull();
|
||||
expect(payload?.sub).toBe(user.id);
|
||||
expect(payload?.email).toBe(user.email);
|
||||
expect(payload?.tokenVersion).toBe(user.tokenVersion);
|
||||
|
||||
const anyPayload = payload as any;
|
||||
const iat = anyPayload.iat;
|
||||
const exp = anyPayload.exp;
|
||||
|
||||
expect(typeof iat).toBe("number");
|
||||
expect(typeof exp).toBe("number");
|
||||
expect(exp > iat).toBe(true);
|
||||
});
|
||||
|
||||
it("잘못된 토큰이나 시크릿으로 검증할 경우 null 을 반환해야 한다", async () => {
|
||||
const user: TestUser = {
|
||||
id: "user-2",
|
||||
email: "user2@example.com",
|
||||
tokenVersion: 3,
|
||||
};
|
||||
|
||||
const token = await signAccessToken(user);
|
||||
|
||||
// 시크릿을 바꿔서 검증 실패 상황을 만든다.
|
||||
process.env.AUTH_JWT_SECRET = "other-secret";
|
||||
|
||||
const payload = await verifyAccessToken(token);
|
||||
expect(payload).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("민감정보 JSON 암호화/복호화", () => {
|
||||
it("JSON 객체를 암호화한 뒤 복호화하면 원래 객체와 동일해야 한다", async () => {
|
||||
const original = {
|
||||
cardToken: "tok_123",
|
||||
customerId: "cus_456",
|
||||
meta: { plan: "pro", country: "KR" },
|
||||
};
|
||||
|
||||
const ciphertext = await encryptJson(original);
|
||||
|
||||
expect(ciphertext).toBeTypeOf("string");
|
||||
expect(ciphertext).not.toContain("tok_123");
|
||||
expect(ciphertext).not.toContain("cus_456");
|
||||
|
||||
const decrypted = await decryptJson<typeof original>(ciphertext);
|
||||
|
||||
expect(decrypted).toEqual(original);
|
||||
});
|
||||
|
||||
it("암호화 문자열이 손상되었거나 키가 잘못된 경우 복호화 시 오류를 발생시켜야 한다", async () => {
|
||||
const original = { secret: "value" };
|
||||
const ciphertext = await encryptJson(original);
|
||||
|
||||
// 중간 일부를 잘라 손상시킨다.
|
||||
const broken = ciphertext.slice(0, Math.floor(ciphertext.length / 2));
|
||||
|
||||
await expect(decryptJson(broken as string)).rejects.toThrow();
|
||||
|
||||
// 키를 바꿔서 복호화 시도
|
||||
process.env.AUTH_ENCRYPTION_KEY = "ffffffffffffffffffffffffffffffff";
|
||||
await expect(decryptJson(ciphertext)).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,5 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { expectNoPxInStyleParts } from "./styleInvariants";
|
||||
import {
|
||||
computeButtonPbTokens,
|
||||
computeButtonPublicTokens,
|
||||
@@ -53,15 +54,31 @@ describe("buttonHelpers.computeButtonPbTokens", () => {
|
||||
});
|
||||
|
||||
expect(tokens.alignClass).toBe("pb-text-right");
|
||||
expect(tokens.inlineStyles).toContain("width:200px");
|
||||
expect(tokens.inlineStyles).toContain("padding-left:16px");
|
||||
expect(tokens.inlineStyles).toContain("padding-right:16px");
|
||||
expect(tokens.inlineStyles).toContain("padding-top:8px");
|
||||
expect(tokens.inlineStyles).toContain("padding-bottom:8px");
|
||||
expect(tokens.inlineStyles).toContain("width:12.5em");
|
||||
expect(tokens.inlineStyles).toContain("padding-left:1em");
|
||||
expect(tokens.inlineStyles).toContain("padding-right:1em");
|
||||
expect(tokens.inlineStyles).toContain("padding-top:0.5em");
|
||||
expect(tokens.inlineStyles).toContain("padding-bottom:0.5em");
|
||||
expect(tokens.inlineStyles).toContain("background-color:#111111");
|
||||
expect(tokens.inlineStyles).toContain("border-color:#222222");
|
||||
expect(tokens.inlineStyles).toContain("color:#ffffff");
|
||||
});
|
||||
|
||||
it("Button Pb inlineStyles 에는 px 단위 값이 포함되지 않아야 한다", () => {
|
||||
const tokens = computeButtonPbTokens({
|
||||
align: "right",
|
||||
size: "sm",
|
||||
widthMode: "fixed",
|
||||
widthPx: 200,
|
||||
paddingX: 16,
|
||||
paddingY: 8,
|
||||
fillColorCustom: "#111111",
|
||||
strokeColorCustom: "#222222",
|
||||
textColorCustom: "#ffffff",
|
||||
});
|
||||
|
||||
expectNoPxInStyleParts(tokens.inlineStyles);
|
||||
});
|
||||
});
|
||||
|
||||
describe("buttonHelpers.computeButtonPublicTokens", () => {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { expectNoPxInStyleObject, expectNoPxInStyleString } from "./styleInvariants";
|
||||
import {
|
||||
computeDividerExportTokens,
|
||||
computeDividerEditorTokens,
|
||||
@@ -16,9 +17,9 @@ const baseProps: DividerBlockProps = {
|
||||
describe("dividerHelpers.computeDividerExportTokens", () => {
|
||||
const escapers = { escapeAttr: (v: string) => v };
|
||||
|
||||
it("기본 값은 1px 두께, 기본 색상(#475569), marginY=16px 이어야 한다", () => {
|
||||
it("기본 값은 1px 두께, 기본 색상(#475569), marginY=1em 이어야 한다", () => {
|
||||
const tokens = computeDividerExportTokens(baseProps, escapers);
|
||||
expect(tokens.style).toBe("border:0;border-bottom:1px solid #475569;margin:16px 0;");
|
||||
expect(tokens.style).toBe("border:0;border-bottom:1px solid #475569;margin:1em 0;");
|
||||
});
|
||||
|
||||
it("thickness=\"medium\" 인 경우 2px 두께로 렌더링되어야 한다", () => {
|
||||
@@ -33,12 +34,25 @@ describe("dividerHelpers.computeDividerExportTokens", () => {
|
||||
|
||||
it("marginYPx > 0 이면 해당 값을 margin Y 로 사용해야 한다", () => {
|
||||
const tokens = computeDividerExportTokens({ ...baseProps, marginYPx: 24 }, escapers);
|
||||
expect(tokens.style).toContain("margin:24px 0;");
|
||||
expect(tokens.style).toContain("margin:1.5em 0;");
|
||||
});
|
||||
|
||||
it("marginYPx 가 0 이하이거나 숫자가 아니면 기본 16px 을 사용해야 한다", () => {
|
||||
it("marginYPx 가 0 이하이거나 숫자가 아니면 기본 1em 을 사용해야 한다", () => {
|
||||
const zeroTokens = computeDividerExportTokens({ ...baseProps, marginYPx: 0 }, escapers);
|
||||
expect(zeroTokens.style).toContain("margin:16px 0;");
|
||||
expect(zeroTokens.style).toContain("margin:1em 0;");
|
||||
});
|
||||
|
||||
it("Export 구분선 style 에서는 border-bottom 두께를 제외하고 px 단위가 없어야 한다", () => {
|
||||
const tokens = computeDividerExportTokens({
|
||||
...baseProps,
|
||||
thickness: "medium",
|
||||
colorHex: "#123456",
|
||||
marginYPx: 24,
|
||||
}, escapers);
|
||||
|
||||
expectNoPxInStyleString(tokens.style, {
|
||||
allowPatterns: [/border-bottom:\d+px/],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -136,4 +150,18 @@ describe("dividerHelpers.computeDividerPublicTokens", () => {
|
||||
expect(tokens.innerStyle.width).toBe("auto");
|
||||
expect(tokens.lineStyle.width).toBe("30em");
|
||||
});
|
||||
|
||||
it("퍼블릭 구분선 스타일에 px 단위 값이 포함되지 않아야 한다", () => {
|
||||
const tokens = computeDividerPublicTokens({
|
||||
align: "center",
|
||||
thickness: "medium",
|
||||
colorHex: "#123456",
|
||||
marginYPx: 32,
|
||||
widthMode: "fixed",
|
||||
widthPx: 480,
|
||||
} as DividerBlockProps);
|
||||
|
||||
expectNoPxInStyleObject(tokens.innerStyle);
|
||||
expectNoPxInStyleObject(tokens.lineStyle);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { expectNoPxInStyleObject, expectNoPxInStyleString } from "./styleInvariants";
|
||||
import {
|
||||
computeFormInputExportTokens,
|
||||
computeFormSelectExportTokens,
|
||||
@@ -64,12 +65,17 @@ describe("formHelpers.computeFormInputExportTokens", () => {
|
||||
expect(tokens.inputStyleAttr).toContain("color:#ff0000");
|
||||
expect(tokens.inputStyleAttr).toContain("background-color:#111827");
|
||||
expect(tokens.inputStyleAttr).toContain("border-color:#4b5563");
|
||||
expect(tokens.inputStyleAttr).toContain("padding-left:8px");
|
||||
expect(tokens.inputStyleAttr).toContain("padding-right:8px");
|
||||
expect(tokens.inputStyleAttr).toContain("padding-top:4px");
|
||||
expect(tokens.inputStyleAttr).toContain("padding-bottom:4px");
|
||||
expect(tokens.inputStyleAttr).toContain("width:300px");
|
||||
expect(tokens.inputStyleAttr).toContain("padding-left:0.5em");
|
||||
expect(tokens.inputStyleAttr).toContain("padding-right:0.5em");
|
||||
expect(tokens.inputStyleAttr).toContain("padding-top:0.25em");
|
||||
expect(tokens.inputStyleAttr).toContain("padding-bottom:0.25em");
|
||||
expect(tokens.inputStyleAttr).toContain("width:18.75em");
|
||||
expect(tokens.inputStyleAttr).toContain("border-radius:6px");
|
||||
|
||||
// Export 인풋 스타일에는 border-radius 등의 예외를 제외하고 px 단위가 포함되지 않아야 한다.
|
||||
expectNoPxInStyleString(tokens.inputStyleAttr, {
|
||||
allowPatterns: [/border-radius:\d+px/, /border-width:\d+px/],
|
||||
});
|
||||
});
|
||||
|
||||
it("스타일 값이 없으면 style 속성이 비어 있어야 한다", () => {
|
||||
@@ -97,12 +103,17 @@ describe("formHelpers.computeFormSelectExportTokens", () => {
|
||||
expect(tokens.selectStyleAttr).toContain("color:#00ff00");
|
||||
expect(tokens.selectStyleAttr).toContain("background-color:#020617");
|
||||
expect(tokens.selectStyleAttr).toContain("border-color:#64748b");
|
||||
expect(tokens.selectStyleAttr).toContain("padding-left:12px");
|
||||
expect(tokens.selectStyleAttr).toContain("padding-right:12px");
|
||||
expect(tokens.selectStyleAttr).toContain("padding-top:6px");
|
||||
expect(tokens.selectStyleAttr).toContain("padding-bottom:6px");
|
||||
expect(tokens.selectStyleAttr).toContain("width:240px");
|
||||
expect(tokens.selectStyleAttr).toContain("padding-left:0.75em");
|
||||
expect(tokens.selectStyleAttr).toContain("padding-right:0.75em");
|
||||
expect(tokens.selectStyleAttr).toContain("padding-top:0.375em");
|
||||
expect(tokens.selectStyleAttr).toContain("padding-bottom:0.375em");
|
||||
expect(tokens.selectStyleAttr).toContain("width:15em");
|
||||
expect(tokens.selectStyleAttr).toContain("border-radius:9999px");
|
||||
|
||||
// Export 셀렉트 스타일에는 border-radius 등의 예외를 제외하고 px 단위가 포함되지 않아야 한다.
|
||||
expectNoPxInStyleString(tokens.selectStyleAttr, {
|
||||
allowPatterns: [/border-radius:\d+px/, /border-width:\d+px/],
|
||||
});
|
||||
});
|
||||
|
||||
it("스타일 값이 없으면 style 속성이 비어 있어야 한다", () => {
|
||||
@@ -130,11 +141,16 @@ describe("formHelpers.computeFormCheckboxExportTokens", () => {
|
||||
expect(tokens.optionStyleAttr).toContain("border-color:#e5e7eb");
|
||||
expect(tokens.optionStyleAttr).toContain("border-width:1px");
|
||||
expect(tokens.optionStyleAttr).toContain("border-style:solid");
|
||||
expect(tokens.optionStyleAttr).toContain("padding-left:10px");
|
||||
expect(tokens.optionStyleAttr).toContain("padding-right:10px");
|
||||
expect(tokens.optionStyleAttr).toContain("padding-top:5px");
|
||||
expect(tokens.optionStyleAttr).toContain("padding-bottom:5px");
|
||||
expect(tokens.optionStyleAttr).toContain("padding-left:0.625em");
|
||||
expect(tokens.optionStyleAttr).toContain("padding-right:0.625em");
|
||||
expect(tokens.optionStyleAttr).toContain("padding-top:0.3125em");
|
||||
expect(tokens.optionStyleAttr).toContain("padding-bottom:0.3125em");
|
||||
expect(tokens.optionStyleAttr).toContain("border-radius:2px");
|
||||
|
||||
// Export 체크박스 옵션 스타일에는 border/border-radius 를 제외하고 px 단위가 포함되지 않아야 한다.
|
||||
expectNoPxInStyleString(tokens.optionStyleAttr, {
|
||||
allowPatterns: [/border-radius:\d+px/, /border-width:\d+px/],
|
||||
});
|
||||
});
|
||||
|
||||
it("스타일 값이 없으면 style 속성이 비어 있어야 한다", () => {
|
||||
@@ -162,11 +178,16 @@ describe("formHelpers.computeFormRadioExportTokens", () => {
|
||||
expect(tokens.optionStyleAttr).toContain("border-color:#facc15");
|
||||
expect(tokens.optionStyleAttr).toContain("border-width:1px");
|
||||
expect(tokens.optionStyleAttr).toContain("border-style:solid");
|
||||
expect(tokens.optionStyleAttr).toContain("padding-left:4px");
|
||||
expect(tokens.optionStyleAttr).toContain("padding-right:4px");
|
||||
expect(tokens.optionStyleAttr).toContain("padding-top:2px");
|
||||
expect(tokens.optionStyleAttr).toContain("padding-bottom:2px");
|
||||
expect(tokens.optionStyleAttr).toContain("padding-left:0.25em");
|
||||
expect(tokens.optionStyleAttr).toContain("padding-right:0.25em");
|
||||
expect(tokens.optionStyleAttr).toContain("padding-top:0.125em");
|
||||
expect(tokens.optionStyleAttr).toContain("padding-bottom:0.125em");
|
||||
expect(tokens.optionStyleAttr).toContain("border-radius:4px");
|
||||
|
||||
// Export 라디오 옵션 스타일에는 border/border-radius 를 제외하고 px 단위가 포함되지 않아야 한다.
|
||||
expectNoPxInStyleString(tokens.optionStyleAttr, {
|
||||
allowPatterns: [/border-radius:\d+px/, /border-width:\d+px/],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -200,6 +221,11 @@ describe("formHelpers - editor tokens", () => {
|
||||
widthMode: "fixed",
|
||||
widthPx: 260,
|
||||
borderRadius: "lg",
|
||||
paddingX: 12,
|
||||
paddingY: 6,
|
||||
fontSizeCustom: "14px",
|
||||
lineHeightCustom: "20px",
|
||||
letterSpacingCustom: "1px",
|
||||
} as any);
|
||||
|
||||
expect(tokens.fieldStyle.color).toBe("#ff0000");
|
||||
@@ -207,6 +233,11 @@ describe("formHelpers - editor tokens", () => {
|
||||
expect(tokens.fieldStyle.borderColor).toBe("#654321");
|
||||
expect(tokens.fieldStyle.width).toBe("260px");
|
||||
expect(tokens.fieldStyle.borderRadius).toBe(9999);
|
||||
expect(tokens.fieldStyle.paddingInline).toBe("12px");
|
||||
expect(tokens.fieldStyle.paddingBlock).toBe("6px");
|
||||
expect(tokens.fieldStyle.fontSize).toBe("14px");
|
||||
expect(tokens.fieldStyle.lineHeight).toBe("20px");
|
||||
expect(tokens.fieldStyle.letterSpacing).toBe("1px");
|
||||
});
|
||||
|
||||
it("computeFormOptionGroupEditorTokens: 에디터 라디오/체크박스 그룹 필드 스타일을 계산해야 한다", () => {
|
||||
@@ -218,6 +249,11 @@ describe("formHelpers - editor tokens", () => {
|
||||
widthMode: "fixed",
|
||||
widthPx: 280,
|
||||
borderRadius: "lg",
|
||||
paddingX: 8,
|
||||
paddingY: 4,
|
||||
fontSizeCustom: "15px",
|
||||
lineHeightCustom: "22px",
|
||||
letterSpacingCustom: "0.5px",
|
||||
} as any);
|
||||
|
||||
expect(tokens.fieldStyle.color).toBe("#ff0000");
|
||||
@@ -225,6 +261,11 @@ describe("formHelpers - editor tokens", () => {
|
||||
expect(tokens.fieldStyle.borderColor).toBe("#654321");
|
||||
expect(tokens.fieldStyle.width).toBe("280px");
|
||||
expect(tokens.fieldStyle.borderRadius).toBe(9999);
|
||||
expect(tokens.fieldStyle.paddingInline).toBe("8px");
|
||||
expect(tokens.fieldStyle.paddingBlock).toBe("4px");
|
||||
expect(tokens.fieldStyle.fontSize).toBe("15px");
|
||||
expect(tokens.fieldStyle.lineHeight).toBe("22px");
|
||||
expect(tokens.fieldStyle.letterSpacing).toBe("0.5px");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -253,6 +294,10 @@ describe("formHelpers - public tokens", () => {
|
||||
expect(tokens.inputStyle.backgroundColor).toBe("#123456");
|
||||
expect(tokens.inputStyle.borderColor).toBe("#ff0000");
|
||||
expect(tokens.inputStyle.borderRadius).toBe("6px");
|
||||
|
||||
// 퍼블릭 인풋 토큰 스타일에는 borderRadius/borderWidth 를 제외하고 px 단위가 없어야 한다.
|
||||
expectNoPxInStyleObject(tokens.wrapperStyle);
|
||||
expectNoPxInStyleObject(tokens.inputStyle, { allowKeys: ["borderRadius", "borderWidth"] });
|
||||
});
|
||||
|
||||
it("computeFormSelectPublicTokens: 퍼블릭 셀렉트 필드 스타일을 계산해야 한다", () => {
|
||||
@@ -276,6 +321,10 @@ describe("formHelpers - public tokens", () => {
|
||||
expect(tokens.selectStyle.backgroundColor).toBe("#123456");
|
||||
expect(tokens.selectStyle.borderColor).toBe("#ff0000");
|
||||
expect(tokens.selectStyle.borderRadius).toBe("6px");
|
||||
|
||||
// 퍼블릭 셀렉트 토큰 스타일에는 borderRadius/borderWidth 를 제외하고 px 단위가 없어야 한다.
|
||||
expectNoPxInStyleObject(tokens.wrapperStyle);
|
||||
expectNoPxInStyleObject(tokens.selectStyle, { allowKeys: ["borderRadius", "borderWidth"] });
|
||||
});
|
||||
|
||||
it("computeFormCheckboxPublicTokens: 퍼블릭 체크박스 그룹 스타일을 계산해야 한다", () => {
|
||||
@@ -304,6 +353,13 @@ describe("formHelpers - public tokens", () => {
|
||||
expect(tokens.optionContainerStyle.borderRadius).toBe("6px");
|
||||
expect(tokens.groupTextStyle.color).toBe("#ffffff");
|
||||
expect(tokens.optionTextStyle.color).toBe("#ffffff");
|
||||
|
||||
// 퍼블릭 체크박스 토큰 스타일에는 borderRadius/borderWidth 를 제외하고 px 단위가 없어야 한다.
|
||||
expectNoPxInStyleObject(tokens.groupStyle);
|
||||
expectNoPxInStyleObject(tokens.optionsStyle);
|
||||
expectNoPxInStyleObject(tokens.optionContainerStyle, { allowKeys: ["borderRadius", "borderWidth"] });
|
||||
expectNoPxInStyleObject(tokens.groupTextStyle);
|
||||
expectNoPxInStyleObject(tokens.optionTextStyle);
|
||||
});
|
||||
|
||||
it("computeFormRadioPublicTokens: 퍼블릭 라디오 그룹 스타일을 계산해야 한다", () => {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { expectNoPxInStyleParts } from "./styleInvariants";
|
||||
import {
|
||||
computeImageExportStyles,
|
||||
computeImageEditorTokens,
|
||||
@@ -28,7 +29,7 @@ describe("imageHelpers.computeImageExportStyles", () => {
|
||||
widthPx: 320,
|
||||
});
|
||||
|
||||
expect(imgStyleParts).toContain("width:320px");
|
||||
expect(imgStyleParts).toContain("width:20em");
|
||||
});
|
||||
|
||||
it("borderRadius 토큰이 full 이면 border-radius 9999px 를 사용해야 한다", () => {
|
||||
@@ -47,6 +48,18 @@ describe("imageHelpers.computeImageExportStyles", () => {
|
||||
|
||||
expect(imgStyleParts).toContain("border-radius:20px");
|
||||
});
|
||||
|
||||
it("Export 이미지 스타일에는 border-radius 를 제외하고 px 단위 값이 포함되지 않아야 한다", () => {
|
||||
const { wrapperStyleParts, imgStyleParts } = computeImageExportStyles({
|
||||
backgroundColorCustom: "#123456",
|
||||
widthMode: "fixed",
|
||||
widthPx: 320,
|
||||
borderRadius: "lg",
|
||||
});
|
||||
|
||||
expectNoPxInStyleParts(wrapperStyleParts);
|
||||
expectNoPxInStyleParts(imgStyleParts, { allowPatterns: [/border-radius:\d+px/] });
|
||||
});
|
||||
});
|
||||
|
||||
describe("computeImageEditorTokens", () => {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { expectNoPxInStyleObject, expectNoPxInStyleParts } from "./styleInvariants";
|
||||
import {
|
||||
computeListExportTokens,
|
||||
computeListEditorTokens,
|
||||
@@ -81,6 +82,17 @@ describe("listHelpers.computeListExportTokens", () => {
|
||||
const tokens = computeListExportTokens({ ...baseProps });
|
||||
expect(tokens.items).toEqual([]);
|
||||
});
|
||||
|
||||
it("Export 리스트 listStyleParts 에는 px 단위 값이 포함되지 않아야 한다", () => {
|
||||
const tokens = computeListExportTokens({
|
||||
...baseProps,
|
||||
align: "center",
|
||||
items: ["a"],
|
||||
backgroundColorCustom: "#123456",
|
||||
});
|
||||
|
||||
expectNoPxInStyleParts(tokens.listStyleParts);
|
||||
});
|
||||
});
|
||||
|
||||
describe("listHelpers.computeListEditorTokens", () => {
|
||||
@@ -158,17 +170,28 @@ describe("listHelpers.computeListPublicTokens", () => {
|
||||
expect(byTokenLg.gapEm).toBe(16 / 16);
|
||||
});
|
||||
|
||||
it("fontSizeCustom 이 px 인 경우 em 단위로 변환되어야 하고, backgroundColorCustom 은 trim 되어야 한다", () => {
|
||||
it("fontSizeCustom / lineHeightCustom 이 px 인 경우 em 단위로 변환되어야 하고, backgroundColorCustom 은 trim 되어야 한다", () => {
|
||||
const tokens = computeListPublicTokens({
|
||||
...baseProps,
|
||||
fontSizeCustom: "32px",
|
||||
lineHeightCustom: "24px",
|
||||
backgroundColorCustom: " #123456 ",
|
||||
});
|
||||
|
||||
expect(tokens.listStyle.fontSize).toBe("2em");
|
||||
expect(tokens.listStyle.lineHeight).toBe("1.5em");
|
||||
expect(tokens.listStyle.backgroundColor).toBe("#123456");
|
||||
});
|
||||
|
||||
it("lineHeightCustom 이 px 인 경우 em 단위로 변환되어야 한다", () => {
|
||||
const tokens = computeListPublicTokens({
|
||||
...baseProps,
|
||||
lineHeightCustom: "24px",
|
||||
});
|
||||
|
||||
expect(tokens.listStyle.lineHeight).toBe("1.5em");
|
||||
});
|
||||
|
||||
it("bulletStyle 은 bulletStyle 지정값 또는 ordered 여부에 따라 결정되어야 한다", () => {
|
||||
const defaultDisc = computeListPublicTokens({ ...baseProps, ordered: false, bulletStyle: undefined });
|
||||
const defaultDecimal = computeListPublicTokens({ ...baseProps, ordered: true, bulletStyle: undefined });
|
||||
@@ -178,4 +201,16 @@ describe("listHelpers.computeListPublicTokens", () => {
|
||||
expect(defaultDecimal.bulletStyle).toBe("decimal");
|
||||
expect(none.bulletStyle).toBe("none");
|
||||
});
|
||||
|
||||
it("퍼블릭 리스트 style 에 px 단위 값이 포함되지 않아야 한다", () => {
|
||||
const tokens = computeListPublicTokens({
|
||||
...baseProps,
|
||||
fontSizeCustom: "32px",
|
||||
lineHeightCustom: "24px",
|
||||
textColorCustom: "#ff0000",
|
||||
backgroundColorCustom: "#123456",
|
||||
});
|
||||
|
||||
expectNoPxInStyleObject(tokens.listStyle);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -181,9 +181,6 @@ describe("프리뷰와 정적 내보내기 일치 (고수준)", () => {
|
||||
"섹션 안 텍스트",
|
||||
"섹션 버튼",
|
||||
"리스트 아이템 1",
|
||||
"폼 이름",
|
||||
"폼 이메일",
|
||||
"폼 메시지",
|
||||
"입력(단독)",
|
||||
"선택(단독)",
|
||||
"체크 A",
|
||||
@@ -195,4 +192,74 @@ describe("프리뷰와 정적 내보내기 일치 (고수준)", () => {
|
||||
expect(staticHtml).toContain(text);
|
||||
}
|
||||
});
|
||||
|
||||
it(
|
||||
"리스트+폼+구분선 조합에서 리스트 컨테이너와 구분선이 프리뷰/정적 HTML 모두에 존재하고, 리스트는 pb-list 클래스를 사용하며 li 에 margin-bottom 인라인 스타일을 사용하지 않아야 한다",
|
||||
() => {
|
||||
const blocks: Block[] = [
|
||||
{
|
||||
id: "divider_combo",
|
||||
type: "divider",
|
||||
props: {
|
||||
align: "center",
|
||||
thickness: "medium",
|
||||
widthMode: "full",
|
||||
},
|
||||
} as any,
|
||||
{
|
||||
id: "list_combo",
|
||||
type: "list",
|
||||
props: {
|
||||
items: ["리스트 아이템 1", "리스트 아이템 2"],
|
||||
ordered: false,
|
||||
align: "left",
|
||||
},
|
||||
} as any,
|
||||
{
|
||||
id: "form_combo",
|
||||
type: "form",
|
||||
props: {
|
||||
kind: "contact",
|
||||
submitTarget: "internal",
|
||||
// v1 fallback 필드를 사용해 양쪽 렌더러가 동일한 폼 필드를 가지도록 한다.
|
||||
fields: [
|
||||
{ id: "f_name", name: "name", label: "이름", type: "text", required: true },
|
||||
],
|
||||
fieldIds: [],
|
||||
submitButtonId: null,
|
||||
formWidthMode: "full",
|
||||
},
|
||||
} as any,
|
||||
];
|
||||
|
||||
const projectConfig: ProjectConfig = {
|
||||
title: "리스트+폼+구분선 레이아웃 테스트",
|
||||
slug: "list-form-divider-layout",
|
||||
canvasPreset: "full",
|
||||
};
|
||||
|
||||
const previewHtml = ReactDOMServer.renderToString(
|
||||
React.createElement(PublicPageRenderer, { blocks }),
|
||||
);
|
||||
|
||||
const staticHtml = buildStaticHtml(blocks, projectConfig);
|
||||
|
||||
// 리스트 텍스트는 양쪽에 존재해야 한다.
|
||||
expect(previewHtml).toContain("리스트 아이템 1");
|
||||
expect(staticHtml).toContain("리스트 아이템 1");
|
||||
|
||||
// 구분선은 프리뷰/Export 모두에 존재해야 한다.
|
||||
expect(previewHtml).toContain('data-testid="preview-divider"');
|
||||
expect(staticHtml).toContain('<hr class="pb-divider"');
|
||||
|
||||
// 리스트 컨테이너는 pb-list 클래스를 사용해야 한다.
|
||||
expect(previewHtml).toContain('class="pb-list');
|
||||
expect(staticHtml).toContain('class="pb-list"');
|
||||
|
||||
// 프리뷰에서는 리스트 gap 이 li 의 margin-bottom 인라인 스타일로 표현된다.
|
||||
// Export HTML 은 여전히 li 인라인 margin-bottom 을 사용하지 않고 CSS 에서 처리한다.
|
||||
expect(previewHtml).toMatch(/<li[^>]*style=\"[^\"]*margin-bottom:/);
|
||||
expect(staticHtml).not.toMatch(/<li[^>]*style=\"[^\"]*margin-bottom:/);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { expectNoPxInStyleObject, expectNoPxInStyleParts } from "./styleInvariants";
|
||||
import {
|
||||
computeSectionExportTokens,
|
||||
computeSectionPublicTokens,
|
||||
@@ -73,6 +74,21 @@ describe("sectionHelpers.computeSectionExportTokens", () => {
|
||||
expect(tokens.sectionStyleParts).toContain("position:relative");
|
||||
expect(tokens.sectionStyleParts).toContain("overflow:hidden");
|
||||
});
|
||||
|
||||
it("Export 섹션 sectionStyleParts 에는 px 단위 값이 포함되지 않아야 한다", () => {
|
||||
const tokens = computeSectionExportTokens({
|
||||
...baseProps,
|
||||
backgroundColorCustom: "#123456",
|
||||
backgroundImageSrc: "https://example.com/bg.png",
|
||||
backgroundImageSize: "cover",
|
||||
backgroundImagePositionMode: "preset",
|
||||
backgroundImagePosition: "top",
|
||||
backgroundImageRepeat: "repeat-x",
|
||||
backgroundVideoSrc: "https://example.com/bg.mp4",
|
||||
} as SectionBlockProps);
|
||||
|
||||
expectNoPxInStyleParts(tokens.sectionStyleParts);
|
||||
});
|
||||
});
|
||||
|
||||
describe("sectionHelpers.computeSectionPublicTokens", () => {
|
||||
@@ -90,6 +106,11 @@ describe("sectionHelpers.computeSectionPublicTokens", () => {
|
||||
expect(tokens.sectionStyle.paddingBottom).toBe("2em");
|
||||
expect(tokens.innerWrapperStyle.maxWidth).toBe("50em");
|
||||
expect(tokens.columnsContainerStyle.columnGap).toBe("1.5em");
|
||||
|
||||
// 퍼블릭 섹션 토큰 스타일에는 px 단위 값이 포함되지 않아야 한다.
|
||||
expectNoPxInStyleObject(tokens.sectionStyle);
|
||||
expectNoPxInStyleObject(tokens.innerWrapperStyle);
|
||||
expectNoPxInStyleObject(tokens.columnsContainerStyle);
|
||||
});
|
||||
|
||||
it("backgroundImage* 와 backgroundVideoSrc 를 올바르게 반영해야 한다", () => {
|
||||
|
||||
@@ -16,7 +16,7 @@ describe("getSectionLayoutConfig", () => {
|
||||
const cfg = getSectionLayoutConfig(baseProps);
|
||||
|
||||
expect(cfg.backgroundClass).toBe("bg-slate-950");
|
||||
expect(cfg.paddingYClass).toBe("py-12");
|
||||
expect(cfg.paddingYClass).toBe("py-6");
|
||||
expect(cfg.maxWidthClass).toBe("max-w-5xl");
|
||||
expect(cfg.gapXClass).toBe("gap-8");
|
||||
expect(cfg.alignItemsClass).toBe("items-start");
|
||||
@@ -49,7 +49,7 @@ describe("getSectionLayoutConfig", () => {
|
||||
expect(narrowCfg.maxWidthClass).toBe("max-w-3xl");
|
||||
expect(narrowCfg.gapXClass).toBe("gap-4");
|
||||
expect(narrowCfg.alignItemsClass).toBe("items-end");
|
||||
expect(narrowCfg.paddingYClass).toBe("py-8");
|
||||
expect(narrowCfg.paddingYClass).toBe("py-4");
|
||||
});
|
||||
|
||||
it("primary 배경과 큰 패딩에 대해 올바른 클래스를 반환해야 한다", () => {
|
||||
@@ -61,6 +61,6 @@ describe("getSectionLayoutConfig", () => {
|
||||
|
||||
const cfg = getSectionLayoutConfig(primaryLarge);
|
||||
expect(cfg.backgroundClass).toBe("bg-sky-900");
|
||||
expect(cfg.paddingYClass).toBe("py-20");
|
||||
expect(cfg.paddingYClass).toBe("py-10");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import { expect } from "vitest";
|
||||
|
||||
// 스타일 관련 인변언트(전역 규칙)를 검증하기 위한 공통 테스트 유틸리티.
|
||||
// - 퍼블릭/익스포트 토큰에서 px 단위 값 사용을 금지하거나,
|
||||
// - border-width 등 꼭 필요한 px 사용만 예외로 허용할 때 사용한다.
|
||||
export type StyleObject = Record<string, unknown>;
|
||||
|
||||
export function expectNoPxInStyleObject(style: unknown, options?: { allowKeys?: string[] }) {
|
||||
if (!style || typeof style !== "object") return;
|
||||
const allow = new Set(options?.allowKeys ?? []);
|
||||
|
||||
for (const [key, value] of Object.entries(style as StyleObject)) {
|
||||
if (value == null) continue;
|
||||
if (allow.has(key)) continue;
|
||||
if (typeof value === "object") continue;
|
||||
|
||||
const str = String(value);
|
||||
expect(str).not.toContain("px");
|
||||
}
|
||||
}
|
||||
|
||||
export function expectNoPxInStyleString(style: string | undefined | null, options?: { allowPatterns?: RegExp[] }) {
|
||||
if (!style) return;
|
||||
const allowPatterns = options?.allowPatterns ?? [];
|
||||
if (allowPatterns.some((re) => re.test(style))) return;
|
||||
|
||||
expect(style).not.toContain("px");
|
||||
}
|
||||
|
||||
export function expectNoPxInStyleParts(parts: string[] | undefined | null, options?: { allowPatterns?: RegExp[] }) {
|
||||
if (!parts || parts.length === 0) return;
|
||||
const combined = parts.join(";");
|
||||
expectNoPxInStyleString(combined, options);
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { expectNoPxInStyleObject, expectNoPxInStyleParts } from "./styleInvariants";
|
||||
import {
|
||||
computeTextPbTokens,
|
||||
computeTextEditorTokens,
|
||||
@@ -68,6 +69,17 @@ describe("textHelpers.computeTextPbTokens", () => {
|
||||
expect(prose.maxWidthClass).toBe("pb-text-maxw-prose");
|
||||
expect(narrow.maxWidthClass).toBe("pb-text-maxw-narrow");
|
||||
});
|
||||
|
||||
it("텍스트 Pb inlineStyles 에는 px 단위 값이 포함되지 않아야 한다", () => {
|
||||
const tokens = computeTextPbTokens({
|
||||
...baseProps,
|
||||
colorMode: "custom",
|
||||
colorCustom: "#ff0000",
|
||||
backgroundColorCustom: "#123456",
|
||||
});
|
||||
|
||||
expectNoPxInStyleParts(tokens.inlineStyles);
|
||||
});
|
||||
});
|
||||
|
||||
describe("textHelpers.computeTextEditorTokens", () => {
|
||||
@@ -128,7 +140,7 @@ describe("textHelpers.computeTextPublicTokens", () => {
|
||||
} as TextBlockProps);
|
||||
|
||||
expect(tokens.sizeClass).toBe("");
|
||||
expect(tokens.styleOverrides.fontSize).toBe("24px");
|
||||
expect(tokens.styleOverrides.fontSize).toBe("1.5em");
|
||||
});
|
||||
|
||||
it("colorCustom / backgroundColorCustom 이 styleOverrides 에 반영되어야 한다", () => {
|
||||
@@ -141,4 +153,29 @@ describe("textHelpers.computeTextPublicTokens", () => {
|
||||
expect(tokens.styleOverrides.color).toBe("#ff0000");
|
||||
expect(tokens.styleOverrides.backgroundColor).toBe("#123456");
|
||||
});
|
||||
|
||||
it("lineHeightCustom / letterSpacingCustom \uc774 px \uc778 \uacbd\uc6b0 em \ub2e8\uc704 \uc2a4\ud0c0\uc77c\ub85c \ubcc0\ud658\ub418\uc5b4\uc57c \ud55c\ub2e4", () => {
|
||||
const tokens = computeTextPublicTokens({
|
||||
...baseBlockProps,
|
||||
lineHeightCustom: "24px",
|
||||
letterSpacingCustom: "2px",
|
||||
} as TextBlockProps);
|
||||
|
||||
expect(tokens.styleOverrides.lineHeight).toBe("1.5em");
|
||||
expect(tokens.styleOverrides.letterSpacing).toBe("0.125em");
|
||||
});
|
||||
|
||||
it("퍼블릭 텍스트 styleOverrides 에 px 단위 값이 포함되지 않아야 한다", () => {
|
||||
const tokens = computeTextPublicTokens({
|
||||
...baseBlockProps,
|
||||
fontSizeMode: "custom",
|
||||
fontSizeCustom: "24px",
|
||||
lineHeightCustom: "24px",
|
||||
letterSpacingCustom: "2px",
|
||||
colorCustom: "#ff0000",
|
||||
backgroundColorCustom: "#123456",
|
||||
} as TextBlockProps);
|
||||
|
||||
expectNoPxInStyleObject(tokens.styleOverrides);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { expectNoPxInStyleParts } from "./styleInvariants";
|
||||
import {
|
||||
normalizeVideoSourceUrl,
|
||||
resolveVideoPlatform,
|
||||
@@ -124,7 +125,7 @@ describe("videoHelpers.computeVideoExportTokens", () => {
|
||||
|
||||
const escapers = { escapeAttr: (value: string) => value };
|
||||
|
||||
it("fixed width / 카드 스타일을 px 단위 스타일 파트로 계산해야 한다", () => {
|
||||
it("fixed width / 카드 스타일을 em 단위 스타일 파트로 계산해야 한다", () => {
|
||||
const tokens = computeVideoExportTokens(
|
||||
{
|
||||
...baseProps,
|
||||
@@ -139,15 +140,33 @@ describe("videoHelpers.computeVideoExportTokens", () => {
|
||||
);
|
||||
|
||||
expect(tokens.aspectClass).toBe("");
|
||||
expect(tokens.wrapperStyleParts).toContain("width:640px");
|
||||
expect(tokens.videoStyleParts).toContain("width:640px");
|
||||
expect(tokens.wrapperStyleParts).toContain("width:40em");
|
||||
expect(tokens.videoStyleParts).toContain("width:40em");
|
||||
expect(tokens.wrapperStyleParts).toContain("background-color:#123456");
|
||||
expect(tokens.wrapperStyleParts).toContain("padding:24px");
|
||||
expect(tokens.wrapperStyleParts).toContain("border-radius:16px");
|
||||
expect(tokens.wrapperStyleParts).toContain("padding:1.5em");
|
||||
expect(tokens.wrapperStyleParts).toContain("border-radius:1em");
|
||||
expect(tokens.outerStyleParts).toContain("display:flex");
|
||||
expect(tokens.outerStyleParts).toContain("justify-content:flex-start");
|
||||
});
|
||||
|
||||
it("Export 비디오 wrapper/video 스타일 파트에는 px 단위 값이 포함되지 않아야 한다", () => {
|
||||
const tokens = computeVideoExportTokens(
|
||||
{
|
||||
...baseProps,
|
||||
align: "left",
|
||||
widthMode: "fixed",
|
||||
widthPx: 640,
|
||||
backgroundColorCustom: "#123456",
|
||||
cardPaddingPx: 24,
|
||||
borderRadiusPx: 16,
|
||||
} as any,
|
||||
escapers,
|
||||
);
|
||||
|
||||
expectNoPxInStyleParts(tokens.wrapperStyleParts);
|
||||
expectNoPxInStyleParts(tokens.videoStyleParts);
|
||||
});
|
||||
|
||||
it("widthMode 가 full 이면 width:100% 로 설정하고 align 에 따라 justify-content 를 계산해야 한다", () => {
|
||||
const right = computeVideoExportTokens(
|
||||
{
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
dummy-video
|
||||
@@ -0,0 +1 @@
|
||||
PNG TEST FIXTURE
|
||||
@@ -0,0 +1 @@
|
||||
PNG TEST FIXTURE
|
||||
@@ -0,0 +1 @@
|
||||
PNG TEST FIXTURE
|
||||
@@ -0,0 +1 @@
|
||||
PNG TEST FIXTURE
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user