Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 9db7de9c79 |
@@ -1,157 +0,0 @@
|
|||||||
name: CI
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches:
|
|
||||||
- main
|
|
||||||
- feature/**
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
test:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: Checkout repository
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Install dependencies
|
|
||||||
run: |
|
|
||||||
npm ci
|
|
||||||
|
|
||||||
- name: Generate Prisma Client
|
|
||||||
env:
|
|
||||||
# CI에서는 실제 DB에 접속하지 않고 Prisma Client만 생성하면 되므로,
|
|
||||||
# 유효한 형식의 더미 DATABASE_URL 을 사용한다.
|
|
||||||
DATABASE_URL: postgresql://example:example@localhost:5432/example
|
|
||||||
# Prisma 엔진 바이너리 체크섬 파일을 원격에서 가져오지 못하는 경우(예: 500 오류)에도
|
|
||||||
# CI가 계속 진행되도록 체크섬 누락을 무시한다.
|
|
||||||
PRISMA_ENGINES_CHECKSUM_IGNORE_MISSING: "1"
|
|
||||||
run: |
|
|
||||||
npx prisma generate
|
|
||||||
|
|
||||||
- name: Build Next app
|
|
||||||
env:
|
|
||||||
# 빌드 시에도 Prisma Client 가 필요하므로 더미 DATABASE_URL 을 사용한다.
|
|
||||||
DATABASE_URL: postgresql://example:example@localhost:5432/example
|
|
||||||
PRISMA_ENGINES_CHECKSUM_IGNORE_MISSING: "1"
|
|
||||||
run: |
|
|
||||||
npm run build
|
|
||||||
|
|
||||||
- name: Run unit tests
|
|
||||||
if: github.event_name == 'push'
|
|
||||||
env:
|
|
||||||
# API 유닛 테스트에서도 PrismaClient가 필요하므로,
|
|
||||||
# generate 단계와 동일한 더미 DATABASE_URL 을 사용한다.
|
|
||||||
DATABASE_URL: postgresql://example:example@localhost:5432/example
|
|
||||||
# 테스트 중 Prisma가 엔진 체크섬 파일을 다시 확인하는 경우를 대비해 동일 설정을 적용한다.
|
|
||||||
PRISMA_ENGINES_CHECKSUM_IGNORE_MISSING: "1"
|
|
||||||
run: |
|
|
||||||
npm test
|
|
||||||
|
|
||||||
e2e:
|
|
||||||
needs: test
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
|
|
||||||
|
|
||||||
services:
|
|
||||||
postgres:
|
|
||||||
image: postgres:16
|
|
||||||
env:
|
|
||||||
POSTGRES_USER: app
|
|
||||||
POSTGRES_PASSWORD: app_password
|
|
||||||
POSTGRES_DB: page_builder
|
|
||||||
options: >-
|
|
||||||
--health-cmd="pg_isready -U app -d page_builder"
|
|
||||||
--health-interval=5s
|
|
||||||
--health-timeout=3s
|
|
||||||
--health-retries=20
|
|
||||||
|
|
||||||
env:
|
|
||||||
DATABASE_URL: postgresql://app:app_password@postgres:5432/page_builder?schema=public
|
|
||||||
AUTH_JWT_SECRET: ${{ secrets.AUTH_JWT_SECRET }}
|
|
||||||
AUTH_ENCRYPTION_KEY: ${{ secrets.AUTH_ENCRYPTION_KEY }}
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: Checkout
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Install dependencies
|
|
||||||
run: npm ci
|
|
||||||
|
|
||||||
- name: Run Prisma migrations
|
|
||||||
run: npx prisma migrate deploy
|
|
||||||
|
|
||||||
- name: Build Next.js app
|
|
||||||
run: npm run build
|
|
||||||
|
|
||||||
- name: Run Playwright E2E tests
|
|
||||||
run: npm run e2e
|
|
||||||
|
|
||||||
pr_and_merge:
|
|
||||||
needs: test
|
|
||||||
# feature/* 브랜치에 push 되었을 때만 실행한다.
|
|
||||||
if: ${{ startsWith(github.ref, 'refs/heads/feature/') }}
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: Install jq
|
|
||||||
run: |
|
|
||||||
apt-get update
|
|
||||||
apt-get install -y jq
|
|
||||||
|
|
||||||
- name: Create or update PR and try auto-merge
|
|
||||||
env:
|
|
||||||
CI_BASE_URL: ${{ secrets.CI_BASE_URL }}
|
|
||||||
CI_TOKEN: ${{ secrets.CI_TOKEN }}
|
|
||||||
CI_OWNER: ${{ secrets.CI_OWNER }}
|
|
||||||
CI_REPO: ${{ secrets.CI_REPO }}
|
|
||||||
BRANCH_REF: ${{ github.ref }}
|
|
||||||
BRANCH_NAME: ${{ github.ref_name }}
|
|
||||||
run: |
|
|
||||||
set -e
|
|
||||||
|
|
||||||
if [ -z "$CI_BASE_URL" ] || [ -z "$CI_TOKEN" ] || [ -z "$CI_OWNER" ] || [ -z "$CI_REPO" ]; then
|
|
||||||
echo "CI_* 시크릿이 설정되지 않아 PR/자동 머지를 건너뜁니다." >&2
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "현재 브랜치: $BRANCH_NAME ($BRANCH_REF)"
|
|
||||||
|
|
||||||
# 1) PR 생성 시도 (이미 존재하면 4xx를 허용)
|
|
||||||
create_pr_payload=$(jq -n \
|
|
||||||
--arg title "CI: $BRANCH_NAME" \
|
|
||||||
--arg head "$BRANCH_NAME" \
|
|
||||||
--arg base "main" \
|
|
||||||
'{title: $title, head: $head, base: $base}')
|
|
||||||
|
|
||||||
echo "PR 생성 시도..."
|
|
||||||
curl -sS -X POST \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-H "Authorization: token $CI_TOKEN" \
|
|
||||||
"$CI_BASE_URL/api/v1/repos/$CI_OWNER/$CI_REPO/pulls" \
|
|
||||||
-d "$create_pr_payload" || true
|
|
||||||
|
|
||||||
# 2) 열린 PR 목록에서 해당 브랜치의 PR 번호를 찾는다.
|
|
||||||
echo "열린 PR 목록 조회..."
|
|
||||||
pr_list=$(curl -sS \
|
|
||||||
-H "Authorization: token $CI_TOKEN" \
|
|
||||||
"$CI_BASE_URL/api/v1/repos/$CI_OWNER/$CI_REPO/pulls?state=open")
|
|
||||||
|
|
||||||
pr_number=$(echo "$pr_list" | jq ".[] | select(.head.ref == \"$BRANCH_NAME\") | .number" | head -n 1)
|
|
||||||
|
|
||||||
if [ -z "$pr_number" ]; then
|
|
||||||
echo "브랜치 $BRANCH_NAME 에 대한 열린 PR을 찾지 못했습니다. 종료합니다."
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "브랜치 $BRANCH_NAME 에 대한 PR #$pr_number 발견"
|
|
||||||
|
|
||||||
# 3) main 대상으로 자동 머지 시도
|
|
||||||
merge_payload=$(jq -n '{Do: "merge"}')
|
|
||||||
|
|
||||||
echo "PR #$pr_number 자동 머지 시도..."
|
|
||||||
curl -sS -X POST \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-H "Authorization: token $CI_TOKEN" \
|
|
||||||
"$CI_BASE_URL/api/v1/repos/$CI_OWNER/$CI_REPO/pulls/$pr_number/merge" \
|
|
||||||
-d "$merge_payload" || true
|
|
||||||
@@ -19,6 +19,5 @@ blob-report/
|
|||||||
# Plans (exclude from Git)
|
# Plans (exclude from Git)
|
||||||
메인플랜.md
|
메인플랜.md
|
||||||
최초플랜.md
|
최초플랜.md
|
||||||
MAIN_PLAN.md
|
|
||||||
|
|
||||||
/src/generated/prisma
|
/src/generated/prisma
|
||||||
|
|||||||
Generated
+2
-256
@@ -12,11 +12,7 @@
|
|||||||
"@dnd-kit/core": "^6.3.1",
|
"@dnd-kit/core": "^6.3.1",
|
||||||
"@dnd-kit/sortable": "^10.0.0",
|
"@dnd-kit/sortable": "^10.0.0",
|
||||||
"@prisma/client": "^6.19.0",
|
"@prisma/client": "^6.19.0",
|
||||||
"bcryptjs": "^2.4.3",
|
|
||||||
"dotenv": "^17.2.3",
|
"dotenv": "^17.2.3",
|
||||||
"jsonwebtoken": "^9.0.2",
|
|
||||||
"jszip": "^3.10.1",
|
|
||||||
"lucide-react": "^0.555.0",
|
|
||||||
"next": "^16.0.3",
|
"next": "^16.0.3",
|
||||||
"react": "^19.2.0",
|
"react": "^19.2.0",
|
||||||
"react-dom": "^19.2.0",
|
"react-dom": "^19.2.0",
|
||||||
@@ -28,9 +24,6 @@
|
|||||||
"@testing-library/jest-dom": "^6.9.1",
|
"@testing-library/jest-dom": "^6.9.1",
|
||||||
"@testing-library/react": "^16.3.0",
|
"@testing-library/react": "^16.3.0",
|
||||||
"@testing-library/user-event": "^14.6.1",
|
"@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/node": "^24.10.1",
|
||||||
"@types/react": "^19.2.5",
|
"@types/react": "^19.2.5",
|
||||||
"@types/react-dom": "^19.2.3",
|
"@types/react-dom": "^19.2.3",
|
||||||
@@ -2793,13 +2786,6 @@
|
|||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true
|
"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": {
|
"node_modules/@types/chai": {
|
||||||
"version": "5.2.3",
|
"version": "5.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz",
|
||||||
@@ -2839,34 +2825,6 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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",
|
|
||||||
"integrity": "sha512-GFHqtQQP3R4NNuvZH3hNCYD0NbyBZ42bkN7kO3NDrU/SnvIZWMS8Bp38XCsRKBT5BXvgm0y1zqpZWp/ZkRzBzg==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"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": {
|
"node_modules/@types/node": {
|
||||||
"version": "24.10.1",
|
"version": "24.10.1",
|
||||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.10.1.tgz",
|
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.10.1.tgz",
|
||||||
@@ -3953,12 +3911,6 @@
|
|||||||
"baseline-browser-mapping": "dist/cli.js"
|
"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": {
|
"node_modules/bidi-js": {
|
||||||
"version": "1.0.3",
|
"version": "1.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz",
|
||||||
@@ -4027,12 +3979,6 @@
|
|||||||
"node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
|
"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": {
|
"node_modules/c12": {
|
||||||
"version": "3.1.0",
|
"version": "3.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/c12/-/c12-3.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/c12/-/c12-3.1.0.tgz",
|
||||||
@@ -4315,12 +4261,6 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/core-util-is": {
|
|
||||||
"version": "1.0.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz",
|
|
||||||
"integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==",
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/cross-spawn": {
|
"node_modules/cross-spawn": {
|
||||||
"version": "7.0.6",
|
"version": "7.0.6",
|
||||||
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
|
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
|
||||||
@@ -4614,15 +4554,6 @@
|
|||||||
"node": ">= 0.4"
|
"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": {
|
"node_modules/effect": {
|
||||||
"version": "3.18.4",
|
"version": "3.18.4",
|
||||||
"resolved": "https://registry.npmjs.org/effect/-/effect-3.18.4.tgz",
|
"resolved": "https://registry.npmjs.org/effect/-/effect-3.18.4.tgz",
|
||||||
@@ -6051,12 +5982,6 @@
|
|||||||
"node": ">= 4"
|
"node": ">= 4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/immediate": {
|
|
||||||
"version": "3.0.6",
|
|
||||||
"resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz",
|
|
||||||
"integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==",
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/import-fresh": {
|
"node_modules/import-fresh": {
|
||||||
"version": "3.3.1",
|
"version": "3.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz",
|
||||||
@@ -6094,12 +6019,6 @@
|
|||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/inherits": {
|
|
||||||
"version": "2.0.4",
|
|
||||||
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
|
|
||||||
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
|
|
||||||
"license": "ISC"
|
|
||||||
},
|
|
||||||
"node_modules/internal-slot": {
|
"node_modules/internal-slot": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz",
|
||||||
@@ -6689,28 +6608,6 @@
|
|||||||
"node": ">=6"
|
"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": {
|
"node_modules/jsx-ast-utils": {
|
||||||
"version": "3.3.5",
|
"version": "3.3.5",
|
||||||
"resolved": "https://registry.npmjs.org/jsx-ast-utils/-/jsx-ast-utils-3.3.5.tgz",
|
"resolved": "https://registry.npmjs.org/jsx-ast-utils/-/jsx-ast-utils-3.3.5.tgz",
|
||||||
@@ -6727,39 +6624,6 @@
|
|||||||
"node": ">=4.0"
|
"node": ">=4.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/jszip": {
|
|
||||||
"version": "3.10.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/jszip/-/jszip-3.10.1.tgz",
|
|
||||||
"integrity": "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==",
|
|
||||||
"license": "(MIT OR GPL-3.0-or-later)",
|
|
||||||
"dependencies": {
|
|
||||||
"lie": "~3.3.0",
|
|
||||||
"pako": "~1.0.2",
|
|
||||||
"readable-stream": "~2.3.6",
|
|
||||||
"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": {
|
"node_modules/keyv": {
|
||||||
"version": "4.5.4",
|
"version": "4.5.4",
|
||||||
"resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
|
"resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
|
||||||
@@ -6804,15 +6668,6 @@
|
|||||||
"node": ">= 0.8.0"
|
"node": ">= 0.8.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/lie": {
|
|
||||||
"version": "3.3.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz",
|
|
||||||
"integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"immediate": "~3.0.5"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/lightningcss": {
|
"node_modules/lightningcss": {
|
||||||
"version": "1.30.2",
|
"version": "1.30.2",
|
||||||
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.30.2.tgz",
|
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.30.2.tgz",
|
||||||
@@ -7133,42 +6988,6 @@
|
|||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"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": {
|
"node_modules/lodash.merge": {
|
||||||
"version": "4.6.2",
|
"version": "4.6.2",
|
||||||
"resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz",
|
"resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz",
|
||||||
@@ -7176,12 +6995,6 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/log-update": {
|
||||||
"version": "6.1.0",
|
"version": "6.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/log-update/-/log-update-6.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/log-update/-/log-update-6.1.0.tgz",
|
||||||
@@ -7225,15 +7038,6 @@
|
|||||||
"yallist": "^3.0.2"
|
"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": {
|
"node_modules/lz-string": {
|
||||||
"version": "1.5.0",
|
"version": "1.5.0",
|
||||||
"resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz",
|
"resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz",
|
||||||
@@ -7346,6 +7150,7 @@
|
|||||||
"version": "2.1.3",
|
"version": "2.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||||
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
||||||
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/nano-spawn": {
|
"node_modules/nano-spawn": {
|
||||||
@@ -7750,12 +7555,6 @@
|
|||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/pako": {
|
|
||||||
"version": "1.0.11",
|
|
||||||
"resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz",
|
|
||||||
"integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==",
|
|
||||||
"license": "(MIT AND Zlib)"
|
|
||||||
},
|
|
||||||
"node_modules/parent-module": {
|
"node_modules/parent-module": {
|
||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",
|
||||||
@@ -8027,12 +7826,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/process-nextick-args": {
|
|
||||||
"version": "2.0.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz",
|
|
||||||
"integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==",
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/prop-types": {
|
"node_modules/prop-types": {
|
||||||
"version": "15.8.1",
|
"version": "15.8.1",
|
||||||
"resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
|
"resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
|
||||||
@@ -8140,27 +7933,6 @@
|
|||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true
|
"peer": true
|
||||||
},
|
},
|
||||||
"node_modules/readable-stream": {
|
|
||||||
"version": "2.3.8",
|
|
||||||
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz",
|
|
||||||
"integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"core-util-is": "~1.0.0",
|
|
||||||
"inherits": "~2.0.3",
|
|
||||||
"isarray": "~1.0.0",
|
|
||||||
"process-nextick-args": "~2.0.0",
|
|
||||||
"safe-buffer": "~5.1.1",
|
|
||||||
"string_decoder": "~1.1.1",
|
|
||||||
"util-deprecate": "~1.0.1"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/readable-stream/node_modules/isarray": {
|
|
||||||
"version": "1.0.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz",
|
|
||||||
"integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==",
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/readdirp": {
|
"node_modules/readdirp": {
|
||||||
"version": "4.1.2",
|
"version": "4.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz",
|
||||||
@@ -8405,12 +8177,6 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/safe-buffer": {
|
|
||||||
"version": "5.1.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
|
|
||||||
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/safe-push-apply": {
|
"node_modules/safe-push-apply": {
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/safe-push-apply/-/safe-push-apply-1.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/safe-push-apply/-/safe-push-apply-1.0.0.tgz",
|
||||||
@@ -8476,6 +8242,7 @@
|
|||||||
"version": "7.7.3",
|
"version": "7.7.3",
|
||||||
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz",
|
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz",
|
||||||
"integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==",
|
"integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==",
|
||||||
|
"devOptional": true,
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"bin": {
|
"bin": {
|
||||||
"semver": "bin/semver.js"
|
"semver": "bin/semver.js"
|
||||||
@@ -8533,12 +8300,6 @@
|
|||||||
"node": ">= 0.4"
|
"node": ">= 0.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/setimmediate": {
|
|
||||||
"version": "1.0.5",
|
|
||||||
"resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz",
|
|
||||||
"integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==",
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/sharp": {
|
"node_modules/sharp": {
|
||||||
"version": "0.34.5",
|
"version": "0.34.5",
|
||||||
"resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz",
|
"resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz",
|
||||||
@@ -8777,15 +8538,6 @@
|
|||||||
"node": ">= 0.4"
|
"node": ">= 0.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/string_decoder": {
|
|
||||||
"version": "1.1.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz",
|
|
||||||
"integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"safe-buffer": "~5.1.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/string-argv": {
|
"node_modules/string-argv": {
|
||||||
"version": "0.3.2",
|
"version": "0.3.2",
|
||||||
"resolved": "https://registry.npmjs.org/string-argv/-/string-argv-0.3.2.tgz",
|
"resolved": "https://registry.npmjs.org/string-argv/-/string-argv-0.3.2.tgz",
|
||||||
@@ -9475,12 +9227,6 @@
|
|||||||
"punycode": "^2.1.0"
|
"punycode": "^2.1.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/util-deprecate": {
|
|
||||||
"version": "1.0.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
|
|
||||||
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/vite": {
|
"node_modules/vite": {
|
||||||
"version": "7.2.2",
|
"version": "7.2.2",
|
||||||
"resolved": "https://registry.npmjs.org/vite/-/vite-7.2.2.tgz",
|
"resolved": "https://registry.npmjs.org/vite/-/vite-7.2.2.tgz",
|
||||||
|
|||||||
@@ -7,7 +7,6 @@
|
|||||||
"dev": "next dev",
|
"dev": "next dev",
|
||||||
"build": "next build",
|
"build": "next build",
|
||||||
"start": "next start",
|
"start": "next start",
|
||||||
"start:e2e": "next start -p 3000",
|
|
||||||
"lint": "next lint",
|
"lint": "next lint",
|
||||||
"test": "vitest run",
|
"test": "vitest run",
|
||||||
"e2e": "playwright test"
|
"e2e": "playwright test"
|
||||||
@@ -19,11 +18,7 @@
|
|||||||
"@dnd-kit/core": "^6.3.1",
|
"@dnd-kit/core": "^6.3.1",
|
||||||
"@dnd-kit/sortable": "^10.0.0",
|
"@dnd-kit/sortable": "^10.0.0",
|
||||||
"@prisma/client": "^6.19.0",
|
"@prisma/client": "^6.19.0",
|
||||||
"bcryptjs": "^2.4.3",
|
|
||||||
"dotenv": "^17.2.3",
|
"dotenv": "^17.2.3",
|
||||||
"jsonwebtoken": "^9.0.2",
|
|
||||||
"jszip": "^3.10.1",
|
|
||||||
"lucide-react": "^0.555.0",
|
|
||||||
"next": "^16.0.3",
|
"next": "^16.0.3",
|
||||||
"react": "^19.2.0",
|
"react": "^19.2.0",
|
||||||
"react-dom": "^19.2.0",
|
"react-dom": "^19.2.0",
|
||||||
@@ -35,9 +30,6 @@
|
|||||||
"@testing-library/jest-dom": "^6.9.1",
|
"@testing-library/jest-dom": "^6.9.1",
|
||||||
"@testing-library/react": "^16.3.0",
|
"@testing-library/react": "^16.3.0",
|
||||||
"@testing-library/user-event": "^14.6.1",
|
"@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/node": "^24.10.1",
|
||||||
"@types/react": "^19.2.5",
|
"@types/react": "^19.2.5",
|
||||||
"@types/react-dom": "^19.2.3",
|
"@types/react-dom": "^19.2.3",
|
||||||
|
|||||||
@@ -1,7 +1,5 @@
|
|||||||
import { defineConfig, devices } from "@playwright/test";
|
import { defineConfig, devices } from "@playwright/test";
|
||||||
|
|
||||||
const isCI = !!process.env.CI;
|
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
testDir: "./tests/e2e",
|
testDir: "./tests/e2e",
|
||||||
timeout: 30_000,
|
timeout: 30_000,
|
||||||
@@ -18,12 +16,4 @@ export default defineConfig({
|
|||||||
use: { ...devices["Desktop Chrome"] },
|
use: { ...devices["Desktop Chrome"] },
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
webServer: {
|
|
||||||
// CI 에서는 프로덕션 빌드 서버(next start)를 대상으로 E2E 를 수행해
|
|
||||||
// dev 모드의 느린 HMR/StrictMode 2회 렌더로 인한 flakiness 를 줄인다.
|
|
||||||
command: isCI ? "npm run start:e2e" : "npm run dev",
|
|
||||||
url: "http://localhost:3000",
|
|
||||||
reuseExistingServer: !isCI,
|
|
||||||
timeout: 120_000,
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,20 +0,0 @@
|
|||||||
-- 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;
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
-- CreateTable
|
|
||||||
CREATE TABLE "FormSubmission" (
|
|
||||||
"id" TEXT NOT NULL,
|
|
||||||
"projectId" TEXT,
|
|
||||||
"projectSlug" TEXT NOT NULL,
|
|
||||||
"userId" TEXT,
|
|
||||||
"payloadJson" JSONB NOT NULL,
|
|
||||||
"sensitiveEnc" TEXT,
|
|
||||||
"metaJson" JSONB,
|
|
||||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
|
|
||||||
CONSTRAINT "FormSubmission_pkey" PRIMARY KEY ("id")
|
|
||||||
);
|
|
||||||
|
|
||||||
-- AddForeignKey
|
|
||||||
ALTER TABLE "FormSubmission" ADD CONSTRAINT "FormSubmission_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
|
||||||
|
|
||||||
-- AddForeignKey
|
|
||||||
ALTER TABLE "FormSubmission" ADD CONSTRAINT "FormSubmission_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
|
||||||
+2
-34
@@ -5,8 +5,8 @@
|
|||||||
// Try Prisma Accelerate: https://pris.ly/cli/accelerate-init
|
// Try Prisma Accelerate: https://pris.ly/cli/accelerate-init
|
||||||
|
|
||||||
generator client {
|
generator client {
|
||||||
provider = "prisma-client-js"
|
provider = "prisma-client"
|
||||||
binaryTargets = ["native", "linux-musl-arm64-openssl-3.0.x"]
|
output = "../src/generated/prisma"
|
||||||
}
|
}
|
||||||
|
|
||||||
datasource db {
|
datasource db {
|
||||||
@@ -24,25 +24,6 @@ model Project {
|
|||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
assets Asset[]
|
assets Asset[]
|
||||||
|
|
||||||
formSubmissions FormSubmission[]
|
|
||||||
|
|
||||||
// 인증 도입을 위한 관계: 프로젝트는 특정 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[]
|
|
||||||
|
|
||||||
formSubmissions FormSubmission[]
|
|
||||||
}
|
}
|
||||||
|
|
||||||
model Asset {
|
model Asset {
|
||||||
@@ -61,16 +42,3 @@ enum ProjectStatus {
|
|||||||
PUBLISHED
|
PUBLISHED
|
||||||
ARCHIVED
|
ARCHIVED
|
||||||
}
|
}
|
||||||
|
|
||||||
model FormSubmission {
|
|
||||||
id String @id @default(uuid())
|
|
||||||
project Project? @relation(fields: [projectId], references: [id], onDelete: SetNull)
|
|
||||||
projectId String?
|
|
||||||
projectSlug String
|
|
||||||
user User? @relation(fields: [userId], references: [id], onDelete: SetNull)
|
|
||||||
userId String?
|
|
||||||
payloadJson Json
|
|
||||||
sensitiveEnc String?
|
|
||||||
metaJson Json?
|
|
||||||
createdAt DateTime @default(now())
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,56 +0,0 @@
|
|||||||
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: "/",
|
|
||||||
maxAge: 7 * 24 * 60 * 60,
|
|
||||||
});
|
|
||||||
|
|
||||||
return res;
|
|
||||||
} catch (error: any) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ message: "로그인 처리 중 오류가 발생했습니다.", error: error?.message },
|
|
||||||
{ status: 500 },
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
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;
|
|
||||||
}
|
|
||||||
@@ -1,44 +0,0 @@
|
|||||||
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 });
|
|
||||||
}
|
|
||||||
@@ -1,71 +0,0 @@
|
|||||||
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: "/",
|
|
||||||
maxAge: 7 * 24 * 60 * 60,
|
|
||||||
});
|
|
||||||
|
|
||||||
return res;
|
|
||||||
} catch (error: any) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ message: "회원가입 처리 중 오류가 발생했습니다.", error: error?.message },
|
|
||||||
{ status: 500 },
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,38 +0,0 @@
|
|||||||
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",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -1,708 +0,0 @@
|
|||||||
import { NextResponse } from "next/server";
|
|
||||||
import { promises as fs } from "fs";
|
|
||||||
import path from "path";
|
|
||||||
import JSZip from "jszip";
|
|
||||||
import type {
|
|
||||||
Block,
|
|
||||||
ProjectConfig,
|
|
||||||
FormBlockProps,
|
|
||||||
FormSelectOption,
|
|
||||||
TextBlockProps,
|
|
||||||
ButtonBlockProps,
|
|
||||||
SectionBlockProps,
|
|
||||||
VideoBlockProps,
|
|
||||||
ListBlockProps,
|
|
||||||
} from "@/features/editor/state/editorStore";
|
|
||||||
import {
|
|
||||||
normalizeVideoSourceUrl,
|
|
||||||
resolveVideoPlatform,
|
|
||||||
buildVideoEmbedUrl,
|
|
||||||
computeVideoExportTokens,
|
|
||||||
} from "@/features/editor/utils/videoHelpers";
|
|
||||||
import { computeTextPbTokens } from "@/features/editor/utils/textHelpers";
|
|
||||||
import { computeButtonPbTokens } from "@/features/editor/utils/buttonHelpers";
|
|
||||||
import { computeImageExportStyles } from "@/features/editor/utils/imageHelpers";
|
|
||||||
import { computeSectionExportTokens } from "@/features/editor/utils/sectionHelpers";
|
|
||||||
import { computeListExportTokens } from "@/features/editor/utils/listHelpers";
|
|
||||||
import {
|
|
||||||
computeFormInputExportTokens,
|
|
||||||
computeFormSelectExportTokens,
|
|
||||||
computeFormCheckboxExportTokens,
|
|
||||||
computeFormRadioExportTokens,
|
|
||||||
computeFormBlockExportTokens,
|
|
||||||
} from "@/features/editor/utils/formHelpers";
|
|
||||||
import { computeDividerExportTokens } from "@/features/editor/utils/dividerHelpers";
|
|
||||||
|
|
||||||
type ExportRequestBody = {
|
|
||||||
blocks: Block[];
|
|
||||||
projectConfig?: ProjectConfig;
|
|
||||||
};
|
|
||||||
|
|
||||||
const BUILDER_CSS_PATH = path.join(process.cwd(), "src", "styles", "builder.css");
|
|
||||||
const UPLOAD_DIR = path.join(process.cwd(), "uploads");
|
|
||||||
|
|
||||||
const escapeHtml = (value: string): string =>
|
|
||||||
value
|
|
||||||
.replace(/&/g, "&")
|
|
||||||
.replace(/</g, "<")
|
|
||||||
.replace(/>/g, ">")
|
|
||||||
.replace(/"/g, """)
|
|
||||||
.replace(/'/g, "'");
|
|
||||||
|
|
||||||
const escapeAttr = (value: string): string => escapeHtml(value);
|
|
||||||
|
|
||||||
export const buildStaticHtml = (blocks: Block[], projectConfig?: ProjectConfig): string => {
|
|
||||||
const baseTitleRaw = (projectConfig?.title ?? "").trim() || "Page Builder Export";
|
|
||||||
const seoTitleRaw = (projectConfig?.seoTitle ?? "").trim();
|
|
||||||
const pageTitleRaw = seoTitleRaw || baseTitleRaw;
|
|
||||||
const pageTitle = escapeHtml(pageTitleRaw);
|
|
||||||
|
|
||||||
const headExtraRaw = (projectConfig?.headHtml ?? "").trim();
|
|
||||||
const headExtra = headExtraRaw ? `\n${headExtraRaw}\n` : "";
|
|
||||||
|
|
||||||
const preset = projectConfig?.canvasPreset ?? "full";
|
|
||||||
let maxWidth: string | null = null;
|
|
||||||
const widthPx =
|
|
||||||
typeof projectConfig?.canvasWidthPx === "number" && projectConfig.canvasWidthPx > 0
|
|
||||||
? projectConfig.canvasWidthPx
|
|
||||||
: null;
|
|
||||||
if (widthPx != null) {
|
|
||||||
maxWidth = `${widthPx}px`;
|
|
||||||
} else if (preset === "mobile") {
|
|
||||||
maxWidth = "390px";
|
|
||||||
} else if (preset === "tablet") {
|
|
||||||
maxWidth = "768px";
|
|
||||||
} else if (preset === "desktop") {
|
|
||||||
maxWidth = "1200px";
|
|
||||||
}
|
|
||||||
const bgColor = (projectConfig?.canvasBgColorHex ?? "").trim();
|
|
||||||
const widthPart = maxWidth != null ? `max-width:${maxWidth};` : "";
|
|
||||||
const bgPart = bgColor ? `background-color:${bgColor};` : "";
|
|
||||||
const basePart = "margin:0 auto;padding:24px;box-sizing:border-box;";
|
|
||||||
const canvasStyle = `${widthPart}${bgPart}${basePart}`;
|
|
||||||
|
|
||||||
const bodyBgRaw = (projectConfig?.bodyBgColorHex ?? "#020617").trim() || "#020617";
|
|
||||||
const bodyStyle = `background-color:${bodyBgRaw};margin:0;padding:0;`;
|
|
||||||
const trackingRaw = (projectConfig?.trackingScript ?? "").trim();
|
|
||||||
const trackingHtml = trackingRaw ? `\n${trackingRaw}\n` : "";
|
|
||||||
|
|
||||||
const seoDescriptionRaw = (projectConfig?.seoDescription ?? "").trim();
|
|
||||||
const seoOgImageRaw = (projectConfig?.seoOgImageUrl ?? "").trim();
|
|
||||||
const seoCanonicalRaw = (projectConfig?.seoCanonicalUrl ?? "").trim();
|
|
||||||
const seoNoIndex = projectConfig?.seoNoIndex === true;
|
|
||||||
|
|
||||||
const seoHeadParts: string[] = [];
|
|
||||||
|
|
||||||
if (seoDescriptionRaw) {
|
|
||||||
seoHeadParts.push(
|
|
||||||
` <meta name="description" content="${escapeAttr(seoDescriptionRaw)}" />`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (pageTitleRaw) {
|
|
||||||
seoHeadParts.push(
|
|
||||||
` <meta property="og:title" content="${escapeAttr(pageTitleRaw)}" />`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (seoDescriptionRaw) {
|
|
||||||
seoHeadParts.push(
|
|
||||||
` <meta property="og:description" content="${escapeAttr(seoDescriptionRaw)}" />`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
seoHeadParts.push(` <meta property="og:type" content="website" />`);
|
|
||||||
|
|
||||||
if (seoOgImageRaw) {
|
|
||||||
seoHeadParts.push(
|
|
||||||
` <meta property="og:image" content="${escapeAttr(seoOgImageRaw)}" />`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (seoCanonicalRaw) {
|
|
||||||
seoHeadParts.push(
|
|
||||||
` <meta property="og:url" content="${escapeAttr(seoCanonicalRaw)}" />`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Twitter 카드 메타 태그
|
|
||||||
seoHeadParts.push(
|
|
||||||
` <meta name="twitter:card" content="summary_large_image" />`,
|
|
||||||
);
|
|
||||||
if (pageTitleRaw) {
|
|
||||||
seoHeadParts.push(
|
|
||||||
` <meta name="twitter:title" content="${escapeAttr(pageTitleRaw)}" />`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (seoDescriptionRaw) {
|
|
||||||
seoHeadParts.push(
|
|
||||||
` <meta name="twitter:description" content="${escapeAttr(seoDescriptionRaw)}" />`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (seoOgImageRaw) {
|
|
||||||
seoHeadParts.push(
|
|
||||||
` <meta name="twitter:image" content="${escapeAttr(seoOgImageRaw)}" />`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (seoCanonicalRaw) {
|
|
||||||
seoHeadParts.push(
|
|
||||||
` <link rel="canonical" href="${escapeAttr(seoCanonicalRaw)}" />`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (seoNoIndex) {
|
|
||||||
seoHeadParts.push(
|
|
||||||
` <meta name="robots" content="noindex, nofollow" />`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const seoHeadHtml = seoHeadParts.length > 0 ? `\n${seoHeadParts.join("\n")}` : "";
|
|
||||||
|
|
||||||
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;
|
|
||||||
const text = typeof props.text === "string" ? props.text : "";
|
|
||||||
|
|
||||||
const tokens = computeTextPbTokens({
|
|
||||||
text,
|
|
||||||
align: props.align ?? "left",
|
|
||||||
size: props.size ?? "base",
|
|
||||||
fontSizeMode: props.fontSizeMode ?? "scale",
|
|
||||||
fontSizeScale: props.fontSizeScale ?? undefined,
|
|
||||||
fontSizeCustom: props.fontSizeCustom ?? undefined,
|
|
||||||
lineHeightMode: props.lineHeightMode ?? "scale",
|
|
||||||
lineHeightScale: props.lineHeightScale ?? undefined,
|
|
||||||
lineHeightCustom: props.lineHeightCustom ?? undefined,
|
|
||||||
fontWeightMode: props.fontWeightMode ?? "scale",
|
|
||||||
fontWeightScale: props.fontWeightScale ?? undefined,
|
|
||||||
fontWeightCustom: props.fontWeightCustom ?? undefined,
|
|
||||||
colorMode: props.colorMode ?? undefined,
|
|
||||||
colorPalette: props.colorPalette ?? undefined,
|
|
||||||
colorCustom: props.colorCustom ?? undefined,
|
|
||||||
backgroundColorCustom: (props as any).backgroundColorCustom ?? undefined,
|
|
||||||
maxWidthMode: props.maxWidthMode ?? "scale",
|
|
||||||
maxWidthScale: props.maxWidthScale ?? undefined,
|
|
||||||
underline: props.underline ?? false,
|
|
||||||
strike: props.strike ?? false,
|
|
||||||
italic: props.italic ?? false,
|
|
||||||
});
|
|
||||||
|
|
||||||
const classes = [
|
|
||||||
tokens.alignClass,
|
|
||||||
tokens.sizeClass,
|
|
||||||
tokens.leadingClass,
|
|
||||||
tokens.weightClass,
|
|
||||||
tokens.colorClass,
|
|
||||||
tokens.maxWidthClass,
|
|
||||||
...tokens.extraClasses,
|
|
||||||
]
|
|
||||||
.filter(Boolean)
|
|
||||||
.join(" ");
|
|
||||||
|
|
||||||
const styleAttr = tokens.inlineStyles.length > 0 ? ` style="${tokens.inlineStyles.join(";")}"` : "";
|
|
||||||
|
|
||||||
return `<p class="${classes}"${styleAttr}>${escapeHtml(text)}</p>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (block.type === "button") {
|
|
||||||
const props = (block.props ?? {}) as ButtonBlockProps;
|
|
||||||
const href = typeof props.href === "string" ? props.href : "#";
|
|
||||||
const label = typeof props.label === "string" ? props.label : "";
|
|
||||||
|
|
||||||
const tokens = 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 styleAttr = tokens.inlineStyles.length > 0 ? ` style="${tokens.inlineStyles.join(";")}"` : "";
|
|
||||||
|
|
||||||
const btnClasses = ["pb-btn-base", tokens.sizeClass, tokens.variantClass, tokens.radiusClass]
|
|
||||||
.filter(Boolean)
|
|
||||||
.join(" ");
|
|
||||||
|
|
||||||
let innerHtml = escapeHtml(label);
|
|
||||||
|
|
||||||
if (typeof (props as any).imageSrc === "string" && (props as any).imageSrc.trim() !== "") {
|
|
||||||
const imgSrc = escapeAttr((props as any).imageSrc.trim());
|
|
||||||
const altRaw = (props as any).imageAlt as string | undefined;
|
|
||||||
const altValue = altRaw && altRaw.trim() !== "" ? altRaw.trim() : label;
|
|
||||||
const imgAlt = escapeAttr(altValue);
|
|
||||||
innerHtml = `<img src="${imgSrc}" alt="${imgAlt}" />${innerHtml}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
return `<div class="${tokens.alignClass}"><a href="${escapeAttr(
|
|
||||||
href,
|
|
||||||
)}" class="${btnClasses}"${styleAttr}>${innerHtml}</a></div>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (block.type === "video") {
|
|
||||||
const props: any = block.props ?? {};
|
|
||||||
const rawUrl = normalizeVideoSourceUrl(props.sourceUrl ?? "");
|
|
||||||
|
|
||||||
const platform = resolveVideoPlatform(rawUrl, props.platform ?? "auto");
|
|
||||||
const embedUrl = buildVideoEmbedUrl(rawUrl, platform, { enableVimeoEmbed: true });
|
|
||||||
|
|
||||||
const tokens = computeVideoExportTokens(props, { escapeAttr });
|
|
||||||
|
|
||||||
const outerStyleAttr =
|
|
||||||
tokens.outerStyleParts.length > 0 ? ` style="${tokens.outerStyleParts.join(";")}"` : "";
|
|
||||||
const wrapperStyleAttr =
|
|
||||||
tokens.wrapperStyleParts.length > 0 ? ` style="${tokens.wrapperStyleParts.join(";")}"` : "";
|
|
||||||
const videoStyleAttr =
|
|
||||||
tokens.videoStyleParts.length > 0 ? ` style="${tokens.videoStyleParts.join(";")}"` : "";
|
|
||||||
|
|
||||||
const isEmbed = platform === "youtube" || platform === "vimeo";
|
|
||||||
|
|
||||||
if (isEmbed) {
|
|
||||||
const titleRaw = typeof (props as any).titleText === "string" ? (props as any).titleText.trim() : "";
|
|
||||||
const iframeTitle = titleRaw !== "" ? titleRaw : "비디오";
|
|
||||||
|
|
||||||
return `<div${outerStyleAttr}><div class="pb-video-wrapper${tokens.aspectClass}"${wrapperStyleAttr}><iframe title="${escapeAttr(
|
|
||||||
iframeTitle,
|
|
||||||
)}" src="${escapeAttr(
|
|
||||||
embedUrl,
|
|
||||||
)}" class="pb-video-frame" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" allowfullscreen></iframe></div></div>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
const controlsAttr = props.controls === false ? "" : " controls";
|
|
||||||
const autoplayAttr = props.autoplay ? " autoplay" : "";
|
|
||||||
const loopAttr = props.loop ? " loop" : "";
|
|
||||||
const mutedAttr = props.muted ? " muted" : "";
|
|
||||||
const posterRaw = typeof (props as any).posterImageSrc === "string" ? (props as any).posterImageSrc.trim() : "";
|
|
||||||
const posterAttr = posterRaw !== "" ? ` poster="${escapeAttr(posterRaw)}"` : "";
|
|
||||||
const ariaRaw = typeof (props as any).ariaLabel === "string" ? (props as any).ariaLabel.trim() : "";
|
|
||||||
const ariaAttr = ariaRaw !== "" ? ` aria-label="${escapeAttr(ariaRaw)}"` : "";
|
|
||||||
const startDataAttr =
|
|
||||||
typeof (props as any).startTimeSec === "number" && (props as any).startTimeSec >= 0
|
|
||||||
? ` data-start-seconds="${(props as any).startTimeSec}"`
|
|
||||||
: "";
|
|
||||||
const endDataAttr =
|
|
||||||
typeof (props as any).endTimeSec === "number" && (props as any).endTimeSec >= 0
|
|
||||||
? ` data-end-seconds="${(props as any).endTimeSec}"`
|
|
||||||
: "";
|
|
||||||
const captionRaw = typeof (props as any).captionText === "string" ? (props as any).captionText.trim() : "";
|
|
||||||
const captionHtml = captionRaw !== "" ? `<p class="pb-video-caption">${escapeHtml(captionRaw)}</p>` : "";
|
|
||||||
|
|
||||||
return `<div${outerStyleAttr}><div class="pb-video-wrapper${tokens.aspectClass}"${wrapperStyleAttr}><video class="pb-video-frame" src="${escapeAttr(
|
|
||||||
rawUrl,
|
|
||||||
)}"${posterAttr}${ariaAttr}${startDataAttr}${endDataAttr}${videoStyleAttr}${controlsAttr}${autoplayAttr}${loopAttr}${mutedAttr}></video></div>${captionHtml}</div>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (block.type === "image") {
|
|
||||||
const props: any = block.props ?? {};
|
|
||||||
const src = typeof props.src === "string" ? props.src : "";
|
|
||||||
const alt = typeof props.alt === "string" ? props.alt : "";
|
|
||||||
|
|
||||||
const styles = computeImageExportStyles({
|
|
||||||
backgroundColorCustom:
|
|
||||||
typeof props.backgroundColorCustom === "string" ? props.backgroundColorCustom : undefined,
|
|
||||||
widthMode: props.widthMode ?? "auto",
|
|
||||||
widthPx: typeof props.widthPx === "number" ? props.widthPx : undefined,
|
|
||||||
borderRadius: props.borderRadius ?? "md",
|
|
||||||
borderRadiusPx: typeof props.borderRadiusPx === "number" ? props.borderRadiusPx : undefined,
|
|
||||||
});
|
|
||||||
|
|
||||||
const wrapperStyleAttr =
|
|
||||||
styles.wrapperStyleParts.length > 0 ? ` style="${styles.wrapperStyleParts.join(";")}"` : "";
|
|
||||||
const imgStyleAttr = styles.imgStyleParts.length > 0 ? ` style="${styles.imgStyleParts.join(";")}"` : "";
|
|
||||||
|
|
||||||
return `<div${wrapperStyleAttr}><img src="${escapeAttr(src)}" alt="${escapeAttr(alt)}"${imgStyleAttr} /></div>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (block.type === "form") {
|
|
||||||
const props = (block.props ?? {}) as FormBlockProps;
|
|
||||||
const tokens = computeFormBlockExportTokens(block, blocks);
|
|
||||||
const controllerFields = tokens.hasControllerFields ? tokens.controllerFields : [];
|
|
||||||
|
|
||||||
const hasControllerFields = controllerFields.length > 0;
|
|
||||||
|
|
||||||
// FormBlock 이 컨트롤러 필드 정보를 전혀 가지고 있지 않으면
|
|
||||||
// Export 레이어에서는 아무 것도 렌더하지 않는다 (fallback fields 기반 pb-form 도 생성하지 않음).
|
|
||||||
if (!hasControllerFields) {
|
|
||||||
return "";
|
|
||||||
}
|
|
||||||
|
|
||||||
// 컨트롤러 전용 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"`;
|
|
||||||
|
|
||||||
// 정적 Export 에서도 FormBlock 설정(전송 대상/웹훅 설정 등)을 함께 전달할 수 있도록
|
|
||||||
// preview 렌더러와 동일하게 __config hidden 필드에 FormBlockProps 전체를 JSON 으로 담아둔다.
|
|
||||||
const configJson = JSON.stringify(props ?? {});
|
|
||||||
const escapedConfig = escapeAttr(configJson);
|
|
||||||
|
|
||||||
const projectSlugRaw = (projectConfig?.slug ?? "").trim();
|
|
||||||
const projectSlugValue = projectSlugRaw !== "" ? projectSlugRaw : "";
|
|
||||||
|
|
||||||
const parts: string[] = [];
|
|
||||||
parts.push(
|
|
||||||
`<form id="${escapeAttr(formId)}" class="pb-form-controller"${methodAttr}${actionAttr}>`,
|
|
||||||
);
|
|
||||||
parts.push(`<input type="hidden" name="__config" value="${escapedConfig}" />`);
|
|
||||||
if (projectSlugValue) {
|
|
||||||
parts.push(
|
|
||||||
`<input type="hidden" name="__projectSlug" value="${escapeAttr(projectSlugValue)}" />`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
parts.push("</form>");
|
|
||||||
return parts.join("");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (block.type === "divider") {
|
|
||||||
const props: any = block.props ?? {};
|
|
||||||
const tokens = computeDividerExportTokens(props, { escapeAttr });
|
|
||||||
return `<hr class="pb-divider" style="${tokens.style}" />`;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (block.type === "list") {
|
|
||||||
const props = (block.props ?? {}) as ListBlockProps;
|
|
||||||
|
|
||||||
const tokens = computeListExportTokens(props);
|
|
||||||
|
|
||||||
if (tokens.items.length === 0) {
|
|
||||||
return "";
|
|
||||||
}
|
|
||||||
|
|
||||||
const lis = tokens.items.map((text) => `<li>${escapeHtml(text)}</li>`).join("");
|
|
||||||
const listStyleAttr = tokens.listStyleParts.join(";");
|
|
||||||
|
|
||||||
return `<${tokens.Tag} class="pb-list" style="${listStyleAttr}">${lis}</${tokens.Tag}>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (block.type === "formInput") {
|
|
||||||
const props: any = block.props ?? {};
|
|
||||||
const name = typeof props.formFieldName === "string" ? props.formFieldName : block.id;
|
|
||||||
const label =
|
|
||||||
typeof props.label === "string" && props.label.trim() !== "" ? props.label : name;
|
|
||||||
const type = props.inputType === "email" ? "email" : "text";
|
|
||||||
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}${formAttr} />`,
|
|
||||||
"</div>",
|
|
||||||
].join("");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (block.type === "formSelect") {
|
|
||||||
const props: any = block.props ?? {};
|
|
||||||
const name = typeof props.formFieldName === "string" ? props.formFieldName : block.id;
|
|
||||||
const label =
|
|
||||||
typeof props.label === "string" && props.label.trim() !== "" ? props.label : name;
|
|
||||||
const required = props.required ? " required" : "";
|
|
||||||
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}${formAttr}>`,
|
|
||||||
);
|
|
||||||
for (const opt of options) {
|
|
||||||
parts.push(
|
|
||||||
`<option value="${escapeAttr(opt.value)}">${escapeHtml(opt.label)}</option>`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
parts.push("</select>");
|
|
||||||
parts.push("</div>");
|
|
||||||
|
|
||||||
return parts.join("");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (block.type === "formCheckbox") {
|
|
||||||
const props: any = block.props ?? {};
|
|
||||||
const name = typeof props.formFieldName === "string" ? props.formFieldName : block.id;
|
|
||||||
const label =
|
|
||||||
typeof props.groupLabel === "string" && props.groupLabel.trim() !== ""
|
|
||||||
? props.groupLabel
|
|
||||||
: name;
|
|
||||||
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">');
|
|
||||||
parts.push(`<label class="pb-form-label"${tokens.labelStyleAttr}>${escapeHtml(label)}</label>`);
|
|
||||||
for (const opt of options) {
|
|
||||||
parts.push(
|
|
||||||
`<label class="pb-form-option"${tokens.optionStyleAttr}><input type="checkbox" name="${escapeAttr(
|
|
||||||
name,
|
|
||||||
)}" value="${escapeAttr(opt.value)}"${formAttr} /> ${escapeHtml(opt.label)}</label>`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
parts.push("</div>");
|
|
||||||
|
|
||||||
return parts.join("");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (block.type === "formRadio") {
|
|
||||||
const props: any = block.props ?? {};
|
|
||||||
const name = typeof props.formFieldName === "string" ? props.formFieldName : block.id;
|
|
||||||
const label =
|
|
||||||
typeof props.groupLabel === "string" && props.groupLabel.trim() !== ""
|
|
||||||
? props.groupLabel
|
|
||||||
: name;
|
|
||||||
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">');
|
|
||||||
parts.push(`<label class="pb-form-label"${tokens.labelStyleAttr}>${escapeHtml(label)}</label>`);
|
|
||||||
for (const opt of options) {
|
|
||||||
parts.push(
|
|
||||||
`<label class="pb-form-option"${tokens.optionStyleAttr}><input type="radio" name="${escapeAttr(
|
|
||||||
name,
|
|
||||||
)}" value="${escapeAttr(opt.value)}"${formAttr} /> ${escapeHtml(opt.label)}</label>`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
parts.push("</div>");
|
|
||||||
|
|
||||||
return parts.join("");
|
|
||||||
}
|
|
||||||
|
|
||||||
return "";
|
|
||||||
};
|
|
||||||
|
|
||||||
const bodyParts: string[] = [];
|
|
||||||
|
|
||||||
if (rootBlocks.length > 0) {
|
|
||||||
bodyParts.push('<section class="pb-root"><div class="pb-root-inner">');
|
|
||||||
for (const block of rootBlocks) {
|
|
||||||
bodyParts.push(renderBlock(block));
|
|
||||||
}
|
|
||||||
bodyParts.push("</div></section>");
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const section of sectionBlocks) {
|
|
||||||
const props = (section.props ?? {}) as SectionBlockProps;
|
|
||||||
const columns =
|
|
||||||
Array.isArray(props.columns) && props.columns.length > 0
|
|
||||||
? props.columns
|
|
||||||
: [{ id: `${section.id}_col`, span: 12 }];
|
|
||||||
|
|
||||||
const tokens = computeSectionExportTokens(props);
|
|
||||||
|
|
||||||
const sectionStyleAttr =
|
|
||||||
tokens.sectionStyleParts.length > 0 ? ` style="${tokens.sectionStyleParts.join(";")}"` : "";
|
|
||||||
|
|
||||||
bodyParts.push(
|
|
||||||
`<section class="${tokens.sectionClasses}"${sectionStyleAttr}>${tokens.backgroundVideoHtml}<div class="pb-section-inner"><div class="pb-section-columns">`,
|
|
||||||
);
|
|
||||||
|
|
||||||
for (const column of columns) {
|
|
||||||
const columnBlocks = blocks.filter(
|
|
||||||
(b) => b.sectionId === section.id && b.columnId === column.id,
|
|
||||||
);
|
|
||||||
bodyParts.push('<div class="pb-section-column">');
|
|
||||||
for (const block of columnBlocks) {
|
|
||||||
bodyParts.push(renderBlock(block));
|
|
||||||
}
|
|
||||||
bodyParts.push("</div>");
|
|
||||||
}
|
|
||||||
|
|
||||||
bodyParts.push("</div></div></section>");
|
|
||||||
}
|
|
||||||
|
|
||||||
const bodyContent = bodyParts.join("\n");
|
|
||||||
|
|
||||||
return `<!DOCTYPE html>
|
|
||||||
<html lang="ko">
|
|
||||||
<head>
|
|
||||||
<meta charset="utf-8" />
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
||||||
<title>${pageTitle}</title>
|
|
||||||
<link rel="stylesheet" href="./builder.css" />${seoHeadHtml}${headExtra}
|
|
||||||
</head>
|
|
||||||
<body style="${bodyStyle}">
|
|
||||||
<main>
|
|
||||||
<div id="app-root" style="${canvasStyle}">
|
|
||||||
${bodyContent}
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
<script src="./main.js"></script>${trackingHtml}
|
|
||||||
</body>
|
|
||||||
</html>`;
|
|
||||||
};
|
|
||||||
|
|
||||||
export async function POST(request: Request) {
|
|
||||||
let body: ExportRequestBody;
|
|
||||||
try {
|
|
||||||
body = (await request.json()) as ExportRequestBody;
|
|
||||||
} catch {
|
|
||||||
return NextResponse.json({ message: "잘못된 JSON 요청입니다." }, { status: 400 });
|
|
||||||
}
|
|
||||||
|
|
||||||
const blocks = Array.isArray(body.blocks) ? body.blocks : [];
|
|
||||||
const projectConfig = body.projectConfig;
|
|
||||||
|
|
||||||
let html = buildStaticHtml(blocks, projectConfig);
|
|
||||||
|
|
||||||
const zip = new JSZip();
|
|
||||||
|
|
||||||
const imageIds = new Set<string>();
|
|
||||||
const imageRegex = /\/api\/image\/([^"'>\s)]+)/g;
|
|
||||||
let match: RegExpExecArray | null;
|
|
||||||
// eslint-disable-next-line no-cond-assign
|
|
||||||
while ((match = imageRegex.exec(html)) !== null) {
|
|
||||||
if (match[1]) {
|
|
||||||
imageIds.add(match[1]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const id of imageIds) {
|
|
||||||
const filePath = path.join(UPLOAD_DIR, id);
|
|
||||||
try {
|
|
||||||
const bytes = await fs.readFile(filePath);
|
|
||||||
zip.file(`images/${id}`, bytes);
|
|
||||||
|
|
||||||
const escapedId = id.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
||||||
const srcRegex = new RegExp(`/api/image/${escapedId}`, "g");
|
|
||||||
html = html.replace(srcRegex, `./images/${id}`);
|
|
||||||
} catch {
|
|
||||||
// 업로드 디렉터리에 파일이 없는 경우에는 조용히 건너뛴다.
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const videoIds = new Set<string>();
|
|
||||||
const videoRegex = /\/api\/video\/([^"'>\s]+)/g;
|
|
||||||
let videoMatch: RegExpExecArray | null;
|
|
||||||
// eslint-disable-next-line no-cond-assign
|
|
||||||
while ((videoMatch = videoRegex.exec(html)) !== null) {
|
|
||||||
if (videoMatch[1]) {
|
|
||||||
videoIds.add(videoMatch[1]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const id of videoIds) {
|
|
||||||
const filePath = path.join(UPLOAD_DIR, id);
|
|
||||||
try {
|
|
||||||
const bytes = await fs.readFile(filePath);
|
|
||||||
zip.file(`videos/${id}`, bytes);
|
|
||||||
|
|
||||||
const escapedId = id.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
||||||
const srcRegex = new RegExp(`/api/video/${escapedId}`, "g");
|
|
||||||
html = html.replace(srcRegex, `./videos/${id}`);
|
|
||||||
} catch {
|
|
||||||
// 업로드 디렉터리에 파일이 없는 경우에는 조용히 건너뛴다.
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
zip.file("index.html", html);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const cssContent = await fs.readFile(BUILDER_CSS_PATH, "utf8");
|
|
||||||
zip.file("builder.css", cssContent);
|
|
||||||
} catch {
|
|
||||||
// CSS 파일이 없어도 내보내기가 가능하도록 한다.
|
|
||||||
zip.file("builder.css", "/* builder.css 를 찾을 수 없습니다. */\n");
|
|
||||||
}
|
|
||||||
|
|
||||||
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" });
|
|
||||||
|
|
||||||
return new NextResponse(zipArrayBuffer, {
|
|
||||||
status: 200,
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/zip",
|
|
||||||
"Content-Disposition": `attachment; filename=page-builder-export.zip`,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -1,262 +0,0 @@
|
|||||||
import { NextResponse } from "next/server";
|
|
||||||
import { PrismaClient } from "@prisma/client";
|
|
||||||
import type { FormBlockProps, FormFieldConfig } from "@/features/editor/state/editorStore";
|
|
||||||
import { encryptJson } from "@/features/auth/authCrypto";
|
|
||||||
|
|
||||||
const prisma = new PrismaClient() as any;
|
|
||||||
|
|
||||||
function buildSensitiveFieldNameSet(config: FormBlockProps | null): Set<string> {
|
|
||||||
const result = new Set<string>();
|
|
||||||
const fields: FormFieldConfig[] = Array.isArray(config?.fields) ? (config!.fields as FormFieldConfig[]) : [];
|
|
||||||
|
|
||||||
for (const field of fields) {
|
|
||||||
const name = (field.name ?? "").toLowerCase();
|
|
||||||
const label = (field.label ?? "").toLowerCase();
|
|
||||||
if (!name) continue;
|
|
||||||
|
|
||||||
const isEmail =
|
|
||||||
field.type === "email" ||
|
|
||||||
name.includes("email") ||
|
|
||||||
label.includes("email") ||
|
|
||||||
label.includes("이메일");
|
|
||||||
|
|
||||||
if (isEmail) {
|
|
||||||
result.add(field.name);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
const isPhone =
|
|
||||||
name.includes("phone") ||
|
|
||||||
name.includes("tel") ||
|
|
||||||
name.includes("mobile") ||
|
|
||||||
label.includes("전화") ||
|
|
||||||
label.includes("연락처") ||
|
|
||||||
label.includes("휴대폰");
|
|
||||||
|
|
||||||
if (isPhone) {
|
|
||||||
result.add(field.name);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
const isBirth =
|
|
||||||
name.includes("birth") ||
|
|
||||||
name.includes("birthday") ||
|
|
||||||
name.includes("dob") ||
|
|
||||||
label.includes("생년월일") ||
|
|
||||||
label.includes("생일");
|
|
||||||
|
|
||||||
if (isBirth) {
|
|
||||||
result.add(field.name);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function persistFormSubmission(formData: FormData, config: FormBlockProps | null) {
|
|
||||||
const projectSlugRaw = formData.get("__projectSlug");
|
|
||||||
let projectSlug: string | null = null;
|
|
||||||
|
|
||||||
if (typeof projectSlugRaw === "string" && projectSlugRaw.trim() !== "") {
|
|
||||||
projectSlug = projectSlugRaw.trim();
|
|
||||||
} else if (config?.extraParams && typeof config.extraParams.projectSlug === "string") {
|
|
||||||
const v = config.extraParams.projectSlug.trim();
|
|
||||||
if (v !== "") {
|
|
||||||
projectSlug = v;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const sensitiveNames = buildSensitiveFieldNameSet(config);
|
|
||||||
|
|
||||||
formData.forEach((_, key) => {
|
|
||||||
if (key === "__config" || key === "__projectSlug") {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const lower = key.toLowerCase();
|
|
||||||
|
|
||||||
const isEmailField =
|
|
||||||
lower.includes("email") ||
|
|
||||||
lower.includes("e-mail") ||
|
|
||||||
lower.includes("이메일");
|
|
||||||
|
|
||||||
const isPhoneField =
|
|
||||||
lower.includes("phone") ||
|
|
||||||
lower.includes("tel") ||
|
|
||||||
lower.includes("mobile") ||
|
|
||||||
lower.includes("전화") ||
|
|
||||||
lower.includes("연락처") ||
|
|
||||||
lower.includes("휴대폰");
|
|
||||||
|
|
||||||
const isBirthField =
|
|
||||||
lower.includes("birth") ||
|
|
||||||
lower.includes("birthday") ||
|
|
||||||
lower.includes("birthdate") ||
|
|
||||||
lower.includes("dob") ||
|
|
||||||
lower.includes("생년월일") ||
|
|
||||||
lower.includes("생일");
|
|
||||||
|
|
||||||
if (isEmailField || isPhoneField || isBirthField) {
|
|
||||||
sensitiveNames.add(key);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
const payloadJson: Record<string, string> = {};
|
|
||||||
const sensitivePayload: Record<string, string> = {};
|
|
||||||
|
|
||||||
formData.forEach((value, key) => {
|
|
||||||
if (key === "__config" || key === "__projectSlug") {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const strValue = String(value);
|
|
||||||
|
|
||||||
if (sensitiveNames.has(key)) {
|
|
||||||
sensitivePayload[key] = strValue;
|
|
||||||
} else {
|
|
||||||
payloadJson[key] = strValue;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
let sensitiveEnc: string | null = null;
|
|
||||||
if (Object.keys(sensitivePayload).length > 0) {
|
|
||||||
sensitiveEnc = await encryptJson(sensitivePayload);
|
|
||||||
}
|
|
||||||
|
|
||||||
let projectId: string | null = null;
|
|
||||||
let userId: string | null = null;
|
|
||||||
|
|
||||||
if (projectSlug) {
|
|
||||||
const project = await prisma.project.findUnique({ where: { slug: projectSlug } });
|
|
||||||
if (project) {
|
|
||||||
projectId = project.id;
|
|
||||||
userId = project.userId ?? null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
await prisma.formSubmission.create({
|
|
||||||
data: {
|
|
||||||
projectSlug: projectSlug ?? "",
|
|
||||||
projectId,
|
|
||||||
userId,
|
|
||||||
payloadJson,
|
|
||||||
sensitiveEnc,
|
|
||||||
metaJson: null,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function POST(req: Request) {
|
|
||||||
const formData = await req.formData();
|
|
||||||
|
|
||||||
const name = formData.get("name");
|
|
||||||
const email = formData.get("email");
|
|
||||||
const message = formData.get("message");
|
|
||||||
|
|
||||||
const rawConfig = formData.get("__config");
|
|
||||||
let config: FormBlockProps | null = null;
|
|
||||||
if (typeof rawConfig === "string") {
|
|
||||||
try {
|
|
||||||
config = JSON.parse(rawConfig) as FormBlockProps;
|
|
||||||
} catch {
|
|
||||||
config = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const submitTarget = config?.submitTarget ?? "internal";
|
|
||||||
const successMessage = config?.successMessage;
|
|
||||||
const errorMessage = config?.errorMessage;
|
|
||||||
|
|
||||||
if (submitTarget === "internal") {
|
|
||||||
await persistFormSubmission(formData, config);
|
|
||||||
console.log("[forms/submit][internal]", { name, email, message });
|
|
||||||
return NextResponse.json({ ok: true, message: successMessage });
|
|
||||||
}
|
|
||||||
|
|
||||||
if (submitTarget === "both") {
|
|
||||||
await persistFormSubmission(formData, config);
|
|
||||||
console.log("[forms/submit][internal]", { name, email, message });
|
|
||||||
}
|
|
||||||
|
|
||||||
if (submitTarget === "webhook" || submitTarget === "both") {
|
|
||||||
const destinationUrl = config?.destinationUrl;
|
|
||||||
if (!destinationUrl) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ ok: false, error: "destinationUrl_missing", message: errorMessage },
|
|
||||||
{ status: 400 },
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const payloadFormat = config?.payloadFormat ?? "form";
|
|
||||||
|
|
||||||
let body: string;
|
|
||||||
const headers: Record<string, string> = {
|
|
||||||
...(config?.headers ?? {}),
|
|
||||||
};
|
|
||||||
|
|
||||||
if (payloadFormat === "json") {
|
|
||||||
const jsonPayload: Record<string, string> = {};
|
|
||||||
formData.forEach((value, key) => {
|
|
||||||
if (key !== "__config") {
|
|
||||||
jsonPayload[key] = String(value);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
if (config?.extraParams) {
|
|
||||||
Object.entries(config.extraParams).forEach(([k, v]) => {
|
|
||||||
jsonPayload[k] = v;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
headers["Content-Type"] = "application/json";
|
|
||||||
body = JSON.stringify(jsonPayload);
|
|
||||||
} else {
|
|
||||||
// 기본 동작: FormData -> x-www-form-urlencoded 로 변환 (많은 webhook/Apps Script가 이 형식을 사용)
|
|
||||||
const payload = new URLSearchParams();
|
|
||||||
formData.forEach((value, key) => {
|
|
||||||
if (key !== "__config") {
|
|
||||||
payload.append(key, String(value));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
if (config?.extraParams) {
|
|
||||||
Object.entries(config.extraParams).forEach(([k, v]) => {
|
|
||||||
payload.append(k, v);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
headers["Content-Type"] = "application/x-www-form-urlencoded";
|
|
||||||
body = payload.toString();
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const res = await fetch(destinationUrl, {
|
|
||||||
method: config?.method ?? "POST",
|
|
||||||
headers,
|
|
||||||
body,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!res.ok) {
|
|
||||||
console.error("[forms/submit][webhook] remote error", res.status, await res.text().catch(() => ""));
|
|
||||||
return NextResponse.json(
|
|
||||||
{ ok: false, error: "webhook_failed", message: errorMessage },
|
|
||||||
{ status: 502 },
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error("[forms/submit][webhook] fetch error", error);
|
|
||||||
return NextResponse.json(
|
|
||||||
{ ok: false, error: "webhook_exception", message: errorMessage },
|
|
||||||
{ status: 500 },
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log("[forms/submit][webhook] forwarded", { name, email, message, destinationUrl });
|
|
||||||
return NextResponse.json({ ok: true, message: successMessage });
|
|
||||||
}
|
|
||||||
|
|
||||||
// 그 외 알 수 없는 모드는 internal 과 동일하게 처리
|
|
||||||
console.warn("[forms/submit] unknown submitTarget, fallback to internal", submitTarget);
|
|
||||||
console.log("[forms/submit][internal]", { name, email, message });
|
|
||||||
return NextResponse.json({ ok: true, message: successMessage });
|
|
||||||
}
|
|
||||||
@@ -1,93 +0,0 @@
|
|||||||
import { NextResponse } from "next/server";
|
|
||||||
import { promises as fs } from "fs";
|
|
||||||
import path from "path";
|
|
||||||
import { PrismaClient } from "@prisma/client";
|
|
||||||
|
|
||||||
const UPLOAD_DIR = path.join(process.cwd(), "uploads");
|
|
||||||
|
|
||||||
let prisma: PrismaClient | null = 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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function GET(request: Request) {
|
|
||||||
// Next.js 15 이후 params 가 Promise 로 전달되는 경고를 피하기 위해,
|
|
||||||
// 동적 세그먼트는 request.url 의 path 에서 직접 파싱한다.
|
|
||||||
let id: string | undefined;
|
|
||||||
|
|
||||||
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) {
|
|
||||||
return NextResponse.json({ message: "id 파라미터가 필요합니다." }, { status: 400 });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Referer 기반으로 간단한 핫링크 방지: 동일 호스트에서 온 요청만 허용한다.
|
|
||||||
try {
|
|
||||||
const requestUrl = new URL(request.url);
|
|
||||||
const referer = request.headers.get("referer");
|
|
||||||
|
|
||||||
if (!referer) {
|
|
||||||
return NextResponse.json({ message: "이미지 접근이 허용되지 않습니다." }, { status: 403 });
|
|
||||||
}
|
|
||||||
|
|
||||||
const refererUrl = new URL(referer);
|
|
||||||
if (refererUrl.host !== requestUrl.host) {
|
|
||||||
return NextResponse.json({ message: "이미지 접근이 허용되지 않습니다." }, { status: 403 });
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Referer 검사 중 오류", error);
|
|
||||||
return NextResponse.json({ message: "이미지 접근이 허용되지 않습니다." }, { status: 403 });
|
|
||||||
}
|
|
||||||
|
|
||||||
const filePath = path.join(UPLOAD_DIR, id);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const bytes = await fs.readFile(filePath);
|
|
||||||
|
|
||||||
// 기본 MIME 타입은 png 로 두되, 가능하면 Asset 메타데이터에서 실제 타입을 읽어온다.
|
|
||||||
let mimeType = "image/png";
|
|
||||||
if (prisma) {
|
|
||||||
try {
|
|
||||||
const asset = await prisma.asset.findUnique({ where: { id } });
|
|
||||||
const meta: any = asset?.meta ?? null;
|
|
||||||
const candidate = typeof meta?.mimeType === "string" ? meta.mimeType.trim() : "";
|
|
||||||
if (candidate) {
|
|
||||||
mimeType = candidate;
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error("이미지 메타데이터 조회 실패 - 기본 MIME 타입으로 응답합니다.", error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return new NextResponse(bytes, {
|
|
||||||
status: 200,
|
|
||||||
headers: {
|
|
||||||
"Content-Type": mimeType,
|
|
||||||
"Cache-Control": "public, max-age=31536000, immutable",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
} catch (error: any) {
|
|
||||||
if (error && typeof error === "object" && (error as any).code === "ENOENT") {
|
|
||||||
return NextResponse.json({ message: "이미지를 찾을 수 없습니다." }, { status: 404 });
|
|
||||||
}
|
|
||||||
|
|
||||||
console.error("이미지 파일 읽기 중 오류", error);
|
|
||||||
return NextResponse.json({ message: "이미지 로드 중 오류가 발생했습니다." }, { status: 500 });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,91 +0,0 @@
|
|||||||
import { NextResponse } from "next/server";
|
|
||||||
import { promises as fs } from "fs";
|
|
||||||
import path from "path";
|
|
||||||
import { randomUUID } from "crypto";
|
|
||||||
import { PrismaClient } from "@prisma/client";
|
|
||||||
|
|
||||||
// 업로드된 이미지 파일을 저장할 디렉터리 (프로젝트 루트 기준)
|
|
||||||
const UPLOAD_DIR = path.join(process.cwd(), "uploads");
|
|
||||||
|
|
||||||
let prisma: PrismaClient | null = 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) {
|
|
||||||
const formData = await request.formData();
|
|
||||||
const file = formData.get("file");
|
|
||||||
|
|
||||||
if (!file || typeof (file as any).arrayBuffer !== "function") {
|
|
||||||
return NextResponse.json({ message: "file 필드는 필수입니다." }, { status: 400 });
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
await fs.mkdir(UPLOAD_DIR, { recursive: true });
|
|
||||||
} catch (error) {
|
|
||||||
console.error("업로드 디렉터리 생성 실패", error);
|
|
||||||
return NextResponse.json({ message: "업로드 디렉터리를 생성할 수 없습니다." }, { status: 500 });
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const arrayBuffer = await (file as any).arrayBuffer();
|
|
||||||
const bytes = new Uint8Array(arrayBuffer);
|
|
||||||
const id = randomUUID();
|
|
||||||
|
|
||||||
// 파일 경로는 uploads/<id> 형태로 저장한다.
|
|
||||||
const relativePath = path.join("uploads", id);
|
|
||||||
const filePath = path.join(process.cwd(), relativePath);
|
|
||||||
|
|
||||||
await fs.writeFile(filePath, bytes);
|
|
||||||
|
|
||||||
// 가능하다면 Asset 테이블에도 메타데이터를 남긴다.
|
|
||||||
if (prisma) {
|
|
||||||
try {
|
|
||||||
const project = await prisma.project.upsert({
|
|
||||||
where: { slug: "assets-global" },
|
|
||||||
update: {},
|
|
||||||
create: {
|
|
||||||
id: randomUUID(),
|
|
||||||
title: "Assets Global",
|
|
||||||
slug: "assets-global",
|
|
||||||
contentJson: [],
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
await prisma.asset.create({
|
|
||||||
data: {
|
|
||||||
id,
|
|
||||||
projectId: project.id,
|
|
||||||
kind: "image",
|
|
||||||
url: relativePath,
|
|
||||||
meta: {
|
|
||||||
sourceType: "uploaded",
|
|
||||||
originalName: (file as any).name ?? "upload",
|
|
||||||
mimeType: (file as any).type ?? "application/octet-stream",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
console.error("이미지 Asset DB 저장 실패 - 파일 시스템만 사용합니다.", error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return NextResponse.json(
|
|
||||||
{
|
|
||||||
id,
|
|
||||||
servedUrl: `/api/image/${id}`,
|
|
||||||
},
|
|
||||||
{ status: 201 },
|
|
||||||
);
|
|
||||||
} catch (error) {
|
|
||||||
console.error("이미지 업로드 처리 중 오류", error);
|
|
||||||
return NextResponse.json({ message: "이미지 업로드 중 오류가 발생했습니다." }, { status: 500 });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,100 +1,24 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { PrismaClient } from "@prisma/client";
|
import { PrismaClient } from "@/generated/prisma/client";
|
||||||
import { verifyAccessToken } from "@/features/auth/authCrypto";
|
|
||||||
|
|
||||||
const prisma = new PrismaClient();
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
interface AuthUser {
|
interface Params {
|
||||||
id: string;
|
params: {
|
||||||
email: string;
|
slug: 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(
|
export async function GET(_request: Request, { params }: Params) {
|
||||||
request: Request,
|
const { slug } = params;
|
||||||
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({
|
const project = await prisma.project.findUnique({
|
||||||
where: { slug },
|
where: { slug },
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!project || (project.userId && project.userId !== authUser.id)) {
|
if (!project) {
|
||||||
return NextResponse.json({ message: "프로젝트를 찾을 수 없습니다." }, { status: 404 });
|
return NextResponse.json({ message: "프로젝트를 찾을 수 없습니다." }, { status: 404 });
|
||||||
}
|
}
|
||||||
|
|
||||||
return NextResponse.json(project, { status: 200 });
|
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,92 +0,0 @@
|
|||||||
import { NextResponse } from "next/server";
|
|
||||||
import { PrismaClient } from "@prisma/client";
|
|
||||||
import { verifyAccessToken, decryptJson } 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,
|
|
||||||
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 || (project.userId && project.userId !== authUser.id)) {
|
|
||||||
return NextResponse.json({ message: "프로젝트를 찾을 수 없습니다." }, { status: 404 });
|
|
||||||
}
|
|
||||||
|
|
||||||
const submissions = await prisma.formSubmission.findMany({
|
|
||||||
where: { projectId: project.id },
|
|
||||||
orderBy: { createdAt: "desc" },
|
|
||||||
take: 100,
|
|
||||||
});
|
|
||||||
|
|
||||||
const items = await Promise.all(
|
|
||||||
submissions.map(async (s: any) => {
|
|
||||||
const basePayload = (s.payloadJson ?? {}) as Record<string, unknown>;
|
|
||||||
let sensitive: Record<string, unknown> = {};
|
|
||||||
|
|
||||||
if (s.sensitiveEnc) {
|
|
||||||
try {
|
|
||||||
sensitive = (await decryptJson<Record<string, unknown>>(s.sensitiveEnc)) ?? {};
|
|
||||||
} catch {
|
|
||||||
sensitive = {};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const payload = { ...basePayload, ...sensitive };
|
|
||||||
|
|
||||||
return {
|
|
||||||
id: s.id,
|
|
||||||
createdAt: s.createdAt,
|
|
||||||
projectSlug: s.projectSlug,
|
|
||||||
payload,
|
|
||||||
};
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
return NextResponse.json(items, { status: 200 });
|
|
||||||
}
|
|
||||||
@@ -1,80 +1,9 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { PrismaClient } from "@prisma/client";
|
import { PrismaClient } from "@/generated/prisma/client";
|
||||||
import { verifyAccessToken } from "@/features/auth/authCrypto";
|
|
||||||
|
|
||||||
const prisma = new PrismaClient();
|
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) {
|
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 body = await request.json();
|
||||||
const { title, slug, contentJson } = body;
|
const { title, slug, contentJson } = body;
|
||||||
|
|
||||||
@@ -83,28 +12,11 @@ export async function POST(request: Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const existing = await prisma.project.findUnique({
|
const project = await prisma.project.create({
|
||||||
where: { slug },
|
data: {
|
||||||
});
|
|
||||||
|
|
||||||
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,
|
title,
|
||||||
slug,
|
slug,
|
||||||
contentJson,
|
contentJson,
|
||||||
userId: authUser.id,
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,105 +0,0 @@
|
|||||||
import { NextResponse } from "next/server";
|
|
||||||
import { promises as fs } from "fs";
|
|
||||||
import path from "path";
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Params {
|
|
||||||
params: Promise<{
|
|
||||||
id: string;
|
|
||||||
}>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function GET(request: Request, ctx: Params) {
|
|
||||||
// Next.js 가 params 를 항상 보장하지 않는 상황을 대비해, URL 경로에서 id 를 보완적으로 파싱한다.
|
|
||||||
let id: string | undefined;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const resolved = await ctx.params;
|
|
||||||
id = resolved?.id;
|
|
||||||
} catch {
|
|
||||||
id = undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!id) {
|
|
||||||
try {
|
|
||||||
const url = new URL(request.url);
|
|
||||||
const segments = url.pathname.split("/").filter(Boolean);
|
|
||||||
// [..., "api", "video", ":id"] 형태를 기대한다.
|
|
||||||
const last = segments[segments.length - 1];
|
|
||||||
if (last && last !== "video") {
|
|
||||||
id = last;
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// URL 파싱 실패 시에는 그대로 둔다.
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!id) {
|
|
||||||
return NextResponse.json({ message: "id 파라미터가 필요합니다." }, { status: 400 });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Referer 기반으로 간단한 핫링크 방지: 동일 호스트에서 온 요청만 허용한다.
|
|
||||||
try {
|
|
||||||
const requestUrl = new URL(request.url);
|
|
||||||
const referer = request.headers.get("referer");
|
|
||||||
|
|
||||||
if (!referer) {
|
|
||||||
return NextResponse.json({ message: "비디오 접근이 허용되지 않습니다." }, { status: 403 });
|
|
||||||
}
|
|
||||||
|
|
||||||
const refererUrl = new URL(referer);
|
|
||||||
if (refererUrl.host !== requestUrl.host) {
|
|
||||||
return NextResponse.json({ message: "비디오 접근이 허용되지 않습니다." }, { status: 403 });
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error("비디오 Referer 검사 중 오류", error);
|
|
||||||
return NextResponse.json({ message: "비디오 접근이 허용되지 않습니다." }, { status: 403 });
|
|
||||||
}
|
|
||||||
|
|
||||||
const filePath = path.join(UPLOAD_DIR, id);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const bytes = await fs.readFile(filePath);
|
|
||||||
|
|
||||||
// 기본 MIME 타입은 mp4 로 두되, 가능하면 Asset 메타데이터에서 실제 타입을 읽어온다.
|
|
||||||
let mimeType = "video/mp4";
|
|
||||||
if (prisma) {
|
|
||||||
try {
|
|
||||||
const asset = await prisma.asset.findUnique({ where: { id } });
|
|
||||||
const meta: any = asset?.meta ?? null;
|
|
||||||
const candidate = typeof meta?.mimeType === "string" ? meta.mimeType.trim() : "";
|
|
||||||
if (candidate) {
|
|
||||||
mimeType = candidate;
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error("비디오 메타데이터 조회 실패 - 기본 MIME 타입으로 응답합니다.", error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return new NextResponse(bytes, {
|
|
||||||
status: 200,
|
|
||||||
headers: {
|
|
||||||
"Content-Type": mimeType,
|
|
||||||
"Cache-Control": "public, max-age=31536000, immutable",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
} catch (error: any) {
|
|
||||||
if (error && typeof error === "object" && (error as any).code === "ENOENT") {
|
|
||||||
return NextResponse.json({ message: "비디오를 찾을 수 없습니다." }, { status: 404 });
|
|
||||||
}
|
|
||||||
|
|
||||||
console.error("비디오 파일 읽기 중 오류", error);
|
|
||||||
return NextResponse.json({ message: "비디오 로드 중 오류가 발생했습니다." }, { status: 500 });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,89 +0,0 @@
|
|||||||
import { NextResponse } from "next/server";
|
|
||||||
import { promises as fs } from "fs";
|
|
||||||
import path from "path";
|
|
||||||
import { randomUUID } from "crypto";
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function POST(request: Request) {
|
|
||||||
const formData = await request.formData();
|
|
||||||
const file = formData.get("file");
|
|
||||||
|
|
||||||
if (!file || typeof (file as any).arrayBuffer !== "function") {
|
|
||||||
return NextResponse.json({ message: "file 필드는 필수입니다." }, { status: 400 });
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
await fs.mkdir(UPLOAD_DIR, { recursive: true });
|
|
||||||
} catch (error) {
|
|
||||||
console.error("비디오 업로드 디렉터리 생성 실패", error);
|
|
||||||
return NextResponse.json({ message: "업로드 디렉터리를 생성할 수 없습니다." }, { status: 500 });
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const arrayBuffer = await (file as any).arrayBuffer();
|
|
||||||
const bytes = new Uint8Array(arrayBuffer);
|
|
||||||
const id = randomUUID();
|
|
||||||
|
|
||||||
// 파일 경로는 uploads/<id> 형태로 저장한다.
|
|
||||||
const relativePath = path.join("uploads", id);
|
|
||||||
const filePath = path.join(process.cwd(), relativePath);
|
|
||||||
|
|
||||||
await fs.writeFile(filePath, bytes);
|
|
||||||
|
|
||||||
// 가능하다면 Asset 테이블에도 메타데이터를 남긴다.
|
|
||||||
if (prisma) {
|
|
||||||
try {
|
|
||||||
const project = await prisma.project.upsert({
|
|
||||||
where: { slug: "assets-global" },
|
|
||||||
update: {},
|
|
||||||
create: {
|
|
||||||
id: randomUUID(),
|
|
||||||
title: "Assets Global",
|
|
||||||
slug: "assets-global",
|
|
||||||
contentJson: [],
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
await prisma.asset.create({
|
|
||||||
data: {
|
|
||||||
id,
|
|
||||||
projectId: project.id,
|
|
||||||
kind: "video",
|
|
||||||
url: relativePath,
|
|
||||||
meta: {
|
|
||||||
sourceType: "uploaded",
|
|
||||||
originalName: (file as any).name ?? "upload",
|
|
||||||
mimeType: (file as any).type ?? "application/octet-stream",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
console.error("비디오 Asset DB 저장 실패 - 파일 시스템만 사용합니다.", error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return NextResponse.json(
|
|
||||||
{
|
|
||||||
id,
|
|
||||||
servedUrl: `/api/video/${id}`,
|
|
||||||
},
|
|
||||||
{ status: 201 },
|
|
||||||
);
|
|
||||||
} catch (error) {
|
|
||||||
console.error("비디오 업로드 처리 중 오류", error);
|
|
||||||
return NextResponse.json({ message: "비디오 업로드 중 오류가 발생했습니다." }, { status: 500 });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,154 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import type { CSSProperties, ReactNode } from "react";
|
|
||||||
import {
|
|
||||||
DndContext,
|
|
||||||
DragOverlay,
|
|
||||||
type DragEndEvent,
|
|
||||||
type DragStartEvent,
|
|
||||||
} from "@dnd-kit/core";
|
|
||||||
import { SortableContext, verticalListSortingStrategy } from "@dnd-kit/sortable";
|
|
||||||
import { rectIntersection } from "@dnd-kit/core";
|
|
||||||
|
|
||||||
import type { Block, ButtonBlockProps, ImageBlockProps, ProjectConfig, TextBlockProps } from "@/features/editor/state/editorStore";
|
|
||||||
|
|
||||||
interface EditorCanvasProps {
|
|
||||||
blocks: Block[];
|
|
||||||
rootBlocks: Block[];
|
|
||||||
selectedBlockId: string | null;
|
|
||||||
editingBlockId: string | null;
|
|
||||||
editingText: string;
|
|
||||||
activeDragId: string | null;
|
|
||||||
sensors: any;
|
|
||||||
onCanvasEmptyClick: () => void;
|
|
||||||
renderBlocks: (targetBlocks: Block[]) => ReactNode;
|
|
||||||
handleDragStart: (event: DragStartEvent) => void;
|
|
||||||
handleDragEnd: (event: DragEndEvent) => void;
|
|
||||||
handleDragCancel: () => void;
|
|
||||||
projectConfig: ProjectConfig;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function EditorCanvas(props: EditorCanvasProps) {
|
|
||||||
const {
|
|
||||||
blocks,
|
|
||||||
rootBlocks,
|
|
||||||
activeDragId,
|
|
||||||
sensors,
|
|
||||||
onCanvasEmptyClick,
|
|
||||||
renderBlocks,
|
|
||||||
handleDragStart,
|
|
||||||
handleDragEnd,
|
|
||||||
handleDragCancel,
|
|
||||||
projectConfig,
|
|
||||||
} = props;
|
|
||||||
|
|
||||||
const canvasOuterStyle: CSSProperties = {};
|
|
||||||
const canvasInnerStyle: CSSProperties = {};
|
|
||||||
const preset = projectConfig.canvasPreset ?? "full";
|
|
||||||
const widthPx =
|
|
||||||
typeof projectConfig.canvasWidthPx === "number" && projectConfig.canvasWidthPx > 0
|
|
||||||
? projectConfig.canvasWidthPx
|
|
||||||
: null;
|
|
||||||
|
|
||||||
if (widthPx != null) {
|
|
||||||
canvasInnerStyle.maxWidth = `${widthPx}px`;
|
|
||||||
} else if (preset === "mobile") {
|
|
||||||
canvasInnerStyle.maxWidth = "390px";
|
|
||||||
} else if (preset === "tablet") {
|
|
||||||
canvasInnerStyle.maxWidth = "768px";
|
|
||||||
} else if (preset === "desktop") {
|
|
||||||
canvasInnerStyle.maxWidth = "1200px";
|
|
||||||
}
|
|
||||||
|
|
||||||
if (projectConfig.bodyBgColorHex && projectConfig.bodyBgColorHex.trim()) {
|
|
||||||
canvasOuterStyle.backgroundColor = projectConfig.bodyBgColorHex;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (projectConfig.canvasBgColorHex && projectConfig.canvasBgColorHex.trim()) {
|
|
||||||
canvasInnerStyle.backgroundColor = projectConfig.canvasBgColorHex;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className="flex-1 p-4 flex flex-col gap-2 text-sm text-slate-200 border-r border-slate-800 overflow-auto pb-scroll"
|
|
||||||
data-testid="editor-canvas"
|
|
||||||
style={canvasOuterStyle}
|
|
||||||
onClick={(event) => {
|
|
||||||
if (event.target === event.currentTarget) {
|
|
||||||
onCanvasEmptyClick();
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
data-testid="editor-canvas-inner"
|
|
||||||
className="mx-auto w-full flex flex-col gap-2"
|
|
||||||
style={canvasInnerStyle}
|
|
||||||
>
|
|
||||||
{blocks.length === 0 ? (
|
|
||||||
<div className="flex-1 flex items-center justify-center text-slate-500 text-xs">
|
|
||||||
왼쪽에서 "텍스트" 버튼을 눌러 블록을 추가해 보세요.
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<DndContext
|
|
||||||
sensors={sensors}
|
|
||||||
collisionDetection={rectIntersection}
|
|
||||||
onDragStart={handleDragStart}
|
|
||||||
onDragEnd={handleDragEnd}
|
|
||||||
onDragCancel={handleDragCancel}
|
|
||||||
>
|
|
||||||
<SortableContext
|
|
||||||
items={rootBlocks.map((b) => b.id)}
|
|
||||||
strategy={verticalListSortingStrategy}
|
|
||||||
>
|
|
||||||
{renderBlocks(rootBlocks)}
|
|
||||||
</SortableContext>
|
|
||||||
<DragOverlay>
|
|
||||||
{activeDragId && (
|
|
||||||
<DragPreview block={blocks.find((b) => b.id === activeDragId) ?? null} />
|
|
||||||
)}
|
|
||||||
</DragOverlay>
|
|
||||||
</DndContext>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
interface DragPreviewProps {
|
|
||||||
block: Block | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function DragPreview({ block }: DragPreviewProps) {
|
|
||||||
if (!block) return null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="pointer-events-none rounded border border-sky-500 bg-slate-900/80 px-3 py-2 text-xs text-slate-100 shadow-lg opacity-90 min-w-[160px] max-w-[260px]">
|
|
||||||
<div className="text-[10px] uppercase tracking-wide text-sky-300 mb-1">
|
|
||||||
{block.type === "text"
|
|
||||||
? "Text"
|
|
||||||
: block.type === "button"
|
|
||||||
? "Button"
|
|
||||||
: block.type === "image"
|
|
||||||
? "Image"
|
|
||||||
: block.type === "divider"
|
|
||||||
? "Divider"
|
|
||||||
: block.type === "list"
|
|
||||||
? "List"
|
|
||||||
: "Section"}
|
|
||||||
</div>
|
|
||||||
<div className="truncate">
|
|
||||||
{block.type === "text"
|
|
||||||
? (block.props as TextBlockProps).text || "텍스트 블록"
|
|
||||||
: block.type === "button"
|
|
||||||
? (block.props as ButtonBlockProps).label || "버튼 블록"
|
|
||||||
: block.type === "image"
|
|
||||||
? (block.props as ImageBlockProps).alt || "이미지 블록"
|
|
||||||
: block.type === "list"
|
|
||||||
? "리스트 블록"
|
|
||||||
: block.type === "divider"
|
|
||||||
? "구분선 블록"
|
|
||||||
: "섹션 블록"}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,597 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import type { Block, FormCheckboxBlockProps } from "@/features/editor/state/editorStore";
|
|
||||||
import { ColorPickerField, TEXT_COLOR_PALETTE } from "@/features/editor/components/ColorPickerField";
|
|
||||||
import { NumericPropertyControl } from "@/features/editor/components/NumericPropertyControl";
|
|
||||||
|
|
||||||
interface FormCheckboxPropertiesPanelProps {
|
|
||||||
block: Block;
|
|
||||||
selectedBlockId: string | null;
|
|
||||||
updateBlock: (id: string, partial: any) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function FormCheckboxPropertiesPanel({ block, selectedBlockId, updateBlock }: FormCheckboxPropertiesPanelProps) {
|
|
||||||
if (!selectedBlockId || block.id !== selectedBlockId) return null;
|
|
||||||
|
|
||||||
const checkboxProps = block.props as FormCheckboxBlockProps;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-3 text-xs">
|
|
||||||
<h3 className="text-[11px] font-semibold text-slate-200">체크박스 필드</h3>
|
|
||||||
|
|
||||||
<label className="flex flex-col gap-1">
|
|
||||||
<span className="text-slate-400">그룹 타이틀 타입</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={checkboxProps.groupLabelMode ?? "text"}
|
|
||||||
onChange={(e) =>
|
|
||||||
updateBlock(selectedBlockId, {
|
|
||||||
groupLabelMode: e.target.value,
|
|
||||||
} as any)
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<option value="text">텍스트</option>
|
|
||||||
<option value="image">이미지</option>
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<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={checkboxProps.groupLabel ?? ""}
|
|
||||||
onChange={(e) =>
|
|
||||||
updateBlock(selectedBlockId, {
|
|
||||||
groupLabel: e.target.value,
|
|
||||||
} as any)
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
|
|
||||||
{checkboxProps.groupLabelMode === "image" && (() => {
|
|
||||||
const source: "url" | "upload" =
|
|
||||||
checkboxProps.groupLabelImageSource ??
|
|
||||||
(checkboxProps.groupLabelImageUrl && checkboxProps.groupLabelImageUrl.startsWith("/api/image/")
|
|
||||||
? "upload"
|
|
||||||
: "url");
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-1">
|
|
||||||
<label className="flex flex-col gap-1">
|
|
||||||
<span className="text-slate-400">그룹 타이틀 이미지 소스</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={source}
|
|
||||||
onChange={(e) =>
|
|
||||||
updateBlock(selectedBlockId, {
|
|
||||||
groupLabelImageSource: e.target.value,
|
|
||||||
} as any)
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<option value="url">URL</option>
|
|
||||||
<option value="upload">파일 업로드</option>
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
|
|
||||||
{source === "url" && (
|
|
||||||
<label className="flex flex-col gap-1">
|
|
||||||
<span className="text-slate-400">그룹 타이틀 이미지 URL</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={checkboxProps.groupLabelImageUrl ?? ""}
|
|
||||||
onChange={(e) =>
|
|
||||||
updateBlock(selectedBlockId, {
|
|
||||||
groupLabelImageUrl: e.target.value,
|
|
||||||
} as any)
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{source === "upload" && (
|
|
||||||
<label className="flex flex-col gap-1">
|
|
||||||
<span className="text-slate-400">그룹 타이틀 이미지 파일 업로드</span>
|
|
||||||
<input
|
|
||||||
type="file"
|
|
||||||
accept="image/*"
|
|
||||||
className="w-full text-[11px] text-slate-300 file:mr-2 file:rounded file:border file:border-slate-700 file:bg-slate-800 file:px-2 file:py-1 file:text-[11px] file:text-slate-100 hover:file:bg-slate-700"
|
|
||||||
aria-label="그룹 타이틀 이미지 파일 업로드"
|
|
||||||
onChange={async (event) => {
|
|
||||||
const file = event.target.files?.[0];
|
|
||||||
if (!file) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const formData = new FormData();
|
|
||||||
formData.append("file", file);
|
|
||||||
|
|
||||||
const response = await fetch("/api/image", {
|
|
||||||
method: "POST",
|
|
||||||
body: formData,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
console.error("체크박스 그룹 라벨 이미지 업로드 실패", await response.text());
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = (await response.json()) as { id: string; servedUrl?: string | null };
|
|
||||||
const servedUrl = data.servedUrl ?? `/api/image/${data.id}`;
|
|
||||||
|
|
||||||
updateBlock(selectedBlockId, {
|
|
||||||
groupLabelImageUrl: servedUrl,
|
|
||||||
groupLabelImageSource: "upload",
|
|
||||||
} as any);
|
|
||||||
} catch (error) {
|
|
||||||
console.error("체크박스 그룹 라벨 이미지 업로드 중 오류", error);
|
|
||||||
} finally {
|
|
||||||
event.target.value = "";
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
)}
|
|
||||||
</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={checkboxProps.formFieldName ?? ""}
|
|
||||||
onChange={(e) =>
|
|
||||||
updateBlock(selectedBlockId, {
|
|
||||||
formFieldName: e.target.value,
|
|
||||||
} as any)
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<span className="text-slate-400">옵션</span>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="rounded border border-slate-700 bg-slate-900 px-2 py-0.5 text-[10px] text-slate-200 hover:bg-slate-800"
|
|
||||||
onClick={() => {
|
|
||||||
const next = Array.isArray((checkboxProps as any).options)
|
|
||||||
? [...(checkboxProps as any).options]
|
|
||||||
: [];
|
|
||||||
next.push({
|
|
||||||
label: "새 옵션",
|
|
||||||
value: `option_${next.length + 1}`,
|
|
||||||
});
|
|
||||||
updateBlock(selectedBlockId, {
|
|
||||||
options: next,
|
|
||||||
} as any);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
옵션 추가
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<label className="flex flex-col gap-1">
|
|
||||||
<span className="text-slate-400">옵션 이미지 소스</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={(() => {
|
|
||||||
if (checkboxProps.optionImageSource) return checkboxProps.optionImageSource;
|
|
||||||
const first = (((checkboxProps as any).options ?? []) as any[])[0];
|
|
||||||
if (first?.labelImageUrl && first.labelImageUrl.startsWith("/api/image/")) return "upload";
|
|
||||||
return "url";
|
|
||||||
})()}
|
|
||||||
onChange={(e) =>
|
|
||||||
updateBlock(selectedBlockId, {
|
|
||||||
optionImageSource: e.target.value,
|
|
||||||
} as any)
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<option value="url">URL</option>
|
|
||||||
<option value="upload">파일 업로드</option>
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
|
|
||||||
{(((checkboxProps as any).options ?? []) as any[]).map((opt, index) => (
|
|
||||||
<div key={opt.value ?? index} className="space-y-1 rounded border border-slate-800 p-2">
|
|
||||||
<div className="flex items-center gap-1">
|
|
||||||
<input
|
|
||||||
className="flex-1 rounded border border-slate-700 bg-slate-950 px-2 py-1 text-[11px] outline-none focus:border-sky-500"
|
|
||||||
placeholder="라벨"
|
|
||||||
value={opt.label ?? ""}
|
|
||||||
onChange={(e) => {
|
|
||||||
const next = [...(((checkboxProps as any).options ?? []) as any[])];
|
|
||||||
next[index] = {
|
|
||||||
...next[index],
|
|
||||||
label: e.target.value,
|
|
||||||
};
|
|
||||||
updateBlock(selectedBlockId, {
|
|
||||||
options: next,
|
|
||||||
} as any);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<input
|
|
||||||
className="flex-1 rounded border border-slate-700 bg-slate-950 px-2 py-1 text-[11px] outline-none focus:border-sky-500"
|
|
||||||
placeholder="값(value)"
|
|
||||||
value={opt.value ?? ""}
|
|
||||||
onChange={(e) => {
|
|
||||||
const next = [...(((checkboxProps as any).options ?? []) as any[])];
|
|
||||||
next[index] = {
|
|
||||||
...next[index],
|
|
||||||
value: e.target.value,
|
|
||||||
};
|
|
||||||
updateBlock(selectedBlockId, {
|
|
||||||
options: next,
|
|
||||||
} as any);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="h-7 w-7 rounded border border-slate-700 bg-slate-950 text-[10px] text-slate-300 hover:bg-red-900/60 hover:border-red-700"
|
|
||||||
onClick={() => {
|
|
||||||
const next = (((checkboxProps as any).options ?? []) as any[]).filter((_, i) => i !== index);
|
|
||||||
updateBlock(selectedBlockId, {
|
|
||||||
options: next,
|
|
||||||
} as any);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
×
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col gap-1">
|
|
||||||
{(() => {
|
|
||||||
const source: "url" | "upload" =
|
|
||||||
checkboxProps.optionImageSource ??
|
|
||||||
(opt.labelImageUrl && opt.labelImageUrl.startsWith("/api/image/") ? "upload" : "url");
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{source === "url" && (
|
|
||||||
<input
|
|
||||||
className="w-full rounded border border-slate-700 bg-slate-950 px-2 py-1 text-[11px] outline-none focus:border-sky-500"
|
|
||||||
placeholder="옵션 이미지 URL (선택)"
|
|
||||||
value={opt.labelImageUrl ?? ""}
|
|
||||||
onChange={(e) => {
|
|
||||||
const next = [...(((checkboxProps as any).options ?? []) as any[])];
|
|
||||||
next[index] = {
|
|
||||||
...next[index],
|
|
||||||
labelImageUrl: e.target.value,
|
|
||||||
};
|
|
||||||
updateBlock(selectedBlockId, {
|
|
||||||
options: next,
|
|
||||||
} as any);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{source === "upload" && (
|
|
||||||
<label className="flex flex-col gap-1">
|
|
||||||
<span className="text-slate-400">옵션 이미지 파일 업로드</span>
|
|
||||||
<input
|
|
||||||
type="file"
|
|
||||||
accept="image/*"
|
|
||||||
className="w-full text-[11px] text-slate-300 file:mr-2 file:rounded file:border file:border-slate-700 file:bg-slate-800 file:px-2 file:py-1 file:text-[11px] file:text-slate-100 hover:file:bg-slate-700"
|
|
||||||
aria-label="옵션 이미지 파일 업로드"
|
|
||||||
onChange={async (event) => {
|
|
||||||
const file = event.target.files?.[0];
|
|
||||||
if (!file) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const formData = new FormData();
|
|
||||||
formData.append("file", file);
|
|
||||||
|
|
||||||
const response = await fetch("/api/image", {
|
|
||||||
method: "POST",
|
|
||||||
body: formData,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
console.error("체크박스 옵션 라벨 이미지 업로드 실패", await response.text());
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = (await response.json()) as { id: string; servedUrl?: string | null };
|
|
||||||
const servedUrl = data.servedUrl ?? `/api/image/${data.id}`;
|
|
||||||
|
|
||||||
const next = [...(((checkboxProps as any).options ?? []) as any[])];
|
|
||||||
next[index] = {
|
|
||||||
...next[index],
|
|
||||||
labelImageUrl: servedUrl,
|
|
||||||
};
|
|
||||||
|
|
||||||
updateBlock(selectedBlockId, {
|
|
||||||
options: next,
|
|
||||||
optionImageSource: "upload",
|
|
||||||
} as any);
|
|
||||||
} catch (error) {
|
|
||||||
console.error("체크박스 옵션 라벨 이미지 업로드 중 오류", error);
|
|
||||||
} finally {
|
|
||||||
event.target.value = "";
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
})()}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<label className="inline-flex items-center gap-2">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
className="h-3 w-3 rounded border border-slate-600 bg-slate-900"
|
|
||||||
checked={Boolean(checkboxProps.required)}
|
|
||||||
onChange={(e) =>
|
|
||||||
updateBlock(selectedBlockId, {
|
|
||||||
required: e.target.checked,
|
|
||||||
} as any)
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<span className="text-slate-400">필수 필드</span>
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<div className="mt-3 space-y-2 border-t border-slate-800 pt-3">
|
|
||||||
<h4 className="text-[11px] font-semibold text-slate-200">필드 스타일</h4>
|
|
||||||
{/* px 기반 체크박스 타이포 입력값을 노출하여 em 스케일 변환을 위한 근거를 남긴다 */}
|
|
||||||
<NumericPropertyControl
|
|
||||||
label="체크박스 텍스트 크기 (px)"
|
|
||||||
unitLabel="(px)"
|
|
||||||
value={(() => {
|
|
||||||
const raw = checkboxProps.fontSizeCustom ?? "";
|
|
||||||
const match = raw.match(/([0-9]+(?:\.[0-9]+)?)/);
|
|
||||||
if (match) return Number(match[1]);
|
|
||||||
return 14;
|
|
||||||
})()}
|
|
||||||
min={8}
|
|
||||||
max={40}
|
|
||||||
step={1}
|
|
||||||
presets={[
|
|
||||||
{ id: "xs", label: "XS", value: 12 },
|
|
||||||
{ id: "sm", label: "S", value: 14 },
|
|
||||||
{ id: "md", label: "M", value: 16 },
|
|
||||||
{ id: "lg", label: "L", value: 18 },
|
|
||||||
{ id: "xl", label: "XL", value: 20 },
|
|
||||||
]}
|
|
||||||
onChangeValue={(v) => {
|
|
||||||
updateBlock(selectedBlockId, {
|
|
||||||
fontSizeCustom: `${v}px`,
|
|
||||||
} as any);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
{/* 줄간격 px 입력값을 노출해 preview em 변환과 TDD 케이스를 연동한다 */}
|
|
||||||
<NumericPropertyControl
|
|
||||||
label="체크박스 줄간격 (px)"
|
|
||||||
unitLabel="(px)"
|
|
||||||
value={(() => {
|
|
||||||
const raw = checkboxProps.lineHeightCustom ?? "";
|
|
||||||
const match = raw.match(/([0-9]+(?:\.[0-9]+)?)/);
|
|
||||||
if (match) return Number(match[1]);
|
|
||||||
return 20;
|
|
||||||
})()}
|
|
||||||
min={8}
|
|
||||||
max={60}
|
|
||||||
step={1}
|
|
||||||
presets={[
|
|
||||||
{ id: "tight", label: "타이트", value: 18 },
|
|
||||||
{ id: "normal", label: "보통", value: 24 },
|
|
||||||
{ id: "loose", label: "루즈", value: 32 },
|
|
||||||
]}
|
|
||||||
onChangeValue={(v) => {
|
|
||||||
updateBlock(selectedBlockId, {
|
|
||||||
lineHeightCustom: `${v}px`,
|
|
||||||
} as any);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
{/* 자간 px 입력을 통한 폼 체크박스 미세 조정값을 노출 */}
|
|
||||||
<NumericPropertyControl
|
|
||||||
label="체크박스 자간 (px)"
|
|
||||||
unitLabel="(px)"
|
|
||||||
value={(() => {
|
|
||||||
const raw = checkboxProps.letterSpacingCustom ?? "";
|
|
||||||
const match = raw.match(/(-?[0-9]+(?:\.[0-9]+)?)/);
|
|
||||||
if (match) return Number(match[1]);
|
|
||||||
return 0;
|
|
||||||
})()}
|
|
||||||
min={-10}
|
|
||||||
max={20}
|
|
||||||
step={0.5}
|
|
||||||
presets={[
|
|
||||||
{ id: "tight", label: "좁게", value: -1 },
|
|
||||||
{ id: "normal", label: "보통", value: 0 },
|
|
||||||
{ id: "wide", label: "넓게", value: 2 },
|
|
||||||
]}
|
|
||||||
onChangeValue={(v) => {
|
|
||||||
updateBlock(selectedBlockId, {
|
|
||||||
letterSpacingCustom: `${v}px`,
|
|
||||||
} as any);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<label className="flex flex-col gap-1 text-[11px] 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={checkboxProps.widthMode ?? "full"}
|
|
||||||
onChange={(e) =>
|
|
||||||
updateBlock(selectedBlockId, {
|
|
||||||
widthMode: e.target.value,
|
|
||||||
} as any)
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<option value="auto">자동</option>
|
|
||||||
<option value="full">전체 폭</option>
|
|
||||||
<option value="fixed">고정 값</option>
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
{(checkboxProps.widthMode ?? "full") === "fixed" && (
|
|
||||||
<NumericPropertyControl
|
|
||||||
label="필드 고정 너비"
|
|
||||||
unitLabel="(px)"
|
|
||||||
value={checkboxProps.widthPx ?? 240}
|
|
||||||
min={80}
|
|
||||||
max={800}
|
|
||||||
step={10}
|
|
||||||
presets={[
|
|
||||||
{ id: "sm", label: "작게", value: 200 },
|
|
||||||
{ id: "md", label: "보통", value: 280 },
|
|
||||||
{ id: "lg", label: "넓게", value: 360 },
|
|
||||||
]}
|
|
||||||
onChangeValue={(v) => {
|
|
||||||
updateBlock(selectedBlockId, {
|
|
||||||
widthPx: v,
|
|
||||||
} as any);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<NumericPropertyControl
|
|
||||||
label="체크박스 가로 패딩 (px)"
|
|
||||||
unitLabel="(px)"
|
|
||||||
value={typeof checkboxProps.paddingX === "number" ? checkboxProps.paddingX : 12}
|
|
||||||
min={0}
|
|
||||||
max={60}
|
|
||||||
step={2}
|
|
||||||
presets={[
|
|
||||||
{ id: "xs", label: "아주 얇게", value: 6 },
|
|
||||||
{ id: "sm", label: "얇게", value: 10 },
|
|
||||||
{ id: "md", label: "보통", value: 12 },
|
|
||||||
{ id: "lg", label: "넓게", value: 16 },
|
|
||||||
{ id: "xl", label: "아주 넓게", value: 20 },
|
|
||||||
]}
|
|
||||||
onChangeValue={(v) =>
|
|
||||||
updateBlock(selectedBlockId, {
|
|
||||||
paddingX: v,
|
|
||||||
} as any)
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<NumericPropertyControl
|
|
||||||
label="체크박스 세로 패딩 (px)"
|
|
||||||
unitLabel="(px)"
|
|
||||||
value={typeof checkboxProps.paddingY === "number" ? checkboxProps.paddingY : 10}
|
|
||||||
min={0}
|
|
||||||
max={40}
|
|
||||||
step={1}
|
|
||||||
presets={[
|
|
||||||
{ id: "xs", label: "아주 얇게", value: 6 },
|
|
||||||
{ id: "sm", label: "얇게", value: 8 },
|
|
||||||
{ id: "md", label: "보통", value: 10 },
|
|
||||||
{ id: "lg", label: "넓게", value: 12 },
|
|
||||||
{ id: "xl", label: "아주 넓게", value: 16 },
|
|
||||||
]}
|
|
||||||
onChangeValue={(v) =>
|
|
||||||
updateBlock(selectedBlockId, {
|
|
||||||
paddingY: v,
|
|
||||||
} as any)
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<NumericPropertyControl
|
|
||||||
label="체크박스 옵션 간격 (px)"
|
|
||||||
unitLabel="(px)"
|
|
||||||
value={typeof checkboxProps.optionGapPx === "number" ? checkboxProps.optionGapPx : 4}
|
|
||||||
min={0}
|
|
||||||
max={40}
|
|
||||||
step={1}
|
|
||||||
presets={[
|
|
||||||
{ id: "tight", label: "타이트", value: 2 },
|
|
||||||
{ id: "normal", label: "보통", value: 4 },
|
|
||||||
{ id: "relaxed", label: "느슨", value: 8 },
|
|
||||||
{ id: "extra", label: "넓게", value: 12 },
|
|
||||||
]}
|
|
||||||
onChangeValue={(v) =>
|
|
||||||
updateBlock(selectedBlockId, {
|
|
||||||
optionGapPx: v,
|
|
||||||
} as any)
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<ColorPickerField
|
|
||||||
label="텍스트 색상"
|
|
||||||
ariaLabelColorInput="체크박스 텍스트 색상 피커"
|
|
||||||
ariaLabelHexInput="체크박스 텍스트 색상 HEX"
|
|
||||||
value={
|
|
||||||
checkboxProps.textColorCustom && checkboxProps.textColorCustom.trim() !== ""
|
|
||||||
? checkboxProps.textColorCustom
|
|
||||||
: "#f9fafb"
|
|
||||||
}
|
|
||||||
onChange={(hex) => {
|
|
||||||
updateBlock(selectedBlockId, {
|
|
||||||
textColorCustom: hex,
|
|
||||||
} as any);
|
|
||||||
}}
|
|
||||||
palette={TEXT_COLOR_PALETTE}
|
|
||||||
onPaletteSelect={(item) => {
|
|
||||||
updateBlock(selectedBlockId, {
|
|
||||||
textColorCustom: item.color,
|
|
||||||
} as any);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<ColorPickerField
|
|
||||||
label="배경 색상"
|
|
||||||
ariaLabelColorInput="체크박스 배경 색상 피커"
|
|
||||||
ariaLabelHexInput="체크박스 배경 색상 HEX"
|
|
||||||
value={
|
|
||||||
checkboxProps.fillColorCustom && checkboxProps.fillColorCustom.trim() !== ""
|
|
||||||
? checkboxProps.fillColorCustom
|
|
||||||
: TEXT_COLOR_PALETTE[0]?.color ?? "#0ea5e9"
|
|
||||||
}
|
|
||||||
onChange={(hex) => {
|
|
||||||
updateBlock(selectedBlockId, {
|
|
||||||
fillColorCustom: hex,
|
|
||||||
} as any);
|
|
||||||
}}
|
|
||||||
palette={TEXT_COLOR_PALETTE}
|
|
||||||
onPaletteSelect={(item) => {
|
|
||||||
updateBlock(selectedBlockId, {
|
|
||||||
fillColorCustom: item.color,
|
|
||||||
} as any);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<ColorPickerField
|
|
||||||
label="테두리 색상"
|
|
||||||
ariaLabelColorInput="체크박스 테두리 색상 피커"
|
|
||||||
ariaLabelHexInput="체크박스 테두리 색상 HEX"
|
|
||||||
value={
|
|
||||||
checkboxProps.strokeColorCustom && checkboxProps.strokeColorCustom.trim() !== ""
|
|
||||||
? checkboxProps.strokeColorCustom
|
|
||||||
: TEXT_COLOR_PALETTE[0]?.color ?? "#0ea5e9"
|
|
||||||
}
|
|
||||||
onChange={(hex) => {
|
|
||||||
updateBlock(selectedBlockId, {
|
|
||||||
strokeColorCustom: hex,
|
|
||||||
} as any);
|
|
||||||
}}
|
|
||||||
palette={TEXT_COLOR_PALETTE}
|
|
||||||
onPaletteSelect={(item) => {
|
|
||||||
updateBlock(selectedBlockId, {
|
|
||||||
strokeColorCustom: item.color,
|
|
||||||
} as any);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<NumericPropertyControl
|
|
||||||
label="필드 모서리 둥글기"
|
|
||||||
value={(() => {
|
|
||||||
const r = checkboxProps.borderRadius ?? "md";
|
|
||||||
return r === "none" ? 0 : r === "sm" ? 2 : r === "lg" ? 6 : r === "full" ? 8 : 4;
|
|
||||||
})()}
|
|
||||||
min={0}
|
|
||||||
max={8}
|
|
||||||
step={1}
|
|
||||||
presets={[
|
|
||||||
{ id: "none", label: "없음", value: 0 },
|
|
||||||
{ id: "sm", label: "작게", value: 2 },
|
|
||||||
{ id: "md", label: "보통", value: 4 },
|
|
||||||
{ id: "lg", label: "크게", value: 6 },
|
|
||||||
{ id: "full", label: "완전 둥글게", value: 8 },
|
|
||||||
]}
|
|
||||||
onChangeValue={(v) => {
|
|
||||||
const next =
|
|
||||||
v <= 0 ? "none" : v <= 2 ? "sm" : v <= 5 ? "md" : v <= 7 ? "lg" : "full";
|
|
||||||
updateBlock(selectedBlockId, {
|
|
||||||
borderRadius: next,
|
|
||||||
} as any);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,311 +0,0 @@
|
|||||||
"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"
|
|
||||||
blocks: Block[];
|
|
||||||
selectedBlockId: string | null;
|
|
||||||
updateBlock: (id: string, partial: any) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function FormControllerPanel({ block, blocks, selectedBlockId, updateBlock }: FormControllerPanelProps) {
|
|
||||||
if (!selectedBlockId || block.id !== selectedBlockId) return null;
|
|
||||||
|
|
||||||
const formProps = block.props as FormBlockProps;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-4 text-xs">
|
|
||||||
<p className="text-slate-400">
|
|
||||||
이 폼은 퍼블릭 페이지에서 <code className="font-mono text-[11px]">/api/forms/submit</code>
|
|
||||||
엔드포인트로 먼저 전송된 뒤, 설정에 따라 내부 처리 또는 Webhook 으로 전달됩니다.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
<div className="flex flex-col gap-1">
|
|
||||||
<span className="text-slate-400">전송 대상</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.submitTarget ?? "internal"}
|
|
||||||
onChange={(e) =>
|
|
||||||
updateBlock(selectedBlockId, {
|
|
||||||
submitTarget: e.target.value as FormBlockProps["submitTarget"],
|
|
||||||
} as any)
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<option value="internal">서버 처리 전용 (Webhook 없이 사용)</option>
|
|
||||||
<option value="webhook">외부 Webhook / Google Sheets 로 전달</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{formProps.submitTarget === "webhook" && (
|
|
||||||
<div className="space-y-2">
|
|
||||||
<label className="flex flex-col gap-1">
|
|
||||||
<span className="text-slate-400">Webhook / Google Sheets URL</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"
|
|
||||||
placeholder="예: https://script.google.com/macros/s/.../exec"
|
|
||||||
value={formProps.destinationUrl ?? ""}
|
|
||||||
onChange={(e) =>
|
|
||||||
updateBlock(selectedBlockId, {
|
|
||||||
destinationUrl: e.target.value,
|
|
||||||
} as any)
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
<label className="flex items-center justify-between gap-2">
|
|
||||||
<span className="text-slate-400">전송 포맷</span>
|
|
||||||
<select
|
|
||||||
className="w-40 rounded border border-slate-700 bg-slate-950 px-2 py-1 text-[11px] outline-none focus:border-sky-500"
|
|
||||||
value={formProps.payloadFormat ?? "form"}
|
|
||||||
onChange={(e) =>
|
|
||||||
updateBlock(selectedBlockId, {
|
|
||||||
payloadFormat: e.target.value as any,
|
|
||||||
} as any)
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<option value="form">폼 데이터 (x-www-form-urlencoded)</option>
|
|
||||||
<option value="json">JSON (application/json)</option>
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
<label className="flex items-center justify-between gap-2">
|
|
||||||
<span className="text-slate-400">HTTP 메서드</span>
|
|
||||||
<select
|
|
||||||
className="w-28 rounded border border-slate-700 bg-slate-950 px-2 py-1 text-[11px] outline-none focus:border-sky-500"
|
|
||||||
value={formProps.method ?? "POST"}
|
|
||||||
onChange={(e) =>
|
|
||||||
updateBlock(selectedBlockId, {
|
|
||||||
method: e.target.value as FormBlockProps["method"],
|
|
||||||
} as any)
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<option value="POST">POST</option>
|
|
||||||
<option value="GET">GET</option>
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
<label className="flex flex-col gap-1">
|
|
||||||
<span className="text-slate-400">Authorization 토큰 (옵션)</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"
|
|
||||||
placeholder="예: Bearer xxxxxx"
|
|
||||||
value={formProps.headers?.Authorization ?? ""}
|
|
||||||
onChange={(e) =>
|
|
||||||
updateBlock(selectedBlockId, {
|
|
||||||
headers: {
|
|
||||||
...(formProps.headers ?? {}),
|
|
||||||
Authorization: e.target.value,
|
|
||||||
},
|
|
||||||
} as any)
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
<label className="flex flex-col gap-1">
|
|
||||||
<span className="text-slate-400">추가 파라미터 (key=value 형식, 줄바꿈으로 구분)</span>
|
|
||||||
<textarea
|
|
||||||
className="w-full h-16 rounded border border-slate-700 bg-slate-950 px-2 py-1 text-[11px] font-mono outline-none focus:border-sky-500"
|
|
||||||
placeholder={"예:\nsource=landing\nformId=contact-hero"}
|
|
||||||
value={Object.entries(formProps.extraParams ?? {})
|
|
||||||
.map(([k, v]) => `${k}=${v}`)
|
|
||||||
.join("\n")}
|
|
||||||
onChange={(e) => {
|
|
||||||
const lines = e.target.value.split(/\r?\n/).map((line) => line.trim()).filter(Boolean);
|
|
||||||
const next: Record<string, string> = {};
|
|
||||||
for (const line of lines) {
|
|
||||||
const idx = line.indexOf("=");
|
|
||||||
if (idx > 0) {
|
|
||||||
const key = line.slice(0, idx).trim();
|
|
||||||
const value = line.slice(idx + 1).trim();
|
|
||||||
if (key) next[key] = value;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
updateBlock(selectedBlockId, {
|
|
||||||
extraParams: next,
|
|
||||||
} as any);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</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">
|
|
||||||
<span>성공 메시지</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"
|
|
||||||
placeholder="예: 성공적으로 전송되었습니다."
|
|
||||||
value={formProps.successMessage ?? ""}
|
|
||||||
onChange={(e) =>
|
|
||||||
updateBlock(selectedBlockId, {
|
|
||||||
successMessage: e.target.value,
|
|
||||||
} as any)
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
<label className="flex flex-col gap-1 text-xs text-slate-400">
|
|
||||||
<span>에러 메시지</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"
|
|
||||||
placeholder="예: 전송 중 오류가 발생했습니다."
|
|
||||||
value={formProps.errorMessage ?? ""}
|
|
||||||
onChange={(e) =>
|
|
||||||
updateBlock(selectedBlockId, {
|
|
||||||
errorMessage: e.target.value,
|
|
||||||
} as any)
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-3 border-t border-slate-800 pt-3 mt-4">
|
|
||||||
<h3 className="text-[11px] font-semibold text-slate-200">폼 컨트롤러</h3>
|
|
||||||
|
|
||||||
<fieldset className="space-y-2" aria-label="폼 필드 매핑">
|
|
||||||
<legend className="text-[11px] text-slate-400 mb-1">폼 필드 매핑</legend>
|
|
||||||
{blocks
|
|
||||||
.filter((b) =>
|
|
||||||
b.type === "formInput" ||
|
|
||||||
b.type === "formSelect" ||
|
|
||||||
b.type === "formCheckbox" ||
|
|
||||||
b.type === "formRadio",
|
|
||||||
)
|
|
||||||
.map((fieldBlock) => {
|
|
||||||
const fieldId = fieldBlock.id;
|
|
||||||
const anyProps: any = fieldBlock.props ?? {};
|
|
||||||
|
|
||||||
const transmissionKey: string | undefined = anyProps.formFieldName;
|
|
||||||
const labelText: string | undefined = anyProps.label ?? anyProps.groupLabel;
|
|
||||||
|
|
||||||
const displayLabel = transmissionKey
|
|
||||||
? labelText && labelText !== transmissionKey
|
|
||||||
? `${transmissionKey} (${labelText})`
|
|
||||||
: transmissionKey
|
|
||||||
: labelText || fieldId;
|
|
||||||
|
|
||||||
const checked = (formProps.fieldIds ?? []).includes(fieldId);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<label
|
|
||||||
key={fieldId}
|
|
||||||
className="flex items-center gap-2 text-[11px] text-slate-200"
|
|
||||||
>
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
className="h-3 w-3 rounded border-slate-600 bg-slate-950"
|
|
||||||
checked={checked}
|
|
||||||
onChange={(e) => {
|
|
||||||
const current = formProps.fieldIds ?? [];
|
|
||||||
const next = e.target.checked
|
|
||||||
? [...current, fieldId]
|
|
||||||
: current.filter((id) => id !== fieldId);
|
|
||||||
|
|
||||||
updateBlock(selectedBlockId, {
|
|
||||||
fieldIds: next,
|
|
||||||
} as any);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<span>{displayLabel}</span>
|
|
||||||
</label>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</fieldset>
|
|
||||||
|
|
||||||
<div className="space-y-1">
|
|
||||||
<span className="text-[11px] text-slate-400">Submit 버튼</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"
|
|
||||||
aria-label="Submit 버튼"
|
|
||||||
value={formProps.submitButtonId ?? ""}
|
|
||||||
onChange={(e) =>
|
|
||||||
updateBlock(selectedBlockId, {
|
|
||||||
submitButtonId: e.target.value || null,
|
|
||||||
} as any)
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<option value="">선택 안 함</option>
|
|
||||||
{blocks
|
|
||||||
.filter((b) => b.type === "button")
|
|
||||||
.map((buttonBlock) => {
|
|
||||||
const btnProps = buttonBlock.props as ButtonBlockProps;
|
|
||||||
const transmissionKey = btnProps.formFieldName;
|
|
||||||
const labelText = btnProps.label;
|
|
||||||
const displayLabel = transmissionKey
|
|
||||||
? labelText && labelText !== transmissionKey
|
|
||||||
? `${transmissionKey} (${labelText})`
|
|
||||||
: transmissionKey
|
|
||||||
: labelText || buttonBlock.id;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<option key={buttonBlock.id} value={buttonBlock.id}>
|
|
||||||
{displayLabel}
|
|
||||||
</option>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,424 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import type { Block, FormInputBlockProps } from "@/features/editor/state/editorStore";
|
|
||||||
import { ColorPickerField, TEXT_COLOR_PALETTE } from "@/features/editor/components/ColorPickerField";
|
|
||||||
import { NumericPropertyControl } from "@/features/editor/components/NumericPropertyControl";
|
|
||||||
|
|
||||||
interface FormInputPropertiesPanelProps {
|
|
||||||
block: Block;
|
|
||||||
selectedBlockId: string | null;
|
|
||||||
updateBlock: (id: string, partial: any) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function FormInputPropertiesPanel({ block, selectedBlockId, updateBlock }: FormInputPropertiesPanelProps) {
|
|
||||||
if (!selectedBlockId || block.id !== selectedBlockId || block.type !== "formInput") return null;
|
|
||||||
|
|
||||||
const inputProps = block.props as FormInputBlockProps;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-3 text-xs">
|
|
||||||
<h3 className="text-[11px] font-semibold text-slate-200">입력 필드</h3>
|
|
||||||
<label className="flex flex-col gap-1">
|
|
||||||
<span className="text-slate-400">라벨 타입</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={inputProps.labelMode ?? "text"}
|
|
||||||
onChange={(e) =>
|
|
||||||
updateBlock(selectedBlockId, {
|
|
||||||
labelMode: e.target.value,
|
|
||||||
} as any)
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<option value="text">텍스트</option>
|
|
||||||
<option value="image">이미지</option>
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
<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={inputProps.label ?? ""}
|
|
||||||
onChange={(e) =>
|
|
||||||
updateBlock(selectedBlockId, {
|
|
||||||
label: e.target.value,
|
|
||||||
} as any)
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
{inputProps.labelMode === "image" && (
|
|
||||||
<>
|
|
||||||
<label className="flex flex-col gap-1">
|
|
||||||
<span className="text-slate-400">라벨 이미지 URL</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={inputProps.labelImageUrl ?? ""}
|
|
||||||
onChange={(e) =>
|
|
||||||
updateBlock(selectedBlockId, {
|
|
||||||
labelImageUrl: e.target.value,
|
|
||||||
} as any)
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
<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={inputProps.labelImageAlt ?? ""}
|
|
||||||
onChange={(e) =>
|
|
||||||
updateBlock(selectedBlockId, {
|
|
||||||
labelImageAlt: e.target.value,
|
|
||||||
} as any)
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
<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={inputProps.formFieldName ?? ""}
|
|
||||||
onChange={(e) =>
|
|
||||||
updateBlock(selectedBlockId, {
|
|
||||||
formFieldName: e.target.value,
|
|
||||||
} as any)
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
<label className="flex flex-col gap-1">
|
|
||||||
<span className="text-slate-400">필드 타입</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"
|
|
||||||
aria-label="필드 타입"
|
|
||||||
value={inputProps.inputType ?? "text"}
|
|
||||||
onChange={(e) =>
|
|
||||||
updateBlock(selectedBlockId, {
|
|
||||||
inputType: e.target.value,
|
|
||||||
} as any)
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<option value="text">텍스트</option>
|
|
||||||
<option value="email">이메일</option>
|
|
||||||
<option value="textarea">긴 텍스트</option>
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
<label className="flex flex-col gap-1">
|
|
||||||
<span className="text-slate-400">Placeholder</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"
|
|
||||||
aria-label="Placeholder"
|
|
||||||
value={inputProps.placeholder ?? ""}
|
|
||||||
onChange={(e) =>
|
|
||||||
updateBlock(selectedBlockId, {
|
|
||||||
placeholder: e.target.value,
|
|
||||||
} as any)
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
<label className="inline-flex items-center gap-2">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
className="h-3 w-3 rounded border border-slate-600 bg-slate-900"
|
|
||||||
checked={Boolean(inputProps.required)}
|
|
||||||
onChange={(e) =>
|
|
||||||
updateBlock(selectedBlockId, {
|
|
||||||
required: e.target.checked,
|
|
||||||
} as any)
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<span className="text-slate-400">필수 필드</span>
|
|
||||||
</label>
|
|
||||||
<div className="mt-3 space-y-2 border-t border-slate-800 pt-3">
|
|
||||||
<h4 className="text-[11px] font-semibold text-slate-200">필드 스타일</h4>
|
|
||||||
<NumericPropertyControl
|
|
||||||
label="필드 텍스트 크기 (px)"
|
|
||||||
unitLabel="(px)"
|
|
||||||
value={(() => {
|
|
||||||
const raw = inputProps.fontSizeCustom ?? "";
|
|
||||||
const match = raw.match(/([0-9]+(?:\.[0-9]+)?)/);
|
|
||||||
if (match) return Number(match[1]);
|
|
||||||
return 14;
|
|
||||||
})()}
|
|
||||||
min={8}
|
|
||||||
max={40}
|
|
||||||
step={1}
|
|
||||||
presets={[
|
|
||||||
{ id: "xs", label: "XS", value: 12 },
|
|
||||||
{ id: "sm", label: "S", value: 14 },
|
|
||||||
{ id: "md", label: "M", value: 16 },
|
|
||||||
{ id: "lg", label: "L", value: 18 },
|
|
||||||
{ id: "xl", label: "XL", value: 20 },
|
|
||||||
]}
|
|
||||||
onChangeValue={(v) => {
|
|
||||||
updateBlock(selectedBlockId, {
|
|
||||||
fontSizeCustom: `${v}px`,
|
|
||||||
} as any);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
{/* 줄간격 px 입력을 노출하여 preview em 변환의 근거를 제공 */}
|
|
||||||
<NumericPropertyControl
|
|
||||||
label="필드 줄간격 (px)"
|
|
||||||
unitLabel="(px)"
|
|
||||||
value={(() => {
|
|
||||||
const raw = inputProps.lineHeightCustom ?? "";
|
|
||||||
const match = raw.match(/([0-9]+(?:\.[0-9]+)?)/);
|
|
||||||
if (match) return Number(match[1]);
|
|
||||||
return 20;
|
|
||||||
})()}
|
|
||||||
min={8}
|
|
||||||
max={60}
|
|
||||||
step={1}
|
|
||||||
presets={[
|
|
||||||
{ id: "tight", label: "타이트", value: 18 },
|
|
||||||
{ id: "normal", label: "보통", value: 24 },
|
|
||||||
{ id: "loose", label: "루즈", value: 32 },
|
|
||||||
]}
|
|
||||||
onChangeValue={(v) => {
|
|
||||||
updateBlock(selectedBlockId, {
|
|
||||||
lineHeightCustom: `${v}px`,
|
|
||||||
} as any);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
{/* 자간 px 입력으로 폼 입력 타이포를 세밀하게 조정 */}
|
|
||||||
<NumericPropertyControl
|
|
||||||
label="필드 자간 (px)"
|
|
||||||
unitLabel="(px)"
|
|
||||||
value={(() => {
|
|
||||||
const raw = inputProps.letterSpacingCustom ?? "";
|
|
||||||
const match = raw.match(/(-?[0-9]+(?:\.[0-9]+)?)/);
|
|
||||||
if (match) return Number(match[1]);
|
|
||||||
return 0;
|
|
||||||
})()}
|
|
||||||
min={-10}
|
|
||||||
max={20}
|
|
||||||
step={0.5}
|
|
||||||
presets={[
|
|
||||||
{ id: "tight", label: "좁게", value: -1 },
|
|
||||||
{ id: "normal", label: "보통", value: 0 },
|
|
||||||
{ id: "wide", label: "넓게", value: 2 },
|
|
||||||
]}
|
|
||||||
onChangeValue={(v) => {
|
|
||||||
updateBlock(selectedBlockId, {
|
|
||||||
letterSpacingCustom: `${v}px`,
|
|
||||||
} as any);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<label className="flex flex-col gap-1 text-[11px] 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={inputProps.align ?? "left"}
|
|
||||||
onChange={(e) =>
|
|
||||||
updateBlock(selectedBlockId, {
|
|
||||||
align: e.target.value,
|
|
||||||
} as any)
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<option value="left">왼쪽</option>
|
|
||||||
<option value="center">가운데</option>
|
|
||||||
<option value="right">오른쪽</option>
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
<label className="flex flex-col gap-1 text-[11px] 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={inputProps.labelLayout ?? "stacked"}
|
|
||||||
onChange={(e) =>
|
|
||||||
updateBlock(selectedBlockId, {
|
|
||||||
labelLayout: e.target.value,
|
|
||||||
} as any)
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<option value="stacked">세로 (기본)</option>
|
|
||||||
<option value="inline">가로 (인라인)</option>
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
{(inputProps.labelLayout ?? "stacked") === "inline" && (
|
|
||||||
<NumericPropertyControl
|
|
||||||
label="라벨/필드 간격 (px)"
|
|
||||||
unitLabel="(px)"
|
|
||||||
value={typeof inputProps.labelGapPx === "number" ? inputProps.labelGapPx : 8}
|
|
||||||
min={0}
|
|
||||||
max={80}
|
|
||||||
step={2}
|
|
||||||
presets={[
|
|
||||||
{ id: "tight", label: "타이트", value: 4 },
|
|
||||||
{ id: "normal", label: "보통", value: 8 },
|
|
||||||
{ id: "relaxed", label: "느슨", value: 12 },
|
|
||||||
{ id: "extra", label: "넓게", value: 16 },
|
|
||||||
]}
|
|
||||||
onChangeValue={(v) =>
|
|
||||||
updateBlock(selectedBlockId, {
|
|
||||||
labelGapPx: v,
|
|
||||||
} as any)
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<label className="flex flex-col gap-1 text-[11px] 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={inputProps.widthMode ?? "full"}
|
|
||||||
onChange={(e) =>
|
|
||||||
updateBlock(selectedBlockId, {
|
|
||||||
widthMode: e.target.value,
|
|
||||||
} as any)
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<option value="auto">자동</option>
|
|
||||||
<option value="full">전체 폭</option>
|
|
||||||
<option value="fixed">고정 값</option>
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
{(inputProps.widthMode ?? "full") === "fixed" && (
|
|
||||||
<NumericPropertyControl
|
|
||||||
label="필드 고정 너비"
|
|
||||||
unitLabel="(px)"
|
|
||||||
value={inputProps.widthPx ?? 240}
|
|
||||||
min={80}
|
|
||||||
max={800}
|
|
||||||
step={10}
|
|
||||||
presets={[
|
|
||||||
{ id: "sm", label: "작게", value: 200 },
|
|
||||||
{ id: "md", label: "보통", value: 280 },
|
|
||||||
{ id: "lg", label: "넓게", value: 360 },
|
|
||||||
]}
|
|
||||||
onChangeValue={(v) => {
|
|
||||||
updateBlock(selectedBlockId, {
|
|
||||||
widthPx: v,
|
|
||||||
} as any);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<NumericPropertyControl
|
|
||||||
label="필드 가로 패딩 (px)"
|
|
||||||
unitLabel="(px)"
|
|
||||||
value={typeof inputProps.paddingX === "number" ? inputProps.paddingX : 12}
|
|
||||||
min={0}
|
|
||||||
max={60}
|
|
||||||
step={2}
|
|
||||||
presets={[
|
|
||||||
{ id: "xs", label: "아주 얇게", value: 6 },
|
|
||||||
{ id: "sm", label: "얇게", value: 10 },
|
|
||||||
{ id: "md", label: "보통", value: 12 },
|
|
||||||
{ id: "lg", label: "넓게", value: 16 },
|
|
||||||
{ id: "xl", label: "아주 넓게", value: 20 },
|
|
||||||
]}
|
|
||||||
onChangeValue={(v) => {
|
|
||||||
updateBlock(selectedBlockId, {
|
|
||||||
paddingX: v,
|
|
||||||
} as any);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<NumericPropertyControl
|
|
||||||
label="필드 세로 패딩 (px)"
|
|
||||||
unitLabel="(px)"
|
|
||||||
value={typeof inputProps.paddingY === "number" ? inputProps.paddingY : 10}
|
|
||||||
min={0}
|
|
||||||
max={40}
|
|
||||||
step={1}
|
|
||||||
presets={[
|
|
||||||
{ id: "xs", label: "아주 얇게", value: 6 },
|
|
||||||
{ id: "sm", label: "얇게", value: 8 },
|
|
||||||
{ id: "md", label: "보통", value: 10 },
|
|
||||||
{ id: "lg", label: "넓게", value: 12 },
|
|
||||||
{ id: "xl", label: "아주 넓게", value: 16 },
|
|
||||||
]}
|
|
||||||
onChangeValue={(v) => {
|
|
||||||
updateBlock(selectedBlockId, {
|
|
||||||
paddingY: v,
|
|
||||||
} as any);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<ColorPickerField
|
|
||||||
label="필드 텍스트 색상"
|
|
||||||
ariaLabelColorInput="필드 텍스트 색상 피커"
|
|
||||||
ariaLabelHexInput="필드 텍스트 색상 HEX"
|
|
||||||
value={
|
|
||||||
inputProps.textColorCustom && inputProps.textColorCustom.trim() !== ""
|
|
||||||
? inputProps.textColorCustom
|
|
||||||
: "#f9fafb"
|
|
||||||
}
|
|
||||||
onChange={(hex) => {
|
|
||||||
updateBlock(selectedBlockId, {
|
|
||||||
textColorCustom: hex,
|
|
||||||
} as any);
|
|
||||||
}}
|
|
||||||
palette={TEXT_COLOR_PALETTE}
|
|
||||||
onPaletteSelect={(item) => {
|
|
||||||
updateBlock(selectedBlockId, {
|
|
||||||
textColorCustom: item.color,
|
|
||||||
} as any);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<ColorPickerField
|
|
||||||
label="필드 채움 색상"
|
|
||||||
ariaLabelColorInput="필드 채움 색상 피커"
|
|
||||||
ariaLabelHexInput="필드 채움 색상 HEX"
|
|
||||||
value={
|
|
||||||
inputProps.fillColorCustom && inputProps.fillColorCustom.trim() !== ""
|
|
||||||
? inputProps.fillColorCustom
|
|
||||||
: TEXT_COLOR_PALETTE[0]?.color ?? "#0ea5e9"
|
|
||||||
}
|
|
||||||
onChange={(hex) => {
|
|
||||||
updateBlock(selectedBlockId, {
|
|
||||||
fillColorCustom: hex,
|
|
||||||
} as any);
|
|
||||||
}}
|
|
||||||
palette={TEXT_COLOR_PALETTE}
|
|
||||||
onPaletteSelect={(item) => {
|
|
||||||
updateBlock(selectedBlockId, {
|
|
||||||
fillColorCustom: item.color,
|
|
||||||
} as any);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<ColorPickerField
|
|
||||||
label="필드 테두리 색상"
|
|
||||||
ariaLabelColorInput="필드 테두리 색상 피커"
|
|
||||||
ariaLabelHexInput="필드 테두리 색상 HEX"
|
|
||||||
value={
|
|
||||||
inputProps.strokeColorCustom && inputProps.strokeColorCustom.trim() !== ""
|
|
||||||
? inputProps.strokeColorCustom
|
|
||||||
: TEXT_COLOR_PALETTE[0]?.color ?? "#0ea5e9"
|
|
||||||
}
|
|
||||||
onChange={(hex) => {
|
|
||||||
updateBlock(selectedBlockId, {
|
|
||||||
strokeColorCustom: hex,
|
|
||||||
} as any);
|
|
||||||
}}
|
|
||||||
palette={TEXT_COLOR_PALETTE}
|
|
||||||
onPaletteSelect={(item) => {
|
|
||||||
updateBlock(selectedBlockId, {
|
|
||||||
strokeColorCustom: item.color,
|
|
||||||
} as any);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<NumericPropertyControl
|
|
||||||
label="필드 모서리 둥글기"
|
|
||||||
value={(() => {
|
|
||||||
const r = inputProps.borderRadius ?? "md";
|
|
||||||
return r === "none" ? 0 : r === "sm" ? 2 : r === "lg" ? 6 : r === "full" ? 8 : 4;
|
|
||||||
})()}
|
|
||||||
min={0}
|
|
||||||
max={8}
|
|
||||||
step={1}
|
|
||||||
presets={[
|
|
||||||
{ id: "none", label: "없음", value: 0 },
|
|
||||||
{ id: "sm", label: "작게", value: 2 },
|
|
||||||
{ id: "md", label: "보통", value: 4 },
|
|
||||||
{ id: "lg", label: "크게", value: 6 },
|
|
||||||
{ id: "full", label: "완전 둥글게", value: 8 },
|
|
||||||
]}
|
|
||||||
onChangeValue={(v) => {
|
|
||||||
const next =
|
|
||||||
v <= 0 ? "none" : v <= 2 ? "sm" : v <= 5 ? "md" : v <= 7 ? "lg" : "full";
|
|
||||||
updateBlock(selectedBlockId, {
|
|
||||||
borderRadius: next,
|
|
||||||
} as any);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,597 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import type { Block, FormRadioBlockProps } from "@/features/editor/state/editorStore";
|
|
||||||
import { ColorPickerField, TEXT_COLOR_PALETTE } from "@/features/editor/components/ColorPickerField";
|
|
||||||
import { NumericPropertyControl } from "@/features/editor/components/NumericPropertyControl";
|
|
||||||
|
|
||||||
interface FormRadioPropertiesPanelProps {
|
|
||||||
block: Block;
|
|
||||||
selectedBlockId: string | null;
|
|
||||||
updateBlock: (id: string, partial: any) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function FormRadioPropertiesPanel({ block, selectedBlockId, updateBlock }: FormRadioPropertiesPanelProps) {
|
|
||||||
if (!selectedBlockId || block.id !== selectedBlockId) return null;
|
|
||||||
|
|
||||||
const radioProps = block.props as FormRadioBlockProps;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-3 text-xs">
|
|
||||||
<h3 className="text-[11px] font-semibold text-slate-200">라디오 필드</h3>
|
|
||||||
|
|
||||||
<label className="flex flex-col gap-1">
|
|
||||||
<span className="text-slate-400">그룹 타이틀 타입</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={radioProps.groupLabelMode ?? "text"}
|
|
||||||
onChange={(e) =>
|
|
||||||
updateBlock(selectedBlockId, {
|
|
||||||
groupLabelMode: e.target.value,
|
|
||||||
} as any)
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<option value="text">텍스트</option>
|
|
||||||
<option value="image">이미지</option>
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<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={radioProps.groupLabel ?? ""}
|
|
||||||
onChange={(e) =>
|
|
||||||
updateBlock(selectedBlockId, {
|
|
||||||
groupLabel: e.target.value,
|
|
||||||
} as any)
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
|
|
||||||
{radioProps.groupLabelMode === "image" && (() => {
|
|
||||||
const source: "url" | "upload" =
|
|
||||||
radioProps.groupLabelImageSource ??
|
|
||||||
(radioProps.groupLabelImageUrl && radioProps.groupLabelImageUrl.startsWith("/api/image/")
|
|
||||||
? "upload"
|
|
||||||
: "url");
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-1">
|
|
||||||
<label className="flex flex-col gap-1">
|
|
||||||
<span className="text-slate-400">그룹 타이틀 이미지 소스</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={source}
|
|
||||||
onChange={(e) =>
|
|
||||||
updateBlock(selectedBlockId, {
|
|
||||||
groupLabelImageSource: e.target.value,
|
|
||||||
} as any)
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<option value="url">URL</option>
|
|
||||||
<option value="upload">파일 업로드</option>
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
|
|
||||||
{source === "url" && (
|
|
||||||
<label className="flex flex-col gap-1">
|
|
||||||
<span className="text-slate-400">그룹 타이틀 이미지 URL</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={radioProps.groupLabelImageUrl ?? ""}
|
|
||||||
onChange={(e) =>
|
|
||||||
updateBlock(selectedBlockId, {
|
|
||||||
groupLabelImageUrl: e.target.value,
|
|
||||||
} as any)
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{source === "upload" && (
|
|
||||||
<label className="flex flex-col gap-1">
|
|
||||||
<span className="text-slate-400">그룹 타이틀 이미지 파일 업로드</span>
|
|
||||||
<input
|
|
||||||
type="file"
|
|
||||||
accept="image/*"
|
|
||||||
className="w-full text-[11px] text-slate-300 file:mr-2 file:rounded file:border file:border-slate-700 file:bg-slate-800 file:px-2 file:py-1 file:text-[11px] file:text-slate-100 hover:file:bg-slate-700"
|
|
||||||
aria-label="그룹 타이틀 이미지 파일 업로드"
|
|
||||||
onChange={async (event) => {
|
|
||||||
const file = event.target.files?.[0];
|
|
||||||
if (!file) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const formData = new FormData();
|
|
||||||
formData.append("file", file);
|
|
||||||
|
|
||||||
const response = await fetch("/api/image", {
|
|
||||||
method: "POST",
|
|
||||||
body: formData,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
console.error("라디오 그룹 라벨 이미지 업로드 실패", await response.text());
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = (await response.json()) as { id: string; servedUrl?: string | null };
|
|
||||||
const servedUrl = data.servedUrl ?? `/api/image/${data.id}`;
|
|
||||||
|
|
||||||
updateBlock(selectedBlockId, {
|
|
||||||
groupLabelImageUrl: servedUrl,
|
|
||||||
groupLabelImageSource: "upload",
|
|
||||||
} as any);
|
|
||||||
} catch (error) {
|
|
||||||
console.error("라디오 그룹 라벨 이미지 업로드 중 오류", error);
|
|
||||||
} finally {
|
|
||||||
event.target.value = "";
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
)}
|
|
||||||
</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={radioProps.formFieldName ?? ""}
|
|
||||||
onChange={(e) =>
|
|
||||||
updateBlock(selectedBlockId, {
|
|
||||||
formFieldName: e.target.value,
|
|
||||||
} as any)
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<span className="text-slate-400">옵션</span>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="rounded border border-slate-700 bg-slate-900 px-2 py-0.5 text-[10px] text-slate-200 hover:bg-slate-800"
|
|
||||||
onClick={() => {
|
|
||||||
const next = Array.isArray((radioProps as any).options)
|
|
||||||
? [...(radioProps as any).options]
|
|
||||||
: [];
|
|
||||||
next.push({
|
|
||||||
label: "새 옵션",
|
|
||||||
value: `option_${next.length + 1}`,
|
|
||||||
});
|
|
||||||
updateBlock(selectedBlockId, {
|
|
||||||
options: next,
|
|
||||||
} as any);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
옵션 추가
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<label className="flex flex-col gap-1">
|
|
||||||
<span className="text-slate-400">옵션 이미지 소스</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={(() => {
|
|
||||||
if (radioProps.optionImageSource) return radioProps.optionImageSource;
|
|
||||||
const first = (((radioProps as any).options ?? []) as any[])[0];
|
|
||||||
if (first?.labelImageUrl && first.labelImageUrl.startsWith("/api/image/")) return "upload";
|
|
||||||
return "url";
|
|
||||||
})()}
|
|
||||||
onChange={(e) =>
|
|
||||||
updateBlock(selectedBlockId, {
|
|
||||||
optionImageSource: e.target.value,
|
|
||||||
} as any)
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<option value="url">URL</option>
|
|
||||||
<option value="upload">파일 업로드</option>
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
|
|
||||||
{(((radioProps as any).options ?? []) as any[]).map((opt, index) => (
|
|
||||||
<div key={opt.value ?? index} className="space-y-1 rounded border border-slate-800 p-2">
|
|
||||||
<div className="flex items-center gap-1">
|
|
||||||
<input
|
|
||||||
className="flex-1 rounded border border-slate-700 bg-slate-950 px-2 py-1 text-[11px] outline-none focus:border-sky-500"
|
|
||||||
placeholder="라벨"
|
|
||||||
value={opt.label ?? ""}
|
|
||||||
onChange={(e) => {
|
|
||||||
const next = [...(((radioProps as any).options ?? []) as any[])];
|
|
||||||
next[index] = {
|
|
||||||
...next[index],
|
|
||||||
label: e.target.value,
|
|
||||||
};
|
|
||||||
updateBlock(selectedBlockId, {
|
|
||||||
options: next,
|
|
||||||
} as any);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<input
|
|
||||||
className="flex-1 rounded border border-slate-700 bg-slate-950 px-2 py-1 text-[11px] outline-none focus:border-sky-500"
|
|
||||||
placeholder="값(value)"
|
|
||||||
value={opt.value ?? ""}
|
|
||||||
onChange={(e) => {
|
|
||||||
const next = [...(((radioProps as any).options ?? []) as any[])];
|
|
||||||
next[index] = {
|
|
||||||
...next[index],
|
|
||||||
value: e.target.value,
|
|
||||||
};
|
|
||||||
updateBlock(selectedBlockId, {
|
|
||||||
options: next,
|
|
||||||
} as any);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="h-7 w-7 rounded border border-slate-700 bg-slate-950 text-[10px] text-slate-300 hover:bg-red-900/60 hover:border-red-700"
|
|
||||||
onClick={() => {
|
|
||||||
const next = (((radioProps as any).options ?? []) as any[]).filter((_, i) => i !== index);
|
|
||||||
updateBlock(selectedBlockId, {
|
|
||||||
options: next,
|
|
||||||
} as any);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
×
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col gap-1">
|
|
||||||
{(() => {
|
|
||||||
const source: "url" | "upload" =
|
|
||||||
radioProps.optionImageSource ??
|
|
||||||
(opt.labelImageUrl && opt.labelImageUrl.startsWith("/api/image/") ? "upload" : "url");
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{source === "url" && (
|
|
||||||
<input
|
|
||||||
className="w-full rounded border border-slate-700 bg-slate-950 px-2 py-1 text-[11px] outline-none focus:border-sky-500"
|
|
||||||
placeholder="옵션 이미지 URL (선택)"
|
|
||||||
value={opt.labelImageUrl ?? ""}
|
|
||||||
onChange={(e) => {
|
|
||||||
const next = [...(((radioProps as any).options ?? []) as any[])];
|
|
||||||
next[index] = {
|
|
||||||
...next[index],
|
|
||||||
labelImageUrl: e.target.value,
|
|
||||||
};
|
|
||||||
updateBlock(selectedBlockId, {
|
|
||||||
options: next,
|
|
||||||
} as any);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{source === "upload" && (
|
|
||||||
<label className="flex flex-col gap-1">
|
|
||||||
<span className="text-slate-400">옵션 이미지 파일 업로드</span>
|
|
||||||
<input
|
|
||||||
type="file"
|
|
||||||
accept="image/*"
|
|
||||||
className="w-full text-[11px] text-slate-300 file:mr-2 file:rounded file:border file:border-slate-700 file:bg-slate-800 file:px-2 file:py-1 file:text-[11px] file:text-slate-100 hover:file:bg-slate-700"
|
|
||||||
aria-label="옵션 이미지 파일 업로드"
|
|
||||||
onChange={async (event) => {
|
|
||||||
const file = event.target.files?.[0];
|
|
||||||
if (!file) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const formData = new FormData();
|
|
||||||
formData.append("file", file);
|
|
||||||
|
|
||||||
const response = await fetch("/api/image", {
|
|
||||||
method: "POST",
|
|
||||||
body: formData,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
console.error("라디오 옵션 라벨 이미지 업로드 실패", await response.text());
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = (await response.json()) as { id: string; servedUrl?: string | null };
|
|
||||||
const servedUrl = data.servedUrl ?? `/api/image/${data.id}`;
|
|
||||||
|
|
||||||
const next = [...(((radioProps as any).options ?? []) as any[])];
|
|
||||||
next[index] = {
|
|
||||||
...next[index],
|
|
||||||
labelImageUrl: servedUrl,
|
|
||||||
};
|
|
||||||
|
|
||||||
updateBlock(selectedBlockId, {
|
|
||||||
options: next,
|
|
||||||
optionImageSource: "upload",
|
|
||||||
} as any);
|
|
||||||
} catch (error) {
|
|
||||||
console.error("라디오 옵션 라벨 이미지 업로드 중 오류", error);
|
|
||||||
} finally {
|
|
||||||
event.target.value = "";
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
})()}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<label className="inline-flex items-center gap-2">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
className="h-3 w-3 rounded border border-slate-600 bg-slate-900"
|
|
||||||
checked={Boolean(radioProps.required)}
|
|
||||||
onChange={(e) =>
|
|
||||||
updateBlock(selectedBlockId, {
|
|
||||||
required: e.target.checked,
|
|
||||||
} as any)
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<span className="text-slate-400">필수 필드</span>
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<div className="mt-3 space-y-2 border-t border-slate-800 pt-3">
|
|
||||||
<h4 className="text-[11px] font-semibold text-slate-200">필드 스타일</h4>
|
|
||||||
{/* 라디오 타이포 px 값을 직접 입력받아 em 스케일 변환 로직과 연동한다 */}
|
|
||||||
<NumericPropertyControl
|
|
||||||
label="라디오 텍스트 크기 (px)"
|
|
||||||
unitLabel="(px)"
|
|
||||||
value={(() => {
|
|
||||||
const raw = radioProps.fontSizeCustom ?? "";
|
|
||||||
const match = raw.match(/([0-9]+(?:\.[0-9]+)?)/);
|
|
||||||
if (match) return Number(match[1]);
|
|
||||||
return 14;
|
|
||||||
})()}
|
|
||||||
min={8}
|
|
||||||
max={40}
|
|
||||||
step={1}
|
|
||||||
presets={[
|
|
||||||
{ id: "xs", label: "XS", value: 12 },
|
|
||||||
{ id: "sm", label: "S", value: 14 },
|
|
||||||
{ id: "md", label: "M", value: 16 },
|
|
||||||
{ id: "lg", label: "L", value: 18 },
|
|
||||||
{ id: "xl", label: "XL", value: 20 },
|
|
||||||
]}
|
|
||||||
onChangeValue={(v) => {
|
|
||||||
updateBlock(selectedBlockId, {
|
|
||||||
fontSizeCustom: `${v}px`,
|
|
||||||
} as any);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
{/* 줄간격 px 입력을 노출하여 preview 에서 em 변환 근거로 사용 */}
|
|
||||||
<NumericPropertyControl
|
|
||||||
label="라디오 줄간격 (px)"
|
|
||||||
unitLabel="(px)"
|
|
||||||
value={(() => {
|
|
||||||
const raw = radioProps.lineHeightCustom ?? "";
|
|
||||||
const match = raw.match(/([0-9]+(?:\.[0-9]+)?)/);
|
|
||||||
if (match) return Number(match[1]);
|
|
||||||
return 20;
|
|
||||||
})()}
|
|
||||||
min={8}
|
|
||||||
max={60}
|
|
||||||
step={1}
|
|
||||||
presets={[
|
|
||||||
{ id: "tight", label: "타이트", value: 18 },
|
|
||||||
{ id: "normal", label: "보통", value: 24 },
|
|
||||||
{ id: "loose", label: "루즈", value: 32 },
|
|
||||||
]}
|
|
||||||
onChangeValue={(v) => {
|
|
||||||
updateBlock(selectedBlockId, {
|
|
||||||
lineHeightCustom: `${v}px`,
|
|
||||||
} as any);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
{/* 자간 px 입력 컨트롤로 프리뷰 적용 근거 제공 */}
|
|
||||||
<NumericPropertyControl
|
|
||||||
label="라디오 자간 (px)"
|
|
||||||
unitLabel="(px)"
|
|
||||||
value={(() => {
|
|
||||||
const raw = radioProps.letterSpacingCustom ?? "";
|
|
||||||
const match = raw.match(/(-?[0-9]+(?:\.[0-9]+)?)/);
|
|
||||||
if (match) return Number(match[1]);
|
|
||||||
return 0;
|
|
||||||
})()}
|
|
||||||
min={-10}
|
|
||||||
max={20}
|
|
||||||
step={0.5}
|
|
||||||
presets={[
|
|
||||||
{ id: "tight", label: "좁게", value: -1 },
|
|
||||||
{ id: "normal", label: "보통", value: 0 },
|
|
||||||
{ id: "wide", label: "넓게", value: 2 },
|
|
||||||
]}
|
|
||||||
onChangeValue={(v) => {
|
|
||||||
updateBlock(selectedBlockId, {
|
|
||||||
letterSpacingCustom: `${v}px`,
|
|
||||||
} as any);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<label className="flex flex-col gap-1 text-[11px] 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={radioProps.widthMode ?? "full"}
|
|
||||||
onChange={(e) =>
|
|
||||||
updateBlock(selectedBlockId, {
|
|
||||||
widthMode: e.target.value,
|
|
||||||
} as any)
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<option value="auto">자동</option>
|
|
||||||
<option value="full">전체 폭</option>
|
|
||||||
<option value="fixed">고정 값</option>
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
{(radioProps.widthMode ?? "full") === "fixed" && (
|
|
||||||
<NumericPropertyControl
|
|
||||||
label="필드 고정 너비"
|
|
||||||
unitLabel="(px)"
|
|
||||||
value={radioProps.widthPx ?? 240}
|
|
||||||
min={80}
|
|
||||||
max={800}
|
|
||||||
step={10}
|
|
||||||
presets={[
|
|
||||||
{ id: "sm", label: "작게", value: 200 },
|
|
||||||
{ id: "md", label: "보통", value: 280 },
|
|
||||||
{ id: "lg", label: "넓게", value: 360 },
|
|
||||||
]}
|
|
||||||
onChangeValue={(v) => {
|
|
||||||
updateBlock(selectedBlockId, {
|
|
||||||
widthPx: v,
|
|
||||||
} as any);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<NumericPropertyControl
|
|
||||||
label="라디오 가로 패딩 (px)"
|
|
||||||
unitLabel="(px)"
|
|
||||||
value={typeof radioProps.paddingX === "number" ? radioProps.paddingX : 12}
|
|
||||||
min={0}
|
|
||||||
max={60}
|
|
||||||
step={2}
|
|
||||||
presets={[
|
|
||||||
{ id: "xs", label: "아주 얇게", value: 6 },
|
|
||||||
{ id: "sm", label: "얇게", value: 10 },
|
|
||||||
{ id: "md", label: "보통", value: 12 },
|
|
||||||
{ id: "lg", label: "넓게", value: 16 },
|
|
||||||
{ id: "xl", label: "아주 넓게", value: 20 },
|
|
||||||
]}
|
|
||||||
onChangeValue={(v) =>
|
|
||||||
updateBlock(selectedBlockId, {
|
|
||||||
paddingX: v,
|
|
||||||
} as any)
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<NumericPropertyControl
|
|
||||||
label="라디오 세로 패딩 (px)"
|
|
||||||
unitLabel="(px)"
|
|
||||||
value={typeof radioProps.paddingY === "number" ? radioProps.paddingY : 10}
|
|
||||||
min={0}
|
|
||||||
max={40}
|
|
||||||
step={1}
|
|
||||||
presets={[
|
|
||||||
{ id: "xs", label: "아주 얇게", value: 6 },
|
|
||||||
{ id: "sm", label: "얇게", value: 8 },
|
|
||||||
{ id: "md", label: "보통", value: 10 },
|
|
||||||
{ id: "lg", label: "넓게", value: 12 },
|
|
||||||
{ id: "xl", label: "아주 넓게", value: 16 },
|
|
||||||
]}
|
|
||||||
onChangeValue={(v) =>
|
|
||||||
updateBlock(selectedBlockId, {
|
|
||||||
paddingY: v,
|
|
||||||
} as any)
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<NumericPropertyControl
|
|
||||||
label="라디오 옵션 간격 (px)"
|
|
||||||
unitLabel="(px)"
|
|
||||||
value={typeof radioProps.optionGapPx === "number" ? radioProps.optionGapPx : 4}
|
|
||||||
min={0}
|
|
||||||
max={40}
|
|
||||||
step={1}
|
|
||||||
presets={[
|
|
||||||
{ id: "tight", label: "타이트", value: 2 },
|
|
||||||
{ id: "normal", label: "보통", value: 4 },
|
|
||||||
{ id: "relaxed", label: "느슨", value: 8 },
|
|
||||||
{ id: "extra", label: "넓게", value: 12 },
|
|
||||||
]}
|
|
||||||
onChangeValue={(v) =>
|
|
||||||
updateBlock(selectedBlockId, {
|
|
||||||
optionGapPx: v,
|
|
||||||
} as any)
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<ColorPickerField
|
|
||||||
label="텍스트 색상"
|
|
||||||
ariaLabelColorInput="라디오 텍스트 색상 피커"
|
|
||||||
ariaLabelHexInput="라디오 텍스트 색상 HEX"
|
|
||||||
value={
|
|
||||||
radioProps.textColorCustom && radioProps.textColorCustom.trim() !== ""
|
|
||||||
? radioProps.textColorCustom
|
|
||||||
: "#f9fafb"
|
|
||||||
}
|
|
||||||
onChange={(hex) => {
|
|
||||||
updateBlock(selectedBlockId, {
|
|
||||||
textColorCustom: hex,
|
|
||||||
} as any);
|
|
||||||
}}
|
|
||||||
palette={TEXT_COLOR_PALETTE}
|
|
||||||
onPaletteSelect={(item) => {
|
|
||||||
updateBlock(selectedBlockId, {
|
|
||||||
textColorCustom: item.color,
|
|
||||||
} as any);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<ColorPickerField
|
|
||||||
label="배경 색상"
|
|
||||||
ariaLabelColorInput="라디오 배경 색상 피커"
|
|
||||||
ariaLabelHexInput="라디오 배경 색상 HEX"
|
|
||||||
value={
|
|
||||||
radioProps.fillColorCustom && radioProps.fillColorCustom.trim() !== ""
|
|
||||||
? radioProps.fillColorCustom
|
|
||||||
: TEXT_COLOR_PALETTE[0]?.color ?? "#0ea5e9"
|
|
||||||
}
|
|
||||||
onChange={(hex) => {
|
|
||||||
updateBlock(selectedBlockId, {
|
|
||||||
fillColorCustom: hex,
|
|
||||||
} as any);
|
|
||||||
}}
|
|
||||||
palette={TEXT_COLOR_PALETTE}
|
|
||||||
onPaletteSelect={(item) => {
|
|
||||||
updateBlock(selectedBlockId, {
|
|
||||||
fillColorCustom: item.color,
|
|
||||||
} as any);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<ColorPickerField
|
|
||||||
label="테두리 색상"
|
|
||||||
ariaLabelColorInput="라디오 테두리 색상 피커"
|
|
||||||
ariaLabelHexInput="라디오 테두리 색상 HEX"
|
|
||||||
value={
|
|
||||||
radioProps.strokeColorCustom && radioProps.strokeColorCustom.trim() !== ""
|
|
||||||
? radioProps.strokeColorCustom
|
|
||||||
: TEXT_COLOR_PALETTE[0]?.color ?? "#0ea5e9"
|
|
||||||
}
|
|
||||||
onChange={(hex) => {
|
|
||||||
updateBlock(selectedBlockId, {
|
|
||||||
strokeColorCustom: hex,
|
|
||||||
} as any);
|
|
||||||
}}
|
|
||||||
palette={TEXT_COLOR_PALETTE}
|
|
||||||
onPaletteSelect={(item) => {
|
|
||||||
updateBlock(selectedBlockId, {
|
|
||||||
strokeColorCustom: item.color,
|
|
||||||
} as any);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<NumericPropertyControl
|
|
||||||
label="필드 모서리 둥글기"
|
|
||||||
value={(() => {
|
|
||||||
const r = radioProps.borderRadius ?? "md";
|
|
||||||
return r === "none" ? 0 : r === "sm" ? 2 : r === "lg" ? 6 : r === "full" ? 8 : 4;
|
|
||||||
})()}
|
|
||||||
min={0}
|
|
||||||
max={8}
|
|
||||||
step={1}
|
|
||||||
presets={[
|
|
||||||
{ id: "none", label: "없음", value: 0 },
|
|
||||||
{ id: "sm", label: "작게", value: 2 },
|
|
||||||
{ id: "md", label: "보통", value: 4 },
|
|
||||||
{ id: "lg", label: "크게", value: 6 },
|
|
||||||
{ id: "full", label: "완전 둥글게", value: 8 },
|
|
||||||
]}
|
|
||||||
onChangeValue={(v) => {
|
|
||||||
const next =
|
|
||||||
v <= 0 ? "none" : v <= 2 ? "sm" : v <= 5 ? "md" : v <= 7 ? "lg" : "full";
|
|
||||||
updateBlock(selectedBlockId, {
|
|
||||||
borderRadius: next,
|
|
||||||
} as any);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,392 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import type { Block, FormSelectBlockProps } from "@/features/editor/state/editorStore";
|
|
||||||
import { ColorPickerField, TEXT_COLOR_PALETTE } from "@/features/editor/components/ColorPickerField";
|
|
||||||
import { NumericPropertyControl } from "@/features/editor/components/NumericPropertyControl";
|
|
||||||
|
|
||||||
interface FormSelectPropertiesPanelProps {
|
|
||||||
block: Block;
|
|
||||||
selectedBlockId: string | null;
|
|
||||||
updateBlock: (id: string, partial: any) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function FormSelectPropertiesPanel({ block, selectedBlockId, updateBlock }: FormSelectPropertiesPanelProps) {
|
|
||||||
if (!selectedBlockId || block.id !== selectedBlockId) return null;
|
|
||||||
|
|
||||||
const selectProps = block.props as FormSelectBlockProps;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-3 text-xs">
|
|
||||||
<h3 className="text-[11px] font-semibold text-slate-200">셀렉트 필드</h3>
|
|
||||||
|
|
||||||
<label className="flex flex-col gap-1">
|
|
||||||
<span className="text-slate-400">라벨 타입</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={selectProps.labelMode ?? "text"}
|
|
||||||
onChange={(e) =>
|
|
||||||
updateBlock(selectedBlockId, {
|
|
||||||
labelMode: e.target.value,
|
|
||||||
} as any)
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<option value="text">텍스트</option>
|
|
||||||
<option value="image">이미지</option>
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<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={selectProps.label ?? ""}
|
|
||||||
onChange={(e) =>
|
|
||||||
updateBlock(selectedBlockId, {
|
|
||||||
label: e.target.value,
|
|
||||||
} as any)
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<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={selectProps.formFieldName ?? ""}
|
|
||||||
onChange={(e) =>
|
|
||||||
updateBlock(selectedBlockId, {
|
|
||||||
formFieldName: e.target.value,
|
|
||||||
} as any)
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<span className="text-slate-400">옵션</span>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="rounded border border-slate-700 bg-slate-900 px-2 py-0.5 text-[10px] text-slate-200 hover:bg-slate-800"
|
|
||||||
onClick={() => {
|
|
||||||
const next = Array.isArray((selectProps as any).options)
|
|
||||||
? [...(selectProps as any).options]
|
|
||||||
: [];
|
|
||||||
next.push({
|
|
||||||
label: "새 옵션",
|
|
||||||
value: `option_${next.length + 1}`,
|
|
||||||
});
|
|
||||||
updateBlock(selectedBlockId, {
|
|
||||||
options: next,
|
|
||||||
} as any);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
옵션 추가
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2">
|
|
||||||
{(((selectProps as any).options ?? []) as any[]).map((opt, index) => (
|
|
||||||
<div key={opt.value ?? index} className="space-y-1 rounded border border-slate-800 p-2">
|
|
||||||
<div className="flex items-center gap-1">
|
|
||||||
<input
|
|
||||||
className="flex-1 rounded border border-slate-700 bg-slate-950 px-2 py-1 text-[11px] outline-none focus:border-sky-500"
|
|
||||||
placeholder="라벨"
|
|
||||||
value={opt.label ?? ""}
|
|
||||||
onChange={(e) => {
|
|
||||||
const next = [...(((selectProps as any).options ?? []) as any[])];
|
|
||||||
next[index] = {
|
|
||||||
...next[index],
|
|
||||||
label: e.target.value,
|
|
||||||
};
|
|
||||||
updateBlock(selectedBlockId, {
|
|
||||||
options: next,
|
|
||||||
} as any);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<input
|
|
||||||
className="flex-1 rounded border border-slate-700 bg-slate-950 px-2 py-1 text-[11px] outline-none focus:border-sky-500"
|
|
||||||
placeholder="값(value)"
|
|
||||||
value={opt.value ?? ""}
|
|
||||||
onChange={(e) => {
|
|
||||||
const next = [...(((selectProps as any).options ?? []) as any[])];
|
|
||||||
next[index] = {
|
|
||||||
...next[index],
|
|
||||||
value: e.target.value,
|
|
||||||
};
|
|
||||||
updateBlock(selectedBlockId, {
|
|
||||||
options: next,
|
|
||||||
} as any);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="h-7 w-7 rounded border border-slate-700 bg-slate-950 text-[10px] text-slate-300 hover:bg-red-900/60 hover:border-red-700"
|
|
||||||
onClick={() => {
|
|
||||||
const next = (((selectProps as any).options ?? []) as any[]).filter((_, i) => i !== index);
|
|
||||||
updateBlock(selectedBlockId, {
|
|
||||||
options: next,
|
|
||||||
} as any);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
×
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<label className="inline-flex items-center gap-2">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
className="h-3 w-3 rounded border border-slate-600 bg-slate-900"
|
|
||||||
checked={Boolean(selectProps.required)}
|
|
||||||
onChange={(e) =>
|
|
||||||
updateBlock(selectedBlockId, {
|
|
||||||
required: e.target.checked,
|
|
||||||
} as any)
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<span className="text-slate-400">필수 필드</span>
|
|
||||||
</label>
|
|
||||||
<div className="mt-3 space-y-2 border-t border-slate-800 pt-3">
|
|
||||||
<h4 className="text-[11px] font-semibold text-slate-200">필드 스타일</h4>
|
|
||||||
<NumericPropertyControl
|
|
||||||
label="셀렉트 텍스트 크기 (px)"
|
|
||||||
unitLabel="(px)"
|
|
||||||
value={(() => {
|
|
||||||
const raw = selectProps.fontSizeCustom ?? "";
|
|
||||||
const match = raw.match(/([0-9]+(?:\.[0-9]+)?)/);
|
|
||||||
if (match) return Number(match[1]);
|
|
||||||
return 14;
|
|
||||||
})()}
|
|
||||||
min={8}
|
|
||||||
max={40}
|
|
||||||
step={1}
|
|
||||||
presets={[
|
|
||||||
{ id: "xs", label: "XS", value: 12 },
|
|
||||||
{ id: "sm", label: "S", value: 14 },
|
|
||||||
{ id: "md", label: "M", value: 16 },
|
|
||||||
{ id: "lg", label: "L", value: 18 },
|
|
||||||
{ id: "xl", label: "XL", value: 20 },
|
|
||||||
]}
|
|
||||||
onChangeValue={(v) => {
|
|
||||||
updateBlock(selectedBlockId, {
|
|
||||||
fontSizeCustom: `${v}px`,
|
|
||||||
} as any);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
{/* 줄간격 px 입력을 받아 preview 렌더러의 em 변환 근거로 사용한다 */}
|
|
||||||
<NumericPropertyControl
|
|
||||||
label="셀렉트 줄간격 (px)"
|
|
||||||
unitLabel="(px)"
|
|
||||||
value={(() => {
|
|
||||||
const raw = selectProps.lineHeightCustom ?? "";
|
|
||||||
const match = raw.match(/([0-9]+(?:\.[0-9]+)?)/);
|
|
||||||
if (match) return Number(match[1]);
|
|
||||||
return 20;
|
|
||||||
})()}
|
|
||||||
min={8}
|
|
||||||
max={60}
|
|
||||||
step={1}
|
|
||||||
presets={[
|
|
||||||
{ id: "tight", label: "타이트", value: 18 },
|
|
||||||
{ id: "normal", label: "보통", value: 24 },
|
|
||||||
{ id: "loose", label: "루즈", value: 32 },
|
|
||||||
]}
|
|
||||||
onChangeValue={(v) => {
|
|
||||||
updateBlock(selectedBlockId, {
|
|
||||||
lineHeightCustom: `${v}px`,
|
|
||||||
} as any);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
{/* 자간 px 입력을 제공하여 TDD 시나리오와 동기화한다 */}
|
|
||||||
<NumericPropertyControl
|
|
||||||
label="셀렉트 자간 (px)"
|
|
||||||
unitLabel="(px)"
|
|
||||||
value={(() => {
|
|
||||||
const raw = selectProps.letterSpacingCustom ?? "";
|
|
||||||
const match = raw.match(/(-?[0-9]+(?:\.[0-9]+)?)/);
|
|
||||||
if (match) return Number(match[1]);
|
|
||||||
return 0;
|
|
||||||
})()}
|
|
||||||
min={-10}
|
|
||||||
max={20}
|
|
||||||
step={0.5}
|
|
||||||
presets={[
|
|
||||||
{ id: "tight", label: "좁게", value: -1 },
|
|
||||||
{ id: "normal", label: "보통", value: 0 },
|
|
||||||
{ id: "wide", label: "넓게", value: 2 },
|
|
||||||
]}
|
|
||||||
onChangeValue={(v) => {
|
|
||||||
updateBlock(selectedBlockId, {
|
|
||||||
letterSpacingCustom: `${v}px`,
|
|
||||||
} as any);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<label className="flex flex-col gap-1 text-[11px] 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={selectProps.widthMode ?? "full"}
|
|
||||||
onChange={(e) =>
|
|
||||||
updateBlock(selectedBlockId, {
|
|
||||||
widthMode: e.target.value,
|
|
||||||
} as any)
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<option value="auto">자동</option>
|
|
||||||
<option value="full">전체 폭</option>
|
|
||||||
<option value="fixed">고정 값</option>
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
{(selectProps.widthMode ?? "full") === "fixed" && (
|
|
||||||
<NumericPropertyControl
|
|
||||||
label="필드 고정 너비"
|
|
||||||
unitLabel="(px)"
|
|
||||||
value={selectProps.widthPx ?? 240}
|
|
||||||
min={80}
|
|
||||||
max={800}
|
|
||||||
step={10}
|
|
||||||
presets={[
|
|
||||||
{ id: "sm", label: "작게", value: 200 },
|
|
||||||
{ id: "md", label: "보통", value: 280 },
|
|
||||||
{ id: "lg", label: "넓게", value: 360 },
|
|
||||||
]}
|
|
||||||
onChangeValue={(v) => {
|
|
||||||
updateBlock(selectedBlockId, {
|
|
||||||
widthPx: v,
|
|
||||||
} as any);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<NumericPropertyControl
|
|
||||||
label="셀렉트 가로 패딩 (px)"
|
|
||||||
unitLabel="(px)"
|
|
||||||
value={typeof selectProps.paddingX === "number" ? selectProps.paddingX : 12}
|
|
||||||
min={0}
|
|
||||||
max={60}
|
|
||||||
step={2}
|
|
||||||
presets={[
|
|
||||||
{ id: "xs", label: "아주 얇게", value: 6 },
|
|
||||||
{ id: "sm", label: "얇게", value: 10 },
|
|
||||||
{ id: "md", label: "보통", value: 12 },
|
|
||||||
{ id: "lg", label: "넓게", value: 16 },
|
|
||||||
{ id: "xl", label: "아주 넓게", value: 20 },
|
|
||||||
]}
|
|
||||||
onChangeValue={(v) =>
|
|
||||||
updateBlock(selectedBlockId, {
|
|
||||||
paddingX: v,
|
|
||||||
} as any)
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<NumericPropertyControl
|
|
||||||
label="셀렉트 세로 패딩 (px)"
|
|
||||||
unitLabel="(px)"
|
|
||||||
value={typeof selectProps.paddingY === "number" ? selectProps.paddingY : 10}
|
|
||||||
min={0}
|
|
||||||
max={40}
|
|
||||||
step={1}
|
|
||||||
presets={[
|
|
||||||
{ id: "xs", label: "아주 얇게", value: 6 },
|
|
||||||
{ id: "sm", label: "얇게", value: 8 },
|
|
||||||
{ id: "md", label: "보통", value: 10 },
|
|
||||||
{ id: "lg", label: "넓게", value: 12 },
|
|
||||||
{ id: "xl", label: "아주 넓게", value: 16 },
|
|
||||||
]}
|
|
||||||
onChangeValue={(v) =>
|
|
||||||
updateBlock(selectedBlockId, {
|
|
||||||
paddingY: v,
|
|
||||||
} as any)
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<ColorPickerField
|
|
||||||
label="필드 텍스트 색상"
|
|
||||||
ariaLabelColorInput="셀렉트 텍스트 색상 피커"
|
|
||||||
ariaLabelHexInput="셀렉트 텍스트 색상 HEX"
|
|
||||||
value={
|
|
||||||
selectProps.textColorCustom && selectProps.textColorCustom.trim() !== ""
|
|
||||||
? selectProps.textColorCustom
|
|
||||||
: "#f9fafb"
|
|
||||||
}
|
|
||||||
onChange={(hex) => {
|
|
||||||
updateBlock(selectedBlockId, {
|
|
||||||
textColorCustom: hex,
|
|
||||||
} as any);
|
|
||||||
}}
|
|
||||||
palette={TEXT_COLOR_PALETTE}
|
|
||||||
onPaletteSelect={(item) => {
|
|
||||||
updateBlock(selectedBlockId, {
|
|
||||||
textColorCustom: item.color,
|
|
||||||
} as any);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<ColorPickerField
|
|
||||||
label="필드 채움 색상"
|
|
||||||
ariaLabelColorInput="셀렉트 채움 색상 피커"
|
|
||||||
ariaLabelHexInput="셀렉트 채움 색상 HEX"
|
|
||||||
value={
|
|
||||||
selectProps.fillColorCustom && selectProps.fillColorCustom.trim() !== ""
|
|
||||||
? selectProps.fillColorCustom
|
|
||||||
: TEXT_COLOR_PALETTE[0]?.color ?? "#0ea5e9"
|
|
||||||
}
|
|
||||||
onChange={(hex) => {
|
|
||||||
updateBlock(selectedBlockId, {
|
|
||||||
fillColorCustom: hex,
|
|
||||||
} as any);
|
|
||||||
}}
|
|
||||||
palette={TEXT_COLOR_PALETTE}
|
|
||||||
onPaletteSelect={(item) => {
|
|
||||||
updateBlock(selectedBlockId, {
|
|
||||||
fillColorCustom: item.color,
|
|
||||||
} as any);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<ColorPickerField
|
|
||||||
label="필드 테두리 색상"
|
|
||||||
ariaLabelColorInput="셀렉트 테두리 색상 피커"
|
|
||||||
ariaLabelHexInput="셀렉트 테두리 색상 HEX"
|
|
||||||
value={
|
|
||||||
selectProps.strokeColorCustom && selectProps.strokeColorCustom.trim() !== ""
|
|
||||||
? selectProps.strokeColorCustom
|
|
||||||
: TEXT_COLOR_PALETTE[0]?.color ?? "#0ea5e9"
|
|
||||||
}
|
|
||||||
onChange={(hex) => {
|
|
||||||
updateBlock(selectedBlockId, {
|
|
||||||
strokeColorCustom: hex,
|
|
||||||
} as any);
|
|
||||||
}}
|
|
||||||
palette={TEXT_COLOR_PALETTE}
|
|
||||||
onPaletteSelect={(item) => {
|
|
||||||
updateBlock(selectedBlockId, {
|
|
||||||
strokeColorCustom: item.color,
|
|
||||||
} as any);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<NumericPropertyControl
|
|
||||||
label="필드 모서리 둥글기"
|
|
||||||
value={(() => {
|
|
||||||
const r = selectProps.borderRadius ?? "md";
|
|
||||||
return r === "none" ? 0 : r === "sm" ? 2 : r === "lg" ? 6 : r === "full" ? 8 : 4;
|
|
||||||
})()}
|
|
||||||
min={0}
|
|
||||||
max={8}
|
|
||||||
step={1}
|
|
||||||
presets={[
|
|
||||||
{ id: "none", label: "없음", value: 0 },
|
|
||||||
{ id: "sm", label: "작게", value: 2 },
|
|
||||||
{ id: "md", label: "보통", value: 4 },
|
|
||||||
{ id: "lg", label: "크게", value: 6 },
|
|
||||||
{ id: "full", label: "완전 둥글게", value: 8 },
|
|
||||||
]}
|
|
||||||
onChangeValue={(v) => {
|
|
||||||
const next =
|
|
||||||
v <= 0 ? "none" : v <= 2 ? "sm" : v <= 5 ? "md" : v <= 7 ? "lg" : "full";
|
|
||||||
updateBlock(selectedBlockId, {
|
|
||||||
borderRadius: next,
|
|
||||||
} as any);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
+437
-1855
File diff suppressed because it is too large
Load Diff
@@ -1,550 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
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() {
|
|
||||||
const addTextBlock = useEditorStore((state) => state.addTextBlock);
|
|
||||||
const addButtonBlock = useEditorStore((state) => state.addButtonBlock);
|
|
||||||
const addImageBlock = useEditorStore((state) => state.addImageBlock);
|
|
||||||
const addVideoBlock = useEditorStore((state) => (state as any).addVideoBlock);
|
|
||||||
const addDividerBlock = useEditorStore((state) => state.addDividerBlock);
|
|
||||||
const addListBlock = useEditorStore((state) => state.addListBlock);
|
|
||||||
const addSectionBlock = useEditorStore((state) => state.addSectionBlock);
|
|
||||||
|
|
||||||
const addFormBlock = useEditorStore((state) => state.addFormBlock);
|
|
||||||
const addFormInputBlock = useEditorStore((state) => (state as any).addFormInputBlock);
|
|
||||||
const addFormSelectBlock = useEditorStore((state) => (state as any).addFormSelectBlock);
|
|
||||||
const addFormCheckboxBlock = useEditorStore((state) => (state as any).addFormCheckboxBlock);
|
|
||||||
const addFormRadioBlock = useEditorStore((state) => (state as any).addFormRadioBlock);
|
|
||||||
|
|
||||||
const addHeroTemplateSection = useEditorStore((state) => state.addHeroTemplateSection);
|
|
||||||
const addFeaturesTemplateSection = useEditorStore((state) => state.addFeaturesTemplateSection);
|
|
||||||
const addCtaTemplateSection = useEditorStore((state) => state.addCtaTemplateSection);
|
|
||||||
const addFaqTemplateSection = useEditorStore((state) => state.addFaqTemplateSection);
|
|
||||||
const addPricingTemplateSection = useEditorStore((state) => state.addPricingTemplateSection);
|
|
||||||
const addTestimonialsTemplateSection = useEditorStore((state) => state.addTestimonialsTemplateSection);
|
|
||||||
const addBlogTemplateSection = useEditorStore((state) => state.addBlogTemplateSection);
|
|
||||||
const addTeamTemplateSection = useEditorStore((state) => state.addTeamTemplateSection);
|
|
||||||
const addFooterTemplateSection = useEditorStore((state) => state.addFooterTemplateSection);
|
|
||||||
|
|
||||||
// 버튼 핸들러를 useCallback으로 래핑해 불필요한 재생성을 줄인다.
|
|
||||||
const handleAddText = useCallback(() => addTextBlock(), [addTextBlock]);
|
|
||||||
const handleAddButton = useCallback(() => addButtonBlock(), [addButtonBlock]);
|
|
||||||
const handleAddImage = useCallback(() => addImageBlock(), [addImageBlock]);
|
|
||||||
const handleAddVideo = useCallback(() => addVideoBlock(), [addVideoBlock]);
|
|
||||||
const handleAddDivider = useCallback(() => addDividerBlock(), [addDividerBlock]);
|
|
||||||
const handleAddList = useCallback(() => addListBlock(), [addListBlock]);
|
|
||||||
const handleAddSection = useCallback(() => addSectionBlock(), [addSectionBlock]);
|
|
||||||
|
|
||||||
const handleAddFormBlock = useCallback(() => addFormBlock(), [addFormBlock]);
|
|
||||||
const handleAddFormInput = useCallback(() => addFormInputBlock(), [addFormInputBlock]);
|
|
||||||
const handleAddFormSelect = useCallback(() => addFormSelectBlock(), [addFormSelectBlock]);
|
|
||||||
const handleAddFormRadio = useCallback(() => addFormRadioBlock(), [addFormRadioBlock]);
|
|
||||||
const handleAddFormCheckbox = useCallback(() => addFormCheckboxBlock(), [addFormCheckboxBlock]);
|
|
||||||
|
|
||||||
const handleAddHeroTemplate = useCallback(
|
|
||||||
() => addHeroTemplateSection(),
|
|
||||||
[addHeroTemplateSection],
|
|
||||||
);
|
|
||||||
const handleAddFeaturesTemplate = useCallback(
|
|
||||||
() => addFeaturesTemplateSection(),
|
|
||||||
[addFeaturesTemplateSection],
|
|
||||||
);
|
|
||||||
const handleAddCtaTemplate = useCallback(() => addCtaTemplateSection(), [addCtaTemplateSection]);
|
|
||||||
const handleAddFaqTemplate = useCallback(() => addFaqTemplateSection(), [addFaqTemplateSection]);
|
|
||||||
const handleAddPricingTemplate = useCallback(
|
|
||||||
() => addPricingTemplateSection(),
|
|
||||||
[addPricingTemplateSection],
|
|
||||||
);
|
|
||||||
const handleAddTestimonialsTemplate = useCallback(
|
|
||||||
() => addTestimonialsTemplateSection(),
|
|
||||||
[addTestimonialsTemplateSection],
|
|
||||||
);
|
|
||||||
const handleAddBlogTemplate = useCallback(() => addBlogTemplateSection(), [addBlogTemplateSection]);
|
|
||||||
const handleAddTeamTemplate = useCallback(() => addTeamTemplateSection(), [addTeamTemplateSection]);
|
|
||||||
const handleAddFooterTemplate = useCallback(
|
|
||||||
() => addFooterTemplateSection(),
|
|
||||||
[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 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 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 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>
|
|
||||||
|
|
||||||
{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>
|
|
||||||
<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>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</aside>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,495 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import type { ButtonBlockProps } from "@/features/editor/state/editorStore";
|
|
||||||
import { NumericPropertyControl } from "@/features/editor/components/NumericPropertyControl";
|
|
||||||
import { ColorPickerField, TEXT_COLOR_PALETTE } from "@/features/editor/components/ColorPickerField";
|
|
||||||
|
|
||||||
export type ButtonPropertiesPanelProps = {
|
|
||||||
buttonProps: ButtonBlockProps;
|
|
||||||
selectedBlockId: string;
|
|
||||||
updateBlock: (id: string, partial: Partial<ButtonBlockProps>) => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
export function ButtonPropertiesPanel({ buttonProps, selectedBlockId, updateBlock }: ButtonPropertiesPanelProps) {
|
|
||||||
const imageSource: "none" | "url" | "upload" = (() => {
|
|
||||||
const hasSrc = (buttonProps.imageSrc ?? "").trim() !== "";
|
|
||||||
if (!hasSrc) return "none";
|
|
||||||
if (buttonProps.imageSourceType === "asset") return "upload";
|
|
||||||
return "url";
|
|
||||||
})();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<div className="space-y-1">
|
|
||||||
<label className="flex flex-col gap-1 text-xs text-slate-400">
|
|
||||||
<span>버튼 텍스트</span>
|
|
||||||
<textarea
|
|
||||||
className="w-full min-h-[60px] rounded border border-slate-700 bg-slate-900 px-2 py-1 text-xs outline-none focus:border-sky-500"
|
|
||||||
aria-label="버튼 텍스트"
|
|
||||||
value={buttonProps.label}
|
|
||||||
onChange={(e) => {
|
|
||||||
updateBlock(selectedBlockId, { label: e.target.value } as any);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
<div className="mt-3 space-y-2 border-t border-slate-800 pt-3 text-xs text-slate-400">
|
|
||||||
<h4 className="text-[11px] font-semibold text-slate-200">버튼 이미지</h4>
|
|
||||||
|
|
||||||
<div className="space-y-1">
|
|
||||||
<label className="flex flex-col gap-1">
|
|
||||||
<span>버튼 이미지 소스</span>
|
|
||||||
<select
|
|
||||||
className="w-full rounded border border-slate-700 bg-slate-900 px-2 py-1 text-[11px] outline-none focus:border-sky-500"
|
|
||||||
aria-label="버튼 이미지 소스"
|
|
||||||
value={imageSource}
|
|
||||||
onChange={(e) => {
|
|
||||||
const next = e.target.value as "none" | "url" | "upload";
|
|
||||||
if (next === "none") {
|
|
||||||
updateBlock(selectedBlockId, {
|
|
||||||
imageSrc: "",
|
|
||||||
imageAssetId: null,
|
|
||||||
} as any);
|
|
||||||
} else if (next === "url") {
|
|
||||||
updateBlock(selectedBlockId, {
|
|
||||||
imageSourceType: "externalUrl",
|
|
||||||
imageAssetId: null,
|
|
||||||
} as any);
|
|
||||||
} else {
|
|
||||||
updateBlock(selectedBlockId, {
|
|
||||||
imageSourceType: "asset",
|
|
||||||
} as any);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<option value="none">사용 안 함</option>
|
|
||||||
<option value="url">URL</option>
|
|
||||||
<option value="upload">파일 업로드</option>
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{imageSource === "url" && (
|
|
||||||
<div className="space-y-1">
|
|
||||||
<label className="flex flex-col gap-1">
|
|
||||||
<span>버튼 이미지 URL</span>
|
|
||||||
<input
|
|
||||||
className="w-full rounded border border-slate-700 bg-slate-900 px-2 py-1 text-xs outline-none focus:border-sky-500"
|
|
||||||
aria-label="버튼 이미지 URL"
|
|
||||||
value={buttonProps.imageSrc ?? ""}
|
|
||||||
onChange={(e) => {
|
|
||||||
const value = e.target.value;
|
|
||||||
updateBlock(selectedBlockId, {
|
|
||||||
imageSrc: value,
|
|
||||||
imageSourceType: "externalUrl",
|
|
||||||
imageAssetId: null,
|
|
||||||
} as any);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{imageSource === "upload" && (
|
|
||||||
<div className="space-y-1">
|
|
||||||
<label className="flex flex-col gap-1">
|
|
||||||
<span>버튼 이미지 파일 업로드</span>
|
|
||||||
<input
|
|
||||||
type="file"
|
|
||||||
accept="image/*"
|
|
||||||
className="w-full text-[11px] text-slate-300 file:mr-2 file:rounded file:border file:border-slate-700 file:bg-slate-800 file:px-2 file:py-1 file:text-[11px] file:text-slate-100 hover:file:bg-slate-700"
|
|
||||||
aria-label="버튼 이미지 파일 업로드"
|
|
||||||
onChange={async (event) => {
|
|
||||||
const file = event.target.files?.[0];
|
|
||||||
if (!file) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const formData = new FormData();
|
|
||||||
formData.append("file", file);
|
|
||||||
|
|
||||||
const response = await fetch("/api/image", {
|
|
||||||
method: "POST",
|
|
||||||
body: formData,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
console.error("버튼 이미지 업로드 실패", await response.text());
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = (await response.json()) as { id: string; servedUrl?: string | null };
|
|
||||||
const servedUrl = data.servedUrl ?? `/api/image/${data.id}`;
|
|
||||||
|
|
||||||
updateBlock(selectedBlockId, {
|
|
||||||
imageSrc: servedUrl,
|
|
||||||
imageSourceType: "asset",
|
|
||||||
imageAssetId: data.id,
|
|
||||||
} as any);
|
|
||||||
} catch (error) {
|
|
||||||
console.error("버튼 이미지 업로드 중 오류", error);
|
|
||||||
} finally {
|
|
||||||
event.target.value = "";
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{imageSource !== "none" && (
|
|
||||||
<>
|
|
||||||
<div className="space-y-1">
|
|
||||||
<label className="flex flex-col gap-1">
|
|
||||||
<span>버튼 이미지 대체 텍스트</span>
|
|
||||||
<input
|
|
||||||
className="w-full rounded border border-slate-700 bg-slate-900 px-2 py-1 text-xs outline-none focus:border-sky-500"
|
|
||||||
aria-label="버튼 이미지 대체 텍스트"
|
|
||||||
value={buttonProps.imageAlt ?? ""}
|
|
||||||
onChange={(e) => {
|
|
||||||
updateBlock(selectedBlockId, { imageAlt: e.target.value } as any);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-1">
|
|
||||||
<label className="flex flex-col gap-1">
|
|
||||||
<span>버튼 이미지 위치</span>
|
|
||||||
<select
|
|
||||||
className="rounded border border-slate-700 bg-slate-900 px-2 py-1 text-xs outline-none focus:border-sky-500"
|
|
||||||
aria-label="버튼 이미지 위치"
|
|
||||||
value={buttonProps.imagePlacement ?? "left"}
|
|
||||||
onChange={(e) => {
|
|
||||||
updateBlock(selectedBlockId, {
|
|
||||||
imagePlacement: e.target.value as any,
|
|
||||||
} as any);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<option value="left">텍스트 왼쪽</option>
|
|
||||||
<option value="right">텍스트 오른쪽</option>
|
|
||||||
<option value="top">텍스트 위쪽</option>
|
|
||||||
<option value="bottom">텍스트 아래쪽</option>
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="space-y-1">
|
|
||||||
<NumericPropertyControl
|
|
||||||
label="가로 패딩 (px)"
|
|
||||||
unitLabel="(px)"
|
|
||||||
value={buttonProps.paddingX ?? 16}
|
|
||||||
min={0}
|
|
||||||
max={120}
|
|
||||||
step={4}
|
|
||||||
presets={[
|
|
||||||
{ id: "xs", label: "XS", value: 8 },
|
|
||||||
{ id: "sm", label: "S", value: 12 },
|
|
||||||
{ id: "md", label: "M", value: 16 },
|
|
||||||
{ id: "lg", label: "L", value: 24 },
|
|
||||||
{ id: "xl", label: "XL", value: 32 },
|
|
||||||
]}
|
|
||||||
onChangeValue={(v) => {
|
|
||||||
updateBlock(selectedBlockId, {
|
|
||||||
paddingX: v,
|
|
||||||
} as any);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-1">
|
|
||||||
<NumericPropertyControl
|
|
||||||
label="세로 패딩 (px)"
|
|
||||||
unitLabel="(px)"
|
|
||||||
value={buttonProps.paddingY ?? 10}
|
|
||||||
min={0}
|
|
||||||
max={80}
|
|
||||||
step={2}
|
|
||||||
presets={[
|
|
||||||
{ id: "xs", label: "XS", value: 6 },
|
|
||||||
{ id: "sm", label: "S", value: 8 },
|
|
||||||
{ id: "md", label: "M", value: 10 },
|
|
||||||
{ id: "lg", label: "L", value: 14 },
|
|
||||||
{ id: "xl", label: "XL", value: 20 },
|
|
||||||
]}
|
|
||||||
onChangeValue={(v) => {
|
|
||||||
updateBlock(selectedBlockId, {
|
|
||||||
paddingY: v,
|
|
||||||
} as any);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-1">
|
|
||||||
<label className="flex flex-col gap-1 text-xs text-slate-400">
|
|
||||||
<span>버튼 링크</span>
|
|
||||||
<input
|
|
||||||
className="w-full rounded border border-slate-700 bg-slate-900 px-2 py-1 text-xs outline-none focus:border-sky-500"
|
|
||||||
aria-label="버튼 링크"
|
|
||||||
value={buttonProps.href}
|
|
||||||
onChange={(e) => {
|
|
||||||
updateBlock(selectedBlockId, { href: e.target.value } as any);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-1">
|
|
||||||
<label className="flex flex-col gap-1 text-xs text-slate-400">
|
|
||||||
<span>정렬</span>
|
|
||||||
<select
|
|
||||||
className="rounded border border-slate-700 bg-slate-900 px-2 py-1 text-xs outline-none focus:border-sky-500"
|
|
||||||
aria-label="버튼 정렬"
|
|
||||||
value={buttonProps.align ?? "left"}
|
|
||||||
onChange={(e) => {
|
|
||||||
const value = e.target.value as NonNullable<ButtonBlockProps["align"]>;
|
|
||||||
updateBlock(selectedBlockId, { align: value } as any);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<option value="left">왼쪽</option>
|
|
||||||
<option value="center">가운데</option>
|
|
||||||
<option value="right">오른쪽</option>
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-1">
|
|
||||||
{/* 버튼 크기 스케일 컨트롤은 paddingX/paddingY 및 폰트 크기(px) 컨트롤로 충분하므로 제거했다. */}
|
|
||||||
</div>
|
|
||||||
<div className="space-y-1">
|
|
||||||
<ColorPickerField
|
|
||||||
label="텍스트 색상"
|
|
||||||
ariaLabelColorInput="버튼 텍스트 색상 피커"
|
|
||||||
ariaLabelHexInput="버튼 텍스트 색상 HEX"
|
|
||||||
value={
|
|
||||||
buttonProps.textColorCustom && buttonProps.textColorCustom.startsWith("#")
|
|
||||||
? buttonProps.textColorCustom
|
|
||||||
: "#f9fafb"
|
|
||||||
}
|
|
||||||
onChange={(hex) => {
|
|
||||||
updateBlock(selectedBlockId, {
|
|
||||||
textColorCustom: hex,
|
|
||||||
} as any);
|
|
||||||
}}
|
|
||||||
palette={TEXT_COLOR_PALETTE}
|
|
||||||
onPaletteSelect={(item) => {
|
|
||||||
updateBlock(selectedBlockId, {
|
|
||||||
textColorCustom: item.color,
|
|
||||||
} as any);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-1">
|
|
||||||
<label className="flex flex-col gap-1 text-xs text-slate-400">
|
|
||||||
<span>스타일</span>
|
|
||||||
<select
|
|
||||||
className="rounded border border-slate-700 bg-slate-900 px-2 py-1 text-xs outline-none focus:border-sky-500"
|
|
||||||
aria-label="버튼 스타일"
|
|
||||||
value={buttonProps.variant ?? "solid"}
|
|
||||||
onChange={(e) => {
|
|
||||||
const value = e.target.value as NonNullable<ButtonBlockProps["variant"]>;
|
|
||||||
updateBlock(selectedBlockId, { variant: value } as any);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<option value="solid">채움</option>
|
|
||||||
<option value="outline">외곽선</option>
|
|
||||||
<option value="ghost">고스트</option>
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-1">
|
|
||||||
<ColorPickerField
|
|
||||||
label="채움 색상"
|
|
||||||
ariaLabelColorInput="버튼 채움 색상 피커"
|
|
||||||
ariaLabelHexInput="버튼 채움 색상 HEX"
|
|
||||||
value={
|
|
||||||
buttonProps.fillColorCustom && buttonProps.fillColorCustom.startsWith("#")
|
|
||||||
? buttonProps.fillColorCustom
|
|
||||||
: TEXT_COLOR_PALETTE[0]?.color ?? "#0ea5e9"
|
|
||||||
}
|
|
||||||
onChange={(hex) => {
|
|
||||||
updateBlock(selectedBlockId, {
|
|
||||||
fillColorCustom: hex,
|
|
||||||
} as any);
|
|
||||||
}}
|
|
||||||
palette={TEXT_COLOR_PALETTE}
|
|
||||||
onPaletteSelect={(item) => {
|
|
||||||
updateBlock(selectedBlockId, {
|
|
||||||
fillColorCustom: item.color,
|
|
||||||
} as any);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-1">
|
|
||||||
<ColorPickerField
|
|
||||||
label="외곽선 색상"
|
|
||||||
ariaLabelColorInput="버튼 외곽선 색상 피커"
|
|
||||||
ariaLabelHexInput="버튼 외곽선 색상 HEX"
|
|
||||||
value={
|
|
||||||
buttonProps.strokeColorCustom && buttonProps.strokeColorCustom.startsWith("#")
|
|
||||||
? buttonProps.strokeColorCustom
|
|
||||||
: TEXT_COLOR_PALETTE[0]?.color ?? "#0ea5e9"
|
|
||||||
}
|
|
||||||
onChange={(hex) => {
|
|
||||||
updateBlock(selectedBlockId, {
|
|
||||||
strokeColorCustom: hex,
|
|
||||||
} as any);
|
|
||||||
}}
|
|
||||||
palette={TEXT_COLOR_PALETTE}
|
|
||||||
onPaletteSelect={(item) => {
|
|
||||||
updateBlock(selectedBlockId, {
|
|
||||||
strokeColorCustom: item.color,
|
|
||||||
} as any);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-1">
|
|
||||||
<NumericPropertyControl
|
|
||||||
label="모서리 둥글기"
|
|
||||||
value={(() => {
|
|
||||||
const r = buttonProps.borderRadius ?? "md";
|
|
||||||
return r === "none" ? 0 : r === "sm" ? 1 : r === "lg" ? 3 : r === "full" ? 4 : 2;
|
|
||||||
})()}
|
|
||||||
min={0}
|
|
||||||
max={4}
|
|
||||||
step={1}
|
|
||||||
presets={[
|
|
||||||
{ id: "none", label: "없음", value: 0 },
|
|
||||||
{ id: "sm", label: "작게", value: 1 },
|
|
||||||
{ id: "md", label: "보통", value: 2 },
|
|
||||||
{ id: "lg", label: "크게", value: 3 },
|
|
||||||
{ id: "full", label: "완전 둥글게", value: 4 },
|
|
||||||
]}
|
|
||||||
onChangeValue={(v) => {
|
|
||||||
const next =
|
|
||||||
v <= 0 ? "none" : v === 1 ? "sm" : v === 3 ? "lg" : v >= 4 ? "full" : "md";
|
|
||||||
updateBlock(selectedBlockId, { borderRadius: next } as any);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-1">
|
|
||||||
<label className="flex flex-col gap-1 text-xs text-slate-400">
|
|
||||||
<span>버튼 너비 모드</span>
|
|
||||||
<select
|
|
||||||
className="rounded border border-slate-700 bg-slate-900 px-2 py-1 text-xs outline-none focus:border-sky-500"
|
|
||||||
aria-label="버튼 너비 모드"
|
|
||||||
value={buttonProps.widthMode ?? (buttonProps.fullWidth ? "full" : "auto")}
|
|
||||||
onChange={(e) => {
|
|
||||||
updateBlock(selectedBlockId, {
|
|
||||||
widthMode: e.target.value as NonNullable<ButtonBlockProps["widthMode"]>,
|
|
||||||
} as any);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<option value="auto">자동</option>
|
|
||||||
<option value="full">전체 폭</option>
|
|
||||||
<option value="fixed">고정 값</option>
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
{(buttonProps.widthMode ?? (buttonProps.fullWidth ? "full" : "auto")) === "fixed" && (
|
|
||||||
<NumericPropertyControl
|
|
||||||
label="버튼 고정 너비"
|
|
||||||
unitLabel="(px)"
|
|
||||||
value={buttonProps.widthPx ?? 240}
|
|
||||||
min={80}
|
|
||||||
max={600}
|
|
||||||
step={10}
|
|
||||||
presets={[
|
|
||||||
{ id: "sm", label: "작게", value: 180 },
|
|
||||||
{ id: "md", label: "보통", value: 240 },
|
|
||||||
{ id: "lg", label: "넓게", value: 320 },
|
|
||||||
]}
|
|
||||||
onChangeValue={(v) => {
|
|
||||||
updateBlock(selectedBlockId, {
|
|
||||||
widthPx: v,
|
|
||||||
} as any);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="space-y-1">
|
|
||||||
<NumericPropertyControl
|
|
||||||
label="버튼 크기"
|
|
||||||
unitLabel="(px)"
|
|
||||||
value={(() => {
|
|
||||||
const raw = buttonProps.fontSizeCustom ?? "";
|
|
||||||
const match = raw.match(/([0-9]+(?:\.[0-9]+)?)/);
|
|
||||||
if (match) return Number(match[1]);
|
|
||||||
// 기본 버튼 텍스트 크기: md 스케일(16px) 기준
|
|
||||||
return 16;
|
|
||||||
})()}
|
|
||||||
min={10}
|
|
||||||
max={32}
|
|
||||||
step={1}
|
|
||||||
presets={[
|
|
||||||
{ id: "xs", label: "XS", value: 12 },
|
|
||||||
{ id: "sm", label: "S", value: 14 },
|
|
||||||
{ id: "base", label: "M", value: 16 },
|
|
||||||
{ id: "lg", label: "L", value: 18 },
|
|
||||||
{ id: "xl", label: "XL", value: 20 },
|
|
||||||
{ id: "2xl", label: "2XL", value: 24 },
|
|
||||||
{ id: "3xl", label: "3XL", value: 30 },
|
|
||||||
]}
|
|
||||||
onChangeValue={(px) => {
|
|
||||||
// 버튼은 텍스트 스케일 상태 없이 px만 저장하지만,
|
|
||||||
// 프리셋 값은 TextPropertiesPanel과 동일한 테이블을 사용한다.
|
|
||||||
updateBlock(selectedBlockId, {
|
|
||||||
fontSizeCustom: `${px}px`,
|
|
||||||
} as any);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-1">
|
|
||||||
<NumericPropertyControl
|
|
||||||
label="줄 간격"
|
|
||||||
value={(() => {
|
|
||||||
const raw = buttonProps.lineHeightCustom ?? "";
|
|
||||||
const match = raw.match(/(-?[0-9]+(?:\.[0-9]+)?)/);
|
|
||||||
if (match) return Number(match[1]);
|
|
||||||
return 1.4;
|
|
||||||
})()}
|
|
||||||
min={0.8}
|
|
||||||
max={3}
|
|
||||||
step={0.05}
|
|
||||||
presets={[
|
|
||||||
{ id: "tight", label: "좁게", value: 1.1 },
|
|
||||||
{ id: "normal", label: "보통", value: 1.4 },
|
|
||||||
{ id: "relaxed", label: "넓게", value: 1.8 },
|
|
||||||
]}
|
|
||||||
onChangeValue={(v) => {
|
|
||||||
updateBlock(selectedBlockId, {
|
|
||||||
lineHeightCustom: v.toString(),
|
|
||||||
} as any);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-1">
|
|
||||||
<NumericPropertyControl
|
|
||||||
label="글자 간격"
|
|
||||||
unitLabel="(px)"
|
|
||||||
value={(() => {
|
|
||||||
const raw = buttonProps.letterSpacingCustom ?? "";
|
|
||||||
const match = raw.match(/(-?[0-9]+(?:\.[0-9]+)?)/);
|
|
||||||
if (match) {
|
|
||||||
const em = Number(match[1]);
|
|
||||||
if (Number.isFinite(em)) return em * 16;
|
|
||||||
}
|
|
||||||
return 0;
|
|
||||||
})()}
|
|
||||||
min={-2}
|
|
||||||
max={10}
|
|
||||||
step={0.1}
|
|
||||||
presets={[
|
|
||||||
{ id: "tighter", label: "아주 좁게", value: -1.5 },
|
|
||||||
{ id: "tight", label: "좁게", value: -0.5 },
|
|
||||||
{ id: "normal", label: "보통", value: 0 },
|
|
||||||
{ id: "wide", label: "넓게", value: 1 },
|
|
||||||
{ id: "wider", label: "아주 넓게", value: 2 },
|
|
||||||
]}
|
|
||||||
onChangeValue={(px) => {
|
|
||||||
const em = px / 16;
|
|
||||||
updateBlock(selectedBlockId, {
|
|
||||||
letterSpacingCustom: `${em}em`,
|
|
||||||
} as any);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,137 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import type { DividerBlockProps } from "@/features/editor/state/editorStore";
|
|
||||||
import { NumericPropertyControl } from "@/features/editor/components/NumericPropertyControl";
|
|
||||||
import { ColorPickerField, TEXT_COLOR_PALETTE } from "@/features/editor/components/ColorPickerField";
|
|
||||||
|
|
||||||
export type DividerPropertiesPanelProps = {
|
|
||||||
dividerProps: DividerBlockProps;
|
|
||||||
selectedBlockId: string;
|
|
||||||
updateBlock: (id: string, partial: Partial<DividerBlockProps>) => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
export function DividerPropertiesPanel({ dividerProps, selectedBlockId, updateBlock }: DividerPropertiesPanelProps) {
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<div className="space-y-1">
|
|
||||||
<label className="flex flex-col gap-1 text-xs text-slate-400">
|
|
||||||
<span>정렬</span>
|
|
||||||
<select
|
|
||||||
className="rounded border border-slate-700 bg-slate-900 px-2 py-1 text-xs outline-none focus:border-sky-500"
|
|
||||||
aria-label="구분선 정렬"
|
|
||||||
value={dividerProps.align}
|
|
||||||
onChange={(e) => {
|
|
||||||
const value = e.target.value as DividerBlockProps["align"];
|
|
||||||
updateBlock(selectedBlockId, { align: value } as any);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<option value="left">왼쪽</option>
|
|
||||||
<option value="center">가운데</option>
|
|
||||||
<option value="right">오른쪽</option>
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-1">
|
|
||||||
<label className="flex flex-col gap-1 text-xs text-slate-400">
|
|
||||||
<span>두께</span>
|
|
||||||
<select
|
|
||||||
className="rounded border border-slate-700 bg-slate-900 px-2 py-1 text-xs outline-none focus:border-sky-500"
|
|
||||||
aria-label="구분선 두께"
|
|
||||||
value={dividerProps.thickness}
|
|
||||||
onChange={(e) => {
|
|
||||||
const value = e.target.value as DividerBlockProps["thickness"];
|
|
||||||
updateBlock(selectedBlockId, { thickness: value } as any);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<option value="thin">얇게</option>
|
|
||||||
<option value="medium">보통</option>
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-3 space-y-2 border-t border-slate-800 pt-3 text-xs text-slate-400">
|
|
||||||
<h4 className="text-[11px] font-semibold text-slate-200">구분선 스타일</h4>
|
|
||||||
|
|
||||||
{/* 길이/너비 모드 */}
|
|
||||||
<label className="flex flex-col gap-1">
|
|
||||||
<span>길이 모드</span>
|
|
||||||
<select
|
|
||||||
className="rounded border border-slate-700 bg-slate-900 px-2 py-1 text-xs outline-none focus:border-sky-500"
|
|
||||||
aria-label="구분선 길이 모드"
|
|
||||||
value={dividerProps.widthMode ?? "full"}
|
|
||||||
onChange={(e) => {
|
|
||||||
const value = e.target.value as NonNullable<DividerBlockProps["widthMode"]>;
|
|
||||||
updateBlock(selectedBlockId, { widthMode: value } as any);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<option value="auto">내용에 맞춤</option>
|
|
||||||
<option value="full">전체 폭</option>
|
|
||||||
<option value="fixed">고정 길이 (px)</option>
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
|
|
||||||
{(dividerProps.widthMode ?? "full") === "fixed" && (
|
|
||||||
<NumericPropertyControl
|
|
||||||
label="고정 길이 (px)"
|
|
||||||
unitLabel="(px)"
|
|
||||||
value={dividerProps.widthPx ?? 320}
|
|
||||||
min={40}
|
|
||||||
max={1200}
|
|
||||||
step={10}
|
|
||||||
presets={[
|
|
||||||
{ id: "sm", label: "짧게", value: 240 },
|
|
||||||
{ id: "md", label: "보통", value: 320 },
|
|
||||||
{ id: "lg", label: "길게", value: 480 },
|
|
||||||
]}
|
|
||||||
onChangeValue={(v) => {
|
|
||||||
updateBlock(selectedBlockId, { widthPx: v } as any);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 색상 */}
|
|
||||||
<ColorPickerField
|
|
||||||
label="선 색상"
|
|
||||||
ariaLabelColorInput="구분선 색상 피커"
|
|
||||||
ariaLabelHexInput="구분선 색상 HEX"
|
|
||||||
value={
|
|
||||||
dividerProps.colorHex && dividerProps.colorHex.trim() !== ""
|
|
||||||
? dividerProps.colorHex
|
|
||||||
: TEXT_COLOR_PALETTE[0]?.color ?? "#64748b"
|
|
||||||
}
|
|
||||||
onChange={(hex) => {
|
|
||||||
updateBlock(selectedBlockId, { colorHex: hex } as any);
|
|
||||||
}}
|
|
||||||
palette={TEXT_COLOR_PALETTE}
|
|
||||||
onPaletteSelect={(item) => {
|
|
||||||
updateBlock(selectedBlockId, { colorHex: item.color } as any);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* 상하 여백 (슬라이더) */}
|
|
||||||
<NumericPropertyControl
|
|
||||||
label="위/아래 여백"
|
|
||||||
unitLabel="(px)"
|
|
||||||
value={(() => {
|
|
||||||
if (typeof dividerProps.marginYPx === "number") {
|
|
||||||
return dividerProps.marginYPx;
|
|
||||||
}
|
|
||||||
const y = dividerProps.marginY ?? "md";
|
|
||||||
return y === "sm" ? 8 : y === "lg" ? 24 : 16;
|
|
||||||
})()}
|
|
||||||
min={0}
|
|
||||||
max={50}
|
|
||||||
step={2}
|
|
||||||
presets={[
|
|
||||||
{ id: "sm", label: "작게", value: 8 },
|
|
||||||
{ id: "md", label: "보통", value: 16 },
|
|
||||||
{ id: "lg", label: "크게", value: 24 },
|
|
||||||
]}
|
|
||||||
onChangeValue={(v) => {
|
|
||||||
updateBlock(selectedBlockId, { marginYPx: v } as any);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,238 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import type { ImageBlockProps } from "@/features/editor/state/editorStore";
|
|
||||||
import { NumericPropertyControl } from "@/features/editor/components/NumericPropertyControl";
|
|
||||||
import { ColorPickerField, TEXT_COLOR_PALETTE } from "@/features/editor/components/ColorPickerField";
|
|
||||||
|
|
||||||
export type ImagePropertiesPanelProps = {
|
|
||||||
imageProps: ImageBlockProps;
|
|
||||||
selectedBlockId: string;
|
|
||||||
updateBlock: (id: string, partial: Partial<ImageBlockProps>) => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
export function ImagePropertiesPanel({ imageProps, selectedBlockId, updateBlock }: ImagePropertiesPanelProps) {
|
|
||||||
const source: "url" | "upload" =
|
|
||||||
imageProps.sourceType === "asset" || (imageProps.src && imageProps.src.startsWith("/api/image/"))
|
|
||||||
? "upload"
|
|
||||||
: "url";
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<div className="space-y-1">
|
|
||||||
<label className="flex flex-col gap-1 text-xs text-slate-400">
|
|
||||||
<span>이미지 소스</span>
|
|
||||||
<select
|
|
||||||
className="w-full rounded border border-slate-700 bg-slate-900 px-2 py-1 text-[11px] outline-none focus:border-sky-500"
|
|
||||||
aria-label="이미지 소스"
|
|
||||||
value={source}
|
|
||||||
onChange={(e) => {
|
|
||||||
const next = e.target.value as "url" | "upload";
|
|
||||||
if (next === "url") {
|
|
||||||
updateBlock(selectedBlockId, {
|
|
||||||
sourceType: "externalUrl",
|
|
||||||
assetId: null,
|
|
||||||
} as any);
|
|
||||||
} else {
|
|
||||||
updateBlock(selectedBlockId, {
|
|
||||||
sourceType: "asset",
|
|
||||||
} as any);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<option value="url">URL</option>
|
|
||||||
<option value="upload">파일 업로드</option>
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{source === "url" && (
|
|
||||||
<div className="space-y-1">
|
|
||||||
<label className="flex flex-col gap-1 text-xs text-slate-400">
|
|
||||||
<span>이미지 URL</span>
|
|
||||||
<input
|
|
||||||
className="w-full rounded border border-slate-700 bg-slate-900 px-2 py-1 text-xs outline-none focus:border-sky-500"
|
|
||||||
aria-label="이미지 URL"
|
|
||||||
value={imageProps.src}
|
|
||||||
onChange={(e) => {
|
|
||||||
const value = e.target.value;
|
|
||||||
updateBlock(selectedBlockId, {
|
|
||||||
src: value,
|
|
||||||
sourceType: "externalUrl",
|
|
||||||
assetId: null,
|
|
||||||
} as any);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{source === "upload" && (
|
|
||||||
<div className="space-y-1">
|
|
||||||
<label className="flex flex-col gap-1 text-xs text-slate-400">
|
|
||||||
<span>이미지 파일 업로드</span>
|
|
||||||
<input
|
|
||||||
type="file"
|
|
||||||
accept="image/*"
|
|
||||||
className="w-full text-[11px] text-slate-300 file:mr-2 file:rounded file:border file:border-slate-700 file:bg-slate-800 file:px-2 file:py-1 file:text-[11px] file:text-slate-100 hover:file:bg-slate-700"
|
|
||||||
aria-label="이미지 파일 업로드"
|
|
||||||
onChange={async (event) => {
|
|
||||||
const file = event.target.files?.[0];
|
|
||||||
if (!file) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const formData = new FormData();
|
|
||||||
formData.append("file", file);
|
|
||||||
|
|
||||||
const response = await fetch("/api/image", {
|
|
||||||
method: "POST",
|
|
||||||
body: formData,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
console.error("이미지 업로드 실패", await response.text());
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = (await response.json()) as { id: string; servedUrl?: string | null };
|
|
||||||
const servedUrl = data.servedUrl ?? `/api/image/${data.id}`;
|
|
||||||
|
|
||||||
updateBlock(selectedBlockId, {
|
|
||||||
src: servedUrl,
|
|
||||||
sourceType: "asset",
|
|
||||||
assetId: data.id,
|
|
||||||
} as any);
|
|
||||||
} catch (error) {
|
|
||||||
console.error("이미지 업로드 중 오류", error);
|
|
||||||
} finally {
|
|
||||||
event.target.value = "";
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div className="space-y-1">
|
|
||||||
<label className="flex flex-col gap-1 text-xs text-slate-400">
|
|
||||||
<span>대체 텍스트</span>
|
|
||||||
<input
|
|
||||||
className="w-full rounded border border-slate-700 bg-slate-900 px-2 py-1 text-xs outline-none focus:border-sky-500"
|
|
||||||
aria-label="대체 텍스트"
|
|
||||||
value={imageProps.alt}
|
|
||||||
onChange={(e) => {
|
|
||||||
updateBlock(selectedBlockId, { alt: e.target.value } as any);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-3 space-y-2 border-t border-slate-800 pt-3 text-xs text-slate-400">
|
|
||||||
<h4 className="text-[11px] font-semibold text-slate-200">이미지 스타일</h4>
|
|
||||||
|
|
||||||
{/* 카드 배경색 */}
|
|
||||||
<div className="space-y-1">
|
|
||||||
<ColorPickerField
|
|
||||||
label="카드 배경색"
|
|
||||||
ariaLabelColorInput="이미지 카드 배경색 피커"
|
|
||||||
ariaLabelHexInput="이미지 카드 배경색 HEX"
|
|
||||||
value={imageProps.backgroundColorCustom ?? ""}
|
|
||||||
onChange={(hex) => {
|
|
||||||
const next = hex && hex.trim().length > 0 ? hex : undefined;
|
|
||||||
updateBlock(selectedBlockId, { backgroundColorCustom: next } as any);
|
|
||||||
}}
|
|
||||||
palette={TEXT_COLOR_PALETTE}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 정렬 */}
|
|
||||||
<label className="flex flex-col gap-1">
|
|
||||||
<span>정렬</span>
|
|
||||||
<select
|
|
||||||
className="w-full rounded border border-slate-700 bg-slate-900 px-2 py-1 text-[11px] outline-none focus:border-sky-500"
|
|
||||||
aria-label="이미지 정렬"
|
|
||||||
value={imageProps.align ?? "center"}
|
|
||||||
onChange={(e) =>
|
|
||||||
updateBlock(selectedBlockId, {
|
|
||||||
align: e.target.value as ImageBlockProps["align"],
|
|
||||||
} as any)
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<option value="left">왼쪽</option>
|
|
||||||
<option value="center">가운데</option>
|
|
||||||
<option value="right">오른쪽</option>
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
|
|
||||||
{/* 너비 모드 */}
|
|
||||||
<label className="flex flex-col gap-1">
|
|
||||||
<span>너비 모드</span>
|
|
||||||
<select
|
|
||||||
className="w-full rounded border border-slate-700 bg-slate-900 px-2 py-1 text-[11px] outline-none focus:border-sky-500"
|
|
||||||
aria-label="이미지 너비 모드"
|
|
||||||
value={imageProps.widthMode ?? "auto"}
|
|
||||||
onChange={(e) =>
|
|
||||||
updateBlock(selectedBlockId, {
|
|
||||||
widthMode: e.target.value as ImageBlockProps["widthMode"],
|
|
||||||
} as any)
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<option value="auto">내용에 맞춤</option>
|
|
||||||
<option value="fixed">고정 너비 (px)</option>
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
|
|
||||||
{(imageProps.widthMode ?? "auto") === "fixed" && (
|
|
||||||
<NumericPropertyControl
|
|
||||||
label="고정 너비 (px)"
|
|
||||||
unitLabel="(px)"
|
|
||||||
value={imageProps.widthPx ?? 320}
|
|
||||||
min={40}
|
|
||||||
max={1200}
|
|
||||||
step={10}
|
|
||||||
presets={[
|
|
||||||
{ id: "sm", label: "작게", value: 240 },
|
|
||||||
{ id: "md", label: "보통", value: 320 },
|
|
||||||
{ id: "lg", label: "넓게", value: 480 },
|
|
||||||
]}
|
|
||||||
onChangeValue={(v) => {
|
|
||||||
updateBlock(selectedBlockId, {
|
|
||||||
widthPx: v,
|
|
||||||
} as any);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 모서리 둥글기 */}
|
|
||||||
<NumericPropertyControl
|
|
||||||
label="모서리 둥글기"
|
|
||||||
value={(() => {
|
|
||||||
if (typeof imageProps.borderRadiusPx === "number") {
|
|
||||||
return imageProps.borderRadiusPx;
|
|
||||||
}
|
|
||||||
const r = imageProps.borderRadius ?? "md";
|
|
||||||
// 토큰만 있는 기존 데이터의 경우, 토큰별 대표값으로 초기화한다.
|
|
||||||
return r === "none" ? 0 : r === "sm" ? 20 : r === "lg" ? 120 : r === "full" ? 180 : 60;
|
|
||||||
})()}
|
|
||||||
min={0}
|
|
||||||
max={200}
|
|
||||||
step={1}
|
|
||||||
presets={[
|
|
||||||
{ id: "none", label: "없음", value: 0 },
|
|
||||||
{ id: "sm", label: "작게", value: 20 },
|
|
||||||
{ id: "md", label: "보통", value: 60 },
|
|
||||||
{ id: "lg", label: "크게", value: 120 },
|
|
||||||
{ id: "full", label: "완전 둥글게", value: 180 },
|
|
||||||
]}
|
|
||||||
onChangeValue={(v) => {
|
|
||||||
// 0~50 범위를 토큰으로 매핑한다.
|
|
||||||
const nextToken =
|
|
||||||
v <= 0 ? "none" : v <= 30 ? "sm" : v <= 90 ? "md" : v <= 150 ? "lg" : "full";
|
|
||||||
updateBlock(selectedBlockId, {
|
|
||||||
borderRadius: nextToken,
|
|
||||||
borderRadiusPx: v,
|
|
||||||
} as any);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,242 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import type { ListBlockProps } from "@/features/editor/state/editorStore";
|
|
||||||
import {
|
|
||||||
buildItemsTreeFromItems,
|
|
||||||
itemsTreeToLines,
|
|
||||||
linesToItemsTree,
|
|
||||||
parseTextareaToLines,
|
|
||||||
stringifyLinesToTextarea,
|
|
||||||
} from "@/features/editor/state/editorStore";
|
|
||||||
import { NumericPropertyControl } from "@/features/editor/components/NumericPropertyControl";
|
|
||||||
import { ColorPickerField, TEXT_COLOR_PALETTE } from "@/features/editor/components/ColorPickerField";
|
|
||||||
|
|
||||||
export type ListPropertiesPanelProps = {
|
|
||||||
listProps: ListBlockProps;
|
|
||||||
selectedBlockId: string;
|
|
||||||
selectedListItemId: string | null;
|
|
||||||
updateBlock: (id: string, partial: Partial<ListBlockProps>) => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
export function ListPropertiesPanel({
|
|
||||||
listProps,
|
|
||||||
selectedBlockId,
|
|
||||||
selectedListItemId,
|
|
||||||
updateBlock,
|
|
||||||
}: ListPropertiesPanelProps) {
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<div className="space-y-1">
|
|
||||||
<label className="flex flex-col gap-1 text-xs text-slate-400">
|
|
||||||
<span>리스트 아이템 (줄바꿈으로 구분)</span>
|
|
||||||
<textarea
|
|
||||||
className="w-full min-h-[80px] rounded border border-slate-700 bg-slate-900 px-2 py-1 text-xs outline-none focus:border-sky-500"
|
|
||||||
aria-label="리스트 아이템들"
|
|
||||||
value={(() => {
|
|
||||||
const tree = (listProps as any).itemsTree as any[] | undefined;
|
|
||||||
|
|
||||||
if (tree && tree.length > 0) {
|
|
||||||
// 중첩 리스트가 존재하는 경우, itemsTree → ListLine → textarea 문자열 순으로 직렬화한다.
|
|
||||||
const lines = itemsTreeToLines(tree as any);
|
|
||||||
return stringifyLinesToTextarea(lines as any);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 기존 하위호환: items 배열이 있다면 그대로 줄바꿈으로 이어 붙여 사용한다.
|
|
||||||
return (listProps.items ?? []).join("\n");
|
|
||||||
})()}
|
|
||||||
onChange={(e) => {
|
|
||||||
const textValue = e.target.value;
|
|
||||||
|
|
||||||
// textarea 내용을 ListLine 배열로 파싱한다.
|
|
||||||
const parsedLines = parseTextareaToLines(textValue);
|
|
||||||
|
|
||||||
// 전체 입력이 완전히 비어 있는 경우에는 빈 리스트 상태를 유지한다.
|
|
||||||
const hasAnyContent = parsedLines.some((line) => line.text.length > 0);
|
|
||||||
|
|
||||||
if (!hasAnyContent) {
|
|
||||||
updateBlock(
|
|
||||||
selectedBlockId,
|
|
||||||
{
|
|
||||||
items: [],
|
|
||||||
itemsTree: [],
|
|
||||||
} as any,
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// depth 기반 라인 모델에서 중첩 리스트 트리를 생성한다.
|
|
||||||
const nextTree = linesToItemsTree(selectedBlockId, parsedLines as any);
|
|
||||||
|
|
||||||
// 플랫 items 배열은 기존 하위호환을 위해 text 만 뽑아서 유지한다.
|
|
||||||
const flatItems = parsedLines.map((line) => line.text);
|
|
||||||
|
|
||||||
updateBlock(
|
|
||||||
selectedBlockId,
|
|
||||||
{
|
|
||||||
items: flatItems,
|
|
||||||
itemsTree: nextTree,
|
|
||||||
} as any,
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center justify-between text-xs text-slate-400">
|
|
||||||
<label className="flex items-center gap-2">
|
|
||||||
<span>정렬</span>
|
|
||||||
<select
|
|
||||||
className="rounded border border-slate-700 bg-slate-900 px-2 py-1 text-xs outline-none focus:border-sky-500"
|
|
||||||
aria-label="리스트 정렬"
|
|
||||||
value={listProps.align}
|
|
||||||
onChange={(e) => {
|
|
||||||
const value = e.target.value as ListBlockProps["align"];
|
|
||||||
updateBlock(selectedBlockId, { align: value } as any);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<option value="left">왼쪽</option>
|
|
||||||
<option value="center">가운데</option>
|
|
||||||
<option value="right">오른쪽</option>
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-3 space-y-2 border-t border-slate-800 pt-3 text-xs text-slate-400">
|
|
||||||
<h4 className="text-[11px] font-semibold text-slate-200">리스트 스타일</h4>
|
|
||||||
|
|
||||||
{/* 글자 크기 */}
|
|
||||||
<NumericPropertyControl
|
|
||||||
label="글자 크기 (px)"
|
|
||||||
unitLabel="(px)"
|
|
||||||
value={(() => {
|
|
||||||
const raw = listProps.fontSizeCustom ?? "";
|
|
||||||
const match = raw.match(/([0-9]+(?:\.[0-9]+)?)/);
|
|
||||||
if (match) return Number(match[1]);
|
|
||||||
return 14;
|
|
||||||
})()}
|
|
||||||
min={10}
|
|
||||||
max={32}
|
|
||||||
step={1}
|
|
||||||
presets={[
|
|
||||||
{ id: "sm", label: "작게", value: 12 },
|
|
||||||
{ id: "md", label: "보통", value: 14 },
|
|
||||||
{ id: "lg", label: "크게", value: 18 },
|
|
||||||
]}
|
|
||||||
onChangeValue={(px) => {
|
|
||||||
updateBlock(selectedBlockId, { fontSizeCustom: `${px}px` } as any);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* 줄 간격 */}
|
|
||||||
<NumericPropertyControl
|
|
||||||
label="줄 간격"
|
|
||||||
value={(() => {
|
|
||||||
const raw = listProps.lineHeightCustom ?? "";
|
|
||||||
const match = raw.match(/(-?[0-9]+(?:\.[0-9]+)?)/);
|
|
||||||
if (match) return Number(match[1]);
|
|
||||||
return 1.5;
|
|
||||||
})()}
|
|
||||||
min={1}
|
|
||||||
max={3}
|
|
||||||
step={0.05}
|
|
||||||
presets={[
|
|
||||||
{ id: "tight", label: "좁게", value: 1.2 },
|
|
||||||
{ id: "normal", label: "보통", value: 1.5 },
|
|
||||||
{ id: "relaxed", label: "넓게", value: 1.8 },
|
|
||||||
]}
|
|
||||||
onChangeValue={(v) => {
|
|
||||||
updateBlock(selectedBlockId, { lineHeightCustom: v.toString() } as any);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* 텍스트 색상 */}
|
|
||||||
<ColorPickerField
|
|
||||||
label="텍스트 색상"
|
|
||||||
ariaLabelColorInput="리스트 텍스트 색상 피커"
|
|
||||||
ariaLabelHexInput="리스트 텍스트 색상 HEX"
|
|
||||||
value={
|
|
||||||
listProps.textColorCustom && listProps.textColorCustom.trim() !== ""
|
|
||||||
? listProps.textColorCustom
|
|
||||||
: "#e5e7eb"
|
|
||||||
}
|
|
||||||
onChange={(hex) => {
|
|
||||||
updateBlock(selectedBlockId, { textColorCustom: hex } as any);
|
|
||||||
}}
|
|
||||||
palette={TEXT_COLOR_PALETTE}
|
|
||||||
onPaletteSelect={(item) => {
|
|
||||||
updateBlock(selectedBlockId, { textColorCustom: item.color } as any);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<ColorPickerField
|
|
||||||
label="블록 배경색"
|
|
||||||
ariaLabelColorInput="리스트 배경색 피커"
|
|
||||||
ariaLabelHexInput="리스트 배경색 HEX"
|
|
||||||
value={listProps.backgroundColorCustom ?? ""}
|
|
||||||
onChange={(hex) => {
|
|
||||||
updateBlock(selectedBlockId, { backgroundColorCustom: hex } as any);
|
|
||||||
}}
|
|
||||||
palette={TEXT_COLOR_PALETTE}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* 불릿 스타일 (● / ○ / ■ / 숫자형 / 없음) */}
|
|
||||||
<label className="flex flex-col gap-1">
|
|
||||||
<span>불릿 스타일</span>
|
|
||||||
<select
|
|
||||||
className="rounded border border-slate-700 bg-slate-900 px-2 py-1 text-xs outline-none focus:border-sky-500"
|
|
||||||
aria-label="리스트 불릿 스타일"
|
|
||||||
value={listProps.bulletStyle ?? "disc"}
|
|
||||||
onChange={(e) => {
|
|
||||||
const value = e.target.value as NonNullable<ListBlockProps["bulletStyle"]>;
|
|
||||||
|
|
||||||
// 순서형 스타일(decimal / alpha / roman)은 ordered=true, 나머지는 false 로 설정한다.
|
|
||||||
const orderedStyles: Array<NonNullable<ListBlockProps["bulletStyle"]>> = [
|
|
||||||
"decimal",
|
|
||||||
"lower-alpha",
|
|
||||||
"upper-alpha",
|
|
||||||
"lower-roman",
|
|
||||||
"upper-roman",
|
|
||||||
];
|
|
||||||
const ordered = orderedStyles.includes(value);
|
|
||||||
|
|
||||||
updateBlock(selectedBlockId, { bulletStyle: value, ordered } as any);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<option value="disc">기본 (●)</option>
|
|
||||||
<option value="circle">원 (○)</option>
|
|
||||||
<option value="square">사각 (■)</option>
|
|
||||||
<option value="decimal">숫자 (1.)</option>
|
|
||||||
<option value="lower-alpha">알파벳 소문자 (a.)</option>
|
|
||||||
<option value="upper-alpha">알파벳 대문자 (A.)</option>
|
|
||||||
<option value="lower-roman">로마 숫자 소문자 (i.)</option>
|
|
||||||
<option value="upper-roman">로마 숫자 대문자 (I.)</option>
|
|
||||||
<option value="none">없음</option>
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
|
|
||||||
{/* 아이템 간 여백 (슬라이더) */}
|
|
||||||
<NumericPropertyControl
|
|
||||||
label="아이템 간 여백 (px)"
|
|
||||||
unitLabel="(px)"
|
|
||||||
value={(() => {
|
|
||||||
if (typeof listProps.gapYPx === "number") {
|
|
||||||
return listProps.gapYPx;
|
|
||||||
}
|
|
||||||
const token = listProps.gapY ?? "md";
|
|
||||||
return token === "sm" ? 4 : token === "lg" ? 16 : 8;
|
|
||||||
})()}
|
|
||||||
min={0}
|
|
||||||
max={40}
|
|
||||||
step={2}
|
|
||||||
presets={[
|
|
||||||
{ id: "tight", label: "좁게", value: 4 },
|
|
||||||
{ id: "normal", label: "보통", value: 8 },
|
|
||||||
{ id: "relaxed", label: "넓게", value: 16 },
|
|
||||||
]}
|
|
||||||
onChangeValue={(v) => {
|
|
||||||
updateBlock(selectedBlockId, { gapYPx: v } as any);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,193 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { useEditorStore } from "@/features/editor/state/editorStore";
|
|
||||||
import type { CanvasPreset, ProjectConfig } from "@/features/editor/state/editorStore";
|
|
||||||
import { NumericPropertyControl } from "@/features/editor/components/NumericPropertyControl";
|
|
||||||
import { ColorPickerField, TEXT_COLOR_PALETTE } from "@/features/editor/components/ColorPickerField";
|
|
||||||
|
|
||||||
export function ProjectPropertiesPanel() {
|
|
||||||
const projectConfig = useEditorStore((state) => (state as any).projectConfig as ProjectConfig);
|
|
||||||
const updateProjectConfig = useEditorStore(
|
|
||||||
(state) => (state as any).updateProjectConfig as (partial: Partial<ProjectConfig>) => void,
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleChangePreset = (preset: CanvasPreset) => {
|
|
||||||
if (preset === "mobile") {
|
|
||||||
updateProjectConfig({ canvasPreset: preset, canvasWidthPx: 390 });
|
|
||||||
} else if (preset === "tablet") {
|
|
||||||
updateProjectConfig({ canvasPreset: preset, canvasWidthPx: 768 });
|
|
||||||
} else if (preset === "desktop") {
|
|
||||||
updateProjectConfig({ canvasPreset: preset, canvasWidthPx: 1200 });
|
|
||||||
} else if (preset === "full") {
|
|
||||||
updateProjectConfig({ canvasPreset: preset, canvasWidthPx: undefined });
|
|
||||||
} else {
|
|
||||||
updateProjectConfig({ canvasPreset: preset });
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleChangeCanvasWidth = (value: number) => {
|
|
||||||
if (value === 390) {
|
|
||||||
handleChangePreset("mobile");
|
|
||||||
} else if (value === 768) {
|
|
||||||
handleChangePreset("tablet");
|
|
||||||
} else if (value === 1200) {
|
|
||||||
handleChangePreset("desktop");
|
|
||||||
} else {
|
|
||||||
updateProjectConfig({ canvasPreset: "custom", canvasWidthPx: value });
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-4 text-xs text-slate-200">
|
|
||||||
<h3 className="text-sm font-medium text-slate-100">프로젝트 설정</h3>
|
|
||||||
|
|
||||||
<div className="space-y-1">
|
|
||||||
<label className="flex flex-col gap-1">
|
|
||||||
<span className="text-slate-400">프로젝트 제목</span>
|
|
||||||
<input
|
|
||||||
className="w-full rounded border border-slate-700 bg-slate-900 px-2 py-1 text-xs outline-none focus:border-sky-500"
|
|
||||||
aria-label="프로젝트 제목"
|
|
||||||
value={projectConfig.title}
|
|
||||||
onChange={(e) => updateProjectConfig({ title: e.target.value })}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-1">
|
|
||||||
<label className="flex flex-col gap-1">
|
|
||||||
<span className="text-slate-400">프로젝트 주소 (slug)</span>
|
|
||||||
<input
|
|
||||||
className="w-full rounded border border-slate-700 bg-slate-900 px-2 py-1 text-xs outline-none focus:border-sky-500"
|
|
||||||
aria-label="프로젝트 주소 (slug)"
|
|
||||||
value={projectConfig.slug}
|
|
||||||
onChange={(e) => updateProjectConfig({ slug: e.target.value })}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-1">
|
|
||||||
<NumericPropertyControl
|
|
||||||
label="캔버스 너비"
|
|
||||||
unitLabel="(px)"
|
|
||||||
value={projectConfig.canvasWidthPx ?? 1024}
|
|
||||||
min={320}
|
|
||||||
max={1920}
|
|
||||||
step={10}
|
|
||||||
presets={[
|
|
||||||
{ id: "mobile", label: "모바일 (390px)", value: 390 },
|
|
||||||
{ id: "tablet", label: "태블릿 (768px)", value: 768 },
|
|
||||||
{ id: "desktop", label: "데스크톱 (1200px)", value: 1200 },
|
|
||||||
]}
|
|
||||||
onChangeValue={handleChangeCanvasWidth}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-1">
|
|
||||||
<ColorPickerField
|
|
||||||
label="캔버스 배경색"
|
|
||||||
ariaLabelColorInput="캔버스 배경색"
|
|
||||||
ariaLabelHexInput="캔버스 배경색 HEX"
|
|
||||||
value={projectConfig.canvasBgColorHex ?? "#020617"}
|
|
||||||
onChange={(hex) => updateProjectConfig({ canvasBgColorHex: hex })}
|
|
||||||
palette={TEXT_COLOR_PALETTE}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-1">
|
|
||||||
<ColorPickerField
|
|
||||||
label="페이지 배경색"
|
|
||||||
ariaLabelColorInput="페이지 배경색"
|
|
||||||
ariaLabelHexInput="페이지 배경색 HEX"
|
|
||||||
value={projectConfig.bodyBgColorHex ?? "#020617"}
|
|
||||||
onChange={(hex) => updateProjectConfig({ bodyBgColorHex: hex })}
|
|
||||||
palette={TEXT_COLOR_PALETTE}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2 border-t border-slate-800 pt-3">
|
|
||||||
<h4 className="text-[11px] font-semibold text-slate-200">SEO / 메타</h4>
|
|
||||||
|
|
||||||
<label className="flex flex-col gap-1">
|
|
||||||
<span className="text-slate-400">SEO 타이틀</span>
|
|
||||||
<input
|
|
||||||
className="w-full rounded border border-slate-700 bg-slate-900 px-2 py-1 text-xs outline-none focus:border-sky-500"
|
|
||||||
aria-label="SEO 타이틀"
|
|
||||||
placeholder={projectConfig.title || "페이지 제목"}
|
|
||||||
value={projectConfig.seoTitle ?? ""}
|
|
||||||
onChange={(e) => updateProjectConfig({ seoTitle: e.target.value })}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<label className="flex flex-col gap-1">
|
|
||||||
<span className="text-slate-400">메타 디스크립션</span>
|
|
||||||
<textarea
|
|
||||||
className="w-full rounded border border-slate-700 bg-slate-900 px-2 py-1 text-[11px] outline-none focus:border-sky-500 min-h-[56px]"
|
|
||||||
aria-label="메타 디스크립션"
|
|
||||||
placeholder="검색엔진 및 SNS 공유에 노출될 페이지 설명을 입력하세요."
|
|
||||||
value={projectConfig.seoDescription ?? ""}
|
|
||||||
onChange={(e) => updateProjectConfig({ seoDescription: e.target.value })}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<label className="flex flex-col gap-1">
|
|
||||||
<span className="text-slate-400">OG/Twitter 이미지 URL</span>
|
|
||||||
<input
|
|
||||||
className="w-full rounded border border-slate-700 bg-slate-900 px-2 py-1 text-xs outline-none focus:border-sky-500"
|
|
||||||
aria-label="OG/Twitter 이미지 URL"
|
|
||||||
placeholder="예: https://example.com/og-image.png"
|
|
||||||
value={projectConfig.seoOgImageUrl ?? ""}
|
|
||||||
onChange={(e) => updateProjectConfig({ seoOgImageUrl: e.target.value })}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<label className="flex flex-col gap-1">
|
|
||||||
<span className="text-slate-400">Canonical URL</span>
|
|
||||||
<input
|
|
||||||
className="w-full rounded border border-slate-700 bg-slate-900 px-2 py-1 text-xs outline-none focus:border-sky-500"
|
|
||||||
aria-label="Canonical URL"
|
|
||||||
placeholder="예: https://example.com/landing"
|
|
||||||
value={projectConfig.seoCanonicalUrl ?? ""}
|
|
||||||
onChange={(e) => updateProjectConfig({ seoCanonicalUrl: e.target.value })}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<label className="flex items-center gap-2 text-[11px] text-slate-300">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
className="h-3 w-3 rounded border-slate-600 bg-slate-900"
|
|
||||||
aria-label="검색 엔진에 노출하지 않기 (noindex)"
|
|
||||||
checked={Boolean(projectConfig.seoNoIndex)}
|
|
||||||
onChange={(e) => updateProjectConfig({ seoNoIndex: e.target.checked })}
|
|
||||||
/>
|
|
||||||
<span>검색 엔진에 노출하지 않기 (noindex)</span>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-1">
|
|
||||||
<label className="flex flex-col gap-1">
|
|
||||||
<span className="text-slate-400">페이지 head HTML</span>
|
|
||||||
<textarea
|
|
||||||
className="w-full rounded border border-slate-700 bg-slate-900 px-2 py-1 text-[11px] font-mono outline-none focus:border-sky-500 min-h-[72px]"
|
|
||||||
aria-label="페이지 head HTML"
|
|
||||||
value={projectConfig.headHtml ?? ""}
|
|
||||||
onChange={(e) => updateProjectConfig({ headHtml: e.target.value })}
|
|
||||||
placeholder="예: <meta name="description" content="..." />"
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-1">
|
|
||||||
<label className="flex flex-col gap-1">
|
|
||||||
<span className="text-slate-400">추적 스크립트</span>
|
|
||||||
<textarea
|
|
||||||
className="w-full rounded border border-slate-700 bg-slate-900 px-2 py-1 text-[11px] font-mono outline-none focus:border-sky-500 min-h-[72px]"
|
|
||||||
aria-label="추적 스크립트"
|
|
||||||
value={projectConfig.trackingScript ?? ""}
|
|
||||||
onChange={(e) => updateProjectConfig({ trackingScript: e.target.value })}
|
|
||||||
placeholder="예: <script>/* GA, Pixel 코드 */</script>"
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,898 +0,0 @@
|
|||||||
"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";
|
|
||||||
import { ListPropertiesPanel } from "./ListPropertiesPanel";
|
|
||||||
import { DividerPropertiesPanel } from "./DividerPropertiesPanel";
|
|
||||||
import { ImagePropertiesPanel } from "./ImagePropertiesPanel";
|
|
||||||
import { SectionPropertiesPanel } from "./SectionPropertiesPanel";
|
|
||||||
import { VideoPropertiesPanel } from "./VideoPropertiesPanel";
|
|
||||||
import { FormInputPropertiesPanel } from "../forms/FormInputPropertiesPanel";
|
|
||||||
import { FormSelectPropertiesPanel } from "../forms/FormSelectPropertiesPanel";
|
|
||||||
import { FormCheckboxPropertiesPanel } from "../forms/FormCheckboxPropertiesPanel";
|
|
||||||
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[];
|
|
||||||
selectedBlockId: string | null;
|
|
||||||
selectedListItemId: string | null;
|
|
||||||
updateBlock: (id: string, partial: any) => void;
|
|
||||||
removeBlock: (id: string) => void;
|
|
||||||
duplicateBlock: (id: string) => void;
|
|
||||||
// 텍스트 속성 패널에서 사용하는 인라인 편집 상태를 그대로 전달한다.
|
|
||||||
editingBlockId: string | null;
|
|
||||||
setEditingText: (value: string) => void;
|
|
||||||
onMoveSelectedItemUp: (blockId: string) => void;
|
|
||||||
onMoveSelectedItemDown: (blockId: string) => void;
|
|
||||||
onIndentSelectedItem: (blockId: string) => void;
|
|
||||||
onOutdentSelectedItem: (blockId: string) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 우측 속성 패널을 분리한 컴포넌트
|
|
||||||
export function PropertiesSidebar(props: PropertiesSidebarProps) {
|
|
||||||
const {
|
|
||||||
blocks,
|
|
||||||
selectedBlockId,
|
|
||||||
selectedListItemId,
|
|
||||||
updateBlock,
|
|
||||||
removeBlock,
|
|
||||||
duplicateBlock,
|
|
||||||
editingBlockId,
|
|
||||||
setEditingText,
|
|
||||||
onMoveSelectedItemUp,
|
|
||||||
onMoveSelectedItemDown,
|
|
||||||
onIndentSelectedItem,
|
|
||||||
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 bg-slate-950/40"
|
|
||||||
onKeyDownCapture={(e) => {
|
|
||||||
// 속성 패널 안에서 발생한 키 입력은 에디터 단축키/텍스트 블록 편집으로 전달되지 않도록 막는다.
|
|
||||||
e.stopPropagation();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<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]">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
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>
|
|
||||||
{(() => {
|
|
||||||
const selectedBlock = blocks.find((b) => b.id === selectedBlockId);
|
|
||||||
if (!selectedBlock) return null;
|
|
||||||
|
|
||||||
if (selectedBlock.type === "text") {
|
|
||||||
const textProps = selectedBlock.props as TextBlockProps;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<TextPropertiesPanel
|
|
||||||
textProps={textProps}
|
|
||||||
selectedBlockId={selectedBlockId}
|
|
||||||
updateBlock={(id, partial) => updateBlock(id, partial as any)}
|
|
||||||
editingBlockId={editingBlockId}
|
|
||||||
setEditingText={setEditingText}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (selectedBlock.type === "button") {
|
|
||||||
const buttonProps = selectedBlock.props as ButtonBlockProps;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ButtonPropertiesPanel
|
|
||||||
buttonProps={buttonProps}
|
|
||||||
selectedBlockId={selectedBlockId}
|
|
||||||
updateBlock={(id, partial) => updateBlock(id, partial as any)}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (selectedBlock.type === "list") {
|
|
||||||
const listProps = selectedBlock.props as ListBlockProps;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ListPropertiesPanel
|
|
||||||
listProps={listProps}
|
|
||||||
selectedBlockId={selectedBlockId}
|
|
||||||
selectedListItemId={selectedListItemId}
|
|
||||||
updateBlock={(id, partial) => updateBlock(id, partial as any)}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (selectedBlock.type === "divider") {
|
|
||||||
const dividerProps = selectedBlock.props as any;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<DividerPropertiesPanel
|
|
||||||
dividerProps={dividerProps}
|
|
||||||
selectedBlockId={selectedBlockId}
|
|
||||||
updateBlock={(id, partial) => updateBlock(id, partial as any)}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (selectedBlock.type === "image") {
|
|
||||||
const imageProps = selectedBlock.props as any;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ImagePropertiesPanel
|
|
||||||
imageProps={imageProps}
|
|
||||||
selectedBlockId={selectedBlockId}
|
|
||||||
updateBlock={(id, partial) => updateBlock(id, partial as any)}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (selectedBlock.type === "video") {
|
|
||||||
const videoProps = selectedBlock.props as VideoBlockProps;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<VideoPropertiesPanel
|
|
||||||
videoProps={videoProps}
|
|
||||||
selectedBlockId={selectedBlockId}
|
|
||||||
updateBlock={(id, partial) => updateBlock(id, partial as any)}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (selectedBlock.type === "section") {
|
|
||||||
const sectionProps = selectedBlock.props as SectionBlockProps;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<SectionPropertiesPanel
|
|
||||||
sectionProps={sectionProps}
|
|
||||||
selectedBlockId={selectedBlockId}
|
|
||||||
updateBlock={(id, partial) => updateBlock(id, partial as any)}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (selectedBlock.type === "formInput") {
|
|
||||||
return (
|
|
||||||
<FormInputPropertiesPanel
|
|
||||||
block={selectedBlock}
|
|
||||||
selectedBlockId={selectedBlockId}
|
|
||||||
updateBlock={(id, partial) => updateBlock(id, partial as any)}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (selectedBlock.type === "formSelect") {
|
|
||||||
return (
|
|
||||||
<FormSelectPropertiesPanel
|
|
||||||
block={selectedBlock}
|
|
||||||
selectedBlockId={selectedBlockId}
|
|
||||||
updateBlock={(id, partial) => updateBlock(id, partial as any)}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (selectedBlock.type === "formCheckbox") {
|
|
||||||
return (
|
|
||||||
<FormCheckboxPropertiesPanel
|
|
||||||
block={selectedBlock}
|
|
||||||
selectedBlockId={selectedBlockId}
|
|
||||||
updateBlock={(id, partial) => updateBlock(id, partial as any)}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (selectedBlock.type === "formRadio") {
|
|
||||||
return (
|
|
||||||
<FormRadioPropertiesPanel
|
|
||||||
block={selectedBlock}
|
|
||||||
selectedBlockId={selectedBlockId}
|
|
||||||
updateBlock={(id, partial) => updateBlock(id, partial as any)}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (selectedBlock.type === "form") {
|
|
||||||
return (
|
|
||||||
<FormControllerPanel
|
|
||||||
block={selectedBlock}
|
|
||||||
blocks={blocks}
|
|
||||||
selectedBlockId={selectedBlockId}
|
|
||||||
updateBlock={(id, partial) => updateBlock(id, partial as any)}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
})()}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,744 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import type { SectionBlockProps } from "@/features/editor/state/editorStore";
|
|
||||||
import { ColorPickerField, TEXT_COLOR_PALETTE } from "@/features/editor/components/ColorPickerField";
|
|
||||||
import { NumericPropertyControl } from "@/features/editor/components/NumericPropertyControl";
|
|
||||||
|
|
||||||
export type SectionPropertiesPanelProps = {
|
|
||||||
sectionProps: SectionBlockProps;
|
|
||||||
selectedBlockId: string;
|
|
||||||
updateBlock: (id: string, partial: Partial<SectionBlockProps>) => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
export function SectionPropertiesPanel({ sectionProps, selectedBlockId, updateBlock }: SectionPropertiesPanelProps) {
|
|
||||||
const backgroundSource: "none" | "url" | "upload" = (() => {
|
|
||||||
const src = sectionProps.backgroundImageSrc?.trim();
|
|
||||||
const type = sectionProps.backgroundImageSourceType;
|
|
||||||
|
|
||||||
if (type === "asset") return "upload";
|
|
||||||
if (type === "externalUrl") return "url";
|
|
||||||
|
|
||||||
if (src && src.startsWith("/api/image/")) return "upload";
|
|
||||||
if (src) return "url";
|
|
||||||
|
|
||||||
return "none";
|
|
||||||
})();
|
|
||||||
|
|
||||||
const positionMode: "preset" | "custom" = sectionProps.backgroundImagePositionMode ?? "preset";
|
|
||||||
|
|
||||||
const backgroundVideoSource: "none" | "url" | "upload" = (() => {
|
|
||||||
const src = sectionProps.backgroundVideoSrc?.trim();
|
|
||||||
const type = sectionProps.backgroundVideoSourceType;
|
|
||||||
|
|
||||||
if (type === "asset") return "upload";
|
|
||||||
if (type === "externalUrl") return "url";
|
|
||||||
|
|
||||||
if (src && src.startsWith("/api/video/")) return "upload";
|
|
||||||
if (src) return "url";
|
|
||||||
|
|
||||||
return "none";
|
|
||||||
})();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{/* 섹션 배경: 커스텀 색상 피커만 사용 */}
|
|
||||||
<div className="mt-1">
|
|
||||||
<ColorPickerField
|
|
||||||
label="배경 색상"
|
|
||||||
ariaLabelColorInput="섹션 배경 색상 선택"
|
|
||||||
ariaLabelHexInput="섹션 배경 색상 HEX 입력"
|
|
||||||
value={sectionProps.backgroundColorCustom ?? ""}
|
|
||||||
onChange={(hex) => {
|
|
||||||
const next = hex && hex.trim().length > 0 ? hex : undefined;
|
|
||||||
updateBlock(selectedBlockId, { backgroundColorCustom: next } as any);
|
|
||||||
}}
|
|
||||||
palette={TEXT_COLOR_PALETTE}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 섹션 배경 이미지 */}
|
|
||||||
<div className="mt-3 space-y-2 text-xs text-slate-400">
|
|
||||||
<label className="flex flex-col gap-1">
|
|
||||||
<span>배경 이미지 소스</span>
|
|
||||||
<select
|
|
||||||
className="w-full rounded border border-slate-700 bg-slate-900 px-2 py-1 text-[11px] outline-none focus:border-sky-500"
|
|
||||||
aria-label="배경 이미지 소스"
|
|
||||||
value={backgroundSource}
|
|
||||||
onChange={(e) => {
|
|
||||||
const next = e.target.value as "none" | "url" | "upload";
|
|
||||||
if (next === "none") {
|
|
||||||
updateBlock(selectedBlockId, {
|
|
||||||
backgroundImageSrc: undefined,
|
|
||||||
backgroundImageSourceType: undefined,
|
|
||||||
backgroundImageAssetId: null,
|
|
||||||
} as any);
|
|
||||||
} else if (next === "url") {
|
|
||||||
updateBlock(selectedBlockId, {
|
|
||||||
backgroundImageSourceType: "externalUrl",
|
|
||||||
backgroundImageAssetId: null,
|
|
||||||
} as any);
|
|
||||||
} else {
|
|
||||||
updateBlock(selectedBlockId, {
|
|
||||||
backgroundImageSourceType: "asset",
|
|
||||||
} as any);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<option value="none">없음</option>
|
|
||||||
<option value="url">URL</option>
|
|
||||||
<option value="upload">파일 업로드</option>
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
|
|
||||||
{backgroundSource === "url" && (
|
|
||||||
<label className="flex flex-col gap-1">
|
|
||||||
<span>배경 이미지 URL</span>
|
|
||||||
<input
|
|
||||||
className="w-full rounded border border-slate-700 bg-slate-900 px-2 py-1 text-xs outline-none focus:border-sky-500"
|
|
||||||
aria-label="배경 이미지 URL"
|
|
||||||
value={sectionProps.backgroundImageSrc ?? ""}
|
|
||||||
onChange={(e) => {
|
|
||||||
const value = e.target.value;
|
|
||||||
updateBlock(selectedBlockId, {
|
|
||||||
backgroundImageSrc: value,
|
|
||||||
backgroundImageSourceType: "externalUrl",
|
|
||||||
backgroundImageAssetId: null,
|
|
||||||
} as any);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{backgroundSource === "upload" && (
|
|
||||||
<label className="flex flex-col gap-1">
|
|
||||||
<span>배경 이미지 파일 업로드</span>
|
|
||||||
<input
|
|
||||||
type="file"
|
|
||||||
accept="image/*"
|
|
||||||
className="w-full text-[11px] text-slate-300 file:mr-2 file:rounded file:border file:border-slate-700 file:bg-slate-800 file:px-2 file:py-1 file:text-[11px] file:text-slate-100 hover:file:bg-slate-700"
|
|
||||||
aria-label="배경 이미지 파일 업로드"
|
|
||||||
onChange={async (event) => {
|
|
||||||
const file = event.target.files?.[0];
|
|
||||||
if (!file) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const formData = new FormData();
|
|
||||||
formData.append("file", file);
|
|
||||||
|
|
||||||
const response = await fetch("/api/image", {
|
|
||||||
method: "POST",
|
|
||||||
body: formData,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
console.error("배경 이미지 업로드 실패", await response.text());
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = (await response.json()) as { id: string; servedUrl?: string | null };
|
|
||||||
const servedUrl = data.servedUrl ?? `/api/image/${data.id}`;
|
|
||||||
|
|
||||||
updateBlock(selectedBlockId, {
|
|
||||||
backgroundImageSrc: servedUrl,
|
|
||||||
backgroundImageSourceType: "asset",
|
|
||||||
backgroundImageAssetId: data.id,
|
|
||||||
} as any);
|
|
||||||
} catch (error) {
|
|
||||||
console.error("배경 이미지 업로드 중 오류", error);
|
|
||||||
} finally {
|
|
||||||
event.target.value = "";
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
)}
|
|
||||||
{backgroundSource !== "none" && (
|
|
||||||
<>
|
|
||||||
<label className="flex flex-col gap-1">
|
|
||||||
<span>배경 이미지 위치 모드</span>
|
|
||||||
<select
|
|
||||||
className="w-full rounded border border-slate-700 bg-slate-900 px-2 py-1 text-[11px] outline-none focus:border-sky-500"
|
|
||||||
aria-label="배경 이미지 위치 모드"
|
|
||||||
value={positionMode}
|
|
||||||
onChange={(e) =>
|
|
||||||
updateBlock(selectedBlockId, {
|
|
||||||
backgroundImagePositionMode: e.target.value as SectionBlockProps["backgroundImagePositionMode"],
|
|
||||||
} as any)
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<option value="preset">프리셋</option>
|
|
||||||
<option value="custom">커스텀 (X/Y)</option>
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<label className="flex flex-col gap-1">
|
|
||||||
<span>배경 이미지 크기</span>
|
|
||||||
<select
|
|
||||||
className="w-full rounded border border-slate-700 bg-slate-900 px-2 py-1 text-[11px] outline-none focus:border-sky-500"
|
|
||||||
aria-label="배경 이미지 크기"
|
|
||||||
value={sectionProps.backgroundImageSize ?? "cover"}
|
|
||||||
onChange={(e) =>
|
|
||||||
updateBlock(selectedBlockId, {
|
|
||||||
backgroundImageSize: e.target.value as SectionBlockProps["backgroundImageSize"],
|
|
||||||
} as any)
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<option value="cover">cover</option>
|
|
||||||
<option value="contain">contain</option>
|
|
||||||
<option value="auto">auto</option>
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
|
|
||||||
{positionMode === "preset" && (
|
|
||||||
<label className="flex flex-col gap-1">
|
|
||||||
<span>배경 이미지 위치</span>
|
|
||||||
<select
|
|
||||||
className="w-full rounded border border-slate-700 bg-slate-900 px-2 py-1 text-[11px] outline-none focus:border-sky-500"
|
|
||||||
aria-label="배경 이미지 위치"
|
|
||||||
value={sectionProps.backgroundImagePosition ?? "center"}
|
|
||||||
onChange={(e) =>
|
|
||||||
updateBlock(selectedBlockId, {
|
|
||||||
backgroundImagePosition: e.target.value as SectionBlockProps["backgroundImagePosition"],
|
|
||||||
} as any)
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<option value="center">가운데</option>
|
|
||||||
<option value="top">위</option>
|
|
||||||
<option value="bottom">아래</option>
|
|
||||||
<option value="left">왼쪽</option>
|
|
||||||
<option value="right">오른쪽</option>
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{positionMode === "custom" && (
|
|
||||||
<>
|
|
||||||
<NumericPropertyControl
|
|
||||||
label="배경 이미지 가로 위치"
|
|
||||||
unitLabel="(%)"
|
|
||||||
value={
|
|
||||||
typeof sectionProps.backgroundImagePositionXPercent === "number"
|
|
||||||
? sectionProps.backgroundImagePositionXPercent
|
|
||||||
: 50
|
|
||||||
}
|
|
||||||
min={0}
|
|
||||||
max={100}
|
|
||||||
step={1}
|
|
||||||
presets={[
|
|
||||||
{ id: "left", label: "왼쪽", value: 0 },
|
|
||||||
{ id: "center", label: "가운데", value: 50 },
|
|
||||||
{ id: "right", label: "오른쪽", value: 100 },
|
|
||||||
]}
|
|
||||||
onChangeValue={(next) =>
|
|
||||||
updateBlock(selectedBlockId, {
|
|
||||||
backgroundImagePositionMode: "custom",
|
|
||||||
backgroundImagePositionXPercent: next,
|
|
||||||
} as any)
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<NumericPropertyControl
|
|
||||||
label="배경 이미지 세로 위치"
|
|
||||||
unitLabel="(%)"
|
|
||||||
value={
|
|
||||||
typeof sectionProps.backgroundImagePositionYPercent === "number"
|
|
||||||
? sectionProps.backgroundImagePositionYPercent
|
|
||||||
: 50
|
|
||||||
}
|
|
||||||
min={0}
|
|
||||||
max={100}
|
|
||||||
step={1}
|
|
||||||
presets={[
|
|
||||||
{ id: "top", label: "위", value: 0 },
|
|
||||||
{ id: "center", label: "가운데", value: 50 },
|
|
||||||
{ id: "bottom", label: "아래", value: 100 },
|
|
||||||
]}
|
|
||||||
onChangeValue={(next) =>
|
|
||||||
updateBlock(selectedBlockId, {
|
|
||||||
backgroundImagePositionMode: "custom",
|
|
||||||
backgroundImagePositionYPercent: next,
|
|
||||||
} as any)
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<label className="flex flex-col gap-1">
|
|
||||||
<span>배경 이미지 반복</span>
|
|
||||||
<select
|
|
||||||
className="w-full rounded border border-slate-700 bg-slate-900 px-2 py-1 text-[11px] outline-none focus:border-sky-500"
|
|
||||||
aria-label="배경 이미지 반복"
|
|
||||||
value={sectionProps.backgroundImageRepeat ?? "no-repeat"}
|
|
||||||
onChange={(e) =>
|
|
||||||
updateBlock(selectedBlockId, {
|
|
||||||
backgroundImageRepeat: e.target.value as SectionBlockProps["backgroundImageRepeat"],
|
|
||||||
} as any)
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<option value="no-repeat">반복 없음</option>
|
|
||||||
<option value="repeat">가로/세로 반복</option>
|
|
||||||
<option value="repeat-x">가로 반복</option>
|
|
||||||
<option value="repeat-y">세로 반복</option>
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 섹션 배경 비디오 */}
|
|
||||||
<div className="mt-3 space-y-2 text-xs text-slate-400">
|
|
||||||
<label className="flex flex-col gap-1">
|
|
||||||
<span>배경 비디오 소스</span>
|
|
||||||
<select
|
|
||||||
className="w-full rounded border border-slate-700 bg-slate-900 px-2 py-1 text-[11px] outline-none focus:border-sky-500"
|
|
||||||
aria-label="배경 비디오 소스"
|
|
||||||
value={backgroundVideoSource}
|
|
||||||
onChange={(e) => {
|
|
||||||
const next = e.target.value as "none" | "url" | "upload";
|
|
||||||
if (next === "none") {
|
|
||||||
updateBlock(selectedBlockId, {
|
|
||||||
backgroundVideoSrc: undefined,
|
|
||||||
backgroundVideoSourceType: undefined,
|
|
||||||
backgroundVideoAssetId: null,
|
|
||||||
} as any);
|
|
||||||
} else if (next === "url") {
|
|
||||||
updateBlock(selectedBlockId, {
|
|
||||||
backgroundVideoSourceType: "externalUrl",
|
|
||||||
backgroundVideoAssetId: null,
|
|
||||||
} as any);
|
|
||||||
} else {
|
|
||||||
updateBlock(selectedBlockId, {
|
|
||||||
backgroundVideoSourceType: "asset",
|
|
||||||
} as any);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<option value="none">없음</option>
|
|
||||||
<option value="url">URL</option>
|
|
||||||
<option value="upload">파일 업로드</option>
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
|
|
||||||
{backgroundVideoSource === "url" && (
|
|
||||||
<label className="flex flex-col gap-1">
|
|
||||||
<span>배경 비디오 URL</span>
|
|
||||||
<input
|
|
||||||
className="w-full rounded border border-slate-700 bg-slate-900 px-2 py-1 text-xs outline-none focus:border-sky-500"
|
|
||||||
aria-label="배경 비디오 URL"
|
|
||||||
value={sectionProps.backgroundVideoSrc ?? ""}
|
|
||||||
onChange={(e) => {
|
|
||||||
const value = e.target.value;
|
|
||||||
updateBlock(selectedBlockId, {
|
|
||||||
backgroundVideoSrc: value,
|
|
||||||
backgroundVideoSourceType: "externalUrl",
|
|
||||||
backgroundVideoAssetId: null,
|
|
||||||
} as any);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{backgroundVideoSource === "upload" && (
|
|
||||||
<label className="flex flex-col gap-1">
|
|
||||||
<span>배경 비디오 파일 업로드</span>
|
|
||||||
<input
|
|
||||||
type="file"
|
|
||||||
accept="video/*"
|
|
||||||
className="w-full text-[11px] text-slate-300 file:mr-2 file:rounded file:border file:border-slate-700 file:bg-slate-800 file:px-2 file:py-1 file:text-[11px] file:text-slate-100 hover:file:bg-slate-700"
|
|
||||||
aria-label="배경 비디오 파일 업로드"
|
|
||||||
onChange={async (event) => {
|
|
||||||
const file = event.target.files?.[0];
|
|
||||||
if (!file) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const formData = new FormData();
|
|
||||||
formData.append("file", file);
|
|
||||||
|
|
||||||
const response = await fetch("/api/video", {
|
|
||||||
method: "POST",
|
|
||||||
body: formData,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
console.error("배경 비디오 업로드 실패", await response.text());
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = (await response.json()) as { id: string; servedUrl?: string | null };
|
|
||||||
const servedUrl = data.servedUrl ?? `/api/video/${data.id}`;
|
|
||||||
|
|
||||||
updateBlock(selectedBlockId, {
|
|
||||||
backgroundVideoSrc: servedUrl,
|
|
||||||
backgroundVideoSourceType: "asset",
|
|
||||||
backgroundVideoAssetId: data.id,
|
|
||||||
} as any);
|
|
||||||
} catch (error) {
|
|
||||||
console.error("배경 비디오 업로드 중 오류", error);
|
|
||||||
} finally {
|
|
||||||
event.target.value = "";
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 세로 패딩 슬라이더 (px) - 프리셋 + 자유 슬라이더 */}
|
|
||||||
<div className="mt-3 space-y-1">
|
|
||||||
<NumericPropertyControl
|
|
||||||
label="세로 패딩"
|
|
||||||
unitLabel="(px)"
|
|
||||||
value={typeof sectionProps.paddingYPx === "number" ? sectionProps.paddingYPx : 48}
|
|
||||||
min={16}
|
|
||||||
max={80}
|
|
||||||
step={2}
|
|
||||||
presets={[
|
|
||||||
{ id: "x-tight", label: "매우 좁게", value: 16 },
|
|
||||||
{ id: "tight", label: "좁게", value: 32 },
|
|
||||||
{ id: "normal", label: "보통", value: 48 },
|
|
||||||
{ id: "relaxed", label: "넓게", value: 64 },
|
|
||||||
{ id: "x-relaxed", label: "아주 넓게", value: 80 },
|
|
||||||
]}
|
|
||||||
onChangeValue={(next) => {
|
|
||||||
const safe = Number.isFinite(next) ? next : 48;
|
|
||||||
updateBlock(selectedBlockId, { paddingYPx: safe } as any);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-3 space-y-2 border-t border-slate-800 pt-3 text-xs text-slate-400">
|
|
||||||
<h4 className="text-[11px] font-semibold text-slate-200">섹션 레이아웃</h4>
|
|
||||||
|
|
||||||
{/* 컬럼 레이아웃 프리셋 */}
|
|
||||||
<label className="flex flex-col gap-1">
|
|
||||||
<span>섹션 컬럼 레이아웃</span>
|
|
||||||
<select
|
|
||||||
className="rounded border border-slate-700 bg-slate-900 px-2 py-1 text-xs outline-none focus:border-sky-500"
|
|
||||||
aria-label="섹션 컬럼 레이아웃"
|
|
||||||
value={(() => {
|
|
||||||
const cols = sectionProps.columns ?? [];
|
|
||||||
const spans = cols.map((c) => c.span).join("-");
|
|
||||||
if (spans === "12") return "one-full";
|
|
||||||
if (spans === "6-6") return "two-equal";
|
|
||||||
if (spans === "4-8") return "two-1-2";
|
|
||||||
if (spans === "8-4") return "two-2-1";
|
|
||||||
if (spans === "4-4-4") return "three-equal";
|
|
||||||
return "custom";
|
|
||||||
})()}
|
|
||||||
onChange={(e) => {
|
|
||||||
const v = e.target.value as
|
|
||||||
| "one-full"
|
|
||||||
| "two-equal"
|
|
||||||
| "two-1-2"
|
|
||||||
| "two-2-1"
|
|
||||||
| "three-equal"
|
|
||||||
| "custom";
|
|
||||||
|
|
||||||
if (v === "custom") return;
|
|
||||||
const current = sectionProps.columns ?? [];
|
|
||||||
|
|
||||||
const ensureId = (index: number) => {
|
|
||||||
if (current[index]?.id) return current[index].id;
|
|
||||||
return `${selectedBlockId}_col_${index + 1}`;
|
|
||||||
};
|
|
||||||
|
|
||||||
const nextColumns =
|
|
||||||
v === "one-full"
|
|
||||||
? [
|
|
||||||
{ id: ensureId(0), span: 12 },
|
|
||||||
]
|
|
||||||
: v === "two-equal"
|
|
||||||
? [
|
|
||||||
{ id: ensureId(0), span: 6 },
|
|
||||||
{ id: ensureId(1), span: 6 },
|
|
||||||
]
|
|
||||||
: v === "two-1-2"
|
|
||||||
? [
|
|
||||||
{ id: ensureId(0), span: 4 },
|
|
||||||
{ id: ensureId(1), span: 8 },
|
|
||||||
]
|
|
||||||
: v === "two-2-1"
|
|
||||||
? [
|
|
||||||
{ id: ensureId(0), span: 8 },
|
|
||||||
{ id: ensureId(1), span: 4 },
|
|
||||||
]
|
|
||||||
: [
|
|
||||||
{ id: ensureId(0), span: 4 },
|
|
||||||
{ id: ensureId(1), span: 4 },
|
|
||||||
{ id: ensureId(2), span: 4 },
|
|
||||||
];
|
|
||||||
|
|
||||||
updateBlock(selectedBlockId, { columns: nextColumns } as any);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<option value="one-full">1열 (1/1)</option>
|
|
||||||
<option value="two-equal">2열 (1/2 - 1/2)</option>
|
|
||||||
<option value="two-1-2">2열 (1/3 - 2/3)</option>
|
|
||||||
<option value="two-2-1">2열 (2/3 - 1/3)</option>
|
|
||||||
<option value="three-equal">3열 (1/3 - 1/3 - 1/3)</option>
|
|
||||||
<option value="custom">직접 조정</option>
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
|
|
||||||
{/* 컬럼 개수 직접 조정 (1~5열) */}
|
|
||||||
{(() => {
|
|
||||||
const current = sectionProps.columns ?? [];
|
|
||||||
const count = current.length || 1;
|
|
||||||
|
|
||||||
const handleChangeCount = (nextCount: number) => {
|
|
||||||
const clamped = Math.max(1, Math.min(5, Math.floor(nextCount)));
|
|
||||||
if (clamped === count) return;
|
|
||||||
|
|
||||||
const base = Math.floor(12 / clamped) || 1;
|
|
||||||
const rest = 12 - base * clamped;
|
|
||||||
|
|
||||||
const nextColumns = Array.from({ length: clamped }).map((_, index) => {
|
|
||||||
const existing = current[index];
|
|
||||||
const id = existing?.id ?? `${selectedBlockId}_col_${index + 1}`;
|
|
||||||
const span = base + (index === clamped - 1 ? rest : 0);
|
|
||||||
return { id, span };
|
|
||||||
});
|
|
||||||
|
|
||||||
updateBlock(selectedBlockId, { columns: nextColumns } as any);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="mt-2 flex items-center justify-between text-xs text-slate-300">
|
|
||||||
<span>{`컬럼 개수: ${count}열 (최대 5열)`}</span>
|
|
||||||
<div className="flex gap-1">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="rounded border border-slate-700 px-2 py-0.5 text-[11px] disabled:opacity-40"
|
|
||||||
onClick={() => handleChangeCount(count - 1)}
|
|
||||||
disabled={count <= 1}
|
|
||||||
>
|
|
||||||
- 열 제거
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="rounded border border-slate-700 px-2 py-0.5 text-[11px] disabled:opacity-40"
|
|
||||||
onClick={() => handleChangeCount(count + 1)}
|
|
||||||
disabled={count >= 5}
|
|
||||||
>
|
|
||||||
+ 열 추가
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})()}
|
|
||||||
|
|
||||||
{/* 컬럼별 span 조정
|
|
||||||
- 컬럼이 1개일 때: 12 고정, 편집 불가능
|
|
||||||
- 컬럼이 2개 이상일 때: 앞의 N-1개 컬럼만 직접 조정, 마지막 컬럼은 합이 항상 12가 되도록 자동 계산 */}
|
|
||||||
{(() => {
|
|
||||||
const columns = sectionProps.columns ?? [];
|
|
||||||
const colCount = columns.length || 1;
|
|
||||||
|
|
||||||
// 1컬럼인 경우: 12 고정 표시
|
|
||||||
if (colCount === 1) {
|
|
||||||
const only = columns[0] ?? { id: `${selectedBlockId}_col_1`, span: 12 };
|
|
||||||
return (
|
|
||||||
<label key={only.id} className="flex flex-col gap-1">
|
|
||||||
<span>{`1열 폭 (고정 12/12)`}</span>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<input
|
|
||||||
type="range"
|
|
||||||
min={12}
|
|
||||||
max={12}
|
|
||||||
step={1}
|
|
||||||
className="flex-1 opacity-60"
|
|
||||||
aria-label="1열 폭 슬라이더 (고정)"
|
|
||||||
value={12}
|
|
||||||
disabled
|
|
||||||
/>
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
min={12}
|
|
||||||
max={12}
|
|
||||||
className="w-16 rounded border border-slate-700 bg-slate-900 px-2 py-1 text-xs outline-none text-slate-400"
|
|
||||||
aria-label="1열 폭 (고정 12/12)"
|
|
||||||
value={12}
|
|
||||||
disabled
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</label>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const lastIndex = colCount - 1;
|
|
||||||
|
|
||||||
return columns.map((col, index) => {
|
|
||||||
const maxSpanForEach = Math.max(1, 12 - (colCount - 1));
|
|
||||||
|
|
||||||
// 마지막 컬럼: 자동 계산, 편집 불가
|
|
||||||
if (index === lastIndex) {
|
|
||||||
const nonLast = columns.slice(0, lastIndex);
|
|
||||||
const nonLastSum = nonLast.reduce(
|
|
||||||
(sum, c) => sum + (Number.isFinite(c.span) ? c.span : 0),
|
|
||||||
0,
|
|
||||||
);
|
|
||||||
let autoSpan = 12 - nonLastSum;
|
|
||||||
if (!Number.isFinite(autoSpan) || autoSpan < 1) autoSpan = 1;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<label key={col.id} className="flex flex-col gap-1">
|
|
||||||
<span>{`${index + 1}열 폭 (자동, 합계 12 유지)`}</span>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<input
|
|
||||||
type="range"
|
|
||||||
min={1}
|
|
||||||
max={maxSpanForEach}
|
|
||||||
step={1}
|
|
||||||
className="flex-1 opacity-60"
|
|
||||||
aria-label={`${index + 1}열 폭 슬라이더 (자동)`}
|
|
||||||
value={Number.isFinite(col.span) ? col.span : autoSpan}
|
|
||||||
disabled
|
|
||||||
/>
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
min={1}
|
|
||||||
max={maxSpanForEach}
|
|
||||||
className="w-16 rounded border border-slate-700 bg-slate-900 px-2 py-1 text-xs outline-none text-slate-400"
|
|
||||||
aria-label={`${index + 1}열 폭 (자동)`}
|
|
||||||
value={Number.isFinite(col.span) ? col.span : autoSpan}
|
|
||||||
disabled
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</label>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 앞의 N-1개 컬럼: 직접 조정하면 마지막 컬럼을 자동으로 보정해서 합계 12 유지
|
|
||||||
const otherNonLastSum = columns.reduce((sum, c, idx) => {
|
|
||||||
if (idx === index || idx === lastIndex) return sum;
|
|
||||||
return sum + (Number.isFinite(c.span) ? c.span : 0);
|
|
||||||
}, 0);
|
|
||||||
|
|
||||||
const maxForCurrent = Math.max(
|
|
||||||
1,
|
|
||||||
Math.min(maxSpanForEach, 11 - otherNonLastSum),
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleChange = (raw: number) => {
|
|
||||||
if (Number.isNaN(raw)) return;
|
|
||||||
const clampedCurrent = Math.min(
|
|
||||||
maxForCurrent,
|
|
||||||
Math.max(1, Math.floor(raw)),
|
|
||||||
);
|
|
||||||
|
|
||||||
const newNonLastTotal = otherNonLastSum + clampedCurrent;
|
|
||||||
let lastSpan = 12 - newNonLastTotal;
|
|
||||||
if (!Number.isFinite(lastSpan) || lastSpan < 1) {
|
|
||||||
lastSpan = 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
const nextColumns = columns.map((c, idx) => {
|
|
||||||
if (idx === index) {
|
|
||||||
return { ...c, span: clampedCurrent };
|
|
||||||
}
|
|
||||||
if (idx === lastIndex) {
|
|
||||||
return { ...c, span: lastSpan };
|
|
||||||
}
|
|
||||||
return c;
|
|
||||||
});
|
|
||||||
|
|
||||||
updateBlock(selectedBlockId, { columns: nextColumns } as any);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<label key={col.id} className="flex flex-col gap-1">
|
|
||||||
<span>{`${index + 1}열 폭 (1~${maxForCurrent})`}</span>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<input
|
|
||||||
type="range"
|
|
||||||
min={1}
|
|
||||||
max={maxForCurrent}
|
|
||||||
step={1}
|
|
||||||
className="flex-1"
|
|
||||||
aria-label={`${index + 1}열 폭 슬라이더`}
|
|
||||||
value={Number.isFinite(col.span) ? col.span : 12}
|
|
||||||
onChange={(e) => {
|
|
||||||
handleChange(Number(e.target.value));
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
min={1}
|
|
||||||
max={maxForCurrent}
|
|
||||||
className="w-16 rounded border border-slate-700 bg-slate-900 px-2 py-1 text-xs outline-none focus:border-sky-500"
|
|
||||||
aria-label={`${index + 1}열 폭 (1~${maxForCurrent})`}
|
|
||||||
value={Number.isFinite(col.span) ? col.span : 12}
|
|
||||||
onChange={(e) => {
|
|
||||||
handleChange(Number(e.target.value));
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</label>
|
|
||||||
);
|
|
||||||
});
|
|
||||||
})()}
|
|
||||||
|
|
||||||
{/* 최대 폭 슬라이더 (px) - 프리셋 + 자유 슬라이더 */}
|
|
||||||
<NumericPropertyControl
|
|
||||||
label="최대 폭 (px)"
|
|
||||||
unitLabel="(px)"
|
|
||||||
value={typeof sectionProps.maxWidthPx === "number" ? sectionProps.maxWidthPx : 960}
|
|
||||||
min={640}
|
|
||||||
max={1440}
|
|
||||||
step={16}
|
|
||||||
presets={[
|
|
||||||
{ id: "x-narrow", label: "매우 좁게", value: 640 },
|
|
||||||
{ id: "narrow", label: "좁게", value: 800 },
|
|
||||||
{ id: "normal", label: "보통", value: 960 },
|
|
||||||
{ id: "wide", label: "넓게", value: 1200 },
|
|
||||||
{ id: "x-wide", label: "아주 넓게", value: 1440 },
|
|
||||||
]}
|
|
||||||
onChangeValue={(next) => {
|
|
||||||
const safe = Number.isFinite(next) ? next : 960;
|
|
||||||
updateBlock(selectedBlockId, { maxWidthPx: safe } as any);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* 컬럼 간 간격 슬라이더 (px) - 프리셋 + 자유 슬라이더 */}
|
|
||||||
<NumericPropertyControl
|
|
||||||
label="컬럼 간 간격 (px)"
|
|
||||||
unitLabel="(px)"
|
|
||||||
value={typeof sectionProps.gapXPx === "number" ? sectionProps.gapXPx : 24}
|
|
||||||
min={0}
|
|
||||||
max={64}
|
|
||||||
step={2}
|
|
||||||
presets={[
|
|
||||||
{ id: "zero", label: "0", value: 0 },
|
|
||||||
{ id: "tight", label: "좁게", value: 16 },
|
|
||||||
{ id: "normal", label: "보통", value: 24 },
|
|
||||||
{ id: "relaxed", label: "넓게", value: 32 },
|
|
||||||
{ id: "x-relaxed", label: "아주 넓게", value: 48 },
|
|
||||||
{ id: "max", label: "최대", value: 64 },
|
|
||||||
]}
|
|
||||||
onChangeValue={(next) => {
|
|
||||||
const safe = Number.isFinite(next) ? next : 24;
|
|
||||||
updateBlock(selectedBlockId, { gapXPx: safe } as any);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* 세로 정렬 */}
|
|
||||||
<label className="flex flex-col gap-1">
|
|
||||||
<span>컬럼 세로 정렬</span>
|
|
||||||
<select
|
|
||||||
className="rounded border border-slate-700 bg-slate-900 px-2 py-1 text-xs outline-none focus:border-sky-500"
|
|
||||||
aria-label="섹션 컬럼 세로 정렬"
|
|
||||||
value={sectionProps.alignItems ?? "top"}
|
|
||||||
onChange={(e) => {
|
|
||||||
const value = e.target.value as NonNullable<SectionBlockProps["alignItems"]>;
|
|
||||||
updateBlock(selectedBlockId, { alignItems: value } as any);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<option value="top">위</option>
|
|
||||||
<option value="center">가운데</option>
|
|
||||||
<option value="bottom">아래</option>
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,520 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import type { TextBlockProps } from "@/features/editor/state/editorStore";
|
|
||||||
import { PropertySliderField } from "@/features/editor/components/PropertySliderField";
|
|
||||||
import { ColorPickerField, TEXT_COLOR_PALETTE } from "@/features/editor/components/ColorPickerField";
|
|
||||||
import { NumericPropertyControl } from "@/features/editor/components/NumericPropertyControl";
|
|
||||||
|
|
||||||
export type TextPropertiesPanelProps = {
|
|
||||||
textProps: TextBlockProps;
|
|
||||||
selectedBlockId: string;
|
|
||||||
updateBlock: (id: string, partial: Partial<TextBlockProps>) => void;
|
|
||||||
editingBlockId: string | null;
|
|
||||||
setEditingText: (value: string) => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
export function TextPropertiesPanel({
|
|
||||||
textProps,
|
|
||||||
selectedBlockId,
|
|
||||||
updateBlock,
|
|
||||||
editingBlockId,
|
|
||||||
setEditingText,
|
|
||||||
}: TextPropertiesPanelProps) {
|
|
||||||
const fontSizeMode = textProps.fontSizeMode ?? "scale";
|
|
||||||
const fallbackScale =
|
|
||||||
textProps.size === "sm" ? "sm" : textProps.size === "lg" ? "lg" : "base";
|
|
||||||
const fontSizeScale = textProps.fontSizeScale ?? fallbackScale;
|
|
||||||
const lineHeightMode = textProps.lineHeightMode ?? "scale";
|
|
||||||
const lineHeightScale = textProps.lineHeightScale ?? "normal";
|
|
||||||
const fontWeightMode = textProps.fontWeightMode ?? "scale";
|
|
||||||
const fontWeightScale = textProps.fontWeightScale ?? "normal";
|
|
||||||
const colorPalette = textProps.colorPalette ?? "default";
|
|
||||||
const maxWidthMode = textProps.maxWidthMode ?? "scale";
|
|
||||||
const maxWidthScale = textProps.maxWidthScale ?? "none";
|
|
||||||
|
|
||||||
const fontSizeScaleToPx: Record<NonNullable<TextBlockProps["fontSizeScale"]>, number> = {
|
|
||||||
xs: 12,
|
|
||||||
sm: 14,
|
|
||||||
base: 16,
|
|
||||||
lg: 18,
|
|
||||||
xl: 20,
|
|
||||||
"2xl": 24,
|
|
||||||
"3xl": 30,
|
|
||||||
};
|
|
||||||
|
|
||||||
const parsedFontSize = (() => {
|
|
||||||
const raw = textProps.fontSizeCustom ?? "";
|
|
||||||
const match = raw.match(/([0-9]+(?:\.[0-9]+)?)/);
|
|
||||||
if (match) return Number(match[1]);
|
|
||||||
return fontSizeScaleToPx[fontSizeScale];
|
|
||||||
})();
|
|
||||||
|
|
||||||
const parsedLineHeight = (() => {
|
|
||||||
const raw = textProps.lineHeightCustom ?? "";
|
|
||||||
const match = raw.match(/(-?[0-9]+(?:\.[0-9]+)?)/);
|
|
||||||
if (match) return Number(match[1]);
|
|
||||||
return 1.5;
|
|
||||||
})();
|
|
||||||
|
|
||||||
// 글자 간격: 내부 저장은 em 이지만, UI 에서는 px 단위로 보여준다.
|
|
||||||
const parsedLetterSpacingPx = (() => {
|
|
||||||
const raw = textProps.letterSpacingCustom ?? "";
|
|
||||||
const match = raw.match(/(-?[0-9]+(?:\.[0-9]+)?)/);
|
|
||||||
if (match) {
|
|
||||||
const em = Number(match[1]);
|
|
||||||
if (Number.isFinite(em)) {
|
|
||||||
return em * 16; // 1em = 16px 기준으로 환산
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return 0;
|
|
||||||
})();
|
|
||||||
|
|
||||||
const letterSpacingPreset = (() => {
|
|
||||||
const px = parsedLetterSpacingPx;
|
|
||||||
if (px <= -1) return "tighter" as const;
|
|
||||||
if (px < 0) return "tight" as const;
|
|
||||||
if (px < 1) return "normal" as const;
|
|
||||||
if (px < 3) return "wide" as const;
|
|
||||||
return "wider" as const;
|
|
||||||
})();
|
|
||||||
|
|
||||||
const parsedFontWeight = (() => {
|
|
||||||
const raw = textProps.fontWeightCustom ?? "";
|
|
||||||
const match = raw.match(/([0-9]{3})/);
|
|
||||||
if (match) return Number(match[1]);
|
|
||||||
switch (fontWeightScale) {
|
|
||||||
case "medium":
|
|
||||||
return 500;
|
|
||||||
case "semibold":
|
|
||||||
return 600;
|
|
||||||
case "bold":
|
|
||||||
return 700;
|
|
||||||
default:
|
|
||||||
return 400;
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<div className="space-y-1">
|
|
||||||
<p className="text-xs text-slate-400">선택한 텍스트 블록 내용</p>
|
|
||||||
<textarea
|
|
||||||
className="w-full min-h-[80px] rounded border border-slate-700 bg-slate-900 px-2 py-1 text-xs outline-none focus:border-sky-500"
|
|
||||||
aria-label="선택한 텍스트 블록 내용"
|
|
||||||
value={textProps.text}
|
|
||||||
onChange={(e) => {
|
|
||||||
const value = e.target.value;
|
|
||||||
updateBlock(selectedBlockId, { text: value });
|
|
||||||
if (editingBlockId === selectedBlockId) {
|
|
||||||
setEditingText(value);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-1">
|
|
||||||
<label className="flex flex-col gap-1 text-xs text-slate-400">
|
|
||||||
<span>정렬</span>
|
|
||||||
<select
|
|
||||||
className="rounded border border-slate-700 bg-slate-900 px-2 py-1 text-xs outline-none focus:border-sky-500"
|
|
||||||
aria-label="정렬"
|
|
||||||
value={textProps.align}
|
|
||||||
onChange={(e) => {
|
|
||||||
const value = e.target.value as "left" | "center" | "right";
|
|
||||||
updateBlock(selectedBlockId, { align: value });
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<option value="left">왼쪽</option>
|
|
||||||
<option value="center">가운데</option>
|
|
||||||
<option value="right">오른쪽</option>
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-1">
|
|
||||||
<div className="flex items-center justify-between text-[11px] text-slate-400">
|
|
||||||
<span>텍스트 스타일</span>
|
|
||||||
<div className="flex gap-1">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className={`px-2 py-0.5 rounded border text-[11px] ${
|
|
||||||
textProps.underline
|
|
||||||
? "border-sky-500 bg-sky-900/40 text-sky-100"
|
|
||||||
: "border-slate-700 bg-slate-900 text-slate-300"
|
|
||||||
}`}
|
|
||||||
onClick={() => {
|
|
||||||
updateBlock(selectedBlockId, {
|
|
||||||
underline: !textProps.underline,
|
|
||||||
} as any);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
밑줄
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className={`px-2 py-0.5 rounded border text-[11px] ${
|
|
||||||
textProps.strike
|
|
||||||
? "border-sky-500 bg-sky-900/40 text-sky-100"
|
|
||||||
: "border-slate-700 bg-slate-900 text-slate-300"
|
|
||||||
}`}
|
|
||||||
onClick={() => {
|
|
||||||
updateBlock(selectedBlockId, {
|
|
||||||
strike: !textProps.strike,
|
|
||||||
} as any);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
가운데줄
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className={`px-2 py-0.5 rounded border text-[11px] ${
|
|
||||||
textProps.italic
|
|
||||||
? "border-sky-500 bg-sky-900/40 text-sky-100"
|
|
||||||
: "border-slate-700 bg-slate-900 text-slate-300"
|
|
||||||
}`}
|
|
||||||
onClick={() => {
|
|
||||||
updateBlock(selectedBlockId, {
|
|
||||||
italic: !textProps.italic,
|
|
||||||
} as any);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
이탤릭
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-1">
|
|
||||||
<NumericPropertyControl
|
|
||||||
label="글자 크기"
|
|
||||||
unitLabel="(px)"
|
|
||||||
value={Number.isFinite(parsedFontSize) ? parsedFontSize : 16}
|
|
||||||
min={10}
|
|
||||||
max={72}
|
|
||||||
step={1}
|
|
||||||
presets={(
|
|
||||||
["xs", "sm", "base", "lg", "xl", "2xl", "3xl"] as NonNullable<
|
|
||||||
TextBlockProps["fontSizeScale"]
|
|
||||||
>[]
|
|
||||||
).map((scale) => ({
|
|
||||||
id: scale,
|
|
||||||
label:
|
|
||||||
scale === "xs"
|
|
||||||
? "XS"
|
|
||||||
: scale === "sm"
|
|
||||||
? "S"
|
|
||||||
: scale === "base"
|
|
||||||
? "M"
|
|
||||||
: scale === "lg"
|
|
||||||
? "L"
|
|
||||||
: scale === "xl"
|
|
||||||
? "XL"
|
|
||||||
: scale === "2xl"
|
|
||||||
? "2XL"
|
|
||||||
: "3XL",
|
|
||||||
value: fontSizeScaleToPx[scale],
|
|
||||||
}))}
|
|
||||||
onChangeValue={(px) => {
|
|
||||||
const raw = textProps.fontSizeCustom ?? "";
|
|
||||||
const suffixMatch = raw.match(/px|rem|em|%/);
|
|
||||||
const suffix = suffixMatch ? suffixMatch[0] : "px";
|
|
||||||
|
|
||||||
const entries = Object.entries(fontSizeScaleToPx) as [
|
|
||||||
NonNullable<TextBlockProps["fontSizeScale"]>,
|
|
||||||
number,
|
|
||||||
][];
|
|
||||||
const closest = entries.reduce(
|
|
||||||
(best, [scale, value]) => {
|
|
||||||
const dist = Math.abs(value - px);
|
|
||||||
if (dist < best.dist) return { scale, dist };
|
|
||||||
return best;
|
|
||||||
},
|
|
||||||
{ scale: fontSizeScale as NonNullable<TextBlockProps["fontSizeScale"]>, dist: Infinity },
|
|
||||||
).scale;
|
|
||||||
|
|
||||||
updateBlock(selectedBlockId, {
|
|
||||||
fontSizeScale: closest,
|
|
||||||
fontSizeCustom: `${px}${suffix}`,
|
|
||||||
fontSizeMode: "custom",
|
|
||||||
} as any);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-1">
|
|
||||||
<label className="flex flex-col gap-1 text-xs text-slate-400">
|
|
||||||
<span>글자 간격</span>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<select
|
|
||||||
className="w-32 rounded border border-slate-700 bg-slate-900 px-2 py-1 text-[11px] outline-none focus:border-sky-500"
|
|
||||||
aria-label="글자 간격 프리셋"
|
|
||||||
value={letterSpacingPreset}
|
|
||||||
onChange={(e) => {
|
|
||||||
const preset = e.target.value as
|
|
||||||
| "tighter"
|
|
||||||
| "tight"
|
|
||||||
| "normal"
|
|
||||||
| "wide"
|
|
||||||
| "wider";
|
|
||||||
|
|
||||||
const presetPxMap: Record<
|
|
||||||
"tighter" | "tight" | "normal" | "wide" | "wider",
|
|
||||||
number
|
|
||||||
> = {
|
|
||||||
tighter: -1.5,
|
|
||||||
tight: -0.5,
|
|
||||||
normal: 0,
|
|
||||||
wide: 1,
|
|
||||||
wider: 2,
|
|
||||||
};
|
|
||||||
|
|
||||||
const px = presetPxMap[preset];
|
|
||||||
const em = px / 16;
|
|
||||||
|
|
||||||
updateBlock(selectedBlockId, {
|
|
||||||
letterSpacingCustom: `${em}em`,
|
|
||||||
} as any);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<option value="tighter">아주 좁게</option>
|
|
||||||
<option value="tight">좁게</option>
|
|
||||||
<option value="normal">보통</option>
|
|
||||||
<option value="wide">넓게</option>
|
|
||||||
<option value="wider">아주 넓게</option>
|
|
||||||
</select>
|
|
||||||
|
|
||||||
<PropertySliderField
|
|
||||||
label=""
|
|
||||||
ariaLabelSlider="글자 간격 슬라이더"
|
|
||||||
ariaLabelInput="글자 간격 커스텀 (px)"
|
|
||||||
value={Number.isFinite(parsedLetterSpacingPx) ? parsedLetterSpacingPx : 0}
|
|
||||||
min={-2}
|
|
||||||
max={10}
|
|
||||||
step={0.1}
|
|
||||||
onChange={(px) => {
|
|
||||||
const em = px / 16;
|
|
||||||
updateBlock(selectedBlockId, {
|
|
||||||
letterSpacingCustom: `${em}em`,
|
|
||||||
} as any);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-1">
|
|
||||||
<NumericPropertyControl
|
|
||||||
label="줄 간격"
|
|
||||||
unitLabel=""
|
|
||||||
value={Number.isFinite(parsedLineHeight) ? parsedLineHeight : 1.5}
|
|
||||||
min={-1}
|
|
||||||
max={3}
|
|
||||||
step={0.05}
|
|
||||||
presets={[
|
|
||||||
{ id: "tight", label: "좁게", value: 1.25 },
|
|
||||||
{ id: "snug", label: "약간 좁게", value: 1.35 },
|
|
||||||
{ id: "normal", label: "보통", value: 1.5 },
|
|
||||||
{ id: "relaxed", label: "넓게", value: 1.7 },
|
|
||||||
{ id: "loose", label: "아주 넓게", value: 1.9 },
|
|
||||||
]}
|
|
||||||
onChangeValue={(v) => {
|
|
||||||
const preset: Record<NonNullable<TextBlockProps["lineHeightScale"]>, number> = {
|
|
||||||
tight: 1.25,
|
|
||||||
snug: 1.35,
|
|
||||||
normal: 1.5,
|
|
||||||
relaxed: 1.7,
|
|
||||||
loose: 1.9,
|
|
||||||
};
|
|
||||||
|
|
||||||
const entries = Object.entries(preset) as [
|
|
||||||
NonNullable<TextBlockProps["lineHeightScale"]>,
|
|
||||||
number,
|
|
||||||
][];
|
|
||||||
const closest = entries.reduce(
|
|
||||||
(best, [scale, value]) => {
|
|
||||||
const dist = Math.abs(value - v);
|
|
||||||
if (dist < best.dist) return { scale, dist };
|
|
||||||
return best;
|
|
||||||
},
|
|
||||||
{ scale: lineHeightScale as NonNullable<TextBlockProps["lineHeightScale"]>, dist: Infinity },
|
|
||||||
).scale;
|
|
||||||
|
|
||||||
updateBlock(selectedBlockId, {
|
|
||||||
lineHeightScale: closest,
|
|
||||||
lineHeightCustom: v.toString(),
|
|
||||||
lineHeightMode: "custom",
|
|
||||||
} as any);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-1">
|
|
||||||
<NumericPropertyControl
|
|
||||||
label="굵기"
|
|
||||||
value={Number.isFinite(parsedFontWeight) ? parsedFontWeight : 400}
|
|
||||||
min={100}
|
|
||||||
max={900}
|
|
||||||
step={100}
|
|
||||||
presets={(
|
|
||||||
["normal", "medium", "semibold", "bold"] as NonNullable<
|
|
||||||
TextBlockProps["fontWeightScale"]
|
|
||||||
>[]
|
|
||||||
).map((scale) => ({
|
|
||||||
id: scale,
|
|
||||||
label:
|
|
||||||
scale === "normal"
|
|
||||||
? "보통"
|
|
||||||
: scale === "medium"
|
|
||||||
? "중간"
|
|
||||||
: scale === "semibold"
|
|
||||||
? "세미볼드"
|
|
||||||
: "볼드",
|
|
||||||
value:
|
|
||||||
scale === "normal"
|
|
||||||
? 400
|
|
||||||
: scale === "medium"
|
|
||||||
? 500
|
|
||||||
: scale === "semibold"
|
|
||||||
? 600
|
|
||||||
: 700,
|
|
||||||
}))}
|
|
||||||
onChangeValue={(v) => {
|
|
||||||
const preset: Record<NonNullable<TextBlockProps["fontWeightScale"]>, number> = {
|
|
||||||
normal: 400,
|
|
||||||
medium: 500,
|
|
||||||
semibold: 600,
|
|
||||||
bold: 700,
|
|
||||||
};
|
|
||||||
|
|
||||||
const entries = Object.entries(preset) as [
|
|
||||||
NonNullable<TextBlockProps["fontWeightScale"]>,
|
|
||||||
number,
|
|
||||||
][];
|
|
||||||
const closest = entries.reduce(
|
|
||||||
(best, [scale, value]) => {
|
|
||||||
const dist = Math.abs(value - v);
|
|
||||||
if (dist < best.dist) return { scale, dist };
|
|
||||||
return best;
|
|
||||||
},
|
|
||||||
{ scale: fontWeightScale as NonNullable<TextBlockProps["fontWeightScale"]>, dist: Infinity },
|
|
||||||
).scale;
|
|
||||||
|
|
||||||
updateBlock(selectedBlockId, {
|
|
||||||
fontWeightScale: closest,
|
|
||||||
fontWeightCustom: v.toString(),
|
|
||||||
fontWeightMode: "custom",
|
|
||||||
} as any);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-1">
|
|
||||||
<ColorPickerField
|
|
||||||
label="텍스트 색상"
|
|
||||||
ariaLabelColorInput="텍스트 색상 피커"
|
|
||||||
ariaLabelHexInput="텍스트 색상 HEX"
|
|
||||||
value={
|
|
||||||
(textProps.colorCustom && textProps.colorCustom.startsWith("#")
|
|
||||||
? textProps.colorCustom
|
|
||||||
: TEXT_COLOR_PALETTE.find((p) => p.id === colorPalette)?.color ?? "#ffffff")
|
|
||||||
}
|
|
||||||
onChange={(hex) => {
|
|
||||||
updateBlock(selectedBlockId, {
|
|
||||||
colorCustom: hex,
|
|
||||||
colorMode: "custom",
|
|
||||||
} as any);
|
|
||||||
}}
|
|
||||||
palette={TEXT_COLOR_PALETTE}
|
|
||||||
selectedPaletteId={colorPalette}
|
|
||||||
onPaletteSelect={(item) => {
|
|
||||||
const paletteId = item.id as NonNullable<TextBlockProps["colorPalette"]>;
|
|
||||||
updateBlock(selectedBlockId, {
|
|
||||||
colorPalette: paletteId,
|
|
||||||
colorCustom: item.color,
|
|
||||||
colorMode: "custom",
|
|
||||||
} as any);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-1">
|
|
||||||
<ColorPickerField
|
|
||||||
label="블록 배경색"
|
|
||||||
ariaLabelColorInput="텍스트 블록 배경색 피커"
|
|
||||||
ariaLabelHexInput="텍스트 블록 배경색 HEX"
|
|
||||||
value={textProps.backgroundColorCustom ?? ""}
|
|
||||||
onChange={(hex) => {
|
|
||||||
updateBlock(selectedBlockId, {
|
|
||||||
backgroundColorCustom: hex,
|
|
||||||
} as any);
|
|
||||||
}}
|
|
||||||
palette={TEXT_COLOR_PALETTE}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-1">
|
|
||||||
<label className="flex flex-col gap-1 text-xs text-slate-400">
|
|
||||||
<span>최대 너비</span>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<select
|
|
||||||
className="w-32 rounded border border-slate-700 bg-slate-900 px-2 py-1 text-[11px] outline-none focus:border-sky-500"
|
|
||||||
aria-label="최대 너비 프리셋"
|
|
||||||
value={maxWidthScale}
|
|
||||||
onChange={(e) => {
|
|
||||||
const value = e.target.value as NonNullable<TextBlockProps["maxWidthScale"]>;
|
|
||||||
let custom = textProps.maxWidthCustom ?? "";
|
|
||||||
if (value === "prose") custom = "60ch";
|
|
||||||
else if (value === "narrow") custom = "40ch";
|
|
||||||
else custom = "";
|
|
||||||
updateBlock(selectedBlockId, {
|
|
||||||
maxWidthScale: value,
|
|
||||||
maxWidthMode: custom ? "custom" : "scale",
|
|
||||||
maxWidthCustom: custom,
|
|
||||||
} as any);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<option value="none">제한 없음</option>
|
|
||||||
<option value="prose">본문 폭 (60ch)</option>
|
|
||||||
<option value="narrow">좁게 (40ch)</option>
|
|
||||||
</select>
|
|
||||||
<input
|
|
||||||
type="range"
|
|
||||||
min={20}
|
|
||||||
max={120}
|
|
||||||
step={5}
|
|
||||||
aria-label="최대 너비 슬라이더 (ch 단위)"
|
|
||||||
value={(() => {
|
|
||||||
const raw = textProps.maxWidthCustom ?? "";
|
|
||||||
const match = raw.match(/([0-9]+)ch/);
|
|
||||||
if (match) return Number(match[1]);
|
|
||||||
if (maxWidthScale === "prose") return 60;
|
|
||||||
if (maxWidthScale === "narrow") return 40;
|
|
||||||
return 120;
|
|
||||||
})()}
|
|
||||||
onChange={(e) => {
|
|
||||||
const ch = Number(e.target.value || 60);
|
|
||||||
updateBlock(selectedBlockId, {
|
|
||||||
maxWidthCustom: `${ch}ch`,
|
|
||||||
maxWidthMode: "custom",
|
|
||||||
} as any);
|
|
||||||
}}
|
|
||||||
className="flex-1"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<input
|
|
||||||
className="w-full rounded border border-slate-700 bg-slate-900 px-2 py-1 text-xs outline-none focus:border-sky-500"
|
|
||||||
aria-label="최대 너비 커스텀"
|
|
||||||
placeholder="예: 600px, 40rem, 80%, 60ch"
|
|
||||||
value={textProps.maxWidthCustom ?? ""}
|
|
||||||
onChange={(e) => {
|
|
||||||
updateBlock(selectedBlockId, {
|
|
||||||
maxWidthCustom: e.target.value,
|
|
||||||
maxWidthMode: "custom",
|
|
||||||
} as any);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,387 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import type { VideoBlockProps } from "@/features/editor/state/editorStore";
|
|
||||||
import { NumericPropertyControl } from "@/features/editor/components/NumericPropertyControl";
|
|
||||||
import { ColorPickerField, TEXT_COLOR_PALETTE } from "@/features/editor/components/ColorPickerField";
|
|
||||||
|
|
||||||
export type VideoPropertiesPanelProps = {
|
|
||||||
videoProps: VideoBlockProps;
|
|
||||||
selectedBlockId: string;
|
|
||||||
updateBlock: (id: string, partial: Partial<VideoBlockProps>) => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
export function VideoPropertiesPanel({ videoProps, selectedBlockId, updateBlock }: VideoPropertiesPanelProps) {
|
|
||||||
// 비디오 소스 유형: 업로드(/api/video/:id) 또는 외부 URL 구분
|
|
||||||
const source: "url" | "upload" =
|
|
||||||
videoProps.sourceType === "asset" || (videoProps.sourceUrl && videoProps.sourceUrl.startsWith("/api/video/"))
|
|
||||||
? "upload"
|
|
||||||
: "url";
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{/* 비디오 소스 선택 (URL / 업로드) */}
|
|
||||||
<div className="space-y-1">
|
|
||||||
<label className="flex flex-col gap-1 text-xs text-slate-400">
|
|
||||||
<span>비디오 소스</span>
|
|
||||||
<select
|
|
||||||
className="w-full rounded border border-slate-700 bg-slate-900 px-2 py-1 text-[11px] outline-none focus:border-sky-500"
|
|
||||||
aria-label="비디오 소스"
|
|
||||||
value={source}
|
|
||||||
onChange={(e) => {
|
|
||||||
const next = e.target.value as "url" | "upload";
|
|
||||||
if (next === "url") {
|
|
||||||
updateBlock(selectedBlockId, {
|
|
||||||
sourceType: "externalUrl",
|
|
||||||
assetId: null,
|
|
||||||
} as any);
|
|
||||||
} else {
|
|
||||||
updateBlock(selectedBlockId, {
|
|
||||||
sourceType: "asset",
|
|
||||||
} as any);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<option value="url">URL</option>
|
|
||||||
<option value="upload">파일 업로드</option>
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* URL 입력 */}
|
|
||||||
{source === "url" && (
|
|
||||||
<div className="space-y-1">
|
|
||||||
<label className="flex flex-col gap-1 text-xs text-slate-400">
|
|
||||||
<span>비디오 URL</span>
|
|
||||||
<input
|
|
||||||
className="w-full rounded border border-slate-700 bg-slate-900 px-2 py-1 text-xs outline-none focus:border-sky-500"
|
|
||||||
aria-label="비디오 URL"
|
|
||||||
value={videoProps.sourceUrl ?? ""}
|
|
||||||
onChange={(e) => {
|
|
||||||
const value = e.target.value;
|
|
||||||
updateBlock(selectedBlockId, {
|
|
||||||
sourceUrl: value,
|
|
||||||
sourceType: "externalUrl",
|
|
||||||
assetId: null,
|
|
||||||
} as any);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
|
|
||||||
{/* 접근성: 제목 / aria-label / 캡션 텍스트 (현재는 UI에서 숨김 처리) */}
|
|
||||||
<div className="hidden">
|
|
||||||
<label className="flex flex-col gap-1 mt-2">
|
|
||||||
<span>비디오 제목</span>
|
|
||||||
<input
|
|
||||||
className="w-full rounded border border-slate-700 bg-slate-900 px-2 py-1 text-xs outline-none focus:border-sky-500"
|
|
||||||
aria-label="비디오 제목"
|
|
||||||
value={(videoProps as any).titleText ?? ""}
|
|
||||||
onChange={(e) => {
|
|
||||||
updateBlock(selectedBlockId, { titleText: e.target.value } as any);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<label className="mt-2 flex flex-col gap-1">
|
|
||||||
<span>비디오 aria-label</span>
|
|
||||||
<input
|
|
||||||
className="w-full rounded border border-slate-700 bg-slate-900 px-2 py-1 text-xs outline-none focus:border-sky-500"
|
|
||||||
aria-label="비디오 aria-label"
|
|
||||||
value={(videoProps as any).ariaLabel ?? ""}
|
|
||||||
onChange={(e) => {
|
|
||||||
updateBlock(selectedBlockId, { ariaLabel: e.target.value } as any);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<label className="mt-2 flex flex-col gap-1">
|
|
||||||
<span>비디오 캡션 텍스트</span>
|
|
||||||
<input
|
|
||||||
className="w-full rounded border border-slate-700 bg-slate-900 px-2 py-1 text-xs outline-none focus:border-sky-500"
|
|
||||||
aria-label="비디오 캡션 텍스트"
|
|
||||||
value={(videoProps as any).captionText ?? ""}
|
|
||||||
onChange={(e) => {
|
|
||||||
updateBlock(selectedBlockId, { captionText: e.target.value } as any);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="mt-3 space-y-1 text-xs text-slate-400">
|
|
||||||
<label className="flex flex-col gap-1">
|
|
||||||
<span>포스터 이미지 URL</span>
|
|
||||||
<input
|
|
||||||
className="w-full rounded border border-slate-700 bg-slate-900 px-2 py-1 text-xs outline-none focus:border-sky-500"
|
|
||||||
aria-label="포스터 이미지 URL"
|
|
||||||
value={videoProps.posterImageSrc ?? ""}
|
|
||||||
onChange={(e) => {
|
|
||||||
const value = e.target.value;
|
|
||||||
updateBlock(selectedBlockId, {
|
|
||||||
posterImageSrc: value,
|
|
||||||
posterSourceType: "externalUrl",
|
|
||||||
posterAssetId: null,
|
|
||||||
} as any);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 파일 업로드 */}
|
|
||||||
{source === "upload" && (
|
|
||||||
<div className="space-y-1">
|
|
||||||
<label className="flex flex-col gap-1 text-xs text-slate-400">
|
|
||||||
<span>비디오 파일 업로드</span>
|
|
||||||
<input
|
|
||||||
type="file"
|
|
||||||
accept="video/*"
|
|
||||||
className="w-full text-[11px] text-slate-300 file:mr-2 file:rounded file:border file:border-slate-700 file:bg-slate-800 file:px-2 file:py-1 file:text-[11px] file:text-slate-100 hover:file:bg-slate-700"
|
|
||||||
aria-label="비디오 파일 업로드"
|
|
||||||
onChange={async (event) => {
|
|
||||||
const file = event.target.files?.[0];
|
|
||||||
if (!file) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const formData = new FormData();
|
|
||||||
formData.append("file", file);
|
|
||||||
|
|
||||||
const response = await fetch("/api/video", {
|
|
||||||
method: "POST",
|
|
||||||
body: formData,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
console.error("비디오 업로드 실패", await response.text());
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = (await response.json()) as { id: string; servedUrl?: string | null };
|
|
||||||
const servedUrl = data.servedUrl ?? `/api/video/${data.id}`;
|
|
||||||
|
|
||||||
updateBlock(selectedBlockId, {
|
|
||||||
sourceUrl: servedUrl,
|
|
||||||
sourceType: "asset",
|
|
||||||
assetId: data.id,
|
|
||||||
} as any);
|
|
||||||
} catch (error) {
|
|
||||||
console.error("비디오 업로드 중 오류", error);
|
|
||||||
} finally {
|
|
||||||
event.target.value = "";
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="mt-3 space-y-2 border-t border-slate-800 pt-3 text-xs text-slate-400">
|
|
||||||
<h4 className="text-[11px] font-semibold text-slate-200">비디오 스타일</h4>
|
|
||||||
|
|
||||||
{/* 카드 배경색 */}
|
|
||||||
<div className="space-y-1">
|
|
||||||
<ColorPickerField
|
|
||||||
label="카드 배경색"
|
|
||||||
ariaLabelColorInput="비디오 카드 배경색 피커"
|
|
||||||
ariaLabelHexInput="비디오 카드 배경색 HEX"
|
|
||||||
value={videoProps.backgroundColorCustom ?? ""}
|
|
||||||
onChange={(hex) => {
|
|
||||||
const next = hex && hex.trim().length > 0 ? hex : undefined;
|
|
||||||
updateBlock(selectedBlockId, { backgroundColorCustom: next } as any);
|
|
||||||
}}
|
|
||||||
palette={TEXT_COLOR_PALETTE}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 정렬 */}
|
|
||||||
<label className="flex flex-col gap-1">
|
|
||||||
<span>비디오 정렬</span>
|
|
||||||
<select
|
|
||||||
className="w-full rounded border border-slate-700 bg-slate-900 px-2 py-1 text-[11px] outline-none focus:border-sky-500"
|
|
||||||
aria-label="비디오 정렬"
|
|
||||||
value={videoProps.align ?? "center"}
|
|
||||||
onChange={(e) =>
|
|
||||||
updateBlock(selectedBlockId, {
|
|
||||||
align: e.target.value as VideoBlockProps["align"],
|
|
||||||
} as any)
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<option value="left">왼쪽</option>
|
|
||||||
<option value="center">가운데</option>
|
|
||||||
<option value="right">오른쪽</option>
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
|
|
||||||
{/* 너비 모드 */}
|
|
||||||
<label className="flex flex-col gap-1">
|
|
||||||
<span>비디오 너비 모드</span>
|
|
||||||
<select
|
|
||||||
className="w-full rounded border border-slate-700 bg-slate-900 px-2 py-1 text-[11px] outline-none focus:border-sky-500"
|
|
||||||
aria-label="비디오 너비 모드"
|
|
||||||
value={videoProps.widthMode ?? "auto"}
|
|
||||||
onChange={(e) =>
|
|
||||||
updateBlock(selectedBlockId, {
|
|
||||||
widthMode: e.target.value as VideoBlockProps["widthMode"],
|
|
||||||
} as any)
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<option value="auto">내용에 맞춤</option>
|
|
||||||
<option value="full">가로 전체</option>
|
|
||||||
<option value="fixed">고정 너비 (px)</option>
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
|
|
||||||
{(videoProps.widthMode ?? "auto") === "fixed" && (
|
|
||||||
<NumericPropertyControl
|
|
||||||
label="고정 너비 (px)"
|
|
||||||
unitLabel="(px)"
|
|
||||||
value={videoProps.widthPx ?? 640}
|
|
||||||
min={160}
|
|
||||||
max={1920}
|
|
||||||
step={10}
|
|
||||||
presets={[
|
|
||||||
{ id: "sm", label: "작게", value: 480 },
|
|
||||||
{ id: "md", label: "보통", value: 640 },
|
|
||||||
{ id: "lg", label: "넓게", value: 960 },
|
|
||||||
]}
|
|
||||||
onChangeValue={(v) => {
|
|
||||||
updateBlock(selectedBlockId, {
|
|
||||||
widthPx: v,
|
|
||||||
} as any);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 카드 패딩 */}
|
|
||||||
<NumericPropertyControl
|
|
||||||
label="카드 패딩"
|
|
||||||
unitLabel="(px)"
|
|
||||||
value={typeof videoProps.cardPaddingPx === "number" ? videoProps.cardPaddingPx : 0}
|
|
||||||
min={0}
|
|
||||||
max={64}
|
|
||||||
step={2}
|
|
||||||
onChangeValue={(v) => {
|
|
||||||
const safe = Number.isFinite(v) && v >= 0 ? v : 0;
|
|
||||||
updateBlock(selectedBlockId, { cardPaddingPx: safe } as any);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* 카드 모서리 둥글기 */}
|
|
||||||
<NumericPropertyControl
|
|
||||||
label="카드 모서리 둥글기"
|
|
||||||
unitLabel="(px)"
|
|
||||||
value={typeof videoProps.borderRadiusPx === "number" ? videoProps.borderRadiusPx : 0}
|
|
||||||
min={0}
|
|
||||||
max={64}
|
|
||||||
step={1}
|
|
||||||
onChangeValue={(v) => {
|
|
||||||
const safe = Number.isFinite(v) && v >= 0 ? v : 0;
|
|
||||||
updateBlock(selectedBlockId, { borderRadiusPx: safe } as any);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* 시작 시점 (초) */}
|
|
||||||
<NumericPropertyControl
|
|
||||||
label="시작 시점 (초)"
|
|
||||||
unitLabel="(초)"
|
|
||||||
value={typeof videoProps.startTimeSec === "number" ? videoProps.startTimeSec : 0}
|
|
||||||
min={0}
|
|
||||||
max={600}
|
|
||||||
step={1}
|
|
||||||
onChangeValue={(v) => {
|
|
||||||
const safe = Number.isFinite(v) && v >= 0 ? v : 0;
|
|
||||||
updateBlock(selectedBlockId, { startTimeSec: safe } as any);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* 종료 시점 (초) */}
|
|
||||||
<NumericPropertyControl
|
|
||||||
label="종료 시점 (초)"
|
|
||||||
unitLabel="(초)"
|
|
||||||
value={typeof videoProps.endTimeSec === "number" ? videoProps.endTimeSec : 0}
|
|
||||||
min={0}
|
|
||||||
max={600}
|
|
||||||
step={1}
|
|
||||||
onChangeValue={(v) => {
|
|
||||||
const safe = Number.isFinite(v) && v >= 0 ? v : 0;
|
|
||||||
updateBlock(selectedBlockId, { endTimeSec: safe } as any);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* 화면 비율 */}
|
|
||||||
<label className="flex flex-col gap-1">
|
|
||||||
<span>화면 비율</span>
|
|
||||||
<select
|
|
||||||
className="w-full rounded border border-slate-700 bg-slate-900 px-2 py-1 text-[11px] outline-none focus:border-sky-500"
|
|
||||||
aria-label="화면 비율"
|
|
||||||
value={videoProps.aspectRatio ?? "16:9"}
|
|
||||||
onChange={(e) =>
|
|
||||||
updateBlock(selectedBlockId, {
|
|
||||||
aspectRatio: e.target.value as VideoBlockProps["aspectRatio"],
|
|
||||||
} as any)
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<option value="16:9">16:9</option>
|
|
||||||
<option value="4:3">4:3</option>
|
|
||||||
<option value="1:1">1:1</option>
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
|
|
||||||
{/* 재생 옵션 */}
|
|
||||||
<div className="mt-2 grid grid-cols-2 gap-2">
|
|
||||||
<label className="flex items-center gap-1">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
className="h-3 w-3 rounded border-slate-700 bg-slate-900"
|
|
||||||
checked={!!videoProps.autoplay}
|
|
||||||
onChange={(e) =>
|
|
||||||
updateBlock(selectedBlockId, {
|
|
||||||
autoplay: e.target.checked,
|
|
||||||
} as any)
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<span className="text-[11px]">자동 재생</span>
|
|
||||||
</label>
|
|
||||||
<label className="flex items-center gap-1">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
className="h-3 w-3 rounded border-slate-700 bg-slate-900"
|
|
||||||
checked={!!videoProps.loop}
|
|
||||||
onChange={(e) =>
|
|
||||||
updateBlock(selectedBlockId, {
|
|
||||||
loop: e.target.checked,
|
|
||||||
} as any)
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<span className="text-[11px]">반복 재생</span>
|
|
||||||
</label>
|
|
||||||
<label className="flex items-center gap-1">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
className="h-3 w-3 rounded border-slate-700 bg-slate-900"
|
|
||||||
checked={!!videoProps.muted}
|
|
||||||
onChange={(e) =>
|
|
||||||
updateBlock(selectedBlockId, {
|
|
||||||
muted: e.target.checked,
|
|
||||||
} as any)
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<span className="text-[11px]">음소거</span>
|
|
||||||
</label>
|
|
||||||
<label className="flex items-center gap-1">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
className="h-3 w-3 rounded border-slate-700 bg-slate-900"
|
|
||||||
checked={videoProps.controls !== false}
|
|
||||||
onChange={(e) =>
|
|
||||||
updateBlock(selectedBlockId, {
|
|
||||||
controls: e.target.checked,
|
|
||||||
} as any)
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<span className="text-[11px]">재생 컨트롤 표시</span>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,102 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import type { Block, SectionBlockProps, TextBlockProps, ImageBlockProps } from "@/features/editor/state/editorStore";
|
|
||||||
|
|
||||||
export function createBlogTemplateBlocks(opts: {
|
|
||||||
sectionId: string;
|
|
||||||
createId: () => string;
|
|
||||||
}): { blocks: Block[]; lastSelectedId: string } {
|
|
||||||
const { sectionId, createId } = opts;
|
|
||||||
|
|
||||||
const columns: SectionBlockProps["columns"] = [
|
|
||||||
{ id: `${sectionId}_col_1`, span: 4 },
|
|
||||||
{ id: `${sectionId}_col_2`, span: 4 },
|
|
||||||
{ id: `${sectionId}_col_3`, span: 4 },
|
|
||||||
];
|
|
||||||
|
|
||||||
const sectionProps: SectionBlockProps = {
|
|
||||||
background: "default",
|
|
||||||
paddingY: "lg",
|
|
||||||
// 블로그 리스트는 넓은 폭과 충분한 카드 간격을 사용한다.
|
|
||||||
paddingYPx: 72,
|
|
||||||
maxWidthPx: 1120,
|
|
||||||
gapXPx: 32,
|
|
||||||
backgroundColorCustom: "#020617",
|
|
||||||
columns,
|
|
||||||
} as any;
|
|
||||||
|
|
||||||
const sectionBlock: Block = {
|
|
||||||
id: sectionId,
|
|
||||||
type: "section",
|
|
||||||
props: sectionProps,
|
|
||||||
sectionId: null,
|
|
||||||
columnId: null,
|
|
||||||
};
|
|
||||||
|
|
||||||
const postDefinitions = [
|
|
||||||
{ title: "블로그 포스트 1", summary: "첫 번째 포스트 요약을 여기에 입력하세요." },
|
|
||||||
{ title: "블로그 포스트 2", summary: "두 번째 포스트 요약을 여기에 입력하세요." },
|
|
||||||
{ title: "블로그 포스트 3", summary: "세 번째 포스트 요약을 여기에 입력하세요." },
|
|
||||||
];
|
|
||||||
|
|
||||||
const blogBlocks: Block[] = columns.flatMap((col, index) => {
|
|
||||||
const post = postDefinitions[index] ?? postDefinitions[0];
|
|
||||||
|
|
||||||
const imageId = createId();
|
|
||||||
const titleId = createId();
|
|
||||||
const summaryId = createId();
|
|
||||||
|
|
||||||
const imageProps: ImageBlockProps = {
|
|
||||||
src: "https://via.placeholder.com/400x250/1e293b/94a3b8?text=Blog+Image",
|
|
||||||
alt: post.title,
|
|
||||||
align: "center",
|
|
||||||
widthMode: "auto",
|
|
||||||
borderRadius: "md",
|
|
||||||
};
|
|
||||||
|
|
||||||
const titleProps: TextBlockProps = {
|
|
||||||
text: post.title,
|
|
||||||
align: "left",
|
|
||||||
size: "lg",
|
|
||||||
bold: true,
|
|
||||||
} as any;
|
|
||||||
|
|
||||||
const summaryProps: TextBlockProps = {
|
|
||||||
text: post.summary,
|
|
||||||
align: "left",
|
|
||||||
size: "sm",
|
|
||||||
color: "muted",
|
|
||||||
} as any;
|
|
||||||
|
|
||||||
const imageBlock: Block = {
|
|
||||||
id: imageId,
|
|
||||||
type: "image",
|
|
||||||
props: imageProps,
|
|
||||||
sectionId,
|
|
||||||
columnId: col.id,
|
|
||||||
};
|
|
||||||
|
|
||||||
const titleBlock: Block = {
|
|
||||||
id: titleId,
|
|
||||||
type: "text",
|
|
||||||
props: titleProps,
|
|
||||||
sectionId,
|
|
||||||
columnId: col.id,
|
|
||||||
};
|
|
||||||
|
|
||||||
const summaryBlock: Block = {
|
|
||||||
id: summaryId,
|
|
||||||
type: "text",
|
|
||||||
props: summaryProps,
|
|
||||||
sectionId,
|
|
||||||
columnId: col.id,
|
|
||||||
};
|
|
||||||
|
|
||||||
return [imageBlock, titleBlock, summaryBlock];
|
|
||||||
});
|
|
||||||
|
|
||||||
const blocks: Block[] = [sectionBlock, ...blogBlocks];
|
|
||||||
const lastSelectedId = blogBlocks[blogBlocks.length - 1]?.id ?? sectionId;
|
|
||||||
|
|
||||||
return { blocks, lastSelectedId };
|
|
||||||
}
|
|
||||||
@@ -1,78 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import type { Block, SectionBlockProps, TextBlockProps, ButtonBlockProps } from "@/features/editor/state/editorStore";
|
|
||||||
|
|
||||||
export function createCtaTemplateBlocks(opts: {
|
|
||||||
sectionId: string;
|
|
||||||
createId: () => string;
|
|
||||||
}): { blocks: Block[]; lastSelectedId: string } {
|
|
||||||
const { sectionId, createId } = opts;
|
|
||||||
|
|
||||||
const columns: SectionBlockProps["columns"] = [
|
|
||||||
{ id: `${sectionId}_col_1`, span: 8 },
|
|
||||||
{ id: `${sectionId}_col_2`, span: 4 },
|
|
||||||
];
|
|
||||||
|
|
||||||
const sectionProps: SectionBlockProps = {
|
|
||||||
background: "primary",
|
|
||||||
paddingY: "md",
|
|
||||||
// CTA 는 강한 색상과 적당한 여백, 비교적 좁은 폭을 사용한다.
|
|
||||||
paddingYPx: 64,
|
|
||||||
maxWidthPx: 960,
|
|
||||||
gapXPx: 24,
|
|
||||||
backgroundColorCustom: "#0c4a6e",
|
|
||||||
columns,
|
|
||||||
} as any;
|
|
||||||
|
|
||||||
const sectionBlock: Block = {
|
|
||||||
id: sectionId,
|
|
||||||
type: "section",
|
|
||||||
props: sectionProps,
|
|
||||||
sectionId: null,
|
|
||||||
columnId: null,
|
|
||||||
};
|
|
||||||
|
|
||||||
const leftColumnId = `${sectionId}_col_1`;
|
|
||||||
const rightColumnId = `${sectionId}_col_2`;
|
|
||||||
|
|
||||||
const textId = createId();
|
|
||||||
const textProps: TextBlockProps = {
|
|
||||||
text: "지금 바로 행동을 유도하는 CTA 텍스트를 입력하세요.\n망설이지 말고 시작하세요!",
|
|
||||||
align: "left",
|
|
||||||
size: "lg",
|
|
||||||
} as any;
|
|
||||||
const textBlock: Block = {
|
|
||||||
id: textId,
|
|
||||||
type: "text",
|
|
||||||
props: textProps,
|
|
||||||
sectionId,
|
|
||||||
columnId: leftColumnId,
|
|
||||||
};
|
|
||||||
|
|
||||||
const buttonId = createId();
|
|
||||||
const buttonProps: ButtonBlockProps = {
|
|
||||||
label: "CTA 버튼",
|
|
||||||
href: "#",
|
|
||||||
align: "center",
|
|
||||||
size: "lg",
|
|
||||||
variant: "solid",
|
|
||||||
colorPalette: "primary",
|
|
||||||
fullWidth: true,
|
|
||||||
borderRadius: "md",
|
|
||||||
textColorCustom: "#0b1120",
|
|
||||||
fillColorCustom: "#0ea5e9",
|
|
||||||
strokeColorCustom: "#0284c7",
|
|
||||||
};
|
|
||||||
const buttonBlock: Block = {
|
|
||||||
id: buttonId,
|
|
||||||
type: "button",
|
|
||||||
props: buttonProps,
|
|
||||||
sectionId,
|
|
||||||
columnId: rightColumnId,
|
|
||||||
};
|
|
||||||
|
|
||||||
const blocks: Block[] = [sectionBlock, textBlock, buttonBlock];
|
|
||||||
const lastSelectedId = buttonId;
|
|
||||||
|
|
||||||
return { blocks, lastSelectedId };
|
|
||||||
}
|
|
||||||
@@ -1,127 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import type { Block, SectionBlockProps, TextBlockProps } from "@/features/editor/state/editorStore";
|
|
||||||
|
|
||||||
export function createFaqTemplateBlocks(opts: {
|
|
||||||
sectionId: string;
|
|
||||||
createId: () => string;
|
|
||||||
}): { blocks: Block[]; lastSelectedId: string } {
|
|
||||||
const { sectionId, createId } = opts;
|
|
||||||
|
|
||||||
const columns: SectionBlockProps["columns"] = [
|
|
||||||
{ id: `${sectionId}_col_1`, span: 3 },
|
|
||||||
{ id: `${sectionId}_col_2`, span: 9 },
|
|
||||||
];
|
|
||||||
|
|
||||||
const sectionProps: SectionBlockProps = {
|
|
||||||
background: "default",
|
|
||||||
paddingY: "md",
|
|
||||||
// FAQ 는 비교적 좁은 폭과 보통 여백을 사용한다.
|
|
||||||
paddingYPx: 80,
|
|
||||||
maxWidthPx: 1024,
|
|
||||||
gapXPx: 48,
|
|
||||||
backgroundColorCustom: "#020617",
|
|
||||||
columns,
|
|
||||||
} as any;
|
|
||||||
|
|
||||||
const sectionBlock: Block = {
|
|
||||||
id: sectionId,
|
|
||||||
type: "section",
|
|
||||||
props: sectionProps,
|
|
||||||
sectionId: null,
|
|
||||||
columnId: null,
|
|
||||||
};
|
|
||||||
|
|
||||||
const leftColumnId = `${sectionId}_col_1`;
|
|
||||||
const rightColumnId = `${sectionId}_col_2`;
|
|
||||||
|
|
||||||
// Left Column: Title
|
|
||||||
const titleId = createId();
|
|
||||||
const titleProps: TextBlockProps = {
|
|
||||||
text: "FAQ",
|
|
||||||
align: "left",
|
|
||||||
size: "xl",
|
|
||||||
bold: true,
|
|
||||||
} as any;
|
|
||||||
const titleBlock: Block = {
|
|
||||||
id: titleId,
|
|
||||||
type: "text",
|
|
||||||
props: titleProps,
|
|
||||||
sectionId,
|
|
||||||
columnId: leftColumnId,
|
|
||||||
};
|
|
||||||
|
|
||||||
const subTitleId = createId();
|
|
||||||
const subTitleProps: TextBlockProps = {
|
|
||||||
text: "자주 묻는 질문",
|
|
||||||
align: "left",
|
|
||||||
size: "sm",
|
|
||||||
color: "muted",
|
|
||||||
} as any;
|
|
||||||
const subTitleBlock: Block = {
|
|
||||||
id: subTitleId,
|
|
||||||
type: "text",
|
|
||||||
props: subTitleProps,
|
|
||||||
sectionId,
|
|
||||||
columnId: leftColumnId,
|
|
||||||
};
|
|
||||||
|
|
||||||
|
|
||||||
// Right Column: Q&A List
|
|
||||||
const faqPairs = [
|
|
||||||
{
|
|
||||||
question: "서비스 이용료는 얼마인가요?",
|
|
||||||
answer: "기본 기능은 무료로 제공되며, 프리미엄 기능은 월 구독료가 발생합니다.",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
question: "환불 정책은 어떻게 되나요?",
|
|
||||||
answer: "결제 후 7일 이내에 사용 이력이 없는 경우 전액 환불 가능합니다.",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
question: "팀원 초대 기능이 있나요?",
|
|
||||||
answer: "네, 프로 요금제 이상부터 팀원 초대가 가능합니다.",
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const faqBlocks: Block[] = faqPairs.flatMap((pair) => {
|
|
||||||
const qId = createId();
|
|
||||||
const aId = createId();
|
|
||||||
|
|
||||||
const questionProps: TextBlockProps = {
|
|
||||||
text: pair.question,
|
|
||||||
align: "left",
|
|
||||||
size: "lg",
|
|
||||||
bold: true,
|
|
||||||
} as any;
|
|
||||||
|
|
||||||
const answerProps: TextBlockProps = {
|
|
||||||
text: pair.answer,
|
|
||||||
align: "left",
|
|
||||||
size: "base",
|
|
||||||
color: "muted",
|
|
||||||
} as any;
|
|
||||||
|
|
||||||
const questionBlock: Block = {
|
|
||||||
id: qId,
|
|
||||||
type: "text",
|
|
||||||
props: questionProps,
|
|
||||||
sectionId,
|
|
||||||
columnId: rightColumnId,
|
|
||||||
};
|
|
||||||
|
|
||||||
const answerBlock: Block = {
|
|
||||||
id: aId,
|
|
||||||
type: "text",
|
|
||||||
props: answerProps,
|
|
||||||
sectionId,
|
|
||||||
columnId: rightColumnId,
|
|
||||||
};
|
|
||||||
|
|
||||||
return [questionBlock, answerBlock];
|
|
||||||
});
|
|
||||||
|
|
||||||
const blocks: Block[] = [sectionBlock, titleBlock, subTitleBlock, ...faqBlocks];
|
|
||||||
const lastSelectedId = faqBlocks[faqBlocks.length - 1]?.id ?? sectionId;
|
|
||||||
|
|
||||||
return { blocks, lastSelectedId };
|
|
||||||
}
|
|
||||||
@@ -1,75 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import type { Block, SectionBlockProps, TextBlockProps } from "@/features/editor/state/editorStore";
|
|
||||||
|
|
||||||
export function createFeaturesTemplateBlocks(opts: {
|
|
||||||
sectionId: string;
|
|
||||||
createId: () => string;
|
|
||||||
}): { blocks: Block[]; lastSelectedId: string } {
|
|
||||||
const { sectionId, createId } = opts;
|
|
||||||
|
|
||||||
const columns = [
|
|
||||||
{ id: `${sectionId}_col_1`, span: 4 },
|
|
||||||
{ id: `${sectionId}_col_2`, span: 4 },
|
|
||||||
{ id: `${sectionId}_col_3`, span: 4 },
|
|
||||||
];
|
|
||||||
|
|
||||||
const sectionProps: SectionBlockProps = {
|
|
||||||
background: "muted",
|
|
||||||
paddingY: "md",
|
|
||||||
// 기능 리스트는 비교적 넓은 폭과 보통 수준의 여백을 사용한다.
|
|
||||||
paddingYPx: 72,
|
|
||||||
maxWidthPx: 1040,
|
|
||||||
gapXPx: 32,
|
|
||||||
backgroundColorCustom: "#0f172a",
|
|
||||||
columns,
|
|
||||||
} as any;
|
|
||||||
|
|
||||||
const sectionBlock: Block = {
|
|
||||||
id: sectionId,
|
|
||||||
type: "section",
|
|
||||||
props: sectionProps,
|
|
||||||
sectionId: null,
|
|
||||||
columnId: null,
|
|
||||||
};
|
|
||||||
|
|
||||||
const featureBlocks: Block[] = columns.flatMap((col, index) => {
|
|
||||||
const titleId = createId();
|
|
||||||
const descId = createId();
|
|
||||||
|
|
||||||
const titleProps: TextBlockProps = {
|
|
||||||
text: `Feature ${index + 1} 제목`,
|
|
||||||
align: "left",
|
|
||||||
size: "lg",
|
|
||||||
} as any;
|
|
||||||
|
|
||||||
const descProps: TextBlockProps = {
|
|
||||||
text: "해당 기능을 간단히 설명하는 텍스트입니다.",
|
|
||||||
align: "left",
|
|
||||||
size: "sm",
|
|
||||||
} as any;
|
|
||||||
|
|
||||||
const title: Block = {
|
|
||||||
id: titleId,
|
|
||||||
type: "text",
|
|
||||||
props: titleProps,
|
|
||||||
sectionId,
|
|
||||||
columnId: col.id,
|
|
||||||
};
|
|
||||||
|
|
||||||
const description: Block = {
|
|
||||||
id: descId,
|
|
||||||
type: "text",
|
|
||||||
props: descProps,
|
|
||||||
sectionId,
|
|
||||||
columnId: col.id,
|
|
||||||
};
|
|
||||||
|
|
||||||
return [title, description];
|
|
||||||
});
|
|
||||||
|
|
||||||
const blocks: Block[] = [sectionBlock, ...featureBlocks];
|
|
||||||
const lastSelectedId = featureBlocks[featureBlocks.length - 1]?.id ?? sectionId;
|
|
||||||
|
|
||||||
return { blocks, lastSelectedId };
|
|
||||||
}
|
|
||||||
@@ -1,106 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import type { Block, SectionBlockProps, TextBlockProps } from "@/features/editor/state/editorStore";
|
|
||||||
|
|
||||||
export function createFooterTemplateBlocks(opts: {
|
|
||||||
sectionId: string;
|
|
||||||
createId: () => string;
|
|
||||||
}): { blocks: Block[]; lastSelectedId: string } {
|
|
||||||
const { sectionId, createId } = opts;
|
|
||||||
|
|
||||||
const columns: SectionBlockProps["columns"] = [
|
|
||||||
{ id: `${sectionId}_col_1`, span: 4 },
|
|
||||||
{ id: `${sectionId}_col_2`, span: 4 },
|
|
||||||
{ id: `${sectionId}_col_3`, span: 4 },
|
|
||||||
];
|
|
||||||
|
|
||||||
const sectionProps: SectionBlockProps = {
|
|
||||||
background: "muted",
|
|
||||||
paddingY: "md",
|
|
||||||
// 푸터는 상대적으로 낮은 높이와 좁은 최대 폭을 사용한다.
|
|
||||||
paddingYPx: 64,
|
|
||||||
maxWidthPx: 1120,
|
|
||||||
backgroundColorCustom: "#020617",
|
|
||||||
columns,
|
|
||||||
} as any;
|
|
||||||
|
|
||||||
const sectionBlock: Block = {
|
|
||||||
id: sectionId,
|
|
||||||
type: "section",
|
|
||||||
props: sectionProps,
|
|
||||||
sectionId: null,
|
|
||||||
columnId: null,
|
|
||||||
};
|
|
||||||
|
|
||||||
const col1Id = `${sectionId}_col_1`;
|
|
||||||
const col2Id = `${sectionId}_col_2`;
|
|
||||||
const col3Id = `${sectionId}_col_3`;
|
|
||||||
|
|
||||||
// Col 1: Brand
|
|
||||||
const brandId = createId();
|
|
||||||
const brandProps: TextBlockProps = {
|
|
||||||
text: "MyLanding",
|
|
||||||
align: "left",
|
|
||||||
size: "xl",
|
|
||||||
bold: true,
|
|
||||||
} as any;
|
|
||||||
const brandBlock: Block = {
|
|
||||||
id: brandId,
|
|
||||||
type: "text",
|
|
||||||
props: brandProps,
|
|
||||||
sectionId,
|
|
||||||
columnId: col1Id,
|
|
||||||
};
|
|
||||||
|
|
||||||
const descId = createId();
|
|
||||||
const descProps: TextBlockProps = {
|
|
||||||
text: "더 나은 웹사이트를 위한 최고의 선택.",
|
|
||||||
align: "left",
|
|
||||||
size: "sm",
|
|
||||||
color: "muted",
|
|
||||||
} as any;
|
|
||||||
const descBlock: Block = {
|
|
||||||
id: descId,
|
|
||||||
type: "text",
|
|
||||||
props: descProps,
|
|
||||||
sectionId,
|
|
||||||
columnId: col1Id,
|
|
||||||
};
|
|
||||||
|
|
||||||
// Col 2: Links
|
|
||||||
const linksId = createId();
|
|
||||||
const linksProps: TextBlockProps = {
|
|
||||||
text: "서비스 소개\n요금제\n고객지원\n문의하기",
|
|
||||||
align: "center",
|
|
||||||
size: "sm",
|
|
||||||
color: "muted",
|
|
||||||
} as any;
|
|
||||||
const linksBlock: Block = {
|
|
||||||
id: linksId,
|
|
||||||
type: "text",
|
|
||||||
props: linksProps,
|
|
||||||
sectionId,
|
|
||||||
columnId: col2Id,
|
|
||||||
};
|
|
||||||
|
|
||||||
// Col 3: Copyright
|
|
||||||
const copyrightId = createId();
|
|
||||||
const copyrightProps: TextBlockProps = {
|
|
||||||
text: "© 2025 MyLanding.\nAll rights reserved.",
|
|
||||||
align: "right",
|
|
||||||
size: "xs",
|
|
||||||
color: "muted",
|
|
||||||
} as any;
|
|
||||||
const copyrightBlock: Block = {
|
|
||||||
id: copyrightId,
|
|
||||||
type: "text",
|
|
||||||
props: copyrightProps,
|
|
||||||
sectionId,
|
|
||||||
columnId: col3Id,
|
|
||||||
};
|
|
||||||
|
|
||||||
const blocks: Block[] = [sectionBlock, brandBlock, descBlock, linksBlock, copyrightBlock];
|
|
||||||
const lastSelectedId = copyrightId;
|
|
||||||
|
|
||||||
return { blocks, lastSelectedId };
|
|
||||||
}
|
|
||||||
@@ -1,96 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import type {
|
|
||||||
Block,
|
|
||||||
SectionBlockProps,
|
|
||||||
TextBlockProps,
|
|
||||||
ButtonBlockProps,
|
|
||||||
} from "@/features/editor/state/editorStore";
|
|
||||||
|
|
||||||
export function createHeroTemplateBlocks(opts: {
|
|
||||||
sectionId: string;
|
|
||||||
createId: () => string;
|
|
||||||
}): { blocks: Block[]; lastSelectedId: string } {
|
|
||||||
const { sectionId, createId } = opts;
|
|
||||||
|
|
||||||
const sectionProps: SectionBlockProps = {
|
|
||||||
background: "default",
|
|
||||||
paddingY: "lg",
|
|
||||||
// 히어로는 가장 여백이 넓고 화면 중앙에 집중되도록 설정한다.
|
|
||||||
paddingYPx: 96,
|
|
||||||
maxWidthPx: 960,
|
|
||||||
gapXPx: 40,
|
|
||||||
backgroundColorCustom: "#020617", // 다크 히어로 섹션
|
|
||||||
columns: [
|
|
||||||
{
|
|
||||||
id: `${sectionId}_col_1`,
|
|
||||||
span: 12,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
|
|
||||||
const sectionBlock: Block = {
|
|
||||||
id: sectionId,
|
|
||||||
type: "section",
|
|
||||||
props: sectionProps,
|
|
||||||
sectionId: null,
|
|
||||||
columnId: null,
|
|
||||||
};
|
|
||||||
|
|
||||||
const firstColumnId = `${sectionId}_col_1`;
|
|
||||||
|
|
||||||
const heroHeadlineId = createId();
|
|
||||||
const heroHeadlineProps: TextBlockProps = {
|
|
||||||
text: "Hero 제목을 여기에 입력하세요",
|
|
||||||
align: "center",
|
|
||||||
size: "lg",
|
|
||||||
} as any;
|
|
||||||
const heroHeadline: Block = {
|
|
||||||
id: heroHeadlineId,
|
|
||||||
type: "text",
|
|
||||||
props: heroHeadlineProps,
|
|
||||||
sectionId,
|
|
||||||
columnId: firstColumnId,
|
|
||||||
};
|
|
||||||
|
|
||||||
const heroSubId = createId();
|
|
||||||
const heroSubProps: TextBlockProps = {
|
|
||||||
text: "제품이나 서비스를 한 문장으로 설명하는 서브텍스트입니다.",
|
|
||||||
align: "center",
|
|
||||||
size: "base",
|
|
||||||
} as any;
|
|
||||||
const heroSub: Block = {
|
|
||||||
id: heroSubId,
|
|
||||||
type: "text",
|
|
||||||
props: heroSubProps,
|
|
||||||
sectionId,
|
|
||||||
columnId: firstColumnId,
|
|
||||||
};
|
|
||||||
|
|
||||||
const heroButtonId = createId();
|
|
||||||
const heroButtonProps: ButtonBlockProps = {
|
|
||||||
label: "지금 시작하기",
|
|
||||||
href: "#",
|
|
||||||
align: "center",
|
|
||||||
size: "md",
|
|
||||||
variant: "solid",
|
|
||||||
colorPalette: "primary",
|
|
||||||
fullWidth: false,
|
|
||||||
borderRadius: "md",
|
|
||||||
fontSizeCustom: "14px",
|
|
||||||
textColorCustom: "#0b1120",
|
|
||||||
fillColorCustom: "#0ea5e9",
|
|
||||||
strokeColorCustom: "#0284c7",
|
|
||||||
};
|
|
||||||
const heroButton: Block = {
|
|
||||||
id: heroButtonId,
|
|
||||||
type: "button",
|
|
||||||
props: heroButtonProps,
|
|
||||||
sectionId,
|
|
||||||
columnId: firstColumnId,
|
|
||||||
};
|
|
||||||
|
|
||||||
const blocks: Block[] = [sectionBlock, heroHeadline, heroSub, heroButton];
|
|
||||||
|
|
||||||
return { blocks, lastSelectedId: heroButtonId };
|
|
||||||
}
|
|
||||||
@@ -1,142 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import type { Block, SectionBlockProps, TextBlockProps, ButtonBlockProps } from "@/features/editor/state/editorStore";
|
|
||||||
|
|
||||||
export function createPricingTemplateBlocks(opts: {
|
|
||||||
sectionId: string;
|
|
||||||
createId: () => string;
|
|
||||||
}): { blocks: Block[]; lastSelectedId: string } {
|
|
||||||
const { sectionId, createId } = opts;
|
|
||||||
|
|
||||||
const columns: SectionBlockProps["columns"] = [
|
|
||||||
{ id: `${sectionId}_col_1`, span: 4 },
|
|
||||||
{ id: `${sectionId}_col_2`, span: 4 },
|
|
||||||
{ id: `${sectionId}_col_3`, span: 4 },
|
|
||||||
];
|
|
||||||
|
|
||||||
const sectionProps: SectionBlockProps = {
|
|
||||||
background: "muted",
|
|
||||||
paddingY: "lg",
|
|
||||||
// 요금제는 꽤 넓은 여백과 넓은 최대 폭, 넉넉한 컬럼 간격을 사용한다.
|
|
||||||
paddingYPx: 88,
|
|
||||||
maxWidthPx: 1120,
|
|
||||||
gapXPx: 40,
|
|
||||||
backgroundColorCustom: "#0f172a",
|
|
||||||
columns,
|
|
||||||
} as any;
|
|
||||||
|
|
||||||
const sectionBlock: Block = {
|
|
||||||
id: sectionId,
|
|
||||||
type: "section",
|
|
||||||
props: sectionProps,
|
|
||||||
sectionId: null,
|
|
||||||
columnId: null,
|
|
||||||
};
|
|
||||||
|
|
||||||
const planDefinitions: Array<{ name: string; price: string; popular?: boolean }> = [
|
|
||||||
{ name: "Basic", price: "₩9,900/월" },
|
|
||||||
{ name: "Pro", price: "₩29,900/월", popular: true },
|
|
||||||
{ name: "Enterprise", price: "문의" },
|
|
||||||
];
|
|
||||||
|
|
||||||
const pricingBlocks: Block[] = columns.flatMap((col, index) => {
|
|
||||||
const plan = planDefinitions[index] ?? planDefinitions[0];
|
|
||||||
const isPopular = !!plan.popular;
|
|
||||||
|
|
||||||
const blocksInCol: Block[] = [];
|
|
||||||
|
|
||||||
// Popular Badge (only for middle)
|
|
||||||
if (isPopular) {
|
|
||||||
const badgeId = createId();
|
|
||||||
const badgeProps: TextBlockProps = {
|
|
||||||
text: "MOST POPULAR",
|
|
||||||
align: "center",
|
|
||||||
size: "xs",
|
|
||||||
color: "primary",
|
|
||||||
bold: true,
|
|
||||||
} as any;
|
|
||||||
blocksInCol.push({
|
|
||||||
id: badgeId,
|
|
||||||
type: "text",
|
|
||||||
props: badgeProps,
|
|
||||||
sectionId,
|
|
||||||
columnId: col.id,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const nameId = createId();
|
|
||||||
const nameProps: TextBlockProps = {
|
|
||||||
text: plan.name,
|
|
||||||
align: "center",
|
|
||||||
size: "lg",
|
|
||||||
bold: true,
|
|
||||||
} as any;
|
|
||||||
blocksInCol.push({
|
|
||||||
id: nameId,
|
|
||||||
type: "text",
|
|
||||||
props: nameProps,
|
|
||||||
sectionId,
|
|
||||||
columnId: col.id,
|
|
||||||
});
|
|
||||||
|
|
||||||
const priceId = createId();
|
|
||||||
const priceProps: TextBlockProps = {
|
|
||||||
text: plan.price,
|
|
||||||
align: "center",
|
|
||||||
size: "xl", // Price larger
|
|
||||||
} as any;
|
|
||||||
blocksInCol.push({
|
|
||||||
id: priceId,
|
|
||||||
type: "text",
|
|
||||||
props: priceProps,
|
|
||||||
sectionId,
|
|
||||||
columnId: col.id,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Features list (simple text for now)
|
|
||||||
const featuresId = createId();
|
|
||||||
const featuresProps: TextBlockProps = {
|
|
||||||
text: "• 모든 기본 기능\n• 이메일 지원\n• 1GB 스토리지",
|
|
||||||
align: "center",
|
|
||||||
size: "sm",
|
|
||||||
color: "muted",
|
|
||||||
} as any;
|
|
||||||
blocksInCol.push({
|
|
||||||
id: featuresId,
|
|
||||||
type: "text",
|
|
||||||
props: featuresProps,
|
|
||||||
sectionId,
|
|
||||||
columnId: col.id,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Button
|
|
||||||
const buttonId = createId();
|
|
||||||
const buttonProps: ButtonBlockProps = {
|
|
||||||
label: isPopular ? "시작하기" : "선택하기",
|
|
||||||
href: "#",
|
|
||||||
align: "center",
|
|
||||||
size: "md",
|
|
||||||
variant: isPopular ? "solid" : "outline",
|
|
||||||
colorPalette: "primary",
|
|
||||||
fullWidth: true,
|
|
||||||
borderRadius: "md",
|
|
||||||
textColorCustom: isPopular ? "#0b1120" : "#e2e8f0",
|
|
||||||
fillColorCustom: isPopular ? "#0ea5e9" : "transparent",
|
|
||||||
strokeColorCustom: "#0ea5e9",
|
|
||||||
};
|
|
||||||
blocksInCol.push({
|
|
||||||
id: buttonId,
|
|
||||||
type: "button",
|
|
||||||
props: buttonProps,
|
|
||||||
sectionId,
|
|
||||||
columnId: col.id,
|
|
||||||
});
|
|
||||||
|
|
||||||
return blocksInCol;
|
|
||||||
});
|
|
||||||
|
|
||||||
const blocks: Block[] = [sectionBlock, ...pricingBlocks];
|
|
||||||
const lastSelectedId = pricingBlocks[pricingBlocks.length - 1]?.id ?? sectionId;
|
|
||||||
|
|
||||||
return { blocks, lastSelectedId };
|
|
||||||
}
|
|
||||||
@@ -1,119 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import type { Block, SectionBlockProps, TextBlockProps, ImageBlockProps } from "@/features/editor/state/editorStore";
|
|
||||||
|
|
||||||
export function createTeamTemplateBlocks(opts: {
|
|
||||||
sectionId: string;
|
|
||||||
createId: () => string;
|
|
||||||
}): { blocks: Block[]; lastSelectedId: string } {
|
|
||||||
const { sectionId, createId } = opts;
|
|
||||||
|
|
||||||
const columns: SectionBlockProps["columns"] = [
|
|
||||||
{ id: `${sectionId}_col_1`, span: 4 },
|
|
||||||
{ id: `${sectionId}_col_2`, span: 4 },
|
|
||||||
{ id: `${sectionId}_col_3`, span: 4 },
|
|
||||||
];
|
|
||||||
|
|
||||||
const sectionProps: SectionBlockProps = {
|
|
||||||
background: "muted",
|
|
||||||
paddingY: "lg",
|
|
||||||
// 팀 섹션은 보라 계열 배경과 넓은 폭, 보통 간격을 사용한다.
|
|
||||||
paddingYPx: 80,
|
|
||||||
maxWidthPx: 1120,
|
|
||||||
gapXPx: 32,
|
|
||||||
backgroundColorCustom: "#1e1b4b", // 보라/인디고 계열
|
|
||||||
columns,
|
|
||||||
} as any;
|
|
||||||
|
|
||||||
const sectionBlock: Block = {
|
|
||||||
id: sectionId,
|
|
||||||
type: "section",
|
|
||||||
props: sectionProps,
|
|
||||||
sectionId: null,
|
|
||||||
columnId: null,
|
|
||||||
};
|
|
||||||
|
|
||||||
const memberDefinitions = [
|
|
||||||
{ name: "홍길동", role: "Product Designer", bio: "사용자 경험을 설계합니다." },
|
|
||||||
{ name: "김영희", role: "Frontend Engineer", bio: "깔끔한 UI를 구현합니다." },
|
|
||||||
{ name: "이철수", role: "Backend Engineer", bio: "안정적인 인프라를 책임집니다." },
|
|
||||||
];
|
|
||||||
|
|
||||||
const teamBlocks: Block[] = columns.flatMap((col, index) => {
|
|
||||||
const m = memberDefinitions[index] ?? memberDefinitions[0];
|
|
||||||
|
|
||||||
const imageId = createId();
|
|
||||||
const nameId = createId();
|
|
||||||
const roleId = createId();
|
|
||||||
const bioId = createId();
|
|
||||||
|
|
||||||
const imageProps: ImageBlockProps = {
|
|
||||||
src: "https://via.placeholder.com/150x150/334155/94a3b8?text=User",
|
|
||||||
alt: m.name,
|
|
||||||
align: "center",
|
|
||||||
widthMode: "fixed",
|
|
||||||
widthPx: 120,
|
|
||||||
borderRadius: "full",
|
|
||||||
};
|
|
||||||
|
|
||||||
const nameProps: TextBlockProps = {
|
|
||||||
text: m.name,
|
|
||||||
align: "center",
|
|
||||||
size: "lg",
|
|
||||||
bold: true,
|
|
||||||
} as any;
|
|
||||||
|
|
||||||
const roleProps: TextBlockProps = {
|
|
||||||
text: m.role,
|
|
||||||
align: "center",
|
|
||||||
size: "base",
|
|
||||||
color: "primary",
|
|
||||||
} as any;
|
|
||||||
|
|
||||||
const bioProps: TextBlockProps = {
|
|
||||||
text: m.bio,
|
|
||||||
align: "center",
|
|
||||||
size: "sm",
|
|
||||||
color: "muted",
|
|
||||||
} as any;
|
|
||||||
|
|
||||||
const imageBlock: Block = {
|
|
||||||
id: imageId,
|
|
||||||
type: "image",
|
|
||||||
props: imageProps,
|
|
||||||
sectionId,
|
|
||||||
columnId: col.id,
|
|
||||||
};
|
|
||||||
|
|
||||||
const nameBlock: Block = {
|
|
||||||
id: nameId,
|
|
||||||
type: "text",
|
|
||||||
props: nameProps,
|
|
||||||
sectionId,
|
|
||||||
columnId: col.id,
|
|
||||||
};
|
|
||||||
|
|
||||||
const roleBlock: Block = {
|
|
||||||
id: roleId,
|
|
||||||
type: "text",
|
|
||||||
props: roleProps,
|
|
||||||
sectionId,
|
|
||||||
columnId: col.id,
|
|
||||||
};
|
|
||||||
|
|
||||||
const bioBlock: Block = {
|
|
||||||
id: bioId,
|
|
||||||
type: "text",
|
|
||||||
props: bioProps,
|
|
||||||
sectionId,
|
|
||||||
columnId: col.id,
|
|
||||||
};
|
|
||||||
|
|
||||||
return [imageBlock, nameBlock, roleBlock, bioBlock];
|
|
||||||
});
|
|
||||||
|
|
||||||
const blocks: Block[] = [sectionBlock, ...teamBlocks];
|
|
||||||
const lastSelectedId = teamBlocks[teamBlocks.length - 1]?.id ?? sectionId;
|
|
||||||
|
|
||||||
return { blocks, lastSelectedId };
|
|
||||||
}
|
|
||||||
@@ -1,83 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import type { Block, SectionBlockProps, TextBlockProps } from "@/features/editor/state/editorStore";
|
|
||||||
|
|
||||||
export function createTestimonialsTemplateBlocks(opts: {
|
|
||||||
sectionId: string;
|
|
||||||
createId: () => string;
|
|
||||||
}): { blocks: Block[]; lastSelectedId: string } {
|
|
||||||
const { sectionId, createId } = opts;
|
|
||||||
|
|
||||||
const columns: SectionBlockProps["columns"] = [
|
|
||||||
{ id: `${sectionId}_col_1`, span: 4 },
|
|
||||||
{ id: `${sectionId}_col_2`, span: 4 },
|
|
||||||
{ id: `${sectionId}_col_3`, span: 4 },
|
|
||||||
];
|
|
||||||
|
|
||||||
const sectionProps: SectionBlockProps = {
|
|
||||||
background: "default",
|
|
||||||
paddingY: "lg",
|
|
||||||
// 후기 섹션은 중간 폭과 적당한 카드 간격을 사용한다.
|
|
||||||
paddingYPx: 80,
|
|
||||||
maxWidthPx: 1040,
|
|
||||||
gapXPx: 36,
|
|
||||||
backgroundColorCustom: "#020617",
|
|
||||||
columns,
|
|
||||||
} as any;
|
|
||||||
|
|
||||||
const sectionBlock: Block = {
|
|
||||||
id: sectionId,
|
|
||||||
type: "section",
|
|
||||||
props: sectionProps,
|
|
||||||
sectionId: null,
|
|
||||||
columnId: null,
|
|
||||||
};
|
|
||||||
|
|
||||||
const testimonialDefinitions: Array<{ body: string; author: string }> = [
|
|
||||||
{ body: "이 서비스 덕분에 랜딩 페이지 제작 시간이 크게 줄었습니다.", author: "홍길동" },
|
|
||||||
{ body: "디자인을 잘 못해도 깔끔한 페이지를 만들 수 있어요.", author: "김영희" },
|
|
||||||
{ body: "팀 전체가 만족하는 빌더입니다.", author: "이철수" },
|
|
||||||
];
|
|
||||||
|
|
||||||
const testimonialBlocks: Block[] = columns.flatMap((col, index) => {
|
|
||||||
const t = testimonialDefinitions[index] ?? testimonialDefinitions[0];
|
|
||||||
|
|
||||||
const bodyId = createId();
|
|
||||||
const authorId = createId();
|
|
||||||
|
|
||||||
const bodyProps: TextBlockProps = {
|
|
||||||
text: t.body,
|
|
||||||
align: "left",
|
|
||||||
size: "base",
|
|
||||||
} as any;
|
|
||||||
|
|
||||||
const authorProps: TextBlockProps = {
|
|
||||||
text: `- ${t.author}`,
|
|
||||||
align: "left",
|
|
||||||
size: "sm",
|
|
||||||
} as any;
|
|
||||||
|
|
||||||
const bodyBlock: Block = {
|
|
||||||
id: bodyId,
|
|
||||||
type: "text",
|
|
||||||
props: bodyProps,
|
|
||||||
sectionId,
|
|
||||||
columnId: col.id,
|
|
||||||
};
|
|
||||||
|
|
||||||
const authorBlock: Block = {
|
|
||||||
id: authorId,
|
|
||||||
type: "text",
|
|
||||||
props: authorProps,
|
|
||||||
sectionId,
|
|
||||||
columnId: col.id,
|
|
||||||
};
|
|
||||||
|
|
||||||
return [bodyBlock, authorBlock];
|
|
||||||
});
|
|
||||||
|
|
||||||
const blocks: Block[] = [sectionBlock, ...testimonialBlocks];
|
|
||||||
const lastSelectedId = testimonialBlocks[testimonialBlocks.length - 1]?.id ?? sectionId;
|
|
||||||
|
|
||||||
return { blocks, lastSelectedId };
|
|
||||||
}
|
|
||||||
+1
-2
@@ -1,5 +1,4 @@
|
|||||||
import "../styles/globals.css";
|
import "../styles/globals.css";
|
||||||
import "../styles/builder.css";
|
|
||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
|
|
||||||
export const metadata = {
|
export const metadata = {
|
||||||
@@ -9,7 +8,7 @@ export const metadata = {
|
|||||||
|
|
||||||
export default function RootLayout({ children }: { children: ReactNode }) {
|
export default function RootLayout({ children }: { children: ReactNode }) {
|
||||||
return (
|
return (
|
||||||
<html lang="ko" suppressHydrationWarning>
|
<html lang="ko">
|
||||||
<body className="min-h-screen bg-slate-950 text-slate-50">
|
<body className="min-h-screen bg-slate-950 text-slate-50">
|
||||||
{children}
|
{children}
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
@@ -1,131 +0,0 @@
|
|||||||
"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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,190 +0,0 @@
|
|||||||
"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);
|
|
||||||
const resetHistory = useEditorStore((state) => (state as any).resetHistory as () => void);
|
|
||||||
|
|
||||||
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;
|
|
||||||
|
|
||||||
let slug = (projectConfig as any)?.slug?.trim?.();
|
|
||||||
|
|
||||||
if (!slug) {
|
|
||||||
try {
|
|
||||||
const url = new URL(window.location.href);
|
|
||||||
const fromQuery = url.searchParams.get("slug")?.trim();
|
|
||||||
|
|
||||||
if (fromQuery && fromQuery !== slug) {
|
|
||||||
slug = fromQuery;
|
|
||||||
updateProjectConfig({ slug: fromQuery } as any);
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// URL 파싱 오류는 무시하고, autosave 복원 없이 진행한다.
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 프리뷰 진입 시 autosave 프로젝트를 복원하는 경우에도 에디터 히스토리는 새 프로젝트 기준으로만 유지되도록 초기화한다.
|
|
||||||
resetHistory();
|
|
||||||
} catch {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}, [projectConfig?.slug, replaceBlocks, updateProjectConfig]);
|
|
||||||
|
|
||||||
const canvasStyle: CSSProperties = {};
|
|
||||||
const preset = projectConfig?.canvasPreset ?? "full";
|
|
||||||
|
|
||||||
const mainStyle: CSSProperties = {};
|
|
||||||
if (projectConfig?.bodyBgColorHex && projectConfig.bodyBgColorHex.trim()) {
|
|
||||||
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
|
|
||||||
: null;
|
|
||||||
|
|
||||||
if (widthPx != null) {
|
|
||||||
canvasStyle.maxWidth = `${widthPx}px`;
|
|
||||||
} else if (preset === "mobile") {
|
|
||||||
canvasStyle.maxWidth = "390px";
|
|
||||||
} else if (preset === "tablet") {
|
|
||||||
canvasStyle.maxWidth = "768px";
|
|
||||||
} else if (preset === "desktop") {
|
|
||||||
canvasStyle.maxWidth = "1200px";
|
|
||||||
}
|
|
||||||
|
|
||||||
if (projectConfig?.canvasBgColorHex && projectConfig.canvasBgColorHex.trim()) {
|
|
||||||
canvasStyle.backgroundColor = projectConfig.canvasBgColorHex;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<main className="min-h-screen flex flex-col bg-slate-950 text-slate-50" style={mainStyle}>
|
|
||||||
<header className="border-b border-slate-800 px-6 py-4 flex items-center justify-between">
|
|
||||||
<div>
|
|
||||||
<h1 className="text-xl font-semibold">Page Preview</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 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">
|
|
||||||
<div className="flex-1 flex justify-center px-4 py-6">
|
|
||||||
<div
|
|
||||||
data-testid="preview-canvas-inner"
|
|
||||||
className="w-full"
|
|
||||||
style={canvasStyle}
|
|
||||||
>
|
|
||||||
<PublicPageRenderer
|
|
||||||
blocks={blocks}
|
|
||||||
projectSlug={(projectConfig?.slug ?? "").trim() || undefined}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
</main>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,177 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { useEffect, useState } from "react";
|
|
||||||
import { useRouter, useParams } from "next/navigation";
|
|
||||||
|
|
||||||
// 프로젝트별 폼 제출 내역을 조회해서 보여주는 페이지 컴포넌트.
|
|
||||||
// - URL 의 slug 파라미터를 기준으로 /api/projects/[slug]/submissions 에 요청을 보낸다.
|
|
||||||
// - 401 이면 로그인 페이지로 이동시키고, 404/500 계열 에러는 한국어 에러 메시지로 안내한다.
|
|
||||||
// - 응답 payload 의 name/email/phone/birthdate 는 개별 컬럼으로, 나머지는 "기타 필드" 텍스트로 렌더링한다.
|
|
||||||
|
|
||||||
interface SubmissionItem {
|
|
||||||
id: string;
|
|
||||||
createdAt: string;
|
|
||||||
projectSlug: string;
|
|
||||||
// 서버에서 복호화된 폼 필드 전체가 payload 로 내려온다.
|
|
||||||
payload: Record<string, unknown>;
|
|
||||||
}
|
|
||||||
|
|
||||||
type PageStatus = "idle" | "loading" | "error";
|
|
||||||
|
|
||||||
export default function ProjectSubmissionsPage() {
|
|
||||||
const router = useRouter();
|
|
||||||
const params = useParams() as { slug?: string } | null;
|
|
||||||
const slug = (params?.slug ?? "").toString();
|
|
||||||
|
|
||||||
const [submissions, setSubmissions] = useState<SubmissionItem[]>([]);
|
|
||||||
const [status, setStatus] = useState<PageStatus>("idle");
|
|
||||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
|
||||||
|
|
||||||
// 마운트 시 현재 slug 기준으로 폼 제출 내역을 불러온다.
|
|
||||||
useEffect(() => {
|
|
||||||
if (!slug) return;
|
|
||||||
|
|
||||||
let cancelled = false;
|
|
||||||
|
|
||||||
const load = async () => {
|
|
||||||
try {
|
|
||||||
setStatus("loading");
|
|
||||||
setErrorMessage(null);
|
|
||||||
|
|
||||||
const res = await fetch(`/api/projects/${encodeURIComponent(slug)}/submissions`);
|
|
||||||
|
|
||||||
if (!res.ok) {
|
|
||||||
if (cancelled) return;
|
|
||||||
|
|
||||||
if (res.status === 401) {
|
|
||||||
setStatus("error");
|
|
||||||
setErrorMessage("폼 제출 내역을 조회하려면 로그인이 필요합니다. 다시 로그인해 주세요.");
|
|
||||||
router.push("/login");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (res.status === 404) {
|
|
||||||
setStatus("error");
|
|
||||||
setErrorMessage(
|
|
||||||
"프로젝트를 찾을 수 없거나 접근 권한이 없습니다. 프로젝트 소유자 계정으로 다시 로그인해 주세요.",
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setStatus("error");
|
|
||||||
setErrorMessage("폼 제출 내역을 불러오는 중 오류가 발생했습니다. 잠시 후 다시 시도해 주세요.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = (await res.json()) as SubmissionItem[];
|
|
||||||
|
|
||||||
if (!cancelled) {
|
|
||||||
setSubmissions(Array.isArray(data) ? data : []);
|
|
||||||
setStatus("idle");
|
|
||||||
setErrorMessage(null);
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
if (!cancelled) {
|
|
||||||
setStatus("error");
|
|
||||||
setErrorMessage("폼 제출 내역을 불러오는 중 오류가 발생했습니다. 잠시 후 다시 시도해 주세요.");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
void load();
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
cancelled = true;
|
|
||||||
};
|
|
||||||
}, [slug]);
|
|
||||||
|
|
||||||
// payload 에서 name/email/phone/birthdate 를 제외한 나머지 필드를 "기타" 영역으로 모아 보여준다.
|
|
||||||
const renderOtherFields = (payload: Record<string, unknown>): string => {
|
|
||||||
const knownKeys = new Set(["name", "email", "phone", "birthdate"]);
|
|
||||||
const entries = Object.entries(payload).filter(([key]) => !knownKeys.has(key));
|
|
||||||
|
|
||||||
if (entries.length === 0) {
|
|
||||||
return "-";
|
|
||||||
}
|
|
||||||
|
|
||||||
return entries
|
|
||||||
.map(([key, value]) => {
|
|
||||||
const stringValue = typeof value === "string" ? value : JSON.stringify(value);
|
|
||||||
return `${key}: ${stringValue}`;
|
|
||||||
})
|
|
||||||
.join("\n");
|
|
||||||
};
|
|
||||||
|
|
||||||
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="text-xs text-slate-300">
|
|
||||||
<span className="font-mono text-[11px]">{slug}</span>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<section className="flex-1 px-6 py-4 overflow-auto">
|
|
||||||
{status === "error" && errorMessage && (
|
|
||||||
<p className="text-xs text-red-300 mb-3">{errorMessage}</p>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{status === "loading" && (
|
|
||||||
<p className="text-xs text-slate-400 mb-3">폼 제출 내역을 불러오는 중입니다...</p>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{submissions.length === 0 && status === "idle" && !errorMessage && (
|
|
||||||
<p className="text-xs text-slate-400">아직 수집된 폼 제출 내역이 없습니다.</p>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{submissions.length > 0 && (
|
|
||||||
<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-4">제출 시각</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">생년월일</th>
|
|
||||||
<th className="py-2 pr-4">기타 필드</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{submissions.map((item) => {
|
|
||||||
const payload = (item.payload ?? {}) as Record<string, unknown>;
|
|
||||||
|
|
||||||
const nameValue = payload["name"];
|
|
||||||
const emailValue = payload["email"];
|
|
||||||
const phoneValue = payload["phone"];
|
|
||||||
const birthdateValue = payload["birthdate"];
|
|
||||||
|
|
||||||
const name = typeof nameValue === "string" ? nameValue : "";
|
|
||||||
const email = typeof emailValue === "string" ? emailValue : "";
|
|
||||||
const phone = typeof phoneValue === "string" ? phoneValue : "";
|
|
||||||
const birthdate = typeof birthdateValue === "string" ? birthdateValue : "";
|
|
||||||
|
|
||||||
return (
|
|
||||||
<tr key={item.id} className="border-b border-slate-900 hover:bg-slate-900/60">
|
|
||||||
<td className="py-2 pr-4 text-slate-300 text-[11px]">
|
|
||||||
{new Date(item.createdAt).toLocaleString()}
|
|
||||||
</td>
|
|
||||||
<td className="py-2 pr-4 text-slate-100">{name}</td>
|
|
||||||
<td className="py-2 pr-4 text-slate-300">{email}</td>
|
|
||||||
<td className="py-2 pr-4 text-slate-300">{phone}</td>
|
|
||||||
<td className="py-2 pr-4 text-slate-300">{birthdate}</td>
|
|
||||||
<td className="py-2 pr-4 text-slate-300 whitespace-pre-wrap">
|
|
||||||
{renderOtherFields(payload)}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
)}
|
|
||||||
</section>
|
|
||||||
</main>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,413 +0,0 @@
|
|||||||
"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>
|
|
||||||
<Link
|
|
||||||
href={`/projects/${encodeURIComponent(project.slug)}/submissions`}
|
|
||||||
className="inline-flex items-center gap-1 text-emerald-300 hover:text-emerald-100 underline-offset-2 hover:underline"
|
|
||||||
>
|
|
||||||
<ListChecks 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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,130 +0,0 @@
|
|||||||
"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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,146 +0,0 @@
|
|||||||
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,
|
|
||||||
};
|
|
||||||
|
|
||||||
// 만료 시간은 기본 7일으로 설정한다.
|
|
||||||
const token = jwt.sign(payload, secret, {
|
|
||||||
algorithm: "HS256",
|
|
||||||
expiresIn: "7d",
|
|
||||||
});
|
|
||||||
|
|
||||||
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("민감정보 복호화에 실패했습니다.");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,178 +0,0 @@
|
|||||||
import type { ChangeEvent } from "react";
|
|
||||||
import { useState } from "react";
|
|
||||||
|
|
||||||
export type ColorPaletteItem = {
|
|
||||||
// 팔레트 식별자
|
|
||||||
id: string;
|
|
||||||
// UI에 보여줄 이름
|
|
||||||
label: string;
|
|
||||||
// HEX 색상 값
|
|
||||||
color: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type ColorPickerFieldProps = {
|
|
||||||
// 필드 라벨 텍스트
|
|
||||||
label: string;
|
|
||||||
// 컬러 인풋에 대한 접근성 라벨
|
|
||||||
ariaLabelColorInput: string;
|
|
||||||
// HEX 텍스트 인풋에 대한 접근성 라벨
|
|
||||||
ariaLabelHexInput: string;
|
|
||||||
// 현재 선택된 색상 값 (HEX)
|
|
||||||
value: string;
|
|
||||||
// 값 변경 콜백
|
|
||||||
onChange: (value: string) => void;
|
|
||||||
// 선택 가능한 팔레트 목록
|
|
||||||
palette?: ColorPaletteItem[];
|
|
||||||
// 현재 선택된 팔레트 ID (있다면 드롭다운 요약에 표시)
|
|
||||||
selectedPaletteId?: string;
|
|
||||||
// 팔레트 선택 시 호출되는 콜백 (팔레트 메타 정보까지 필요할 때 사용)
|
|
||||||
onPaletteSelect?: (item: ColorPaletteItem) => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
// 텍스트/버튼 등에서 공통으로 사용하는 기본 색상 팔레트
|
|
||||||
export const TEXT_COLOR_PALETTE: ColorPaletteItem[] = [
|
|
||||||
// 기본/중립 계열
|
|
||||||
{ id: "default", label: "기본", color: "#e5e7eb" },
|
|
||||||
// 투명
|
|
||||||
{ id: "transparent", label: "투명", color: "transparent" },
|
|
||||||
{ id: "muted", label: "연한", color: "#9ca3af" },
|
|
||||||
{ id: "strong", label: "강조", color: "#f9fafb" },
|
|
||||||
{ id: "neutral", label: "중립", color: "#94a3b8" },
|
|
||||||
|
|
||||||
// 브랜드/포인트 계열
|
|
||||||
{ id: "accent", label: "포인트", color: "#38bdf8" },
|
|
||||||
{ id: "accent-deep", label: "포인트 진하게", color: "#0ea5e9" },
|
|
||||||
{ id: "accent-soft", label: "포인트 연하게", color: "#bae6fd" },
|
|
||||||
|
|
||||||
// 상태 계열
|
|
||||||
{ id: "danger", label: "위험", color: "#f97373" },
|
|
||||||
{ id: "danger-deep", label: "위험 진하게", color: "#ef4444" },
|
|
||||||
{ id: "success", label: "성공", color: "#22c55e" },
|
|
||||||
{ id: "success-soft", label: "성공 연하게", color: "#bbf7d0" },
|
|
||||||
{ id: "warning", label: "경고", color: "#eab308" },
|
|
||||||
{ id: "info", label: "정보", color: "#0ea5e9" },
|
|
||||||
|
|
||||||
// 보라/핑크 계열
|
|
||||||
{ id: "purple", label: "보라", color: "#a855f7" },
|
|
||||||
{ id: "purple-soft", label: "연한 보라", color: "#e9d5ff" },
|
|
||||||
{ id: "pink", label: "핑크", color: "#ec4899" },
|
|
||||||
{ id: "pink-soft", label: "연한 핑크", color: "#f9a8d4" },
|
|
||||||
|
|
||||||
// 배경 대비용 어두운 텍스트
|
|
||||||
{ id: "dark", label: "어두운 텍스트", color: "#020617" },
|
|
||||||
{ id: "dark-muted", label: "어두운 연한", color: "#64748b" },
|
|
||||||
];
|
|
||||||
|
|
||||||
export function ColorPickerField({
|
|
||||||
label,
|
|
||||||
ariaLabelColorInput,
|
|
||||||
ariaLabelHexInput,
|
|
||||||
value,
|
|
||||||
onChange,
|
|
||||||
palette = [],
|
|
||||||
selectedPaletteId,
|
|
||||||
onPaletteSelect,
|
|
||||||
}: ColorPickerFieldProps) {
|
|
||||||
const [open, setOpen] = useState(false);
|
|
||||||
|
|
||||||
const colorInputValue = value && value.startsWith("#") ? value : "#ffffff";
|
|
||||||
// 컬러 인풋 변경 시 HEX 문자열을 onChange로 전달한다.
|
|
||||||
const handleColorInputChange = (event: ChangeEvent<HTMLInputElement>) => {
|
|
||||||
onChange(event.target.value);
|
|
||||||
};
|
|
||||||
|
|
||||||
// 텍스트 인풋 변경 시 그대로 onChange로 전달한다.
|
|
||||||
const handleHexInputChange = (event: ChangeEvent<HTMLInputElement>) => {
|
|
||||||
onChange(event.target.value);
|
|
||||||
};
|
|
||||||
|
|
||||||
// 팔레트 버튼 클릭 시 해당 팔레트의 색상으로 onChange를 호출한다.
|
|
||||||
const handlePaletteClick = (item: ColorPaletteItem) => {
|
|
||||||
onChange(item.color);
|
|
||||||
if (onPaletteSelect) {
|
|
||||||
onPaletteSelect(item);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// 현재 선택된 팔레트 정보를 계산한다.
|
|
||||||
// 1) selectedPaletteId 가 넘어오면 ID 기준으로 찾고,
|
|
||||||
// 2) 없으면 현재 value 와 color 가 일치하는 팔레트 항목을 사용한다.
|
|
||||||
let selectedPalette: ColorPaletteItem | null = null;
|
|
||||||
if (palette.length > 0) {
|
|
||||||
if (selectedPaletteId) {
|
|
||||||
selectedPalette = palette.find((item) => item.id === selectedPaletteId) ?? null;
|
|
||||||
} else if (value) {
|
|
||||||
selectedPalette = palette.find((item) => item.color === value) ?? null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex flex-col gap-1 text-xs text-slate-400">
|
|
||||||
<span>{label}</span>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<input
|
|
||||||
type="color"
|
|
||||||
aria-label={ariaLabelColorInput}
|
|
||||||
className="h-8 w-8 rounded border border-slate-700 bg-slate-900 p-0"
|
|
||||||
value={colorInputValue}
|
|
||||||
onChange={handleColorInputChange}
|
|
||||||
/>
|
|
||||||
<input
|
|
||||||
className="w-28 flex-none rounded border border-slate-700 bg-slate-900 px-2 py-1 text-xs outline-none focus:border-sky-500"
|
|
||||||
aria-label={ariaLabelHexInput}
|
|
||||||
placeholder="예: #ff0000"
|
|
||||||
value={value}
|
|
||||||
onChange={handleHexInputChange}
|
|
||||||
/>
|
|
||||||
{palette.length > 0 ? (
|
|
||||||
<div className="relative text-[11px] flex-none">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="w-28 rounded border border-slate-800 bg-slate-950/60 flex items-center justify-between px-2 py-1 text-left"
|
|
||||||
onClick={() => setOpen((prev) => !prev)}
|
|
||||||
>
|
|
||||||
<span className="flex items-center gap-1">
|
|
||||||
<span
|
|
||||||
className="inline-block h-3 w-3 rounded-full"
|
|
||||||
style={{ backgroundColor: selectedPalette?.color ?? value ?? "#ffffff" }}
|
|
||||||
/>
|
|
||||||
<span className="truncate text-slate-200">
|
|
||||||
{selectedPalette?.label ?? "색상 팔레트"}
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
<span className="text-slate-500 text-[10px]">▼</span>
|
|
||||||
</button>
|
|
||||||
{open ? (
|
|
||||||
<div className="absolute right-0 top-full mt-1 w-32 rounded border border-slate-800 bg-slate-950 max-h-40 overflow-auto z-10">
|
|
||||||
{palette.map((item) => (
|
|
||||||
<button
|
|
||||||
key={item.id}
|
|
||||||
type="button"
|
|
||||||
className={`w-full flex items-center justify-between px-2 py-1 text-left text-[11px] border-b border-slate-900 last:border-b-0 ${
|
|
||||||
selectedPaletteId === item.id
|
|
||||||
? "bg-sky-900/40 text-sky-100"
|
|
||||||
: "bg-transparent text-slate-300 hover:bg-slate-900/60"
|
|
||||||
}`}
|
|
||||||
onClick={(event) => {
|
|
||||||
event.stopPropagation();
|
|
||||||
handlePaletteClick(item);
|
|
||||||
setOpen(false);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<span className="flex items-center gap-2">
|
|
||||||
<span
|
|
||||||
className="inline-block h-3 w-3 rounded-full"
|
|
||||||
style={{ backgroundColor: item.color }}
|
|
||||||
/>
|
|
||||||
<span>{item.label}</span>
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,97 +0,0 @@
|
|||||||
import type { ChangeEvent } from "react";
|
|
||||||
import { PropertySliderField } from "@/features/editor/components/PropertySliderField";
|
|
||||||
|
|
||||||
export type NumericPresetOption = {
|
|
||||||
// 프리셋 식별자 (예: "tight", "normal")
|
|
||||||
id: string;
|
|
||||||
// UI 에 표시할 라벨
|
|
||||||
label: string;
|
|
||||||
// UI 기준 값 (px, 배율 등)
|
|
||||||
value: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type NumericPropertyControlProps = {
|
|
||||||
// 필드 라벨 (예: 글자 크기, 줄 간격)
|
|
||||||
label: string;
|
|
||||||
// 단위 설명 (예: px, 배율 등)
|
|
||||||
unitLabel?: string;
|
|
||||||
// 현재 UI 값
|
|
||||||
value: number;
|
|
||||||
min: number;
|
|
||||||
max: number;
|
|
||||||
step: number;
|
|
||||||
// 프리셋 목록 (없으면 select 를 렌더링하지 않음)
|
|
||||||
presets?: NumericPresetOption[];
|
|
||||||
// 값 변경 시 호출되는 콜백 (UI 값 기준)
|
|
||||||
onChangeValue: (value: number) => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
export function NumericPropertyControl({
|
|
||||||
label,
|
|
||||||
unitLabel,
|
|
||||||
value,
|
|
||||||
min,
|
|
||||||
max,
|
|
||||||
step,
|
|
||||||
presets,
|
|
||||||
onChangeValue,
|
|
||||||
}: NumericPropertyControlProps) {
|
|
||||||
// 현재 값에 가장 가까운 프리셋 ID 를 계산한다.
|
|
||||||
const currentPresetId = (() => {
|
|
||||||
if (!presets || presets.length === 0) return "";
|
|
||||||
let bestId = presets[0].id;
|
|
||||||
let bestDist = Math.abs(presets[0].value - value);
|
|
||||||
for (let i = 1; i < presets.length; i += 1) {
|
|
||||||
const dist = Math.abs(presets[i].value - value);
|
|
||||||
if (dist < bestDist) {
|
|
||||||
bestDist = dist;
|
|
||||||
bestId = presets[i].id;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return bestId;
|
|
||||||
})();
|
|
||||||
|
|
||||||
const handlePresetChange = (event: ChangeEvent<HTMLSelectElement>) => {
|
|
||||||
if (!presets || presets.length === 0) return;
|
|
||||||
const id = event.target.value;
|
|
||||||
const found = presets.find((p) => p.id === id);
|
|
||||||
if (!found) return;
|
|
||||||
onChangeValue(found.value);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<label className="flex flex-col gap-1 text-xs text-slate-400">
|
|
||||||
<span>
|
|
||||||
{label}
|
|
||||||
{unitLabel ? ` ${unitLabel}` : ""}
|
|
||||||
</span>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
{presets && presets.length > 0 ? (
|
|
||||||
<select
|
|
||||||
className="w-32 rounded border border-slate-700 bg-slate-900 px-2 py-1 text-[11px] outline-none focus:border-sky-500"
|
|
||||||
aria-label={`${label} 프리셋`}
|
|
||||||
value={currentPresetId}
|
|
||||||
onChange={handlePresetChange}
|
|
||||||
>
|
|
||||||
{presets.map((preset) => (
|
|
||||||
<option key={preset.id} value={preset.id}>
|
|
||||||
{preset.label}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
<PropertySliderField
|
|
||||||
label=""
|
|
||||||
ariaLabelSlider={`${label} 슬라이더`}
|
|
||||||
ariaLabelInput={`${label} 커스텀${unitLabel ? ` ${unitLabel}` : ""}`}
|
|
||||||
value={Number.isFinite(value) ? value : 0}
|
|
||||||
min={min}
|
|
||||||
max={max}
|
|
||||||
step={step}
|
|
||||||
onChange={onChangeValue}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</label>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,70 +0,0 @@
|
|||||||
import type { ChangeEvent } from "react";
|
|
||||||
|
|
||||||
export type PropertySliderFieldProps = {
|
|
||||||
// 슬라이더 그룹 전체 라벨 텍스트
|
|
||||||
label: string;
|
|
||||||
// 슬라이더 요소에 대한 접근성 라벨
|
|
||||||
ariaLabelSlider: string;
|
|
||||||
// 텍스트 입력 요소에 대한 접근성 라벨
|
|
||||||
ariaLabelInput: string;
|
|
||||||
// 현재 값 (슬라이더와 입력이 공유)
|
|
||||||
value: number;
|
|
||||||
// 최소 값
|
|
||||||
min: number;
|
|
||||||
// 최대 값
|
|
||||||
max: number;
|
|
||||||
// 슬라이더 스텝
|
|
||||||
step: number;
|
|
||||||
// 값 변경 콜백
|
|
||||||
onChange: (value: number) => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
export function PropertySliderField({
|
|
||||||
label,
|
|
||||||
ariaLabelSlider,
|
|
||||||
ariaLabelInput,
|
|
||||||
value,
|
|
||||||
min,
|
|
||||||
max,
|
|
||||||
step,
|
|
||||||
onChange,
|
|
||||||
}: PropertySliderFieldProps) {
|
|
||||||
// 슬라이더 변경 시 상위에서 전달받은 onChange에 숫자 값으로 전달한다.
|
|
||||||
const handleSliderChange = (event: ChangeEvent<HTMLInputElement>) => {
|
|
||||||
const numeric = Number(event.target.value || 0);
|
|
||||||
onChange(numeric);
|
|
||||||
};
|
|
||||||
|
|
||||||
// 텍스트 입력 변경 시에도 숫자 값으로 파싱하여 onChange를 호출한다.
|
|
||||||
const handleInputChange = (event: ChangeEvent<HTMLInputElement>) => {
|
|
||||||
const raw = event.target.value;
|
|
||||||
const numeric = Number(raw);
|
|
||||||
if (!Number.isNaN(numeric)) {
|
|
||||||
onChange(numeric);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<label className="flex flex-col gap-1 text-xs text-slate-400">
|
|
||||||
<span>{label}</span>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<input
|
|
||||||
type="range"
|
|
||||||
min={min}
|
|
||||||
max={max}
|
|
||||||
step={step}
|
|
||||||
aria-label={ariaLabelSlider}
|
|
||||||
value={value}
|
|
||||||
onChange={handleSliderChange}
|
|
||||||
className="flex-1"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<input
|
|
||||||
className="w-full rounded border border-slate-700 bg-slate-900 px-2 py-1 text-xs outline-none focus:border-sky-500"
|
|
||||||
aria-label={ariaLabelInput}
|
|
||||||
value={Number.isFinite(value) ? value : ""}
|
|
||||||
onChange={handleInputChange}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,821 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { useState, type CSSProperties } from "react";
|
|
||||||
import type {
|
|
||||||
Block,
|
|
||||||
TextBlockProps,
|
|
||||||
ButtonBlockProps,
|
|
||||||
ImageBlockProps,
|
|
||||||
VideoBlockProps,
|
|
||||||
SectionBlockProps,
|
|
||||||
DividerBlockProps,
|
|
||||||
ListBlockProps,
|
|
||||||
ListItemNode,
|
|
||||||
FormBlockProps,
|
|
||||||
FormInputBlockProps,
|
|
||||||
FormSelectBlockProps,
|
|
||||||
FormCheckboxBlockProps,
|
|
||||||
FormRadioBlockProps,
|
|
||||||
FormSelectOption,
|
|
||||||
FormRadioOption,
|
|
||||||
FormCheckboxOption,
|
|
||||||
} from "@/features/editor/state/editorStore";
|
|
||||||
import {
|
|
||||||
normalizeVideoSourceUrl,
|
|
||||||
resolveVideoPlatform,
|
|
||||||
buildVideoEmbedUrl,
|
|
||||||
computeVideoPublicTokens,
|
|
||||||
} from "@/features/editor/utils/videoHelpers";
|
|
||||||
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";
|
|
||||||
import { computeListPublicTokens } from "@/features/editor/utils/listHelpers";
|
|
||||||
import { computeSectionPublicTokens } from "@/features/editor/utils/sectionHelpers";
|
|
||||||
import {
|
|
||||||
computeFormInputPublicTokens,
|
|
||||||
computeFormSelectPublicTokens,
|
|
||||||
computeFormCheckboxPublicTokens,
|
|
||||||
computeFormRadioPublicTokens,
|
|
||||||
computeFormControllerPublicTokens,
|
|
||||||
} from "@/features/editor/utils/formHelpers";
|
|
||||||
|
|
||||||
// 섹션 레이아웃/스타일 토큰을 실제 Tailwind 클래스 조합으로 변환하는 유틸
|
|
||||||
// (에디터/퍼블릭 렌더러에서 동일한 규칙을 재사용하기 위해 export 한다.)
|
|
||||||
export function getSectionLayoutConfig(props: SectionBlockProps) {
|
|
||||||
const backgroundClass =
|
|
||||||
props.background === "muted"
|
|
||||||
? "bg-slate-900"
|
|
||||||
: props.background === "primary"
|
|
||||||
? "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-4" : props.paddingY === "lg" ? "py-10" : "py-6";
|
|
||||||
|
|
||||||
const maxWidthClass =
|
|
||||||
props.maxWidthMode === "narrow"
|
|
||||||
? "max-w-3xl"
|
|
||||||
: props.maxWidthMode === "wide"
|
|
||||||
? "max-w-6xl"
|
|
||||||
: "max-w-5xl";
|
|
||||||
|
|
||||||
const gapXClass =
|
|
||||||
props.gapX === "sm" ? "gap-4" : props.gapX === "lg" ? "gap-10" : "gap-8";
|
|
||||||
|
|
||||||
const alignItemsClass =
|
|
||||||
props.alignItems === "center"
|
|
||||||
? "items-center"
|
|
||||||
: props.alignItems === "bottom"
|
|
||||||
? "items-end"
|
|
||||||
: "items-start";
|
|
||||||
|
|
||||||
return {
|
|
||||||
backgroundClass,
|
|
||||||
paddingYClass,
|
|
||||||
maxWidthClass,
|
|
||||||
gapXClass,
|
|
||||||
alignItemsClass,
|
|
||||||
} as const;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 에디터 크롬 없이 실제 랜딩 페이지처럼 블록들을 렌더링하는 컴포넌트
|
|
||||||
interface PublicPageRendererProps {
|
|
||||||
blocks: Block[];
|
|
||||||
projectSlug?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
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 PublicPageRenderer({ blocks, projectSlug }: PublicPageRendererProps) {
|
|
||||||
const sectionBlocks = blocks.filter((b) => b.type === "section");
|
|
||||||
const rootBlocks = blocks.filter((b) => !b.sectionId && b.type !== "section");
|
|
||||||
|
|
||||||
const [formStatus, setFormStatus] = useState<"idle" | "submitting" | "success" | "error">("idle");
|
|
||||||
const [formMessage, setFormMessage] = useState<string>("");
|
|
||||||
|
|
||||||
const renderBlock = (block: Block) => {
|
|
||||||
if (block.type === "text") {
|
|
||||||
const props = block.props as TextBlockProps;
|
|
||||||
const tokens = computeTextPublicTokens(props);
|
|
||||||
const textStyle: CSSProperties = { ...tokens.styleOverrides };
|
|
||||||
|
|
||||||
return (
|
|
||||||
<p
|
|
||||||
key={block.id}
|
|
||||||
className={`${tokens.alignClass} ${tokens.sizeClass} text-slate-50 leading-relaxed whitespace-pre-wrap`}
|
|
||||||
style={textStyle}
|
|
||||||
>
|
|
||||||
{props.text}
|
|
||||||
</p>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (block.type === "formInput") {
|
|
||||||
const props = block.props as FormInputBlockProps;
|
|
||||||
const tokens = computeFormInputPublicTokens(props);
|
|
||||||
|
|
||||||
const baseInputClass =
|
|
||||||
"rounded border px-3 py-2 text-xs outline-none focus:border-sky-500";
|
|
||||||
|
|
||||||
return (
|
|
||||||
<label
|
|
||||||
key={block.id}
|
|
||||||
data-testid="preview-form-input-wrapper"
|
|
||||||
className={`${tokens.wrapperLayoutClass} text-xs text-slate-200`}
|
|
||||||
style={tokens.wrapperStyle}
|
|
||||||
>
|
|
||||||
<span>{props.label}</span>
|
|
||||||
{props.inputType === "textarea" ? (
|
|
||||||
<textarea
|
|
||||||
data-testid="preview-form-input"
|
|
||||||
className={`${baseInputClass} ${tokens.widthClass}`}
|
|
||||||
style={tokens.inputStyle}
|
|
||||||
placeholder={props.placeholder || props.label}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<input
|
|
||||||
data-testid="preview-form-input"
|
|
||||||
className={`${baseInputClass} ${tokens.widthClass}`}
|
|
||||||
style={tokens.inputStyle}
|
|
||||||
type={props.inputType === "email" ? "email" : "text"}
|
|
||||||
placeholder={props.placeholder || props.label}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</label>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (block.type === "formSelect") {
|
|
||||||
const props = block.props as FormSelectBlockProps;
|
|
||||||
const tokens = computeFormSelectPublicTokens(props);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<label key={block.id} className="flex flex-col gap-1 text-xs text-slate-200">
|
|
||||||
<span>{props.label}</span>
|
|
||||||
<div
|
|
||||||
data-testid="preview-form-select"
|
|
||||||
className={`${tokens.widthClass}`}
|
|
||||||
style={tokens.wrapperStyle}
|
|
||||||
>
|
|
||||||
<select
|
|
||||||
className="w-full rounded border border-slate-700 bg-slate-900 px-3 py-2 text-xs outline-none focus:border-sky-500"
|
|
||||||
style={tokens.selectStyle}
|
|
||||||
defaultValue={props.options[0]?.value ?? ""}
|
|
||||||
>
|
|
||||||
{props.options.map((opt) => (
|
|
||||||
<option key={opt.value} value={opt.value}>
|
|
||||||
{opt.label}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</label>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (block.type === "formCheckbox") {
|
|
||||||
const props = block.props as FormCheckboxBlockProps;
|
|
||||||
const tokens = computeFormCheckboxPublicTokens(props);
|
|
||||||
const groupLabelMode = props.groupLabelMode ?? "text";
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={block.id}
|
|
||||||
data-testid="preview-form-checkbox-group"
|
|
||||||
className={["flex flex-col gap-1", tokens.textSizeClass, "text-slate-200", tokens.widthClass]
|
|
||||||
.filter(Boolean)
|
|
||||||
.join(" ")}
|
|
||||||
style={tokens.groupStyle}
|
|
||||||
>
|
|
||||||
{groupLabelMode === "image" && props.groupLabelImageUrl ? (
|
|
||||||
// eslint-disable-next-line @next/next/no-img-element
|
|
||||||
<img
|
|
||||||
src={props.groupLabelImageUrl}
|
|
||||||
alt={props.groupLabel || "폼 그룹 타이틀"}
|
|
||||||
className="inline-block max-w-full h-auto"
|
|
||||||
style={tokens.groupTextStyle}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<span style={tokens.groupTextStyle}>{props.groupLabel}</span>
|
|
||||||
)}
|
|
||||||
<div className="flex flex-col" data-testid="preview-form-checkbox-options" style={tokens.optionsStyle}>
|
|
||||||
{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}
|
|
||||||
>
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
className="h-4 w-4 rounded border-slate-700 bg-slate-900 text-sky-500"
|
|
||||||
/>
|
|
||||||
{opt.labelImageUrl ? (
|
|
||||||
// eslint-disable-next-line @next/next/no-img-element
|
|
||||||
<img
|
|
||||||
src={opt.labelImageUrl}
|
|
||||||
alt={opt.label || props.groupLabel || "체크박스 옵션"}
|
|
||||||
className="inline-block max-w-full h-auto"
|
|
||||||
style={tokens.optionTextStyle}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<span data-testid="preview-form-checkbox-option" style={tokens.optionTextStyle}>
|
|
||||||
{opt.label}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</label>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (block.type === "formRadio") {
|
|
||||||
const props = block.props as FormRadioBlockProps;
|
|
||||||
const tokens = computeFormRadioPublicTokens(props);
|
|
||||||
const groupLabelMode = props.groupLabelMode ?? "text";
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={block.id}
|
|
||||||
data-testid="preview-form-radio-group"
|
|
||||||
className={["flex flex-col gap-1", tokens.textSizeClass, "text-slate-200", tokens.widthClass]
|
|
||||||
.filter(Boolean)
|
|
||||||
.join(" ")}
|
|
||||||
style={tokens.groupStyle}
|
|
||||||
>
|
|
||||||
{groupLabelMode === "image" && props.groupLabelImageUrl ? (
|
|
||||||
// eslint-disable-next-line @next/next/no-img-element
|
|
||||||
<img
|
|
||||||
src={props.groupLabelImageUrl}
|
|
||||||
alt={props.groupLabel || "폼 그룹 타이틀"}
|
|
||||||
className="inline-block max-w-full h-auto"
|
|
||||||
style={tokens.groupTextStyle}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<span style={tokens.groupTextStyle}>{props.groupLabel}</span>
|
|
||||||
)}
|
|
||||||
<div className="flex flex-col" data-testid="preview-form-radio-options" style={tokens.optionsStyle}>
|
|
||||||
{props.options.map((opt) => (
|
|
||||||
<label
|
|
||||||
key={opt.value}
|
|
||||||
className="inline-flex items-center gap-1"
|
|
||||||
style={tokens.optionContainerStyle}
|
|
||||||
>
|
|
||||||
<input
|
|
||||||
type="radio"
|
|
||||||
className="h-4 w-4 border-slate-700 bg-slate-900 text-sky-500"
|
|
||||||
name={props.formFieldName}
|
|
||||||
/>
|
|
||||||
{opt.labelImageUrl ? (
|
|
||||||
// eslint-disable-next-line @next/next/no-img-element
|
|
||||||
<img
|
|
||||||
src={opt.labelImageUrl}
|
|
||||||
alt={opt.label || props.groupLabel || "라디오 옵션"}
|
|
||||||
className="inline-block max-w-full h-auto"
|
|
||||||
style={tokens.optionTextStyle}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<span data-testid="preview-form-radio-option" style={tokens.optionTextStyle}>
|
|
||||||
{opt.label}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</label>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (block.type === "button") {
|
|
||||||
const props = block.props as ButtonBlockProps;
|
|
||||||
|
|
||||||
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(" ");
|
|
||||||
|
|
||||||
const hasImage = typeof (props as any).imageSrc === "string" && (props as any).imageSrc.trim() !== "";
|
|
||||||
const placement = ((props as any).imagePlacement as string | undefined) ?? "left";
|
|
||||||
|
|
||||||
let innerContent: React.ReactNode;
|
|
||||||
if (!hasImage) {
|
|
||||||
innerContent = <span className="whitespace-pre-wrap">{props.label}</span>;
|
|
||||||
} else {
|
|
||||||
const src = (props as any).imageSrc as string;
|
|
||||||
const altRaw = (props as any).imageAlt as string | undefined;
|
|
||||||
const alt = altRaw && altRaw.trim() !== "" ? altRaw.trim() : props.label;
|
|
||||||
|
|
||||||
const imgEl = (
|
|
||||||
// eslint-disable-next-line @next/next/no-img-element
|
|
||||||
<img src={src} alt={alt} className="inline-block max-w-full h-auto" />
|
|
||||||
);
|
|
||||||
|
|
||||||
const labelSpan = <span className="whitespace-pre-wrap">{props.label}</span>;
|
|
||||||
const isVertical = placement === "top" || placement === "bottom";
|
|
||||||
const wrapperClassNames = [
|
|
||||||
"inline-flex",
|
|
||||||
"items-center",
|
|
||||||
"gap-2",
|
|
||||||
isVertical ? "flex-col" : "",
|
|
||||||
]
|
|
||||||
.filter(Boolean)
|
|
||||||
.join(" ");
|
|
||||||
|
|
||||||
const first = placement === "left" || placement === "top" ? imgEl : labelSpan;
|
|
||||||
const second = placement === "left" || placement === "top" ? labelSpan : imgEl;
|
|
||||||
|
|
||||||
innerContent = (
|
|
||||||
<span className={wrapperClassNames}>
|
|
||||||
{first}
|
|
||||||
{second}
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div key={block.id} className={wrapperClass}>
|
|
||||||
<a href={props.href} className={buttonClassName} style={buttonStyle}>
|
|
||||||
{innerContent}
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (block.type === "divider") {
|
|
||||||
const props = block.props as DividerBlockProps;
|
|
||||||
const tokens = computeDividerPublicTokens(props);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={block.id}
|
|
||||||
data-testid="preview-divider"
|
|
||||||
className={`flex w-full ${tokens.alignClass}`}
|
|
||||||
style={{ marginTop: tokens.wrapperMarginEm, marginBottom: tokens.wrapperMarginEm }}
|
|
||||||
>
|
|
||||||
<div className={tokens.innerWidthClass} style={tokens.innerStyle}>
|
|
||||||
<div
|
|
||||||
data-testid="preview-divider-line"
|
|
||||||
className={tokens.thicknessClass}
|
|
||||||
style={tokens.lineStyle}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (block.type === "list") {
|
|
||||||
const props = block.props as ListBlockProps;
|
|
||||||
const tokens = computeListPublicTokens(props);
|
|
||||||
|
|
||||||
const itemsTree: ListItemNode[] =
|
|
||||||
(props as any).itemsTree && (props as any).itemsTree.length > 0
|
|
||||||
? ((props as any).itemsTree as ListItemNode[])
|
|
||||||
: props.items && props.items.length > 0
|
|
||||||
? props.items.map((text, index) => ({
|
|
||||||
id: `${block.id}_item_${index + 1}`,
|
|
||||||
text,
|
|
||||||
children: [],
|
|
||||||
}))
|
|
||||||
: [
|
|
||||||
{
|
|
||||||
id: `${block.id}_item_1`,
|
|
||||||
text: "리스트 아이템 1",
|
|
||||||
children: [],
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const renderListNodes = (nodes: ListItemNode[], level: number): React.ReactNode => {
|
|
||||||
const isOrderedLevel = tokens.bulletStyle === "decimal";
|
|
||||||
const ListTag = isOrderedLevel ? "ol" : "ul";
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ListTag
|
|
||||||
className={`pb-list ${tokens.alignClass}`}
|
|
||||||
style={{
|
|
||||||
...tokens.listStyle,
|
|
||||||
listStyleType:
|
|
||||||
tokens.bulletStyle === "none"
|
|
||||||
? "none"
|
|
||||||
: tokens.bulletStyle,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{nodes.map((node, index) => (
|
|
||||||
<li
|
|
||||||
key={node.id}
|
|
||||||
style={index < nodes.length - 1 ? { marginBottom: `${tokens.gapEm}em` } : undefined}
|
|
||||||
>
|
|
||||||
{node.text}
|
|
||||||
{node.children && node.children.length > 0 && renderListNodes(node.children, level + 1)}
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ListTag>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
return renderListNodes(itemsTree, 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (block.type === "video") {
|
|
||||||
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 });
|
|
||||||
|
|
||||||
const tokens = computeVideoPublicTokens(props);
|
|
||||||
|
|
||||||
const startTimeSec =
|
|
||||||
typeof (props as any).startTimeSec === "number" && (props as any).startTimeSec >= 0
|
|
||||||
? (props as any).startTimeSec
|
|
||||||
: null;
|
|
||||||
const endTimeSec =
|
|
||||||
typeof (props as any).endTimeSec === "number" && (props as any).endTimeSec >= 0
|
|
||||||
? (props as any).endTimeSec
|
|
||||||
: null;
|
|
||||||
|
|
||||||
const isEmbed = platform === "youtube" || platform === "vimeo";
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div key={block.id} className={`w-full flex ${tokens.justifyClass}`}>
|
|
||||||
<div>
|
|
||||||
<div className={`pb-video-wrapper${tokens.aspectClass}`} style={tokens.wrapperStyle}>
|
|
||||||
{isEmbed ? (
|
|
||||||
<iframe
|
|
||||||
title={props.titleText && props.titleText.trim() !== "" ? props.titleText.trim() : "비디오"}
|
|
||||||
src={embedUrl}
|
|
||||||
className="pb-video-frame"
|
|
||||||
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
|
|
||||||
allowFullScreen
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<video
|
|
||||||
data-testid="preview-video"
|
|
||||||
className="pb-video-frame"
|
|
||||||
src={rawUrl}
|
|
||||||
style={tokens.videoStyle}
|
|
||||||
poster={props.posterImageSrc && props.posterImageSrc.trim() !== "" ? props.posterImageSrc.trim() : undefined}
|
|
||||||
aria-label={props.ariaLabel && props.ariaLabel.trim() !== "" ? props.ariaLabel.trim() : undefined}
|
|
||||||
onLoadedMetadata={(event) => {
|
|
||||||
if (startTimeSec != null) {
|
|
||||||
const el = event.currentTarget;
|
|
||||||
try {
|
|
||||||
el.currentTime = startTimeSec;
|
|
||||||
} catch {
|
|
||||||
// ignore
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
onTimeUpdate={(event) => {
|
|
||||||
if (endTimeSec != null) {
|
|
||||||
const el = event.currentTarget;
|
|
||||||
if (el.currentTime >= endTimeSec) {
|
|
||||||
el.pause();
|
|
||||||
try {
|
|
||||||
if (!Number.isNaN(endTimeSec)) {
|
|
||||||
el.currentTime = endTimeSec;
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// ignore
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
controls={props.controls !== false}
|
|
||||||
autoPlay={!!props.autoplay}
|
|
||||||
loop={!!props.loop}
|
|
||||||
muted={!!props.muted}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
{props.captionText && props.captionText.trim() !== "" && (
|
|
||||||
<p
|
|
||||||
data-testid="preview-video-caption"
|
|
||||||
className="mt-2 text-xs text-slate-400 hidden"
|
|
||||||
>
|
|
||||||
{props.captionText}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (block.type === "form") {
|
|
||||||
const props = block.props as FormBlockProps;
|
|
||||||
const tokens = computeFormControllerPublicTokens(block, blocks);
|
|
||||||
const fields = tokens.fields as any[];
|
|
||||||
const hasFields = fields.length > 0;
|
|
||||||
const submitLabelBase = tokens.submitLabel ?? (hasFields ? "폼 전송" : null);
|
|
||||||
|
|
||||||
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
|
|
||||||
e.preventDefault();
|
|
||||||
|
|
||||||
const form = e.currentTarget;
|
|
||||||
const data = new FormData(form);
|
|
||||||
|
|
||||||
try {
|
|
||||||
setFormStatus("submitting");
|
|
||||||
setFormMessage("");
|
|
||||||
|
|
||||||
const res = await fetch("/api/forms/submit", {
|
|
||||||
method: "POST",
|
|
||||||
body: data,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (res.ok) {
|
|
||||||
setFormStatus("success");
|
|
||||||
setFormMessage(props.successMessage ?? "성공적으로 전송되었습니다.");
|
|
||||||
form.reset();
|
|
||||||
} else {
|
|
||||||
setFormStatus("error");
|
|
||||||
setFormMessage(props.errorMessage ?? "전송 중 오류가 발생했습니다.");
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error("[PublicPageRenderer] form submit error", error);
|
|
||||||
setFormStatus("error");
|
|
||||||
setFormMessage(props.errorMessage ?? "전송 중 오류가 발생했습니다.");
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<form
|
|
||||||
key={block.id}
|
|
||||||
data-testid="preview-form-controller"
|
|
||||||
className={tokens.formClassName}
|
|
||||||
style={tokens.formStyle}
|
|
||||||
onSubmit={handleSubmit}
|
|
||||||
>
|
|
||||||
{/* 폼 설정 전체를 서버로 함께 전달하기 위한 hidden 필드 */}
|
|
||||||
<input type="hidden" name="__config" value={JSON.stringify(props)} />
|
|
||||||
{projectSlug && projectSlug.trim() !== "" ? (
|
|
||||||
<input type="hidden" name="__projectSlug" value={projectSlug.trim()} />
|
|
||||||
) : null}
|
|
||||||
{hasFields && (
|
|
||||||
<div className="flex flex-col gap-2 text-xs text-slate-200">
|
|
||||||
{fields.map((field: any) => (
|
|
||||||
<label key={field.id} className="flex flex-col gap-1">
|
|
||||||
<span>{field.label}</span>
|
|
||||||
{field.type === "textarea" ? (
|
|
||||||
<textarea
|
|
||||||
className="w-full min-h-[100px] rounded border border-slate-700 bg-slate-900 px-3 py-2 text-xs outline-none focus:border-sky-500"
|
|
||||||
name={field.name}
|
|
||||||
placeholder={field.label}
|
|
||||||
required={field.required}
|
|
||||||
/>
|
|
||||||
) : field.type === "select" ? (
|
|
||||||
<select
|
|
||||||
className="w-full rounded border border-slate-700 bg-slate-900 px-3 py-2 text-xs outline-none focus:border-sky-500"
|
|
||||||
name={field.name}
|
|
||||||
required={field.required}
|
|
||||||
defaultValue={field.options?.[0]?.value ?? ""}
|
|
||||||
>
|
|
||||||
{(field.options ?? []).map((opt: FormSelectOption) => (
|
|
||||||
<option key={opt.value} value={opt.value}>
|
|
||||||
{opt.label}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
) : field.type === "checkbox" ? (
|
|
||||||
<div className="flex flex-col gap-1">
|
|
||||||
{/* 그룹 타이틀: 텍스트/이미지 모드를 지원한다. alt 는 그룹 라벨 텍스트를 사용한다. */}
|
|
||||||
{field.groupLabelMode === "image" && field.groupLabelImageUrl ? (
|
|
||||||
// eslint-disable-next-line @next/next/no-img-element
|
|
||||||
<img
|
|
||||||
src={field.groupLabelImageUrl}
|
|
||||||
alt={field.label}
|
|
||||||
className="max-h-10 w-auto max-w-[200px]"
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<span>{field.label}</span>
|
|
||||||
)}
|
|
||||||
{(field.options ?? []).map((opt: FormCheckboxOption, index: number) => (
|
|
||||||
<label key={opt.value} className="inline-flex items-center gap-1 text-xs">
|
|
||||||
<input
|
|
||||||
className="h-4 w-4 rounded border-slate-700 bg-slate-900 text-sky-500"
|
|
||||||
type="checkbox"
|
|
||||||
name={field.name}
|
|
||||||
value={opt.value}
|
|
||||||
required={field.required && index === 0}
|
|
||||||
/>
|
|
||||||
{opt.labelImageUrl ? (
|
|
||||||
// eslint-disable-next-line @next/next/no-img-element
|
|
||||||
<img
|
|
||||||
src={opt.labelImageUrl}
|
|
||||||
alt={opt.label || field.label || "체크박스 옵션"}
|
|
||||||
className="h-4 w-auto"
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<span>{opt.label}</span>
|
|
||||||
)}
|
|
||||||
</label>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
) : field.type === "radio" ? (
|
|
||||||
<div className="flex flex-col gap-1">
|
|
||||||
{field.groupLabelMode === "image" && field.groupLabelImageUrl ? (
|
|
||||||
// eslint-disable-next-line @next/next/no-img-element
|
|
||||||
<img
|
|
||||||
src={field.groupLabelImageUrl}
|
|
||||||
alt={field.label}
|
|
||||||
className="max-h-10 w-auto max-w-[200px] md:max-w-[300px] lg:max-w-[400px]"
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<span>{field.label}</span>
|
|
||||||
)}
|
|
||||||
{(field.options ?? []).map((opt: FormRadioOption, index: number) => (
|
|
||||||
<label key={opt.value} className="inline-flex items-center gap-1 text-xs">
|
|
||||||
<input
|
|
||||||
type="radio"
|
|
||||||
className="h-4 w-4 border-slate-700 bg-slate-900 text-sky-500"
|
|
||||||
name={field.name}
|
|
||||||
value={opt.value}
|
|
||||||
required={field.required && index === 0}
|
|
||||||
/>
|
|
||||||
{opt.labelImageUrl ? (
|
|
||||||
// eslint-disable-next-line @next/next/no-img-element
|
|
||||||
<img
|
|
||||||
src={opt.labelImageUrl}
|
|
||||||
alt={opt.label || field.label || "라디오 옵션"}
|
|
||||||
className="h-5 w-auto max-w-[120px]"
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<span>{opt.label}</span>
|
|
||||||
)}
|
|
||||||
</label>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<input
|
|
||||||
className="w-full rounded border border-slate-700 bg-slate-900 px-3 py-2 text-xs outline-none focus:border-sky-500"
|
|
||||||
name={field.name}
|
|
||||||
type={field.type === "email" ? "email" : "text"}
|
|
||||||
placeholder={field.label}
|
|
||||||
required={field.required}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</label>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{submitLabelBase != null && (
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
disabled={formStatus === "submitting"}
|
|
||||||
className="inline-flex items-center justify-center rounded border border-emerald-500 bg-emerald-600 px-4 py-2 text-xs font-medium text-white hover:bg-emerald-500 disabled:opacity-60 disabled:cursor-not-allowed"
|
|
||||||
>
|
|
||||||
{formStatus === "submitting" ? "전송 중..." : submitLabelBase}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{formStatus !== "idle" && formMessage ? (
|
|
||||||
<p
|
|
||||||
className={`text-xs mt-1 ${
|
|
||||||
formStatus === "success" ? "text-emerald-400" : "text-rose-400"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{formMessage}
|
|
||||||
</p>
|
|
||||||
) : null}
|
|
||||||
</form>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (block.type === "image") {
|
|
||||||
const props = block.props as ImageBlockProps;
|
|
||||||
const tokens = computeImagePublicTokens(props);
|
|
||||||
|
|
||||||
const rawSrc = typeof props.src === "string" ? props.src.trim() : "";
|
|
||||||
const hasSrc = rawSrc !== "";
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div key={block.id} className={`w-full flex ${tokens.alignClass}`} style={tokens.wrapperStyle}>
|
|
||||||
{hasSrc && (
|
|
||||||
// eslint-disable-next-line @next/next/no-img-element
|
|
||||||
<img src={rawSrc} alt={props.alt} className="object-contain" style={tokens.imageStyle} />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
};
|
|
||||||
|
|
||||||
const renderSection = (section: Block) => {
|
|
||||||
const props = section.props as SectionBlockProps;
|
|
||||||
|
|
||||||
const { backgroundClass, paddingYClass, maxWidthClass, gapXClass, alignItemsClass } =
|
|
||||||
getSectionLayoutConfig(props);
|
|
||||||
|
|
||||||
const columns = props.columns && props.columns.length > 0 ? props.columns : [{ id: `${section.id}_col`, span: 12 }];
|
|
||||||
const tokens = computeSectionPublicTokens(props);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<section
|
|
||||||
key={section.id}
|
|
||||||
data-testid="preview-section"
|
|
||||||
data-section-id={section.id}
|
|
||||||
className={`${backgroundClass} ${typeof props.paddingYPx === "number" ? "" : paddingYClass}`}
|
|
||||||
style={tokens.sectionStyle}
|
|
||||||
>
|
|
||||||
{tokens.hasBackgroundVideo && tokens.backgroundVideoSrc && (
|
|
||||||
<video
|
|
||||||
data-testid="preview-section-bg-video"
|
|
||||||
className="pb-section-bg-video"
|
|
||||||
src={tokens.backgroundVideoSrc}
|
|
||||||
autoPlay
|
|
||||||
loop
|
|
||||||
muted
|
|
||||||
playsInline
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<div
|
|
||||||
data-testid="preview-section-inner"
|
|
||||||
className={`mx-auto ${typeof props.maxWidthPx === "number" ? "" : maxWidthClass} px-4`}
|
|
||||||
style={tokens.innerWrapperStyle}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
data-testid="preview-section-columns"
|
|
||||||
className={`flex ${typeof props.gapXPx === "number" ? "" : gapXClass} ${alignItemsClass}`}
|
|
||||||
style={tokens.columnsContainerStyle}
|
|
||||||
>
|
|
||||||
{columns.map((col) => {
|
|
||||||
const basis = `${(col.span / 12) * 100}%`;
|
|
||||||
const columnBlocks = blocks.filter((b) => b.sectionId === section.id && b.columnId === col.id);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div key={col.id} className="flex flex-col gap-4" style={{ flexBasis: basis }}>
|
|
||||||
{columnBlocks.map((b) => (
|
|
||||||
<div key={b.id}>{renderBlock(b)}</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex-1 flex flex-col text-slate-50">
|
|
||||||
{/* 루트 텍스트/버튼/이미지 블록들이 있다면 페이지 상단에 노출 */}
|
|
||||||
{rootBlocks.length > 0 && (
|
|
||||||
<section className="py-12">
|
|
||||||
<div className="mx-auto max-w-3xl px-4 flex flex-col gap-4">
|
|
||||||
{rootBlocks.map((b) => (
|
|
||||||
<div key={b.id}>{renderBlock(b)}</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 섹션 블록들 */}
|
|
||||||
{sectionBlocks.map((section) => renderSection(section))}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,251 +0,0 @@
|
|||||||
import type { CSSProperties } from "react";
|
|
||||||
import type { ButtonBlockProps } from "@/features/editor/state/editorStore";
|
|
||||||
|
|
||||||
export type ButtonAlign = "left" | "center" | "right" | null | undefined;
|
|
||||||
export type ButtonSize = "xs" | "sm" | "md" | "lg" | "xl" | null | undefined;
|
|
||||||
export type ButtonVariant = "solid" | "outline" | "ghost" | null | undefined;
|
|
||||||
export type ButtonColorPalette = "primary" | "muted" | "danger" | "success" | "neutral" | null | undefined;
|
|
||||||
export type ButtonRadius = "none" | "sm" | "md" | "lg" | "full" | null | undefined;
|
|
||||||
|
|
||||||
export interface ButtonStyleInput {
|
|
||||||
align?: ButtonAlign;
|
|
||||||
size?: ButtonSize;
|
|
||||||
variant?: ButtonVariant;
|
|
||||||
colorPalette?: ButtonColorPalette;
|
|
||||||
borderRadius?: ButtonRadius;
|
|
||||||
fullWidth?: boolean | null | undefined;
|
|
||||||
widthMode?: "auto" | "full" | "fixed" | null | undefined;
|
|
||||||
widthPx?: number | null | undefined;
|
|
||||||
paddingX?: number | null | undefined;
|
|
||||||
paddingY?: number | null | undefined;
|
|
||||||
fillColorCustom?: string | null | undefined;
|
|
||||||
strokeColorCustom?: string | null | undefined;
|
|
||||||
textColorCustom?: string | null | undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ButtonPbTokens {
|
|
||||||
alignClass: string;
|
|
||||||
sizeClass: string;
|
|
||||||
variantClass: string;
|
|
||||||
radiusClass: string;
|
|
||||||
inlineStyles: string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export function computeButtonPbTokens(input: ButtonStyleInput): ButtonPbTokens {
|
|
||||||
const align = input.align === "center" ? "center" : input.align === "right" ? "right" : "left";
|
|
||||||
const alignClass =
|
|
||||||
align === "center" ? "pb-text-center" : align === "right" ? "pb-text-right" : "pb-text-left";
|
|
||||||
|
|
||||||
const sizeToken = (input.size as ButtonSize | null) ?? "md";
|
|
||||||
const sizeMap: Record<Exclude<ButtonSize, null | undefined>, string> = {
|
|
||||||
xs: "pb-btn-size-xs",
|
|
||||||
sm: "pb-btn-size-sm",
|
|
||||||
md: "pb-btn-size-md",
|
|
||||||
lg: "pb-btn-size-lg",
|
|
||||||
xl: "pb-btn-size-xl",
|
|
||||||
};
|
|
||||||
const sizeClass = sizeMap[sizeToken as Exclude<ButtonSize, null | undefined>] ?? "pb-btn-size-md";
|
|
||||||
|
|
||||||
const variant = (input.variant as ButtonVariant | null) ?? "solid";
|
|
||||||
const palette = (input.colorPalette as ButtonColorPalette | null) ?? "primary";
|
|
||||||
const variantClass = `pb-btn-variant-${variant}-${palette}`;
|
|
||||||
|
|
||||||
const radiusToken = (input.borderRadius as ButtonRadius | null) ?? "md";
|
|
||||||
const radiusMap: Record<Exclude<ButtonRadius, null | undefined>, string> = {
|
|
||||||
none: "pb-btn-radius-none",
|
|
||||||
sm: "pb-btn-radius-sm",
|
|
||||||
md: "pb-btn-radius-md",
|
|
||||||
lg: "pb-btn-radius-lg",
|
|
||||||
full: "pb-btn-radius-full",
|
|
||||||
};
|
|
||||||
const radiusClass = radiusMap[radiusToken as Exclude<ButtonRadius, null | undefined>] ?? "";
|
|
||||||
|
|
||||||
const inlineStyles: string[] = [];
|
|
||||||
|
|
||||||
const widthMode = input.widthMode ?? (input.fullWidth ? "full" : "auto");
|
|
||||||
if (widthMode === "fixed" && typeof input.widthPx === "number" && input.widthPx > 0) {
|
|
||||||
inlineStyles.push(`width:${pxToEm(input.widthPx)}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (typeof input.paddingX === "number") {
|
|
||||||
const paddingInline = pxToEm(input.paddingX);
|
|
||||||
inlineStyles.push(`padding-left:${paddingInline}`);
|
|
||||||
inlineStyles.push(`padding-right:${paddingInline}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (typeof input.paddingY === "number") {
|
|
||||||
const paddingBlock = pxToEm(input.paddingY);
|
|
||||||
inlineStyles.push(`padding-top:${paddingBlock}`);
|
|
||||||
inlineStyles.push(`padding-bottom:${paddingBlock}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (input.fillColorCustom && input.fillColorCustom.trim() !== "") {
|
|
||||||
inlineStyles.push(`background-color:${input.fillColorCustom.trim()}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (input.strokeColorCustom && input.strokeColorCustom.trim() !== "") {
|
|
||||||
inlineStyles.push(`border-color:${input.strokeColorCustom.trim()}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (input.textColorCustom && input.textColorCustom.trim() !== "") {
|
|
||||||
inlineStyles.push(`color:${input.textColorCustom.trim()}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
alignClass,
|
|
||||||
sizeClass,
|
|
||||||
variantClass,
|
|
||||||
radiusClass,
|
|
||||||
inlineStyles,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// 퍼블릭 렌더러용 버튼 스타일 토큰
|
|
||||||
export interface ButtonPublicTokens {
|
|
||||||
alignClass: string;
|
|
||||||
widthClass: string;
|
|
||||||
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 computeButtonPublicTokens(props: ButtonBlockProps): ButtonPublicTokens {
|
|
||||||
const align = props.align ?? "left";
|
|
||||||
const alignClass =
|
|
||||||
align === "center" ? "text-center" : align === "right" ? "text-right" : "text-left";
|
|
||||||
|
|
||||||
const styleOverrides: CSSProperties = {};
|
|
||||||
|
|
||||||
// 폰트 크기: fontSizeCustom(px)는 em 으로 변환, 그 외는 그대로 사용
|
|
||||||
const fontSizeEm = convertPxStringToEm(props.fontSizeCustom);
|
|
||||||
if (fontSizeEm) {
|
|
||||||
styleOverrides.fontSize = fontSizeEm;
|
|
||||||
}
|
|
||||||
|
|
||||||
// lineHeightCustom 은 px 단위든 아니든 그대로 사용 (기존 PublicPageRenderer 로직 유지)
|
|
||||||
if (props.lineHeightCustom && props.lineHeightCustom.trim() !== "") {
|
|
||||||
styleOverrides.lineHeight = props.lineHeightCustom;
|
|
||||||
}
|
|
||||||
|
|
||||||
// letterSpacingCustom 은 px 인 경우만 em 으로 변환, 그 외는 그대로 사용
|
|
||||||
if (props.letterSpacingCustom && props.letterSpacingCustom.trim() !== "") {
|
|
||||||
const letterSpacing = props.letterSpacingCustom.trim();
|
|
||||||
if (letterSpacing.endsWith("px")) {
|
|
||||||
const emValue = convertPxStringToEm(letterSpacing);
|
|
||||||
if (emValue) {
|
|
||||||
styleOverrides.letterSpacing = emValue;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
styleOverrides.letterSpacing = letterSpacing;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const widthMode = props.widthMode ?? (props.fullWidth ? "full" : "auto");
|
|
||||||
if (widthMode === "fixed" && typeof props.widthPx === "number" && props.widthPx > 0) {
|
|
||||||
styleOverrides.width = pxToEm(props.widthPx);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (typeof props.paddingX === "number" && props.paddingX >= 0) {
|
|
||||||
styleOverrides.paddingInline = pxToEm(props.paddingX);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (typeof props.paddingY === "number" && props.paddingY >= 0) {
|
|
||||||
styleOverrides.paddingBlock = pxToEm(props.paddingY);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (props.fillColorCustom && props.fillColorCustom.trim() !== "") {
|
|
||||||
styleOverrides.backgroundColor = props.fillColorCustom;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (props.strokeColorCustom && props.strokeColorCustom.trim() !== "") {
|
|
||||||
styleOverrides.borderColor = props.strokeColorCustom;
|
|
||||||
styleOverrides.borderWidth = "1px";
|
|
||||||
styleOverrides.borderStyle = "solid";
|
|
||||||
}
|
|
||||||
|
|
||||||
if (props.textColorCustom && props.textColorCustom.trim() !== "") {
|
|
||||||
styleOverrides.color = props.textColorCustom;
|
|
||||||
}
|
|
||||||
|
|
||||||
const radiusToken = props.borderRadius ?? "md";
|
|
||||||
const radiusPx =
|
|
||||||
radiusToken === "none"
|
|
||||||
? 0
|
|
||||||
: radiusToken === "sm"
|
|
||||||
? 4
|
|
||||||
: radiusToken === "lg"
|
|
||||||
? 12
|
|
||||||
: radiusToken === "full"
|
|
||||||
? 9999
|
|
||||||
: 8;
|
|
||||||
styleOverrides.borderRadius = radiusPx === 9999 ? "9999px" : pxToEm(radiusPx);
|
|
||||||
|
|
||||||
const widthClass = widthMode === "full" ? "w-full" : "";
|
|
||||||
|
|
||||||
return {
|
|
||||||
alignClass,
|
|
||||||
widthClass,
|
|
||||||
styleOverrides,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ButtonEditorTokens {
|
|
||||||
wrapperWidthClass: string;
|
|
||||||
buttonStyle: CSSProperties;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function computeButtonEditorTokens(props: ButtonBlockProps): ButtonEditorTokens {
|
|
||||||
const baseFullWidth = props.fullWidth ?? false;
|
|
||||||
const widthMode = props.widthMode ?? (baseFullWidth ? "full" : "auto");
|
|
||||||
const isFullWidth = widthMode === "full" || baseFullWidth;
|
|
||||||
|
|
||||||
const wrapperWidthClass = isFullWidth ? "w-full" : "inline-block";
|
|
||||||
|
|
||||||
const buttonStyle: CSSProperties = {};
|
|
||||||
|
|
||||||
if (props.fontSizeCustom && props.fontSizeCustom.trim() !== "") {
|
|
||||||
buttonStyle.fontSize = props.fontSizeCustom;
|
|
||||||
}
|
|
||||||
if (props.lineHeightCustom && props.lineHeightCustom.trim() !== "") {
|
|
||||||
buttonStyle.lineHeight = props.lineHeightCustom;
|
|
||||||
}
|
|
||||||
if (props.letterSpacingCustom && props.letterSpacingCustom.trim() !== "") {
|
|
||||||
buttonStyle.letterSpacing = props.letterSpacingCustom;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (props.fillColorCustom && props.fillColorCustom.trim() !== "") {
|
|
||||||
buttonStyle.backgroundColor = props.fillColorCustom;
|
|
||||||
}
|
|
||||||
if (props.strokeColorCustom && props.strokeColorCustom.trim() !== "") {
|
|
||||||
buttonStyle.borderColor = props.strokeColorCustom;
|
|
||||||
}
|
|
||||||
if (props.textColorCustom && props.textColorCustom.trim() !== "") {
|
|
||||||
buttonStyle.color = props.textColorCustom;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (widthMode === "fixed" && typeof props.widthPx === "number" && props.widthPx > 0) {
|
|
||||||
buttonStyle.width = `${props.widthPx}px`;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (typeof props.paddingX === "number" && props.paddingX >= 0) {
|
|
||||||
buttonStyle.paddingInline = `${props.paddingX}px`;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (typeof props.paddingY === "number" && props.paddingY >= 0) {
|
|
||||||
buttonStyle.paddingBlock = `${props.paddingY}px`;
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
wrapperWidthClass,
|
|
||||||
buttonStyle,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,138 +0,0 @@
|
|||||||
import type { CSSProperties } from "react";
|
|
||||||
import type { DividerBlockProps } from "@/features/editor/state/editorStore";
|
|
||||||
|
|
||||||
export interface DividerEscapers {
|
|
||||||
escapeAttr: (value: string) => string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface DividerExportTokens {
|
|
||||||
style: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const computeDividerExportTokens = (
|
|
||||||
props: DividerBlockProps,
|
|
||||||
escapers: DividerEscapers,
|
|
||||||
): DividerExportTokens => {
|
|
||||||
const { escapeAttr } = escapers;
|
|
||||||
|
|
||||||
const thickness = props.thickness === "medium" ? "2px" : "1px";
|
|
||||||
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:${marginEm} 0;`;
|
|
||||||
|
|
||||||
return { style };
|
|
||||||
};
|
|
||||||
|
|
||||||
export interface DividerEditorTokens {
|
|
||||||
thicknessClass: string;
|
|
||||||
innerWidthClass: string;
|
|
||||||
widthPx?: number;
|
|
||||||
borderColor: string;
|
|
||||||
marginPx: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const computeDividerEditorTokens = (props: DividerBlockProps): DividerEditorTokens => {
|
|
||||||
const thicknessClass = props.thickness === "medium" ? "border-t-2" : "border-t";
|
|
||||||
|
|
||||||
const colorRaw = typeof props.colorHex === "string" ? props.colorHex.trim() : "";
|
|
||||||
const borderColor = colorRaw !== "" ? colorRaw : "#475569";
|
|
||||||
|
|
||||||
const widthMode = props.widthMode ?? "full";
|
|
||||||
let innerWidthClass = "w-full";
|
|
||||||
let widthPx: number | undefined;
|
|
||||||
|
|
||||||
if (widthMode === "auto") {
|
|
||||||
innerWidthClass = "w-1/2";
|
|
||||||
} else if (widthMode === "fixed") {
|
|
||||||
innerWidthClass = "";
|
|
||||||
if (typeof props.widthPx === "number" && props.widthPx > 0) {
|
|
||||||
widthPx = props.widthPx;
|
|
||||||
} else {
|
|
||||||
widthPx = 320;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const marginPx =
|
|
||||||
typeof props.marginYPx === "number"
|
|
||||||
? props.marginYPx
|
|
||||||
: props.marginY === "sm"
|
|
||||||
? 8
|
|
||||||
: props.marginY === "lg"
|
|
||||||
? 24
|
|
||||||
: 16;
|
|
||||||
|
|
||||||
return {
|
|
||||||
thicknessClass,
|
|
||||||
innerWidthClass,
|
|
||||||
widthPx,
|
|
||||||
borderColor,
|
|
||||||
marginPx,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
export interface DividerPublicTokens {
|
|
||||||
alignClass: string;
|
|
||||||
thicknessClass: string;
|
|
||||||
innerWidthClass: string;
|
|
||||||
wrapperMarginEm: string;
|
|
||||||
innerStyle: CSSProperties;
|
|
||||||
lineStyle: CSSProperties;
|
|
||||||
}
|
|
||||||
|
|
||||||
const pxToEm = (px: number, base = 16) => `${px / base}em`;
|
|
||||||
|
|
||||||
export const computeDividerPublicTokens = (props: DividerBlockProps): DividerPublicTokens => {
|
|
||||||
const alignClass =
|
|
||||||
props.align === "center"
|
|
||||||
? "items-center"
|
|
||||||
: props.align === "right"
|
|
||||||
? "items-end"
|
|
||||||
: "items-start";
|
|
||||||
|
|
||||||
const thicknessClass = props.thickness === "medium" ? "h-[2px]" : "h-px";
|
|
||||||
|
|
||||||
const marginPx =
|
|
||||||
typeof props.marginYPx === "number"
|
|
||||||
? props.marginYPx
|
|
||||||
: props.marginY === "sm"
|
|
||||||
? 8
|
|
||||||
: props.marginY === "lg"
|
|
||||||
? 24
|
|
||||||
: 16;
|
|
||||||
const wrapperMarginEm = pxToEm(marginPx);
|
|
||||||
|
|
||||||
const colorRaw = typeof props.colorHex === "string" ? props.colorHex.trim() : "";
|
|
||||||
const backgroundColor = colorRaw !== "" ? colorRaw : "#475569";
|
|
||||||
|
|
||||||
const widthMode = props.widthMode ?? "full";
|
|
||||||
const innerStyle: CSSProperties = {};
|
|
||||||
let innerWidthClass = "w-full";
|
|
||||||
const lineStyle: CSSProperties = {
|
|
||||||
backgroundColor,
|
|
||||||
};
|
|
||||||
|
|
||||||
if (widthMode === "auto") {
|
|
||||||
innerWidthClass = "w-1/2";
|
|
||||||
} else if (widthMode === "fixed") {
|
|
||||||
innerWidthClass = "";
|
|
||||||
innerStyle.width = "auto";
|
|
||||||
|
|
||||||
if (typeof props.widthPx === "number" && props.widthPx > 0) {
|
|
||||||
lineStyle.width = pxToEm(props.widthPx);
|
|
||||||
} else {
|
|
||||||
lineStyle.width = pxToEm(320);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
alignClass,
|
|
||||||
thicknessClass,
|
|
||||||
innerWidthClass,
|
|
||||||
wrapperMarginEm,
|
|
||||||
innerStyle,
|
|
||||||
lineStyle,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
@@ -1,984 +0,0 @@
|
|||||||
import type { CSSProperties } from "react";
|
|
||||||
import type {
|
|
||||||
Block,
|
|
||||||
ButtonBlockProps,
|
|
||||||
FormBlockProps,
|
|
||||||
FormCheckboxBlockProps,
|
|
||||||
FormCheckboxOption,
|
|
||||||
FormFieldConfig,
|
|
||||||
FormInputBlockProps,
|
|
||||||
FormRadioBlockProps,
|
|
||||||
FormRadioOption,
|
|
||||||
FormSelectBlockProps,
|
|
||||||
FormSelectOption,
|
|
||||||
} from "@/features/editor/state/editorStore";
|
|
||||||
|
|
||||||
export interface FormStyleEscapers {
|
|
||||||
escapeAttr: (value: string) => string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const defaultEscapers: FormStyleEscapers = {
|
|
||||||
escapeAttr: (value) => value,
|
|
||||||
};
|
|
||||||
|
|
||||||
export interface FormInputExportTokens {
|
|
||||||
labelStyleAttr: string;
|
|
||||||
inputStyleAttr: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface FormSelectExportTokens {
|
|
||||||
labelStyleAttr: string;
|
|
||||||
selectStyleAttr: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface FormOptionGroupExportTokens {
|
|
||||||
labelStyleAttr: string;
|
|
||||||
optionStyleAttr: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const normalizeTextColor = (raw: unknown): string => {
|
|
||||||
if (typeof raw !== "string") return "";
|
|
||||||
const trimmed = raw.trim();
|
|
||||||
return trimmed !== "" ? trimmed : "";
|
|
||||||
};
|
|
||||||
|
|
||||||
const computeRadiusPx = (radius: unknown): number => {
|
|
||||||
const token = typeof radius === "string" ? radius : "md";
|
|
||||||
if (token === "none") return 0;
|
|
||||||
if (token === "sm") return 2;
|
|
||||||
if (token === "lg") return 6;
|
|
||||||
if (token === "full") return 9999;
|
|
||||||
return 4;
|
|
||||||
};
|
|
||||||
|
|
||||||
const computeWidthMode = (props: { widthMode?: string; fullWidth?: boolean }): "auto" | "full" | "fixed" => {
|
|
||||||
if (typeof props.widthMode === "string") {
|
|
||||||
return props.widthMode as "auto" | "full" | "fixed";
|
|
||||||
}
|
|
||||||
if (props.fullWidth) {
|
|
||||||
return "full";
|
|
||||||
}
|
|
||||||
return "auto";
|
|
||||||
};
|
|
||||||
|
|
||||||
export const computeFormInputExportTokens = (
|
|
||||||
props: FormInputBlockProps,
|
|
||||||
escapers: FormStyleEscapers = defaultEscapers,
|
|
||||||
): FormInputExportTokens => {
|
|
||||||
const { escapeAttr } = escapers;
|
|
||||||
|
|
||||||
const textColorRaw = normalizeTextColor(props.textColorCustom);
|
|
||||||
const labelStyleAttr = textColorRaw ? ` style="color:${escapeAttr(textColorRaw)}"` : "";
|
|
||||||
|
|
||||||
const inputStyleParts: string[] = [];
|
|
||||||
if (textColorRaw) {
|
|
||||||
inputStyleParts.push(`color:${escapeAttr(textColorRaw)}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (typeof props.fillColorCustom === "string" && props.fillColorCustom.trim() !== "") {
|
|
||||||
inputStyleParts.push(`background-color:${escapeAttr(props.fillColorCustom.trim())}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (typeof props.strokeColorCustom === "string" && props.strokeColorCustom.trim() !== "") {
|
|
||||||
inputStyleParts.push(`border-color:${escapeAttr(props.strokeColorCustom.trim())}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (typeof props.paddingX === "number" && props.paddingX >= 0) {
|
|
||||||
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 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) {
|
|
||||||
const em = props.widthPx / 16;
|
|
||||||
inputStyleParts.push(`width:${em}em`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const radiusPx = computeRadiusPx(props.borderRadius);
|
|
||||||
inputStyleParts.push(`border-radius:${radiusPx}px`);
|
|
||||||
|
|
||||||
const inputStyleAttr = inputStyleParts.length > 0 ? ` style="${inputStyleParts.join(";")}"` : "";
|
|
||||||
|
|
||||||
return {
|
|
||||||
labelStyleAttr,
|
|
||||||
inputStyleAttr,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
export const computeFormSelectExportTokens = (
|
|
||||||
props: FormSelectBlockProps,
|
|
||||||
escapers: FormStyleEscapers = defaultEscapers,
|
|
||||||
): FormSelectExportTokens => {
|
|
||||||
const { escapeAttr } = escapers;
|
|
||||||
|
|
||||||
const textColorRaw = normalizeTextColor(props.textColorCustom);
|
|
||||||
const labelStyleAttr = textColorRaw ? ` style="color:${escapeAttr(textColorRaw)}"` : "";
|
|
||||||
|
|
||||||
const selectStyleParts: string[] = [];
|
|
||||||
if (textColorRaw) {
|
|
||||||
selectStyleParts.push(`color:${escapeAttr(textColorRaw)}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const widthMode = computeWidthMode({ widthMode: props.widthMode, fullWidth: props.fullWidth });
|
|
||||||
if (widthMode === "fixed" && typeof props.widthPx === "number" && props.widthPx > 0) {
|
|
||||||
const em = props.widthPx / 16;
|
|
||||||
selectStyleParts.push(`width:${em}em`);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (typeof props.paddingX === "number" && props.paddingX >= 0) {
|
|
||||||
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 em = props.paddingY / 16;
|
|
||||||
selectStyleParts.push(`padding-top:${em}em`);
|
|
||||||
selectStyleParts.push(`padding-bottom:${em}em`);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (typeof props.fillColorCustom === "string" && props.fillColorCustom.trim() !== "") {
|
|
||||||
selectStyleParts.push(`background-color:${escapeAttr(props.fillColorCustom.trim())}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (typeof props.strokeColorCustom === "string" && props.strokeColorCustom.trim() !== "") {
|
|
||||||
selectStyleParts.push(`border-color:${escapeAttr(props.strokeColorCustom.trim())}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const radiusPx = computeRadiusPx(props.borderRadius);
|
|
||||||
selectStyleParts.push(`border-radius:${radiusPx}px`);
|
|
||||||
|
|
||||||
const selectStyleAttr = selectStyleParts.length > 0 ? ` style="${selectStyleParts.join(";")}"` : "";
|
|
||||||
|
|
||||||
return {
|
|
||||||
labelStyleAttr,
|
|
||||||
selectStyleAttr,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
const computeOptionGroupExportTokensBase = (
|
|
||||||
props: { textColorCustom?: string; paddingX?: number; paddingY?: number; fillColorCustom?: string; strokeColorCustom?: string; borderRadius?: string },
|
|
||||||
escapers: FormStyleEscapers,
|
|
||||||
): FormOptionGroupExportTokens => {
|
|
||||||
const { escapeAttr } = escapers;
|
|
||||||
|
|
||||||
const textColorRaw = normalizeTextColor(props.textColorCustom);
|
|
||||||
const labelStyleAttr = textColorRaw ? ` style="color:${escapeAttr(textColorRaw)}"` : "";
|
|
||||||
|
|
||||||
const optionStyleParts: string[] = [];
|
|
||||||
if (textColorRaw) {
|
|
||||||
optionStyleParts.push(`color:${escapeAttr(textColorRaw)}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (typeof props.paddingX === "number" && props.paddingX >= 0) {
|
|
||||||
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 em = props.paddingY / 16;
|
|
||||||
optionStyleParts.push(`padding-top:${em}em`);
|
|
||||||
optionStyleParts.push(`padding-bottom:${em}em`);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (typeof props.fillColorCustom === "string" && props.fillColorCustom.trim() !== "") {
|
|
||||||
optionStyleParts.push(`background-color:${escapeAttr(props.fillColorCustom.trim())}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (typeof props.strokeColorCustom === "string" && props.strokeColorCustom.trim() !== "") {
|
|
||||||
optionStyleParts.push(`border-color:${escapeAttr(props.strokeColorCustom.trim())}`);
|
|
||||||
optionStyleParts.push("border-width:1px");
|
|
||||||
optionStyleParts.push("border-style:solid");
|
|
||||||
}
|
|
||||||
|
|
||||||
const radiusPx = computeRadiusPx(props.borderRadius);
|
|
||||||
optionStyleParts.push(`border-radius:${radiusPx}px`);
|
|
||||||
|
|
||||||
const optionStyleAttr = optionStyleParts.length > 0 ? ` style="${optionStyleParts.join(";")}"` : "";
|
|
||||||
|
|
||||||
return {
|
|
||||||
labelStyleAttr,
|
|
||||||
optionStyleAttr,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
export const computeFormCheckboxExportTokens = (
|
|
||||||
props: FormCheckboxBlockProps,
|
|
||||||
escapers: FormStyleEscapers = defaultEscapers,
|
|
||||||
): FormOptionGroupExportTokens =>
|
|
||||||
computeOptionGroupExportTokensBase(
|
|
||||||
{
|
|
||||||
textColorCustom: props.textColorCustom,
|
|
||||||
paddingX: props.paddingX,
|
|
||||||
paddingY: props.paddingY,
|
|
||||||
fillColorCustom: props.fillColorCustom,
|
|
||||||
strokeColorCustom: props.strokeColorCustom,
|
|
||||||
borderRadius: props.borderRadius,
|
|
||||||
},
|
|
||||||
escapers,
|
|
||||||
);
|
|
||||||
|
|
||||||
export interface FormInputEditorTokens {
|
|
||||||
fieldStyle: CSSProperties;
|
|
||||||
widthClass: string;
|
|
||||||
inputAlignClass: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface FormFieldEditorTokens {
|
|
||||||
fieldStyle: CSSProperties;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const computeFormInputEditorTokens = (props: FormInputBlockProps): FormInputEditorTokens => {
|
|
||||||
const fieldStyle: CSSProperties = {};
|
|
||||||
|
|
||||||
if (props.textColorCustom && props.textColorCustom.trim() !== "") {
|
|
||||||
fieldStyle.color = props.textColorCustom;
|
|
||||||
}
|
|
||||||
if (props.fillColorCustom && props.fillColorCustom.trim() !== "") {
|
|
||||||
fieldStyle.backgroundColor = props.fillColorCustom;
|
|
||||||
}
|
|
||||||
if (props.strokeColorCustom && props.strokeColorCustom.trim() !== "") {
|
|
||||||
fieldStyle.borderColor = props.strokeColorCustom;
|
|
||||||
}
|
|
||||||
|
|
||||||
const widthMode = props.widthMode ?? "full";
|
|
||||||
if (widthMode === "fixed" && typeof props.widthPx === "number" && props.widthPx > 0) {
|
|
||||||
fieldStyle.width = `${props.widthPx}px`;
|
|
||||||
}
|
|
||||||
|
|
||||||
const radius = props.borderRadius ?? "md";
|
|
||||||
if (radius === "none") {
|
|
||||||
fieldStyle.borderRadius = 0;
|
|
||||||
} else if (radius === "sm") {
|
|
||||||
fieldStyle.borderRadius = 4;
|
|
||||||
} else if (radius === "lg" || radius === "full") {
|
|
||||||
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";
|
|
||||||
|
|
||||||
const align = props.align ?? "left";
|
|
||||||
const inputAlignClass =
|
|
||||||
align === "center" ? "text-center" : align === "right" ? "text-right" : "text-left";
|
|
||||||
|
|
||||||
// widthMode 와 labelLayout 에 따라 wrapper 의 Tailwind width/flex 클래스를 결정한다.
|
|
||||||
let widthClass = "w-full";
|
|
||||||
if (isInline) {
|
|
||||||
if (widthMode === "fixed") {
|
|
||||||
// 고정 너비일 때는 flex 레이아웃의 너비 확장을 막기 위해 flex-none 으로 둔다.
|
|
||||||
widthClass = "flex-none";
|
|
||||||
} else if (widthMode === "full") {
|
|
||||||
widthClass = "flex-1";
|
|
||||||
} else {
|
|
||||||
// auto: 인라인 레이아웃에서 내용 기반 너비 사용
|
|
||||||
widthClass = "";
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
if (widthMode === "fixed") {
|
|
||||||
widthClass = ""; // 고정 너비는 style.width 로만 제어
|
|
||||||
} else {
|
|
||||||
widthClass = "w-full";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
fieldStyle,
|
|
||||||
widthClass,
|
|
||||||
inputAlignClass,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
export const computeFormSelectEditorTokens = (props: FormSelectBlockProps): FormFieldEditorTokens => {
|
|
||||||
const fieldStyle: CSSProperties = {};
|
|
||||||
|
|
||||||
if (props.textColorCustom && props.textColorCustom.trim() !== "") {
|
|
||||||
fieldStyle.color = props.textColorCustom;
|
|
||||||
}
|
|
||||||
if (props.fillColorCustom && props.fillColorCustom.trim() !== "") {
|
|
||||||
fieldStyle.backgroundColor = props.fillColorCustom;
|
|
||||||
}
|
|
||||||
if (props.strokeColorCustom && props.strokeColorCustom.trim() !== "") {
|
|
||||||
fieldStyle.borderColor = props.strokeColorCustom;
|
|
||||||
}
|
|
||||||
|
|
||||||
const widthMode = props.widthMode ?? "full";
|
|
||||||
if (widthMode === "fixed" && typeof props.widthPx === "number" && props.widthPx > 0) {
|
|
||||||
fieldStyle.width = `${props.widthPx}px`;
|
|
||||||
}
|
|
||||||
|
|
||||||
const radius = props.borderRadius ?? "md";
|
|
||||||
if (radius === "none") {
|
|
||||||
fieldStyle.borderRadius = 0;
|
|
||||||
} else if (radius === "sm") {
|
|
||||||
fieldStyle.borderRadius = 4;
|
|
||||||
} else if (radius === "lg" || radius === "full") {
|
|
||||||
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 };
|
|
||||||
};
|
|
||||||
|
|
||||||
export const computeFormOptionGroupEditorTokens = (
|
|
||||||
props: FormCheckboxBlockProps | FormRadioBlockProps,
|
|
||||||
): FormFieldEditorTokens => {
|
|
||||||
const fieldStyle: CSSProperties = {};
|
|
||||||
|
|
||||||
if (props.textColorCustom && props.textColorCustom.trim() !== "") {
|
|
||||||
fieldStyle.color = props.textColorCustom;
|
|
||||||
}
|
|
||||||
if (props.fillColorCustom && props.fillColorCustom.trim() !== "") {
|
|
||||||
fieldStyle.backgroundColor = props.fillColorCustom;
|
|
||||||
}
|
|
||||||
if (props.strokeColorCustom && props.strokeColorCustom.trim() !== "") {
|
|
||||||
fieldStyle.borderColor = props.strokeColorCustom;
|
|
||||||
}
|
|
||||||
|
|
||||||
const widthMode = props.widthMode ?? "full";
|
|
||||||
if (widthMode === "fixed" && typeof props.widthPx === "number" && props.widthPx > 0) {
|
|
||||||
fieldStyle.width = `${props.widthPx}px`;
|
|
||||||
}
|
|
||||||
|
|
||||||
const radius = props.borderRadius ?? "md";
|
|
||||||
if (radius === "none") {
|
|
||||||
fieldStyle.borderRadius = 0;
|
|
||||||
} else if (radius === "sm") {
|
|
||||||
fieldStyle.borderRadius = 4;
|
|
||||||
} 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 };
|
|
||||||
};
|
|
||||||
|
|
||||||
export const computeFormRadioExportTokens = (
|
|
||||||
props: FormRadioBlockProps,
|
|
||||||
escapers: FormStyleEscapers = defaultEscapers,
|
|
||||||
): FormOptionGroupExportTokens =>
|
|
||||||
computeOptionGroupExportTokensBase(
|
|
||||||
{
|
|
||||||
textColorCustom: props.textColorCustom,
|
|
||||||
paddingX: props.paddingX,
|
|
||||||
paddingY: props.paddingY,
|
|
||||||
fillColorCustom: props.fillColorCustom,
|
|
||||||
strokeColorCustom: props.strokeColorCustom,
|
|
||||||
borderRadius: props.borderRadius,
|
|
||||||
},
|
|
||||||
escapers,
|
|
||||||
);
|
|
||||||
|
|
||||||
// 퍼블릭 렌더러용 폼 필드 스타일 토큰
|
|
||||||
|
|
||||||
export interface FormInputPublicTokens {
|
|
||||||
wrapperLayoutClass: string;
|
|
||||||
wrapperStyle: CSSProperties;
|
|
||||||
widthClass: string;
|
|
||||||
inputStyle: CSSProperties;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface FormSelectPublicTokens {
|
|
||||||
widthClass: string;
|
|
||||||
wrapperStyle: CSSProperties;
|
|
||||||
selectStyle: CSSProperties;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface FormOptionGroupPublicTokens {
|
|
||||||
widthClass: string;
|
|
||||||
textSizeClass: string;
|
|
||||||
groupStyle: CSSProperties;
|
|
||||||
groupTextStyle: CSSProperties;
|
|
||||||
optionContainerStyle: CSSProperties;
|
|
||||||
optionTextStyle: CSSProperties;
|
|
||||||
optionsStyle: 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 const computeFormInputPublicTokens = (props: FormInputBlockProps): FormInputPublicTokens => {
|
|
||||||
const align = props.align ?? "left";
|
|
||||||
const labelLayout = props.labelLayout ?? "stacked";
|
|
||||||
const widthMode = props.widthMode ?? (props.fullWidth ? "full" : "auto");
|
|
||||||
|
|
||||||
const isInlineLayout = labelLayout === "inline";
|
|
||||||
const wrapperLayoutClass = isInlineLayout ? "flex flex-row items-center" : "flex flex-col gap-1";
|
|
||||||
const wrapperStyle: CSSProperties = {};
|
|
||||||
|
|
||||||
if (isInlineLayout) {
|
|
||||||
const gapPx = typeof props.labelGapPx === "number" ? props.labelGapPx : 8;
|
|
||||||
wrapperStyle.columnGap = pxToEm(gapPx);
|
|
||||||
}
|
|
||||||
|
|
||||||
const widthClass = widthMode === "full" ? "w-full" : "";
|
|
||||||
|
|
||||||
const inputStyle: CSSProperties = {
|
|
||||||
textAlign: align,
|
|
||||||
};
|
|
||||||
|
|
||||||
// 너비: fixed 모드일 때 widthPx 를 em 단위로 설정한다.
|
|
||||||
if (widthMode === "fixed" && typeof props.widthPx === "number" && props.widthPx > 0) {
|
|
||||||
inputStyle.width = pxToEm(props.widthPx);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 타이포 관련 커스텀 값
|
|
||||||
if (props.fontSizeCustom && props.fontSizeCustom.trim() !== "") {
|
|
||||||
const fontSizeValue = props.fontSizeCustom.trim();
|
|
||||||
if (fontSizeValue.endsWith("px")) {
|
|
||||||
const fontSizeEm = convertPxStringToEm(fontSizeValue);
|
|
||||||
if (fontSizeEm) {
|
|
||||||
inputStyle.fontSize = fontSizeEm;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
inputStyle.fontSize = fontSizeValue;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (props.lineHeightCustom && props.lineHeightCustom.trim() !== "") {
|
|
||||||
const lineHeightValue = props.lineHeightCustom.trim();
|
|
||||||
if (lineHeightValue.endsWith("px")) {
|
|
||||||
const lineHeightEm = convertPxStringToEm(lineHeightValue);
|
|
||||||
if (lineHeightEm) {
|
|
||||||
inputStyle.lineHeight = lineHeightEm;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
inputStyle.lineHeight = lineHeightValue;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (props.letterSpacingCustom && props.letterSpacingCustom.trim() !== "") {
|
|
||||||
const letterSpacingValue = props.letterSpacingCustom.trim();
|
|
||||||
if (letterSpacingValue.endsWith("px")) {
|
|
||||||
const letterSpacingEm = convertPxStringToEm(letterSpacingValue);
|
|
||||||
if (letterSpacingEm) {
|
|
||||||
inputStyle.letterSpacing = letterSpacingEm;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
inputStyle.letterSpacing = letterSpacingValue;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 색상 관련 커스텀 값
|
|
||||||
if (props.textColorCustom && props.textColorCustom.trim() !== "") {
|
|
||||||
inputStyle.color = props.textColorCustom.trim();
|
|
||||||
} else {
|
|
||||||
inputStyle.color = "#f9fafb";
|
|
||||||
}
|
|
||||||
|
|
||||||
if (props.fillColorCustom && props.fillColorCustom.trim() !== "") {
|
|
||||||
inputStyle.backgroundColor = props.fillColorCustom.trim();
|
|
||||||
} else {
|
|
||||||
inputStyle.backgroundColor = "#020617";
|
|
||||||
}
|
|
||||||
|
|
||||||
if (props.strokeColorCustom && props.strokeColorCustom.trim() !== "") {
|
|
||||||
inputStyle.borderColor = props.strokeColorCustom.trim();
|
|
||||||
} else {
|
|
||||||
inputStyle.borderColor = "#334155";
|
|
||||||
}
|
|
||||||
|
|
||||||
if (typeof props.paddingX === "number" && props.paddingX >= 0) {
|
|
||||||
inputStyle.paddingInline = pxToEm(props.paddingX);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (typeof props.paddingY === "number" && props.paddingY >= 0) {
|
|
||||||
inputStyle.paddingBlock = pxToEm(props.paddingY);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 모서리 둥글기: borderRadius 토큰을 px 값으로 변환한다.
|
|
||||||
const radiusToken = props.borderRadius ?? "md";
|
|
||||||
const radiusPx =
|
|
||||||
radiusToken === "none" ? 0 : radiusToken === "sm" ? 2 : radiusToken === "lg" ? 6 : radiusToken === "full" ? 9999 : 4;
|
|
||||||
inputStyle.borderRadius = `${radiusPx}px`;
|
|
||||||
|
|
||||||
return {
|
|
||||||
wrapperLayoutClass,
|
|
||||||
wrapperStyle,
|
|
||||||
widthClass,
|
|
||||||
inputStyle,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
export const computeFormSelectPublicTokens = (props: FormSelectBlockProps): FormSelectPublicTokens => {
|
|
||||||
const widthMode = props.widthMode ?? (props.fullWidth ? "full" : "auto");
|
|
||||||
const widthClass = widthMode === "full" ? "w-full" : "";
|
|
||||||
|
|
||||||
const wrapperStyle: CSSProperties = {};
|
|
||||||
const selectStyle: CSSProperties = {};
|
|
||||||
|
|
||||||
if (widthMode === "fixed" && typeof props.widthPx === "number" && props.widthPx > 0) {
|
|
||||||
wrapperStyle.width = pxToEm(props.widthPx);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (props.fontSizeCustom && props.fontSizeCustom.trim() !== "") {
|
|
||||||
const fontSizeValue = props.fontSizeCustom.trim();
|
|
||||||
if (fontSizeValue.endsWith("px")) {
|
|
||||||
const fontSizeEm = convertPxStringToEm(fontSizeValue);
|
|
||||||
if (fontSizeEm) {
|
|
||||||
wrapperStyle.fontSize = fontSizeEm;
|
|
||||||
selectStyle.fontSize = fontSizeEm;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
wrapperStyle.fontSize = fontSizeValue;
|
|
||||||
selectStyle.fontSize = fontSizeValue;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (props.lineHeightCustom && props.lineHeightCustom.trim() !== "") {
|
|
||||||
const lineHeightValue = props.lineHeightCustom.trim();
|
|
||||||
if (lineHeightValue.endsWith("px")) {
|
|
||||||
const lineHeightEm = convertPxStringToEm(lineHeightValue);
|
|
||||||
if (lineHeightEm) {
|
|
||||||
wrapperStyle.lineHeight = lineHeightEm;
|
|
||||||
selectStyle.lineHeight = lineHeightEm;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
wrapperStyle.lineHeight = lineHeightValue;
|
|
||||||
selectStyle.lineHeight = lineHeightValue;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (props.letterSpacingCustom && props.letterSpacingCustom.trim() !== "") {
|
|
||||||
const letterSpacingValue = props.letterSpacingCustom.trim();
|
|
||||||
if (letterSpacingValue.endsWith("px")) {
|
|
||||||
const letterSpacingEm = convertPxStringToEm(letterSpacingValue);
|
|
||||||
if (letterSpacingEm) {
|
|
||||||
wrapperStyle.letterSpacing = letterSpacingEm;
|
|
||||||
selectStyle.letterSpacing = letterSpacingEm;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
wrapperStyle.letterSpacing = letterSpacingValue;
|
|
||||||
selectStyle.letterSpacing = letterSpacingValue;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 텍스트 색상: textColorCustom 이 설정되어 있으면 해당 색상을 사용하고, 없으면 기본 밝은 텍스트 색상을 사용한다.
|
|
||||||
if (props.textColorCustom && props.textColorCustom.trim() !== "") {
|
|
||||||
const colorValue = props.textColorCustom.trim();
|
|
||||||
wrapperStyle.color = colorValue;
|
|
||||||
selectStyle.color = colorValue;
|
|
||||||
} else {
|
|
||||||
wrapperStyle.color = "#f9fafb";
|
|
||||||
selectStyle.color = "#f9fafb";
|
|
||||||
}
|
|
||||||
|
|
||||||
// 배경/테두리 색상: fillColorCustom/strokeColorCustom 이 있으면 select 에 적용한다.
|
|
||||||
if (props.fillColorCustom && props.fillColorCustom.trim() !== "") {
|
|
||||||
selectStyle.backgroundColor = props.fillColorCustom.trim();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (props.strokeColorCustom && props.strokeColorCustom.trim() !== "") {
|
|
||||||
selectStyle.borderColor = props.strokeColorCustom.trim();
|
|
||||||
}
|
|
||||||
|
|
||||||
// 모서리 둥글기: borderRadius 토큰을 px 로 변환한다.
|
|
||||||
const selectRadiusToken = props.borderRadius ?? "md";
|
|
||||||
const selectRadiusPx =
|
|
||||||
selectRadiusToken === "none"
|
|
||||||
? 0
|
|
||||||
: selectRadiusToken === "sm"
|
|
||||||
? 2
|
|
||||||
: selectRadiusToken === "lg"
|
|
||||||
? 6
|
|
||||||
: selectRadiusToken === "full"
|
|
||||||
? 9999
|
|
||||||
: 4;
|
|
||||||
selectStyle.borderRadius = `${selectRadiusPx}px`;
|
|
||||||
|
|
||||||
if (typeof props.paddingX === "number" && props.paddingX >= 0) {
|
|
||||||
const paddingEm = pxToEm(props.paddingX);
|
|
||||||
selectStyle.paddingInline = paddingEm;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (typeof props.paddingY === "number" && props.paddingY >= 0) {
|
|
||||||
const paddingEm = pxToEm(props.paddingY);
|
|
||||||
selectStyle.paddingBlock = paddingEm;
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
widthClass,
|
|
||||||
wrapperStyle,
|
|
||||||
selectStyle,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
const computeOptionGroupPublicTokensBase = (
|
|
||||||
props: FormCheckboxBlockProps | FormRadioBlockProps,
|
|
||||||
): FormOptionGroupPublicTokens => {
|
|
||||||
const widthMode = props.widthMode ?? (props.fullWidth ? "full" : "auto");
|
|
||||||
const widthClass = widthMode === "full" ? "w-full" : "";
|
|
||||||
|
|
||||||
const groupStyle: CSSProperties = {};
|
|
||||||
const optionContainerStyle: CSSProperties = {};
|
|
||||||
const optionTextStyle: CSSProperties = {};
|
|
||||||
const groupTextStyle: CSSProperties = {};
|
|
||||||
const optionsStyle: CSSProperties = {};
|
|
||||||
|
|
||||||
const textSizeClass = props.fontSizeCustom && props.fontSizeCustom.trim() !== "" ? "" : "text-xs";
|
|
||||||
|
|
||||||
if (widthMode === "fixed" && typeof props.widthPx === "number" && props.widthPx > 0) {
|
|
||||||
groupStyle.width = pxToEm(props.widthPx);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (props.fontSizeCustom && props.fontSizeCustom.trim() !== "") {
|
|
||||||
const fontSizeValue = props.fontSizeCustom.trim();
|
|
||||||
if (fontSizeValue.endsWith("px")) {
|
|
||||||
const fontSizeEm = convertPxStringToEm(fontSizeValue);
|
|
||||||
if (fontSizeEm) {
|
|
||||||
groupTextStyle.fontSize = fontSizeEm;
|
|
||||||
optionTextStyle.fontSize = fontSizeEm;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
groupTextStyle.fontSize = fontSizeValue;
|
|
||||||
optionTextStyle.fontSize = fontSizeValue;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (props.lineHeightCustom && props.lineHeightCustom.trim() !== "") {
|
|
||||||
const lineHeightValue = props.lineHeightCustom.trim();
|
|
||||||
if (lineHeightValue.endsWith("px")) {
|
|
||||||
const lineHeightEm = convertPxStringToEm(lineHeightValue);
|
|
||||||
if (lineHeightEm) {
|
|
||||||
groupTextStyle.lineHeight = lineHeightEm;
|
|
||||||
optionTextStyle.lineHeight = lineHeightEm;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
groupTextStyle.lineHeight = lineHeightValue;
|
|
||||||
optionTextStyle.lineHeight = lineHeightValue;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (props.letterSpacingCustom && props.letterSpacingCustom.trim() !== "") {
|
|
||||||
const letterSpacingValue = props.letterSpacingCustom.trim();
|
|
||||||
if (letterSpacingValue.endsWith("px")) {
|
|
||||||
const letterSpacingEm = convertPxStringToEm(letterSpacingValue);
|
|
||||||
if (letterSpacingEm) {
|
|
||||||
groupTextStyle.letterSpacing = letterSpacingEm;
|
|
||||||
optionTextStyle.letterSpacing = letterSpacingEm;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
groupTextStyle.letterSpacing = letterSpacingValue;
|
|
||||||
optionTextStyle.letterSpacing = letterSpacingValue;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (props.textColorCustom && props.textColorCustom.trim() !== "") {
|
|
||||||
const colorValue = props.textColorCustom.trim();
|
|
||||||
groupTextStyle.color = colorValue;
|
|
||||||
optionTextStyle.color = colorValue;
|
|
||||||
} else {
|
|
||||||
groupTextStyle.color = "#e5e7eb";
|
|
||||||
optionTextStyle.color = "#e5e7eb";
|
|
||||||
}
|
|
||||||
|
|
||||||
if (typeof props.paddingX === "number" && props.paddingX >= 0) {
|
|
||||||
optionContainerStyle.paddingInline = pxToEm(props.paddingX);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (typeof props.paddingY === "number" && props.paddingY >= 0) {
|
|
||||||
optionContainerStyle.paddingBlock = pxToEm(props.paddingY);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 옵션 컨테이너 배경/테두리 색상
|
|
||||||
if (props.fillColorCustom && props.fillColorCustom.trim() !== "") {
|
|
||||||
optionContainerStyle.backgroundColor = props.fillColorCustom.trim();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (props.strokeColorCustom && props.strokeColorCustom.trim() !== "") {
|
|
||||||
const strokeValue = props.strokeColorCustom.trim();
|
|
||||||
optionContainerStyle.borderColor = strokeValue;
|
|
||||||
optionContainerStyle.borderWidth = "1px";
|
|
||||||
optionContainerStyle.borderStyle = "solid";
|
|
||||||
}
|
|
||||||
|
|
||||||
// 옵션 컨테이너 모서리 둥글기
|
|
||||||
const radiusToken = props.borderRadius ?? "md";
|
|
||||||
const radiusPx =
|
|
||||||
radiusToken === "none"
|
|
||||||
? 0
|
|
||||||
: radiusToken === "sm"
|
|
||||||
? 2
|
|
||||||
: radiusToken === "lg"
|
|
||||||
? 6
|
|
||||||
: radiusToken === "full"
|
|
||||||
? 9999
|
|
||||||
: 4;
|
|
||||||
optionContainerStyle.borderRadius = `${radiusPx}px`;
|
|
||||||
|
|
||||||
if (typeof props.optionGapPx === "number" && props.optionGapPx >= 0) {
|
|
||||||
optionsStyle.rowGap = pxToEm(props.optionGapPx);
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
widthClass,
|
|
||||||
textSizeClass,
|
|
||||||
groupStyle,
|
|
||||||
groupTextStyle,
|
|
||||||
optionContainerStyle,
|
|
||||||
optionTextStyle,
|
|
||||||
optionsStyle,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
export const computeFormCheckboxPublicTokens = (
|
|
||||||
props: FormCheckboxBlockProps,
|
|
||||||
): FormOptionGroupPublicTokens => computeOptionGroupPublicTokensBase(props);
|
|
||||||
|
|
||||||
export const computeFormRadioPublicTokens = (props: FormRadioBlockProps): FormOptionGroupPublicTokens =>
|
|
||||||
computeOptionGroupPublicTokensBase(props);
|
|
||||||
|
|
||||||
export interface FormControllerFieldPublicConfig {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
label: string;
|
|
||||||
type: "text" | "email" | "textarea" | "select" | "checkbox" | "radio";
|
|
||||||
required?: boolean;
|
|
||||||
options?: Array<FormSelectOption | FormCheckboxOption | FormRadioOption>;
|
|
||||||
groupLabelMode?: "text" | "image";
|
|
||||||
groupLabelImageUrl?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface FormControllerPublicTokens {
|
|
||||||
fields: FormControllerFieldPublicConfig[];
|
|
||||||
formClassName: string;
|
|
||||||
formStyle: CSSProperties;
|
|
||||||
submitLabel: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const computeFormControllerPublicTokens = (
|
|
||||||
formBlock: Block,
|
|
||||||
blocks: Block[],
|
|
||||||
): FormControllerPublicTokens => {
|
|
||||||
const props = formBlock.props as FormBlockProps;
|
|
||||||
|
|
||||||
const hasControllerFields = Array.isArray(props.fieldIds) && props.fieldIds.length > 0;
|
|
||||||
|
|
||||||
const controllerFields: FormControllerFieldPublicConfig[] = hasControllerFields
|
|
||||||
? (props.fieldIds ?? [])
|
|
||||||
.map((fieldId) => blocks.find((b) => b.id === fieldId))
|
|
||||||
.filter((b): b is Block => Boolean(b))
|
|
||||||
.filter(
|
|
||||||
(b) =>
|
|
||||||
b.type === "formInput" ||
|
|
||||||
b.type === "formSelect" ||
|
|
||||||
b.type === "formCheckbox" ||
|
|
||||||
b.type === "formRadio",
|
|
||||||
)
|
|
||||||
.map((fieldBlock) => {
|
|
||||||
const anyProps: any = fieldBlock.props ?? {};
|
|
||||||
|
|
||||||
if (fieldBlock.type === "formInput") {
|
|
||||||
const name = anyProps.formFieldName ?? fieldBlock.id;
|
|
||||||
const label = anyProps.label ?? anyProps.formFieldName ?? "입력 필드";
|
|
||||||
|
|
||||||
return {
|
|
||||||
id: fieldBlock.id,
|
|
||||||
name,
|
|
||||||
label,
|
|
||||||
// 기존 PublicPageRenderer 컨트롤러 로직과 동일하게 formInput 은 항상 type "text" 로 취급한다.
|
|
||||||
type: "text",
|
|
||||||
required: Boolean(anyProps.required),
|
|
||||||
} satisfies FormControllerFieldPublicConfig;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (fieldBlock.type === "formSelect") {
|
|
||||||
const options: FormSelectOption[] = Array.isArray(anyProps.options) ? anyProps.options : [];
|
|
||||||
const name = anyProps.formFieldName ?? fieldBlock.id;
|
|
||||||
const label = anyProps.label ?? anyProps.formFieldName ?? "선택 필드";
|
|
||||||
|
|
||||||
return {
|
|
||||||
id: fieldBlock.id,
|
|
||||||
name,
|
|
||||||
label,
|
|
||||||
type: "select",
|
|
||||||
options,
|
|
||||||
required: Boolean(anyProps.required),
|
|
||||||
} satisfies FormControllerFieldPublicConfig;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (fieldBlock.type === "formCheckbox") {
|
|
||||||
const options: FormCheckboxOption[] = Array.isArray(anyProps.options) ? anyProps.options : [];
|
|
||||||
const name = anyProps.formFieldName ?? fieldBlock.id;
|
|
||||||
const label = anyProps.groupLabel ?? anyProps.formFieldName ?? "체크박스";
|
|
||||||
|
|
||||||
return {
|
|
||||||
id: fieldBlock.id,
|
|
||||||
name,
|
|
||||||
label,
|
|
||||||
type: "checkbox",
|
|
||||||
options,
|
|
||||||
groupLabelMode: anyProps.groupLabelMode,
|
|
||||||
groupLabelImageUrl: anyProps.groupLabelImageUrl,
|
|
||||||
required: Boolean(anyProps.required),
|
|
||||||
} satisfies FormControllerFieldPublicConfig;
|
|
||||||
}
|
|
||||||
|
|
||||||
const options: FormRadioOption[] = Array.isArray(anyProps.options) ? anyProps.options : [];
|
|
||||||
const name = anyProps.formFieldName ?? fieldBlock.id;
|
|
||||||
const label = anyProps.groupLabel ?? anyProps.formFieldName ?? "라디오 그룹";
|
|
||||||
|
|
||||||
return {
|
|
||||||
id: fieldBlock.id,
|
|
||||||
name,
|
|
||||||
label,
|
|
||||||
type: "radio",
|
|
||||||
options,
|
|
||||||
groupLabelMode: anyProps.groupLabelMode,
|
|
||||||
groupLabelImageUrl: anyProps.groupLabelImageUrl,
|
|
||||||
required: Boolean(anyProps.required),
|
|
||||||
} satisfies FormControllerFieldPublicConfig;
|
|
||||||
})
|
|
||||||
: [];
|
|
||||||
|
|
||||||
const fields: FormControllerFieldPublicConfig[] =
|
|
||||||
hasControllerFields && controllerFields.length > 0
|
|
||||||
? controllerFields
|
|
||||||
: Array.isArray(props.fields) && props.fields.length > 0
|
|
||||||
? props.fields.map((field) => ({
|
|
||||||
id: field.id,
|
|
||||||
name: field.name,
|
|
||||||
label: field.label,
|
|
||||||
type: field.type,
|
|
||||||
required: field.required,
|
|
||||||
}))
|
|
||||||
: [];
|
|
||||||
|
|
||||||
const mappedSubmitButton = blocks.find(
|
|
||||||
(b) => b.type === "button" && b.id === props.submitButtonId,
|
|
||||||
);
|
|
||||||
const mappedSubmitLabel = (mappedSubmitButton?.props as ButtonBlockProps | undefined)?.label ?? null;
|
|
||||||
|
|
||||||
// FormBlock 은 레이아웃/스타일을 가지지 않는 순수 컨트롤러이지만,
|
|
||||||
// 배경색 커스텀 값을 허용해 form 요소 배경만 제어할 수 있도록 한다.
|
|
||||||
const formClassNames = ["space-y-3"];
|
|
||||||
const formStyle: CSSProperties = {};
|
|
||||||
|
|
||||||
if (props.backgroundColorCustom && props.backgroundColorCustom.trim() !== "") {
|
|
||||||
formStyle.backgroundColor = props.backgroundColorCustom.trim();
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
fields,
|
|
||||||
formClassName: formClassNames.join(" "),
|
|
||||||
formStyle,
|
|
||||||
submitLabel: mappedSubmitLabel,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
export interface FormBlockExportTokens {
|
|
||||||
hasControllerFields: boolean;
|
|
||||||
controllerFields: Block[];
|
|
||||||
fallbackFields: FormFieldConfig[];
|
|
||||||
formStyleParts: string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export const computeFormBlockExportTokens = (
|
|
||||||
formBlock: Block,
|
|
||||||
blocks: Block[],
|
|
||||||
): FormBlockExportTokens => {
|
|
||||||
const props = formBlock.props as FormBlockProps;
|
|
||||||
|
|
||||||
const fieldIds = Array.isArray(props.fieldIds) ? props.fieldIds : [];
|
|
||||||
const controllerFields = fieldIds
|
|
||||||
.map((id) => blocks.find((b) => b.id === id))
|
|
||||||
.filter((b): b is Block => Boolean(b))
|
|
||||||
.filter(
|
|
||||||
(b) =>
|
|
||||||
b.type === "formInput" ||
|
|
||||||
b.type === "formSelect" ||
|
|
||||||
b.type === "formCheckbox" ||
|
|
||||||
b.type === "formRadio",
|
|
||||||
);
|
|
||||||
|
|
||||||
let fallbackFields: FormFieldConfig[] = [];
|
|
||||||
if (Array.isArray(props.fields) && props.fields.length > 0) {
|
|
||||||
fallbackFields = props.fields;
|
|
||||||
}
|
|
||||||
|
|
||||||
// FormBlock 은 Export 레이어에서도 컨테이너 배경/레이아웃 스타일을 가지지 않는다.
|
|
||||||
const formStyleParts: string[] = [];
|
|
||||||
|
|
||||||
return {
|
|
||||||
hasControllerFields: controllerFields.length > 0,
|
|
||||||
controllerFields,
|
|
||||||
fallbackFields,
|
|
||||||
formStyleParts,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
@@ -1,150 +0,0 @@
|
|||||||
import type { CSSProperties } from "react";
|
|
||||||
import type { ImageBlockProps } from "@/features/editor/state/editorStore";
|
|
||||||
|
|
||||||
export type ImageWidthMode = "auto" | "full" | "fixed" | null | undefined;
|
|
||||||
export type ImageBorderRadiusToken = "none" | "sm" | "md" | "lg" | "full" | null | undefined;
|
|
||||||
|
|
||||||
export interface ImageExportStyleInput {
|
|
||||||
backgroundColorCustom?: string | null | undefined;
|
|
||||||
widthMode?: ImageWidthMode;
|
|
||||||
widthPx?: number | null | undefined;
|
|
||||||
borderRadius?: ImageBorderRadiusToken;
|
|
||||||
borderRadiusPx?: number | null | undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ImageExportStyles {
|
|
||||||
wrapperStyleParts: string[];
|
|
||||||
imgStyleParts: string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export function computeImageExportStyles(input: ImageExportStyleInput): ImageExportStyles {
|
|
||||||
const wrapperStyleParts: string[] = [];
|
|
||||||
const imgStyleParts: string[] = [];
|
|
||||||
|
|
||||||
if (input.backgroundColorCustom && input.backgroundColorCustom.trim() !== "") {
|
|
||||||
wrapperStyleParts.push(`background-color:${input.backgroundColorCustom.trim()}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const widthMode: ImageWidthMode = input.widthMode ?? "auto";
|
|
||||||
if (widthMode === "fixed" && typeof input.widthPx === "number" && input.widthPx > 0) {
|
|
||||||
const widthEm = input.widthPx / 16;
|
|
||||||
imgStyleParts.push(`width:${widthEm}em`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const radiusToken: ImageBorderRadiusToken = input.borderRadius ?? "md";
|
|
||||||
const fallbackRadiusPx =
|
|
||||||
radiusToken === "none"
|
|
||||||
? 0
|
|
||||||
: radiusToken === "sm"
|
|
||||||
? 4
|
|
||||||
: radiusToken === "lg"
|
|
||||||
? 16
|
|
||||||
: radiusToken === "full"
|
|
||||||
? 9999
|
|
||||||
: 8;
|
|
||||||
|
|
||||||
const radiusPx =
|
|
||||||
typeof input.borderRadiusPx === "number" && input.borderRadiusPx >= 0
|
|
||||||
? input.borderRadiusPx
|
|
||||||
: fallbackRadiusPx;
|
|
||||||
|
|
||||||
if (typeof radiusPx === "number" && radiusPx >= 0) {
|
|
||||||
imgStyleParts.push(`border-radius:${radiusPx === 9999 ? "9999px" : `${radiusPx}px`}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
wrapperStyleParts,
|
|
||||||
imgStyleParts,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ImageEditorTokens {
|
|
||||||
widthPx?: number;
|
|
||||||
radiusPx: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function computeImageEditorTokens(input: ImageExportStyleInput): ImageEditorTokens {
|
|
||||||
const widthMode: ImageWidthMode = input.widthMode ?? "auto";
|
|
||||||
let widthPx: number | undefined;
|
|
||||||
|
|
||||||
if (widthMode === "fixed" && typeof input.widthPx === "number" && input.widthPx > 0) {
|
|
||||||
widthPx = input.widthPx;
|
|
||||||
}
|
|
||||||
|
|
||||||
const radiusToken: ImageBorderRadiusToken = input.borderRadius ?? "md";
|
|
||||||
const fallbackRadiusPx =
|
|
||||||
radiusToken === "none"
|
|
||||||
? 0
|
|
||||||
: radiusToken === "sm"
|
|
||||||
? 4
|
|
||||||
: radiusToken === "lg"
|
|
||||||
? 16
|
|
||||||
: radiusToken === "full"
|
|
||||||
? 9999
|
|
||||||
: 8;
|
|
||||||
|
|
||||||
const radiusPx =
|
|
||||||
typeof input.borderRadiusPx === "number" && input.borderRadiusPx >= 0
|
|
||||||
? input.borderRadiusPx
|
|
||||||
: fallbackRadiusPx;
|
|
||||||
|
|
||||||
return {
|
|
||||||
widthPx,
|
|
||||||
radiusPx,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ImagePublicTokens {
|
|
||||||
alignClass: string;
|
|
||||||
wrapperStyle: CSSProperties;
|
|
||||||
imageStyle: CSSProperties;
|
|
||||||
}
|
|
||||||
|
|
||||||
const pxToEm = (px: number, base = 16) => `${px / base}em`;
|
|
||||||
|
|
||||||
export function computeImagePublicTokens(props: ImageBlockProps): ImagePublicTokens {
|
|
||||||
const alignClass =
|
|
||||||
props.align === "left" ? "justify-start" : props.align === "right" ? "justify-end" : "justify-center";
|
|
||||||
|
|
||||||
const widthMode: ImageWidthMode = props.widthMode ?? "auto";
|
|
||||||
|
|
||||||
const wrapperStyle: CSSProperties = {};
|
|
||||||
const imageStyle: CSSProperties = {
|
|
||||||
display: "block",
|
|
||||||
maxWidth: "100%",
|
|
||||||
height: "auto",
|
|
||||||
};
|
|
||||||
|
|
||||||
if (widthMode === "fixed" && typeof props.widthPx === "number" && props.widthPx > 0) {
|
|
||||||
imageStyle.width = pxToEm(props.widthPx);
|
|
||||||
}
|
|
||||||
|
|
||||||
const radiusToken: ImageBorderRadiusToken = props.borderRadius ?? "md";
|
|
||||||
const fallbackRadiusPx =
|
|
||||||
radiusToken === "none"
|
|
||||||
? 0
|
|
||||||
: radiusToken === "sm"
|
|
||||||
? 4
|
|
||||||
: radiusToken === "lg"
|
|
||||||
? 16
|
|
||||||
: radiusToken === "full"
|
|
||||||
? 9999
|
|
||||||
: 8;
|
|
||||||
|
|
||||||
const radiusPx =
|
|
||||||
typeof props.borderRadiusPx === "number" && props.borderRadiusPx >= 0
|
|
||||||
? props.borderRadiusPx
|
|
||||||
: fallbackRadiusPx;
|
|
||||||
|
|
||||||
imageStyle.borderRadius = radiusPx === 9999 ? "9999px" : pxToEm(radiusPx);
|
|
||||||
|
|
||||||
if (props.backgroundColorCustom && props.backgroundColorCustom.trim() !== "") {
|
|
||||||
wrapperStyle.backgroundColor = props.backgroundColorCustom.trim();
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
alignClass,
|
|
||||||
wrapperStyle,
|
|
||||||
imageStyle,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,162 +0,0 @@
|
|||||||
import type { CSSProperties } from "react";
|
|
||||||
import type { ListBlockProps } from "@/features/editor/state/editorStore";
|
|
||||||
|
|
||||||
export interface ListExportTokens {
|
|
||||||
Tag: "ul" | "ol";
|
|
||||||
items: string[];
|
|
||||||
listStyleParts: string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export const computeListExportTokens = (props: ListBlockProps): ListExportTokens => {
|
|
||||||
const ordered = Boolean(props.ordered);
|
|
||||||
const Tag: "ul" | "ol" = ordered ? "ol" : "ul";
|
|
||||||
|
|
||||||
const items: string[] = [];
|
|
||||||
|
|
||||||
if (Array.isArray(props.itemsTree) && props.itemsTree.length > 0) {
|
|
||||||
const walk = (nodes: { text?: string; children?: any[] }[]) => {
|
|
||||||
for (const node of nodes) {
|
|
||||||
if (typeof node.text === "string" && node.text.trim() !== "") {
|
|
||||||
items.push(node.text);
|
|
||||||
}
|
|
||||||
if (Array.isArray(node.children) && node.children.length > 0) {
|
|
||||||
walk(node.children);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
walk(props.itemsTree as any);
|
|
||||||
} else if (Array.isArray(props.items)) {
|
|
||||||
for (const raw of props.items) {
|
|
||||||
if (typeof raw === "string" && raw.trim() !== "") {
|
|
||||||
items.push(raw);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const alignToken = props.align;
|
|
||||||
const align = alignToken === "center" ? "center" : alignToken === "right" ? "right" : "left";
|
|
||||||
|
|
||||||
const listStyleParts: string[] = [`text-align:${align}`];
|
|
||||||
if (typeof props.backgroundColorCustom === "string" && props.backgroundColorCustom.trim() !== "") {
|
|
||||||
listStyleParts.push(`background-color:${props.backgroundColorCustom.trim()}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
Tag,
|
|
||||||
items,
|
|
||||||
listStyleParts,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
export interface ListEditorTokens {
|
|
||||||
gapPx: number;
|
|
||||||
listStyle: CSSProperties;
|
|
||||||
bulletStyle: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const computeListEditorTokens = (props: ListBlockProps): ListEditorTokens => {
|
|
||||||
const bulletStyleRaw = props.bulletStyle ?? (props.ordered ? "decimal" : "disc");
|
|
||||||
|
|
||||||
const gapPx =
|
|
||||||
typeof props.gapYPx === "number"
|
|
||||||
? props.gapYPx
|
|
||||||
: props.gapY === "sm"
|
|
||||||
? 4
|
|
||||||
: props.gapY === "lg"
|
|
||||||
? 16
|
|
||||||
: 8;
|
|
||||||
|
|
||||||
const listStyle: CSSProperties = {};
|
|
||||||
if (props.fontSizeCustom && props.fontSizeCustom.trim() !== "") {
|
|
||||||
listStyle.fontSize = props.fontSizeCustom;
|
|
||||||
}
|
|
||||||
if (props.lineHeightCustom && props.lineHeightCustom.trim() !== "") {
|
|
||||||
listStyle.lineHeight = props.lineHeightCustom;
|
|
||||||
}
|
|
||||||
if (props.textColorCustom && props.textColorCustom.trim() !== "") {
|
|
||||||
listStyle.color = props.textColorCustom;
|
|
||||||
}
|
|
||||||
|
|
||||||
const bulletStyle = bulletStyleRaw;
|
|
||||||
|
|
||||||
return {
|
|
||||||
gapPx,
|
|
||||||
listStyle,
|
|
||||||
bulletStyle,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
export interface ListPublicTokens {
|
|
||||||
alignClass: string;
|
|
||||||
gapEm: number;
|
|
||||||
listStyle: CSSProperties;
|
|
||||||
bulletStyle: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
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 const computeListPublicTokens = (props: ListBlockProps): ListPublicTokens => {
|
|
||||||
const alignClass =
|
|
||||||
props.align === "center" ? "text-center" : props.align === "right" ? "text-right" : "text-left";
|
|
||||||
|
|
||||||
const bulletStyleRaw = props.bulletStyle ?? (props.ordered ? "decimal" : "disc");
|
|
||||||
|
|
||||||
const gapPx =
|
|
||||||
typeof props.gapYPx === "number"
|
|
||||||
? props.gapYPx
|
|
||||||
: props.gapY === "sm"
|
|
||||||
? 4
|
|
||||||
: props.gapY === "lg"
|
|
||||||
? 16
|
|
||||||
: 8;
|
|
||||||
|
|
||||||
const gapEm = gapPx / 16;
|
|
||||||
|
|
||||||
const listStyle: CSSProperties = {};
|
|
||||||
if (props.fontSizeCustom && props.fontSizeCustom.trim() !== "") {
|
|
||||||
const fontSizeValue = props.fontSizeCustom.trim();
|
|
||||||
if (fontSizeValue.endsWith("px")) {
|
|
||||||
const fontSizeEm = convertPxStringToEm(fontSizeValue);
|
|
||||||
if (fontSizeEm) {
|
|
||||||
listStyle.fontSize = fontSizeEm;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
listStyle.fontSize = fontSizeValue;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (props.lineHeightCustom && props.lineHeightCustom.trim() !== "") {
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
if (props.backgroundColorCustom && props.backgroundColorCustom.trim() !== "") {
|
|
||||||
listStyle.backgroundColor = props.backgroundColorCustom.trim();
|
|
||||||
}
|
|
||||||
|
|
||||||
const bulletStyle = bulletStyleRaw;
|
|
||||||
|
|
||||||
return {
|
|
||||||
alignClass,
|
|
||||||
gapEm,
|
|
||||||
listStyle,
|
|
||||||
bulletStyle,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
@@ -1,278 +0,0 @@
|
|||||||
import type { CSSProperties } from "react";
|
|
||||||
import type { SectionBlockProps } from "@/features/editor/state/editorStore";
|
|
||||||
|
|
||||||
export interface SectionExportTokens {
|
|
||||||
sectionClasses: string;
|
|
||||||
sectionStyleParts: string[];
|
|
||||||
backgroundVideoHtml: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function computeSectionExportTokens(props: SectionBlockProps): SectionExportTokens {
|
|
||||||
const bgToken = props.background ?? "default";
|
|
||||||
const bgClassMap: Record<SectionBlockProps["background"], string> = {
|
|
||||||
default: "pb-section-bg-default",
|
|
||||||
muted: "pb-section-bg-muted",
|
|
||||||
primary: "pb-section-bg-primary",
|
|
||||||
};
|
|
||||||
const bgClass = bgClassMap[bgToken] ?? "pb-section-bg-default";
|
|
||||||
|
|
||||||
const pyToken = props.paddingY ?? "md";
|
|
||||||
const pyClassMap: Record<SectionBlockProps["paddingY"], string> = {
|
|
||||||
sm: "pb-section-py-sm",
|
|
||||||
md: "pb-section-py-md",
|
|
||||||
lg: "pb-section-py-lg",
|
|
||||||
};
|
|
||||||
const pyClass = pyClassMap[pyToken] ?? "pb-section-py-md";
|
|
||||||
|
|
||||||
const sectionClasses = ["pb-section", bgClass, pyClass].filter(Boolean).join(" ");
|
|
||||||
const sectionStyleParts: string[] = [];
|
|
||||||
|
|
||||||
if (typeof props.backgroundColorCustom === "string" && props.backgroundColorCustom.trim() !== "") {
|
|
||||||
sectionStyleParts.push(`background-color:${props.backgroundColorCustom.trim()}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const bgImgSrc = props.backgroundImageSrc;
|
|
||||||
if (typeof bgImgSrc === "string" && bgImgSrc.trim() !== "") {
|
|
||||||
const url = bgImgSrc.trim();
|
|
||||||
sectionStyleParts.push(`background-image:url(${url})`);
|
|
||||||
|
|
||||||
const size = props.backgroundImageSize ?? "cover";
|
|
||||||
sectionStyleParts.push(`background-size:${size}`);
|
|
||||||
|
|
||||||
const posMode = props.backgroundImagePositionMode ?? "preset";
|
|
||||||
if (posMode === "custom") {
|
|
||||||
const x = props.backgroundImagePositionXPercent;
|
|
||||||
const y = props.backgroundImagePositionYPercent;
|
|
||||||
const xSafe = typeof x === "number" ? x : 50;
|
|
||||||
const ySafe = typeof y === "number" ? y : 50;
|
|
||||||
sectionStyleParts.push(`background-position:${xSafe}% ${ySafe}%`);
|
|
||||||
} else {
|
|
||||||
const posToken = props.backgroundImagePosition ?? "center";
|
|
||||||
const position =
|
|
||||||
posToken === "top"
|
|
||||||
? "center top"
|
|
||||||
: posToken === "bottom"
|
|
||||||
? "center bottom"
|
|
||||||
: posToken === "left"
|
|
||||||
? "left center"
|
|
||||||
: posToken === "right"
|
|
||||||
? "right center"
|
|
||||||
: "center center";
|
|
||||||
sectionStyleParts.push(`background-position:${position}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const repeat = props.backgroundImageRepeat ?? "no-repeat";
|
|
||||||
sectionStyleParts.push(`background-repeat:${repeat}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const bgVideoSrc = props.backgroundVideoSrc;
|
|
||||||
let backgroundVideoHtml = "";
|
|
||||||
if (typeof bgVideoSrc === "string" && bgVideoSrc.trim() !== "") {
|
|
||||||
const url = bgVideoSrc.trim();
|
|
||||||
backgroundVideoHtml = `<video class="pb-section-bg-video" src="${url}" autoplay loop muted playsinline></video>`;
|
|
||||||
sectionStyleParts.push("position:relative");
|
|
||||||
sectionStyleParts.push("overflow:hidden");
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
sectionClasses,
|
|
||||||
sectionStyleParts,
|
|
||||||
backgroundVideoHtml,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface SectionPublicTokens {
|
|
||||||
sectionStyle: CSSProperties;
|
|
||||||
innerWrapperStyle: CSSProperties;
|
|
||||||
columnsContainerStyle: CSSProperties;
|
|
||||||
hasBackgroundVideo: boolean;
|
|
||||||
backgroundVideoSrc: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const pxToEm = (px: number, base = 16) => `${px / base}em`;
|
|
||||||
|
|
||||||
export function computeSectionPublicTokens(props: SectionBlockProps): SectionPublicTokens {
|
|
||||||
const sectionStyle: CSSProperties = {};
|
|
||||||
|
|
||||||
if (props.backgroundColorCustom && props.backgroundColorCustom.trim() !== "") {
|
|
||||||
sectionStyle.backgroundColor = props.backgroundColorCustom.trim();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (typeof props.paddingYPx === "number" && props.paddingYPx > 0) {
|
|
||||||
const paddingEm = pxToEm(props.paddingYPx);
|
|
||||||
sectionStyle.paddingTop = paddingEm;
|
|
||||||
sectionStyle.paddingBottom = paddingEm;
|
|
||||||
}
|
|
||||||
|
|
||||||
const rawBgVideoSrc = (props as any).backgroundVideoSrc;
|
|
||||||
let hasBackgroundVideo = false;
|
|
||||||
let backgroundVideoSrc: string | null = null;
|
|
||||||
if (typeof rawBgVideoSrc === "string" && rawBgVideoSrc.trim() !== "") {
|
|
||||||
hasBackgroundVideo = true;
|
|
||||||
backgroundVideoSrc = rawBgVideoSrc.trim();
|
|
||||||
sectionStyle.position = "relative";
|
|
||||||
sectionStyle.overflow = "hidden";
|
|
||||||
}
|
|
||||||
|
|
||||||
if (props.backgroundImageSrc && props.backgroundImageSrc.trim() !== "") {
|
|
||||||
const url = props.backgroundImageSrc.trim();
|
|
||||||
sectionStyle.backgroundImage = `url(${url})`;
|
|
||||||
|
|
||||||
const size = props.backgroundImageSize ?? "cover";
|
|
||||||
sectionStyle.backgroundSize = size;
|
|
||||||
|
|
||||||
const posMode = props.backgroundImagePositionMode ?? "preset";
|
|
||||||
if (
|
|
||||||
posMode === "custom" &&
|
|
||||||
typeof props.backgroundImagePositionXPercent === "number" &&
|
|
||||||
typeof props.backgroundImagePositionYPercent === "number"
|
|
||||||
) {
|
|
||||||
const x = props.backgroundImagePositionXPercent;
|
|
||||||
const y = props.backgroundImagePositionYPercent;
|
|
||||||
sectionStyle.backgroundPosition = `${x}% ${y}%`;
|
|
||||||
} else {
|
|
||||||
const posToken = props.backgroundImagePosition ?? "center";
|
|
||||||
const position =
|
|
||||||
posToken === "top"
|
|
||||||
? "center top"
|
|
||||||
: posToken === "bottom"
|
|
||||||
? "center bottom"
|
|
||||||
: posToken === "left"
|
|
||||||
? "left center"
|
|
||||||
: posToken === "right"
|
|
||||||
? "right center"
|
|
||||||
: "center center";
|
|
||||||
sectionStyle.backgroundPosition = position;
|
|
||||||
}
|
|
||||||
|
|
||||||
const repeat = props.backgroundImageRepeat ?? "no-repeat";
|
|
||||||
sectionStyle.backgroundRepeat = repeat;
|
|
||||||
}
|
|
||||||
|
|
||||||
const innerWrapperStyle: CSSProperties = {};
|
|
||||||
if (typeof props.maxWidthPx === "number" && props.maxWidthPx > 0) {
|
|
||||||
innerWrapperStyle.maxWidth = pxToEm(props.maxWidthPx);
|
|
||||||
}
|
|
||||||
|
|
||||||
const columnsContainerStyle: CSSProperties = {};
|
|
||||||
if (typeof props.gapXPx === "number" && props.gapXPx > 0) {
|
|
||||||
columnsContainerStyle.columnGap = pxToEm(props.gapXPx);
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
sectionStyle,
|
|
||||||
innerWrapperStyle,
|
|
||||||
columnsContainerStyle,
|
|
||||||
hasBackgroundVideo,
|
|
||||||
backgroundVideoSrc,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface SectionEditorTokens {
|
|
||||||
bgClass: string;
|
|
||||||
pyClass: string;
|
|
||||||
alignItemsClass: string;
|
|
||||||
wrapperStyle: CSSProperties;
|
|
||||||
innerWrapperStyle: CSSProperties;
|
|
||||||
columnsContainerStyle: CSSProperties;
|
|
||||||
hasBackgroundVideo: boolean;
|
|
||||||
backgroundVideoSrc: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function computeSectionEditorTokens(props: SectionBlockProps): SectionEditorTokens {
|
|
||||||
const bgClass =
|
|
||||||
props.background === "muted"
|
|
||||||
? "bg-slate-950/40"
|
|
||||||
: props.background === "primary"
|
|
||||||
? "bg-sky-950/40 border-sky-900/60"
|
|
||||||
: "bg-slate-900/60";
|
|
||||||
|
|
||||||
const pyClass =
|
|
||||||
props.paddingY === "sm"
|
|
||||||
? "py-4"
|
|
||||||
: props.paddingY === "lg"
|
|
||||||
? "py-10"
|
|
||||||
: "py-6";
|
|
||||||
|
|
||||||
const alignItemsClass =
|
|
||||||
props.alignItems === "center"
|
|
||||||
? "items-center"
|
|
||||||
: props.alignItems === "bottom"
|
|
||||||
? "items-end"
|
|
||||||
: "items-start";
|
|
||||||
|
|
||||||
const wrapperStyle: CSSProperties = {};
|
|
||||||
if (props.backgroundColorCustom && props.backgroundColorCustom.trim() !== "") {
|
|
||||||
wrapperStyle.backgroundColor = props.backgroundColorCustom;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (typeof props.paddingYPx === "number" && props.paddingYPx > 0) {
|
|
||||||
wrapperStyle.paddingTop = `${props.paddingYPx}px`;
|
|
||||||
wrapperStyle.paddingBottom = `${props.paddingYPx}px`;
|
|
||||||
}
|
|
||||||
|
|
||||||
let hasBackgroundVideo = false;
|
|
||||||
let backgroundVideoSrc: string | null = null;
|
|
||||||
if (typeof props.backgroundVideoSrc === "string" && props.backgroundVideoSrc.trim() !== "") {
|
|
||||||
hasBackgroundVideo = true;
|
|
||||||
backgroundVideoSrc = props.backgroundVideoSrc.trim();
|
|
||||||
wrapperStyle.position = "relative";
|
|
||||||
wrapperStyle.overflow = "hidden";
|
|
||||||
}
|
|
||||||
|
|
||||||
if (props.backgroundImageSrc && props.backgroundImageSrc.trim() !== "") {
|
|
||||||
const url = props.backgroundImageSrc.trim();
|
|
||||||
wrapperStyle.backgroundImage = `url(${url})`;
|
|
||||||
|
|
||||||
const size = props.backgroundImageSize ?? "cover";
|
|
||||||
wrapperStyle.backgroundSize = size;
|
|
||||||
|
|
||||||
const posMode = props.backgroundImagePositionMode ?? "preset";
|
|
||||||
if (
|
|
||||||
posMode === "custom" &&
|
|
||||||
typeof props.backgroundImagePositionXPercent === "number" &&
|
|
||||||
typeof props.backgroundImagePositionYPercent === "number"
|
|
||||||
) {
|
|
||||||
const x = props.backgroundImagePositionXPercent;
|
|
||||||
const y = props.backgroundImagePositionYPercent;
|
|
||||||
wrapperStyle.backgroundPosition = `${x}% ${y}%`;
|
|
||||||
} else {
|
|
||||||
const posToken = props.backgroundImagePosition ?? "center";
|
|
||||||
const position =
|
|
||||||
posToken === "top"
|
|
||||||
? "center top"
|
|
||||||
: posToken === "bottom"
|
|
||||||
? "center bottom"
|
|
||||||
: posToken === "left"
|
|
||||||
? "left center"
|
|
||||||
: posToken === "right"
|
|
||||||
? "right center"
|
|
||||||
: "center center";
|
|
||||||
wrapperStyle.backgroundPosition = position;
|
|
||||||
}
|
|
||||||
|
|
||||||
const repeat = props.backgroundImageRepeat ?? "no-repeat";
|
|
||||||
wrapperStyle.backgroundRepeat = repeat;
|
|
||||||
}
|
|
||||||
|
|
||||||
const innerWrapperStyle: CSSProperties = {};
|
|
||||||
if (typeof props.maxWidthPx === "number" && props.maxWidthPx > 0) {
|
|
||||||
innerWrapperStyle.maxWidth = `${props.maxWidthPx}px`;
|
|
||||||
}
|
|
||||||
|
|
||||||
const columnsContainerStyle: CSSProperties = {};
|
|
||||||
if (typeof props.gapXPx === "number" && props.gapXPx > 0) {
|
|
||||||
columnsContainerStyle.columnGap = `${props.gapXPx}px`;
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
bgClass,
|
|
||||||
pyClass,
|
|
||||||
alignItemsClass,
|
|
||||||
wrapperStyle,
|
|
||||||
innerWrapperStyle,
|
|
||||||
columnsContainerStyle,
|
|
||||||
hasBackgroundVideo,
|
|
||||||
backgroundVideoSrc,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,370 +0,0 @@
|
|||||||
import type { CSSProperties } from "react";
|
|
||||||
import type { TextBlockProps } from "@/features/editor/state/editorStore";
|
|
||||||
|
|
||||||
export type TextAlign = "left" | "center" | "right" | null | undefined;
|
|
||||||
|
|
||||||
export type TextFontSizeScale = "xs" | "sm" | "base" | "lg" | "xl" | "2xl" | "3xl";
|
|
||||||
export type TextLineHeightScale = "tight" | "snug" | "normal" | "relaxed" | "loose";
|
|
||||||
export type TextFontWeightScale = "normal" | "medium" | "semibold" | "bold";
|
|
||||||
export type TextColorPalette =
|
|
||||||
| "default"
|
|
||||||
| "muted"
|
|
||||||
| "strong"
|
|
||||||
| "accent"
|
|
||||||
| "danger"
|
|
||||||
| "success"
|
|
||||||
| "warning"
|
|
||||||
| "info"
|
|
||||||
| "neutral";
|
|
||||||
|
|
||||||
export interface TextStyleInput {
|
|
||||||
text?: string;
|
|
||||||
align?: TextAlign;
|
|
||||||
size?: "sm" | "base" | "lg" | null | undefined;
|
|
||||||
fontSizeMode?: "scale" | "custom" | null | undefined;
|
|
||||||
fontSizeScale?: TextFontSizeScale | null | undefined;
|
|
||||||
fontSizeCustom?: string | null | undefined;
|
|
||||||
lineHeightMode?: "scale" | "custom" | null | undefined;
|
|
||||||
lineHeightScale?: TextLineHeightScale | null | undefined;
|
|
||||||
lineHeightCustom?: string | null | undefined;
|
|
||||||
fontWeightMode?: "scale" | "custom" | null | undefined;
|
|
||||||
fontWeightScale?: TextFontWeightScale | null | undefined;
|
|
||||||
fontWeightCustom?: string | number | null | undefined;
|
|
||||||
colorMode?: "palette" | "custom" | null | undefined;
|
|
||||||
colorPalette?: TextColorPalette | null | undefined;
|
|
||||||
colorCustom?: string | null | undefined;
|
|
||||||
backgroundColorCustom?: string | null | undefined;
|
|
||||||
maxWidthMode?: "scale" | "custom" | null | undefined;
|
|
||||||
maxWidthScale?: "none" | "prose" | "narrow" | null | undefined;
|
|
||||||
underline?: boolean | null | undefined;
|
|
||||||
strike?: boolean | null | undefined;
|
|
||||||
italic?: boolean | null | undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface TextPbTokens {
|
|
||||||
alignClass: string;
|
|
||||||
sizeClass: string;
|
|
||||||
leadingClass: string;
|
|
||||||
weightClass: string;
|
|
||||||
colorClass: string;
|
|
||||||
maxWidthClass: string;
|
|
||||||
extraClasses: string[];
|
|
||||||
inlineStyles: string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export function computeTextPbTokens(input: TextStyleInput): TextPbTokens {
|
|
||||||
const align = input.align === "center" ? "center" : input.align === "right" ? "right" : "left";
|
|
||||||
const alignClass =
|
|
||||||
align === "center" ? "pb-text-center" : align === "right" ? "pb-text-right" : "pb-text-left";
|
|
||||||
|
|
||||||
const fontSizeMode = input.fontSizeMode ?? "scale";
|
|
||||||
const fallbackScale: TextFontSizeScale = input.size === "sm" ? "sm" : input.size === "lg" ? "lg" : "base";
|
|
||||||
const fontSizeScale: TextFontSizeScale = (input.fontSizeScale as TextFontSizeScale | null) ?? fallbackScale;
|
|
||||||
|
|
||||||
const fontSizeMap: Record<TextFontSizeScale, string> = {
|
|
||||||
xs: "pb-text-xs",
|
|
||||||
sm: "pb-text-sm",
|
|
||||||
base: "pb-text-base",
|
|
||||||
lg: "pb-text-lg",
|
|
||||||
xl: "pb-text-xl",
|
|
||||||
"2xl": "pb-text-2xl",
|
|
||||||
"3xl": "pb-text-3xl",
|
|
||||||
};
|
|
||||||
|
|
||||||
const sizeClass =
|
|
||||||
fontSizeMode === "scale" && fontSizeScale && fontSizeMap[fontSizeScale]
|
|
||||||
? fontSizeMap[fontSizeScale]
|
|
||||||
: "";
|
|
||||||
|
|
||||||
const lineHeightMode = input.lineHeightMode ?? "scale";
|
|
||||||
const lineHeightScale: TextLineHeightScale = (input.lineHeightScale as TextLineHeightScale | null) ?? "normal";
|
|
||||||
const leadingMap: Record<TextLineHeightScale, string> = {
|
|
||||||
tight: "pb-leading-tight",
|
|
||||||
snug: "pb-leading-snug",
|
|
||||||
normal: "pb-leading-normal",
|
|
||||||
relaxed: "pb-leading-relaxed",
|
|
||||||
loose: "pb-leading-loose",
|
|
||||||
};
|
|
||||||
|
|
||||||
const leadingClass =
|
|
||||||
lineHeightMode === "scale" && lineHeightScale && leadingMap[lineHeightScale]
|
|
||||||
? leadingMap[lineHeightScale]
|
|
||||||
: "";
|
|
||||||
|
|
||||||
const fontWeightMode = input.fontWeightMode ?? "scale";
|
|
||||||
const fontWeightScale: TextFontWeightScale =
|
|
||||||
(input.fontWeightScale as TextFontWeightScale | null) ?? "normal";
|
|
||||||
const weightMap: Record<TextFontWeightScale, string> = {
|
|
||||||
normal: "pb-font-normal",
|
|
||||||
medium: "pb-font-medium",
|
|
||||||
semibold: "pb-font-semibold",
|
|
||||||
bold: "pb-font-bold",
|
|
||||||
};
|
|
||||||
|
|
||||||
const weightClass =
|
|
||||||
fontWeightMode === "scale" && fontWeightScale && weightMap[fontWeightScale]
|
|
||||||
? weightMap[fontWeightScale]
|
|
||||||
: "";
|
|
||||||
|
|
||||||
let colorClass = "pb-text-color-strong";
|
|
||||||
const inlineStyles: string[] = [];
|
|
||||||
|
|
||||||
if (input.colorMode === "palette") {
|
|
||||||
const palette: TextColorPalette = (input.colorPalette as TextColorPalette | null) ?? "default";
|
|
||||||
const paletteMap: Record<TextColorPalette, string> = {
|
|
||||||
default: "pb-text-color-default",
|
|
||||||
muted: "pb-text-color-muted",
|
|
||||||
strong: "pb-text-color-strong",
|
|
||||||
accent: "pb-text-color-accent",
|
|
||||||
danger: "pb-text-color-danger",
|
|
||||||
success: "pb-text-color-success",
|
|
||||||
warning: "pb-text-color-warning",
|
|
||||||
info: "pb-text-color-info",
|
|
||||||
neutral: "pb-text-color-neutral",
|
|
||||||
};
|
|
||||||
colorClass = paletteMap[palette] ?? colorClass;
|
|
||||||
} else if (input.colorMode === "custom" && input.colorCustom && input.colorCustom.trim() !== "") {
|
|
||||||
inlineStyles.push(`color:${input.colorCustom.trim()}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (input.backgroundColorCustom && input.backgroundColorCustom.trim() !== "") {
|
|
||||||
inlineStyles.push(`background-color:${input.backgroundColorCustom.trim()}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
let maxWidthClass = "";
|
|
||||||
const maxWidthMode = input.maxWidthMode ?? "scale";
|
|
||||||
const maxWidthScale = input.maxWidthScale ?? "none";
|
|
||||||
if (maxWidthMode === "scale") {
|
|
||||||
if (maxWidthScale === "prose") {
|
|
||||||
maxWidthClass = "pb-text-maxw-prose";
|
|
||||||
} else if (maxWidthScale === "narrow") {
|
|
||||||
maxWidthClass = "pb-text-maxw-narrow";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const extraClasses: string[] = ["pb-whitespace-pre-wrap"];
|
|
||||||
if (input.underline) extraClasses.push("pb-underline");
|
|
||||||
if (input.strike) extraClasses.push("pb-line-through");
|
|
||||||
if (input.italic) extraClasses.push("pb-italic");
|
|
||||||
|
|
||||||
return {
|
|
||||||
alignClass,
|
|
||||||
sizeClass,
|
|
||||||
leadingClass,
|
|
||||||
weightClass,
|
|
||||||
colorClass,
|
|
||||||
maxWidthClass,
|
|
||||||
extraClasses,
|
|
||||||
inlineStyles,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface TextEditorTokens {
|
|
||||||
alignClass: string;
|
|
||||||
sizeClass: string;
|
|
||||||
leadingClass: string;
|
|
||||||
weightClass: string;
|
|
||||||
colorClass: string;
|
|
||||||
maxWidthClass: string;
|
|
||||||
decorationClass: string;
|
|
||||||
styleOverrides: CSSProperties;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function computeTextEditorTokens(props: TextBlockProps): TextEditorTokens {
|
|
||||||
let alignClass = "";
|
|
||||||
let sizeClass = "";
|
|
||||||
let leadingClass = "";
|
|
||||||
let weightClass = "";
|
|
||||||
let colorClass = "";
|
|
||||||
let maxWidthClass = "";
|
|
||||||
let decorationClass = "";
|
|
||||||
const styleOverrides: CSSProperties = {};
|
|
||||||
|
|
||||||
// 정렬: pb-text-*
|
|
||||||
alignClass =
|
|
||||||
props.align === "center"
|
|
||||||
? "pb-text-center"
|
|
||||||
: props.align === "right"
|
|
||||||
? "pb-text-right"
|
|
||||||
: "pb-text-left";
|
|
||||||
|
|
||||||
// 폰트 크기: scale/custom + 기존 size 값 fallback
|
|
||||||
const fontSizeMode = props.fontSizeMode ?? "scale";
|
|
||||||
const fallbackScale = props.size === "sm" ? "sm" : props.size === "lg" ? "lg" : "base";
|
|
||||||
const fontSizeScale = props.fontSizeScale ?? fallbackScale;
|
|
||||||
|
|
||||||
// 스케일/커스텀 모드와 무관하게 pb-text-* 스케일 클래스는 항상 유지한다.
|
|
||||||
sizeClass = `pb-text-${fontSizeScale}`;
|
|
||||||
|
|
||||||
// custom 모드에서는 inline 스타일로 실제 폰트 크기를 덮어쓴다.
|
|
||||||
if (fontSizeMode === "custom" && props.fontSizeCustom && props.fontSizeCustom.trim() !== "") {
|
|
||||||
styleOverrides.fontSize = props.fontSizeCustom;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 줄 간격: scale/custom
|
|
||||||
const lineHeightMode = props.lineHeightMode ?? "scale";
|
|
||||||
const lineHeightScale = props.lineHeightScale ?? "normal";
|
|
||||||
if (lineHeightMode === "scale") {
|
|
||||||
leadingClass = `pb-leading-${lineHeightScale}`;
|
|
||||||
} else if (lineHeightMode === "custom" && props.lineHeightCustom && props.lineHeightCustom.trim() !== "") {
|
|
||||||
styleOverrides.lineHeight = props.lineHeightCustom;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 굵기: scale/custom
|
|
||||||
const fontWeightMode = props.fontWeightMode ?? "scale";
|
|
||||||
const fontWeightScale = props.fontWeightScale ?? "normal";
|
|
||||||
if (fontWeightMode === "scale") {
|
|
||||||
weightClass = `pb-font-${fontWeightScale}`;
|
|
||||||
} else if (fontWeightMode === "custom" && props.fontWeightCustom && String(props.fontWeightCustom).trim() !== "") {
|
|
||||||
styleOverrides.fontWeight = props.fontWeightCustom as CSSProperties["fontWeight"];
|
|
||||||
}
|
|
||||||
|
|
||||||
// 색상: colorCustom이 있으면 항상 우선 사용, 없으면 팔레트
|
|
||||||
const colorPalette = props.colorPalette ?? "default";
|
|
||||||
if (props.colorCustom && props.colorCustom.trim() !== "") {
|
|
||||||
styleOverrides.color = props.colorCustom.trim();
|
|
||||||
} else {
|
|
||||||
colorClass = `pb-text-color-${colorPalette}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 블록 배경색: backgroundColorCustom 이 설정된 경우 편집기 텍스트 컨테이너 배경에도 반영한다.
|
|
||||||
if (props.backgroundColorCustom && props.backgroundColorCustom.trim() !== "") {
|
|
||||||
styleOverrides.backgroundColor = props.backgroundColorCustom.trim();
|
|
||||||
}
|
|
||||||
|
|
||||||
// 최대 너비: 커스텀 값이 있으면 우선 사용, 없으면 scale 프리셋
|
|
||||||
const maxWidthScale = props.maxWidthScale ?? "none";
|
|
||||||
if (props.maxWidthCustom && props.maxWidthCustom.trim() !== "") {
|
|
||||||
styleOverrides.maxWidth = props.maxWidthCustom.trim();
|
|
||||||
} else if (maxWidthScale === "prose") {
|
|
||||||
maxWidthClass = "pb-text-maxw-prose";
|
|
||||||
} else if (maxWidthScale === "narrow") {
|
|
||||||
maxWidthClass = "pb-text-maxw-narrow";
|
|
||||||
}
|
|
||||||
|
|
||||||
// 글자 간격: 커스텀 값이 있으면 em 단위 그대로 사용
|
|
||||||
if (props.letterSpacingCustom && props.letterSpacingCustom.trim() !== "") {
|
|
||||||
styleOverrides.letterSpacing = props.letterSpacingCustom.trim();
|
|
||||||
}
|
|
||||||
|
|
||||||
// 텍스트 장식: 밑줄/가운데줄/이탤릭
|
|
||||||
if (props.underline) {
|
|
||||||
decorationClass += " pb-underline";
|
|
||||||
}
|
|
||||||
if (props.strike) {
|
|
||||||
decorationClass += " pb-line-through";
|
|
||||||
}
|
|
||||||
if (props.italic) {
|
|
||||||
decorationClass += " pb-italic";
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
alignClass,
|
|
||||||
sizeClass,
|
|
||||||
leadingClass,
|
|
||||||
weightClass,
|
|
||||||
colorClass,
|
|
||||||
maxWidthClass,
|
|
||||||
decorationClass,
|
|
||||||
styleOverrides,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface TextPublicTokens {
|
|
||||||
alignClass: string;
|
|
||||||
sizeClass: string;
|
|
||||||
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"
|
|
||||||
? "text-center"
|
|
||||||
: props.align === "right"
|
|
||||||
? "text-right"
|
|
||||||
: "text-left";
|
|
||||||
|
|
||||||
const fontSizeMode = props.fontSizeMode ?? "scale";
|
|
||||||
const fallbackScale = props.size === "sm" ? "sm" : props.size === "lg" ? "lg" : "base";
|
|
||||||
const fontSizeScale = props.fontSizeScale ?? fallbackScale;
|
|
||||||
|
|
||||||
const fontSizeScaleToClass: Record<string, string> = {
|
|
||||||
xs: "text-xs",
|
|
||||||
sm: "text-sm",
|
|
||||||
base: "text-base",
|
|
||||||
lg: "text-lg",
|
|
||||||
xl: "text-xl",
|
|
||||||
"2xl": "text-2xl",
|
|
||||||
"3xl": "text-3xl",
|
|
||||||
};
|
|
||||||
|
|
||||||
let sizeClass = "";
|
|
||||||
const styleOverrides: CSSProperties = {};
|
|
||||||
|
|
||||||
if (fontSizeMode === "scale" && fontSizeScale && fontSizeScaleToClass[fontSizeScale]) {
|
|
||||||
sizeClass = fontSizeScaleToClass[fontSizeScale];
|
|
||||||
} else if (
|
|
||||||
fontSizeMode === "custom" &&
|
|
||||||
props.fontSizeCustom &&
|
|
||||||
props.fontSizeCustom.trim() !== ""
|
|
||||||
) {
|
|
||||||
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() !== "") {
|
|
||||||
styleOverrides.color = props.colorCustom.trim();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (props.backgroundColorCustom && props.backgroundColorCustom.trim() !== "") {
|
|
||||||
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,
|
|
||||||
styleOverrides,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,231 +0,0 @@
|
|||||||
import type { CSSProperties } from "react";
|
|
||||||
import type { VideoBlockProps } from "@/features/editor/state/editorStore";
|
|
||||||
|
|
||||||
export type VideoPlatform = "youtube" | "vimeo" | "html5";
|
|
||||||
|
|
||||||
export type VideoPlatformInput = VideoPlatform | "auto" | null | undefined;
|
|
||||||
|
|
||||||
// 비디오 sourceUrl 을 정규화한다: null/undefined 를 빈 문자열로 만들고 양 끝 공백을 제거한다.
|
|
||||||
export function normalizeVideoSourceUrl(source: string | null | undefined): string {
|
|
||||||
if (!source) {
|
|
||||||
return "";
|
|
||||||
}
|
|
||||||
|
|
||||||
return source.trim();
|
|
||||||
}
|
|
||||||
|
|
||||||
// platform 값과 URL 을 기반으로 최종 비디오 플랫폼을 결정한다.
|
|
||||||
// - platform 이 auto 가 아니면 해당 값을 우선 사용한다.
|
|
||||||
// - auto 인 경우 URL 을 검사해 youtube/vimeo/html5 를 판별한다.
|
|
||||||
export function resolveVideoPlatform(rawUrl: string, platform: VideoPlatformInput): VideoPlatform {
|
|
||||||
if (platform && platform !== "auto") {
|
|
||||||
return platform;
|
|
||||||
}
|
|
||||||
|
|
||||||
const normalized = normalizeVideoSourceUrl(rawUrl);
|
|
||||||
const lower = normalized.toLowerCase();
|
|
||||||
|
|
||||||
if (lower.includes("youtube.com") || lower.includes("youtu.be")) {
|
|
||||||
return "youtube";
|
|
||||||
}
|
|
||||||
|
|
||||||
if (lower.includes("vimeo.com")) {
|
|
||||||
return "vimeo";
|
|
||||||
}
|
|
||||||
|
|
||||||
return "html5";
|
|
||||||
}
|
|
||||||
|
|
||||||
export type BuildEmbedUrlOptions = {
|
|
||||||
enableVimeoEmbed?: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
// 플랫폼과 URL 을 기반으로 최종 embed URL 을 생성한다.
|
|
||||||
// - YouTube: 항상 watch/short/youtu.be 형태에서 embed URL 로 변환.
|
|
||||||
// - Vimeo: enableVimeoEmbed 가 true 인 경우에만 player.vimeo.com 기반 embed URL 로 변환.
|
|
||||||
// - HTML5: 항상 정규화된 원본 URL 을 그대로 사용.
|
|
||||||
export function buildVideoEmbedUrl(
|
|
||||||
rawUrl: string,
|
|
||||||
platform: VideoPlatform,
|
|
||||||
options?: BuildEmbedUrlOptions,
|
|
||||||
): string {
|
|
||||||
const normalized = normalizeVideoSourceUrl(rawUrl);
|
|
||||||
|
|
||||||
if (platform === "youtube") {
|
|
||||||
const idMatch =
|
|
||||||
normalized.match(/[?&]v=([^&]+)/) ||
|
|
||||||
normalized.match(/youtu\.be\/([^?&]+)/) ||
|
|
||||||
normalized.match(/youtube\.com\/shorts\/([^?&]+)/);
|
|
||||||
const videoId = idMatch && idMatch[1] ? idMatch[1] : "";
|
|
||||||
if (videoId) {
|
|
||||||
return `https://www.youtube.com/embed/${videoId}`;
|
|
||||||
}
|
|
||||||
return normalized;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (platform === "vimeo" && options?.enableVimeoEmbed) {
|
|
||||||
const idMatch = normalized.match(/vimeo\.com\/(\d+)/);
|
|
||||||
const videoId = idMatch && idMatch[1] ? idMatch[1] : "";
|
|
||||||
if (videoId) {
|
|
||||||
return `https://player.vimeo.com/video/${videoId}`;
|
|
||||||
}
|
|
||||||
return normalized;
|
|
||||||
}
|
|
||||||
|
|
||||||
return normalized;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface VideoExportEscapers {
|
|
||||||
escapeAttr: (value: string) => string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface VideoExportTokens {
|
|
||||||
wrapperStyleParts: string[];
|
|
||||||
videoStyleParts: string[];
|
|
||||||
outerStyleParts: string[];
|
|
||||||
aspectClass: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function computeVideoExportTokens(
|
|
||||||
props: VideoBlockProps,
|
|
||||||
escapers: VideoExportEscapers,
|
|
||||||
): VideoExportTokens {
|
|
||||||
const { escapeAttr } = escapers;
|
|
||||||
|
|
||||||
const aspect = props.aspectRatio ?? "16:9";
|
|
||||||
const aspectClass =
|
|
||||||
aspect === "4:3" ? " pb-video-wrapper--4by3" : aspect === "1:1" ? " pb-video-wrapper--1by1" : "";
|
|
||||||
|
|
||||||
const wrapperStyleParts: string[] = [];
|
|
||||||
const videoStyleParts: string[] = [];
|
|
||||||
|
|
||||||
const widthMode = props.widthMode ?? "auto";
|
|
||||||
if (widthMode === "fixed" && typeof props.widthPx === "number" && props.widthPx > 0) {
|
|
||||||
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%");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (typeof props.backgroundColorCustom === "string" && props.backgroundColorCustom.trim() !== "") {
|
|
||||||
wrapperStyleParts.push(`background-color:${escapeAttr(props.backgroundColorCustom.trim())}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (typeof props.cardPaddingPx === "number" && props.cardPaddingPx >= 0) {
|
|
||||||
wrapperStyleParts.push(`padding:${pxToEm(props.cardPaddingPx)}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (typeof props.borderRadiusPx === "number" && props.borderRadiusPx >= 0) {
|
|
||||||
wrapperStyleParts.push(`border-radius:${pxToEm(props.borderRadiusPx)}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const align =
|
|
||||||
props.align === "left" ? "flex-start" : props.align === "right" ? "flex-end" : "center";
|
|
||||||
const outerStyleParts = ["display:flex", `justify-content:${align}`];
|
|
||||||
|
|
||||||
return {
|
|
||||||
wrapperStyleParts,
|
|
||||||
videoStyleParts,
|
|
||||||
outerStyleParts,
|
|
||||||
aspectClass,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface VideoPublicTokens {
|
|
||||||
wrapperStyle: CSSProperties;
|
|
||||||
videoStyle: CSSProperties;
|
|
||||||
justifyClass: string;
|
|
||||||
aspectClass: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const pxToEm = (px: number, base = 16) => `${px / base}em`;
|
|
||||||
|
|
||||||
export function computeVideoPublicTokens(props: VideoBlockProps): VideoPublicTokens {
|
|
||||||
const aspect = props.aspectRatio ?? "16:9";
|
|
||||||
const aspectClass =
|
|
||||||
aspect === "4:3" ? " pb-video-wrapper--4by3" : aspect === "1:1" ? " pb-video-wrapper--1by1" : "";
|
|
||||||
|
|
||||||
const wrapperStyle: CSSProperties = {};
|
|
||||||
const videoStyle: CSSProperties = {};
|
|
||||||
|
|
||||||
const widthMode = props.widthMode ?? "auto";
|
|
||||||
if (widthMode === "fixed" && typeof props.widthPx === "number" && props.widthPx > 0) {
|
|
||||||
const widthEm = pxToEm(props.widthPx);
|
|
||||||
wrapperStyle.width = widthEm;
|
|
||||||
videoStyle.width = widthEm;
|
|
||||||
} else if (widthMode === "full") {
|
|
||||||
wrapperStyle.width = "100%";
|
|
||||||
videoStyle.width = "100%";
|
|
||||||
}
|
|
||||||
|
|
||||||
if (props.backgroundColorCustom && props.backgroundColorCustom.trim() !== "") {
|
|
||||||
wrapperStyle.backgroundColor = props.backgroundColorCustom.trim();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (typeof (props as any).cardPaddingPx === "number" && (props as any).cardPaddingPx >= 0) {
|
|
||||||
wrapperStyle.padding = pxToEm((props as any).cardPaddingPx as number);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (typeof (props as any).borderRadiusPx === "number" && (props as any).borderRadiusPx >= 0) {
|
|
||||||
wrapperStyle.borderRadius = pxToEm((props as any).borderRadiusPx as number);
|
|
||||||
}
|
|
||||||
|
|
||||||
const align = props.align ?? "center";
|
|
||||||
const justifyClass =
|
|
||||||
align === "left" ? "justify-start" : align === "right" ? "justify-end" : "justify-center";
|
|
||||||
|
|
||||||
return {
|
|
||||||
wrapperStyle,
|
|
||||||
videoStyle,
|
|
||||||
justifyClass,
|
|
||||||
aspectClass,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface VideoEditorTokens {
|
|
||||||
wrapperStyle: CSSProperties;
|
|
||||||
videoStyle: CSSProperties;
|
|
||||||
justifyClass: string;
|
|
||||||
aspectClass: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function computeVideoEditorTokens(props: VideoBlockProps): VideoEditorTokens {
|
|
||||||
const aspect = props.aspectRatio ?? "16:9";
|
|
||||||
const aspectClass =
|
|
||||||
aspect === "4:3" ? " pb-video-wrapper--4by3" : aspect === "1:1" ? " pb-video-wrapper--1by1" : "";
|
|
||||||
|
|
||||||
const wrapperStyle: CSSProperties = {};
|
|
||||||
const videoStyle: CSSProperties = {};
|
|
||||||
|
|
||||||
const widthMode = props.widthMode ?? "auto";
|
|
||||||
if (widthMode === "fixed" && typeof props.widthPx === "number" && props.widthPx > 0) {
|
|
||||||
wrapperStyle.width = `${props.widthPx}px`;
|
|
||||||
videoStyle.width = `${props.widthPx}px`;
|
|
||||||
} else if (widthMode === "full") {
|
|
||||||
wrapperStyle.width = "100%";
|
|
||||||
videoStyle.width = "100%";
|
|
||||||
}
|
|
||||||
|
|
||||||
if (props.backgroundColorCustom && props.backgroundColorCustom.trim() !== "") {
|
|
||||||
wrapperStyle.backgroundColor = props.backgroundColorCustom.trim();
|
|
||||||
}
|
|
||||||
if (typeof props.cardPaddingPx === "number" && props.cardPaddingPx >= 0) {
|
|
||||||
wrapperStyle.padding = `${props.cardPaddingPx}px`;
|
|
||||||
}
|
|
||||||
if (typeof props.borderRadiusPx === "number" && props.borderRadiusPx >= 0) {
|
|
||||||
wrapperStyle.borderRadius = `${props.borderRadiusPx}px`;
|
|
||||||
}
|
|
||||||
|
|
||||||
const align = props.align ?? "center";
|
|
||||||
const justifyClass =
|
|
||||||
align === "left" ? "justify-start" : align === "right" ? "justify-end" : "justify-center";
|
|
||||||
|
|
||||||
return {
|
|
||||||
wrapperStyle,
|
|
||||||
videoStyle,
|
|
||||||
justifyClass,
|
|
||||||
aspectClass,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,462 +0,0 @@
|
|||||||
/* Builder export/global design system classes */
|
|
||||||
|
|
||||||
/*
|
|
||||||
디자인 토큰: 폰트/라인하이트/색상/텍스트 폭
|
|
||||||
- 에디터 프리뷰와 내보내기 HTML 모두 이 토큰과 pb-* 클래스를 공유한다.
|
|
||||||
*/
|
|
||||||
:root {
|
|
||||||
/* Font sizes (rem 단위, Tailwind 스케일과 유사) */
|
|
||||||
--pb-font-xs: 0.75rem; /* 12px */
|
|
||||||
--pb-font-sm: 0.875rem; /* 14px */
|
|
||||||
--pb-font-base: 1rem; /* 16px */
|
|
||||||
--pb-font-lg: 1.125rem; /* 18px */
|
|
||||||
--pb-font-xl: 1.25rem; /* 20px */
|
|
||||||
--pb-font-2xl: 1.5rem; /* 24px */
|
|
||||||
--pb-font-3xl: 1.875rem; /* 30px */
|
|
||||||
|
|
||||||
/* Line heights */
|
|
||||||
--pb-leading-tight: 1.25;
|
|
||||||
--pb-leading-snug: 1.35;
|
|
||||||
--pb-leading-normal: 1.5;
|
|
||||||
--pb-leading-relaxed: 1.7;
|
|
||||||
--pb-leading-loose: 1.9;
|
|
||||||
|
|
||||||
/* Text colors (팔레트) */
|
|
||||||
--pb-color-text-default: #e5e7eb; /* slate-200 정도 */
|
|
||||||
--pb-color-text-muted: #9ca3af; /* slate-400 */
|
|
||||||
--pb-color-text-strong: #f9fafb; /* slate-50 */
|
|
||||||
--pb-color-text-accent: #38bdf8; /* sky-400 */
|
|
||||||
--pb-color-text-danger: #f97373; /* red-400 근처 */
|
|
||||||
|
|
||||||
/* Text max width */
|
|
||||||
--pb-text-maxw-prose: 60ch;
|
|
||||||
--pb-text-maxw-narrow: 40ch;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 에디터/프리뷰 공통 기본 타이포 설정 (em/rem 기반) */
|
|
||||||
body {
|
|
||||||
margin: 0;
|
|
||||||
padding: 0;
|
|
||||||
font-size: 1rem; /* 기본 16px 기준 */
|
|
||||||
line-height: 1.5; /* 읽기 좋은 기본 라인하이트 */
|
|
||||||
letter-spacing: 0em; /* 기본 글자 간격은 0em */
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Static export root layout */
|
|
||||||
.pb-root {
|
|
||||||
display: block;
|
|
||||||
}
|
|
||||||
|
|
||||||
.pb-root-inner {
|
|
||||||
max-width: 72rem; /* ~1152px */
|
|
||||||
margin-inline: auto;
|
|
||||||
padding-inline: 1.5rem;
|
|
||||||
padding-block: 2.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.pb-section {
|
|
||||||
display: block;
|
|
||||||
}
|
|
||||||
|
|
||||||
.pb-section-inner {
|
|
||||||
padding-inline: 1.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.pb-section-columns {
|
|
||||||
display: flex;
|
|
||||||
gap: 2rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.pb-section-column {
|
|
||||||
flex: 1 1 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Section background video */
|
|
||||||
.pb-section-bg-video {
|
|
||||||
position: absolute;
|
|
||||||
inset: 0;
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
object-fit: cover;
|
|
||||||
z-index: -1;
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Text alignment */
|
|
||||||
.pb-text-left {
|
|
||||||
text-align: left;
|
|
||||||
}
|
|
||||||
.pb-text-center {
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
.pb-text-right {
|
|
||||||
text-align: right;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Text size scale (디자인 토큰 기반) */
|
|
||||||
.pb-text-xs {
|
|
||||||
font-size: var(--pb-font-xs);
|
|
||||||
}
|
|
||||||
.pb-text-sm {
|
|
||||||
font-size: var(--pb-font-sm);
|
|
||||||
}
|
|
||||||
.pb-text-base {
|
|
||||||
font-size: var(--pb-font-base);
|
|
||||||
}
|
|
||||||
.pb-text-lg {
|
|
||||||
font-size: var(--pb-font-lg);
|
|
||||||
}
|
|
||||||
.pb-text-xl {
|
|
||||||
font-size: var(--pb-font-xl);
|
|
||||||
}
|
|
||||||
.pb-text-2xl {
|
|
||||||
font-size: var(--pb-font-2xl);
|
|
||||||
}
|
|
||||||
.pb-text-3xl {
|
|
||||||
font-size: var(--pb-font-3xl);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Line-height scale */
|
|
||||||
.pb-leading-tight {
|
|
||||||
line-height: var(--pb-leading-tight);
|
|
||||||
}
|
|
||||||
.pb-leading-snug {
|
|
||||||
line-height: var(--pb-leading-snug);
|
|
||||||
}
|
|
||||||
.pb-leading-normal {
|
|
||||||
line-height: var(--pb-leading-normal);
|
|
||||||
}
|
|
||||||
.pb-leading-relaxed {
|
|
||||||
line-height: var(--pb-leading-relaxed);
|
|
||||||
}
|
|
||||||
.pb-leading-loose {
|
|
||||||
line-height: var(--pb-leading-loose);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Font weight scale */
|
|
||||||
.pb-font-normal {
|
|
||||||
font-weight: 400;
|
|
||||||
}
|
|
||||||
.pb-font-medium {
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
|
||||||
.pb-font-semibold {
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
.pb-font-bold {
|
|
||||||
font-weight: 700;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Text color palette */
|
|
||||||
.pb-text-color-default {
|
|
||||||
color: var(--pb-color-text-default);
|
|
||||||
}
|
|
||||||
.pb-text-color-muted {
|
|
||||||
color: var(--pb-color-text-muted);
|
|
||||||
}
|
|
||||||
.pb-text-color-strong {
|
|
||||||
color: var(--pb-color-text-strong);
|
|
||||||
}
|
|
||||||
.pb-text-color-accent {
|
|
||||||
color: var(--pb-color-text-accent);
|
|
||||||
}
|
|
||||||
.pb-text-color-danger {
|
|
||||||
color: var(--pb-color-text-danger);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Additional text colors for presets */
|
|
||||||
.pb-text-color-success {
|
|
||||||
color: #22c55e;
|
|
||||||
}
|
|
||||||
.pb-text-color-warning {
|
|
||||||
color: #eab308;
|
|
||||||
}
|
|
||||||
.pb-text-color-info {
|
|
||||||
color: #0ea5e9;
|
|
||||||
}
|
|
||||||
.pb-text-color-neutral {
|
|
||||||
color: #94a3b8;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Text decoration & style */
|
|
||||||
.pb-underline {
|
|
||||||
text-decoration-line: underline;
|
|
||||||
}
|
|
||||||
.pb-line-through {
|
|
||||||
text-decoration-line: line-through;
|
|
||||||
}
|
|
||||||
.pb-italic {
|
|
||||||
font-style: italic;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Text max width presets */
|
|
||||||
.pb-text-maxw-prose {
|
|
||||||
max-width: var(--pb-text-maxw-prose);
|
|
||||||
}
|
|
||||||
.pb-text-maxw-narrow {
|
|
||||||
max-width: var(--pb-text-maxw-narrow);
|
|
||||||
}
|
|
||||||
|
|
||||||
.pb-whitespace-pre-wrap {
|
|
||||||
white-space: pre-wrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.pb-scroll {
|
|
||||||
scrollbar-width: thin;
|
|
||||||
scrollbar-color: #1f2937 #020617;
|
|
||||||
}
|
|
||||||
|
|
||||||
.pb-scroll::-webkit-scrollbar {
|
|
||||||
width: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.pb-scroll::-webkit-scrollbar-track {
|
|
||||||
background: transparent;
|
|
||||||
}
|
|
||||||
|
|
||||||
.pb-scroll::-webkit-scrollbar-thumb {
|
|
||||||
background-color: #1f2937;
|
|
||||||
border-radius: 9999px;
|
|
||||||
border: 2px solid #020617;
|
|
||||||
}
|
|
||||||
|
|
||||||
.pb-scroll::-webkit-scrollbar-thumb:hover {
|
|
||||||
background-color: #374151;
|
|
||||||
}
|
|
||||||
|
|
||||||
.pb-video-wrapper {
|
|
||||||
position: relative;
|
|
||||||
width: 100%;
|
|
||||||
height: 0;
|
|
||||||
padding-bottom: 56.25%;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.pb-video-wrapper--4by3 {
|
|
||||||
padding-bottom: 75%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.pb-video-wrapper--1by1 {
|
|
||||||
padding-bottom: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.pb-video-frame {
|
|
||||||
position: absolute;
|
|
||||||
inset: 0;
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
border: 0;
|
|
||||||
display: block;
|
|
||||||
}
|
|
||||||
|
|
||||||
.pb-video-caption {
|
|
||||||
display: none;
|
|
||||||
margin-top: 0.5rem;
|
|
||||||
font-size: 0.75rem;
|
|
||||||
color: #9ca3af;
|
|
||||||
}
|
|
||||||
|
|
||||||
.pb-btn-base {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
font-size: 0.75rem;
|
|
||||||
font-weight: 500;
|
|
||||||
border-width: 1px;
|
|
||||||
text-decoration: none;
|
|
||||||
}
|
|
||||||
.pb-btn-size-xs {
|
|
||||||
padding: 0.125rem 0.5rem;
|
|
||||||
}
|
|
||||||
.pb-btn-size-sm {
|
|
||||||
padding: 0.25rem 0.75rem;
|
|
||||||
}
|
|
||||||
.pb-btn-size-md {
|
|
||||||
padding: 0.375rem 1rem;
|
|
||||||
}
|
|
||||||
.pb-btn-size-lg {
|
|
||||||
padding: 0.5rem 1.25rem;
|
|
||||||
}
|
|
||||||
.pb-btn-size-xl {
|
|
||||||
padding: 0.75rem 1.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.pb-btn-radius-none {
|
|
||||||
border-radius: 0;
|
|
||||||
}
|
|
||||||
.pb-btn-radius-sm {
|
|
||||||
border-radius: 0.25rem;
|
|
||||||
}
|
|
||||||
.pb-btn-radius-md {
|
|
||||||
border-radius: 0.5rem;
|
|
||||||
}
|
|
||||||
.pb-btn-radius-lg {
|
|
||||||
border-radius: 0.75rem;
|
|
||||||
}
|
|
||||||
.pb-btn-radius-full {
|
|
||||||
border-radius: 9999px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.pb-btn-variant-solid-primary {
|
|
||||||
background-color: #0ea5e9;
|
|
||||||
border-color: #0284c7;
|
|
||||||
color: #0b1120;
|
|
||||||
}
|
|
||||||
.pb-btn-variant-solid-muted {
|
|
||||||
background-color: #1f2937;
|
|
||||||
border-color: #4b5563;
|
|
||||||
color: #e5e7eb;
|
|
||||||
}
|
|
||||||
.pb-btn-variant-solid-danger {
|
|
||||||
background-color: #ef4444;
|
|
||||||
border-color: #b91c1c;
|
|
||||||
color: #f9fafb;
|
|
||||||
}
|
|
||||||
.pb-btn-variant-solid-success {
|
|
||||||
background-color: #22c55e;
|
|
||||||
border-color: #15803d;
|
|
||||||
color: #022c22;
|
|
||||||
}
|
|
||||||
.pb-btn-variant-solid-neutral {
|
|
||||||
background-color: #4b5563;
|
|
||||||
border-color: #374151;
|
|
||||||
color: #e5e7eb;
|
|
||||||
}
|
|
||||||
|
|
||||||
.pb-btn-variant-outline-primary {
|
|
||||||
background-color: transparent;
|
|
||||||
border-color: #38bdf8;
|
|
||||||
color: #e0f2fe;
|
|
||||||
}
|
|
||||||
.pb-btn-variant-outline-muted {
|
|
||||||
background-color: transparent;
|
|
||||||
border-color: #4b5563;
|
|
||||||
color: #e5e7eb;
|
|
||||||
}
|
|
||||||
.pb-btn-variant-outline-danger {
|
|
||||||
background-color: transparent;
|
|
||||||
border-color: #f97373;
|
|
||||||
color: #fecaca;
|
|
||||||
}
|
|
||||||
.pb-btn-variant-outline-success {
|
|
||||||
background-color: transparent;
|
|
||||||
border-color: #22c55e;
|
|
||||||
color: #bbf7d0;
|
|
||||||
}
|
|
||||||
.pb-btn-variant-outline-neutral {
|
|
||||||
background-color: transparent;
|
|
||||||
border-color: #64748b;
|
|
||||||
color: #e5e7eb;
|
|
||||||
}
|
|
||||||
|
|
||||||
.pb-btn-variant-ghost-primary {
|
|
||||||
background-color: transparent;
|
|
||||||
border-color: transparent;
|
|
||||||
color: #38bdf8;
|
|
||||||
}
|
|
||||||
.pb-btn-variant-ghost-muted {
|
|
||||||
background-color: transparent;
|
|
||||||
border-color: transparent;
|
|
||||||
color: #9ca3af;
|
|
||||||
}
|
|
||||||
.pb-btn-variant-ghost-danger {
|
|
||||||
background-color: transparent;
|
|
||||||
border-color: transparent;
|
|
||||||
color: #f97373;
|
|
||||||
}
|
|
||||||
.pb-btn-variant-ghost-success {
|
|
||||||
background-color: transparent;
|
|
||||||
border-color: transparent;
|
|
||||||
color: #4ade80;
|
|
||||||
}
|
|
||||||
.pb-btn-variant-ghost-neutral {
|
|
||||||
background-color: transparent;
|
|
||||||
border-color: transparent;
|
|
||||||
color: #cbd5f5;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Section background variants */
|
|
||||||
.pb-section-bg-default {
|
|
||||||
background-color: rgba(15, 23, 42, 0.6); /* slate-900/60 느낌 */
|
|
||||||
}
|
|
||||||
.pb-section-bg-muted {
|
|
||||||
background-color: rgba(15, 23, 42, 0.4); /* slate-950/40 느낌 */
|
|
||||||
}
|
|
||||||
.pb-section-bg-primary {
|
|
||||||
background-color: rgba(8, 47, 73, 0.4); /* sky-950/40 느낌 */
|
|
||||||
border-color: rgba(8, 47, 73, 0.6);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Section vertical padding scale */
|
|
||||||
.pb-section-py-sm {
|
|
||||||
padding-top: 1rem;
|
|
||||||
padding-bottom: 1rem;
|
|
||||||
}
|
|
||||||
.pb-section-py-md {
|
|
||||||
padding-top: 1.5rem;
|
|
||||||
padding-bottom: 1.5rem;
|
|
||||||
}
|
|
||||||
.pb-section-py-lg {
|
|
||||||
padding-top: 2.5rem;
|
|
||||||
padding-bottom: 2.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Simple form styling for static export */
|
|
||||||
.pb-form {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 0.75rem;
|
|
||||||
max-width: 40rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Form 컨트롤러용 최소 폼: 레이아웃에 영향이 없도록 margin/padding 을 0 으로 고정한다. */
|
|
||||||
.pb-form-controller {
|
|
||||||
margin: 0;
|
|
||||||
padding: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.pb-form-field {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 0.25rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.pb-form-label {
|
|
||||||
font-size: 0.75rem;
|
|
||||||
color: #e5e7eb;
|
|
||||||
}
|
|
||||||
|
|
||||||
.pb-form-option {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.25rem;
|
|
||||||
font-size: 0.75rem;
|
|
||||||
color: #e5e7eb;
|
|
||||||
}
|
|
||||||
|
|
||||||
.pb-input,
|
|
||||||
.pb-select,
|
|
||||||
.pb-textarea {
|
|
||||||
width: 100%;
|
|
||||||
border-radius: 0.375rem;
|
|
||||||
border: 1px solid #1f2937;
|
|
||||||
background-color: #020617;
|
|
||||||
padding: 0.5rem 0.75rem;
|
|
||||||
font-size: 0.75rem;
|
|
||||||
color: #e5e7eb;
|
|
||||||
box-sizing: border-box;
|
|
||||||
}
|
|
||||||
|
|
||||||
.pb-textarea {
|
|
||||||
min-height: 6rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* List styling for static export */
|
|
||||||
.pb-list {
|
|
||||||
margin: 0;
|
|
||||||
padding-left: 1.25rem;
|
|
||||||
font-size: 0.875rem;
|
|
||||||
line-height: var(--pb-leading-normal);
|
|
||||||
color: var(--pb-color-text-default);
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -1,4 +1,6 @@
|
|||||||
@import "tailwindcss";
|
@tailwind base;
|
||||||
|
@tailwind components;
|
||||||
|
@tailwind utilities;
|
||||||
|
|
||||||
html,
|
html,
|
||||||
body {
|
body {
|
||||||
|
|||||||
@@ -1,267 +0,0 @@
|
|||||||
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=");
|
|
||||||
expect(setCookie?.toLowerCase()).toContain("max-age=604800");
|
|
||||||
});
|
|
||||||
|
|
||||||
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);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,89 +0,0 @@
|
|||||||
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);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,111 +0,0 @@
|
|||||||
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 unknown as [any, RequestInit];
|
|
||||||
|
|
||||||
expect(url).toBe("/api/forms/submit");
|
|
||||||
expect(options.method).toBe("POST");
|
|
||||||
expect(options.body).toBeInstanceOf(FormData);
|
|
||||||
|
|
||||||
const body = options.body as FormData;
|
|
||||||
expect(body.get("__projectSlug")).toBe(projectConfig.slug);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,557 +0,0 @@
|
|||||||
import "dotenv/config";
|
|
||||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
||||||
|
|
||||||
import type { FormBlockProps } from "@/features/editor/state/editorStore";
|
|
||||||
|
|
||||||
const BASE_URL = "http://localhost";
|
|
||||||
|
|
||||||
// /api/forms/submit 테스트에서는 실제 DB 대신 메모리 기반 프로젝트/제출 내역 저장소를 사용한다.
|
|
||||||
// 이렇게 하면 DATABASE_URL 없이도 Prisma 의 의존성을 만족시키면서 라우트 로직을 검증할 수 있다.
|
|
||||||
const inMemoryProjects: any[] = [];
|
|
||||||
const inMemoryFormSubmissions: any[] = [];
|
|
||||||
|
|
||||||
vi.mock("@prisma/client", () => {
|
|
||||||
class PrismaClientMock {
|
|
||||||
// 프로젝트 조회는 슬러그 기준으로 수행한다.
|
|
||||||
project = {
|
|
||||||
findUnique: async ({ where: { slug } }: any) => {
|
|
||||||
return inMemoryProjects.find((p) => p.slug === slug) ?? null;
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
// 폼 제출 내역은 단순 배열에 push 하여 테스트에서 검증할 수 있도록 한다.
|
|
||||||
formSubmission = {
|
|
||||||
create: async ({ data }: any) => {
|
|
||||||
const now = new Date();
|
|
||||||
const record = {
|
|
||||||
id: String(inMemoryFormSubmissions.length + 1),
|
|
||||||
createdAt: now,
|
|
||||||
...data,
|
|
||||||
};
|
|
||||||
inMemoryFormSubmissions.push(record);
|
|
||||||
return record;
|
|
||||||
},
|
|
||||||
findMany: async () => {
|
|
||||||
return [...inMemoryFormSubmissions];
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return { PrismaClient: PrismaClientMock };
|
|
||||||
});
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
// 각 테스트 전에 메모리 스토리지를 초기화하고, 암호화 키를 고정 값으로 설정한다.
|
|
||||||
inMemoryProjects.length = 0;
|
|
||||||
inMemoryFormSubmissions.length = 0;
|
|
||||||
process.env.AUTH_ENCRYPTION_KEY = "0123456789abcdef0123456789abcdef";
|
|
||||||
});
|
|
||||||
|
|
||||||
// /api/forms/submit 라우트에 대한 기본 TDD:
|
|
||||||
// - internal 모드에서 임의 필드가 포함된 FormData 를 받아도 에러 없이 ok:true 를 반환해야 한다.
|
|
||||||
// - webhook 모드에서는 config.extraParams / headers 와 함께 x-www-form-urlencoded 로 전달해야 한다.
|
|
||||||
|
|
||||||
describe("/api/forms/submit", () => {
|
|
||||||
it("internal 모드에서는 v2 필드 이름(email_address 등)을 포함한 FormData 를 받아도 ok:true 를 반환해야 한다", async () => {
|
|
||||||
const config: FormBlockProps = {
|
|
||||||
kind: "contact",
|
|
||||||
submitTarget: "internal",
|
|
||||||
successMessage: "성공적으로 전송되었습니다.",
|
|
||||||
errorMessage: "전송 중 오류가 발생했습니다.",
|
|
||||||
fieldIds: [],
|
|
||||||
submitButtonId: null,
|
|
||||||
};
|
|
||||||
|
|
||||||
const formData = new FormData();
|
|
||||||
formData.append("email_address", "test@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);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("webhook 모드에서는 extraParams 와 FormData 필드들이 x-www-form-urlencoded payload 로 전달되어야 한다", async () => {
|
|
||||||
const destinationUrl = `${BASE_URL}/webhook-test`;
|
|
||||||
|
|
||||||
const config: FormBlockProps = {
|
|
||||||
kind: "contact",
|
|
||||||
submitTarget: "webhook",
|
|
||||||
destinationUrl,
|
|
||||||
method: "POST",
|
|
||||||
headers: { Authorization: "Bearer test-token" },
|
|
||||||
extraParams: { source: "builder", formId: "contact-hero" },
|
|
||||||
successMessage: "성공적으로 전송되었습니다.",
|
|
||||||
errorMessage: "전송 중 오류가 발생했습니다.",
|
|
||||||
fieldIds: [],
|
|
||||||
submitButtonId: null,
|
|
||||||
};
|
|
||||||
|
|
||||||
// fetch 를 목킹하여 payload 와 헤더가 기대대로 전달되는지 검증한다.
|
|
||||||
const fetchMock = vi.fn(async () => new Response("ok", { status: 200 }));
|
|
||||||
// @ts-expect-error - 글로벌 fetch 재정의
|
|
||||||
global.fetch = fetchMock;
|
|
||||||
|
|
||||||
const formData = new FormData();
|
|
||||||
formData.append("email_address", "test@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(fetchMock).toHaveBeenCalledTimes(1);
|
|
||||||
const [url, options] = fetchMock.mock.calls[0] as [string, RequestInit];
|
|
||||||
expect(url).toBe(destinationUrl);
|
|
||||||
expect(options.method).toBe("POST");
|
|
||||||
expect(options.headers).toMatchObject({
|
|
||||||
"Content-Type": "application/x-www-form-urlencoded",
|
|
||||||
Authorization: "Bearer test-token",
|
|
||||||
});
|
|
||||||
|
|
||||||
const body = options.body as string;
|
|
||||||
// payload 안에 폼 필드와 extraParams 가 모두 포함되어야 한다.
|
|
||||||
expect(body).toContain("email_address=test%40example.com");
|
|
||||||
expect(body).toContain("source=builder");
|
|
||||||
expect(body).toContain("formId=contact-hero");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("webhook 모드에서 payloadFormat 이 json 인 경우 application/json 으로 JSON body 를 전송해야 한다", async () => {
|
|
||||||
const destinationUrl = `${BASE_URL}/webhook-json-test`;
|
|
||||||
|
|
||||||
const config: FormBlockProps = {
|
|
||||||
kind: "contact",
|
|
||||||
submitTarget: "webhook",
|
|
||||||
destinationUrl,
|
|
||||||
method: "POST",
|
|
||||||
headers: { Authorization: "Bearer json-token" },
|
|
||||||
extraParams: { source: "builder", formId: "contact-json" },
|
|
||||||
successMessage: "성공적으로 전송되었습니다.",
|
|
||||||
errorMessage: "전송 중 오류가 발생했습니다.",
|
|
||||||
fieldIds: [],
|
|
||||||
submitButtonId: null,
|
|
||||||
payloadFormat: "json",
|
|
||||||
};
|
|
||||||
|
|
||||||
const fetchMock = vi.fn(async () => new Response("ok", { status: 200 }));
|
|
||||||
// @ts-expect-error - 글로벌 fetch 재정의
|
|
||||||
global.fetch = fetchMock;
|
|
||||||
|
|
||||||
const formData = new FormData();
|
|
||||||
formData.append("email_address", "json@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 unknown as [string, RequestInit];
|
|
||||||
expect(url).toBe(destinationUrl);
|
|
||||||
expect(options.method).toBe("POST");
|
|
||||||
expect(options.headers).toMatchObject({
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
Authorization: "Bearer json-token",
|
|
||||||
});
|
|
||||||
|
|
||||||
const body = options.body as string;
|
|
||||||
const parsed = JSON.parse(body) as Record<string, string>;
|
|
||||||
expect(parsed.email_address).toBe("json@example.com");
|
|
||||||
expect(parsed.source).toBe("builder");
|
|
||||||
expect(parsed.formId).toBe("contact-json");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("webhook 모드 성공 시에도 successMessage 가 응답 message 필드에 포함되어야 한다", async () => {
|
|
||||||
const destinationUrl = `${BASE_URL}/webhook-success-message`;
|
|
||||||
|
|
||||||
const config: FormBlockProps = {
|
|
||||||
kind: "contact",
|
|
||||||
submitTarget: "webhook",
|
|
||||||
destinationUrl,
|
|
||||||
method: "POST",
|
|
||||||
headers: { Authorization: "Bearer success-token" },
|
|
||||||
extraParams: { source: "builder" },
|
|
||||||
successMessage: "웹훅으로 전송되었습니다.",
|
|
||||||
errorMessage: "웹훅 전송 실패",
|
|
||||||
fieldIds: [],
|
|
||||||
submitButtonId: null,
|
|
||||||
};
|
|
||||||
|
|
||||||
const fetchMock = vi.fn(async () => new Response("ok", { status: 200 }));
|
|
||||||
// @ts-expect-error - 글로벌 fetch 재정의
|
|
||||||
global.fetch = fetchMock;
|
|
||||||
|
|
||||||
const formData = new FormData();
|
|
||||||
formData.append("email_address", "success-webhook@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);
|
|
||||||
});
|
|
||||||
|
|
||||||
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",
|
|
||||||
submitTarget: "webhook",
|
|
||||||
// destinationUrl 누락
|
|
||||||
successMessage: "성공 메시지",
|
|
||||||
errorMessage: "Webhook URL 이 설정되지 않았습니다.",
|
|
||||||
fieldIds: [],
|
|
||||||
submitButtonId: null,
|
|
||||||
} as any;
|
|
||||||
|
|
||||||
const formData = new FormData();
|
|
||||||
formData.append("email_address", "missing-destination@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(400);
|
|
||||||
const json = (await res.json()) as any;
|
|
||||||
expect(json.ok).toBe(false);
|
|
||||||
expect(json.error).toBe("destinationUrl_missing");
|
|
||||||
expect(json.message).toBe(config.errorMessage);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("webhook 모드에서 원격 응답이 실패하면 502 와 webhook_failed, errorMessage 가 포함되어야 한다", async () => {
|
|
||||||
const destinationUrl = `${BASE_URL}/webhook-fail`;
|
|
||||||
|
|
||||||
const config: FormBlockProps = {
|
|
||||||
kind: "contact",
|
|
||||||
submitTarget: "webhook",
|
|
||||||
destinationUrl,
|
|
||||||
method: "POST",
|
|
||||||
headers: {},
|
|
||||||
extraParams: {},
|
|
||||||
successMessage: "성공 메시지",
|
|
||||||
errorMessage: "웹훅 호출이 실패했습니다.",
|
|
||||||
fieldIds: [],
|
|
||||||
submitButtonId: null,
|
|
||||||
};
|
|
||||||
|
|
||||||
const fetchMock = vi.fn(async () => new Response("remote error", { status: 503 }));
|
|
||||||
// @ts-expect-error - 글로벌 fetch 재정의
|
|
||||||
global.fetch = fetchMock;
|
|
||||||
|
|
||||||
const formData = new FormData();
|
|
||||||
formData.append("email_address", "fail-remote@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(502);
|
|
||||||
const json = (await res.json()) as any;
|
|
||||||
expect(json.ok).toBe(false);
|
|
||||||
expect(json.error).toBe("webhook_failed");
|
|
||||||
expect(json.message).toBe(config.errorMessage);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("webhook 모드에서 fetch 예외가 발생하면 500 과 webhook_exception, errorMessage 가 포함되어야 한다", async () => {
|
|
||||||
const destinationUrl = `${BASE_URL}/webhook-exception`;
|
|
||||||
|
|
||||||
const config: FormBlockProps = {
|
|
||||||
kind: "contact",
|
|
||||||
submitTarget: "webhook",
|
|
||||||
destinationUrl,
|
|
||||||
method: "POST",
|
|
||||||
headers: {},
|
|
||||||
extraParams: {},
|
|
||||||
successMessage: "성공 메시지",
|
|
||||||
errorMessage: "웹훅 호출 중 예외가 발생했습니다.",
|
|
||||||
fieldIds: [],
|
|
||||||
submitButtonId: null,
|
|
||||||
};
|
|
||||||
|
|
||||||
const fetchMock = vi.fn(async () => {
|
|
||||||
throw new Error("network error");
|
|
||||||
});
|
|
||||||
// @ts-expect-error - 글로벌 fetch 재정의
|
|
||||||
global.fetch = fetchMock;
|
|
||||||
|
|
||||||
const formData = new FormData();
|
|
||||||
formData.append("email_address", "exception@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(500);
|
|
||||||
const json = (await res.json()) as any;
|
|
||||||
expect(json.ok).toBe(false);
|
|
||||||
expect(json.error).toBe("webhook_exception");
|
|
||||||
expect(json.message).toBe(config.errorMessage);
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("internal/both 모드의 DB 저장 및 민감정보 암호화", () => {
|
|
||||||
it("internal 모드에서 프로젝트 슬러그와 민감 필드를 포함해 제출하면 FormSubmission 이 암호화/분리된 상태로 저장되어야 한다", async () => {
|
|
||||||
inMemoryProjects.push({
|
|
||||||
id: "proj-1",
|
|
||||||
slug: "project-1",
|
|
||||||
userId: "owner-1",
|
|
||||||
});
|
|
||||||
|
|
||||||
const config: FormBlockProps = {
|
|
||||||
kind: "contact",
|
|
||||||
submitTarget: "internal",
|
|
||||||
successMessage: "성공적으로 전송되었습니다.",
|
|
||||||
errorMessage: "전송 중 오류가 발생했습니다.",
|
|
||||||
fieldIds: [],
|
|
||||||
submitButtonId: null,
|
|
||||||
fields: [
|
|
||||||
{ id: "f1", name: "name", type: "text", label: "이름", required: true },
|
|
||||||
{ id: "f2", name: "email", type: "email", label: "이메일", required: true },
|
|
||||||
{ id: "f3", name: "phone", type: "text", label: "전화번호", required: false },
|
|
||||||
{ id: "f4", name: "birth", type: "text", label: "생년월일", required: false },
|
|
||||||
{ id: "f5", name: "message", type: "textarea", label: "메시지", required: true },
|
|
||||||
],
|
|
||||||
};
|
|
||||||
|
|
||||||
const formData = new FormData();
|
|
||||||
formData.append("name", "홍길동");
|
|
||||||
formData.append("email", "test@example.com");
|
|
||||||
formData.append("phone", "010-1234-5678");
|
|
||||||
formData.append("birth", "1990-01-02");
|
|
||||||
formData.append("message", "문의 내용");
|
|
||||||
formData.append("__projectSlug", "project-1");
|
|
||||||
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(inMemoryFormSubmissions.length).toBe(1);
|
|
||||||
const saved = inMemoryFormSubmissions[0] as any;
|
|
||||||
|
|
||||||
expect(saved.projectSlug).toBe("project-1");
|
|
||||||
expect(saved.projectId).toBe("proj-1");
|
|
||||||
expect(saved.userId).toBe("owner-1");
|
|
||||||
|
|
||||||
expect(saved.payloadJson).toBeTruthy();
|
|
||||||
expect(saved.payloadJson.name).toBe("홍길동");
|
|
||||||
expect(saved.payloadJson.message).toBe("문의 내용");
|
|
||||||
expect(saved.payloadJson.email).toBeUndefined();
|
|
||||||
expect(saved.payloadJson.phone).toBeUndefined();
|
|
||||||
expect(saved.payloadJson.birth).toBeUndefined();
|
|
||||||
|
|
||||||
expect(typeof saved.sensitiveEnc).toBe("string");
|
|
||||||
|
|
||||||
const { decryptJson } = await import("@/features/auth/authCrypto");
|
|
||||||
const sensitive = await decryptJson<Record<string, string>>(saved.sensitiveEnc);
|
|
||||||
expect(sensitive.email).toBe("test@example.com");
|
|
||||||
expect(sensitive.phone).toBe("010-1234-5678");
|
|
||||||
expect(sensitive.birth).toBe("1990-01-02");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("프로젝트를 찾지 못해도 projectSlug 만으로 FormSubmission 이 저장되어야 한다", async () => {
|
|
||||||
const config: FormBlockProps = {
|
|
||||||
kind: "contact",
|
|
||||||
submitTarget: "internal",
|
|
||||||
successMessage: "ok",
|
|
||||||
errorMessage: "err",
|
|
||||||
fieldIds: [],
|
|
||||||
submitButtonId: null,
|
|
||||||
};
|
|
||||||
|
|
||||||
const formData = new FormData();
|
|
||||||
formData.append("email", "no-project@example.com");
|
|
||||||
formData.append("__projectSlug", "unknown-slug");
|
|
||||||
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(inMemoryFormSubmissions.length).toBe(1);
|
|
||||||
const saved = inMemoryFormSubmissions[0] as any;
|
|
||||||
|
|
||||||
expect(saved.projectSlug).toBe("unknown-slug");
|
|
||||||
expect(saved.projectId ?? null).toBeNull();
|
|
||||||
expect(saved.userId ?? null).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("config.fields 가 비어 있어도 email/phone/birthdate 키는 민감정보로 암호화되어야 한다", async () => {
|
|
||||||
inMemoryProjects.push({
|
|
||||||
id: "proj-2",
|
|
||||||
slug: "project-2",
|
|
||||||
userId: "owner-2",
|
|
||||||
});
|
|
||||||
|
|
||||||
const config: FormBlockProps = {
|
|
||||||
kind: "contact",
|
|
||||||
submitTarget: "internal",
|
|
||||||
successMessage: "ok",
|
|
||||||
errorMessage: "err",
|
|
||||||
fieldIds: [],
|
|
||||||
submitButtonId: null,
|
|
||||||
// fields 를 비워 두고, 실제 FormData 키 이름만으로 민감 필드를 판별하도록 한다.
|
|
||||||
} as any;
|
|
||||||
|
|
||||||
const formData = new FormData();
|
|
||||||
formData.append("name", "홍길동");
|
|
||||||
formData.append("email", "hgd@example.com");
|
|
||||||
formData.append("phone", "010-1111-2222");
|
|
||||||
formData.append("birthdate", "1990-01-01");
|
|
||||||
formData.append("message", "테스트 문의입니다");
|
|
||||||
formData.append("__projectSlug", "project-2");
|
|
||||||
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(inMemoryFormSubmissions.length).toBe(1);
|
|
||||||
const saved = inMemoryFormSubmissions[0] as any;
|
|
||||||
|
|
||||||
// project 연관 정보
|
|
||||||
expect(saved.projectSlug).toBe("project-2");
|
|
||||||
expect(saved.projectId).toBe("proj-2");
|
|
||||||
expect(saved.userId).toBe("owner-2");
|
|
||||||
|
|
||||||
// payloadJson 에는 비민감 필드만 남아야 한다.
|
|
||||||
expect(saved.payloadJson).toBeTruthy();
|
|
||||||
expect(saved.payloadJson.name).toBe("홍길동");
|
|
||||||
expect(saved.payloadJson.message).toBe("테스트 문의입니다");
|
|
||||||
expect(saved.payloadJson.email).toBeUndefined();
|
|
||||||
expect(saved.payloadJson.phone).toBeUndefined();
|
|
||||||
expect(saved.payloadJson.birthdate).toBeUndefined();
|
|
||||||
|
|
||||||
// 민감 필드는 암호화된 sensitiveEnc 에만 존재해야 한다.
|
|
||||||
expect(typeof saved.sensitiveEnc).toBe("string");
|
|
||||||
|
|
||||||
const { decryptJson } = await import("@/features/auth/authCrypto");
|
|
||||||
const sensitive = await decryptJson<Record<string, string>>(saved.sensitiveEnc);
|
|
||||||
expect(sensitive.email).toBe("hgd@example.com");
|
|
||||||
expect(sensitive.phone).toBe("010-1111-2222");
|
|
||||||
expect(sensitive.birthdate).toBe("1990-01-01");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,171 +0,0 @@
|
|||||||
import "dotenv/config";
|
|
||||||
import { describe, it, expect, beforeEach, vi } from "vitest";
|
|
||||||
import { signAccessToken } from "@/features/auth/authCrypto";
|
|
||||||
|
|
||||||
const BASE_URL = "http://localhost";
|
|
||||||
|
|
||||||
const inMemoryProjects: any[] = [];
|
|
||||||
const inMemoryFormSubmissions: any[] = [];
|
|
||||||
|
|
||||||
vi.mock("@prisma/client", () => {
|
|
||||||
class PrismaClientMock {
|
|
||||||
project = {
|
|
||||||
findUnique: async ({ where: { slug } }: any) => {
|
|
||||||
return inMemoryProjects.find((p) => p.slug === slug) ?? null;
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
formSubmission = {
|
|
||||||
findMany: async ({ where: { projectId }, orderBy, take }: any) => {
|
|
||||||
let items = inMemoryFormSubmissions.filter((s) => s.projectId === projectId);
|
|
||||||
if (orderBy?.createdAt === "desc") {
|
|
||||||
items = items.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime());
|
|
||||||
}
|
|
||||||
if (typeof take === "number") {
|
|
||||||
items = items.slice(0, take);
|
|
||||||
}
|
|
||||||
return items;
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return { PrismaClient: PrismaClientMock };
|
|
||||||
});
|
|
||||||
|
|
||||||
const TEST_USER = { id: "user-1", email: "submissions@example.com", tokenVersion: 1 };
|
|
||||||
const OTHER_USER = { id: "user-2", email: "submissions2@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-submissions";
|
|
||||||
process.env.AUTH_ENCRYPTION_KEY = "0123456789abcdef0123456789abcdef";
|
|
||||||
inMemoryProjects.length = 0;
|
|
||||||
inMemoryFormSubmissions.length = 0;
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("/api/projects/[slug]/submissions", () => {
|
|
||||||
it("로그인한 소유자가 자신의 프로젝트 제출 내역을 조회할 수 있어야 한다", async () => {
|
|
||||||
const project = {
|
|
||||||
id: "proj-1",
|
|
||||||
slug: "project-with-submissions",
|
|
||||||
userId: TEST_USER.id,
|
|
||||||
};
|
|
||||||
inMemoryProjects.push(project);
|
|
||||||
|
|
||||||
const createdAt = new Date();
|
|
||||||
inMemoryFormSubmissions.push({
|
|
||||||
id: "sub-1",
|
|
||||||
projectId: project.id,
|
|
||||||
projectSlug: project.slug,
|
|
||||||
userId: TEST_USER.id,
|
|
||||||
createdAt,
|
|
||||||
payloadJson: { name: "홍길동", message: "문의" },
|
|
||||||
sensitiveEnc: undefined,
|
|
||||||
metaJson: null,
|
|
||||||
});
|
|
||||||
|
|
||||||
const { GET: getSubmissions } = await import("@/app/api/projects/[slug]/submissions/route");
|
|
||||||
|
|
||||||
const headers = await buildAuthHeaders();
|
|
||||||
|
|
||||||
const res = await getSubmissions(
|
|
||||||
new Request(`${BASE_URL}/api/projects/${project.slug}/submissions`, {
|
|
||||||
headers,
|
|
||||||
}),
|
|
||||||
{ params: { slug: project.slug } } as any,
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(res.status).toBe(200);
|
|
||||||
const json = (await res.json()) as any[];
|
|
||||||
expect(Array.isArray(json)).toBe(true);
|
|
||||||
expect(json.length).toBe(1);
|
|
||||||
expect(json[0].id).toBe("sub-1");
|
|
||||||
expect(json[0].projectSlug).toBe(project.slug);
|
|
||||||
expect(json[0].payload.name).toBe("홍길동");
|
|
||||||
expect(json[0].payload.message).toBe("문의");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("민감정보는 암호화되어 저장되더라도 조회 시 복호화된 값으로 응답 payload 에 포함되어야 한다", async () => {
|
|
||||||
const project = {
|
|
||||||
id: "proj-2",
|
|
||||||
slug: "project-sensitive",
|
|
||||||
userId: TEST_USER.id,
|
|
||||||
};
|
|
||||||
inMemoryProjects.push(project);
|
|
||||||
|
|
||||||
const { encryptJson } = await import("@/features/auth/authCrypto");
|
|
||||||
const sensitive = { email: "test@example.com", phone: "010-0000-0000" };
|
|
||||||
const sensitiveEnc = await encryptJson(sensitive);
|
|
||||||
|
|
||||||
const createdAt = new Date();
|
|
||||||
inMemoryFormSubmissions.push({
|
|
||||||
id: "sub-2",
|
|
||||||
projectId: project.id,
|
|
||||||
projectSlug: project.slug,
|
|
||||||
userId: TEST_USER.id,
|
|
||||||
createdAt,
|
|
||||||
payloadJson: { name: "김철수" },
|
|
||||||
sensitiveEnc,
|
|
||||||
metaJson: null,
|
|
||||||
});
|
|
||||||
|
|
||||||
const { GET: getSubmissions } = await import("@/app/api/projects/[slug]/submissions/route");
|
|
||||||
|
|
||||||
const headers = await buildAuthHeaders();
|
|
||||||
|
|
||||||
const res = await getSubmissions(
|
|
||||||
new Request(`${BASE_URL}/api/projects/${project.slug}/submissions`, {
|
|
||||||
headers,
|
|
||||||
}),
|
|
||||||
{ params: { slug: project.slug } } as any,
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(res.status).toBe(200);
|
|
||||||
const json = (await res.json()) as any[];
|
|
||||||
expect(json.length).toBe(1);
|
|
||||||
expect(json[0].payload.name).toBe("김철수");
|
|
||||||
expect(json[0].payload.email).toBe("test@example.com");
|
|
||||||
expect(json[0].payload.phone).toBe("010-0000-0000");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("로그인하지 않은 경우 401 을 반환해야 한다", async () => {
|
|
||||||
const { GET: getSubmissions } = await import("@/app/api/projects/[slug]/submissions/route");
|
|
||||||
|
|
||||||
const res = await getSubmissions(
|
|
||||||
new Request(`${BASE_URL}/api/projects/any/submissions`),
|
|
||||||
{ params: { slug: "any" } } as any,
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(res.status).toBe(401);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("다른 사용자가 소유한 프로젝트 제출 내역에 접근하면 404 를 반환해야 한다", async () => {
|
|
||||||
const project = {
|
|
||||||
id: "proj-3",
|
|
||||||
slug: "owner-only-project",
|
|
||||||
userId: OTHER_USER.id,
|
|
||||||
};
|
|
||||||
inMemoryProjects.push(project);
|
|
||||||
|
|
||||||
const { GET: getSubmissions } = await import("@/app/api/projects/[slug]/submissions/route");
|
|
||||||
|
|
||||||
const headers = await buildAuthHeaders();
|
|
||||||
|
|
||||||
const res = await getSubmissions(
|
|
||||||
new Request(`${BASE_URL}/api/projects/${project.slug}/submissions`, {
|
|
||||||
headers,
|
|
||||||
}),
|
|
||||||
{ params: { slug: project.slug } } as any,
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(res.status).toBe(404);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
+5
-436
@@ -1,118 +1,10 @@
|
|||||||
import "dotenv/config";
|
import "dotenv/config";
|
||||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
import { describe, it, expect } from "vitest";
|
||||||
import { signAccessToken } from "@/features/auth/authCrypto";
|
import { POST as createProject } from "@/app/api/projects/route";
|
||||||
|
import { GET as getProjectBySlug } from "@/app/api/projects/[slug]/route";
|
||||||
// PrismaClient를 실제 DB 대신 메모리 기반 저장소를 사용하는 목으로 대체한다.
|
|
||||||
// 이렇게 하면 CI/로컬 어디에서도 DATABASE_URL 이나 실제 DB 없이 API 테스트를 실행할 수 있다.
|
|
||||||
const inMemoryProjects: any[] = [];
|
|
||||||
|
|
||||||
vi.mock("@prisma/client", () => {
|
|
||||||
class PrismaClientMock {
|
|
||||||
project = {
|
|
||||||
create: async ({ data }: any) => {
|
|
||||||
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;
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return { PrismaClient: PrismaClientMock };
|
|
||||||
});
|
|
||||||
|
|
||||||
const BASE_URL = "http://localhost";
|
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", () => {
|
describe("/api/projects", () => {
|
||||||
it("POST /api/projects 로 프로젝트를 생성하고 GET /api/projects/[slug] 로 조회할 수 있어야 한다", async () => {
|
it("POST /api/projects 로 프로젝트를 생성하고 GET /api/projects/[slug] 로 조회할 수 있어야 한다", async () => {
|
||||||
const payload = {
|
const payload = {
|
||||||
@@ -127,14 +19,10 @@ describe("/api/projects", () => {
|
|||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
const { POST: createProject } = await import("@/app/api/projects/route");
|
|
||||||
|
|
||||||
const headers = await buildAuthHeaders();
|
|
||||||
|
|
||||||
const createResponse = await createProject(
|
const createResponse = await createProject(
|
||||||
new Request(`${BASE_URL}/api/projects`, {
|
new Request(`${BASE_URL}/api/projects`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json", ...headers },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify(payload),
|
body: JSON.stringify(payload),
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
@@ -149,12 +37,8 @@ describe("/api/projects", () => {
|
|||||||
expect(created.title).toBe(payload.title);
|
expect(created.title).toBe(payload.title);
|
||||||
expect(created.slug).toBe(payload.slug);
|
expect(created.slug).toBe(payload.slug);
|
||||||
|
|
||||||
const { GET: getProjectBySlug } = await import("@/app/api/projects/[slug]/route");
|
|
||||||
|
|
||||||
const getHeaders = await buildAuthHeaders();
|
|
||||||
|
|
||||||
const getResponse = await getProjectBySlug(
|
const getResponse = await getProjectBySlug(
|
||||||
new Request(`${BASE_URL}/api/projects/${payload.slug}`, { headers: getHeaders }),
|
new Request(`${BASE_URL}/api/projects/${payload.slug}`),
|
||||||
{ params: { slug: payload.slug } } as any,
|
{ params: { slug: payload.slug } } as any,
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -163,319 +47,4 @@ describe("/api/projects", () => {
|
|||||||
expect(fetched.slug).toBe(payload.slug);
|
expect(fetched.slug).toBe(payload.slug);
|
||||||
expect(fetched.contentJson).toEqual(payload.contentJson);
|
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);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,82 +0,0 @@
|
|||||||
import "dotenv/config";
|
|
||||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
|
||||||
import { promises as fs } from "fs";
|
|
||||||
import path from "path";
|
|
||||||
|
|
||||||
const BASE_URL = "http://localhost";
|
|
||||||
|
|
||||||
// /api/video/:id 서브 라우트 TDD
|
|
||||||
// - 존재하는 업로드 파일 id 로 GET /api/video/:id 를 호출하면 200 과 비디오 바이트를 반환해야 한다.
|
|
||||||
// - 존재하지 않는 id 로 호출하면 404 를 반환해야 한다.
|
|
||||||
|
|
||||||
// Prisma 엔진이 실제로 로드되지 않도록 PrismaClient 를 단순 목으로 대체한다.
|
|
||||||
vi.mock("@prisma/client", () => {
|
|
||||||
class PrismaClient {
|
|
||||||
asset = {
|
|
||||||
// GET /api/video/:id 에서는 MIME 타입 조회에만 사용되므로, 테스트에서는 null 을 반환한다.
|
|
||||||
findUnique: async () => null,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return { PrismaClient };
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("/api/video/:id 서브 라우트", () => {
|
|
||||||
const uploadsDir = path.join(process.cwd(), "uploads");
|
|
||||||
let testId: string;
|
|
||||||
let filePath: string;
|
|
||||||
|
|
||||||
beforeEach(async () => {
|
|
||||||
await fs.mkdir(uploadsDir, { recursive: true });
|
|
||||||
testId = `test-video-${Date.now()}`;
|
|
||||||
filePath = path.join(uploadsDir, testId);
|
|
||||||
await fs.writeFile(filePath, Buffer.from("dummy-video-bytes"));
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(async () => {
|
|
||||||
try {
|
|
||||||
await fs.unlink(filePath);
|
|
||||||
} catch {
|
|
||||||
// 이미 삭제된 경우는 무시한다.
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it("업로드된 비디오 파일이 존재하면 200 과 비디오 바이트를 반환해야 한다", async () => {
|
|
||||||
const { GET: handleGet } = await import("@/app/api/video/[id]/route");
|
|
||||||
|
|
||||||
const requestUrl = `${BASE_URL}/api/video/${testId}`;
|
|
||||||
const res = await handleGet(
|
|
||||||
new Request(requestUrl, {
|
|
||||||
headers: {
|
|
||||||
// Referer 기반 핫링크 방지 로직이 있으므로, 동일 호스트의 referer 를 포함한다.
|
|
||||||
referer: `${BASE_URL}/editor`,
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
// Next 16 타입 정의에서는 context.params 가 Promise 형태이므로, Promise 로 감싼다.
|
|
||||||
{ params: Promise.resolve({ id: testId }) } as any,
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(res.status).toBe(200);
|
|
||||||
expect(res.headers.get("Content-Type")).toBe("video/mp4");
|
|
||||||
|
|
||||||
const arrayBuffer = await res.arrayBuffer();
|
|
||||||
const buf = Buffer.from(arrayBuffer);
|
|
||||||
expect(buf.length).toBeGreaterThan(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("존재하지 않는 비디오 id 로 요청하면 404 를 반환해야 한다", async () => {
|
|
||||||
const { GET: handleGet } = await import("@/app/api/video/[id]/route");
|
|
||||||
|
|
||||||
const missingId = `missing-${Date.now()}`;
|
|
||||||
const res = await handleGet(
|
|
||||||
new Request(`${BASE_URL}/api/video/${missingId}`, {
|
|
||||||
headers: {
|
|
||||||
referer: `${BASE_URL}/editor`,
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
{ params: Promise.resolve({ id: missingId }) } as any,
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(res.status).toBe(404);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,52 +0,0 @@
|
|||||||
import "dotenv/config";
|
|
||||||
import { describe, it, expect, vi } from "vitest";
|
|
||||||
import { promises as fs } from "fs";
|
|
||||||
import path from "path";
|
|
||||||
|
|
||||||
const BASE_URL = "http://localhost";
|
|
||||||
|
|
||||||
// /api/video 업로드 TDD
|
|
||||||
// - POST /api/video 로 FormData(file) 를 전송하면 업로드 파일이 uploads 디렉터리에 저장되고
|
|
||||||
// id / servedUrl(`/api/video/:id`) 을 반환해야 한다.
|
|
||||||
|
|
||||||
// Prisma 엔진이 실제로 로드되지 않도록 PrismaClient 를 목 처리한다.
|
|
||||||
vi.mock("@prisma/client", () => {
|
|
||||||
class PrismaClient {
|
|
||||||
project = {
|
|
||||||
upsert: async () => ({ id: "project-mock" }),
|
|
||||||
};
|
|
||||||
|
|
||||||
asset = {
|
|
||||||
create: async () => ({ id: "asset-mock" }),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return { PrismaClient };
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("/api/video 업로드", () => {
|
|
||||||
it("POST /api/video 는 업로드된 비디오 파일을 저장하고 id/servedUrl 을 반환해야 한다", async () => {
|
|
||||||
const { POST: handleUpload } = await import("@/app/api/video/route");
|
|
||||||
|
|
||||||
const formData = new FormData();
|
|
||||||
const blob = new Blob(["dummy-video"], { type: "video/mp4" });
|
|
||||||
// FormData 에서는 File/Blob 모두 허용되므로, Blob 을 그대로 사용한다.
|
|
||||||
formData.append("file", blob as any);
|
|
||||||
|
|
||||||
const res = await handleUpload(
|
|
||||||
new Request(`${BASE_URL}/api/video`, {
|
|
||||||
method: "POST",
|
|
||||||
body: formData,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(res.status).toBe(201);
|
|
||||||
const json = (await res.json()) as { id: string; servedUrl?: string | null };
|
|
||||||
expect(json.id).toBeTruthy();
|
|
||||||
expect(json.servedUrl).toBe(`/api/video/${json.id}`);
|
|
||||||
|
|
||||||
const filePath = path.join(process.cwd(), "uploads", json.id);
|
|
||||||
const stat = await fs.stat(filePath);
|
|
||||||
expect(stat.isFile()).toBe(true);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,112 +0,0 @@
|
|||||||
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();
|
|
||||||
});
|
|
||||||
+45
-1050
File diff suppressed because it is too large
Load Diff
@@ -1,128 +0,0 @@
|
|||||||
import { test, expect } from "@playwright/test";
|
|
||||||
|
|
||||||
// E2E: 에디터에서 폼 컨트롤러를 추가해 프로젝트를 저장하고,
|
|
||||||
// 프리뷰에서 실제 폼을 제출하면 DB에 저장된 뒤
|
|
||||||
// 프로젝트의 "폼 제출 내역" 페이지에서 해당 데이터가 표시되는지 검증한다.
|
|
||||||
test("프리뷰 폼 제출 후 제출 내역 페이지에서 데이터가 보여야 한다", async ({ page, request }) => {
|
|
||||||
// 고유한 테스트용 이메일/프로젝트 slug 를 생성한다.
|
|
||||||
const now = Date.now();
|
|
||||||
const email = `form-e2e-${now}@example.com`;
|
|
||||||
const password = "form-e2e-password";
|
|
||||||
const projectSlug = `form-e2e-project-${now}`;
|
|
||||||
|
|
||||||
// 1) 실제 /api/auth/signup API 를 호출해 테스트용 유저를 생성하고,
|
|
||||||
// 응답 헤더의 Set-Cookie 에서 pb_access 토큰 값을 추출한다.
|
|
||||||
const signupRes = await request.post("/api/auth/signup", {
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
data: { email, password },
|
|
||||||
});
|
|
||||||
|
|
||||||
// 디버깅용: CI 등에서 500 이 떨어질 때 응답 바디를 함께 로그로 출력해 원인을 파악할 수 있게 한다.
|
|
||||||
const signupStatus = signupRes.status();
|
|
||||||
if (signupStatus !== 201) {
|
|
||||||
// text() 는 한 번만 읽을 수 있으므로, status 체크 이후에만 호출한다.
|
|
||||||
// CI 로그에서 이 메시지를 보고 실제 에러 원인(AUTH_JWT_SECRET, DATABASE_URL, Prisma 오류 등)을 추적한다.
|
|
||||||
console.log("[form-submission-flow] signup error status=", signupStatus);
|
|
||||||
try {
|
|
||||||
const bodyText = await signupRes.text();
|
|
||||||
console.log("[form-submission-flow] signup error body=", bodyText);
|
|
||||||
} catch (e) {
|
|
||||||
console.log("[form-submission-flow] signup error: body read failed", e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
expect(signupStatus).toBe(201);
|
|
||||||
|
|
||||||
const setCookieHeader = signupRes.headers()["set-cookie"];
|
|
||||||
expect(setCookieHeader).toBeTruthy();
|
|
||||||
|
|
||||||
const cookieHeaderString = Array.isArray(setCookieHeader)
|
|
||||||
? setCookieHeader.join("; ")
|
|
||||||
: (setCookieHeader as string);
|
|
||||||
|
|
||||||
const match = cookieHeaderString.match(/pb_access=([^;]+)/);
|
|
||||||
expect(match).not.toBeNull();
|
|
||||||
|
|
||||||
const accessToken = match![1];
|
|
||||||
|
|
||||||
// 브라우저 컨텍스트에 pb_access 쿠키를 직접 주입해
|
|
||||||
// /api/auth/me, /api/projects, /api/projects/[slug]/submissions 에서
|
|
||||||
// 실제 인증 플로우를 그대로 타도록 한다.
|
|
||||||
await page.context().addCookies([
|
|
||||||
{
|
|
||||||
name: "pb_access",
|
|
||||||
value: accessToken,
|
|
||||||
domain: "localhost",
|
|
||||||
path: "/",
|
|
||||||
httpOnly: true,
|
|
||||||
secure: false,
|
|
||||||
sameSite: "Lax",
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
|
|
||||||
// 2) 에디터에서 프로젝트 제목/slug 를 설정하고 폼 컨트롤러 블록을 추가한다.
|
|
||||||
await page.goto("/editor");
|
|
||||||
|
|
||||||
const propertiesSidebar = page.getByTestId("properties-sidebar");
|
|
||||||
await propertiesSidebar.getByLabel("프로젝트 제목").fill("E2E 폼 프로젝트");
|
|
||||||
await propertiesSidebar.getByLabel("프로젝트 주소 (slug)").fill(projectSlug);
|
|
||||||
|
|
||||||
// 좌측 블록 사이드바에서 "폼 컨트롤러" 버튼을 눌러 기본 contact 폼을 추가한다.
|
|
||||||
await page.getByRole("button", { name: "폼 컨트롤러" }).click();
|
|
||||||
|
|
||||||
// 3) "저장 (로컬 + 서버)" 액션으로 프로젝트를 서버에 실제 저장한다.
|
|
||||||
await page.getByRole("button", { name: "메뉴 ▼" }).click();
|
|
||||||
await page.getByRole("button", { name: "프로젝트 저장/불러오기" }).click();
|
|
||||||
await page.getByRole("button", { name: "저장 (로컬 + 서버)" }).click();
|
|
||||||
|
|
||||||
// 저장 후 /projects 페이지로 리다이렉트 되었는지 확인하고,
|
|
||||||
// 방금 저장한 slug 가 목록에 노출되는지 검증한다.
|
|
||||||
await expect(page).toHaveURL(/\/_?projects/);
|
|
||||||
await expect(page.getByText(projectSlug)).toBeVisible();
|
|
||||||
|
|
||||||
// 해당 프로젝트 행에서 "편집" 링크를 클릭해 /editor?slug=... 로 이동한다.
|
|
||||||
const projectRow = page.locator("tr", { hasText: projectSlug }).first();
|
|
||||||
await projectRow.getByRole("link", { name: "편집" }).click();
|
|
||||||
|
|
||||||
// 에디터 페이지로 이동했는지 확인한다.
|
|
||||||
await expect(page).toHaveURL(/\/editor/);
|
|
||||||
|
|
||||||
// 에디터 헤더의 "프리뷰 열기" 링크를 클릭해 프리뷰 페이지로 이동한다.
|
|
||||||
await page.getByRole("link", { name: "프리뷰 열기" }).click();
|
|
||||||
|
|
||||||
await expect(page).toHaveURL(/\/preview/);
|
|
||||||
|
|
||||||
// 4) 프리뷰 화면의 폼에 값을 입력하고 제출 버튼("폼 전송")을 클릭한다.
|
|
||||||
const nameValue = "홍길동";
|
|
||||||
const emailValue = `submitted-${now}@example.com`;
|
|
||||||
const messageValue = "E2E 테스트 메시지";
|
|
||||||
|
|
||||||
await page.getByLabel("이름").fill(nameValue);
|
|
||||||
await page.getByLabel("이메일").fill(emailValue);
|
|
||||||
await page.getByLabel("메시지").fill(messageValue);
|
|
||||||
|
|
||||||
await page.getByRole("button", { name: "폼 전송" }).click();
|
|
||||||
|
|
||||||
// 폼 제출 성공 메시지가 표시되어야 한다.
|
|
||||||
await expect(page.getByText("성공적으로 전송되었습니다.")).toBeVisible();
|
|
||||||
|
|
||||||
// 5) 프리뷰 헤더의 "프로젝트 목록" 링크를 통해 다시 /projects 로 이동한다.
|
|
||||||
await page.getByRole("link", { name: "프로젝트 목록" }).click();
|
|
||||||
await expect(page).toHaveURL(/\/_?projects/);
|
|
||||||
|
|
||||||
// 동일한 프로젝트 행에서 "폼 제출 내역" 링크를 클릭해 제출 내역 페이지로 이동한다.
|
|
||||||
const submissionsRow = page.locator("tr", { hasText: projectSlug }).first();
|
|
||||||
await submissionsRow.getByRole("link", { name: "폼 제출 내역" }).click();
|
|
||||||
|
|
||||||
// URL 이 /projects/[slug]/submissions 형태인지 확인한다.
|
|
||||||
await expect(page).toHaveURL(new RegExp(`/projects/${projectSlug}/submissions`));
|
|
||||||
|
|
||||||
// 6) 제출 내역 페이지에서 헤더와 slug 가 올바르게 표시되는지 검증한다.
|
|
||||||
await expect(page.getByRole("heading", { name: "폼 제출 내역" })).toBeVisible();
|
|
||||||
await expect(page.getByText(projectSlug)).toBeVisible();
|
|
||||||
|
|
||||||
// 테이블에 방금 제출한 값들이 포함되어 있어야 한다.
|
|
||||||
await expect(page.getByText(nameValue)).toBeVisible();
|
|
||||||
await expect(page.getByText(emailValue)).toBeVisible();
|
|
||||||
await expect(page.getByText(`message: ${messageValue}`)).toBeVisible();
|
|
||||||
});
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,228 +0,0 @@
|
|||||||
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);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("프로젝트 목록에서 '폼 제출 내역' 링크를 클릭하면 해당 프로젝트의 제출 내역 페이지로 이동해야 한다", 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 }),
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// /api/projects 응답을 목킹해 목록 페이지에 하나의 프로젝트가 보이도록 한다.
|
|
||||||
await page.route("**/api/projects", async (route) => {
|
|
||||||
const request = route.request();
|
|
||||||
const url = new URL(request.url());
|
|
||||||
const method = request.method();
|
|
||||||
|
|
||||||
if (url.pathname === "/api/projects" && method === "GET") {
|
|
||||||
await route.fulfill({
|
|
||||||
status: 200,
|
|
||||||
contentType: "application/json",
|
|
||||||
body: JSON.stringify([
|
|
||||||
{
|
|
||||||
id: "1",
|
|
||||||
title: "테스트 프로젝트 A",
|
|
||||||
slug: "test-project-a",
|
|
||||||
status: "DRAFT",
|
|
||||||
createdAt: new Date("2025-01-01T00:00:00.000Z").toISOString(),
|
|
||||||
updatedAt: new Date("2025-01-01T00:00:00.000Z").toISOString(),
|
|
||||||
},
|
|
||||||
]),
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
await route.fulfill({
|
|
||||||
status: 404,
|
|
||||||
contentType: "application/json",
|
|
||||||
body: JSON.stringify({ message: "Not implemented in E2E mock" }),
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// 선택한 프로젝트의 폼 제출 내역 API 응답도 목킹해, 제출 데이터가 테이블에 렌더링되는지 검증한다.
|
|
||||||
await page.route("**/api/projects/test-project-a/submissions", async (route) => {
|
|
||||||
await route.fulfill({
|
|
||||||
status: 200,
|
|
||||||
contentType: "application/json",
|
|
||||||
body: JSON.stringify([
|
|
||||||
{
|
|
||||||
id: "sub-1",
|
|
||||||
projectId: "1",
|
|
||||||
userId: "user-e2e",
|
|
||||||
createdAt: new Date("2025-01-02T12:34:56.000Z").toISOString(),
|
|
||||||
payload: {
|
|
||||||
name: "홍길동",
|
|
||||||
email: "user@example.com",
|
|
||||||
phone: "010-1234-5678",
|
|
||||||
birthdate: "1990-01-01",
|
|
||||||
message: "안녕하세요",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
]),
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// 프로젝트 목록 페이지로 이동한다.
|
|
||||||
await page.goto("/projects");
|
|
||||||
|
|
||||||
// 목록에 테스트 프로젝트가 보여야 한다.
|
|
||||||
await expect(page.getByText("테스트 프로젝트 A")).toBeVisible();
|
|
||||||
await expect(page.getByText("test-project-a")).toBeVisible();
|
|
||||||
|
|
||||||
// 액션 영역의 "폼 제출 내역" 링크를 클릭하면 제출 내역 페이지로 이동해야 한다.
|
|
||||||
await page.getByRole("link", { name: "폼 제출 내역" }).click();
|
|
||||||
|
|
||||||
await expect(page).toHaveURL(/\/projects\/test-project-a\/submissions/);
|
|
||||||
|
|
||||||
// 제출 내역 페이지의 헤더와 slug, 제출 데이터가 렌더링되어야 한다.
|
|
||||||
await expect(page.getByRole("heading", { name: "폼 제출 내역" })).toBeVisible();
|
|
||||||
await expect(page.getByText("test-project-a")).toBeVisible();
|
|
||||||
|
|
||||||
await expect(page.getByText("홍길동")).toBeVisible();
|
|
||||||
await expect(page.getByText("user@example.com")).toBeVisible();
|
|
||||||
await expect(page.getByText("010-1234-5678")).toBeVisible();
|
|
||||||
await expect(page.getByText("1990-01-01")).toBeVisible();
|
|
||||||
await expect(page.getByText("message: 안녕하세요")).toBeVisible();
|
|
||||||
});
|
|
||||||
Vendored
-1
@@ -1 +0,0 @@
|
|||||||
PNG TEST FIXTURE
|
|
||||||
@@ -1,214 +0,0 @@
|
|||||||
import { describe, it, expect, afterEach, vi } from "vitest";
|
|
||||||
import { render, screen, fireEvent, cleanup } from "@testing-library/react";
|
|
||||||
import { BlocksSidebar } from "@/app/editor/panels/BlocksSidebar";
|
|
||||||
|
|
||||||
// BlocksSidebar 템플릿 버튼 TDD
|
|
||||||
// - 각 템플릿 추가 버튼이 대응하는 editorStore 액션(add*TemplateSection)을 호출하는지 검증한다.
|
|
||||||
|
|
||||||
const templateActions = {
|
|
||||||
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(),
|
|
||||||
};
|
|
||||||
|
|
||||||
// BlocksSidebar 가 사용하는 나머지 액션들도 더미 함수로 채워둔다.
|
|
||||||
const otherActions = {
|
|
||||||
addTextBlock: vi.fn(),
|
|
||||||
addButtonBlock: vi.fn(),
|
|
||||||
addImageBlock: vi.fn(),
|
|
||||||
addVideoBlock: 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(),
|
|
||||||
};
|
|
||||||
|
|
||||||
const mockState = {
|
|
||||||
...templateActions,
|
|
||||||
...otherActions,
|
|
||||||
};
|
|
||||||
|
|
||||||
vi.mock("@/features/editor/state/editorStore", () => {
|
|
||||||
return {
|
|
||||||
__esModule: true,
|
|
||||||
// useEditorStore(selector) 형태로 사용되므로, selector 에 mockState 를 넘겨준다.
|
|
||||||
useEditorStore: (selector: (state: typeof mockState) => unknown) => selector(mockState),
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("BlocksSidebar - 템플릿 버튼", () => {
|
|
||||||
afterEach(() => {
|
|
||||||
cleanup();
|
|
||||||
Object.values(templateActions).forEach((fn) => fn.mockReset());
|
|
||||||
});
|
|
||||||
|
|
||||||
it("Hero 템플릿 버튼 클릭 시 addHeroTemplateSection 이 호출되어야 한다", () => {
|
|
||||||
render(<BlocksSidebar />);
|
|
||||||
const button = screen.getByRole("button", { name: "Hero 템플릿" });
|
|
||||||
fireEvent.click(button);
|
|
||||||
|
|
||||||
expect(templateActions.addHeroTemplateSection).toHaveBeenCalledTimes(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("Features 템플릿 버튼 클릭 시 addFeaturesTemplateSection 이 호출되어야 한다", () => {
|
|
||||||
render(<BlocksSidebar />);
|
|
||||||
const button = screen.getByRole("button", { name: "기능 템플릿" });
|
|
||||||
fireEvent.click(button);
|
|
||||||
|
|
||||||
expect(templateActions.addFeaturesTemplateSection).toHaveBeenCalledTimes(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("CTA 템플릿 버튼 클릭 시 addCtaTemplateSection 이 호출되어야 한다", () => {
|
|
||||||
render(<BlocksSidebar />);
|
|
||||||
const button = screen.getByRole("button", { name: "CTA 템플릿" });
|
|
||||||
fireEvent.click(button);
|
|
||||||
|
|
||||||
expect(templateActions.addCtaTemplateSection).toHaveBeenCalledTimes(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("FAQ/Pricing/Testimonials/Blog/Team/Footer 템플릿 버튼도 각각의 액션을 호출해야 한다", () => {
|
|
||||||
render(<BlocksSidebar />);
|
|
||||||
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);
|
|
||||||
expect(templateActions.addTestimonialsTemplateSection).toHaveBeenCalledTimes(1);
|
|
||||||
expect(templateActions.addBlogTemplateSection).toHaveBeenCalledTimes(1);
|
|
||||||
expect(templateActions.addTeamTemplateSection).toHaveBeenCalledTimes(1);
|
|
||||||
expect(templateActions.addFooterTemplateSection).toHaveBeenCalledTimes(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("템플릿 섹션에는 각 템플릿의 용도를 설명하는 텍스트가 함께 표시되어야 한다", () => {
|
|
||||||
render(<BlocksSidebar />);
|
|
||||||
|
|
||||||
expect(screen.getByText("페이지 상단 Hero 섹션 (큰 제목 + 서브텍스트 + 버튼)"));
|
|
||||||
expect(screen.getByText("3컬럼 기능 소개 섹션"));
|
|
||||||
expect(screen.getByText("콜투액션(CTA) 섹션"));
|
|
||||||
expect(screen.getByText("자주 묻는 질문(FAQ) 섹션"));
|
|
||||||
expect(screen.getByText("요금제/플랜 소개 섹션"));
|
|
||||||
expect(screen.getByText("고객 후기(Testimonials) 섹션"));
|
|
||||||
expect(screen.getByText("블로그 포스트 목록 섹션"));
|
|
||||||
expect(screen.getByText("팀 소개 섹션"));
|
|
||||||
expect(screen.getByText("페이지 푸터 섹션"));
|
|
||||||
});
|
|
||||||
|
|
||||||
it("비디오 버튼 클릭 시 addVideoBlock 이 호출되어야 한다", () => {
|
|
||||||
render(<BlocksSidebar />);
|
|
||||||
const button = screen.getByRole("button", { name: "비디오" });
|
|
||||||
fireEvent.click(button);
|
|
||||||
|
|
||||||
expect(otherActions.addVideoBlock).toHaveBeenCalledTimes(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("템플릿들은 카테고리별로 그룹 헤더를 가져야 한다", () => {
|
|
||||||
render(<BlocksSidebar />);
|
|
||||||
|
|
||||||
expect(screen.getByText("히어로 · CTA")).toBeDefined();
|
|
||||||
expect(screen.getByText("콘텐츠 섹션")).toBeDefined();
|
|
||||||
expect(screen.getByText("신뢰/소개")).toBeDefined();
|
|
||||||
expect(screen.getByText("푸터/기타")).toBeDefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("각 템플릿 카드에는 미니 썸네일(레이아웃 힌트)가 렌더링되어야 한다", () => {
|
|
||||||
render(<BlocksSidebar />);
|
|
||||||
|
|
||||||
expect(screen.getByTestId("template-preview-hero")).toBeDefined();
|
|
||||||
expect(screen.getByTestId("template-preview-features")).toBeDefined();
|
|
||||||
expect(screen.getByTestId("template-preview-cta")).toBeDefined();
|
|
||||||
expect(screen.getByTestId("template-preview-faq")).toBeDefined();
|
|
||||||
expect(screen.getByTestId("template-preview-pricing")).toBeDefined();
|
|
||||||
expect(screen.getByTestId("template-preview-testimonials")).toBeDefined();
|
|
||||||
expect(screen.getByTestId("template-preview-blog")).toBeDefined();
|
|
||||||
expect(screen.getByTestId("template-preview-team")).toBeDefined();
|
|
||||||
expect(screen.getByTestId("template-preview-footer")).toBeDefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("미니 썸네일은 각 템플릿 레이아웃을 암시하는 구조를 가져야 한다", () => {
|
|
||||||
render(<BlocksSidebar />);
|
|
||||||
|
|
||||||
const heroThumb = screen.getByTestId("template-preview-hero");
|
|
||||||
const ctaThumb = screen.getByTestId("template-preview-cta");
|
|
||||||
const featuresThumb = screen.getByTestId("template-preview-features");
|
|
||||||
const pricingThumb = screen.getByTestId("template-preview-pricing");
|
|
||||||
const blogThumb = screen.getByTestId("template-preview-blog");
|
|
||||||
const teamThumb = screen.getByTestId("template-preview-team");
|
|
||||||
|
|
||||||
// Hero/CTA: 하나의 메인 영역을 가진 1열 구조
|
|
||||||
expect(heroThumb.children.length).toBe(1);
|
|
||||||
expect(ctaThumb.children.length).toBe(1);
|
|
||||||
|
|
||||||
// Features/Blog: 3컬럼 레이아웃을 암시하는 3개의 세그먼트
|
|
||||||
expect(featuresThumb.children.length).toBe(3);
|
|
||||||
expect(blogThumb.children.length).toBe(3);
|
|
||||||
|
|
||||||
// Pricing/Team: 높이가 다른 3개의 바를 가진 3컬럼 카드 레이아웃
|
|
||||||
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();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,182 +0,0 @@
|
|||||||
import { describe, it, expect, vi, afterEach } from "vitest";
|
|
||||||
import { render, screen, fireEvent, cleanup } from "@testing-library/react";
|
|
||||||
import { ButtonPropertiesPanel } from "@/app/editor/panels/ButtonPropertiesPanel";
|
|
||||||
import type { ButtonBlockProps } from "@/features/editor/state/editorStore";
|
|
||||||
|
|
||||||
// ButtonPropertiesPanel 컨트롤 TDD
|
|
||||||
// - 색상 HEX 인풋 및 너비 모드 셀렉트가 updateBlock 을 올바르게 호출하는지 검증한다.
|
|
||||||
|
|
||||||
describe("ButtonPropertiesPanel", () => {
|
|
||||||
const baseProps: ButtonBlockProps = {
|
|
||||||
label: "버튼",
|
|
||||||
href: "#",
|
|
||||||
align: "left",
|
|
||||||
size: "md",
|
|
||||||
variant: "solid",
|
|
||||||
colorPalette: "primary",
|
|
||||||
};
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
cleanup();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("텍스트 색상 HEX 인풋 변경 시 updateBlock 이 textColorCustom 으로 호출되어야 한다", () => {
|
|
||||||
const updateBlock = vi.fn();
|
|
||||||
|
|
||||||
render(
|
|
||||||
<ButtonPropertiesPanel
|
|
||||||
buttonProps={{ ...baseProps, textColorCustom: "#ffffff" }}
|
|
||||||
selectedBlockId="btn-1"
|
|
||||||
updateBlock={updateBlock}
|
|
||||||
/>,
|
|
||||||
);
|
|
||||||
|
|
||||||
const hexInput = screen.getByLabelText("버튼 텍스트 색상 HEX");
|
|
||||||
fireEvent.change(hexInput, { target: { value: "#112233" } });
|
|
||||||
|
|
||||||
expect(updateBlock).toHaveBeenCalledWith(
|
|
||||||
"btn-1",
|
|
||||||
expect.objectContaining({ textColorCustom: "#112233" }),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("채움 색상 HEX 인풋 변경 시 updateBlock 이 fillColorCustom 으로 호출되어야 한다", () => {
|
|
||||||
const updateBlock = vi.fn();
|
|
||||||
|
|
||||||
render(
|
|
||||||
<ButtonPropertiesPanel
|
|
||||||
buttonProps={{ ...baseProps, fillColorCustom: "#000000" }}
|
|
||||||
selectedBlockId="btn-2"
|
|
||||||
updateBlock={updateBlock}
|
|
||||||
/>,
|
|
||||||
);
|
|
||||||
|
|
||||||
const hexInput = screen.getByLabelText("버튼 채움 색상 HEX");
|
|
||||||
fireEvent.change(hexInput, { target: { value: "#445566" } });
|
|
||||||
|
|
||||||
expect(updateBlock).toHaveBeenCalledWith(
|
|
||||||
"btn-2",
|
|
||||||
expect.objectContaining({ fillColorCustom: "#445566" }),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("외곽선 색상 HEX 인풋 변경 시 updateBlock 이 strokeColorCustom 으로 호출되어야 한다", () => {
|
|
||||||
const updateBlock = vi.fn();
|
|
||||||
|
|
||||||
render(
|
|
||||||
<ButtonPropertiesPanel
|
|
||||||
buttonProps={{ ...baseProps, strokeColorCustom: "#000000" }}
|
|
||||||
selectedBlockId="btn-3"
|
|
||||||
updateBlock={updateBlock}
|
|
||||||
/>,
|
|
||||||
);
|
|
||||||
|
|
||||||
const hexInput = screen.getByLabelText("버튼 외곽선 색상 HEX");
|
|
||||||
fireEvent.change(hexInput, { target: { value: "#778899" } });
|
|
||||||
|
|
||||||
expect(updateBlock).toHaveBeenCalledWith(
|
|
||||||
"btn-3",
|
|
||||||
expect.objectContaining({ strokeColorCustom: "#778899" }),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("버튼 너비 모드 셀렉트 변경 시 updateBlock 이 widthMode 로 호출되어야 한다", () => {
|
|
||||||
const updateBlock = vi.fn();
|
|
||||||
|
|
||||||
render(
|
|
||||||
<ButtonPropertiesPanel
|
|
||||||
buttonProps={{ ...baseProps, widthMode: "auto", fullWidth: false }}
|
|
||||||
selectedBlockId="btn-4"
|
|
||||||
updateBlock={updateBlock}
|
|
||||||
/>,
|
|
||||||
);
|
|
||||||
|
|
||||||
const select = screen.getByLabelText("버튼 너비 모드");
|
|
||||||
fireEvent.change(select, { target: { value: "fixed" } });
|
|
||||||
|
|
||||||
expect(updateBlock).toHaveBeenCalledWith(
|
|
||||||
"btn-4",
|
|
||||||
expect.objectContaining({ widthMode: "fixed" }),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("버튼 이미지 URL 인풋 변경 시 updateBlock 이 imageSrc 와 imageSourceType(externalUrl) 으로 호출되어야 한다", () => {
|
|
||||||
const updateBlock = vi.fn();
|
|
||||||
|
|
||||||
render(
|
|
||||||
<ButtonPropertiesPanel
|
|
||||||
buttonProps={{
|
|
||||||
...baseProps,
|
|
||||||
imageSrc: "https://example.com/old.png",
|
|
||||||
imageSourceType: "externalUrl",
|
|
||||||
} as any}
|
|
||||||
selectedBlockId="btn-img-url"
|
|
||||||
updateBlock={updateBlock}
|
|
||||||
/>,
|
|
||||||
);
|
|
||||||
|
|
||||||
const urlInput = screen.getByLabelText("버튼 이미지 URL");
|
|
||||||
fireEvent.change(urlInput, { target: { value: "https://example.com/new.png" } });
|
|
||||||
|
|
||||||
expect(updateBlock).toHaveBeenCalledWith(
|
|
||||||
"btn-img-url",
|
|
||||||
expect.objectContaining({
|
|
||||||
imageSrc: "https://example.com/new.png",
|
|
||||||
imageSourceType: "externalUrl",
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("버튼 이미지 위치 셀렉트 변경 시 updateBlock 이 imagePlacement 로 호출되어야 한다", () => {
|
|
||||||
const updateBlock = vi.fn();
|
|
||||||
|
|
||||||
render(
|
|
||||||
<ButtonPropertiesPanel
|
|
||||||
buttonProps={{
|
|
||||||
...baseProps,
|
|
||||||
imagePlacement: "left",
|
|
||||||
imageSrc: "https://example.com/icon.png",
|
|
||||||
imageSourceType: "externalUrl",
|
|
||||||
} as any}
|
|
||||||
selectedBlockId="btn-img-pos"
|
|
||||||
updateBlock={updateBlock}
|
|
||||||
/>,
|
|
||||||
);
|
|
||||||
|
|
||||||
const select = screen.getByLabelText("버튼 이미지 위치");
|
|
||||||
fireEvent.change(select, { target: { value: "right" } });
|
|
||||||
|
|
||||||
expect(updateBlock).toHaveBeenCalledWith(
|
|
||||||
"btn-img-pos",
|
|
||||||
expect.objectContaining({ imagePlacement: "right" }),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("버튼 이미지 소스 셀렉트에서 URL/업로드를 전환할 수 있어야 한다", () => {
|
|
||||||
const updateBlock = vi.fn();
|
|
||||||
|
|
||||||
render(
|
|
||||||
<ButtonPropertiesPanel
|
|
||||||
buttonProps={{
|
|
||||||
...baseProps,
|
|
||||||
imageSrc: "/api/image/test-id",
|
|
||||||
imageSourceType: "asset",
|
|
||||||
} as any}
|
|
||||||
selectedBlockId="btn-img-source"
|
|
||||||
updateBlock={updateBlock}
|
|
||||||
/>,
|
|
||||||
);
|
|
||||||
|
|
||||||
const select = screen.getByLabelText("버튼 이미지 소스");
|
|
||||||
// 업로드 → URL 로 전환
|
|
||||||
fireEvent.change(select, { target: { value: "url" } });
|
|
||||||
|
|
||||||
expect(updateBlock).toHaveBeenCalledWith(
|
|
||||||
"btn-img-source",
|
|
||||||
expect.objectContaining({
|
|
||||||
imageSourceType: "externalUrl",
|
|
||||||
imageAssetId: null,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,106 +0,0 @@
|
|||||||
import { describe, it, expect, vi, afterEach } from "vitest";
|
|
||||||
import { render, screen, fireEvent, cleanup } from "@testing-library/react";
|
|
||||||
import { DividerPropertiesPanel } from "@/app/editor/panels/DividerPropertiesPanel";
|
|
||||||
import type { DividerBlockProps } from "@/features/editor/state/editorStore";
|
|
||||||
|
|
||||||
// DividerPropertiesPanel 컨트롤 TDD
|
|
||||||
// - 정렬/두께 select 변경 시 updateBlock 이 올바른 필드로 호출되는지
|
|
||||||
// - 색상 HEX 인풋 변경 시 colorHex 로 호출되는지
|
|
||||||
// - 위/아래 여백 커스텀(px) 입력 시 marginYPx 로 호출되는지
|
|
||||||
|
|
||||||
describe("DividerPropertiesPanel", () => {
|
|
||||||
const baseProps: DividerBlockProps = {
|
|
||||||
align: "left",
|
|
||||||
thickness: "thin",
|
|
||||||
widthMode: "full",
|
|
||||||
colorHex: "#475569",
|
|
||||||
marginY: "md",
|
|
||||||
};
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
cleanup();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("정렬 select 변경 시 updateBlock 이 align 으로 호출되어야 한다", () => {
|
|
||||||
const updateBlock = vi.fn();
|
|
||||||
|
|
||||||
render(
|
|
||||||
<DividerPropertiesPanel
|
|
||||||
dividerProps={baseProps}
|
|
||||||
selectedBlockId="divider-1"
|
|
||||||
updateBlock={updateBlock}
|
|
||||||
/>,
|
|
||||||
);
|
|
||||||
|
|
||||||
const select = screen.getByLabelText("구분선 정렬");
|
|
||||||
fireEvent.change(select, { target: { value: "center" } });
|
|
||||||
|
|
||||||
expect(updateBlock).toHaveBeenCalledWith(
|
|
||||||
"divider-1",
|
|
||||||
expect.objectContaining({ align: "center" }),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("두께 select 변경 시 updateBlock 이 thickness 로 호출되어야 한다", () => {
|
|
||||||
const updateBlock = vi.fn();
|
|
||||||
|
|
||||||
render(
|
|
||||||
<DividerPropertiesPanel
|
|
||||||
dividerProps={baseProps}
|
|
||||||
selectedBlockId="divider-2"
|
|
||||||
updateBlock={updateBlock}
|
|
||||||
/>,
|
|
||||||
);
|
|
||||||
|
|
||||||
const select = screen.getByLabelText("구분선 두께");
|
|
||||||
fireEvent.change(select, { target: { value: "medium" } });
|
|
||||||
|
|
||||||
expect(updateBlock).toHaveBeenCalledWith(
|
|
||||||
"divider-2",
|
|
||||||
expect.objectContaining({ thickness: "medium" }),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("색상 HEX 인풋 변경 시 updateBlock 이 colorHex 로 호출되어야 한다", () => {
|
|
||||||
const updateBlock = vi.fn();
|
|
||||||
|
|
||||||
render(
|
|
||||||
<DividerPropertiesPanel
|
|
||||||
dividerProps={{ ...baseProps, colorHex: "#111111" }}
|
|
||||||
selectedBlockId="divider-3"
|
|
||||||
updateBlock={updateBlock}
|
|
||||||
/>,
|
|
||||||
);
|
|
||||||
|
|
||||||
const hexInputs = screen.getAllByLabelText("구분선 색상 HEX");
|
|
||||||
const hexInput = hexInputs[0] as HTMLInputElement;
|
|
||||||
fireEvent.change(hexInput, { target: { value: "#123456" } });
|
|
||||||
|
|
||||||
expect(updateBlock).toHaveBeenCalledWith(
|
|
||||||
"divider-3",
|
|
||||||
expect.objectContaining({ colorHex: "#123456" }),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("위/아래 여백 커스텀 (px) 인풋 변경 시 updateBlock 이 marginYPx 로 호출되어야 한다", () => {
|
|
||||||
const updateBlock = vi.fn();
|
|
||||||
|
|
||||||
render(
|
|
||||||
<DividerPropertiesPanel
|
|
||||||
dividerProps={baseProps}
|
|
||||||
selectedBlockId="divider-4"
|
|
||||||
updateBlock={updateBlock}
|
|
||||||
/>,
|
|
||||||
);
|
|
||||||
|
|
||||||
const marginInputs = screen.getAllByLabelText("위/아래 여백 커스텀 (px)");
|
|
||||||
const marginInput = marginInputs[0] as HTMLInputElement;
|
|
||||||
|
|
||||||
fireEvent.change(marginInput, { target: { value: "24" } });
|
|
||||||
|
|
||||||
expect(updateBlock).toHaveBeenCalledWith(
|
|
||||||
"divider-4",
|
|
||||||
expect.objectContaining({ marginYPx: 24 }),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,220 +0,0 @@
|
|||||||
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
|
||||||
import { render, cleanup, waitFor } 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(),
|
|
||||||
resetHistory: 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(),
|
|
||||||
}),
|
|
||||||
useSearchParams: () => ({
|
|
||||||
get: () => null,
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
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 로 복원해야 한다", async () => {
|
|
||||||
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 />);
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(mockState.replaceBlocks).toHaveBeenCalledTimes(1);
|
|
||||||
});
|
|
||||||
expect(mockState.replaceBlocks).toHaveBeenCalledWith(savedBlocks);
|
|
||||||
|
|
||||||
expect(mockState.updateProjectConfig).toHaveBeenCalledTimes(1);
|
|
||||||
expect(mockState.updateProjectConfig).toHaveBeenCalledWith(savedConfig);
|
|
||||||
|
|
||||||
// autosave 로 전체 프로젝트를 복원한 경우 history/future 도 초기화해야 한다.
|
|
||||||
expect(mockState.resetHistory).toHaveBeenCalledTimes(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("savedByUserId 가 다른 autosave 스냅샷은 현재 로그인 유저가 불러오지 않아야 한다", async () => {
|
|
||||||
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,
|
|
||||||
savedByUserId: "user-a",
|
|
||||||
};
|
|
||||||
|
|
||||||
window.localStorage.setItem("pb:autosave:autosave-test", JSON.stringify(payload));
|
|
||||||
|
|
||||||
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({ id: "user-b", email: "user-b@example.com", tokenVersion: 1 }),
|
|
||||||
{
|
|
||||||
status: 200,
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
},
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return Promise.resolve(new Response(null, { status: 200 }));
|
|
||||||
});
|
|
||||||
|
|
||||||
vi.stubGlobal("fetch", fetchMock as any);
|
|
||||||
|
|
||||||
render(<EditorPage />);
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(fetchMock).toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(mockState.replaceBlocks).not.toHaveBeenCalled();
|
|
||||||
expect(mockState.updateProjectConfig).not.toHaveBeenCalled();
|
|
||||||
// 다른 유저의 autosave 는 무시되므로 history 초기화도 호출되지 않아야 한다.
|
|
||||||
expect(mockState.resetHistory).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,178 +0,0 @@
|
|||||||
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
|
||||||
import { render, screen, cleanup } from "@testing-library/react";
|
|
||||||
import type { Block, ProjectConfig } from "@/features/editor/state/editorStore";
|
|
||||||
import EditorPage from "@/app/editor/page";
|
|
||||||
|
|
||||||
let mockState: any;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
const baseProjectConfig: ProjectConfig = {
|
|
||||||
title: "버튼 블록 스타일 테스트",
|
|
||||||
slug: "editor-button-style-test",
|
|
||||||
canvasPreset: "full",
|
|
||||||
canvasWidthPx: 1024,
|
|
||||||
canvasBgColorHex: "#0f172a",
|
|
||||||
bodyBgColorHex: "#020617",
|
|
||||||
} as ProjectConfig;
|
|
||||||
|
|
||||||
mockState = {
|
|
||||||
blocks: [] 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(),
|
|
||||||
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(),
|
|
||||||
replaceBlocks: vi.fn(),
|
|
||||||
reorderBlocks: vi.fn(),
|
|
||||||
moveBlock: vi.fn(),
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
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(),
|
|
||||||
}),
|
|
||||||
useSearchParams: () => ({
|
|
||||||
get: () => null,
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("EditorPage - 버튼 블록 스타일", () => {
|
|
||||||
it("크기/변형/색상/패딩/텍스트 정렬이 에디터 버튼 렌더에 반영되어야 한다", () => {
|
|
||||||
const blocks: Block[] = [
|
|
||||||
{
|
|
||||||
id: "btn_style_1",
|
|
||||||
type: "button",
|
|
||||||
props: {
|
|
||||||
label: "테스트 버튼",
|
|
||||||
href: "#",
|
|
||||||
align: "center",
|
|
||||||
size: "lg",
|
|
||||||
variant: "outline",
|
|
||||||
colorPalette: "success",
|
|
||||||
borderRadius: "full",
|
|
||||||
fullWidth: true,
|
|
||||||
fontSizeCustom: "18px",
|
|
||||||
lineHeightCustom: "1.8",
|
|
||||||
letterSpacingCustom: "0.1em",
|
|
||||||
fillColorCustom: "#123456",
|
|
||||||
strokeColorCustom: "#654321",
|
|
||||||
textColorCustom: "#ff0000",
|
|
||||||
paddingX: 16,
|
|
||||||
paddingY: 10,
|
|
||||||
},
|
|
||||||
} as any,
|
|
||||||
];
|
|
||||||
|
|
||||||
mockState.blocks = blocks;
|
|
||||||
mockState.selectedBlockId = "btn_style_1";
|
|
||||||
|
|
||||||
render(<EditorPage />);
|
|
||||||
|
|
||||||
const editorBlock = screen.getByTestId("editor-block") as HTMLElement;
|
|
||||||
const button = screen.getByRole("button", { name: "테스트 버튼" }) as HTMLButtonElement;
|
|
||||||
|
|
||||||
// 정렬: align="center" → editor-block 에 pb-text-center 클래스
|
|
||||||
expect(editorBlock.className).toContain("pb-text-center");
|
|
||||||
|
|
||||||
// 버튼 클래스: 크기/변형/둥글기 토큰이 pb-btn-* 클래스로 반영되어야 한다.
|
|
||||||
expect(button.className).toContain("pb-btn-base");
|
|
||||||
expect(button.className).toContain("pb-btn-size-lg");
|
|
||||||
expect(button.className).toContain("pb-btn-radius-full");
|
|
||||||
expect(button.className).toContain("pb-btn-variant-outline-success");
|
|
||||||
|
|
||||||
// fullWidth=true 인 경우, 래퍼 div 가 w-full 클래스를 가져야 한다.
|
|
||||||
const wrapper = button.parentElement as HTMLElement;
|
|
||||||
expect(wrapper.className).toContain("w-full");
|
|
||||||
|
|
||||||
// 스타일: 색상/패딩/타이포
|
|
||||||
expect(button.style.backgroundColor).toBe("rgb(18, 52, 86)"); // #123456
|
|
||||||
expect(button.style.borderColor).toBe("rgb(101, 67, 33)"); // #654321
|
|
||||||
expect(button.style.color).toBe("rgb(255, 0, 0)"); // #ff0000
|
|
||||||
expect(button.style.paddingInline).toBe("16px");
|
|
||||||
expect(button.style.paddingBlock).toBe("10px");
|
|
||||||
expect(button.style.fontSize).toBe("18px");
|
|
||||||
expect(button.style.lineHeight).toBe("1.8");
|
|
||||||
expect(button.style.letterSpacing).toBe("0.1em");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("widthMode=fixed 인 경우 widthPx 가 에디터 버튼 width 스타일에 반영되어야 한다", () => {
|
|
||||||
const blocks: Block[] = [
|
|
||||||
{
|
|
||||||
id: "btn_width_fixed",
|
|
||||||
type: "button",
|
|
||||||
props: {
|
|
||||||
label: "고정 너비 버튼",
|
|
||||||
href: "#",
|
|
||||||
align: "left",
|
|
||||||
size: "md",
|
|
||||||
variant: "solid",
|
|
||||||
colorPalette: "primary",
|
|
||||||
borderRadius: "md",
|
|
||||||
fullWidth: false,
|
|
||||||
widthMode: "fixed",
|
|
||||||
widthPx: 200,
|
|
||||||
},
|
|
||||||
} as any,
|
|
||||||
];
|
|
||||||
|
|
||||||
mockState.blocks = blocks;
|
|
||||||
mockState.selectedBlockId = "btn_width_fixed";
|
|
||||||
|
|
||||||
render(<EditorPage />);
|
|
||||||
|
|
||||||
const button = screen.getByRole("button", { name: "고정 너비 버튼" }) as HTMLButtonElement;
|
|
||||||
|
|
||||||
// widthMode="fixed" + widthPx=200 이면 버튼 style.width 가 200px 이어야 한다.
|
|
||||||
expect(button.style.width).toBe("200px");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,76 +0,0 @@
|
|||||||
import { describe, it, expect, afterEach } from "vitest";
|
|
||||||
import { render, screen, cleanup } from "@testing-library/react";
|
|
||||||
import type { Block, ProjectConfig } from "@/features/editor/state/editorStore";
|
|
||||||
import { EditorCanvas } from "@/app/editor/EditorCanvas";
|
|
||||||
|
|
||||||
// 에디터 캔버스/페이지 배경색 적용 규칙에 대한 최소 TDD
|
|
||||||
// - canvasBgColorHex: 실제 캔버스 영역(editor-canvas-inner)의 배경색
|
|
||||||
// - bodyBgColorHex: 에디터 내 페이지 배경(캔버스 바깥 래퍼)의 배경색
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
cleanup();
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("EditorCanvas - 캔버스/페이지 배경색", () => {
|
|
||||||
const baseProjectConfig: ProjectConfig = {
|
|
||||||
title: "배경 테스트",
|
|
||||||
slug: "bg-test",
|
|
||||||
canvasPreset: "full",
|
|
||||||
canvasBgColorHex: "#111111",
|
|
||||||
bodyBgColorHex: "#222222",
|
|
||||||
};
|
|
||||||
|
|
||||||
const baseProps = {
|
|
||||||
blocks: [] as Block[],
|
|
||||||
rootBlocks: [] as Block[],
|
|
||||||
selectedBlockId: null as string | null,
|
|
||||||
editingBlockId: null as string | null,
|
|
||||||
editingText: "",
|
|
||||||
activeDragId: null as string | null,
|
|
||||||
sensors: null as any,
|
|
||||||
onCanvasEmptyClick: () => {},
|
|
||||||
renderBlocks: () => null as any,
|
|
||||||
handleDragStart: () => {},
|
|
||||||
handleDragEnd: () => {},
|
|
||||||
handleDragCancel: () => {},
|
|
||||||
projectConfig: baseProjectConfig,
|
|
||||||
};
|
|
||||||
|
|
||||||
it("canvasBgColorHex 는 editor-canvas-inner 배경색에 적용되어야 한다", () => {
|
|
||||||
render(<EditorCanvas {...baseProps} />);
|
|
||||||
|
|
||||||
const inner = screen.getByTestId("editor-canvas-inner") as HTMLElement;
|
|
||||||
|
|
||||||
expect(inner.style.backgroundColor).toBe("rgb(17, 17, 17)");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("bodyBgColorHex 는 editor-canvas 바깥 래퍼(editor-canvas)의 배경색에 적용되어야 한다", () => {
|
|
||||||
const { container } = render(<EditorCanvas {...baseProps} />);
|
|
||||||
|
|
||||||
const outer = container.querySelector('[data-testid="editor-canvas"]') as HTMLElement | null;
|
|
||||||
|
|
||||||
expect(outer).not.toBeNull();
|
|
||||||
expect(outer!.style.backgroundColor).toBe("rgb(34, 34, 34)");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("canvasPreset / canvasWidthPx 에 따라 editor-canvas-inner 의 maxWidth 가 설정되어야 한다", () => {
|
|
||||||
const renderWithConfig = (partial: Partial<ProjectConfig>) => {
|
|
||||||
const projectConfig = { ...baseProjectConfig, ...partial } as ProjectConfig;
|
|
||||||
const { getByTestId, unmount } = render(<EditorCanvas {...baseProps} projectConfig={projectConfig} />);
|
|
||||||
const inner = getByTestId("editor-canvas-inner") as HTMLElement;
|
|
||||||
const maxWidth = inner.style.maxWidth;
|
|
||||||
unmount();
|
|
||||||
return maxWidth;
|
|
||||||
};
|
|
||||||
|
|
||||||
expect(renderWithConfig({ canvasPreset: "mobile", canvasWidthPx: 390 })).toBe("390px");
|
|
||||||
expect(renderWithConfig({ canvasPreset: "tablet", canvasWidthPx: 768 })).toBe("768px");
|
|
||||||
expect(renderWithConfig({ canvasPreset: "desktop", canvasWidthPx: 1200 })).toBe("1200px");
|
|
||||||
|
|
||||||
// full 프리셋에서 canvasWidthPx 가 없으면 maxWidth 는 비워져 있어야 한다.
|
|
||||||
expect(renderWithConfig({ canvasPreset: "full", canvasWidthPx: undefined })).toBe("");
|
|
||||||
|
|
||||||
// custom 프리셋에서는 canvasWidthPx 값이 그대로 사용된다.
|
|
||||||
expect(renderWithConfig({ canvasPreset: "custom", canvasWidthPx: 1024 })).toBe("1024px");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,133 +0,0 @@
|
|||||||
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
|
||||||
import { render, screen, cleanup } from "@testing-library/react";
|
|
||||||
import type { Block, ProjectConfig } from "@/features/editor/state/editorStore";
|
|
||||||
import EditorPage from "@/app/editor/page";
|
|
||||||
|
|
||||||
let mockState: any;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
const baseProjectConfig: ProjectConfig = {
|
|
||||||
title: "디바이더 블록 스타일 테스트",
|
|
||||||
slug: "editor-divider-style-test",
|
|
||||||
canvasPreset: "full",
|
|
||||||
canvasWidthPx: 1024,
|
|
||||||
canvasBgColorHex: "#0f172a",
|
|
||||||
bodyBgColorHex: "#020617",
|
|
||||||
} as ProjectConfig;
|
|
||||||
|
|
||||||
mockState = {
|
|
||||||
blocks: [] 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(),
|
|
||||||
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(),
|
|
||||||
replaceBlocks: vi.fn(),
|
|
||||||
reorderBlocks: vi.fn(),
|
|
||||||
moveBlock: vi.fn(),
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
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(),
|
|
||||||
}),
|
|
||||||
useSearchParams: () => ({
|
|
||||||
get: () => null,
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
function hexToRgb(hex: string) {
|
|
||||||
const clean = hex.replace("#", "");
|
|
||||||
const int = parseInt(clean, 16);
|
|
||||||
const r = (int >> 16) & 255;
|
|
||||||
const g = (int >> 8) & 255;
|
|
||||||
const b = int & 255;
|
|
||||||
return `rgb(${r}, ${g}, ${b})`;
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("EditorPage - 디바이더 블록 스타일", () => {
|
|
||||||
it("colorHex / widthMode+widthPx / marginYPx 가 에디터 디바이더 렌더에 반영되어야 한다", () => {
|
|
||||||
const blocks: Block[] = [
|
|
||||||
{
|
|
||||||
id: "divider_style_1",
|
|
||||||
type: "divider",
|
|
||||||
props: {
|
|
||||||
align: "center",
|
|
||||||
thickness: "medium",
|
|
||||||
widthMode: "fixed",
|
|
||||||
widthPx: 400,
|
|
||||||
colorHex: "#123456",
|
|
||||||
marginYPx: 32,
|
|
||||||
},
|
|
||||||
} as any,
|
|
||||||
];
|
|
||||||
|
|
||||||
mockState.blocks = blocks;
|
|
||||||
mockState.selectedBlockId = "divider_style_1";
|
|
||||||
|
|
||||||
render(<EditorPage />);
|
|
||||||
|
|
||||||
// EditorPage 의 디바이더는 wrapper div 안에 border-t 클래스를 가진 div 로 렌더된다.
|
|
||||||
const editorBlock = screen.getByTestId("editor-block") as HTMLElement;
|
|
||||||
const inner = editorBlock.querySelector("div.border-t") as HTMLElement;
|
|
||||||
expect(inner).toBeTruthy();
|
|
||||||
|
|
||||||
expect(inner.style.borderColor).toBe(hexToRgb("#123456"));
|
|
||||||
expect(inner.style.width).toBe("400px");
|
|
||||||
|
|
||||||
// marginTop/marginBottom 은 상위 wrapper div 에 적용된다.
|
|
||||||
const wrapper = inner.parentElement as HTMLElement;
|
|
||||||
expect(wrapper.style.marginTop).toBe("32px");
|
|
||||||
expect(wrapper.style.marginBottom).toBe("32px");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,114 +0,0 @@
|
|||||||
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(),
|
|
||||||
}),
|
|
||||||
useSearchParams: () => ({
|
|
||||||
get: () => null,
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
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();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,243 +0,0 @@
|
|||||||
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
|
||||||
import { render, screen, cleanup } from "@testing-library/react";
|
|
||||||
import type { Block, ProjectConfig } from "@/features/editor/state/editorStore";
|
|
||||||
import EditorPage from "@/app/editor/page";
|
|
||||||
|
|
||||||
let mockState: any;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
const baseProjectConfig: ProjectConfig = {
|
|
||||||
title: "폼 블록 스타일 테스트",
|
|
||||||
slug: "editor-form-style-test",
|
|
||||||
canvasPreset: "full",
|
|
||||||
canvasWidthPx: 1024,
|
|
||||||
canvasBgColorHex: "#0f172a",
|
|
||||||
bodyBgColorHex: "#020617",
|
|
||||||
} as ProjectConfig;
|
|
||||||
|
|
||||||
mockState = {
|
|
||||||
blocks: [] 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(),
|
|
||||||
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(),
|
|
||||||
replaceBlocks: vi.fn(),
|
|
||||||
reorderBlocks: vi.fn(),
|
|
||||||
moveBlock: vi.fn(),
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
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(),
|
|
||||||
}),
|
|
||||||
useSearchParams: () => ({
|
|
||||||
get: () => null,
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
function hexToRgb(hex: string) {
|
|
||||||
const clean = hex.replace("#", "");
|
|
||||||
const int = parseInt(clean, 16);
|
|
||||||
const r = (int >> 16) & 255;
|
|
||||||
const g = (int >> 8) & 255;
|
|
||||||
const b = int & 255;
|
|
||||||
return `rgb(${r}, ${g}, ${b})`;
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("EditorPage - 폼 블록 스타일", () => {
|
|
||||||
it("formInput: textColorCustom / fillColorCustom / strokeColorCustom / widthPx / borderRadius 가 에디터 렌더에 반영되어야 한다", () => {
|
|
||||||
const blocks: Block[] = [
|
|
||||||
{
|
|
||||||
id: "form_input_1",
|
|
||||||
type: "formInput",
|
|
||||||
props: {
|
|
||||||
label: "폼 입력",
|
|
||||||
inputType: "text",
|
|
||||||
textColorCustom: "#ff0000",
|
|
||||||
fillColorCustom: "#123456",
|
|
||||||
strokeColorCustom: "#654321",
|
|
||||||
widthMode: "fixed",
|
|
||||||
widthPx: 240,
|
|
||||||
borderRadius: "full",
|
|
||||||
},
|
|
||||||
} as any,
|
|
||||||
];
|
|
||||||
|
|
||||||
mockState.blocks = blocks;
|
|
||||||
mockState.selectedBlockId = "form_input_1";
|
|
||||||
|
|
||||||
render(<EditorPage />);
|
|
||||||
|
|
||||||
const field = screen.getByTestId("form-input-field") as HTMLElement;
|
|
||||||
|
|
||||||
expect(field.style.color).toBe(hexToRgb("#ff0000"));
|
|
||||||
expect(field.style.backgroundColor).toBe(hexToRgb("#123456"));
|
|
||||||
expect(field.style.borderColor).toBe(hexToRgb("#654321"));
|
|
||||||
expect(field.style.width).toBe("240px");
|
|
||||||
// borderRadius=full → 9999px 로 적용
|
|
||||||
expect(field.style.borderRadius).toBe("9999px");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("formSelect: textColorCustom / fillColorCustom / strokeColorCustom / widthPx / borderRadius 가 에디터 렌더에 반영되어야 한다", () => {
|
|
||||||
const blocks: Block[] = [
|
|
||||||
{
|
|
||||||
id: "form_select_1",
|
|
||||||
type: "formSelect",
|
|
||||||
props: {
|
|
||||||
label: "폼 셀렉트",
|
|
||||||
textColorCustom: "#ff0000",
|
|
||||||
fillColorCustom: "#123456",
|
|
||||||
strokeColorCustom: "#654321",
|
|
||||||
widthMode: "fixed",
|
|
||||||
widthPx: 260,
|
|
||||||
borderRadius: "lg",
|
|
||||||
options: [
|
|
||||||
{ label: "A", value: "a" },
|
|
||||||
{ label: "B", value: "b" },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
} as any,
|
|
||||||
];
|
|
||||||
|
|
||||||
mockState.blocks = blocks;
|
|
||||||
mockState.selectedBlockId = "form_select_1";
|
|
||||||
|
|
||||||
render(<EditorPage />);
|
|
||||||
|
|
||||||
const select = screen.getByLabelText("폼 셀렉트") as HTMLSelectElement;
|
|
||||||
const wrapper = select.parentElement as HTMLElement; // style 이 적용된 div
|
|
||||||
|
|
||||||
expect(wrapper.style.color).toBe(hexToRgb("#ff0000"));
|
|
||||||
expect(wrapper.style.backgroundColor).toBe(hexToRgb("#123456"));
|
|
||||||
expect(wrapper.style.borderColor).toBe(hexToRgb("#654321"));
|
|
||||||
expect(wrapper.style.width).toBe("260px");
|
|
||||||
expect(wrapper.style.borderRadius).toBe("9999px"); // lg → 9999 (에디터 구현 기준)
|
|
||||||
});
|
|
||||||
|
|
||||||
it("formRadio: textColorCustom / fillColorCustom / strokeColorCustom / widthPx / borderRadius 가 에디터 렌더에 반영되어야 한다", () => {
|
|
||||||
const blocks: Block[] = [
|
|
||||||
{
|
|
||||||
id: "form_radio_1",
|
|
||||||
type: "formRadio",
|
|
||||||
props: {
|
|
||||||
formFieldName: "radio-group",
|
|
||||||
groupLabel: "라디오 그룹",
|
|
||||||
textColorCustom: "#ff0000",
|
|
||||||
fillColorCustom: "#123456",
|
|
||||||
strokeColorCustom: "#654321",
|
|
||||||
widthMode: "fixed",
|
|
||||||
widthPx: 280,
|
|
||||||
borderRadius: "lg",
|
|
||||||
options: [
|
|
||||||
{ label: "옵션 A", value: "a" },
|
|
||||||
{ label: "옵션 B", value: "b" },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
} as any,
|
|
||||||
];
|
|
||||||
|
|
||||||
mockState.blocks = blocks;
|
|
||||||
mockState.selectedBlockId = "form_radio_1";
|
|
||||||
|
|
||||||
render(<EditorPage />);
|
|
||||||
|
|
||||||
const label = screen.getByText("라디오 그룹");
|
|
||||||
const container = label.nextElementSibling as HTMLElement; // style 이 적용된 div
|
|
||||||
|
|
||||||
expect(container).toBeTruthy();
|
|
||||||
expect(container.style.color).toBe(hexToRgb("#ff0000"));
|
|
||||||
expect(container.style.backgroundColor).toBe(hexToRgb("#123456"));
|
|
||||||
expect(container.style.borderColor).toBe(hexToRgb("#654321"));
|
|
||||||
expect(container.style.width).toBe("280px");
|
|
||||||
expect(container.style.borderRadius).toBe("9999px");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("formCheckbox: textColorCustom / fillColorCustom / strokeColorCustom / widthPx / borderRadius 가 에디터 렌더에 반영되어야 한다", () => {
|
|
||||||
const blocks: Block[] = [
|
|
||||||
{
|
|
||||||
id: "form_checkbox_1",
|
|
||||||
type: "formCheckbox",
|
|
||||||
props: {
|
|
||||||
formFieldName: "check-group",
|
|
||||||
groupLabel: "체크 그룹",
|
|
||||||
textColorCustom: "#ff0000",
|
|
||||||
fillColorCustom: "#123456",
|
|
||||||
strokeColorCustom: "#654321",
|
|
||||||
widthMode: "fixed",
|
|
||||||
widthPx: 300,
|
|
||||||
borderRadius: "lg",
|
|
||||||
options: [
|
|
||||||
{ label: "체크 1", value: "c1" },
|
|
||||||
{ label: "체크 2", value: "c2" },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
} as any,
|
|
||||||
];
|
|
||||||
|
|
||||||
mockState.blocks = blocks;
|
|
||||||
mockState.selectedBlockId = "form_checkbox_1";
|
|
||||||
|
|
||||||
render(<EditorPage />);
|
|
||||||
|
|
||||||
// "체크 그룹" 텍스트를 포함하는 가장 가까운 컨테이너를 찾아 스타일을 검사한다.
|
|
||||||
const label = screen.getByText("체크 그룹");
|
|
||||||
const groupContainer = label.closest("div") as HTMLElement;
|
|
||||||
|
|
||||||
expect(groupContainer.style.color).toBe(hexToRgb("#ff0000"));
|
|
||||||
expect(groupContainer.style.backgroundColor).toBe(hexToRgb("#123456"));
|
|
||||||
expect(groupContainer.style.borderColor).toBe(hexToRgb("#654321"));
|
|
||||||
expect(groupContainer.style.width).toBe("300px");
|
|
||||||
expect(groupContainer.style.borderRadius).toBe("9999px");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,117 +0,0 @@
|
|||||||
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
|
||||||
import { render, screen, cleanup } from "@testing-library/react";
|
|
||||||
import type { Block, ProjectConfig } from "@/features/editor/state/editorStore";
|
|
||||||
import EditorPage from "@/app/editor/page";
|
|
||||||
|
|
||||||
let mockState: any;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
const baseProjectConfig: ProjectConfig = {
|
|
||||||
title: "이미지 블록 스타일 테스트",
|
|
||||||
slug: "editor-image-style-test",
|
|
||||||
canvasPreset: "full",
|
|
||||||
canvasWidthPx: 1024,
|
|
||||||
canvasBgColorHex: "#0f172a",
|
|
||||||
bodyBgColorHex: "#020617",
|
|
||||||
} as ProjectConfig;
|
|
||||||
|
|
||||||
mockState = {
|
|
||||||
blocks: [] 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(),
|
|
||||||
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(),
|
|
||||||
replaceBlocks: vi.fn(),
|
|
||||||
reorderBlocks: vi.fn(),
|
|
||||||
moveBlock: vi.fn(),
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
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(),
|
|
||||||
}),
|
|
||||||
useSearchParams: () => ({
|
|
||||||
get: () => null,
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("EditorPage - 이미지 블록 스타일", () => {
|
|
||||||
it("widthMode=fixed 일 때 widthPx / borderRadiusPx 가 에디터 이미지 렌더에 반영되어야 한다", () => {
|
|
||||||
const blocks: Block[] = [
|
|
||||||
{
|
|
||||||
id: "image_style_1",
|
|
||||||
type: "image",
|
|
||||||
props: {
|
|
||||||
src: "/images/example.png",
|
|
||||||
alt: "예제 이미지",
|
|
||||||
align: "center",
|
|
||||||
widthMode: "fixed",
|
|
||||||
widthPx: 320,
|
|
||||||
borderRadius: "lg",
|
|
||||||
borderRadiusPx: 24,
|
|
||||||
},
|
|
||||||
} as any,
|
|
||||||
];
|
|
||||||
|
|
||||||
mockState.blocks = blocks;
|
|
||||||
mockState.selectedBlockId = "image_style_1";
|
|
||||||
|
|
||||||
render(<EditorPage />);
|
|
||||||
|
|
||||||
const img = screen.getByAltText("예제 이미지") as HTMLImageElement;
|
|
||||||
|
|
||||||
expect(img.style.width).toBe("320px");
|
|
||||||
expect(img.style.borderRadius).toBe("24px");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,207 +0,0 @@
|
|||||||
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(),
|
|
||||||
}),
|
|
||||||
useSearchParams: () => ({
|
|
||||||
get: () => null,
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
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();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,103 +0,0 @@
|
|||||||
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
|
||||||
import { render, screen, cleanup } from "@testing-library/react";
|
|
||||||
import type { Block, ProjectConfig } from "@/features/editor/state/editorStore";
|
|
||||||
import EditorPage from "@/app/editor/page";
|
|
||||||
|
|
||||||
let mockState: any;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
const baseProjectConfig: ProjectConfig = {
|
|
||||||
title: "레이아웃 스크롤 테스트",
|
|
||||||
slug: "layout-scroll-test",
|
|
||||||
canvasPreset: "full",
|
|
||||||
canvasWidthPx: 1024,
|
|
||||||
canvasBgColorHex: "#0f172a",
|
|
||||||
bodyBgColorHex: "#020617",
|
|
||||||
} as ProjectConfig;
|
|
||||||
|
|
||||||
mockState = {
|
|
||||||
blocks: [] 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(),
|
|
||||||
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(),
|
|
||||||
replaceBlocks: vi.fn(),
|
|
||||||
reorderBlocks: vi.fn(),
|
|
||||||
moveBlock: vi.fn(),
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
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(),
|
|
||||||
}),
|
|
||||||
useSearchParams: () => ({
|
|
||||||
get: () => null,
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("EditorPage - 레이아웃 스크롤 컨테이너", () => {
|
|
||||||
it("좌측 사이드바, 캔버스, 우측 속성 패널에 pb-scroll 클래스가 적용되어야 한다", () => {
|
|
||||||
render(<EditorPage />);
|
|
||||||
|
|
||||||
const blocksHeading = screen.getByText("블록");
|
|
||||||
const blocksSidebar = blocksHeading.closest("aside") as HTMLElement;
|
|
||||||
const canvas = screen.getByTestId("editor-canvas");
|
|
||||||
const propertiesSidebar = screen.getByTestId("properties-sidebar");
|
|
||||||
|
|
||||||
expect(blocksSidebar).not.toBeNull();
|
|
||||||
expect(blocksSidebar.className).toContain("pb-scroll");
|
|
||||||
expect(canvas.className).toContain("pb-scroll");
|
|
||||||
expect(propertiesSidebar.className).toContain("pb-scroll");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user